返回 CodeWhale
workflow.rs
根目录 / crates / tui / src / tools / workflow.rs
1 //! Model-facing Workflow runner over the live sub-agent runtime.
2 //!
3 //! The JS VM stays in `codewhale-workflow-js`; this module supplies the TUI
4 //! driver that turns each `task(...)` call into a real `SubAgentManager` spawn.
5
6 use std::collections::HashMap;
7 use std::path::{Path, PathBuf};
8 use std::sync::atomic::{AtomicU32, Ordering};
9 use std::sync::{Arc, Mutex, MutexGuard};
10 use std::time::{SystemTime, UNIX_EPOCH};
11
12 use async_trait::async_trait;
13 use codewhale_workflow::{
14 AgentType, BranchResult, BranchSpec, BudgetSpec, ControlNodeKind, ControlNodeResult,
15 FleetRoleMap, GateKind, GateOn, GateOutcome, GateSpec, GateState, GateStatusLine,
16 HandoffArtifact, LaneGateBoard, LeafResult, LeafSpec, ReduceSpec, SequenceSpec, TaskMode,
17 WorkflowExecution as IrWorkflowExecution, WorkflowMemoUsage, WorkflowNode,
18 WorkflowRunStatus as IrWorkflowRunStatus, WorkflowSpec, WorkflowUsage,
19 compile_javascript_workflow, compile_typescript_workflow, leaf_wants_worktree,
20 resolve_workflow_agent,
21 };
22 use codewhale_workflow_js::{
23 BudgetSnapshot, DriverError, ProgressEvent, SpawnedTask, TaskCompletion, TaskRequest,
24 WORKFLOW_MAX_CONCURRENT, WorkflowDriver, WorkflowRunCancel, WorkflowVm,
25 };
26 use serde::{Deserialize, Serialize};
27 use serde_json::{Value, json};
28 use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc, oneshot};
29 use uuid::Uuid;
30
31 use crate::core::events::Event;
32 use crate::tools::spec::{
33 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
34 optional_bool, optional_str, optional_u64,
35 };
36 use crate::tools::subagent::{
37 SharedSubAgentManager, SubAgentCompletion, SubAgentManager, SubAgentResult, SubAgentRuntime,
38 SubAgentStatus, WorkflowTaskSpawnIdentity, WorkflowTaskSpawnMetadata, spawn_workflow_task,
39 };
40 use crate::tools::verifier::run_workflow_completion_gates;
41 use crate::tools::workflow_plan_approval::{
42 WorkflowPlanApprovalReceipt, analyze_workflow_plan_approval_with_config, analyze_workflow_spec,
43 workflow_approval_requirement_for,
44 };
45 use crate::utils::spawn_supervised;
46 use crate::work_graph::{
47 CancelOutcome, EvidenceKind, EvidenceRef, OperationIntent, OperationObservation,
48 OperationOwnerSnapshot, OwnerState, SharedWorkRuntime,
49 };
50
51 /// Keep promoted artifacts compact without clipping ordinary evidence reports.
52 /// A 900-character cap cut six-line source receipts in half during live Fleet
53 /// acceptance, so downstream roles could not evaluate evidence the host had
54 /// already approved.
55 const WORKFLOW_HANDOFF_MAX_CHARS: usize = 4_000;
56
57 /// Model-facing run-record payloads carry only the newest events; the full
58 /// stream persists per-event in `.codewhale/workflow-runs.jsonl` (#2974).
59 const WORKFLOW_RESULT_EVENTS_TAIL: usize = 50;
60 /// Bounded tail for free-form progress lines in model-facing payloads.
61 const WORKFLOW_RESULT_PROGRESS_TAIL: usize = 20;
62 /// Bounded tail for rejected child dispatches in the model-facing payload.
63 /// The durable run journal retains the complete failure ledger.
64 const WORKFLOW_RESULT_DISPATCH_FAILURES_TAIL: usize = 12;
65 /// Per-field cap for one model-facing dispatch-failure receipt.
66 const WORKFLOW_RESULT_DISPATCH_FAILURE_FIELD_MAX_CHARS: usize = 320;
67 /// Char cap for the VM `result` / `verification` values in model-facing
68 /// payloads (matches the handoff compaction budget); oversized values
69 /// collapse to a preview plus a journal pointer.
70 const WORKFLOW_RESULT_VALUE_MAX_CHARS: usize = 4_000;
71 /// Char cap per leaf output preview inside the model-facing execution
72 /// receipt; full child output stays retrievable via the worker ledger and
73 /// the journal.
74 const WORKFLOW_RESULT_LEAF_OUTPUT_MAX_CHARS: usize = 500;
75 /// Stated upper bound for a bounded model-facing run-record payload; the
76 /// payload tests assert every `start`/`run`/`status` result stays below it.
77 const WORKFLOW_RESULT_MAX_CHARS: usize = 24_000;
78 /// In-memory (and snapshot) event retention per run: only the newest events
79 /// are kept; older ones remain in the per-event journal lines (#2974).
80 const WORKFLOW_RUN_EVENTS_MAX_RETAINED: usize = 1_000;
81
82 #[derive(Clone)]
83 pub struct WorkflowTool {
84 manager: SharedSubAgentManager,
85 runtime: SubAgentRuntime,
86 approval_decision: &'static str,
87 }
88
89 impl WorkflowTool {
90 #[must_use]
91 pub fn new(manager: SharedSubAgentManager, runtime: SubAgentRuntime) -> Self {
92 Self {
93 manager,
94 runtime,
95 approval_decision: "approved",
96 }
97 }
98
99 /// Mark execution as approved by the user's explicit `workflow run`
100 /// command rather than by an Engine tool-call approval gate.
101 #[must_use]
102 pub(crate) fn with_explicit_cli_approval(mut self) -> Self {
103 self.approval_decision = "approved_explicit_cli_command";
104 self
105 }
106 }
107
108 type SharedWorkflowRuns = Arc<Mutex<HashMap<String, WorkflowRunRecord>>>;
109 type SharedWorkflowControllers = Arc<Mutex<HashMap<String, Arc<WorkflowRunController>>>>;
110 type SharedWorkflowLifecycles = Arc<Mutex<HashMap<String, WorkflowWorkLifecycle>>>;
111
112 #[derive(Clone)]
113 struct WorkflowWorkLifecycle {
114 work: SharedWorkRuntime,
115 session_id: String,
116 external: String,
117 }
118
119 impl WorkflowWorkLifecycle {
120 fn register(
121 context: &ToolContext,
122 run_id: &str,
123 title: &str,
124 ) -> Result<Option<Self>, ToolError> {
125 let Some(work) = context.runtime.work.clone() else {
126 return Ok(None);
127 };
128 let lifecycle = Self {
129 work,
130 session_id: context.state_namespace.clone(),
131 external: format!("workflow:{run_id}"),
132 };
133 lifecycle
134 .work
135 .register_operation(
136 &lifecycle.session_id,
137 OperationIntent::new(
138 lifecycle.external.clone(),
139 title,
140 true,
141 "workflow",
142 format!("workflow:{run_id}:start"),
143 ),
144 )
145 .map_err(ToolError::execution_failed)?;
146 Ok(Some(lifecycle))
147 }
148
149 fn for_bound(context: &ToolContext, run_id: &str) -> Option<Self> {
150 let work = context.runtime.work.clone()?;
151 let external = format!("workflow:{run_id}");
152 work.has_operation_binding(Some(&context.state_namespace), &external)
153 .then(|| Self {
154 work,
155 session_id: context.state_namespace.clone(),
156 external,
157 })
158 }
159
160 fn reconcile_record(&self, record: &WorkflowRunRecord) -> Result<bool, String> {
161 let output = record.result.as_ref().and_then(|result| {
162 serde_json::to_vec(result).ok().and_then(|bytes| {
163 EvidenceRef::new(
164 EvidenceKind::Receipt {
165 owner: "workflow".to_string(),
166 },
167 format!("workflow:{}:result", record.run_id),
168 Some(u64::try_from(bytes.len()).unwrap_or(u64::MAX)),
169 false,
170 )
171 .ok()
172 })
173 });
174 let state = match record.status {
175 WorkflowRunStatus::Running => OwnerState::Running,
176 // Degraded still finished and produced output; the run record and
177 // report carry the dropped-slot truth.
178 WorkflowRunStatus::Completed | WorkflowRunStatus::Degraded => OwnerState::Completed,
179 WorkflowRunStatus::Failed => OwnerState::Failed,
180 WorkflowRunStatus::Cancelled => OwnerState::Cancelled,
181 };
182 let mut snapshot = OperationOwnerSnapshot::new(
183 self.external.clone(),
184 state,
185 record.lifecycle_seq,
186 i64::try_from(record.completed_at_ms.unwrap_or(record.started_at_ms))
187 .unwrap_or(i64::MAX),
188 );
189 if let Some(output) = output {
190 snapshot = snapshot.with_output(output);
191 }
192 self.work.reconcile_operation(&self.session_id, snapshot)
193 }
194
195 fn reconcile_cancel(&self, outcome: CancelOutcome) -> Result<bool, String> {
196 self.work.reconcile_observation(
197 &self.session_id,
198 &self.external,
199 OperationObservation::CancelUpdate {
200 outcome,
201 at: i64::try_from(now_ms()).unwrap_or(i64::MAX),
202 },
203 )
204 }
205
206 fn reconcile_spawn_failure(&self) {
207 let _ = self.work.reconcile_operation(
208 &self.session_id,
209 OperationOwnerSnapshot::new(
210 self.external.clone(),
211 OwnerState::Failed,
212 1,
213 i64::try_from(now_ms()).unwrap_or(i64::MAX),
214 ),
215 );
216 }
217
218 fn reconcile_missing(&self) {
219 let _ = self.work.reconcile_observation(
220 &self.session_id,
221 &self.external,
222 OperationObservation::OwnerMissing {
223 checked_at: i64::try_from(now_ms()).unwrap_or(i64::MAX),
224 },
225 );
226 }
227 }
228
229 struct WorkflowRunController {
230 driver: Arc<SubAgentWorkflowDriver>,
231 vm_cancel: WorkflowRunCancel,
232 run_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
233 }
234
235 impl WorkflowRunController {
236 fn new(driver: Arc<SubAgentWorkflowDriver>, vm_cancel: WorkflowRunCancel) -> Self {
237 Self {
238 driver,
239 vm_cancel,
240 run_handle: Mutex::new(None),
241 }
242 }
243
244 fn set_run_handle(&self, handle: tokio::task::JoinHandle<()>) {
245 if let Ok(mut guard) = self.run_handle.lock() {
246 *guard = Some(handle);
247 }
248 }
249
250 fn cancel(&self) {
251 self.vm_cancel.cancel();
252 self.driver.finalize_running_tasks_cancelled();
253 self.driver.force_cancel_all();
254 if let Ok(mut guard) = self.run_handle.lock()
255 && let Some(handle) = guard.take()
256 {
257 handle.abort();
258 }
259 }
260 }
261
262 #[derive(Debug, Clone, Serialize)]
263 struct WorkflowRunSummary {
264 run_id: String,
265 status: WorkflowRunStatus,
266 lifecycle_seq: u64,
267 started_at_ms: u64,
268 completed_at_ms: Option<u64>,
269 source_path: Option<PathBuf>,
270 workflow_id: Option<String>,
271 workflow_goal: Option<String>,
272 token_budget: Option<u64>,
273 child_count: usize,
274 schema_error_count: usize,
275 dispatch_failure_count: usize,
276 progress_count: usize,
277 last_progress: Option<String>,
278 event_count: usize,
279 last_event_type: Option<String>,
280 leaf_count: usize,
281 branch_count: usize,
282 control_count: usize,
283 execution_status: Option<IrWorkflowRunStatus>,
284 gate_count: usize,
285 blocked_gate_count: usize,
286 gate_status: Vec<GateStatusLine>,
287 error: Option<String>,
288 /// Run-wide usage totals reconciled from per-task telemetry (#2974).
289 usage: Option<WorkflowRunUsage>,
290 /// Events evicted from the retained tail; full stream in the journal.
291 events_dropped: u64,
292 }
293
294 #[derive(Debug, Clone, Serialize, Deserialize)]
295 struct WorkflowSchemaError {
296 task_id: String,
297 message: String,
298 }
299
300 /// One `task()` dispatch the driver rejected before any child agent existed.
301 /// Inside `parallel()` the JS throw collapses into a `null` slot, so without
302 /// this ledger a run whose fan-out never dispatched anything still reads as
303 /// successful orchestration (#5035).
304 #[derive(Debug, Clone, Serialize, Deserialize)]
305 struct WorkflowDispatchFailure {
306 at_ms: u64,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 label: Option<String>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 phase: Option<String>,
311 message: String,
312 }
313
314 #[derive(Debug, Clone, Serialize, Deserialize)]
315 struct WorkflowUiEvent {
316 at_ms: u64,
317 #[serde(flatten)]
318 kind: WorkflowUiEventKind,
319 }
320
321 impl WorkflowUiEvent {
322 fn new(kind: WorkflowUiEventKind) -> Self {
323 Self {
324 at_ms: now_ms(),
325 kind,
326 }
327 }
328
329 fn at(at_ms: u64, kind: WorkflowUiEventKind) -> Self {
330 Self { at_ms, kind }
331 }
332
333 fn event_type(&self) -> &'static str {
334 self.kind.event_type()
335 }
336 }
337
338 #[derive(Debug, Clone, Serialize, Deserialize)]
339 #[serde(tag = "type", rename_all = "snake_case")]
340 enum WorkflowUiEventKind {
341 RunStarted {
342 workflow_id: Option<String>,
343 workflow_goal: Option<String>,
344 source_path: Option<PathBuf>,
345 token_budget: Option<u64>,
346 },
347 RunCompleted {
348 status: WorkflowRunStatus,
349 error: Option<String>,
350 /// Run-wide usage totals reconciled from per-task telemetry (#2974).
351 #[serde(default, skip_serializing_if = "Option::is_none")]
352 usage: Option<WorkflowRunUsage>,
353 },
354 RunCancelled {
355 reason: String,
356 },
357 PhaseStarted {
358 title: String,
359 },
360 TaskStarted(Box<WorkflowTaskStartedEvent>),
361 TaskCompleted {
362 task_id: String,
363 status: IrWorkflowRunStatus,
364 /// Per-worker telemetry captured at terminal delivery (#2974).
365 #[serde(default, skip_serializing_if = "Option::is_none")]
366 usage: Option<WorkflowTaskUsage>,
367 },
368 GateUpdated {
369 gate_id: String,
370 role: String,
371 gate: String,
372 state: String,
373 blocked_role: Option<String>,
374 blocked_reason: Option<String>,
375 },
376 HandoffPromoted {
377 artifact_id: String,
378 gate_id: String,
379 kind: String,
380 from_role: String,
381 to_role: String,
382 producer_task_id: String,
383 },
384 HandoffConsumed {
385 artifact_id: String,
386 kind: String,
387 from_role: String,
388 to_role: String,
389 consumer_task_id: String,
390 },
391 TaskSchemaValidationFailed {
392 task_id: String,
393 message: String,
394 },
395 TaskDispatchFailed {
396 #[serde(default, skip_serializing_if = "Option::is_none")]
397 label: Option<String>,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
399 phase: Option<String>,
400 message: String,
401 },
402 BudgetUpdated {
403 total: Option<u64>,
404 spent: u64,
405 remaining: Option<u64>,
406 },
407 Log {
408 message: String,
409 },
410 }
411
412 /// Per-worker usage telemetry carried on `task_completed` events (#2974).
413 ///
414 /// Tokens come from the worker ledger (`AgentRunUsage`); `tool_calls` is the
415 /// worker's model/tool step count (`SubAgentResult::steps_taken`) and
416 /// `result_ref` points at the durable child artifact (transcript handle) so
417 /// consumers can fetch full output by reference instead of inline text.
418 /// Field names mirror `AgentRunUsage` so #4039 can render Tokens/Tools
419 /// columns without a remapping layer.
420 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
421 struct WorkflowTaskUsage {
422 #[serde(default, skip_serializing_if = "Option::is_none")]
423 input_tokens: Option<u64>,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
425 output_tokens: Option<u64>,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
427 total_tokens: Option<u64>,
428 /// Priced USD subtotal carried from the worker's immutable route audits,
429 /// in microdollars. Absence is unknown, never a zero-cost claim.
430 #[serde(default, skip_serializing_if = "Option::is_none")]
431 cost_microusd: Option<u64>,
432 #[serde(default, skip_serializing_if = "Option::is_none")]
433 tool_calls: Option<u32>,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
435 duration_ms: Option<u64>,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
437 result_ref: Option<String>,
438 /// Provenance of the token counts. This producer currently emits only
439 /// `provider_reported`; absent means unknown and must never render as zero
440 /// (#4039).
441 #[serde(default, skip_serializing_if = "Option::is_none")]
442 token_source: Option<WorkflowTokenSource>,
443 }
444
445 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
446 #[serde(rename_all = "snake_case")]
447 enum WorkflowTokenSource {
448 ProviderReported,
449 }
450
451 /// Run-wide usage totals reconciled from per-task telemetry, carried on
452 /// `run_completed` events and the persisted run record (#2974).
453 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
454 struct WorkflowRunUsage {
455 #[serde(default, skip_serializing_if = "Option::is_none")]
456 input_tokens: Option<u64>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
458 output_tokens: Option<u64>,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
460 total_tokens: Option<u64>,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
462 cost_microusd: Option<u64>,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
464 tool_calls: Option<u64>,
465 /// Number of completed tasks that contributed telemetry.
466 #[serde(default)]
467 tasks_reported: u64,
468 }
469
470 impl WorkflowRunUsage {
471 fn from_task(usage: &WorkflowTaskUsage) -> Self {
472 Self {
473 input_tokens: usage.input_tokens,
474 output_tokens: usage.output_tokens,
475 total_tokens: usage.total_tokens,
476 cost_microusd: usage.cost_microusd,
477 tool_calls: usage.tool_calls.map(u64::from),
478 tasks_reported: 1,
479 }
480 }
481
482 fn add_task(&mut self, usage: &WorkflowTaskUsage) {
483 self.input_tokens = sum_optional_usage(self.input_tokens, usage.input_tokens);
484 self.output_tokens = sum_optional_usage(self.output_tokens, usage.output_tokens);
485 self.total_tokens = sum_optional_usage(self.total_tokens, usage.total_tokens);
486 self.cost_microusd = sum_optional_usage(self.cost_microusd, usage.cost_microusd);
487 self.tool_calls = sum_optional_usage(self.tool_calls, usage.tool_calls.map(u64::from));
488 self.tasks_reported = self.tasks_reported.saturating_add(1);
489 }
490 }
491
492 fn sum_optional_usage(left: Option<u64>, right: Option<u64>) -> Option<u64> {
493 match (left, right) {
494 (Some(left), Some(right)) => Some(left.saturating_add(right)),
495 (Some(value), None) | (None, Some(value)) => Some(value),
496 (None, None) => None,
497 }
498 }
499
500 #[derive(Debug, Clone, Serialize, Deserialize)]
501 struct WorkflowTaskStartedEvent {
502 task_id: String,
503 label: Option<String>,
504 /// Fleet role declared on the step, if any (#4177).
505 role: Option<String>,
506 profile: Option<String>,
507 model: Option<String>,
508 strength: Option<String>,
509 thinking: Option<String>,
510 /// Reasoning the task requested, verbatim (`inherit`/`auto`/effort) (#4039).
511 #[serde(default, skip_serializing_if = "Option::is_none")]
512 requested_reasoning: Option<String>,
513 /// Reasoning the child runtime was actually installed with (#4039). Absent
514 /// when the resolved route carries no reasoning control; consumers render
515 /// that as unknown rather than inventing an effort.
516 #[serde(default, skip_serializing_if = "Option::is_none")]
517 effective_reasoning: Option<String>,
518 /// Resolved fleet role after roster lookup (#4177).
519 resolved_role: Option<String>,
520 /// Resolved AgentProfile id after fleet resolution (#4177).
521 resolved_profile: Option<String>,
522 resolved_provider: String,
523 resolved_model: String,
524 route_source: String,
525 worktree: bool,
526 workspace: Option<PathBuf>,
527 git_branch: Option<String>,
528 parent_task_id: Option<String>,
529 depth: u32,
530 /// Workflow run that admitted this child (#4119).
531 workflow_run_id: Option<String>,
532 /// Phase title/id active (or declared on the task) at spawn (#4119).
533 workflow_phase_id: Option<String>,
534 /// Typed task label — UI must prefer this over prompt text (#4119).
535 workflow_task_label: Option<String>,
536 /// 0-based admission order among children of this run (#4119).
537 workflow_child_index: Option<u32>,
538 /// Durable exact-Fleet routing receipt: the fixed member identity, its
539 /// exact provider/model, the requested vs. selector vs. provider-effective
540 /// reasoning, where the decision came from, and the Router's exact identity
541 /// when a Router made it. `default` keeps events written before this field
542 /// existed — and every legacy/non-fleet task — readable unchanged.
543 #[serde(default, skip_serializing_if = "Option::is_none")]
544 fleet_receipt: Option<codewhale_workflow::FleetTaskReceipt>,
545 }
546
547 impl WorkflowUiEventKind {
548 fn event_type(&self) -> &'static str {
549 match self {
550 Self::RunStarted { .. } => "run_started",
551 Self::RunCompleted { .. } => "run_completed",
552 Self::RunCancelled { .. } => "run_cancelled",
553 Self::PhaseStarted { .. } => "phase_started",
554 Self::TaskStarted(_) => "task_started",
555 Self::TaskCompleted { .. } => "task_completed",
556 Self::GateUpdated { .. } => "gate_updated",
557 Self::HandoffPromoted { .. } => "handoff_promoted",
558 Self::HandoffConsumed { .. } => "handoff_consumed",
559 Self::TaskSchemaValidationFailed { .. } => "task_schema_validation_failed",
560 Self::TaskDispatchFailed { .. } => "task_dispatch_failed",
561 Self::BudgetUpdated { .. } => "budget_updated",
562 Self::Log { .. } => "log",
563 }
564 }
565 }
566
567 #[derive(Debug, Clone, Serialize, Deserialize)]
568 struct WorkflowRunRecord {
569 run_id: String,
570 status: WorkflowRunStatus,
571 #[serde(default)]
572 lifecycle_seq: u64,
573 started_at_ms: u64,
574 completed_at_ms: Option<u64>,
575 source_path: Option<PathBuf>,
576 workflow_id: Option<String>,
577 workflow_goal: Option<String>,
578 token_budget: Option<u64>,
579 child_ids: Vec<String>,
580 progress: Vec<String>,
581 #[serde(default)]
582 events: Vec<WorkflowUiEvent>,
583 schema_errors: Vec<WorkflowSchemaError>,
584 /// Task dispatches the driver rejected before any child ran (#5035).
585 #[serde(default, skip_serializing_if = "Vec::is_empty")]
586 dispatch_failures: Vec<WorkflowDispatchFailure>,
587 result: Option<Value>,
588 execution: Option<IrWorkflowExecution>,
589 error: Option<String>,
590 #[serde(default)]
591 verify_on_complete: bool,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
593 verification: Option<Value>,
594 /// Durable elevated-plan approval receipt for audit (#4126).
595 #[serde(default, skip_serializing_if = "Option::is_none")]
596 plan_approval: Option<WorkflowPlanApprovalReceipt>,
597 /// Compact lane gate state for status / panel surfaces (#4179).
598 #[serde(default)]
599 gate_status: Vec<GateStatusLine>,
600 /// Run-wide usage totals reconciled at completion (#2974).
601 #[serde(default, skip_serializing_if = "Option::is_none")]
602 usage: Option<WorkflowRunUsage>,
603 /// Total events recorded for this run (monotonic; survives the bounded
604 /// `events` tail retention) (#2974).
605 #[serde(default)]
606 events_total: u64,
607 /// Events evicted from the in-memory tail; available in the journal.
608 #[serde(default)]
609 events_dropped: u64,
610 }
611
612 impl WorkflowRunRecord {
613 fn new(
614 run_id: String,
615 source_path: Option<PathBuf>,
616 token_budget: Option<u64>,
617 spec: Option<&WorkflowSpec>,
618 ) -> Self {
619 let gate_status = spec
620 .map(|spec| initial_gate_status(&run_id, &spec.gates))
621 .unwrap_or_default();
622 Self {
623 run_id,
624 status: WorkflowRunStatus::Running,
625 lifecycle_seq: 1,
626 started_at_ms: now_ms(),
627 completed_at_ms: None,
628 source_path,
629 workflow_id: spec.and_then(|spec| spec.id.clone()),
630 workflow_goal: spec.map(|spec| spec.goal.clone()),
631 token_budget,
632 child_ids: Vec::new(),
633 progress: Vec::new(),
634 events: Vec::new(),
635 schema_errors: Vec::new(),
636 dispatch_failures: Vec::new(),
637 result: None,
638 execution: None,
639 error: None,
640 verify_on_complete: false,
641 verification: None,
642 plan_approval: None,
643 gate_status,
644 usage: None,
645 events_total: 0,
646 events_dropped: 0,
647 }
648 }
649
650 /// Record one event, bounding retention to the newest
651 /// `WORKFLOW_RUN_EVENTS_MAX_RETAINED` entries (#2974). Every event is
652 /// journaled per-line at record time, so evicted entries remain
653 /// available in `.codewhale/workflow-runs.jsonl`.
654 fn push_event(&mut self, event: WorkflowUiEvent) {
655 self.events_total = self.events_total.saturating_add(1);
656 self.events.push(event);
657 if self.events.len() > WORKFLOW_RUN_EVENTS_MAX_RETAINED {
658 let overflow = self.events.len() - WORKFLOW_RUN_EVENTS_MAX_RETAINED;
659 self.events.drain(..overflow);
660 self.events_dropped = self
661 .events_dropped
662 .saturating_add(u64::try_from(overflow).unwrap_or(u64::MAX));
663 }
664 }
665
666 fn summary(&self) -> WorkflowRunSummary {
667 WorkflowRunSummary {
668 run_id: self.run_id.clone(),
669 status: self.status,
670 lifecycle_seq: self.lifecycle_seq,
671 started_at_ms: self.started_at_ms,
672 completed_at_ms: self.completed_at_ms,
673 source_path: self.source_path.clone(),
674 workflow_id: self.workflow_id.clone(),
675 workflow_goal: self.workflow_goal.clone(),
676 token_budget: self.token_budget,
677 child_count: self.child_ids.len(),
678 schema_error_count: self.schema_errors.len(),
679 dispatch_failure_count: self.dispatch_failures.len(),
680 progress_count: self.progress.len(),
681 last_progress: self.progress.last().cloned(),
682 event_count: usize::try_from(self.events_total.max(self.events.len() as u64))
683 .unwrap_or(usize::MAX),
684 last_event_type: self
685 .events
686 .last()
687 .map(|event| event.event_type().to_string()),
688 leaf_count: self
689 .execution
690 .as_ref()
691 .map(|execution| execution.leaf_results.len())
692 .unwrap_or_default(),
693 branch_count: self
694 .execution
695 .as_ref()
696 .map(|execution| execution.branch_results.len())
697 .unwrap_or_default(),
698 control_count: self
699 .execution
700 .as_ref()
701 .map(|execution| execution.control_node_results.len())
702 .unwrap_or_default(),
703 execution_status: self.execution.as_ref().map(|execution| execution.status),
704 gate_count: self.gate_status.len(),
705 blocked_gate_count: self
706 .gate_status
707 .iter()
708 .filter(|line| line.blocked_reason.is_some())
709 .count(),
710 gate_status: self.gate_status.clone(),
711 error: self.error.clone(),
712 usage: self.usage.clone(),
713 events_dropped: self.events_dropped,
714 }
715 }
716 }
717
718 fn initial_gate_status(run_id: &str, gates: &[GateSpec]) -> Vec<GateStatusLine> {
719 if gates.is_empty() {
720 return Vec::new();
721 }
722 let mut board = LaneGateBoard::new(run_id);
723 board.install_gates(gates);
724 board.status_summary()
725 }
726
727 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
728 #[serde(rename_all = "snake_case")]
729 enum WorkflowRunStatus {
730 Running,
731 Completed,
732 /// The script returned a value, but at least one requested task slot
733 /// failed or was rejected without the script declaring a partial-failure
734 /// contract. The output is preserved; the status refuses to call a run
735 /// with dropped slots a plain success (receipt honesty, morning-report
736 /// issue #2).
737 Degraded,
738 Failed,
739 Cancelled,
740 }
741
742 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
743 enum WorkflowAction {
744 Start,
745 Run,
746 Status,
747 Cancel,
748 }
749
750 fn parse_workflow_action(input: &Value) -> Result<WorkflowAction, ToolError> {
751 let Some(action) = optional_str(input, "action")? else {
752 return Ok(WorkflowAction::Start);
753 };
754 match action.trim().to_ascii_lowercase().as_str() {
755 "" | "start" | "spawn" => Ok(WorkflowAction::Start),
756 "run" | "wait" => Ok(WorkflowAction::Run),
757 "status" | "list" | "inspect" => Ok(WorkflowAction::Status),
758 "cancel" | "stop" | "abort" => Ok(WorkflowAction::Cancel),
759 other => Err(ToolError::invalid_input(format!(
760 "Invalid workflow action '{other}'. Use start, run, status, or cancel."
761 ))),
762 }
763 }
764
765 #[async_trait]
766 impl ToolSpec for WorkflowTool {
767 fn name(&self) -> &'static str {
768 "workflow"
769 }
770
771 fn description(&self) -> &'static str {
772 concat!(
773 "Start, run, inspect, or cancel a Workflow. Workflows execute deterministic JS with args, phase/log progress, and task(...) calls that dispatch real sub-agents through Fleet/sub-agent scheduling. ",
774 "For parallel fan-out, pass an array of zero-argument thunks exactly like `await parallel([() => task({...}), () => task({...})])`; do not pass task promises as variadic arguments. ",
775 "Provide exactly one of script, source_path, or plan (structured planner JSON). ",
776 "Use action=start for detached orchestration and action=status with run_id to inspect progress. Use action=run when the model needs the final result before continuing."
777 )
778 }
779
780 fn input_schema(&self) -> Value {
781 json!({
782 "type": "object",
783 "properties": {
784 "action": {
785 "type": "string",
786 "enum": ["start", "run", "status", "cancel"],
787 "description": "start (default) launches a Workflow in the background. run waits for completion. status lists runs or inspects run_id. cancel stops a run and its child agents."
788 },
789 "run_id": {
790 "type": "string",
791 "description": "Workflow run id for action=status or action=cancel."
792 },
793 "script": {
794 "type": "string",
795 "description": "Workflow JS source. The runtime provides args, task(...), parallel(thunks), pipeline(thunks), log(...), phase(...), and budget. Fan-out syntax: await parallel([() => task({...}), () => task({...})]). parallel() requires one array of zero-argument thunks, not variadic task promises."
796 },
797 "source_path": {
798 "type": "string",
799 "description": "Path to a .workflow.js script inside the workspace. Use instead of script for checked-in workflows."
800 },
801 "fleet": {
802 "type": "string",
803 "description": "Named Fleet to resolve task({ role }) declarations, loaded from $CODEWHALE_HOME/fleets/ or workspace fleets/. Accepts a qualified origin/name. A legacy roster maps roles to profiles. An exact Fleet (schema = \"exact\") is frozen at start: each member's provider, model, reasoning, and permission ceiling are fixed, and per-task model/thinking overrides are rejected."
804 },
805 "plan": {
806 "type": "object",
807 "description": "Structured planner plan JSON (#4124). Alternative to script/source_path. Accepts goal, risk, max_children, token_budget, phases[], and/or children[] (or IR nodes). risk must be exactly read_only, writes, or elevated. For a child, prefer role/profile without an explicit type; do not combine a role/profile with a conflicting type. Lowered to Workflow JS with parallel() partial-success semantics."
808 },
809 "args": {
810 "anyOf": [
811 { "type": "null" },
812 { "type": "boolean" },
813 { "type": "integer" },
814 { "type": "number" },
815 { "type": "string" },
816 { "type": "array" },
817 {
818 "type": "object",
819 "additionalProperties": {}
820 }
821 ],
822 "description": "JSON value exposed to the script as args. Defaults to null."
823 },
824 "token_budget": {
825 "type": "integer",
826 "minimum": 1,
827 "description": "Optional shared Workflow admission hint. Usage is reconciled when children report completion; already-running parallel children can take aggregate spent past the hint, while later and descendant spawns are rejected once exhausted."
828 },
829 "wait": {
830 "type": "boolean",
831 "description": "For action=start, wait for completion instead of returning immediately."
832 },
833 "verify": {
834 "type": "boolean",
835 "default": false,
836 "description": "After a successful workflow completion, run quick workspace verifier gates (auto/quick profile)."
837 }
838 },
839 "required": [],
840 "additionalProperties": false
841 })
842 }
843
844 fn capabilities(&self) -> Vec<ToolCapability> {
845 vec![
846 ToolCapability::ExecutesCode,
847 ToolCapability::RequiresApproval,
848 ]
849 }
850
851 fn approval_requirement(&self) -> ApprovalRequirement {
852 // Default posture: elevated starts require approval. Concrete inputs
853 // refine this via `approval_requirement_for` (#4126).
854 ApprovalRequirement::Required
855 }
856
857 fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement {
858 // Product defaults for [workflow] when the tool has no live Config
859 // handle. YOLO/bypass still short-circuit upstream of this check.
860 let config = codewhale_config::WorkflowConfigToml::default();
861 workflow_approval_requirement_for(input, &config)
862 }
863
864 fn starts_detached_for(&self, input: &Value) -> bool {
865 // A scheduling hint, not an authority decision, and this trait method
866 // cannot report an error. A malformed `wait` reads as "not detached"
867 // so the call stays in the foreground; `execute` then refuses it with
868 // the named-parameter error rather than running anything.
869 matches!(parse_workflow_action(input), Ok(WorkflowAction::Start))
870 && !optional_bool(input, "wait", false).unwrap_or(true)
871 }
872
873 fn supports_parallel_for(&self, input: &Value) -> bool {
874 matches!(parse_workflow_action(input), Ok(WorkflowAction::Status))
875 }
876
877 fn is_read_only_for(&self, input: &Value) -> bool {
878 matches!(parse_workflow_action(input), Ok(WorkflowAction::Status))
879 }
880
881 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
882 let state = shared_workflow_state(&context.workspace);
883 attach_bound_workflow_lifecycles(context, &state)?;
884 // Keyed off the parsed `WorkflowAction` discriminant, never off
885 // `input["action"]`. The JSON Schema published to the model is a
886 // declaration, not a guard: the real parse also accepts `spawn`,
887 // `wait`, `list`, `inspect`, `stop`, and `abort`, and its reject arm
888 // embeds the model's string verbatim.
889 let action = parse_workflow_action(&input)?;
890 codewhale_telemetry::session_counters().bump(codewhale_telemetry::Counter::WorkflowRun);
891 match action {
892 WorkflowAction::Start => {
893 let wait = optional_bool(&input, "wait", false)?;
894 start_workflow(
895 input,
896 context,
897 self.manager.clone(),
898 self.runtime.clone(),
899 state,
900 wait,
901 self.approval_decision,
902 )
903 .await
904 }
905 WorkflowAction::Run => {
906 start_workflow(
907 input,
908 context,
909 self.manager.clone(),
910 self.runtime.clone(),
911 state,
912 true,
913 self.approval_decision,
914 )
915 .await
916 }
917 WorkflowAction::Status => status_workflow(input, state),
918 WorkflowAction::Cancel => cancel_workflow(input, state).await,
919 }
920 }
921 }
922
923 fn attach_bound_workflow_lifecycles(
924 context: &ToolContext,
925 state: &Arc<WorkflowWorkspaceState>,
926 ) -> Result<(), ToolError> {
927 let records = lock_mutex(&state.runs)?
928 .values()
929 .cloned()
930 .collect::<Vec<_>>();
931 for record in records {
932 if let Some(lifecycle) = WorkflowWorkLifecycle::for_bound(context, &record.run_id) {
933 state.attach_lifecycle(&record.run_id, lifecycle);
934 state.reconcile_snapshot(&record);
935 }
936 }
937 Ok(())
938 }
939
940 fn fail_workflow_start(state: &Arc<WorkflowWorkspaceState>, run_id: &str, message: String) {
941 let snapshot = state.runs.lock().ok().and_then(|mut runs| {
942 let record = runs.get_mut(run_id)?;
943 record.status = WorkflowRunStatus::Failed;
944 record.lifecycle_seq = record.lifecycle_seq.saturating_add(1);
945 record.completed_at_ms = Some(now_ms());
946 record.error = Some(message);
947 Some(record.clone())
948 });
949 let Some(snapshot) = snapshot else {
950 state.mark_owner_missing(run_id);
951 return;
952 };
953 if state.try_record_snapshot(&snapshot).is_ok() {
954 state.reconcile_snapshot(&snapshot);
955 } else {
956 state.mark_owner_missing(run_id);
957 }
958 }
959
960 fn fail_workflow_after_controller_registration(
961 state: &Arc<WorkflowWorkspaceState>,
962 run_id: &str,
963 controller: &Arc<WorkflowRunController>,
964 message: String,
965 ) {
966 controller.cancel();
967 if let Ok(mut controllers) = state.controllers.lock() {
968 controllers.remove(run_id);
969 }
970 fail_workflow_start(state, run_id, message);
971 }
972
973 #[allow(clippy::too_many_arguments)]
974 async fn start_workflow(
975 input: Value,
976 context: &ToolContext,
977 manager: SharedSubAgentManager,
978 runtime: SubAgentRuntime,
979 state: Arc<WorkflowWorkspaceState>,
980 wait: bool,
981 approval_decision: &str,
982 ) -> Result<ToolResult, ToolError> {
983 let source = workflow_source(&input, context)?;
984 let args = input.get("args").cloned().unwrap_or(Value::Null);
985 let token_budget = optional_u64(&input, "token_budget", 0)?;
986 let token_budget = (token_budget > 0).then_some(token_budget);
987 let verify_on_complete = optional_bool(&input, "verify", false)?;
988 let fleet = workflow_fleet_binding(&input, context, runtime.api_config.as_deref())?;
989 let run_id = format!("workflow_{}", &Uuid::new_v4().to_string()[..8]);
990 let gate_specs = source
991 .spec
992 .as_ref()
993 .map(|spec| spec.gates.clone())
994 .unwrap_or_default();
995
996 // Capture the approved plan envelope for audit/receipt (#4126). Reaching
997 // execute means the approval gate already passed (or YOLO/auto-start).
998 let workflow_cfg = codewhale_config::WorkflowConfigToml::default();
999 let summary = source
1000 .spec
1001 .as_ref()
1002 .map(|spec| analyze_workflow_spec(spec, token_budget, &workflow_cfg))
1003 .unwrap_or_else(|| analyze_workflow_plan_approval_with_config(&input, &workflow_cfg));
1004 let approval_decision = if summary.is_read_only_envelope() {
1005 "auto_read_only"
1006 } else {
1007 approval_decision
1008 };
1009 let plan_approval = summary.to_receipt(approval_decision, now_ms());
1010 let workflow_title = source
1011 .spec
1012 .as_ref()
1013 .map(|spec| spec.goal.as_str())
1014 .or_else(|| {
1015 source
1016 .path
1017 .as_ref()
1018 .and_then(|path| path.file_name()?.to_str())
1019 })
1020 .unwrap_or("Workflow run");
1021 let lifecycle = WorkflowWorkLifecycle::register(context, &run_id, workflow_title)?;
1022
1023 {
1024 let mut runs_guard = match lock_mutex(&state.runs) {
1025 Ok(guard) => guard,
1026 Err(err) => {
1027 if let Some(lifecycle) = lifecycle.as_ref() {
1028 lifecycle.reconcile_spawn_failure();
1029 }
1030 return Err(err);
1031 }
1032 };
1033 let mut record = WorkflowRunRecord::new(
1034 run_id.clone(),
1035 source.path.clone(),
1036 token_budget,
1037 source.spec.as_ref(),
1038 );
1039 record.verify_on_complete = verify_on_complete;
1040 record.plan_approval = Some(plan_approval.clone());
1041 let started = WorkflowUiEvent::at(
1042 record.started_at_ms,
1043 WorkflowUiEventKind::RunStarted {
1044 workflow_id: record.workflow_id.clone(),
1045 workflow_goal: record.workflow_goal.clone(),
1046 source_path: record.source_path.clone(),
1047 token_budget: record.token_budget,
1048 },
1049 );
1050 record.push_event(started.clone());
1051 runs_guard.insert(run_id.clone(), record.clone());
1052 if let Err(err) = state.try_record_snapshot(&record) {
1053 runs_guard.remove(&run_id);
1054 if let Some(lifecycle) = lifecycle.as_ref() {
1055 lifecycle.reconcile_spawn_failure();
1056 }
1057 return Err(ToolError::execution_failed(format!(
1058 "workflow journal snapshot failed before launch: {err}"
1059 )));
1060 }
1061 // #4122: emit RunStarted immediately so the panel + history card open
1062 // before the first task/phase (including wait:false fire-and-forget).
1063 if let Some(tx) = runtime.event_tx.as_ref()
1064 && let Ok(mut value) = serde_json::to_value(&started)
1065 {
1066 if let Some(obj) = value.as_object_mut() {
1067 obj.insert("run_id".to_string(), json!(run_id));
1068 }
1069 let _ = tx.try_send(Event::WorkflowUi {
1070 run_id: run_id.clone(),
1071 event: value,
1072 });
1073 }
1074 }
1075 if let Some(lifecycle) = lifecycle {
1076 state.attach_lifecycle(&run_id, lifecycle);
1077 }
1078
1079 // An exact Fleet runs on a run-scoped roster projected from its immutable
1080 // snapshot, so every child resolves its member (and that member's exact
1081 // provider pin) from the value frozen at start rather than from whatever
1082 // the session roster holds now.
1083 let mut runtime = runtime;
1084 if let Some(operation) = fleet.exact() {
1085 runtime.fleet_roster = operation.roster().clone();
1086 }
1087
1088 let driver = SubAgentWorkflowDriver::new(
1089 run_id.clone(),
1090 manager,
1091 runtime,
1092 state.clone(),
1093 token_budget,
1094 fleet,
1095 gate_specs,
1096 );
1097 let vm_cancel = WorkflowRunCancel::new();
1098 let controller = Arc::new(WorkflowRunController::new(
1099 driver.clone(),
1100 vm_cancel.clone(),
1101 ));
1102 if let Err(err) = lock_mutex(&state.controllers).map(|mut controllers_guard| {
1103 controllers_guard.insert(run_id.clone(), controller.clone());
1104 }) {
1105 fail_workflow_start(&state, &run_id, err.to_string());
1106 return Err(err);
1107 }
1108 let running_snapshot = {
1109 let mut runs_guard = match lock_mutex(&state.runs) {
1110 Ok(guard) => guard,
1111 Err(err) => {
1112 fail_workflow_after_controller_registration(
1113 &state,
1114 &run_id,
1115 &controller,
1116 err.to_string(),
1117 );
1118 return Err(err);
1119 }
1120 };
1121 let Some(record) = runs_guard.get_mut(&run_id) else {
1122 drop(runs_guard);
1123 fail_workflow_after_controller_registration(
1124 &state,
1125 &run_id,
1126 &controller,
1127 "workflow owner record disappeared before launch".to_string(),
1128 );
1129 return Err(ToolError::execution_failed(
1130 "workflow owner record disappeared before launch",
1131 ));
1132 };
1133 record.lifecycle_seq = record.lifecycle_seq.saturating_add(1);
1134 record.clone()
1135 };
1136 if let Err(err) = state.try_record_snapshot(&running_snapshot) {
1137 fail_workflow_after_controller_registration(
1138 &state,
1139 &run_id,
1140 &controller,
1141 format!("workflow journal failed while activating owner: {err}"),
1142 );
1143 return Err(ToolError::execution_failed(format!(
1144 "workflow journal failed while activating owner: {err}"
1145 )));
1146 }
1147 state.reconcile_snapshot(&running_snapshot);
1148
1149 let run = run_workflow_vm(
1150 run_id.clone(),
1151 source.source,
1152 source.spec,
1153 args,
1154 driver,
1155 state.clone(),
1156 context.clone(),
1157 vm_cancel,
1158 );
1159 if wait {
1160 run.await;
1161 } else {
1162 let handle = spawn_supervised("workflow-run", std::panic::Location::caller(), run);
1163 controller.set_run_handle(handle);
1164 }
1165
1166 workflow_result_for(&run_id, state)
1167 }
1168
1169 fn status_workflow(
1170 input: Value,
1171 state: Arc<WorkflowWorkspaceState>,
1172 ) -> Result<ToolResult, ToolError> {
1173 if let Some(run_id) = optional_str(&input, "run_id")? {
1174 return workflow_result_for(run_id, state);
1175 }
1176 let mut summaries = {
1177 let runs_guard = lock_mutex(&state.runs)?;
1178 runs_guard
1179 .values()
1180 .map(WorkflowRunRecord::summary)
1181 .collect::<Vec<_>>()
1182 };
1183 summaries.sort_by_key(|record| record.started_at_ms);
1184 ToolResult::json(&json!({
1185 "action": "status",
1186 "count": summaries.len(),
1187 "runs": summaries,
1188 }))
1189 .map_err(|err| ToolError::execution_failed(err.to_string()))
1190 }
1191
1192 async fn cancel_workflow(
1193 input: Value,
1194 state: Arc<WorkflowWorkspaceState>,
1195 ) -> Result<ToolResult, ToolError> {
1196 let run_id =
1197 optional_str(&input, "run_id")?.ok_or_else(|| ToolError::missing_field("run_id"))?;
1198 let controller = {
1199 let mut controllers_guard = lock_mutex(&state.controllers)?;
1200 controllers_guard.remove(run_id)
1201 };
1202 let current_status = {
1203 let runs_guard = lock_mutex(&state.runs)?;
1204 let record = runs_guard.get(run_id).ok_or_else(|| {
1205 ToolError::invalid_input(format!("Unknown workflow run_id '{run_id}'"))
1206 })?;
1207 record.status
1208 };
1209 if current_status != WorkflowRunStatus::Running {
1210 state.reconcile_cancel(run_id, CancelOutcome::AlreadyFinished);
1211 if let Ok(runs_guard) = state.runs.lock()
1212 && let Some(record) = runs_guard.get(run_id)
1213 {
1214 state.reconcile_snapshot(record);
1215 }
1216 return workflow_result_for(run_id, state);
1217 }
1218 state.reconcile_cancel(
1219 run_id,
1220 if controller.is_some() {
1221 CancelOutcome::Requested
1222 } else {
1223 CancelOutcome::StaleUnknown
1224 },
1225 );
1226 let Some(controller) = controller else {
1227 return Err(ToolError::execution_failed(
1228 "workflow controller missing; cancellation outcome is unknown",
1229 ));
1230 };
1231 controller.cancel();
1232 let cancelled_event = WorkflowUiEvent::new(WorkflowUiEventKind::RunCancelled {
1233 reason: "cancelled by workflow tool".to_string(),
1234 });
1235 let snapshot = {
1236 let mut runs_guard = lock_mutex(&state.runs)?;
1237 let record = runs_guard.get_mut(run_id).ok_or_else(|| {
1238 ToolError::invalid_input(format!("Unknown workflow run_id '{run_id}'"))
1239 })?;
1240 record.status = WorkflowRunStatus::Cancelled;
1241 record.lifecycle_seq = record.lifecycle_seq.saturating_add(1);
1242 record.completed_at_ms = Some(now_ms());
1243 let reason = "cancelled by workflow tool".to_string();
1244 record.error = Some(reason);
1245 record.push_event(cancelled_event.clone());
1246 record.clone()
1247 };
1248 if let Err(err) = state.try_record_snapshot(&snapshot) {
1249 state.mark_owner_missing(run_id);
1250 return Err(ToolError::execution_failed(format!(
1251 "workflow cancellation journal failed: {err}"
1252 )));
1253 }
1254 state.reconcile_snapshot(&snapshot);
1255 // The VM may publish its terminal `run_completed` event while cancellation
1256 // is racing it. Always stream the authoritative cancellation afterward so
1257 // the live panel finalizes running rows and cannot remain visually failed.
1258 controller.driver.emit_ui_event(&cancelled_event);
1259 workflow_result_for(run_id, state)
1260 }
1261
1262 fn workflow_fleet_name(input: &Value) -> Result<Option<String>, ToolError> {
1263 let named = match optional_str(input, "fleet")? {
1264 Some(name) => Some(name),
1265 None => match input.get("args") {
1266 Some(args) => optional_str(args, "fleet")?,
1267 None => None,
1268 },
1269 };
1270 Ok(named
1271 .map(str::trim)
1272 .filter(|name| !name.is_empty())
1273 .map(str::to_string))
1274 }
1275
1276 /// How a Workflow run is bound to a named Fleet.
1277 ///
1278 /// The two saved forms share one store and one `fleet: "<name>"` option. Legacy
1279 /// role maps keep their exact previous behavior; an exact fleet is frozen into
1280 /// an immutable Workflow snapshot at start and drives every task launch from
1281 /// that snapshot.
1282 #[derive(Debug, Clone, Default)]
1283 enum WorkflowFleetBinding {
1284 #[default]
1285 None,
1286 Legacy {
1287 name: String,
1288 roles: FleetRoleMap,
1289 },
1290 Exact(Arc<crate::fleet::exact::ExactFleetWorkflow>),
1291 }
1292
1293 impl WorkflowFleetBinding {
1294 fn name(&self) -> Option<String> {
1295 match self {
1296 Self::None => None,
1297 Self::Legacy { name, .. } => Some(name.clone()),
1298 Self::Exact(operation) => Some(operation.snapshot().fleet().qualified()),
1299 }
1300 }
1301
1302 fn legacy_roles(&self) -> Option<&FleetRoleMap> {
1303 match self {
1304 Self::Legacy { roles, .. } => Some(roles),
1305 Self::None | Self::Exact(_) => None,
1306 }
1307 }
1308
1309 fn exact(&self) -> Option<&Arc<crate::fleet::exact::ExactFleetWorkflow>> {
1310 match self {
1311 Self::Exact(operation) => Some(operation),
1312 Self::None | Self::Legacy { .. } => None,
1313 }
1314 }
1315 }
1316
1317 fn workflow_fleet_binding(
1318 input: &Value,
1319 context: &ToolContext,
1320 api_config: Option<&crate::config::Config>,
1321 ) -> Result<WorkflowFleetBinding, ToolError> {
1322 let Some(name) = workflow_fleet_name(input)? else {
1323 return Ok(WorkflowFleetBinding::None);
1324 };
1325 let roots = crate::fleet::exact::fleet_search_roots(&context.workspace);
1326 let (document, id) = crate::fleet::exact::load_fleet_document(&name, &context.workspace)
1327 .map_err(|err| {
1328 ToolError::invalid_input(format!(
1329 "Failed to load workflow fleet '{name}' from {}: {err}",
1330 roots
1331 .iter()
1332 .map(|root| format!("{}/{}", root.origin, root.root.display()))
1333 .collect::<Vec<_>>()
1334 .join(", ")
1335 ))
1336 })?;
1337
1338 if let Some(legacy) = document.legacy() {
1339 let roles = FleetRoleMap::from_pairs(
1340 legacy
1341 .roles
1342 .iter()
1343 .map(|(role, profile)| (role.as_str(), profile.as_str())),
1344 )
1345 .map_err(|err| ToolError::invalid_input(err.to_string()))?;
1346 return Ok(WorkflowFleetBinding::Legacy { name, roles });
1347 }
1348
1349 // Exact: freeze the definition now. Everything the run launches afterwards
1350 // comes from this value, so editing the file mid-run cannot move a route.
1351 // The same labelled roots resolve the Fleet *and* the Reasoning Router
1352 // profile it references, so a Router is qualified (`workspace/luna-low`)
1353 // exactly the way a Fleet is and cannot be resolved by shadowing.
1354 let operation = crate::fleet::exact::ExactFleetWorkflow::capture(
1355 &document,
1356 id,
1357 chrono::Utc::now().to_rfc3339(),
1358 api_config,
1359 &roots,
1360 )
1361 .map_err(ToolError::invalid_input)?;
1362 Ok(WorkflowFleetBinding::Exact(Arc::new(operation)))
1363 }
1364
1365 fn apply_named_fleet_to_task_request(
1366 fleet_roles: Option<&FleetRoleMap>,
1367 request: &mut TaskRequest,
1368 ) -> Result<(), DriverError> {
1369 let Some(fleet_roles) = fleet_roles else {
1370 return Ok(());
1371 };
1372 let resolved = resolve_workflow_agent(
1373 request.role.as_deref(),
1374 request.profile.as_deref(),
1375 fleet_roles,
1376 true,
1377 )
1378 .map_err(|err| DriverError::Rejected(err.to_string()))?;
1379 request.role = resolved.resolved_role;
1380 request.profile = Some(resolved.resolved_profile);
1381 Ok(())
1382 }
1383
1384 /// **Phase one** of an exact-Fleet task: resolve the member and stamp its
1385 /// clamped authority onto the request, contacting nobody.
1386 ///
1387 /// This runs *before* gate evaluation and before a concurrency slot is taken,
1388 /// which is what makes it safe: a task that is about to be rejected or queued
1389 /// must not have spent a router call or disclosed a summary to another
1390 /// provider. Everything that can cost money lives in
1391 /// [`route_admitted_exact_task`].
1392 fn bind_exact_fleet_task_request(
1393 operation: &crate::fleet::exact::ExactFleetWorkflow,
1394 session: codewhale_workflow::PermissionCeiling,
1395 request: &mut TaskRequest,
1396 ) -> Result<crate::fleet::exact::ExactMemberBinding, DriverError> {
1397 let fleet = operation.snapshot().fleet().qualified();
1398
1399 // A saved exact Fleet is the authority on routing and on posture. A task
1400 // that tries to re-route a member — or to widen it by asking for a
1401 // different agent type or a broader tool surface — is rejected outright
1402 // rather than silently ignored. `subagent_type` and `allowed_tools` matter
1403 // as much as `model` here: the member's posture role is derived from its
1404 // saved permission ceiling, and a task-supplied type would otherwise pick
1405 // a different tool surface than the one the operator saved.
1406 for (field, present) in [
1407 ("model", request.model.is_some()),
1408 ("model_strength", request.model_strength.is_some()),
1409 ("thinking", request.thinking.is_some()),
1410 ("subagent_type", request.subagent_type.is_some()),
1411 ("allowed_tools", request.allowed_tools.is_some()),
1412 ("write_authority", request.write_authority.is_some()),
1413 ] {
1414 if present {
1415 return Err(DriverError::Rejected(format!(
1416 "fleet `{fleet}` is an exact fleet: task option `{field}` is not allowed. Every \
1417 member's provider, model, reasoning, and permission ceiling are fixed by the \
1418 saved Fleet — switch Fleets or edit the Fleet, do not override a member per \
1419 task."
1420 )));
1421 }
1422 }
1423
1424 let binding = operation
1425 .bind_member(request.profile.as_deref(), request.role.as_deref(), session)
1426 .map_err(|err| DriverError::Rejected(format!("fleet `{fleet}`: {err}")))?;
1427
1428 // Id and role are stamped **separately and semantically**. The member id
1429 // addresses the run-scoped roster profile projected from the snapshot,
1430 // which carries the exact provider pin and canonical wire model. The role
1431 // stays the Fleet's semantic role, because that is what gates, handoffs,
1432 // and records key on — overwriting it with the profile id (as an earlier
1433 // pass did) silently broke every gate whose member id differs from its
1434 // role.
1435 request.profile = Some(binding.member_id.clone());
1436 request.role = Some(binding.member_role.clone());
1437
1438 // Ceilings narrow the child; they never widen it. `subagent_type` is
1439 // cleared rather than defaulted so the roster profile's posture role — the
1440 // one derived from the saved ceiling — is what picks the tool surface.
1441 request.subagent_type = None;
1442 // The clamped authority becomes an actual tool policy the child runtime
1443 // enforces: an empty allowlist when `tools = false`, and a deny list that
1444 // removes every model-visible network surface when `network_tool = false`.
1445 request.allowed_tools = binding.authority.allowed_tools.clone();
1446 request.disallowed_tools = binding.authority.disallowed_tools.clone();
1447 request.write_authority = Some(binding.authority.write_authority.to_string());
1448 request.max_depth = Some(
1449 request
1450 .max_depth
1451 .map_or(binding.authority.max_depth, |asked| {
1452 asked.min(binding.authority.max_depth)
1453 }),
1454 );
1455
1456 // Everything the spawn boundary will reject *predictably* is rejected here,
1457 // while the task has still cost nothing. The write-scope contract is the
1458 // one that bites: a write-capable member launched with no declared scope
1459 // fails at `validate_spawn_write_contract`, which runs long after the
1460 // Router has been paid for a decision about a task that could never run.
1461 validate_exact_write_scope(&fleet, &binding, request)?;
1462 Ok(binding)
1463 }
1464
1465 /// Which role a `task_started` event displays.
1466 ///
1467 /// An exact-Fleet receipt wins over the spawn metadata, because the metadata's
1468 /// role is the roster profile's **permission posture** — the tool surface the
1469 /// clamped ceiling permits — and rendering that where the member's role belongs
1470 /// renames the operator's `auditor` to `scout` in the panel, the history card,
1471 /// and the journal. The posture is not lost: it rides the same receipt in its
1472 /// own field. Non-Fleet tasks keep the previous metadata-then-request order
1473 /// exactly.
1474 fn displayed_resolved_role(
1475 fleet_receipt: Option<&codewhale_workflow::FleetTaskReceipt>,
1476 metadata_role: Option<&str>,
1477 request_role: Option<&str>,
1478 ) -> Option<String> {
1479 fleet_receipt
1480 .map(|receipt| receipt.member_role.clone())
1481 .or_else(|| metadata_role.map(str::to_string))
1482 .or_else(|| request_role.map(str::to_string))
1483 }
1484
1485 /// The visible line for a routing decision whose spawn then failed.
1486 ///
1487 /// Kept separate from the recorder so the wording is testable without a live
1488 /// driver, and so the receipt's own content-free `line()` stays the single
1489 /// source of what a receipt may say.
1490 fn orphaned_fleet_receipt_line(
1491 receipt: &codewhale_workflow::FleetTaskReceipt,
1492 error: &str,
1493 ) -> String {
1494 format!(
1495 "fleet route {} spawn_failed=true reason={}",
1496 receipt.line(),
1497 error.replace('\n', " ")
1498 )
1499 }
1500
1501 /// The write-scope half of the spawn contract, checked before anything costs.
1502 ///
1503 /// Deliberately a mirror of the spawn-boundary rule rather than a replacement
1504 /// for it: the boundary stays authoritative (it is reachable by other callers),
1505 /// and this exists so an exact-Fleet task fails on the same terms *before* the
1506 /// Router call rather than after it.
1507 fn validate_exact_write_scope(
1508 fleet: &str,
1509 binding: &crate::fleet::exact::ExactMemberBinding,
1510 request: &TaskRequest,
1511 ) -> Result<(), DriverError> {
1512 let declares_scope = !request.write_roots.is_empty()
1513 || !request.exact_files.is_empty()
1514 || !request.coordination_contracts.is_empty();
1515
1516 if binding.authority.write_authority == "read_only" {
1517 if declares_scope {
1518 return Err(DriverError::Rejected(format!(
1519 "fleet `{fleet}`: member `{}` is read-only under the clamped ceiling, so this \
1520 task may not declare write_roots, exact_files, or coordination_contracts.",
1521 binding.member_id
1522 )));
1523 }
1524 return Ok(());
1525 }
1526
1527 if !declares_scope {
1528 return Err(DriverError::Rejected(format!(
1529 "fleet `{fleet}`: member `{}` is write-capable, so this task must declare \
1530 write_roots, exact_files, or coordination_contracts before it can start. An \
1531 unbounded write claim is refused at the spawn boundary, and this task would spend a \
1532 reasoning-router call on its way to that refusal.",
1533 binding.member_id
1534 )));
1535 }
1536 Ok(())
1537 }
1538
1539 /// **Phase two**: route an already admitted task.
1540 ///
1541 /// Only reachable once the task has passed its gates and holds a concurrency
1542 /// slot, so this is the one place a reasoning router call — and any
1543 /// cross-provider disclosure — can happen.
1544 async fn route_admitted_exact_task(
1545 operation: &crate::fleet::exact::ExactFleetWorkflow,
1546 binding: &crate::fleet::exact::ExactMemberBinding,
1547 request: &mut TaskRequest,
1548 ) -> Result<codewhale_workflow::FleetTaskReceipt, DriverError> {
1549 let fleet = operation.snapshot().fleet().qualified();
1550 let launch = operation
1551 .route_admitted_task(binding, &request.description)
1552 .await
1553 .map_err(|err| DriverError::Rejected(format!("fleet `{fleet}`: {err}")))?;
1554
1555 request.thinking = Some(launch.thinking.clone());
1556 // **The launch authority is what the child runs under.** Binding stamped a
1557 // provisional copy so the write-scope contract could be checked for free;
1558 // this re-stamps from the value `route_admitted_task` recomputed and
1559 // verified, so the request that reaches the spawn boundary carries the
1560 // launched envelope and not an older one. Without this the launch's
1561 // `authority` was computed, put on a struct, and never read — a ceiling
1562 // that existed only as a field.
1563 apply_launch_authority(&fleet, &launch, request)?;
1564 Ok(launch.receipt)
1565 }
1566
1567 /// Stamp the launched authority onto the request and refuse any drift.
1568 ///
1569 /// Two things happen here and both are load-bearing. The envelope fields are
1570 /// overwritten from `launch.authority`, so the spawn input is built from the
1571 /// launched value rather than the admitted one. And `max_depth` is intersected
1572 /// rather than replaced, because a task may legitimately ask for *less* nesting
1573 /// than its ceiling allows — but never more.
1574 fn apply_launch_authority(
1575 fleet: &str,
1576 launch: &crate::fleet::exact::ExactMemberLaunch,
1577 request: &mut TaskRequest,
1578 ) -> Result<(), DriverError> {
1579 let authority = &launch.authority;
1580
1581 // Identity first: an envelope stamped onto the wrong member's request is a
1582 // widening as surely as a wider envelope would be.
1583 if request.profile.as_deref() != Some(launch.member_id.as_str())
1584 || request.role.as_deref() != Some(launch.member_role.as_str())
1585 {
1586 return Err(DriverError::Rejected(format!(
1587 "fleet `{fleet}`: task identity drifted between admission and launch (request \
1588 profile={:?} role={:?}, launch member `{}` role `{}`); the launch is refused rather \
1589 than run under an envelope resolved for a different member.",
1590 request.profile, request.role, launch.member_id, launch.member_role,
1591 )));
1592 }
1593
1594 request.allowed_tools = authority.allowed_tools.clone();
1595 request.disallowed_tools = authority.disallowed_tools.clone();
1596 request.write_authority = Some(authority.write_authority.to_string());
1597 request.subagent_type = None;
1598 request.max_depth = Some(
1599 request
1600 .max_depth
1601 .map_or(authority.max_depth, |asked| asked.min(authority.max_depth)),
1602 );
1603
1604 // The receipt records the fingerprint; the request now carries the envelope
1605 // it names. Recomputing the fingerprint from what was just stamped is the
1606 // check that the two describe each other — a mismatch here means a field
1607 // was added to the envelope and not to the stamping, which is exactly the
1608 // silent-gap failure this whole seam exists to prevent.
1609 let expected = authority.fingerprint();
1610 if launch.receipt.authority_fingerprint.as_deref() != Some(expected.as_str()) {
1611 return Err(DriverError::Rejected(format!(
1612 "fleet `{fleet}`: member `{}` produced a receipt whose authority fingerprint does not \
1613 match the envelope being installed (receipt={:?} envelope={expected}). Failing \
1614 closed.",
1615 launch.member_id, launch.receipt.authority_fingerprint,
1616 )));
1617 }
1618 Ok(())
1619 }
1620
1621 // Pre-existing spawn signature that grew `vm_cancel` for the cancel-interrupt
1622 // wiring; the args mirror one workflow run's context and are consumed once.
1623 #[allow(clippy::too_many_arguments)]
1624 async fn run_workflow_vm(
1625 run_id: String,
1626 source: String,
1627 spec: Option<WorkflowSpec>,
1628 args: Value,
1629 driver: Arc<SubAgentWorkflowDriver>,
1630 state: Arc<WorkflowWorkspaceState>,
1631 context: ToolContext,
1632 vm_cancel: WorkflowRunCancel,
1633 ) {
1634 let result = WorkflowVm::new()
1635 .run_script_with_cancel(&source, args, driver.clone(), vm_cancel)
1636 .await;
1637 let mut status = WorkflowRunStatus::Completed;
1638 let mut output = None;
1639 let mut error = None;
1640 match result {
1641 Ok(value) => {
1642 if let Some(gate_error) = driver.terminal_gate_failure() {
1643 status = WorkflowRunStatus::Failed;
1644 error = Some(gate_error);
1645 } else {
1646 output = Some(value);
1647 }
1648 }
1649 Err(err) => {
1650 status = WorkflowRunStatus::Failed;
1651 error = Some(err.to_string());
1652 }
1653 }
1654 let snapshot = {
1655 let mut runs_guard = match state.runs.lock() {
1656 Ok(guard) => guard,
1657 Err(_) => {
1658 state.mark_owner_missing(&run_id);
1659 return;
1660 }
1661 };
1662 let Some(record) = runs_guard.get_mut(&run_id) else {
1663 state.mark_owner_missing(&run_id);
1664 return;
1665 };
1666 if record.status != WorkflowRunStatus::Cancelled {
1667 // Receipt honesty: a script that returns a value has not
1668 // necessarily orchestrated anything. Classify against the slot
1669 // ledger — every requested task either became a child with a
1670 // terminal record or landed in `dispatch_failures` (driver
1671 // rejections and, via `ProgressEvent::TaskRejected`, VM-level
1672 // rejections that previously vanished into null slots).
1673 if status == WorkflowRunStatus::Completed {
1674 let task_records = driver.task_records_snapshot();
1675 let failed_children = task_records
1676 .iter()
1677 .filter(|task| task.status == IrWorkflowRunStatus::Failed)
1678 .count();
1679 let rejected = record.dispatch_failures.len();
1680 if record.child_ids.is_empty() && rejected > 0 {
1681 // #5035: every dispatch was rejected before a child ran.
1682 status = WorkflowRunStatus::Failed;
1683 error = Some(format!(
1684 "no child agents ran: all {rejected} task dispatch(es) were rejected; first: {}",
1685 record.dispatch_failures[0].message
1686 ));
1687 } else if failed_children > 0 || rejected > 0 {
1688 status = WorkflowRunStatus::Degraded;
1689 let mut parts = Vec::new();
1690 if failed_children > 0 {
1691 parts.push(format!(
1692 "{failed_children} of {} task(s) failed",
1693 task_records.len()
1694 ));
1695 }
1696 if rejected > 0 {
1697 parts.push(format!("{rejected} dispatch(es) were rejected"));
1698 }
1699 error = Some(format!(
1700 "completed with dropped slots: {}; the recorded result may be partial",
1701 parts.join(" and ")
1702 ));
1703 }
1704 }
1705 record.status = status;
1706 record.result = output;
1707 record.error = error.clone();
1708 record.execution = spec.as_ref().map(|spec| {
1709 execution_from_declarative_spec(spec, driver.task_records_snapshot(), status)
1710 });
1711 record.completed_at_ms = Some(now_ms());
1712 }
1713 record.clone()
1714 };
1715 let verify_on_complete = state
1716 .runs
1717 .lock()
1718 .ok()
1719 .and_then(|guard| guard.get(&run_id).map(|record| record.verify_on_complete))
1720 .unwrap_or(false);
1721 if status == WorkflowRunStatus::Completed && verify_on_complete {
1722 match run_workflow_completion_gates(&context).await {
1723 Ok(verification) => {
1724 if let Ok(mut runs_guard) = state.runs.lock()
1725 && let Some(record) = runs_guard.get_mut(&run_id)
1726 {
1727 record.verification = Some(verification);
1728 }
1729 }
1730 Err(err) => {
1731 if let Ok(mut runs_guard) = state.runs.lock()
1732 && let Some(record) = runs_guard.get_mut(&run_id)
1733 {
1734 record.status = WorkflowRunStatus::Failed;
1735 record.error = Some(format!("verification gates failed: {err}"));
1736 }
1737 }
1738 }
1739 }
1740 let final_budget = driver.current_budget_snapshot();
1741 // Reconcile run-wide usage totals from per-task telemetry (#2974).
1742 let run_usage = run_usage_totals(&driver.task_records_snapshot());
1743 let snapshot = state
1744 .runs
1745 .lock()
1746 .ok()
1747 .and_then(|mut guard| {
1748 let record = guard.get_mut(&run_id)?;
1749 if record.status != WorkflowRunStatus::Cancelled {
1750 record.lifecycle_seq = record.lifecycle_seq.saturating_add(1);
1751 if run_usage.is_some() {
1752 record.usage = run_usage.clone();
1753 }
1754 let budget_event = WorkflowUiEvent::new(budget_event_kind(final_budget));
1755 let completed = WorkflowUiEvent::new(WorkflowUiEventKind::RunCompleted {
1756 status: record.status,
1757 error: record.error.clone(),
1758 usage: run_usage.clone(),
1759 });
1760 record.push_event(budget_event.clone());
1761 record.push_event(completed.clone());
1762 // Live stream terminal events even when recorded outside the
1763 // driver helper (completion path).
1764 driver.emit_ui_event(&budget_event);
1765 driver.emit_ui_event(&completed);
1766 }
1767 Some(record.clone())
1768 })
1769 .unwrap_or(snapshot);
1770 if state.try_record_snapshot(&snapshot).is_ok() {
1771 state.reconcile_snapshot(&snapshot);
1772 } else {
1773 state.mark_owner_missing(&run_id);
1774 }
1775 write_run_report_artifact(&context.workspace, &snapshot);
1776 if let Ok(mut controllers_guard) = state.controllers.lock() {
1777 controllers_guard.remove(&run_id);
1778 }
1779 }
1780
1781 /// Persist a durable per-run report under `.codewhale/reports/<run_id>.md`
1782 /// so a settled background run leaves one synthesized artifact even after
1783 /// the session ends. Best-effort: report IO never affects the run outcome.
1784 fn write_run_report_artifact(workspace: &Path, record: &WorkflowRunRecord) {
1785 if !matches!(
1786 record.status,
1787 WorkflowRunStatus::Completed
1788 | WorkflowRunStatus::Degraded
1789 | WorkflowRunStatus::Failed
1790 | WorkflowRunStatus::Cancelled
1791 ) {
1792 return;
1793 }
1794 // Run ids are generated slugs, but never trust one as a path segment.
1795 let safe_id: String = record
1796 .run_id
1797 .chars()
1798 .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
1799 .collect();
1800 if safe_id.is_empty() {
1801 return;
1802 }
1803 let dir = workspace.join(".codewhale").join("reports");
1804 if let Err(err) = std::fs::create_dir_all(&dir) {
1805 crate::logging::warn(format!(
1806 "workflow report dir {} not created: {err}",
1807 dir.display()
1808 ));
1809 return;
1810 }
1811 let path = dir.join(format!("{safe_id}.md"));
1812 if let Err(err) = std::fs::write(&path, render_run_report(record)) {
1813 crate::logging::warn(format!(
1814 "workflow report {} not written: {err}",
1815 path.display()
1816 ));
1817 }
1818 }
1819
1820 fn render_run_report(record: &WorkflowRunRecord) -> String {
1821 let mut out = String::new();
1822 out.push_str(&format!("# Workflow run {}\n\n", record.run_id));
1823 out.push_str(&format!("- status: {:?}\n", record.status));
1824 if let Some(goal) = record.workflow_goal.as_deref() {
1825 out.push_str(&format!("- goal: {goal}\n"));
1826 }
1827 if let Some(source) = record.source_path.as_deref() {
1828 out.push_str(&format!("- source: {}\n", source.display()));
1829 }
1830 out.push_str(&format!("- started_at_ms: {}\n", record.started_at_ms));
1831 if let Some(completed) = record.completed_at_ms {
1832 out.push_str(&format!("- completed_at_ms: {completed}\n"));
1833 }
1834 if let Some(budget) = record.token_budget {
1835 out.push_str(&format!("- token_budget: {budget}\n"));
1836 }
1837 out.push_str(&format!("- child_agents: {}\n", record.child_ids.len()));
1838 if let Some(error) = record.error.as_deref() {
1839 out.push_str(&format!("- error: {error}\n"));
1840 }
1841 if !record.dispatch_failures.is_empty() {
1842 out.push_str(&format!(
1843 "\n## Dispatch failures ({})\n\n",
1844 record.dispatch_failures.len()
1845 ));
1846 for failure in &record.dispatch_failures {
1847 let slot = failure
1848 .label
1849 .as_deref()
1850 .or(failure.phase.as_deref())
1851 .unwrap_or("task");
1852 out.push_str(&format!("- {slot}: {}\n", failure.message));
1853 }
1854 }
1855 if !record.gate_status.is_empty() {
1856 out.push_str("\n## Gates\n\n");
1857 for line in &record.gate_status {
1858 out.push_str(&format!("- {line:?}\n"));
1859 }
1860 }
1861 if !record.progress.is_empty() {
1862 out.push_str("\n## Progress\n\n");
1863 for line in &record.progress {
1864 out.push_str(&format!("- {line}\n"));
1865 }
1866 }
1867 if !record.schema_errors.is_empty() {
1868 out.push_str(&format!(
1869 "\n## Schema errors ({})\n\n",
1870 record.schema_errors.len()
1871 ));
1872 }
1873 if let Some(result) = record.result.as_ref() {
1874 out.push_str("\n## Result\n\n```json\n");
1875 out.push_str(&serde_json::to_string_pretty(result).unwrap_or_else(|_| result.to_string()));
1876 out.push_str("\n```\n");
1877 }
1878 if let Some(verification) = record.verification.as_ref() {
1879 out.push_str("\n## Verification\n\n```json\n");
1880 out.push_str(
1881 &serde_json::to_string_pretty(verification)
1882 .unwrap_or_else(|_| verification.to_string()),
1883 );
1884 out.push_str("\n```\n");
1885 }
1886 out
1887 }
1888
1889 fn workflow_result_for(
1890 run_id: &str,
1891 state: Arc<WorkflowWorkspaceState>,
1892 ) -> Result<ToolResult, ToolError> {
1893 let record = {
1894 let runs_guard = lock_mutex(&state.runs)?;
1895 runs_guard.get(run_id).cloned().ok_or_else(|| {
1896 ToolError::invalid_input(format!("Unknown workflow run_id '{run_id}'"))
1897 })?
1898 };
1899 let journal_path = state.journal_path().to_path_buf();
1900 let (payload, bounds) = bounded_run_record_value(&record, &journal_path);
1901 let mut result =
1902 ToolResult::json(&payload).map_err(|err| ToolError::execution_failed(err.to_string()))?;
1903 let summary = record.summary();
1904 result.metadata = Some(json!({
1905 "run_id": summary.run_id,
1906 "status": summary.status,
1907 "terminal": summary.status != WorkflowRunStatus::Running,
1908 "child_count": summary.child_count,
1909 "schema_error_count": summary.schema_error_count,
1910 "dispatch_failure_count": summary.dispatch_failure_count,
1911 "event_count": summary.event_count,
1912 "events_returned": bounds.events_returned,
1913 "events_omitted": bounds.events_omitted,
1914 "dispatch_failures_returned": bounds.dispatch_failures_returned,
1915 "dispatch_failures_omitted": bounds.dispatch_failures_omitted,
1916 "events_dropped": summary.events_dropped,
1917 "last_event_type": summary.last_event_type,
1918 "leaf_count": summary.leaf_count,
1919 "branch_count": summary.branch_count,
1920 "control_count": summary.control_count,
1921 "execution_status": summary.execution_status,
1922 "gate_count": summary.gate_count,
1923 "blocked_gate_count": summary.blocked_gate_count,
1924 "gate_status": summary.gate_status,
1925 // #2974: bounded payload; full detail stays in the durable journal.
1926 "truncated": bounds.truncated(),
1927 "payload_budget_chars": WORKFLOW_RESULT_MAX_CHARS,
1928 "journal_path": journal_path.display().to_string(),
1929 // #4126: durable plan-approval receipt for audit/receipt consumers.
1930 "plan_approval": record.plan_approval,
1931 }));
1932 Ok(result)
1933 }
1934
1935 /// What `bounded_run_record_value` clipped out of the model-facing payload.
1936 #[derive(Debug, Default)]
1937 struct RunPayloadBounds {
1938 events_returned: usize,
1939 events_omitted: usize,
1940 progress_omitted: usize,
1941 dispatch_failures_returned: usize,
1942 dispatch_failures_omitted: usize,
1943 dispatch_failure_fields_truncated: usize,
1944 result_truncated: bool,
1945 leaf_outputs_truncated: usize,
1946 }
1947
1948 impl RunPayloadBounds {
1949 fn truncated(&self) -> bool {
1950 self.events_omitted > 0
1951 || self.progress_omitted > 0
1952 || self.dispatch_failures_omitted > 0
1953 || self.dispatch_failure_fields_truncated > 0
1954 || self.result_truncated
1955 || self.leaf_outputs_truncated > 0
1956 }
1957 }
1958
1959 /// Build the model-facing view of a run record (#2974). The JSON shape is
1960 /// identical to the full record (panel hydration and history cards keep
1961 /// working unchanged), but the unbounded parts are clipped:
1962 ///
1963 /// - `events`: newest `WORKFLOW_RESULT_EVENTS_TAIL` entries.
1964 /// - `progress`: newest `WORKFLOW_RESULT_PROGRESS_TAIL` lines.
1965 /// - `dispatch_failures`: newest `WORKFLOW_RESULT_DISPATCH_FAILURES_TAIL`
1966 /// entries with bounded string fields.
1967 /// - `result` / `verification`: collapsed to a preview + journal pointer
1968 /// when the serialized value exceeds `WORKFLOW_RESULT_VALUE_MAX_CHARS`.
1969 /// - `execution.leaf_results[*].output`: per-leaf preview capped at
1970 /// `WORKFLOW_RESULT_LEAF_OUTPUT_MAX_CHARS`.
1971 ///
1972 /// Full detail remains available in `.codewhale/workflow-runs.jsonl`; every
1973 /// clip adds an explicit note/pointer so the model can fetch more on demand.
1974 fn bounded_run_record_value(
1975 record: &WorkflowRunRecord,
1976 journal_path: &Path,
1977 ) -> (Value, RunPayloadBounds) {
1978 let mut bounds = RunPayloadBounds::default();
1979 let journal = journal_path.display().to_string();
1980 let mut value = serde_json::to_value(record).unwrap_or_else(|_| json!({}));
1981 let Some(obj) = value.as_object_mut() else {
1982 return (value, bounds);
1983 };
1984
1985 if let Some(events) = obj.get_mut("events").and_then(Value::as_array_mut) {
1986 if events.len() > WORKFLOW_RESULT_EVENTS_TAIL {
1987 let omitted = events.len() - WORKFLOW_RESULT_EVENTS_TAIL;
1988 events.drain(..omitted);
1989 bounds.events_omitted = omitted;
1990 }
1991 bounds.events_returned = events.len();
1992 }
1993 if bounds.events_omitted > 0 {
1994 obj.insert(
1995 "events_note".to_string(),
1996 json!(format!(
1997 "showing the newest {} of {} events; full stream: {journal}",
1998 bounds.events_returned,
1999 bounds.events_returned + bounds.events_omitted,
2000 )),
2001 );
2002 }
2003
2004 if let Some(progress) = obj.get_mut("progress").and_then(Value::as_array_mut)
2005 && progress.len() > WORKFLOW_RESULT_PROGRESS_TAIL
2006 {
2007 let omitted = progress.len() - WORKFLOW_RESULT_PROGRESS_TAIL;
2008 progress.drain(..omitted);
2009 bounds.progress_omitted = omitted;
2010 obj.insert(
2011 "progress_note".to_string(),
2012 json!(format!(
2013 "showing the newest {WORKFLOW_RESULT_PROGRESS_TAIL} of {} progress lines; full log: {journal}",
2014 omitted + WORKFLOW_RESULT_PROGRESS_TAIL,
2015 )),
2016 );
2017 }
2018
2019 if let Some(failures) = obj
2020 .get_mut("dispatch_failures")
2021 .and_then(Value::as_array_mut)
2022 {
2023 if failures.len() > WORKFLOW_RESULT_DISPATCH_FAILURES_TAIL {
2024 let omitted = failures.len() - WORKFLOW_RESULT_DISPATCH_FAILURES_TAIL;
2025 failures.drain(..omitted);
2026 bounds.dispatch_failures_omitted = omitted;
2027 }
2028 bounds.dispatch_failures_returned = failures.len();
2029 for failure in failures {
2030 let Some(fields) = failure.as_object_mut() else {
2031 continue;
2032 };
2033 for key in ["label", "phase", "message"] {
2034 let Some(slot) = fields.get_mut(key) else {
2035 continue;
2036 };
2037 let Some(raw) = slot.as_str() else {
2038 continue;
2039 };
2040 if raw.chars().count() > WORKFLOW_RESULT_DISPATCH_FAILURE_FIELD_MAX_CHARS {
2041 *slot = Value::String(truncate_chars(
2042 raw,
2043 WORKFLOW_RESULT_DISPATCH_FAILURE_FIELD_MAX_CHARS,
2044 ));
2045 bounds.dispatch_failure_fields_truncated += 1;
2046 }
2047 }
2048 }
2049 }
2050 if bounds.dispatch_failures_omitted > 0 || bounds.dispatch_failure_fields_truncated > 0 {
2051 obj.insert(
2052 "dispatch_failures_note".to_string(),
2053 json!(format!(
2054 "showing {} of {} dispatch failures with bounded fields; full ledger: {journal}",
2055 bounds.dispatch_failures_returned,
2056 bounds.dispatch_failures_returned + bounds.dispatch_failures_omitted,
2057 )),
2058 );
2059 }
2060
2061 for key in ["result", "verification"] {
2062 let Some(raw) = obj.get(key).filter(|value| !value.is_null()) else {
2063 continue;
2064 };
2065 let text = raw.to_string();
2066 if text.chars().count() > WORKFLOW_RESULT_VALUE_MAX_CHARS {
2067 obj.insert(
2068 key.to_string(),
2069 json!({
2070 "truncated": true,
2071 "omitted_chars": text.chars().count() - WORKFLOW_RESULT_VALUE_MAX_CHARS,
2072 "preview": truncate_chars(&text, WORKFLOW_RESULT_VALUE_MAX_CHARS),
2073 "full_detail": journal,
2074 }),
2075 );
2076 bounds.result_truncated = true;
2077 }
2078 }
2079
2080 if let Some(leaves) = obj
2081 .get_mut("execution")
2082 .and_then(|execution| execution.get_mut("leaf_results"))
2083 .and_then(Value::as_array_mut)
2084 {
2085 for leaf in leaves {
2086 let too_long = leaf
2087 .get("output")
2088 .and_then(Value::as_str)
2089 .is_some_and(|output| {
2090 output.chars().count() > WORKFLOW_RESULT_LEAF_OUTPUT_MAX_CHARS
2091 });
2092 if too_long
2093 && let Some(slot) = leaf.get_mut("output")
2094 && let Some(output) = slot.as_str()
2095 {
2096 let clipped = format!(
2097 "{} [leaf output truncated to {WORKFLOW_RESULT_LEAF_OUTPUT_MAX_CHARS} chars; full text: {journal}]",
2098 truncate_chars(output, WORKFLOW_RESULT_LEAF_OUTPUT_MAX_CHARS),
2099 );
2100 *slot = Value::String(clipped);
2101 bounds.leaf_outputs_truncated += 1;
2102 }
2103 }
2104 }
2105
2106 (value, bounds)
2107 }
2108
2109 /// Char-boundary-safe truncation with an ellipsis (precedent:
2110 /// `cargo_failure_summary::truncate_chars`).
2111 fn truncate_chars(text: &str, max_chars: usize) -> String {
2112 if let Some((idx, _)) = text.char_indices().nth(max_chars) {
2113 if max_chars < 3 {
2114 return text[..idx].to_string();
2115 }
2116 let truncate_at = text
2117 .char_indices()
2118 .nth(max_chars - 3)
2119 .map(|(idx, _)| idx)
2120 .unwrap_or(0);
2121 format!("{}...", &text[..truncate_at])
2122 } else {
2123 text.to_string()
2124 }
2125 }
2126
2127 #[derive(Debug)]
2128 struct WorkflowSource {
2129 source: String,
2130 path: Option<PathBuf>,
2131 spec: Option<WorkflowSpec>,
2132 }
2133
2134 fn workflow_source(input: &Value, context: &ToolContext) -> Result<WorkflowSource, ToolError> {
2135 let script = match optional_str(input, "script")? {
2136 Some(script) => Some(script),
2137 None => optional_str(input, "source")?,
2138 }
2139 .map(str::to_string);
2140 let source_path = match optional_str(input, "source_path")? {
2141 Some(path) => Some(path),
2142 None => optional_str(input, "path")?,
2143 };
2144 let plan = input.get("plan").filter(|value| !value.is_null());
2145
2146 let provided = [
2147 script.as_ref().is_some_and(|s| !s.trim().is_empty()),
2148 source_path.is_some(),
2149 plan.is_some(),
2150 ]
2151 .into_iter()
2152 .filter(|present| *present)
2153 .count();
2154 if provided > 1 {
2155 return Err(ToolError::invalid_input(
2156 "Use exactly one of script, source_path, or plan",
2157 ));
2158 }
2159
2160 match (script, source_path, plan) {
2161 (Some(source), None, None) if !source.trim().is_empty() => {
2162 workflow_source_from_raw(source, None)
2163 }
2164 (None, Some(path), None) => read_workflow_source_path(path, context),
2165 (None, None, Some(plan_value)) => workflow_source_from_plan(plan_value),
2166 _ => Err(ToolError::missing_field("script")),
2167 }
2168 }
2169
2170 /// Planner-to-workflow structured launch path (#4124).
2171 ///
2172 /// Accepts product-shaped plans (`goal` + `phases`/`children`) or IR-shaped
2173 /// plans (`goal` + `nodes`), validates them, and lowers to imperative JS that
2174 /// uses `parallel()` (partial success) rather than raw `Promise.all()`.
2175 fn workflow_source_from_plan(plan_value: &Value) -> Result<WorkflowSource, ToolError> {
2176 let spec = structured_plan_to_workflow_spec(plan_value)?;
2177 let lowered = lower_declarative_workflow_to_imperative_js(&spec)?;
2178 Ok(WorkflowSource {
2179 source: lowered,
2180 path: None,
2181 spec: Some(spec),
2182 })
2183 }
2184
2185 #[derive(Debug, Deserialize)]
2186 struct StructuredWorkflowPlan {
2187 goal: String,
2188 #[serde(default)]
2189 risk: Option<String>,
2190 #[serde(default)]
2191 max_children: Option<usize>,
2192 #[serde(default)]
2193 token_budget: Option<u64>,
2194 #[serde(default)]
2195 phases: Vec<StructuredPlanPhase>,
2196 #[serde(default)]
2197 children: Vec<StructuredPlanChild>,
2198 /// Escape hatch: full Workflow IR nodes (kind/spec or JS authoring shapes).
2199 #[serde(default)]
2200 nodes: Option<Value>,
2201 /// Optional Workflow-owned gate specs (#4179).
2202 #[serde(default)]
2203 gates: Vec<GateSpec>,
2204 }
2205
2206 #[derive(Debug, Deserialize)]
2207 struct StructuredPlanPhase {
2208 #[serde(default)]
2209 id: Option<String>,
2210 #[serde(default)]
2211 title: Option<String>,
2212 #[serde(default)]
2213 parallel: Option<bool>,
2214 #[serde(default)]
2215 children: Vec<StructuredPlanChild>,
2216 }
2217
2218 #[derive(Debug, Deserialize)]
2219 struct StructuredPlanChild {
2220 #[serde(default)]
2221 id: Option<String>,
2222 #[serde(default)]
2223 label: Option<String>,
2224 #[serde(alias = "description")]
2225 prompt: String,
2226 #[serde(default, alias = "type", alias = "agent_type")]
2227 agent_type: Option<String>,
2228 /// Fleet role name (#4177). Preferred step identity; resolved via roster.
2229 #[serde(default)]
2230 role: Option<String>,
2231 #[serde(default)]
2232 profile: Option<String>,
2233 #[serde(default)]
2234 mode: Option<String>,
2235 #[serde(default)]
2236 file_scope: Vec<String>,
2237 }
2238
2239 fn structured_plan_to_workflow_spec(plan_value: &Value) -> Result<WorkflowSpec, ToolError> {
2240 if !plan_value.is_object() {
2241 return Err(ToolError::invalid_input(
2242 "Workflow plan must be a JSON object with goal and phases/children (or nodes)",
2243 ));
2244 }
2245
2246 let plan: StructuredWorkflowPlan =
2247 serde_json::from_value(plan_value.clone()).map_err(|err| {
2248 ToolError::invalid_input(format!("Invalid structured Workflow plan: {err}"))
2249 })?;
2250
2251 let goal = plan.goal.trim();
2252 if goal.is_empty() {
2253 return Err(ToolError::invalid_input(
2254 "Workflow plan goal must be a non-empty string",
2255 ));
2256 }
2257
2258 // IR / declarative nodes escape hatch: re-parse as workflow({...}) object.
2259 if let Some(nodes) = plan.nodes.as_ref() {
2260 if !nodes.is_array() {
2261 return Err(ToolError::invalid_input(
2262 "Workflow plan.nodes must be an array of workflow nodes",
2263 ));
2264 }
2265 let mut object = plan_value.clone();
2266 if let Some(obj) = object.as_object_mut() {
2267 obj.insert("goal".to_string(), Value::String(goal.to_string()));
2268 if let Some(token_budget) = plan.token_budget {
2269 let mut budget = obj.get("budget").cloned().unwrap_or_else(|| json!({}));
2270 if let Some(budget_obj) = budget.as_object_mut() {
2271 budget_obj.insert("max_tokens".to_string(), json!(token_budget));
2272 }
2273 obj.insert("budget".to_string(), budget);
2274 }
2275 }
2276 let wrapped = format!("workflow({});", object);
2277 return compile_javascript_workflow("<structured plan>", &wrapped).map_err(|err| {
2278 ToolError::invalid_input(format!("Invalid structured Workflow plan nodes: {err}"))
2279 });
2280 }
2281
2282 let default_mode = plan_risk_to_mode(plan.risk.as_deref())?;
2283 let mut nodes = Vec::new();
2284
2285 if !plan.phases.is_empty() {
2286 for (phase_index, phase) in plan.phases.iter().enumerate() {
2287 let phase_id = phase
2288 .id
2289 .as_deref()
2290 .or(phase.title.as_deref())
2291 .map(str::trim)
2292 .filter(|id| !id.is_empty())
2293 .map(str::to_string)
2294 .unwrap_or_else(|| format!("phase-{}", phase_index + 1));
2295 let children = plan_children_to_leaves(
2296 &phase.children,
2297 default_mode,
2298 plan.token_budget,
2299 &phase_id,
2300 )?;
2301 if children.is_empty() {
2302 return Err(ToolError::invalid_input(format!(
2303 "Workflow plan phase '{phase_id}' must declare at least one child"
2304 )));
2305 }
2306 let parallel = phase.parallel.unwrap_or(children.len() > 1);
2307 if parallel && children.len() > 1 {
2308 nodes.push(WorkflowNode::BranchSet(BranchSpec {
2309 id: phase_id,
2310 description: phase.title.clone(),
2311 parallel: true,
2312 budget: BudgetSpec {
2313 max_tokens: plan.token_budget,
2314 ..BudgetSpec::default()
2315 },
2316 permissions: Default::default(),
2317 model_policy: Default::default(),
2318 children: children.into_iter().map(WorkflowNode::Leaf).collect(),
2319 }));
2320 } else if children.len() == 1 {
2321 nodes.push(WorkflowNode::Leaf(
2322 children.into_iter().next().expect("one child"),
2323 ));
2324 } else {
2325 nodes.push(WorkflowNode::Sequence(SequenceSpec {
2326 id: phase_id,
2327 children: children.into_iter().map(WorkflowNode::Leaf).collect(),
2328 }));
2329 }
2330 }
2331 } else if !plan.children.is_empty() {
2332 let children =
2333 plan_children_to_leaves(&plan.children, default_mode, plan.token_budget, "plan")?;
2334 if children.len() == 1 {
2335 nodes.push(WorkflowNode::Leaf(
2336 children.into_iter().next().expect("one child"),
2337 ));
2338 } else {
2339 nodes.push(WorkflowNode::BranchSet(BranchSpec {
2340 id: "plan".to_string(),
2341 description: Some(goal.to_string()),
2342 parallel: true,
2343 budget: BudgetSpec {
2344 max_tokens: plan.token_budget,
2345 ..BudgetSpec::default()
2346 },
2347 permissions: Default::default(),
2348 model_policy: Default::default(),
2349 children: children.into_iter().map(WorkflowNode::Leaf).collect(),
2350 }));
2351 }
2352 } else {
2353 return Err(ToolError::invalid_input(
2354 "Workflow plan must include phases, children, or nodes",
2355 ));
2356 }
2357
2358 let mut total_children = 0usize;
2359 count_plan_leaves(&nodes, &mut total_children);
2360 if let Some(max_children) = plan.max_children
2361 && total_children > max_children
2362 {
2363 return Err(ToolError::invalid_input(format!(
2364 "Workflow plan declares {total_children} children which exceeds max_children={max_children}"
2365 )));
2366 }
2367
2368 Ok(WorkflowSpec {
2369 id: None,
2370 goal: goal.to_string(),
2371 description: plan.risk.clone(),
2372 budget: BudgetSpec {
2373 max_tokens: plan.token_budget,
2374 ..BudgetSpec::default()
2375 },
2376 permissions: Default::default(),
2377 model_policy: Default::default(),
2378 promotion_policy: Default::default(),
2379 gates: plan.gates,
2380 nodes,
2381 })
2382 }
2383
2384 fn plan_risk_to_mode(risk: Option<&str>) -> Result<TaskMode, ToolError> {
2385 match risk.map(str::trim).filter(|s| !s.is_empty()) {
2386 None | Some("read_only") | Some("readonly") | Some("low") | Some("safe") => {
2387 Ok(TaskMode::ReadOnly)
2388 }
2389 Some("writes") | Some("write") | Some("read_write") | Some("readwrite")
2390 | Some("medium") => Ok(TaskMode::ReadWrite),
2391 Some("elevated") | Some("high") | Some("shell") | Some("network") => {
2392 // Elevated risk still launches as read_write; approval gates (#4126)
2393 // consume the risk string via plan description.
2394 Ok(TaskMode::ReadWrite)
2395 }
2396 Some(other) => Err(ToolError::invalid_input(format!(
2397 "Invalid plan risk '{other}'. Use read_only, writes, or elevated."
2398 ))),
2399 }
2400 }
2401
2402 fn plan_children_to_leaves(
2403 children: &[StructuredPlanChild],
2404 default_mode: TaskMode,
2405 token_budget: Option<u64>,
2406 phase_id: &str,
2407 ) -> Result<Vec<LeafSpec>, ToolError> {
2408 if children.is_empty() {
2409 return Ok(Vec::new());
2410 }
2411 let mut leaves = Vec::with_capacity(children.len());
2412 for (index, child) in children.iter().enumerate() {
2413 let prompt = child.prompt.trim();
2414 if prompt.is_empty() {
2415 return Err(ToolError::invalid_input(format!(
2416 "Workflow plan child {} in phase '{phase_id}' must have a non-empty prompt",
2417 index + 1
2418 )));
2419 }
2420 let id = child
2421 .id
2422 .as_deref()
2423 .or(child.label.as_deref())
2424 .map(str::trim)
2425 .filter(|id| !id.is_empty())
2426 .map(str::to_string)
2427 .unwrap_or_else(|| format!("{phase_id}-child-{}", index + 1));
2428 let agent_type = parse_plan_agent_type(child.agent_type.as_deref())?;
2429 let mode = match child.mode.as_deref().map(str::trim) {
2430 None | Some("") => default_mode,
2431 Some("read_only") | Some("readonly") => TaskMode::ReadOnly,
2432 Some("read_write") | Some("readwrite") | Some("writes") | Some("write") => {
2433 TaskMode::ReadWrite
2434 }
2435 Some(other) => {
2436 return Err(ToolError::invalid_input(format!(
2437 "Invalid plan child mode '{other}' on '{id}'. Use read_only or read_write."
2438 )));
2439 }
2440 };
2441 let role = child
2442 .role
2443 .as_deref()
2444 .map(str::trim)
2445 .filter(|r| !r.is_empty())
2446 .map(|r| r.to_ascii_lowercase());
2447 let profile = child
2448 .profile
2449 .as_deref()
2450 .map(str::trim)
2451 .filter(|p| !p.is_empty())
2452 .map(|p| p.to_ascii_lowercase());
2453 leaves.push(LeafSpec {
2454 id,
2455 prompt: prompt.to_string(),
2456 agent_type,
2457 role,
2458 profile,
2459 mode,
2460 isolation: Default::default(),
2461 file_scope: child.file_scope.clone(),
2462 depends_on_results: Vec::new(),
2463 budget: BudgetSpec {
2464 max_tokens: token_budget,
2465 ..BudgetSpec::default()
2466 },
2467 permissions: Default::default(),
2468 model_policy: Default::default(),
2469 });
2470 }
2471 Ok(leaves)
2472 }
2473
2474 /// Plan-child `type` vocabulary. Accepts the Agent tool's canonical types and
2475 /// legacy aliases so the same option value works for direct Agent dispatch and
2476 /// for Workflow plan children (#5035), normalized onto the workflow IR schema.
2477 /// Rejections use the Agent tool's error contract ("Invalid sub-agent type
2478 /// `'<value>'. Use: ...`") with field-specific guidance.
2479 fn parse_plan_agent_type(raw: Option<&str>) -> Result<AgentType, ToolError> {
2480 let Some(kind) = raw.map(str::trim).filter(|s| !s.is_empty()) else {
2481 return Ok(AgentType::General);
2482 };
2483 match kind.to_ascii_lowercase().as_str() {
2484 "general" | "worker" | "delegate" => Ok(AgentType::General),
2485 "explore" | "explorer" | "scout" => Ok(AgentType::Explore),
2486 "plan" | "planner" | "awaiter" => Ok(AgentType::Plan),
2487 // Consultant/oracle/advisor are the Agent tool's read-only advisory
2488 // roles; Review is the workflow IR's read-only advisory posture.
2489 "review" | "reviewer" | "consultant" | "oracle" | "advisor" => Ok(AgentType::Review),
2490 "implementer" | "implement" | "builder" => Ok(AgentType::Implementer),
2491 "verifier" | "verify" => Ok(AgentType::Verifier),
2492 "custom" => Err(ToolError::invalid_input(
2493 "Invalid sub-agent type 'custom' for a Workflow plan child: custom requires an \
2494 explicit allowed_tools list, which plan children cannot declare. Use role/profile \
2495 or another type.",
2496 )),
2497 _ => Err(ToolError::invalid_input(format!(
2498 "Invalid sub-agent type '{kind}'. Use: worker, scout, planner, reviewer, builder, \
2499 verifier (legacy aliases remain accepted: general, explore/explorer, plan/awaiter, \
2500 review, implementer, consultant/oracle/advisor)."
2501 ))),
2502 }
2503 }
2504
2505 fn count_plan_leaves(nodes: &[WorkflowNode], total: &mut usize) {
2506 for node in nodes {
2507 match node {
2508 WorkflowNode::Leaf(_) => *total += 1,
2509 WorkflowNode::BranchSet(spec) => count_plan_leaves(&spec.children, total),
2510 WorkflowNode::Sequence(spec) => count_plan_leaves(&spec.children, total),
2511 WorkflowNode::Reduce(_)
2512 | WorkflowNode::TeacherReview(_)
2513 | WorkflowNode::LoopUntil(_)
2514 | WorkflowNode::Cond(_)
2515 | WorkflowNode::Expand(_) => {}
2516 }
2517 }
2518 }
2519
2520 fn read_workflow_source_path(
2521 path: &str,
2522 context: &ToolContext,
2523 ) -> Result<WorkflowSource, ToolError> {
2524 let raw = Path::new(path);
2525 let joined = if raw.is_absolute() {
2526 raw.to_path_buf()
2527 } else {
2528 context.workspace.join(raw)
2529 };
2530 let canonical = joined.canonicalize().map_err(|err| {
2531 ToolError::invalid_input(format!(
2532 "Failed to resolve workflow source_path '{path}': {err}"
2533 ))
2534 })?;
2535 if !context.trust_mode {
2536 let workspace = context
2537 .workspace
2538 .canonicalize()
2539 .unwrap_or_else(|_| context.workspace.clone());
2540 // The user-global saved-workflow store is a first-class source
2541 // alongside the workspace: `~/.codewhale/workflows/*.workflow.js`
2542 // definitions surface as slash commands and must launch from any
2543 // workspace without trust_mode.
2544 let home_store = crate::config::effective_home_dir()
2545 .map(|home| home.join(".codewhale").join("workflows"))
2546 .and_then(|dir| dir.canonicalize().ok());
2547 let inside_home_store = home_store
2548 .as_deref()
2549 .is_some_and(|dir| canonical.starts_with(dir));
2550 if !canonical.starts_with(&workspace) && !inside_home_store {
2551 return Err(ToolError::permission_denied(format!(
2552 "workflow source_path must stay inside the workspace or ~/.codewhale/workflows: {}",
2553 canonical.display()
2554 )));
2555 }
2556 }
2557 let source = std::fs::read_to_string(&canonical).map_err(|err| {
2558 ToolError::execution_failed(format!(
2559 "Failed to read workflow source_path '{}': {err}",
2560 canonical.display()
2561 ))
2562 })?;
2563 workflow_source_from_raw(source, Some(canonical))
2564 }
2565
2566 fn workflow_source_from_raw(
2567 source: String,
2568 path: Option<PathBuf>,
2569 ) -> Result<WorkflowSource, ToolError> {
2570 let adapted = adapt_workflow_source(&source, path.as_deref())?;
2571 Ok(WorkflowSource {
2572 source: adapted.source,
2573 path,
2574 spec: adapted.spec,
2575 })
2576 }
2577
2578 struct AdaptedWorkflowSource {
2579 source: String,
2580 spec: Option<WorkflowSpec>,
2581 }
2582
2583 fn adapt_workflow_source(
2584 source: &str,
2585 path: Option<&Path>,
2586 ) -> Result<AdaptedWorkflowSource, ToolError> {
2587 if !looks_like_declarative_workflow(source) {
2588 return Ok(AdaptedWorkflowSource {
2589 source: source.to_string(),
2590 spec: None,
2591 });
2592 }
2593
2594 let identifier = path
2595 .map(|path| path.display().to_string())
2596 .unwrap_or_else(|| "<inline workflow>".to_string());
2597 let extension = path
2598 .and_then(Path::extension)
2599 .and_then(|extension| extension.to_str())
2600 .unwrap_or_default();
2601 let spec = if extension.eq_ignore_ascii_case("ts") {
2602 compile_typescript_workflow(&identifier, source)
2603 } else {
2604 compile_javascript_workflow(&identifier, source)
2605 }
2606 .map_err(|err| {
2607 ToolError::invalid_input(format!(
2608 "Failed to compile declarative Workflow source '{identifier}': {err}"
2609 ))
2610 })?;
2611
2612 let lowered = lower_declarative_workflow_to_imperative_js(&spec)?;
2613 Ok(AdaptedWorkflowSource {
2614 source: lowered,
2615 spec: Some(spec),
2616 })
2617 }
2618
2619 fn looks_like_declarative_workflow(source: &str) -> bool {
2620 // Match a top-level `workflow(...)` / `export default workflow(...)` call on
2621 // any line, ignoring leading indentation, so an indented (non-column-0)
2622 // declarative call is still recognized rather than misrun as an imperative
2623 // script (#dogfood 0.8.67).
2624 source.lines().any(|line| {
2625 let trimmed = line.trim_start();
2626 trimmed.starts_with("workflow(") || trimmed.starts_with("export default workflow(")
2627 })
2628 }
2629
2630 fn lower_declarative_workflow_to_imperative_js(spec: &WorkflowSpec) -> Result<String, ToolError> {
2631 let mut lowerer = DeclarativeWorkflowLowerer::default();
2632 lowerer.line("\"use strict\";");
2633 lowerer.line("const __results = {};");
2634 lowerer.line(format!(
2635 "phase({});",
2636 js_string(&format!("workflow: {}", spec.goal))
2637 ));
2638 for node in &spec.nodes {
2639 lowerer.lower_node(node, None)?;
2640 }
2641 lowerer.line("return __results;");
2642 Ok(lowerer.finish())
2643 }
2644
2645 #[derive(Default)]
2646 struct DeclarativeWorkflowLowerer {
2647 source: String,
2648 next_var: usize,
2649 }
2650
2651 impl DeclarativeWorkflowLowerer {
2652 fn finish(self) -> String {
2653 self.source
2654 }
2655
2656 fn line(&mut self, line: impl AsRef<str>) {
2657 self.source.push_str(line.as_ref());
2658 self.source.push('\n');
2659 }
2660
2661 fn next_temp(&mut self, prefix: &str) -> String {
2662 let value = format!("__{prefix}_{}", self.next_var);
2663 self.next_var += 1;
2664 value
2665 }
2666
2667 fn lower_node(&mut self, node: &WorkflowNode, phase: Option<&str>) -> Result<(), ToolError> {
2668 match node {
2669 WorkflowNode::Leaf(spec) => self.lower_leaf(spec, phase, /* parallel */ false),
2670 WorkflowNode::BranchSet(spec) => self.lower_branch(spec),
2671 WorkflowNode::Sequence(spec) => self.lower_sequence(spec),
2672 WorkflowNode::Reduce(spec) => self.lower_reduce(spec),
2673 WorkflowNode::TeacherReview(_) => Err(unsupported_declarative_node("teacher_review")),
2674 WorkflowNode::LoopUntil(_) => Err(unsupported_declarative_node("loop_until")),
2675 WorkflowNode::Cond(_) => Err(unsupported_declarative_node("cond")),
2676 WorkflowNode::Expand(_) => Err(unsupported_declarative_node("expand")),
2677 }
2678 }
2679
2680 fn lower_leaf(
2681 &mut self,
2682 spec: &LeafSpec,
2683 phase: Option<&str>,
2684 parallel: bool,
2685 ) -> Result<(), ToolError> {
2686 self.line(format!(
2687 "__results[{}] = await task({});",
2688 js_string(&spec.id),
2689 leaf_task_options_expression(spec, phase, parallel)?
2690 ));
2691 Ok(())
2692 }
2693
2694 fn lower_branch(&mut self, spec: &BranchSpec) -> Result<(), ToolError> {
2695 self.line(format!("phase({});", js_string(&spec.id)));
2696 if spec.parallel {
2697 let mut leaves = Vec::new();
2698 for child in &spec.children {
2699 let WorkflowNode::Leaf(leaf) = child else {
2700 return Err(ToolError::invalid_input(format!(
2701 "Declarative Workflow adapter only supports leaf children inside parallel branch '{}'",
2702 spec.id
2703 )));
2704 };
2705 leaves.push(leaf);
2706 }
2707 // #4124: use Workflow `parallel()` (all-settled / partial success)
2708 // instead of raw Promise.all, which aborts siblings on first failure.
2709 let temp = self.next_temp("parallel");
2710 self.line(format!("const {temp} = await parallel(["));
2711 for leaf in &leaves {
2712 // Parallel write-capable children default to worktree isolation
2713 // (#4120) unless the plan explicitly sets isolation: shared.
2714 self.line(format!(
2715 " () => task({}),",
2716 leaf_task_options_expression(leaf, Some(&spec.id), /* parallel */ true)?
2717 ));
2718 }
2719 self.line("]);");
2720 for (index, leaf) in leaves.iter().enumerate() {
2721 self.line(format!(
2722 "__results[{}] = {temp}[{index}];",
2723 js_string(&leaf.id)
2724 ));
2725 }
2726 return Ok(());
2727 }
2728
2729 for child in &spec.children {
2730 self.lower_node(child, Some(&spec.id))?;
2731 }
2732 Ok(())
2733 }
2734
2735 fn lower_sequence(&mut self, spec: &SequenceSpec) -> Result<(), ToolError> {
2736 self.line(format!("phase({});", js_string(&spec.id)));
2737 for child in &spec.children {
2738 self.lower_node(child, Some(&spec.id))?;
2739 }
2740 Ok(())
2741 }
2742
2743 fn lower_reduce(&mut self, spec: &ReduceSpec) -> Result<(), ToolError> {
2744 let inputs = result_inputs_expression(&spec.inputs);
2745 self.line(format!(
2746 "__results[{}] = await task({});",
2747 js_string(&spec.id),
2748 task_options_expression(
2749 format!(
2750 "{} + \"\\n\\nInputs:\\n\" + {inputs}",
2751 js_string(&spec.prompt)
2752 ),
2753 Some("plan"),
2754 None,
2755 None,
2756 false,
2757 None,
2758 None,
2759 None,
2760 Some("read_only"),
2761 &[],
2762 &spec.id,
2763 Some("reduce"),
2764 None,
2765 )
2766 ));
2767 Ok(())
2768 }
2769 }
2770
2771 fn unsupported_declarative_node(kind: &str) -> ToolError {
2772 ToolError::invalid_input(format!(
2773 "Declarative Workflow adapter does not yet support {kind} nodes"
2774 ))
2775 }
2776
2777 fn leaf_description(spec: &LeafSpec) -> String {
2778 let mut description = spec.prompt.trim().to_string();
2779 let mut metadata = Vec::new();
2780 metadata.push(format!("Workflow leaf id: {}", spec.id));
2781 metadata.push(format!("Mode: {}", task_mode_name(spec.mode)));
2782 if !spec.file_scope.is_empty() {
2783 metadata.push(format!("File scope: {}", spec.file_scope.join(", ")));
2784 }
2785 if !spec.depends_on_results.is_empty() {
2786 metadata.push(format!(
2787 "Depends on results: {}",
2788 spec.depends_on_results.join(", ")
2789 ));
2790 }
2791 if spec.budget != BudgetSpec::default() {
2792 let mut budget = Vec::new();
2793 if let Some(max_steps) = spec.budget.max_steps {
2794 budget.push(format!("max_steps={max_steps}"));
2795 }
2796 if let Some(timeout_secs) = spec.budget.timeout_secs {
2797 budget.push(format!("timeout_secs={timeout_secs}"));
2798 }
2799 if let Some(max_parallel) = spec.budget.max_parallel {
2800 budget.push(format!("max_parallel={max_parallel}"));
2801 }
2802 if let Some(max_tokens) = spec.budget.max_tokens {
2803 budget.push(format!("max_tokens={max_tokens}"));
2804 }
2805 if !budget.is_empty() {
2806 metadata.push(format!("Budget: {}", budget.join(", ")));
2807 }
2808 }
2809 if !metadata.is_empty() {
2810 description.push_str("\n\nWorkflow metadata:\n");
2811 for item in metadata {
2812 description.push_str("- ");
2813 description.push_str(&item);
2814 description.push('\n');
2815 }
2816 }
2817 description
2818 }
2819
2820 fn leaf_task_options_expression(
2821 spec: &LeafSpec,
2822 phase: Option<&str>,
2823 parallel: bool,
2824 ) -> Result<String, ToolError> {
2825 validate_leaf_runtime_contract(spec)?;
2826 let worktree = leaf_wants_worktree(spec, parallel);
2827 let write_authority = match spec.mode {
2828 TaskMode::ReadOnly => "read_only",
2829 TaskMode::ReadWrite if worktree => "worktree_write",
2830 TaskMode::ReadWrite => "workspace_write",
2831 };
2832 let write_roots = if spec.mode == TaskMode::ReadWrite {
2833 spec.file_scope
2834 .iter()
2835 .map(|scope| codewhale_workflow::normalize_file_scope_root(scope))
2836 .collect::<Vec<_>>()
2837 } else {
2838 Vec::new()
2839 };
2840 Ok(task_options_expression(
2841 leaf_description_expression(spec),
2842 leaf_subagent_type(spec),
2843 spec.role.as_deref(),
2844 spec.profile.as_deref(),
2845 // Parallel write-capable children default to worktree isolation (#4120).
2846 // Explicit isolation: shared is the approved same-worktree override.
2847 worktree,
2848 spec.budget.max_tokens,
2849 spec.budget.max_steps,
2850 spec.budget.timeout_secs,
2851 Some(write_authority),
2852 &write_roots,
2853 &spec.id,
2854 phase,
2855 leaf_allowed_tools(spec)?,
2856 ))
2857 }
2858
2859 fn validate_leaf_runtime_contract(spec: &LeafSpec) -> Result<(), ToolError> {
2860 if spec.mode == TaskMode::ReadOnly && spec.permissions.allow_write {
2861 return Err(ToolError::invalid_input(format!(
2862 "Workflow leaf '{}' is read_only but requests allow_write permissions",
2863 spec.id
2864 )));
2865 }
2866 if spec.mode == TaskMode::ReadWrite && spec.file_scope.is_empty() {
2867 return Err(ToolError::invalid_input(format!(
2868 "Workflow leaf '{}' is read_write but declares no file_scope for its bounded write claim",
2869 spec.id
2870 )));
2871 }
2872 for scope in &spec.file_scope {
2873 let normalized = codewhale_workflow::normalize_file_scope_root(scope);
2874 if normalized.is_empty() || normalized.contains('*') {
2875 return Err(ToolError::invalid_input(format!(
2876 "Workflow leaf '{}' has unsupported file_scope '{}'; use a concrete path or a trailing /* or /** directory scope",
2877 spec.id, scope
2878 )));
2879 }
2880 }
2881 // A Fleet role and its authority posture are independent. In particular,
2882 // acceptance workflows must be able to resolve the `implementer` role to
2883 // its saved profile while narrowing that child to the read-only tool set.
2884 // `leaf_allowed_tools` enforces the mode below; rejecting the combination
2885 // made verification-only role/gate dogfood impossible.
2886 if spec.mode == TaskMode::ReadWrite
2887 && matches!(
2888 spec.agent_type,
2889 AgentType::Explore | AgentType::Plan | AgentType::Review | AgentType::Verifier
2890 )
2891 {
2892 return Err(ToolError::invalid_input(format!(
2893 "Workflow leaf '{}' is read_write but uses read-only agent_type {}",
2894 spec.id,
2895 agent_type_name(spec.agent_type)
2896 )));
2897 }
2898 if spec.mode == TaskMode::ReadOnly
2899 && spec
2900 .permissions
2901 .allowed_tools
2902 .iter()
2903 .any(|tool| is_write_or_shell_tool(tool))
2904 {
2905 return Err(ToolError::invalid_input(format!(
2906 "Workflow leaf '{}' is read_only but requests write/shell allowed_tools",
2907 spec.id
2908 )));
2909 }
2910 if spec.permissions.deny_all_tools && !spec.permissions.allowed_tools.is_empty() {
2911 return Err(ToolError::invalid_input(format!(
2912 "Workflow leaf '{}' cannot combine deny_all_tools with allowed_tools",
2913 spec.id
2914 )));
2915 }
2916 Ok(())
2917 }
2918
2919 fn leaf_description_expression(spec: &LeafSpec) -> String {
2920 let description = js_string(&leaf_description(spec));
2921 if spec.depends_on_results.is_empty() {
2922 return description;
2923 }
2924 let inputs = result_inputs_expression(&spec.depends_on_results);
2925 format!("{description} + \"\\n\\nInputs:\\n\" + {inputs}")
2926 }
2927
2928 fn result_inputs_expression(inputs: &[String]) -> String {
2929 let entries = inputs
2930 .iter()
2931 .map(|input| format!("[{}, __results[{}]]", js_string(input), js_string(input)))
2932 .collect::<Vec<_>>()
2933 .join(", ");
2934 format!(
2935 "[{entries}].map(([id, value]) => \"--- \" + id + \" ---\\n\" + String(value ?? \"\")).join(\"\\n\\n\")"
2936 )
2937 }
2938
2939 fn leaf_subagent_type(spec: &LeafSpec) -> Option<&'static str> {
2940 // A named Fleet profile owns the child's runtime type. Emitting the IR's
2941 // default `general` here makes role-only leaves look like an explicit type
2942 // override and can conflict with the resolved roster member (for example,
2943 // scout -> explore). Preserve non-General types because those represent an
2944 // authored override and the spawn path must still validate compatibility.
2945 if (spec.role.is_some() || spec.profile.is_some()) && spec.agent_type == AgentType::General {
2946 return None;
2947 }
2948 if spec.mode == TaskMode::ReadOnly && spec.agent_type == AgentType::General {
2949 return Some("review");
2950 }
2951 // A read_only leaf must not *name* a write-capable type. `type` is a claim
2952 // about what the child can do, and claiming it while the leaf narrows the
2953 // child to read-only tools is the contradiction #5123 asks the spawn path
2954 // to reject. The leaf's role/profile already carries the identity roster
2955 // resolution needs, so drop the redundant type and let the role speak —
2956 // this is the `implementer` role narrowed to verification-only work that
2957 // `validate_leaf_runtime_contract` deliberately allows.
2958 if spec.mode == TaskMode::ReadOnly
2959 && spec.agent_type == AgentType::Implementer
2960 && (spec.role.is_some() || spec.profile.is_some())
2961 {
2962 return None;
2963 }
2964 Some(agent_type_name(spec.agent_type))
2965 }
2966
2967 fn leaf_allowed_tools(spec: &LeafSpec) -> Result<Option<Vec<String>>, ToolError> {
2968 if spec.permissions.deny_all_tools {
2969 return Ok(Some(Vec::new()));
2970 }
2971 if !spec.permissions.allowed_tools.is_empty() {
2972 return Ok(Some(spec.permissions.allowed_tools.clone()));
2973 }
2974 if spec.mode != TaskMode::ReadOnly {
2975 return Ok(None);
2976 }
2977 Ok(Some(
2978 read_only_allowed_tools(spec.agent_type)
2979 .iter()
2980 .map(|tool| (*tool).to_string())
2981 .collect(),
2982 ))
2983 }
2984
2985 fn read_only_allowed_tools(agent_type: AgentType) -> &'static [&'static str] {
2986 match agent_type {
2987 AgentType::Verifier => &["File"],
2988 _ => &["File"],
2989 }
2990 }
2991
2992 fn is_write_or_shell_tool(tool: &str) -> bool {
2993 // One list, owned by the workflow crate. This used to be a second copy
2994 // that drifted from `elevation.rs`'s — see `codewhale_workflow::is_write_tool`.
2995 codewhale_workflow::is_write_tool(tool) || codewhale_workflow::is_shell_tool(tool)
2996 }
2997
2998 // Pre-existing builder that grew `allowed_tools`; each arg maps 1:1 onto one
2999 // optional field of the generated JS options literal.
3000 #[allow(clippy::too_many_arguments)]
3001 fn task_options_expression(
3002 description_expr: String,
3003 subagent_type: Option<&str>,
3004 role: Option<&str>,
3005 profile: Option<&str>,
3006 worktree: bool,
3007 token_budget: Option<u64>,
3008 max_steps: Option<u32>,
3009 wall_time_secs: Option<u64>,
3010 write_authority: Option<&str>,
3011 write_roots: &[String],
3012 label: &str,
3013 phase: Option<&str>,
3014 allowed_tools: Option<Vec<String>>,
3015 ) -> String {
3016 let mut fields = vec![format!("description: {description_expr}")];
3017 if let Some(subagent_type) = subagent_type {
3018 fields.push(format!("type: {}", js_string(subagent_type)));
3019 }
3020 fields.push(format!("label: {}", js_string(label)));
3021 if let Some(phase) = phase {
3022 fields.push(format!("phase: {}", js_string(phase)));
3023 }
3024 if let Some(role) = role {
3025 fields.push(format!("role: {}", js_string(role)));
3026 }
3027 if let Some(profile) = profile {
3028 fields.push(format!("profile: {}", js_string(profile)));
3029 }
3030 if worktree {
3031 fields.push("worktree: true".to_string());
3032 }
3033 if let Some(token_budget) = token_budget {
3034 fields.push(format!("tokenBudget: {token_budget}"));
3035 }
3036 if let Some(max_steps) = max_steps {
3037 fields.push(format!("maxSteps: {max_steps}"));
3038 }
3039 if let Some(wall_time_secs) = wall_time_secs {
3040 fields.push(format!("wallTimeSecs: {wall_time_secs}"));
3041 }
3042 if let Some(write_authority) = write_authority {
3043 fields.push(format!("writeAuthority: {}", js_string(write_authority)));
3044 }
3045 if !write_roots.is_empty() {
3046 fields.push(format!(
3047 "writeRoots: {}",
3048 serde_json::to_string(write_roots).expect("serializing write roots cannot fail")
3049 ));
3050 }
3051 if let Some(allowed_tools) = allowed_tools {
3052 fields.push(format!(
3053 "allowedTools: {}",
3054 serde_json::to_string(&allowed_tools).expect("serializing tool names cannot fail")
3055 ));
3056 }
3057 format!("{{ {} }}", fields.join(", "))
3058 }
3059
3060 fn js_string(value: &str) -> String {
3061 serde_json::to_string(value).expect("serializing JS string cannot fail")
3062 }
3063
3064 fn agent_type_name(agent_type: AgentType) -> &'static str {
3065 match agent_type {
3066 AgentType::General => "general",
3067 AgentType::Explore => "explore",
3068 AgentType::Plan => "plan",
3069 AgentType::Review => "review",
3070 AgentType::Implementer => "implementer",
3071 AgentType::Verifier => "verifier",
3072 }
3073 }
3074
3075 fn task_mode_name(mode: TaskMode) -> &'static str {
3076 match mode {
3077 TaskMode::ReadOnly => "read_only",
3078 TaskMode::ReadWrite => "read_write",
3079 }
3080 }
3081
3082 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3083 enum ExplicitGateVerdict {
3084 Approve,
3085 Reject,
3086 }
3087
3088 /// Recognize only a standalone verdict token on the first non-empty line.
3089 ///
3090 /// This deliberately does not interpret prose, Markdown bullets, or verdict
3091 /// words later in an otherwise successful child response. Existing workflows
3092 /// whose children return ordinary prose therefore remain pass-on-success,
3093 /// while review-style children can fail closed with `BLOCK` or `FAIL`.
3094 fn explicit_gate_verdict(output: Option<&str>) -> Option<ExplicitGateVerdict> {
3095 let first_meaningful = output?
3096 .lines()
3097 .map(str::trim)
3098 .find(|line| !line.is_empty())?;
3099 if first_meaningful.eq_ignore_ascii_case("APPROVE")
3100 || first_meaningful.eq_ignore_ascii_case("PASS")
3101 {
3102 Some(ExplicitGateVerdict::Approve)
3103 } else if first_meaningful.eq_ignore_ascii_case("BLOCK")
3104 || first_meaningful.eq_ignore_ascii_case("FAIL")
3105 {
3106 Some(ExplicitGateVerdict::Reject)
3107 } else {
3108 None
3109 }
3110 }
3111
3112 fn has_gate_artifact_body(output: Option<&str>) -> bool {
3113 let Some(output) = output else {
3114 return false;
3115 };
3116 let mut meaningful_lines = output
3117 .lines()
3118 .map(str::trim)
3119 .filter(|line| !line.is_empty());
3120 // A declared artifact needs both a body label and at least one concrete
3121 // entry after the verdict. This keeps `APPROVE\nok` from promoting a
3122 // placeholder while remaining format-agnostic for arbitrary artifact kinds.
3123 meaningful_lines.next();
3124 meaningful_lines.next().is_some() && meaningful_lines.next().is_some()
3125 }
3126
3127 fn gate_outcome_for_completed_role(
3128 record: &RuntimeTaskRecord,
3129 require_explicit_verdict: bool,
3130 artifact_kind: Option<&str>,
3131 ) -> GateOutcome {
3132 match record.status {
3133 IrWorkflowRunStatus::Succeeded => match explicit_gate_verdict(record.output.as_deref()) {
3134 Some(ExplicitGateVerdict::Reject) => GateOutcome::Fail {
3135 reason: record
3136 .output
3137 .clone()
3138 .unwrap_or_else(|| "child returned an explicit rejection verdict".into()),
3139 },
3140 Some(ExplicitGateVerdict::Approve)
3141 if require_explicit_verdict
3142 && artifact_kind.is_some()
3143 && !has_gate_artifact_body(record.output.as_deref()) =>
3144 {
3145 GateOutcome::Fail {
3146 reason: format!(
3147 "task {} approved without the required {} artifact body",
3148 record.agent_id,
3149 artifact_kind.unwrap_or("gate")
3150 ),
3151 }
3152 }
3153 Some(ExplicitGateVerdict::Approve) => GateOutcome::Pass,
3154 None if require_explicit_verdict => GateOutcome::Fail {
3155 reason: format!(
3156 "task {} completed without the required first-line gate verdict; expected exactly APPROVE, PASS, BLOCK, or FAIL",
3157 record.agent_id
3158 ),
3159 },
3160 None => GateOutcome::Pass,
3161 },
3162 _ => GateOutcome::Fail {
3163 reason: record.output.clone().unwrap_or_else(|| {
3164 format!("task {} ended as {:?}", record.agent_id, record.status)
3165 }),
3166 },
3167 }
3168 }
3169
3170 #[derive(Debug, Clone)]
3171 struct RuntimeTaskRecord {
3172 agent_id: String,
3173 label: Option<String>,
3174 role: Option<String>,
3175 status: IrWorkflowRunStatus,
3176 output: Option<String>,
3177 schema_error: Option<String>,
3178 usage: Option<WorkflowTaskUsage>,
3179 }
3180
3181 struct SubAgentWorkflowDriver {
3182 run_id: String,
3183 manager: SharedSubAgentManager,
3184 runtime: SubAgentRuntime,
3185 state: Arc<WorkflowWorkspaceState>,
3186 completion_tx: mpsc::UnboundedSender<SubAgentCompletion>,
3187 completion_state: Arc<Mutex<CompletionState>>,
3188 child_ids: Arc<Mutex<Vec<String>>>,
3189 /// Monotonic 0-based child admission counter for `workflow_child_index`.
3190 child_counter: AtomicU32,
3191 /// Latest `phase(...)` title observed on this run (used when a task omits
3192 /// an explicit `phase` option).
3193 current_phase: Mutex<Option<String>>,
3194 task_records: Arc<Mutex<HashMap<String, RuntimeTaskRecord>>>,
3195 total_budget: Option<u64>,
3196 last_budget_event: Arc<Mutex<Option<BudgetSnapshot>>>,
3197 /// Workflow-owned gates installed for this run (#4179).
3198 gate_specs: Arc<Vec<GateSpec>>,
3199 /// Lane-scoped gate and handoff state keyed by run id.
3200 gate_board: Arc<Mutex<LaneGateBoard>>,
3201 /// Caps concurrently live `task()` children for this run (product: 16).
3202 concurrent_gate: Arc<Semaphore>,
3203 /// Held permits for in-flight children; released on completion/cancel.
3204 spawn_permits: Mutex<HashMap<String, OwnedSemaphorePermit>>,
3205 /// Optional named Fleet roster for resolving Workflow task roles (#4177/#4178).
3206 fleet_name: Option<String>,
3207 /// The Fleet this Workflow is bound to, frozen at start. For an exact
3208 /// fleet this holds the immutable snapshot every task launch reads from,
3209 /// which is why editing `fleets/<name>.toml` mid-run cannot move a route.
3210 fleet: WorkflowFleetBinding,
3211 }
3212
3213 impl SubAgentWorkflowDriver {
3214 #[allow(clippy::too_many_arguments)]
3215 fn new(
3216 run_id: String,
3217 manager: SharedSubAgentManager,
3218 runtime: SubAgentRuntime,
3219 state: Arc<WorkflowWorkspaceState>,
3220 total_budget: Option<u64>,
3221 fleet: WorkflowFleetBinding,
3222 gate_specs: Vec<GateSpec>,
3223 ) -> Arc<Self> {
3224 let fleet_name = fleet.name();
3225 let (completion_tx, completion_rx) = mpsc::unbounded_channel();
3226 let mut gate_board = LaneGateBoard::new(run_id.clone());
3227 gate_board.install_gates(&gate_specs);
3228 let driver = Arc::new(Self {
3229 run_id,
3230 manager,
3231 runtime,
3232 state,
3233 completion_tx,
3234 completion_state: Arc::new(Mutex::new(CompletionState::default())),
3235 child_ids: Arc::new(Mutex::new(Vec::new())),
3236 child_counter: AtomicU32::new(0),
3237 current_phase: Mutex::new(None),
3238 task_records: Arc::new(Mutex::new(HashMap::new())),
3239 total_budget,
3240 last_budget_event: Arc::new(Mutex::new(None)),
3241 gate_specs: Arc::new(gate_specs),
3242 gate_board: Arc::new(Mutex::new(gate_board)),
3243 concurrent_gate: Arc::new(Semaphore::new(WORKFLOW_MAX_CONCURRENT.max(1))),
3244 spawn_permits: Mutex::new(HashMap::new()),
3245 fleet_name,
3246 fleet,
3247 });
3248 spawn_completion_pump(driver.clone(), completion_rx);
3249 driver
3250 }
3251
3252 fn force_cancel_all(&self) {
3253 let ids = self
3254 .child_ids
3255 .lock()
3256 .map(|ids| ids.clone())
3257 .unwrap_or_default();
3258 if let Ok(mut permits) = self.spawn_permits.lock() {
3259 permits.clear();
3260 }
3261 cancel_child_agents(self.manager.clone(), ids);
3262 if let Ok(mut state) = self.completion_state.lock() {
3263 for (_, waiter) in state.waiters.drain() {
3264 let _ = waiter.send(TaskCompletion::Cancelled);
3265 }
3266 }
3267 }
3268
3269 fn finalize_running_tasks_cancelled(&self) {
3270 let ids = self
3271 .child_ids
3272 .lock()
3273 .map(|ids| ids.clone())
3274 .unwrap_or_default();
3275 for id in &ids {
3276 self.record_task_completion(id, &TaskCompletion::Cancelled, None);
3277 }
3278 }
3279
3280 fn record_child(&self, agent_id: &str) {
3281 if let Ok(mut ids) = self.child_ids.lock()
3282 && !ids.iter().any(|id| id == agent_id)
3283 {
3284 ids.push(agent_id.to_string());
3285 }
3286 if let Ok(mut runs) = self.state.runs.lock()
3287 && let Some(record) = runs.get_mut(&self.run_id)
3288 && !record.child_ids.iter().any(|id| id == agent_id)
3289 {
3290 record.child_ids.push(agent_id.to_string());
3291 }
3292 }
3293
3294 fn current_budget_snapshot(&self) -> BudgetSnapshot {
3295 let spent = self
3296 .manager
3297 .try_read()
3298 .ok()
3299 .map(|manager| manager.budget_spent_for_scope(&self.run_id))
3300 .unwrap_or(0);
3301 BudgetSnapshot {
3302 total: self.total_budget,
3303 spent,
3304 }
3305 }
3306
3307 /// Return the first authoritative gate failure after the VM has no more
3308 /// children to admit. Intermediate blocks already reject the downstream
3309 /// spawn; this final check gives a terminal role's BLOCK verdict the same
3310 /// fail-closed semantics.
3311 fn terminal_gate_failure(&self) -> Option<String> {
3312 let board = match self.gate_board.lock() {
3313 Ok(board) => board,
3314 Err(_) => {
3315 return Some(
3316 "workflow gate board was unavailable during terminal finalization".to_string(),
3317 );
3318 }
3319 };
3320 self.gate_specs.iter().find_map(|spec| {
3321 let state = board.gates.get(&spec.id)?;
3322 state.is_blocking().then(|| {
3323 format!(
3324 "workflow gate `{}` ended {}: {}",
3325 spec.id,
3326 state.as_str(),
3327 gate_state_reason(state)
3328 )
3329 })
3330 })
3331 }
3332
3333 fn record_run_event(&self, event: WorkflowUiEvent) {
3334 let recorded = if let Ok(mut runs) = self.state.runs.lock()
3335 && let Some(record) = runs.get_mut(&self.run_id)
3336 {
3337 record.push_event(event.clone());
3338 true
3339 } else {
3340 false
3341 };
3342 if recorded {
3343 self.state.record_event(&self.run_id, &event);
3344 }
3345 // #4122: stream typed events live into the panel + history card.
3346 self.emit_ui_event(&event);
3347 }
3348
3349 /// Publish a flattened WorkflowUiEvent on the engine event bus so the TUI
3350 /// can hydrate the panel while the tool is still running.
3351 fn emit_ui_event(&self, event: &WorkflowUiEvent) {
3352 let Some(tx) = self.runtime.event_tx.as_ref() else {
3353 return;
3354 };
3355 let Ok(mut value) = serde_json::to_value(event) else {
3356 return;
3357 };
3358 if let Some(obj) = value.as_object_mut() {
3359 obj.insert("run_id".to_string(), json!(self.run_id));
3360 }
3361 let _ = tx.try_send(Event::WorkflowUi {
3362 run_id: self.run_id.clone(),
3363 event: value,
3364 });
3365 }
3366
3367 fn record_budget_snapshot(&self, snapshot: BudgetSnapshot) {
3368 let changed = if let Ok(mut last) = self.last_budget_event.lock() {
3369 if last.as_ref() == Some(&snapshot) {
3370 false
3371 } else {
3372 *last = Some(snapshot);
3373 true
3374 }
3375 } else {
3376 false
3377 };
3378 let event = WorkflowUiEvent::new(budget_event_kind(snapshot));
3379 if changed {
3380 self.record_run_event(event);
3381 } else {
3382 // The VM polls the budget before it can admit its first child.
3383 // Keep that live path warm even when no token value changed, but
3384 // do not journal an unbounded stream of identical snapshots.
3385 self.emit_ui_event(&event);
3386 }
3387 }
3388
3389 fn prepare_request_for_gates(
3390 &self,
3391 request: &mut TaskRequest,
3392 ) -> Result<Vec<HandoffArtifact>, DriverError> {
3393 let Some(role) = request.role.as_deref().filter(|role| !role.is_empty()) else {
3394 return Ok(Vec::new());
3395 };
3396 if self.gate_specs.is_empty() {
3397 return Ok(Vec::new());
3398 }
3399
3400 let (blocked, handoffs) = {
3401 let mut board = self
3402 .gate_board
3403 .lock()
3404 .map_err(|_| DriverError::Rejected("workflow gate board lock poisoned".into()))?;
3405 let blocked = board.role_is_blocked(&self.gate_specs, role).cloned();
3406 // Handoffs are consumed (removed from the board) as they are
3407 // delivered — but only when the role is actually admitted. A
3408 // blocked task must leave them in place for the retry after the
3409 // gate clears.
3410 let handoffs = if blocked.is_none() {
3411 board.consume_handoffs_for(role, 4)
3412 } else {
3413 Vec::new()
3414 };
3415 (blocked, handoffs)
3416 };
3417
3418 if let Some(state) = blocked {
3419 return Err(DriverError::Rejected(format!(
3420 "workflow gate blocks role `{role}`: {}",
3421 gate_state_reason(&state)
3422 )));
3423 }
3424
3425 if !handoffs.is_empty() {
3426 append_handoff_context(request, &handoffs);
3427 }
3428 Ok(handoffs)
3429 }
3430
3431 fn update_gate_status(&self, status: Vec<GateStatusLine>) {
3432 let snapshot = if let Ok(mut runs) = self.state.runs.lock()
3433 && let Some(record) = runs.get_mut(&self.run_id)
3434 {
3435 record.gate_status = status;
3436 Some(record.clone())
3437 } else {
3438 None
3439 };
3440 if let Some(record) = snapshot {
3441 self.state.record_snapshot(&record);
3442 }
3443 }
3444
3445 fn evaluate_gates_for_completed_role(&self, record: &RuntimeTaskRecord) {
3446 let Some(role) = record.role.as_deref().filter(|role| !role.is_empty()) else {
3447 return;
3448 };
3449 if self.gate_specs.is_empty() {
3450 return;
3451 }
3452 let specs = self
3453 .gate_specs
3454 .iter()
3455 .filter(|spec| spec.on == GateOn::RoleComplete && spec.role.eq_ignore_ascii_case(role))
3456 .cloned()
3457 .collect::<Vec<_>>();
3458 if specs.is_empty() {
3459 return;
3460 }
3461
3462 let mut events = Vec::new();
3463 let mut next_status = Vec::new();
3464 if let Ok(mut board) = self.gate_board.lock() {
3465 for spec in specs {
3466 let outcome = gate_outcome_for_completed_role(
3467 record,
3468 spec.require_explicit_verdict,
3469 spec.artifact_kind.as_deref(),
3470 );
3471 let mut state = match board.evaluate(&spec, outcome.clone()) {
3472 Ok(state) => state,
3473 Err(err) => {
3474 let state = GateState::Blocked {
3475 reason: err.to_string(),
3476 };
3477 // Evaluation errors must become authoritative board state.
3478 // Otherwise the emitted receipt can say `blocked` while the
3479 // admission check still sees the gate as pending.
3480 board.gates.insert(spec.id.clone(), state.clone());
3481 state
3482 }
3483 };
3484 let mut promotion = None;
3485 if matches!(state, GateState::Passed)
3486 && let (Some(kind), Some(to_role)) =
3487 (spec.artifact_kind.as_deref(), spec.blocks_role.as_deref())
3488 {
3489 let artifact = HandoffArtifact {
3490 // Gate ids are authored input and are not guaranteed unique.
3491 // Use an opaque id so every promotion has a stable, distinct
3492 // identity even when a malformed workflow repeats a gate id.
3493 id: format!("handoff_{}", Uuid::new_v4()),
3494 lane_id: self.run_id.clone(),
3495 from_role: spec.role.clone(),
3496 to_role: to_role.to_string(),
3497 kind: kind.to_string(),
3498 payload: record.output.clone().unwrap_or_default(),
3499 created_at: now_ms().to_string(),
3500 };
3501 match board.record_handoff(artifact.clone()) {
3502 Ok(()) => {
3503 promotion =
3504 Some(WorkflowUiEvent::new(WorkflowUiEventKind::HandoffPromoted {
3505 artifact_id: artifact.id,
3506 gate_id: spec.id.clone(),
3507 kind: artifact.kind,
3508 from_role: artifact.from_role,
3509 to_role: artifact.to_role,
3510 producer_task_id: record.agent_id.clone(),
3511 }));
3512 }
3513 Err(err) => {
3514 state = GateState::Blocked {
3515 reason: format!(
3516 "gate passed but its handoff could not be recorded: {err}"
3517 ),
3518 };
3519 board.gates.insert(spec.id.clone(), state.clone());
3520 }
3521 }
3522 }
3523 events.push(WorkflowUiEvent::new(WorkflowUiEventKind::GateUpdated {
3524 gate_id: spec.id.clone(),
3525 role: spec.role.clone(),
3526 gate: gate_kind_label(spec.gate).to_string(),
3527 state: state.as_str().to_string(),
3528 blocked_role: spec.blocks_role.clone(),
3529 blocked_reason: state.blocked_reason().map(str::to_string),
3530 }));
3531 if let Some(event) = promotion {
3532 events.push(event);
3533 }
3534 }
3535 next_status = board.status_summary();
3536 }
3537 if !events.is_empty() || !next_status.is_empty() {
3538 self.update_gate_status(next_status);
3539 }
3540 for event in events {
3541 self.record_run_event(event);
3542 }
3543 }
3544
3545 fn record_task_started(
3546 &self,
3547 agent_id: &str,
3548 request: &TaskRequest,
3549 metadata: &WorkflowTaskSpawnMetadata,
3550 result: &crate::tools::subagent::SubAgentResult,
3551 fleet_receipt: Option<codewhale_workflow::FleetTaskReceipt>,
3552 ) {
3553 // Prefer typed spawn metadata over request fields so panel/history never
3554 // need to re-derive labels from the child prompt (#4119).
3555 let label = metadata
3556 .workflow_task_label
3557 .clone()
3558 .or_else(|| request.label.clone());
3559 self.record_run_event(WorkflowUiEvent::new(WorkflowUiEventKind::TaskStarted(
3560 Box::new(WorkflowTaskStartedEvent {
3561 task_id: agent_id.to_string(),
3562 label,
3563 role: request.role.clone(),
3564 profile: request.profile.clone(),
3565 model: request.model.clone(),
3566 strength: request.model_strength.clone(),
3567 thinking: request.thinking.clone(),
3568 // #4039: both sides of the reasoning receipt come from the
3569 // spawn metadata the runtime minted, never from the request or
3570 // from current session config.
3571 requested_reasoning: metadata
3572 .requested_reasoning
3573 .clone()
3574 .or_else(|| request.thinking.clone()),
3575 effective_reasoning: metadata.effective_reasoning.clone(),
3576 // Prefer spawn metadata (fleet-resolved); fall back to request.
3577 //
3578 // An exact-Fleet receipt overrides both, because the spawn
3579 // metadata's role is the roster profile's **posture** role —
3580 // the tool surface the clamped ceiling permits — and displaying
3581 // that where the member's role belongs silently renames the
3582 // operator's `auditor` to `scout`. The posture is not lost: it
3583 // rides the receipt as its own field.
3584 resolved_role: displayed_resolved_role(
3585 fleet_receipt.as_ref(),
3586 metadata.resolved_role.as_deref(),
3587 request.role.as_deref(),
3588 ),
3589 resolved_profile: metadata
3590 .resolved_profile
3591 .clone()
3592 .or_else(|| request.profile.clone()),
3593 resolved_provider: metadata.resolved_provider.clone(),
3594 resolved_model: metadata.resolved_model.clone(),
3595 route_source: metadata.route_source.clone(),
3596 worktree: request.worktree,
3597 workspace: result.workspace.clone(),
3598 git_branch: result.git_branch.clone(),
3599 parent_task_id: metadata.parent_task_id.clone(),
3600 depth: metadata.depth,
3601 workflow_run_id: metadata.workflow_run_id.clone(),
3602 workflow_phase_id: metadata.workflow_phase_id.clone(),
3603 workflow_task_label: metadata.workflow_task_label.clone(),
3604 workflow_child_index: metadata.workflow_child_index,
3605 fleet_receipt: fleet_receipt.clone(),
3606 }),
3607 )));
3608 // Also surface the decision as a run log line, so the receipt is
3609 // *visible* in the panel and transcript rather than only structured on
3610 // an event a UI has to know to unpack.
3611 if let Some(receipt) = fleet_receipt {
3612 self.record_run_event(WorkflowUiEvent::new(WorkflowUiEventKind::Log {
3613 message: format!("fleet route {}", receipt.line()),
3614 }));
3615 }
3616 }
3617
3618 /// Preserve a routing receipt whose task never became a child.
3619 ///
3620 /// A receipt normally rides the `task_started` event, which a failed spawn
3621 /// never emits. Recording it here keeps the run's history complete: the
3622 /// decision happened, the tokens were spent, and — if the Router ran on
3623 /// another provider — a bounded summary already left the host. Silence
3624 /// would make all three unrecoverable.
3625 fn record_orphaned_fleet_receipt(
3626 &self,
3627 receipt: &codewhale_workflow::FleetTaskReceipt,
3628 error: &str,
3629 ) {
3630 self.record_run_event(WorkflowUiEvent::new(WorkflowUiEventKind::Log {
3631 message: orphaned_fleet_receipt_line(receipt, error),
3632 }));
3633 }
3634
3635 fn record_task_request(&self, agent_id: &str, request: &TaskRequest) {
3636 if let Ok(mut records) = self.task_records.lock() {
3637 records.insert(
3638 agent_id.to_string(),
3639 RuntimeTaskRecord {
3640 agent_id: agent_id.to_string(),
3641 label: request.label.clone(),
3642 role: request.role.clone(),
3643 status: IrWorkflowRunStatus::Running,
3644 output: None,
3645 schema_error: None,
3646 usage: None,
3647 },
3648 );
3649 }
3650 let pending_completion = self
3651 .completion_state
3652 .lock()
3653 .ok()
3654 .and_then(|state| state.pending.get(agent_id).cloned());
3655 if let Some(completion) = pending_completion {
3656 self.record_task_completion(agent_id, &completion.completion, completion.usage);
3657 }
3658 }
3659
3660 fn record_task_completion(
3661 &self,
3662 agent_id: &str,
3663 completion: &TaskCompletion,
3664 usage: Option<WorkflowTaskUsage>,
3665 ) {
3666 let mut terminal_event = None;
3667 let mut completed_record = None;
3668 if let Ok(mut records) = self.task_records.lock()
3669 && let Some(record) = records.get_mut(agent_id)
3670 {
3671 let was_running = record.status == IrWorkflowRunStatus::Running;
3672 let (status, output) = task_completion_status(completion);
3673 record.status = status;
3674 record.output = output;
3675 if usage.is_some() {
3676 record.usage = usage;
3677 }
3678 if was_running {
3679 terminal_event = Some(WorkflowUiEvent::new(WorkflowUiEventKind::TaskCompleted {
3680 task_id: agent_id.to_string(),
3681 status,
3682 usage: record.usage.clone(),
3683 }));
3684 completed_record = Some(record.clone());
3685 }
3686 }
3687 if let Some(event) = terminal_event {
3688 self.record_run_event(event);
3689 }
3690 if let Some(record) = completed_record.as_ref() {
3691 // A role-complete gate is caused by this terminal transition, so its
3692 // durable task receipt must precede gate evaluation and promotion.
3693 self.evaluate_gates_for_completed_role(record);
3694 }
3695 }
3696
3697 /// A rejected dispatch never produces a child agent, and inside
3698 /// `parallel()` the JS throw collapses to a `null` slot; this ledger keeps
3699 /// the rejection visible on the run record and result payload (#5035).
3700 fn record_dispatch_failure(
3701 &self,
3702 label: Option<String>,
3703 phase: Option<String>,
3704 message: String,
3705 ) {
3706 let failure = WorkflowDispatchFailure {
3707 at_ms: now_ms(),
3708 label,
3709 phase,
3710 message,
3711 };
3712 let slot = failure
3713 .label
3714 .as_deref()
3715 .or(failure.phase.as_deref())
3716 .unwrap_or("task");
3717 let progress_line = format!("dispatch failed for {slot}: {}", failure.message);
3718 let ui_event = WorkflowUiEvent::new(WorkflowUiEventKind::TaskDispatchFailed {
3719 label: failure.label.clone(),
3720 phase: failure.phase.clone(),
3721 message: failure.message.clone(),
3722 });
3723 if let Ok(mut runs) = self.state.runs.lock()
3724 && let Some(record) = runs.get_mut(&self.run_id)
3725 {
3726 record.progress.push(progress_line.clone());
3727 record.push_event(ui_event.clone());
3728 record.dispatch_failures.push(failure);
3729 }
3730 self.state.record_progress(&self.run_id, &progress_line);
3731 self.state.record_event(&self.run_id, &ui_event);
3732 self.emit_ui_event(&ui_event);
3733 }
3734
3735 fn record_schema_validation_failure(&self, agent_id: &str, message: String) {
3736 if let Ok(mut records) = self.task_records.lock()
3737 && let Some(record) = records.get_mut(agent_id)
3738 {
3739 record.status = IrWorkflowRunStatus::Failed;
3740 record.schema_error = Some(message.clone());
3741 record.output = Some(message);
3742 }
3743 }
3744
3745 fn task_records_snapshot(&self) -> Vec<RuntimeTaskRecord> {
3746 self.task_records
3747 .lock()
3748 .map(|records| records.values().cloned().collect())
3749 .unwrap_or_default()
3750 }
3751
3752 fn add_waiter_or_complete(&self, agent_id: String, waiter: oneshot::Sender<TaskCompletion>) {
3753 let mut state = self
3754 .completion_state
3755 .lock()
3756 .unwrap_or_else(|poison| poison.into_inner());
3757 if let Some(completion) = state.pending.remove(&agent_id) {
3758 let _ = waiter.send(completion.completion);
3759 } else {
3760 state.waiters.insert(agent_id, waiter);
3761 }
3762 }
3763
3764 fn deliver_completion(
3765 &self,
3766 agent_id: String,
3767 completion: TaskCompletion,
3768 usage: Option<WorkflowTaskUsage>,
3769 ) {
3770 self.record_task_completion(&agent_id, &completion, usage.clone());
3771 if let Ok(mut permits) = self.spawn_permits.lock() {
3772 permits.remove(&agent_id);
3773 }
3774 let mut state = self
3775 .completion_state
3776 .lock()
3777 .unwrap_or_else(|poison| poison.into_inner());
3778 if let Some(waiter) = state.waiters.remove(&agent_id) {
3779 let _ = waiter.send(completion);
3780 } else {
3781 state
3782 .pending
3783 .insert(agent_id, PendingCompletion { completion, usage });
3784 }
3785 }
3786 }
3787
3788 #[derive(Clone)]
3789 struct PendingCompletion {
3790 completion: TaskCompletion,
3791 usage: Option<WorkflowTaskUsage>,
3792 }
3793
3794 #[derive(Default)]
3795 struct CompletionState {
3796 waiters: HashMap<String, oneshot::Sender<TaskCompletion>>,
3797 pending: HashMap<String, PendingCompletion>,
3798 }
3799
3800 impl SubAgentWorkflowDriver {
3801 /// The admission half of [`WorkflowDriver::spawn_task`]; every `Err` it
3802 /// returns is recorded as a dispatch failure by the trait wrapper.
3803 async fn spawn_task_admitted(
3804 &self,
3805 mut request: TaskRequest,
3806 ) -> Result<SpawnedTask, DriverError> {
3807 // Exact fleets resolve from the frozen snapshot; legacy role maps keep
3808 // their previous path unchanged.
3809 //
3810 // The exact path is deliberately split in two. **Binding** resolves the
3811 // member, its frozen route, and its clamped authority, and contacts
3812 // nobody. **Routing** — the half that may call the fleet's reasoning
3813 // router, spend the operator's tokens, and disclose a bounded summary
3814 // to another provider — happens only after this task has passed its
3815 // gates and holds a concurrency slot. A task that is rejected or
3816 // capacity-blocked therefore costs nothing and reveals nothing.
3817 let exact_binding = if let Some(operation) = self.fleet.exact() {
3818 // The depth budget is the other failure the spawn boundary can be
3819 // predicted to raise, and it does not depend on the member. Check
3820 // it here so an over-deep task is refused for free rather than
3821 // after a routing request has already been paid for.
3822 if self.runtime.would_exceed_depth() {
3823 return Err(DriverError::Rejected(format!(
3824 "fleet `{}`: sub-agent depth limit reached (depth {}, max {}); this task \
3825 cannot spawn a child, so it is refused before the reasoning router is asked \
3826 anything.",
3827 operation.snapshot().fleet().qualified(),
3828 self.runtime.spawn_depth,
3829 self.runtime.max_spawn_depth,
3830 )));
3831 }
3832 Some(bind_exact_fleet_task_request(
3833 operation,
3834 crate::fleet::exact::session_permission_ceiling(&self.runtime),
3835 &mut request,
3836 )?)
3837 } else {
3838 apply_named_fleet_to_task_request(self.fleet.legacy_roles(), &mut request).map_err(
3839 |err| {
3840 if let Some(fleet) = self.fleet_name.as_deref() {
3841 DriverError::Rejected(format!(
3842 "fleet `{fleet}` role resolution failed: {err}"
3843 ))
3844 } else {
3845 err
3846 }
3847 },
3848 )?;
3849 None
3850 };
3851 let consumed_handoffs = self.prepare_request_for_gates(&mut request)?;
3852 // Wait for a concurrent slot (max 16 live children per run).
3853 let permit = self
3854 .concurrent_gate
3855 .clone()
3856 .acquire_owned()
3857 .await
3858 .map_err(|_| DriverError::Rejected("workflow concurrent admission closed".into()))?;
3859
3860 // Admitted. Only now may the reasoning router be consulted.
3861 let fleet_receipt = match (self.fleet.exact(), exact_binding) {
3862 (Some(operation), Some(binding)) => {
3863 match route_admitted_exact_task(operation, &binding, &mut request).await {
3864 Ok(receipt) => Some(receipt),
3865 Err(err) => {
3866 drop(permit);
3867 return Err(err);
3868 }
3869 }
3870 }
3871 _ => None,
3872 };
3873
3874 let runtime = self
3875 .runtime
3876 .clone()
3877 .with_parent_completion_tx(self.completion_tx.clone());
3878 let request_record = request.clone();
3879 let workflow_child_index = self.child_counter.fetch_add(1, Ordering::SeqCst);
3880 let workflow_phase_id = request
3881 .phase
3882 .as_ref()
3883 .map(|phase| phase.trim())
3884 .filter(|phase| !phase.is_empty())
3885 .map(str::to_string)
3886 .or_else(|| {
3887 self.current_phase
3888 .lock()
3889 .ok()
3890 .and_then(|phase| phase.clone())
3891 });
3892 let workflow_task_label = request
3893 .label
3894 .as_ref()
3895 .map(|label| label.trim())
3896 .filter(|label| !label.is_empty())
3897 .map(str::to_string);
3898 let identity = WorkflowTaskSpawnIdentity {
3899 workflow_run_id: self.run_id.clone(),
3900 workflow_phase_id,
3901 workflow_task_label,
3902 workflow_child_index,
3903 // The Fleet decision travels to the spawn boundary as a value that
3904 // boundary re-checks, rather than as trust in the caller.
3905 fleet_authority_fingerprint: fleet_receipt
3906 .as_ref()
3907 .and_then(|receipt| receipt.authority_fingerprint.clone()),
3908 };
3909 let result =
3910 match spawn_workflow_task(request, self.manager.clone(), runtime, identity).await {
3911 Ok(result) => result,
3912 Err(err) => {
3913 drop(permit);
3914 // The Router decision was already made and already paid
3915 // for. Dropping the receipt with the failed spawn would
3916 // erase the only record that a routing request was spent —
3917 // and, when a bounded summary crossed to another provider,
3918 // the only disclosure that it did. It survives the failure.
3919 if let Some(receipt) = fleet_receipt {
3920 self.record_orphaned_fleet_receipt(&receipt, &err.to_string());
3921 }
3922 return Err(DriverError::Rejected(err.to_string()));
3923 }
3924 };
3925 let task_id = result.result.agent_id.clone();
3926 if let Ok(mut permits) = self.spawn_permits.lock() {
3927 permits.insert(task_id.clone(), permit);
3928 }
3929 self.record_child(&task_id);
3930 self.record_task_started(
3931 &task_id,
3932 &request_record,
3933 &result.metadata,
3934 &result.result,
3935 fleet_receipt,
3936 );
3937 for artifact in consumed_handoffs {
3938 self.record_run_event(WorkflowUiEvent::new(WorkflowUiEventKind::HandoffConsumed {
3939 artifact_id: artifact.id,
3940 kind: artifact.kind,
3941 from_role: artifact.from_role,
3942 to_role: artifact.to_role,
3943 consumer_task_id: task_id.clone(),
3944 }));
3945 }
3946 self.record_task_request(&task_id, &request_record);
3947 if let Some(limit) = self.total_budget {
3948 let mut manager = self.manager.write().await;
3949 manager.attach_shared_budget_scope(&task_id, &self.run_id, limit);
3950 }
3951 let (tx, rx) = oneshot::channel();
3952 self.add_waiter_or_complete(task_id.clone(), tx);
3953 Ok(SpawnedTask {
3954 task_id,
3955 completion: rx,
3956 })
3957 }
3958 }
3959
3960 #[async_trait]
3961 impl WorkflowDriver for SubAgentWorkflowDriver {
3962 async fn spawn_task(&self, request: TaskRequest) -> Result<SpawnedTask, DriverError> {
3963 let label = request
3964 .label
3965 .as_deref()
3966 .map(str::trim)
3967 .filter(|label| !label.is_empty())
3968 .map(str::to_string);
3969 let phase = request
3970 .phase
3971 .as_deref()
3972 .map(str::trim)
3973 .filter(|phase| !phase.is_empty())
3974 .map(str::to_string)
3975 .or_else(|| {
3976 self.current_phase
3977 .lock()
3978 .ok()
3979 .and_then(|phase| phase.clone())
3980 });
3981 let result = self.spawn_task_admitted(request).await;
3982 if let Err(err) = &result {
3983 self.record_dispatch_failure(label, phase, err.to_string());
3984 }
3985 result
3986 }
3987
3988 fn cancel_all(&self) {
3989 self.force_cancel_all();
3990 }
3991
3992 fn budget(&self) -> BudgetSnapshot {
3993 let snapshot = self.current_budget_snapshot();
3994 self.record_budget_snapshot(snapshot);
3995 snapshot
3996 }
3997
3998 fn progress(&self, event: ProgressEvent) {
3999 let mut schema_error = None;
4000 let (message, ui_event) = match event {
4001 // Pre-spawn rejections share the dispatch-failure ledger so the
4002 // completion classifier sees every requested slot, whether the VM
4003 // or the driver refused it.
4004 ProgressEvent::TaskRejected {
4005 label,
4006 phase,
4007 message,
4008 } => {
4009 self.record_dispatch_failure(label, phase, message);
4010 return;
4011 }
4012 ProgressEvent::Log { message } => (
4013 format!("log: {message}"),
4014 WorkflowUiEvent::new(WorkflowUiEventKind::Log { message }),
4015 ),
4016 ProgressEvent::Phase { title } => {
4017 if let Ok(mut current) = self.current_phase.lock() {
4018 *current = Some(title.clone());
4019 }
4020 (
4021 format!("phase: {title}"),
4022 WorkflowUiEvent::new(WorkflowUiEventKind::PhaseStarted { title }),
4023 )
4024 }
4025 ProgressEvent::TaskSchemaValidationFailed { task_id, message } => {
4026 self.record_schema_validation_failure(&task_id, message.clone());
4027 schema_error = Some(WorkflowSchemaError {
4028 task_id: task_id.clone(),
4029 message: message.clone(),
4030 });
4031 (
4032 format!("schema validation failed for {task_id}: {message}"),
4033 WorkflowUiEvent::new(WorkflowUiEventKind::TaskSchemaValidationFailed {
4034 task_id,
4035 message,
4036 }),
4037 )
4038 }
4039 };
4040 if let Ok(mut runs) = self.state.runs.lock()
4041 && let Some(record) = runs.get_mut(&self.run_id)
4042 {
4043 record.progress.push(message.clone());
4044 record.push_event(ui_event.clone());
4045 if let Some(schema_error) = schema_error {
4046 record.schema_errors.push(schema_error);
4047 }
4048 }
4049 self.state.record_progress(&self.run_id, &message);
4050 self.state.record_event(&self.run_id, &ui_event);
4051 // #4122: phase/schema/log progress streams into the live panel path.
4052 self.emit_ui_event(&ui_event);
4053 }
4054 }
4055
4056 fn budget_event_kind(snapshot: BudgetSnapshot) -> WorkflowUiEventKind {
4057 WorkflowUiEventKind::BudgetUpdated {
4058 total: snapshot.total,
4059 spent: snapshot.spent,
4060 remaining: snapshot.remaining(),
4061 }
4062 }
4063
4064 fn gate_kind_label(kind: GateKind) -> &'static str {
4065 match kind {
4066 GateKind::Verify => "verify",
4067 GateKind::Review => "review",
4068 GateKind::Approve => "approve",
4069 }
4070 }
4071
4072 fn gate_state_reason(state: &GateState) -> String {
4073 state
4074 .blocked_reason()
4075 .map(str::to_string)
4076 .unwrap_or_else(|| state.as_str().to_string())
4077 }
4078
4079 fn append_handoff_context(request: &mut TaskRequest, handoffs: &[HandoffArtifact]) {
4080 request
4081 .description
4082 .push_str("\n\nWorkflow handoff artifacts available for this role:\n");
4083 for artifact in handoffs {
4084 request.description.push_str(&format!(
4085 "- id: {} kind: {} from: {} to: {}\n payload: {}\n",
4086 artifact.id,
4087 artifact.kind,
4088 artifact.from_role,
4089 artifact.to_role,
4090 compact_handoff_payload(&artifact.payload, WORKFLOW_HANDOFF_MAX_CHARS)
4091 ));
4092 }
4093 }
4094
4095 fn compact_handoff_payload(payload: &str, max_chars: usize) -> String {
4096 let trimmed = payload.trim();
4097 if trimmed.chars().count() <= max_chars {
4098 return trimmed.to_string();
4099 }
4100 let mut out = trimmed.chars().take(max_chars).collect::<String>();
4101 out.push_str("...");
4102 out
4103 }
4104
4105 fn task_completion_status(completion: &TaskCompletion) -> (IrWorkflowRunStatus, Option<String>) {
4106 match completion {
4107 TaskCompletion::Completed { text } => (IrWorkflowRunStatus::Succeeded, Some(text.clone())),
4108 TaskCompletion::Failed { message } => (IrWorkflowRunStatus::Failed, Some(message.clone())),
4109 TaskCompletion::Cancelled => (IrWorkflowRunStatus::Cancelled, None),
4110 TaskCompletion::BudgetExhausted { message } => {
4111 (IrWorkflowRunStatus::BudgetExceeded, Some(message.clone()))
4112 }
4113 }
4114 }
4115
4116 /// Sum per-task telemetry into run-wide totals for `run_completed` (#2974).
4117 /// Returns `None` when no task contributed telemetry (e.g. a plan that ran
4118 /// zero children) so the event stays byte-identical to its pre-#2974 shape.
4119 fn run_usage_totals(records: &[RuntimeTaskRecord]) -> Option<WorkflowRunUsage> {
4120 let mut usages = records.iter().filter_map(|record| record.usage.as_ref());
4121 let mut totals = WorkflowRunUsage::from_task(usages.next()?);
4122 for usage in usages {
4123 totals.add_task(usage);
4124 }
4125 Some(totals)
4126 }
4127
4128 /// Convert captured task telemetry into the shared `WorkflowUsage` aggregate
4129 /// used by the workflow execution record (#2974).
4130 fn workflow_usage_from_task(usage: &WorkflowTaskUsage) -> WorkflowUsage {
4131 WorkflowUsage {
4132 input_tokens: usage.input_tokens,
4133 output_tokens: usage.output_tokens,
4134 cost_microusd: usage.cost_microusd,
4135 }
4136 }
4137
4138 fn execution_from_declarative_spec(
4139 spec: &WorkflowSpec,
4140 records: Vec<RuntimeTaskRecord>,
4141 terminal_status: WorkflowRunStatus,
4142 ) -> IrWorkflowExecution {
4143 let by_label = records
4144 .into_iter()
4145 .filter_map(|record| record.label.clone().map(|label| (label, record)))
4146 .collect::<HashMap<_, _>>();
4147 let mut execution = IrWorkflowExecution::default();
4148 for node in &spec.nodes {
4149 push_execution_node(node, &by_label, &mut execution);
4150 }
4151 let mut leaf_usage = execution.leaf_results.iter().map(|leaf| leaf.usage);
4152 execution.usage = leaf_usage
4153 .next()
4154 .map_or_else(WorkflowUsage::default, |first| {
4155 leaf_usage.fold(first, |mut totals, usage| {
4156 totals.input_tokens = sum_optional_usage(totals.input_tokens, usage.input_tokens);
4157 totals.output_tokens =
4158 sum_optional_usage(totals.output_tokens, usage.output_tokens);
4159 totals.cost_microusd =
4160 sum_optional_usage(totals.cost_microusd, usage.cost_microusd);
4161 totals
4162 })
4163 });
4164 match terminal_status {
4165 WorkflowRunStatus::Completed | WorkflowRunStatus::Degraded => {}
4166 WorkflowRunStatus::Failed => mark_ir_status(&mut execution, IrWorkflowRunStatus::Failed),
4167 WorkflowRunStatus::Cancelled => {
4168 mark_ir_status(&mut execution, IrWorkflowRunStatus::Cancelled);
4169 }
4170 WorkflowRunStatus::Running => {
4171 execution.status = IrWorkflowRunStatus::Running;
4172 }
4173 }
4174 execution
4175 }
4176
4177 fn push_execution_node(
4178 node: &WorkflowNode,
4179 records: &HashMap<String, RuntimeTaskRecord>,
4180 execution: &mut IrWorkflowExecution,
4181 ) {
4182 match node {
4183 WorkflowNode::Leaf(spec) => push_leaf_execution(spec, records, execution),
4184 WorkflowNode::BranchSet(spec) => push_branch_execution(spec, records, execution),
4185 WorkflowNode::Sequence(spec) => push_sequence_execution(spec, records, execution),
4186 WorkflowNode::Reduce(spec) => push_control_execution(
4187 spec.id.as_str(),
4188 ControlNodeKind::Reduce,
4189 records.get(&spec.id),
4190 spec.inputs.clone(),
4191 Some(spec.prompt.clone()),
4192 execution,
4193 ),
4194 WorkflowNode::TeacherReview(spec) => push_control_execution(
4195 spec.id.as_str(),
4196 ControlNodeKind::TeacherReview,
4197 records.get(&spec.id),
4198 spec.candidates.clone(),
4199 Some("teacher review not lowered by the production adapter".to_string()),
4200 execution,
4201 ),
4202 WorkflowNode::LoopUntil(spec) => push_control_execution(
4203 spec.id.as_str(),
4204 ControlNodeKind::LoopUntil,
4205 records.get(&spec.id),
4206 spec.children.iter().map(declarative_node_id).collect(),
4207 Some("loop_until not lowered by the production adapter".to_string()),
4208 execution,
4209 ),
4210 WorkflowNode::Cond(spec) => push_control_execution(
4211 spec.id.as_str(),
4212 ControlNodeKind::Cond,
4213 records.get(&spec.id),
4214 spec.then_nodes
4215 .iter()
4216 .chain(spec.else_nodes.iter())
4217 .map(declarative_node_id)
4218 .collect(),
4219 Some("cond not lowered by the production adapter".to_string()),
4220 execution,
4221 ),
4222 WorkflowNode::Expand(spec) => push_control_execution(
4223 spec.id.as_str(),
4224 ControlNodeKind::Expand,
4225 records.get(&spec.id),
4226 Vec::new(),
4227 Some(format!("expand not lowered from {}", spec.source)),
4228 execution,
4229 ),
4230 }
4231 }
4232
4233 fn push_leaf_execution(
4234 spec: &LeafSpec,
4235 records: &HashMap<String, RuntimeTaskRecord>,
4236 execution: &mut IrWorkflowExecution,
4237 ) {
4238 let record = records.get(&spec.id);
4239 let status = record
4240 .map(|record| record.status)
4241 .unwrap_or(IrWorkflowRunStatus::Pending);
4242 mark_ir_status(execution, status);
4243 execution.leaf_results.push(LeafResult {
4244 leaf_id: spec.id.clone(),
4245 task_id: record
4246 .map(|record| record.agent_id.clone())
4247 .unwrap_or_else(|| spec.id.clone()),
4248 role: spec.role.clone(),
4249 profile: spec.profile.clone(),
4250 status,
4251 usage: record
4252 .and_then(|record| record.usage.as_ref())
4253 .map(workflow_usage_from_task)
4254 .unwrap_or_default(),
4255 memo_usage: WorkflowMemoUsage::default(),
4256 output: record.and_then(|record| record.output.clone()),
4257 artifacts: Vec::new(),
4258 schema_error: record.and_then(|record| record.schema_error.clone()),
4259 });
4260 }
4261
4262 fn push_branch_execution(
4263 spec: &BranchSpec,
4264 records: &HashMap<String, RuntimeTaskRecord>,
4265 execution: &mut IrWorkflowExecution,
4266 ) {
4267 let before = execution.leaf_results.len();
4268 for child in &spec.children {
4269 push_execution_node(child, records, execution);
4270 }
4271 let status = aggregate_ir_status(
4272 execution.leaf_results[before..]
4273 .iter()
4274 .map(|result| result.status),
4275 );
4276 mark_ir_status(execution, status);
4277 execution.branch_results.push(BranchResult {
4278 branch_id: spec.id.clone(),
4279 task_id: spec.id.clone(),
4280 status,
4281 usage: WorkflowUsage::default(),
4282 memo_usage: WorkflowMemoUsage::default(),
4283 artifacts: Vec::new(),
4284 notes: Some("production driver branch receipt from child task outcomes".to_string()),
4285 });
4286 execution.control_node_results.push(ControlNodeResult {
4287 node_id: spec.id.clone(),
4288 kind: ControlNodeKind::BranchSet,
4289 status,
4290 selected_children: spec.children.iter().map(declarative_node_id).collect(),
4291 summary: Some("branch set lowered into production child tasks".to_string()),
4292 });
4293 }
4294
4295 fn push_sequence_execution(
4296 spec: &SequenceSpec,
4297 records: &HashMap<String, RuntimeTaskRecord>,
4298 execution: &mut IrWorkflowExecution,
4299 ) {
4300 let before_leaf = execution.leaf_results.len();
4301 let before_control = execution.control_node_results.len();
4302 for child in &spec.children {
4303 push_execution_node(child, records, execution);
4304 }
4305 let status = aggregate_ir_status(
4306 execution.leaf_results[before_leaf..]
4307 .iter()
4308 .map(|result| result.status)
4309 .chain(
4310 execution.control_node_results[before_control..]
4311 .iter()
4312 .map(|result| result.status),
4313 ),
4314 );
4315 mark_ir_status(execution, status);
4316 execution.control_node_results.push(ControlNodeResult {
4317 node_id: spec.id.clone(),
4318 kind: ControlNodeKind::Sequence,
4319 status,
4320 selected_children: spec.children.iter().map(declarative_node_id).collect(),
4321 summary: Some("sequence lowered in declaration order".to_string()),
4322 });
4323 }
4324
4325 fn push_control_execution(
4326 node_id: &str,
4327 kind: ControlNodeKind,
4328 record: Option<&RuntimeTaskRecord>,
4329 selected_children: Vec<String>,
4330 fallback_summary: Option<String>,
4331 execution: &mut IrWorkflowExecution,
4332 ) {
4333 let status = record
4334 .map(|record| record.status)
4335 .unwrap_or(IrWorkflowRunStatus::Pending);
4336 mark_ir_status(execution, status);
4337 execution.control_node_results.push(ControlNodeResult {
4338 node_id: node_id.to_string(),
4339 kind,
4340 status,
4341 selected_children,
4342 summary: record
4343 .and_then(|record| record.output.clone())
4344 .or(fallback_summary),
4345 });
4346 }
4347
4348 fn aggregate_ir_status(
4349 statuses: impl IntoIterator<Item = IrWorkflowRunStatus>,
4350 ) -> IrWorkflowRunStatus {
4351 let mut saw_pending = false;
4352 let mut saw_running = false;
4353 for status in statuses {
4354 match status {
4355 IrWorkflowRunStatus::BudgetExceeded => return IrWorkflowRunStatus::BudgetExceeded,
4356 IrWorkflowRunStatus::Cancelled => return IrWorkflowRunStatus::Cancelled,
4357 IrWorkflowRunStatus::Failed | IrWorkflowRunStatus::ReplayDiverged => {
4358 return IrWorkflowRunStatus::Failed;
4359 }
4360 IrWorkflowRunStatus::Running => saw_running = true,
4361 IrWorkflowRunStatus::Pending => saw_pending = true,
4362 IrWorkflowRunStatus::Succeeded => {}
4363 }
4364 }
4365 if saw_running {
4366 IrWorkflowRunStatus::Running
4367 } else if saw_pending {
4368 IrWorkflowRunStatus::Pending
4369 } else {
4370 IrWorkflowRunStatus::Succeeded
4371 }
4372 }
4373
4374 fn mark_ir_status(execution: &mut IrWorkflowExecution, status: IrWorkflowRunStatus) {
4375 match status {
4376 IrWorkflowRunStatus::Failed | IrWorkflowRunStatus::ReplayDiverged => {
4377 execution.mark_failed()
4378 }
4379 IrWorkflowRunStatus::Cancelled => execution.mark_cancelled(),
4380 IrWorkflowRunStatus::BudgetExceeded => execution.mark_budget_exceeded(),
4381 IrWorkflowRunStatus::Running => {
4382 if execution.status == IrWorkflowRunStatus::Succeeded {
4383 execution.status = IrWorkflowRunStatus::Running;
4384 }
4385 }
4386 IrWorkflowRunStatus::Pending => {
4387 if execution.status == IrWorkflowRunStatus::Succeeded {
4388 execution.status = IrWorkflowRunStatus::Pending;
4389 }
4390 }
4391 IrWorkflowRunStatus::Succeeded => {}
4392 }
4393 }
4394
4395 fn declarative_node_id(node: &WorkflowNode) -> String {
4396 match node {
4397 WorkflowNode::BranchSet(spec) => spec.id.clone(),
4398 WorkflowNode::Leaf(spec) => spec.id.clone(),
4399 WorkflowNode::Sequence(spec) => spec.id.clone(),
4400 WorkflowNode::Reduce(spec) => spec.id.clone(),
4401 WorkflowNode::TeacherReview(spec) => spec.id.clone(),
4402 WorkflowNode::LoopUntil(spec) => spec.id.clone(),
4403 WorkflowNode::Cond(spec) => spec.id.clone(),
4404 WorkflowNode::Expand(spec) => spec.id.clone(),
4405 }
4406 }
4407
4408 fn spawn_completion_pump(
4409 driver: Arc<SubAgentWorkflowDriver>,
4410 mut rx: mpsc::UnboundedReceiver<SubAgentCompletion>,
4411 ) {
4412 spawn_supervised(
4413 "workflow-completion-pump",
4414 std::panic::Location::caller(),
4415 async move {
4416 while let Some(completion) = rx.recv().await {
4417 let agent_id = completion.agent_id.clone();
4418 let (task_completion, usage) =
4419 completion_from_manager(driver.manager.clone(), &agent_id, completion.payload)
4420 .await;
4421 driver.deliver_completion(agent_id, task_completion, usage);
4422 }
4423 },
4424 );
4425 }
4426
4427 async fn completion_from_manager(
4428 manager: SharedSubAgentManager,
4429 agent_id: &str,
4430 fallback_payload: String,
4431 ) -> (TaskCompletion, Option<WorkflowTaskUsage>) {
4432 for _ in 0..50 {
4433 let snapshot_and_usage = {
4434 let manager = manager.read().await;
4435 let snapshot = manager.get_result(agent_id).ok();
4436 let usage = snapshot
4437 .as_ref()
4438 .filter(|snapshot| snapshot.status != SubAgentStatus::Running)
4439 .map(|snapshot| task_usage_from_manager(&manager, agent_id, snapshot));
4440 (snapshot, usage)
4441 };
4442 if let (Some(snapshot), usage) = snapshot_and_usage
4443 && snapshot.status != SubAgentStatus::Running
4444 {
4445 let completion = match snapshot.status {
4446 SubAgentStatus::Completed => TaskCompletion::Completed {
4447 text: snapshot.result.clone().unwrap_or(fallback_payload),
4448 },
4449 SubAgentStatus::Failed(ref message) => TaskCompletion::Failed {
4450 message: message.clone(),
4451 },
4452 SubAgentStatus::Interrupted(ref message) => TaskCompletion::Failed {
4453 message: message.clone(),
4454 },
4455 SubAgentStatus::Cancelled => TaskCompletion::Cancelled,
4456 SubAgentStatus::BudgetExhausted => TaskCompletion::BudgetExhausted {
4457 message: "sub-agent budget exhausted".to_string(),
4458 },
4459 SubAgentStatus::Running => unreachable!("guarded above"),
4460 };
4461 return (completion, usage);
4462 }
4463 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4464 }
4465 (
4466 TaskCompletion::Failed {
4467 message: format!("sub-agent '{agent_id}' did not report a terminal status within 1s"),
4468 },
4469 None,
4470 )
4471 }
4472
4473 /// Capture per-worker telemetry at terminal delivery (#2974): provider-reported
4474 /// tokens from the worker ledger, the model/tool step count and duration from
4475 /// the agent snapshot, and a durable artifact reference for the full output.
4476 fn task_usage_from_manager(
4477 manager: &SubAgentManager,
4478 agent_id: &str,
4479 snapshot: &SubAgentResult,
4480 ) -> WorkflowTaskUsage {
4481 let record = manager.get_worker_record(agent_id);
4482 let usage = record.as_ref().map(|record| &record.usage);
4483 let result_ref = record.as_ref().and_then(|record| {
4484 record
4485 .artifacts
4486 .iter()
4487 .find(|artifact| artifact.kind == "transcript")
4488 .or_else(|| record.artifacts.last())
4489 .map(|artifact| artifact.target.clone())
4490 });
4491 let input_tokens = usage.and_then(|usage| usage.input_tokens);
4492 let output_tokens = usage.and_then(|usage| usage.output_tokens);
4493 let total_tokens = usage.and_then(|usage| usage.total_tokens);
4494 // #4039: the worker ledger leaves these fields `None` until it receives a
4495 // typed provider usage envelope. Presence, not magnitude, is the receipt:
4496 // a provider-reported zero is still a real observation and must survive.
4497 let reported = provider_usage_was_reported(input_tokens, output_tokens, total_tokens);
4498 WorkflowTaskUsage {
4499 input_tokens: reported.then_some(input_tokens).flatten(),
4500 output_tokens: reported.then_some(output_tokens).flatten(),
4501 total_tokens: reported.then_some(total_tokens).flatten(),
4502 cost_microusd: usage.and_then(|usage| usage.cost_microusd),
4503 tool_calls: Some(snapshot.steps_taken),
4504 duration_ms: Some(snapshot.duration_ms),
4505 result_ref,
4506 token_source: reported.then_some(WorkflowTokenSource::ProviderReported),
4507 }
4508 }
4509
4510 fn provider_usage_was_reported(
4511 input_tokens: Option<u64>,
4512 output_tokens: Option<u64>,
4513 total_tokens: Option<u64>,
4514 ) -> bool {
4515 [input_tokens, output_tokens, total_tokens]
4516 .iter()
4517 .any(Option::is_some)
4518 }
4519
4520 fn cancel_child_agents(manager: SharedSubAgentManager, ids: Vec<String>) {
4521 if ids.is_empty() {
4522 return;
4523 }
4524 if let Ok(mut manager_guard) = manager.try_write() {
4525 for id in ids {
4526 let _ = manager_guard.cancel_agent(&id);
4527 }
4528 return;
4529 }
4530 if tokio::runtime::Handle::try_current().is_ok() {
4531 spawn_supervised(
4532 "workflow-cancel-children",
4533 std::panic::Location::caller(),
4534 async move {
4535 let mut manager_guard = manager.write().await;
4536 for id in ids {
4537 let _ = manager_guard.cancel_agent(&id);
4538 }
4539 },
4540 );
4541 }
4542 }
4543
4544 fn lock_mutex<T>(mutex: &Mutex<T>) -> Result<MutexGuard<'_, T>, ToolError> {
4545 mutex
4546 .lock()
4547 .map_err(|_| ToolError::execution_failed("workflow state lock poisoned"))
4548 }
4549
4550 fn now_ms() -> u64 {
4551 SystemTime::now()
4552 .duration_since(UNIX_EPOCH)
4553 .unwrap_or_default()
4554 .as_millis()
4555 .try_into()
4556 .unwrap_or(u64::MAX)
4557 }
4558
4559 mod journal {
4560 use super::{
4561 SharedWorkflowControllers, SharedWorkflowLifecycles, SharedWorkflowRuns, WorkflowRunRecord,
4562 WorkflowRunStatus, WorkflowUiEvent, WorkflowWorkLifecycle,
4563 };
4564 use serde::{Deserialize, Serialize};
4565 use std::collections::HashMap;
4566 use std::fs::OpenOptions;
4567 use std::io::{BufRead, Write};
4568 use std::path::{Path, PathBuf};
4569 use std::sync::{Arc, Mutex, OnceLock};
4570 use tracing::warn;
4571
4572 const CODEWHALE_DIR: &str = ".codewhale";
4573 const WORKFLOW_RUNS_FILE: &str = "workflow-runs.jsonl";
4574
4575 /// Per-workspace workflow state shared across tool-registry rebuilds.
4576 pub(super) struct WorkflowWorkspaceState {
4577 pub runs: SharedWorkflowRuns,
4578 pub controllers: SharedWorkflowControllers,
4579 lifecycles: SharedWorkflowLifecycles,
4580 journal: WorkflowRunJournal,
4581 }
4582
4583 impl WorkflowWorkspaceState {
4584 pub fn open(workspace: &Path) -> Arc<Self> {
4585 let journal = WorkflowRunJournal::open(workspace);
4586 let runs = Arc::new(Mutex::new(journal.hydrate_runs()));
4587 Arc::new(Self {
4588 runs,
4589 controllers: Arc::new(Mutex::new(HashMap::new())),
4590 lifecycles: Arc::new(Mutex::new(HashMap::new())),
4591 journal,
4592 })
4593 }
4594
4595 pub fn attach_lifecycle(&self, run_id: &str, lifecycle: WorkflowWorkLifecycle) {
4596 self.lifecycles
4597 .lock()
4598 .unwrap_or_else(|poison| poison.into_inner())
4599 .entry(run_id.to_string())
4600 .or_insert(lifecycle);
4601 }
4602
4603 pub fn reconcile_snapshot(&self, record: &WorkflowRunRecord) {
4604 let lifecycle = self
4605 .lifecycles
4606 .lock()
4607 .unwrap_or_else(|poison| poison.into_inner())
4608 .get(&record.run_id)
4609 .cloned();
4610 if let Some(lifecycle) = lifecycle
4611 && let Err(err) = lifecycle.reconcile_record(record)
4612 {
4613 warn!(
4614 run_id = record.run_id,
4615 "workflow Work reconciliation failed: {err}"
4616 );
4617 }
4618 }
4619
4620 pub fn reconcile_cancel(&self, run_id: &str, outcome: super::CancelOutcome) {
4621 let lifecycle = self
4622 .lifecycles
4623 .lock()
4624 .unwrap_or_else(|poison| poison.into_inner())
4625 .get(run_id)
4626 .cloned();
4627 if let Some(lifecycle) = lifecycle
4628 && let Err(err) = lifecycle.reconcile_cancel(outcome)
4629 {
4630 warn!(run_id, "workflow cancellation reconciliation failed: {err}");
4631 }
4632 }
4633
4634 pub fn mark_owner_missing(&self, run_id: &str) {
4635 let lifecycle = self
4636 .lifecycles
4637 .lock()
4638 .unwrap_or_else(|poison| poison.into_inner())
4639 .get(run_id)
4640 .cloned();
4641 if let Some(lifecycle) = lifecycle {
4642 lifecycle.reconcile_missing();
4643 }
4644 }
4645
4646 pub fn try_record_snapshot(&self, record: &WorkflowRunRecord) -> Result<(), String> {
4647 self.journal
4648 .append_snapshot(record)
4649 .map_err(|err| err.to_string())
4650 }
4651
4652 pub fn record_snapshot(&self, record: &WorkflowRunRecord) {
4653 if let Err(err) = self.try_record_snapshot(record) {
4654 warn!("workflow journal snapshot failed: {err}");
4655 }
4656 }
4657
4658 pub fn record_progress(&self, run_id: &str, message: &str) {
4659 if let Err(err) = self.journal.append_progress(run_id, message) {
4660 warn!("workflow journal progress failed: {err}");
4661 }
4662 }
4663
4664 pub fn record_event(&self, run_id: &str, event: &WorkflowUiEvent) {
4665 if let Err(err) = self.journal.append_event(run_id, event) {
4666 warn!("workflow journal event failed: {err}");
4667 }
4668 }
4669
4670 /// Durable journal location for full-fidelity run detail (#2974).
4671 pub fn journal_path(&self) -> &Path {
4672 &self.journal.ledger_path
4673 }
4674 }
4675
4676 fn workspace_store() -> &'static Mutex<HashMap<PathBuf, Arc<WorkflowWorkspaceState>>> {
4677 static STORE: OnceLock<Mutex<HashMap<PathBuf, Arc<WorkflowWorkspaceState>>>> =
4678 OnceLock::new();
4679 STORE.get_or_init(|| Mutex::new(HashMap::new()))
4680 }
4681
4682 pub(super) fn shared_workflow_state(workspace: &Path) -> Arc<WorkflowWorkspaceState> {
4683 let key = workspace
4684 .canonicalize()
4685 .unwrap_or_else(|_| workspace.to_path_buf());
4686 let mut store = workspace_store()
4687 .lock()
4688 .unwrap_or_else(|poison| poison.into_inner());
4689 store
4690 .entry(key)
4691 .or_insert_with(|| WorkflowWorkspaceState::open(workspace))
4692 .clone()
4693 }
4694
4695 /// Read-only lookup that never creates workspace state, a journal
4696 /// directory, or a ledger file. Used by the human-only `/structcopy`
4697 /// command (#2033), which must stay side-effect free.
4698 pub(super) fn peek_shared_workflow_state(
4699 workspace: &Path,
4700 ) -> Option<Arc<WorkflowWorkspaceState>> {
4701 let key = workspace
4702 .canonicalize()
4703 .unwrap_or_else(|_| workspace.to_path_buf());
4704 workspace_store()
4705 .lock()
4706 .unwrap_or_else(|poison| poison.into_inner())
4707 .get(&key)
4708 .cloned()
4709 }
4710
4711 #[derive(Debug, Clone, Serialize, Deserialize)]
4712 #[serde(tag = "kind", rename_all = "snake_case")]
4713 enum WorkflowJournalRecord {
4714 // Boxed: a full run record dwarfs the progress variant
4715 // (clippy::large_enum_variant).
4716 Snapshot {
4717 run: Box<WorkflowRunRecord>,
4718 },
4719 Progress {
4720 run_id: String,
4721 message: String,
4722 },
4723 Event {
4724 run_id: String,
4725 event: Box<WorkflowUiEvent>,
4726 },
4727 }
4728
4729 #[derive(Debug)]
4730 struct WorkflowRunJournal {
4731 ledger_path: PathBuf,
4732 }
4733
4734 impl WorkflowRunJournal {
4735 fn open(workspace: &Path) -> Self {
4736 let dir = workspace.join(CODEWHALE_DIR);
4737 if let Err(err) = std::fs::create_dir_all(&dir) {
4738 warn!(
4739 "workflow journal dir create failed ({}): {err}",
4740 dir.display()
4741 );
4742 }
4743 let ledger_path = dir.join(WORKFLOW_RUNS_FILE);
4744 if !ledger_path.exists()
4745 && let Err(err) = std::fs::write(&ledger_path, "")
4746 {
4747 warn!(
4748 "workflow journal create failed ({}): {err}",
4749 ledger_path.display()
4750 );
4751 }
4752 Self { ledger_path }
4753 }
4754
4755 fn hydrate_runs(&self) -> HashMap<String, WorkflowRunRecord> {
4756 let file = match std::fs::File::open(&self.ledger_path) {
4757 Ok(file) => file,
4758 Err(_) => return HashMap::new(),
4759 };
4760 let mut runs = HashMap::new();
4761 for line in std::io::BufReader::new(file).lines() {
4762 let Ok(line) = line else { continue };
4763 let trimmed = line.trim();
4764 if trimmed.is_empty() {
4765 continue;
4766 }
4767 let record = match serde_json::from_str::<WorkflowJournalRecord>(trimmed) {
4768 Ok(record) => record,
4769 Err(err) => {
4770 warn!("workflow journal skipped malformed line: {err}");
4771 continue;
4772 }
4773 };
4774 match record {
4775 WorkflowJournalRecord::Snapshot { run } => {
4776 runs.insert(run.run_id.clone(), *run);
4777 }
4778 WorkflowJournalRecord::Progress { run_id, message } => {
4779 if let Some(run) = runs.get_mut(&run_id) {
4780 run.progress.push(message);
4781 }
4782 }
4783 WorkflowJournalRecord::Event { run_id, event } => {
4784 if let Some(run) = runs.get_mut(&run_id) {
4785 run.push_event(*event);
4786 }
4787 }
4788 }
4789 }
4790 // Journals written before #2974 have no counters; rebuild them
4791 // from the retained tail so summaries stay truthful.
4792 for run in runs.values_mut() {
4793 run.events_total = run.events_total.max(run.events.len() as u64);
4794 }
4795 // A run journaled as Running belongs to a process that is gone;
4796 // without this it would show as live forever after a restart.
4797 let mut recovered = Vec::new();
4798 for run in runs.values_mut() {
4799 if run.status == WorkflowRunStatus::Running {
4800 run.status = WorkflowRunStatus::Failed;
4801 run.lifecycle_seq = run.lifecycle_seq.saturating_add(1);
4802 run.completed_at_ms.get_or_insert_with(super::now_ms);
4803 run.error = Some(
4804 "process exited before the run completed (recovered on startup)"
4805 .to_string(),
4806 );
4807 recovered.push(run.clone());
4808 }
4809 }
4810 // The recovery decision is owner truth, not a presentation-only
4811 // repair. Append it so another restart replays the same terminal
4812 // sequence instead of rediscovering and incrementing it again.
4813 for run in recovered {
4814 if let Err(err) = self.append_snapshot(&run) {
4815 warn!(
4816 run_id = run.run_id,
4817 "workflow recovery snapshot append failed: {err}"
4818 );
4819 }
4820 }
4821 runs
4822 }
4823
4824 fn append_record(&self, record: &WorkflowJournalRecord) -> std::io::Result<()> {
4825 let mut line = serde_json::to_string(record)
4826 .map_err(|err| std::io::Error::other(err.to_string()))?;
4827 line.push('\n');
4828 let mut file = OpenOptions::new()
4829 .create(true)
4830 .append(true)
4831 .open(&self.ledger_path)?;
4832 file.write_all(line.as_bytes())?;
4833 file.flush()?;
4834 Ok(())
4835 }
4836
4837 fn append_snapshot(&self, record: &WorkflowRunRecord) -> std::io::Result<()> {
4838 self.append_record(&WorkflowJournalRecord::Snapshot {
4839 run: Box::new(record.clone()),
4840 })
4841 }
4842
4843 fn append_progress(&self, run_id: &str, message: &str) -> std::io::Result<()> {
4844 self.append_record(&WorkflowJournalRecord::Progress {
4845 run_id: run_id.to_string(),
4846 message: message.to_string(),
4847 })
4848 }
4849
4850 fn append_event(&self, run_id: &str, event: &WorkflowUiEvent) -> std::io::Result<()> {
4851 self.append_record(&WorkflowJournalRecord::Event {
4852 run_id: run_id.to_string(),
4853 event: Box::new(event.clone()),
4854 })
4855 }
4856 }
4857
4858 #[cfg(test)]
4859 mod tests {
4860 use super::super::WorkflowUiEventKind;
4861 use super::*;
4862
4863 fn sample_record(run_id: &str, status: WorkflowRunStatus) -> WorkflowRunRecord {
4864 WorkflowRunRecord {
4865 run_id: run_id.to_string(),
4866 status,
4867 lifecycle_seq: 1,
4868 started_at_ms: 1,
4869 completed_at_ms: None,
4870 source_path: None,
4871 workflow_id: Some("fixture".to_string()),
4872 workflow_goal: Some("journal test".to_string()),
4873 token_budget: None,
4874 child_ids: Vec::new(),
4875 progress: Vec::new(),
4876 events: Vec::new(),
4877 schema_errors: Vec::new(),
4878 dispatch_failures: Vec::new(),
4879 result: None,
4880 execution: None,
4881 error: None,
4882 verify_on_complete: false,
4883 verification: None,
4884 plan_approval: None,
4885 gate_status: Vec::new(),
4886 usage: None,
4887 events_total: 0,
4888 events_dropped: 0,
4889 }
4890 }
4891
4892 #[test]
4893 fn workflow_journal_hydrates_snapshots_and_progress() {
4894 let tmp = tempfile::tempdir().expect("tempdir");
4895 let state = WorkflowWorkspaceState::open(tmp.path());
4896 let running = sample_record("workflow_abc", WorkflowRunStatus::Running);
4897 state.record_snapshot(&running);
4898 state.record_progress("workflow_abc", "phase: scan");
4899 state.record_event(
4900 "workflow_abc",
4901 &WorkflowUiEvent::at(
4902 5,
4903 WorkflowUiEventKind::PhaseStarted {
4904 title: "scan".to_string(),
4905 },
4906 ),
4907 );
4908
4909 let completed = WorkflowRunRecord {
4910 status: WorkflowRunStatus::Completed,
4911 completed_at_ms: Some(99),
4912 progress: vec!["phase: scan".to_string()],
4913 events: vec![WorkflowUiEvent::at(
4914 5,
4915 WorkflowUiEventKind::PhaseStarted {
4916 title: "scan".to_string(),
4917 },
4918 )],
4919 ..sample_record("workflow_abc", WorkflowRunStatus::Completed)
4920 };
4921 state.record_snapshot(&completed);
4922 state.record_event(
4923 "workflow_abc",
4924 &WorkflowUiEvent::at(
4925 6,
4926 WorkflowUiEventKind::HandoffPromoted {
4927 artifact_id: "workflow_abc:scout-1:scout-gate:findings".to_string(),
4928 gate_id: "scout-gate".to_string(),
4929 kind: "findings".to_string(),
4930 from_role: "scout".to_string(),
4931 to_role: "implementer".to_string(),
4932 producer_task_id: "scout-1".to_string(),
4933 },
4934 ),
4935 );
4936 state.record_event(
4937 "workflow_abc",
4938 &WorkflowUiEvent::at(
4939 7,
4940 WorkflowUiEventKind::HandoffConsumed {
4941 artifact_id: "workflow_abc:scout-1:scout-gate:findings".to_string(),
4942 kind: "findings".to_string(),
4943 from_role: "scout".to_string(),
4944 to_role: "implementer".to_string(),
4945 consumer_task_id: "implementer-1".to_string(),
4946 },
4947 ),
4948 );
4949
4950 let reloaded = WorkflowWorkspaceState::open(tmp.path());
4951 let runs = reloaded
4952 .runs
4953 .lock()
4954 .expect("runs lock")
4955 .get("workflow_abc")
4956 .cloned()
4957 .expect("hydrated run");
4958 assert_eq!(runs.status, WorkflowRunStatus::Completed);
4959 assert_eq!(runs.progress, vec!["phase: scan"]);
4960 assert_eq!(runs.events.len(), 3);
4961 assert_eq!(runs.events[0].event_type(), "phase_started");
4962 let promoted = serde_json::to_value(&runs.events[1]).expect("promoted receipt");
4963 assert_eq!(promoted["type"], "handoff_promoted");
4964 assert_eq!(
4965 promoted["artifact_id"],
4966 "workflow_abc:scout-1:scout-gate:findings"
4967 );
4968 assert_eq!(promoted["gate_id"], "scout-gate");
4969 assert_eq!(promoted["producer_task_id"], "scout-1");
4970 assert!(promoted.get("payload").is_none(), "{promoted}");
4971 let consumed = serde_json::to_value(&runs.events[2]).expect("consumed receipt");
4972 assert_eq!(consumed["type"], "handoff_consumed");
4973 assert_eq!(consumed["artifact_id"], promoted["artifact_id"]);
4974 assert_eq!(consumed["consumer_task_id"], "implementer-1");
4975 assert!(consumed.get("payload").is_none(), "{consumed}");
4976 assert_eq!(runs.completed_at_ms, Some(99));
4977
4978 // The event-line replay above must also survive compaction into a
4979 // final Snapshot record containing both handoff variants.
4980 reloaded.record_snapshot(&runs);
4981 let reopened = WorkflowWorkspaceState::open(tmp.path());
4982 let compacted = reopened
4983 .runs
4984 .lock()
4985 .expect("runs lock")
4986 .get("workflow_abc")
4987 .cloned()
4988 .expect("snapshot with handoff receipts");
4989 assert_eq!(
4990 compacted
4991 .events
4992 .iter()
4993 .map(WorkflowUiEvent::event_type)
4994 .collect::<Vec<_>>(),
4995 vec!["phase_started", "handoff_promoted", "handoff_consumed"]
4996 );
4997 }
4998
4999 #[test]
5000 fn workflow_journal_marks_orphaned_running_runs_failed() {
5001 let tmp = tempfile::tempdir().expect("tempdir");
5002 let state = WorkflowWorkspaceState::open(tmp.path());
5003 state.record_snapshot(&sample_record(
5004 "workflow_orphan",
5005 WorkflowRunStatus::Running,
5006 ));
5007
5008 let reloaded = WorkflowWorkspaceState::open(tmp.path());
5009 let run = reloaded
5010 .runs
5011 .lock()
5012 .expect("runs lock")
5013 .get("workflow_orphan")
5014 .cloned()
5015 .expect("hydrated run");
5016 assert_eq!(run.status, WorkflowRunStatus::Failed);
5017 assert_eq!(
5018 run.lifecycle_seq, 2,
5019 "restart recovery is a durable owner lifecycle transition"
5020 );
5021 assert!(
5022 run.completed_at_ms.is_some(),
5023 "restart recovery must terminalize the durable owner record"
5024 );
5025 assert!(
5026 run.error
5027 .as_deref()
5028 .is_some_and(|error| error.contains("process exited")),
5029 "expected orphan recovery error, got {:?}",
5030 run.error
5031 );
5032
5033 let reopened = WorkflowWorkspaceState::open(tmp.path());
5034 let replayed = reopened
5035 .runs
5036 .lock()
5037 .expect("runs lock")
5038 .get("workflow_orphan")
5039 .cloned()
5040 .expect("durably recovered run");
5041 assert_eq!(replayed.status, WorkflowRunStatus::Failed);
5042 assert_eq!(
5043 replayed.lifecycle_seq, 2,
5044 "reopening must replay the recovery snapshot without another transition"
5045 );
5046 }
5047 }
5048 }
5049
5050 use journal::{WorkflowWorkspaceState, peek_shared_workflow_state, shared_workflow_state};
5051
5052 /// Bounded, read-only projection of one workflow run for the human-only
5053 /// `/structcopy` command (#2033).
5054 ///
5055 /// Built on the existing [`WorkflowRunSummary`] projection so retention and
5056 /// truncation accounting (`events_total` / `events_dropped`) stay in exactly
5057 /// one place. Two extra constraints beyond the model-facing summary:
5058 /// `source_path` collapses to a bare file-name label so no filesystem path
5059 /// leaves the process, and raw event/hook payloads never enter the
5060 /// projection. Returns `None` when `run_id` is unknown to this session;
5061 /// never creates workspace state or touches the journal.
5062 pub(crate) fn structcopy_run_projection(workspace: &Path, run_id: &str) -> Option<Value> {
5063 let state = peek_shared_workflow_state(workspace)?;
5064 let runs = state
5065 .runs
5066 .lock()
5067 .unwrap_or_else(|poison| poison.into_inner());
5068 let record = runs.get(run_id)?;
5069 let mut value = serde_json::to_value(record.summary()).ok()?;
5070 if let Some(object) = value.as_object_mut() {
5071 let source_file = record
5072 .source_path
5073 .as_ref()
5074 .and_then(|path| path.file_name())
5075 .and_then(|name| name.to_str())
5076 .map(|name| Value::String(name.to_string()))
5077 .unwrap_or(Value::Null);
5078 object.remove("source_path");
5079 object.insert("source_file".to_string(), source_file);
5080 // `WorkflowRunSummary` predates truthful unavailable-state rendering
5081 // and uses zero defaults when no execution projection exists. A
5082 // structural export must not turn that absence into measured zeros.
5083 if record.execution.is_none() {
5084 object.insert("leaf_count".to_string(), Value::Null);
5085 object.insert("branch_count".to_string(), Value::Null);
5086 object.insert("control_count".to_string(), Value::Null);
5087 }
5088 }
5089 Some(value)
5090 }
5091
5092 /// Seed a minimal run record so `/structcopy` tests can exercise the
5093 /// workflow projection without standing up the JS VM.
5094 #[cfg(test)]
5095 pub(crate) fn structcopy_test_seed_run(workspace: &Path, run_id: &str) {
5096 let state = shared_workflow_state(workspace);
5097 let record = WorkflowRunRecord::new(run_id.to_string(), None, None, None);
5098 state
5099 .runs
5100 .lock()
5101 .unwrap_or_else(|poison| poison.into_inner())
5102 .insert(run_id.to_string(), record);
5103 }
5104
5105 /// Reconcile workflow bindings after the journal has replayed restart
5106 /// recovery. The journal owns lifecycle truth; the graph only receives its
5107 /// monotonic projection.
5108 pub(crate) fn reconcile_persisted_workflow_bindings(
5109 work: &SharedWorkRuntime,
5110 session_id: &str,
5111 workspace: &Path,
5112 ) -> Result<usize, String> {
5113 let state = shared_workflow_state(workspace);
5114 let records = state
5115 .runs
5116 .lock()
5117 .unwrap_or_else(|poison| poison.into_inner())
5118 .values()
5119 .cloned()
5120 .collect::<Vec<_>>();
5121 let candidates = work
5122 .reconcilable_durable_bindings(Some(session_id))
5123 .into_iter()
5124 .filter(|external| external.starts_with("workflow:"))
5125 .collect::<std::collections::HashSet<_>>();
5126 let mut seen = std::collections::HashSet::new();
5127 let mut changed = 0usize;
5128 for record in records {
5129 let external = format!("workflow:{}", record.run_id);
5130 if !candidates.contains(&external) {
5131 continue;
5132 }
5133 seen.insert(external.clone());
5134 let lifecycle = WorkflowWorkLifecycle {
5135 work: work.clone(),
5136 session_id: session_id.to_string(),
5137 external,
5138 };
5139 changed += usize::from(lifecycle.reconcile_record(&record)?);
5140 }
5141 for external in candidates.difference(&seen) {
5142 changed += usize::from(work.reconcile_observation(
5143 session_id,
5144 external,
5145 OperationObservation::OwnerMissing {
5146 checked_at: i64::try_from(now_ms()).unwrap_or(i64::MAX),
5147 },
5148 )?);
5149 }
5150 Ok(changed)
5151 }
5152
5153 #[cfg(test)]
5154 mod tests {
5155 use super::*;
5156 use crate::client::DeepSeekClient;
5157 use crate::tools::ToolRegistryBuilder;
5158 use crate::tools::subagent::{SubAgentRuntime, new_shared_subagent_manager};
5159 use axum::{Json, Router, routing::post};
5160 use codewhale_workflow::{IsolationMode, leaf_is_write_capable};
5161 use std::sync::atomic::{AtomicUsize, Ordering};
5162
5163 #[test]
5164 fn settled_runs_leave_a_report_artifact_under_codewhale_reports() {
5165 let tmp = tempfile::tempdir().expect("tempdir");
5166 let mut record = WorkflowRunRecord::new("workflow_report_1".to_string(), None, None, None);
5167 record.status = WorkflowRunStatus::Completed;
5168 record.workflow_goal = Some("prove the report artifact".to_string());
5169 record.progress.push("phase: scan".to_string());
5170 record.result = Some(serde_json::json!({"confirmed": 2}));
5171
5172 write_run_report_artifact(tmp.path(), &record);
5173
5174 let path = tmp
5175 .path()
5176 .join(".codewhale")
5177 .join("reports")
5178 .join("workflow_report_1.md");
5179 let body = std::fs::read_to_string(&path).expect("report written");
5180 assert!(body.contains("# Workflow run workflow_report_1"), "{body}");
5181 assert!(body.contains("status: Completed"), "{body}");
5182 assert!(body.contains("prove the report artifact"), "{body}");
5183 assert!(body.contains("phase: scan"), "{body}");
5184 assert!(body.contains("\"confirmed\": 2"), "{body}");
5185 }
5186
5187 #[test]
5188 fn running_runs_write_no_report_artifact() {
5189 let tmp = tempfile::tempdir().expect("tempdir");
5190 let record = WorkflowRunRecord::new("workflow_report_2".to_string(), None, None, None);
5191 write_run_report_artifact(tmp.path(), &record);
5192 assert!(
5193 !tmp.path().join(".codewhale").join("reports").exists(),
5194 "running runs must not leave report files"
5195 );
5196 }
5197
5198 #[test]
5199 fn source_path_accepts_the_home_workflow_store_and_rejects_elsewhere() {
5200 let _lock = crate::test_support::lock_test_env();
5201 let tmp = tempfile::tempdir().expect("tempdir");
5202 let home = tmp.path().join("home");
5203 let store = home.join(".codewhale").join("workflows");
5204 std::fs::create_dir_all(&store).expect("store");
5205 let _home_guard = crate::test_support::EnvVarGuard::set("HOME", &home);
5206 let _userprofile_guard = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
5207
5208 let saved = store.join("triage.workflow.js");
5209 std::fs::write(&saved, "phase('scan');\n").expect("write saved workflow");
5210 let elsewhere = tmp.path().join("outside.workflow.js");
5211 std::fs::write(&elsewhere, "phase('scan');\n").expect("write outside workflow");
5212
5213 let workspace = tmp.path().join("ws");
5214 std::fs::create_dir_all(&workspace).expect("workspace");
5215 let context = ToolContext::new(workspace);
5216
5217 let resolved = read_workflow_source_path(saved.to_str().expect("utf8 path"), &context)
5218 .expect("home workflow store is a first-class source");
5219 assert!(resolved.source.contains("phase('scan')"));
5220
5221 let err = read_workflow_source_path(elsewhere.to_str().expect("utf8 path"), &context)
5222 .expect_err("arbitrary outside paths stay denied");
5223 assert!(
5224 err.to_string()
5225 .contains("workspace or ~/.codewhale/workflows"),
5226 "{err}"
5227 );
5228 }
5229
5230 #[test]
5231 fn restored_workflow_binding_consumes_journal_recovery() {
5232 let tmp = tempfile::tempdir().expect("tempdir");
5233 let state = WorkflowWorkspaceState::open(tmp.path());
5234 let record = WorkflowRunRecord::new("workflow_restore".to_string(), None, None, None);
5235 state.record_snapshot(&record);
5236
5237 let work = crate::work_graph::new_shared_work_runtime(
5238 crate::tools::todo::new_shared_todo_list(),
5239 crate::tools::plan::new_shared_plan_state(),
5240 );
5241 work.register_operation(
5242 "restored-workflow-session",
5243 OperationIntent::new(
5244 "workflow:workflow_restore",
5245 "restored workflow",
5246 true,
5247 "workflow",
5248 "restore-test",
5249 ),
5250 )
5251 .expect("register saved workflow binding");
5252 work.reconcile_operation(
5253 "restored-workflow-session",
5254 OperationOwnerSnapshot::new("workflow:workflow_restore", OwnerState::Running, 1, 1),
5255 )
5256 .expect("saved running owner state");
5257 work.register_operation(
5258 "restored-workflow-session",
5259 OperationIntent::new(
5260 "workflow:workflow_absent",
5261 "absent workflow",
5262 true,
5263 "workflow",
5264 "absent-restore-test",
5265 ),
5266 )
5267 .expect("register absent workflow binding");
5268 work.reconcile_operation(
5269 "restored-workflow-session",
5270 OperationOwnerSnapshot::new("workflow:workflow_absent", OwnerState::Running, 1, 1),
5271 )
5272 .expect("saved absent owner state");
5273
5274 assert_eq!(
5275 reconcile_persisted_workflow_bindings(&work, "restored-workflow-session", tmp.path(),),
5276 Ok(2)
5277 );
5278 let graph = work
5279 .capture(Some("restored-workflow-session"))
5280 .expect("capture restored workflow")
5281 .expect("graph")
5282 .graph;
5283 let operation = graph
5284 .nodes
5285 .iter()
5286 .find(|node| {
5287 node.binding
5288 .as_ref()
5289 .is_some_and(|binding| binding.external == "workflow:workflow_restore")
5290 })
5291 .expect("workflow operation");
5292 assert_eq!(operation.state, crate::work_graph::NodeState::Failed);
5293 assert_eq!(
5294 operation
5295 .binding
5296 .as_ref()
5297 .and_then(|binding| binding.last_observation.as_ref())
5298 .map(|observation| observation.seq),
5299 Some(2),
5300 "journal replay must advance the lost live owner before graph reconciliation"
5301 );
5302 let absent = graph
5303 .nodes
5304 .iter()
5305 .find(|node| {
5306 node.binding
5307 .as_ref()
5308 .is_some_and(|binding| binding.external == "workflow:workflow_absent")
5309 })
5310 .expect("absent workflow operation");
5311 assert_eq!(absent.state, crate::work_graph::NodeState::Stale);
5312 assert_eq!(
5313 reconcile_persisted_workflow_bindings(&work, "restored-workflow-session", tmp.path(),),
5314 Ok(0),
5315 "rechecking an already stale missing owner must be idempotent"
5316 );
5317 }
5318
5319 #[tokio::test]
5320 async fn cancellation_without_controller_fails_closed_as_stale() {
5321 let tmp = tempfile::tempdir().expect("tempdir");
5322 let state = WorkflowWorkspaceState::open(tmp.path());
5323 let record =
5324 WorkflowRunRecord::new("workflow_missing_controller".to_string(), None, None, None);
5325 state
5326 .runs
5327 .lock()
5328 .expect("runs lock")
5329 .insert(record.run_id.clone(), record.clone());
5330 state.record_snapshot(&record);
5331
5332 let work = crate::work_graph::new_shared_work_runtime(
5333 crate::tools::todo::new_shared_todo_list(),
5334 crate::tools::plan::new_shared_plan_state(),
5335 );
5336 work.register_operation(
5337 "missing-controller-session",
5338 OperationIntent::new(
5339 "workflow:workflow_missing_controller",
5340 "missing controller",
5341 true,
5342 "workflow",
5343 "missing-controller-test",
5344 ),
5345 )
5346 .expect("register workflow");
5347 work.reconcile_operation(
5348 "missing-controller-session",
5349 OperationOwnerSnapshot::new(
5350 "workflow:workflow_missing_controller",
5351 OwnerState::Running,
5352 1,
5353 1,
5354 ),
5355 )
5356 .expect("running workflow");
5357 state.attach_lifecycle(
5358 "workflow_missing_controller",
5359 WorkflowWorkLifecycle {
5360 work: work.clone(),
5361 session_id: "missing-controller-session".to_string(),
5362 external: "workflow:workflow_missing_controller".to_string(),
5363 },
5364 );
5365
5366 let error = cancel_workflow(
5367 json!({"run_id": "workflow_missing_controller"}),
5368 state.clone(),
5369 )
5370 .await
5371 .expect_err("missing controller cannot acknowledge cancellation");
5372 assert!(error.to_string().contains("outcome is unknown"), "{error}");
5373 let record = state
5374 .runs
5375 .lock()
5376 .expect("runs lock")
5377 .get("workflow_missing_controller")
5378 .cloned()
5379 .expect("workflow owner");
5380 assert_eq!(record.status, WorkflowRunStatus::Running);
5381 assert_eq!(record.lifecycle_seq, 1);
5382 let operation = work
5383 .capture(Some("missing-controller-session"))
5384 .expect("capture")
5385 .expect("graph")
5386 .graph
5387 .nodes
5388 .into_iter()
5389 .find(|node| node.kind == crate::work_graph::NodeKind::Operation)
5390 .expect("workflow operation");
5391 assert_eq!(operation.state, crate::work_graph::NodeState::Stale);
5392 }
5393
5394 #[test]
5395 fn handoff_compaction_preserves_release_sized_evidence() {
5396 let payload = format!("APPROVE\n{}\nterminal: RunCompleted", "e".repeat(1_500));
5397
5398 assert_eq!(
5399 compact_handoff_payload(&payload, WORKFLOW_HANDOFF_MAX_CHARS),
5400 payload
5401 );
5402 }
5403
5404 #[test]
5405 fn handoff_compaction_still_caps_oversized_artifacts() {
5406 let payload = "e".repeat(WORKFLOW_HANDOFF_MAX_CHARS + 1);
5407 let compacted = compact_handoff_payload(&payload, WORKFLOW_HANDOFF_MAX_CHARS);
5408
5409 assert_eq!(compacted.chars().count(), WORKFLOW_HANDOFF_MAX_CHARS + 3);
5410 assert!(compacted.ends_with("..."));
5411 }
5412
5413 #[test]
5414 fn declarative_detection_matches_indented_and_nonleading_workflow_calls() {
5415 // column-0 forms
5416 assert!(looks_like_declarative_workflow("workflow({ tasks: [] })"));
5417 assert!(looks_like_declarative_workflow(
5418 "export default workflow({})"
5419 ));
5420 // #dogfood 0.8.67: a leading statement/comment followed by an INDENTED
5421 // top-level workflow( call must still be detected as declarative.
5422 assert!(looks_like_declarative_workflow(
5423 "// build the run\n workflow({\n tasks: [],\n })"
5424 ));
5425 // imperative scripts must not be misdetected as declarative
5426 assert!(!looks_like_declarative_workflow(
5427 "return await parallel([() => task({ description: \"x\" })]);"
5428 ));
5429 assert!(!looks_like_declarative_workflow("const x = myworkflow(1);"));
5430 }
5431
5432 #[test]
5433 fn workflow_action_defaults_to_start() {
5434 assert_eq!(
5435 parse_workflow_action(&json!({})).unwrap(),
5436 WorkflowAction::Start
5437 );
5438 assert_eq!(
5439 parse_workflow_action(&json!({"action": "run"})).unwrap(),
5440 WorkflowAction::Run
5441 );
5442 }
5443
5444 #[test]
5445 fn named_fleet_maps_workflow_role_to_profile_before_spawn() {
5446 let fleet = FleetRoleMap::from_pairs([
5447 ("scout", "scout"),
5448 ("implementer", "builder"),
5449 ("reviewer", "reviewer"),
5450 ("verifier", "verifier"),
5451 ("release_lead", "manager"),
5452 ])
5453 .expect("fleet");
5454 let mut request = TaskRequest {
5455 description: "fix it".to_string(),
5456 subagent_type: None,
5457 role: Some("implementer".to_string()),
5458 profile: None,
5459 model: None,
5460 model_strength: None,
5461 thinking: None,
5462 cwd: None,
5463 worktree: true,
5464 write_authority: Some("worktree_write".to_string()),
5465 write_roots: vec!["src".to_string()],
5466 exact_files: Vec::new(),
5467 coordination_contracts: vec!["test-contract".to_string()],
5468 dependencies: Vec::new(),
5469 acceptance: Vec::new(),
5470 allowed_tools: None,
5471 disallowed_tools: Vec::new(),
5472 max_depth: None,
5473 token_budget: None,
5474 max_steps: None,
5475 wall_time_secs: None,
5476 response_schema: None,
5477 label: Some("fix".to_string()),
5478 phase: Some("implement".to_string()),
5479 };
5480
5481 apply_named_fleet_to_task_request(Some(&fleet), &mut request).expect("resolve");
5482
5483 assert_eq!(request.role.as_deref(), Some("implementer"));
5484 assert_eq!(request.profile.as_deref(), Some("builder"));
5485 }
5486
5487 // ── Exact named Fleet (schema = "exact") ────────────────────────────────
5488
5489 /// A Fleet that references a saved, reusable Reasoning Router service, and
5490 /// whose members' ids differ from their semantic roles — the case a gate
5491 /// keyed on a role has to keep working through.
5492 const EXACT_GLM_FLEET: &str = r#"
5493 name = "glm-pair"
5494 schema = "exact"
5495 reasoning_router = "luna-low"
5496
5497 [[members]]
5498 id = "implementer"
5499 role = "builder"
5500 provider = "zai"
5501 model = "glm-5"
5502 reasoning = "auto"
5503 permissions = "read_write"
5504
5505 [[members]]
5506 id = "auditor"
5507 role = "reviewer"
5508 provider = "zai"
5509 model = "glm-5"
5510 reasoning = "high"
5511 permissions = "read_only"
5512 "#;
5513
5514 fn exact_task_request(role: &str) -> TaskRequest {
5515 TaskRequest {
5516 description: "land the fix".to_string(),
5517 subagent_type: None,
5518 role: Some(role.to_string()),
5519 profile: None,
5520 model: None,
5521 model_strength: None,
5522 thinking: None,
5523 cwd: None,
5524 worktree: false,
5525 write_authority: None,
5526 write_roots: Vec::new(),
5527 exact_files: Vec::new(),
5528 coordination_contracts: Vec::new(),
5529 dependencies: Vec::new(),
5530 acceptance: Vec::new(),
5531 allowed_tools: None,
5532 disallowed_tools: Vec::new(),
5533 max_depth: None,
5534 token_budget: None,
5535 max_steps: None,
5536 wall_time_secs: None,
5537 response_schema: None,
5538 label: None,
5539 phase: None,
5540 }
5541 }
5542
5543 /// A task for a write-capable member. The spawn boundary refuses an
5544 /// unbounded write claim, so a write-capable exact task always carries a
5545 /// declared scope — the same contract, checked before the Router runs.
5546 fn exact_write_task_request(role: &str) -> TaskRequest {
5547 TaskRequest {
5548 write_roots: vec!["crates/tui".to_string()],
5549 ..exact_task_request(role)
5550 }
5551 }
5552
5553 fn exact_session() -> codewhale_workflow::PermissionCeiling {
5554 codewhale_workflow::PermissionCeiling::preset("full").expect("preset")
5555 }
5556
5557 fn exact_workflow_with(
5558 text: &str,
5559 router: Option<std::sync::Arc<crate::fleet::exact::StaticFleetRouter>>,
5560 ) -> crate::fleet::exact::ExactFleetWorkflow {
5561 let document = codewhale_workflow::FleetDocument::parse(text).expect("exact fleet parses");
5562 crate::fleet::exact::ExactFleetWorkflow::for_tests(
5563 &document,
5564 codewhale_workflow::QualifiedFleetId {
5565 name: "glm-pair".to_string(),
5566 origin: "workspace".to_string(),
5567 },
5568 router,
5569 )
5570 }
5571
5572 fn exact_workflow(text: &str) -> crate::fleet::exact::ExactFleetWorkflow {
5573 exact_workflow_with(
5574 text,
5575 Some(crate::fleet::exact::StaticFleetRouter::new(
5576 r#"{"reasoning":"max"}"#,
5577 )),
5578 )
5579 }
5580
5581 /// Binding resolves the member and its ceiling; routing resolves reasoning.
5582 /// Both halves land on the request, in that order.
5583 #[tokio::test]
5584 async fn exact_fleet_task_launch_resolves_the_member_route_and_ceiling() {
5585 let operation = exact_workflow(EXACT_GLM_FLEET);
5586 let mut request = exact_write_task_request("builder");
5587
5588 let binding = bind_exact_fleet_task_request(&operation, exact_session(), &mut request)
5589 .expect("exact member resolves");
5590
5591 // Addressed by member id, so the run-scoped roster profile (which
5592 // carries the exact provider pin and canonical wire model) is what the
5593 // spawn resolves…
5594 assert_eq!(request.profile.as_deref(), Some("implementer"));
5595 // …while the semantic role is preserved for gates and records.
5596 assert_eq!(request.role.as_deref(), Some("builder"));
5597 let member = operation.roster().get("implementer").expect("roster");
5598 assert_eq!(member.profile.provider.as_deref(), Some("zai"));
5599 assert_eq!(member.profile.model.as_deref(), Some("glm-5"));
5600
5601 // The saved ceiling reached the spawn request before any routing.
5602 assert_eq!(request.write_authority.as_deref(), Some("workspace_write"));
5603 assert_eq!(request.max_depth, Some(0));
5604 assert!(request.thinking.is_none(), "reasoning is not decided yet");
5605
5606 route_admitted_exact_task(&operation, &binding, &mut request)
5607 .await
5608 .expect("routing");
5609 // `auto` was resolved by the Router into a concrete tier — never the
5610 // literal sentinel, and never the legacy local heuristic.
5611 assert_eq!(request.thinking.as_deref(), Some("max"));
5612
5613 // A read-only member is launched read-only, with no router call.
5614 let mut auditor = exact_task_request("reviewer");
5615 let auditor_binding =
5616 bind_exact_fleet_task_request(&operation, exact_session(), &mut auditor)
5617 .expect("auditor resolves");
5618 route_admitted_exact_task(&operation, &auditor_binding, &mut auditor)
5619 .await
5620 .expect("routing");
5621 assert_eq!(auditor.write_authority.as_deref(), Some("read_only"));
5622 assert_eq!(auditor.thinking.as_deref(), Some("high"));
5623 assert_eq!(auditor.role.as_deref(), Some("reviewer"));
5624 assert_eq!(auditor.profile.as_deref(), Some("auditor"));
5625 }
5626
5627 /// The gate machinery keys on the **semantic role**. It must still fire
5628 /// when a member's id differs from that role — the exact failure an earlier
5629 /// pass introduced by stamping the profile id into `role`.
5630 #[tokio::test]
5631 #[allow(clippy::await_holding_lock)]
5632 async fn a_role_keyed_gate_still_fires_when_the_member_id_differs() {
5633 let tmp = tempfile::tempdir().expect("tempdir");
5634 let ctx = ToolContext::new(tmp.path().to_path_buf());
5635 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
5636 let runtime = SubAgentRuntime::new(
5637 stub_client(),
5638 "deepseek-v4-flash".to_string(),
5639 ctx.clone(),
5640 true,
5641 None,
5642 manager.clone(),
5643 );
5644 let state = WorkflowWorkspaceState::open(tmp.path());
5645 let run_id = "workflow_exact_gate".to_string();
5646 // The gate blocks the semantic role `builder`, whose member id is the
5647 // *different* string `implementer`.
5648 let gates = vec![GateSpec {
5649 id: "scout-findings".to_string(),
5650 role: "scout".to_string(),
5651 on: GateOn::RoleComplete,
5652 gate: GateKind::Approve,
5653 on_fail: codewhale_workflow::GateOnFail::Block,
5654 blocks_role: Some("builder".to_string()),
5655 max_retries: 0,
5656 artifact_kind: Some("findings".to_string()),
5657 require_explicit_verdict: false,
5658 }];
5659 state.runs.lock().expect("runs").insert(
5660 run_id.clone(),
5661 WorkflowRunRecord::new(run_id.clone(), None, None, None),
5662 );
5663 let driver = SubAgentWorkflowDriver::new(
5664 run_id.clone(),
5665 manager,
5666 runtime,
5667 state.clone(),
5668 None,
5669 WorkflowFleetBinding::None,
5670 gates,
5671 );
5672
5673 // The upstream scout fails, which puts the gate into a blocking state.
5674 driver.evaluate_gates_for_completed_role(&RuntimeTaskRecord {
5675 agent_id: "scout-agent".to_string(),
5676 label: Some("scout".to_string()),
5677 role: Some("scout".to_string()),
5678 status: IrWorkflowRunStatus::Failed,
5679 output: None,
5680 schema_error: None,
5681 usage: None,
5682 });
5683
5684 let operation = exact_workflow(EXACT_GLM_FLEET);
5685 let mut request = exact_write_task_request("builder");
5686 bind_exact_fleet_task_request(&operation, exact_session(), &mut request).expect("bind");
5687
5688 assert_ne!(
5689 request.role.as_deref(),
5690 request.profile.as_deref(),
5691 "this fleet's ids and roles differ, which is the whole point"
5692 );
5693 assert_eq!(request.role.as_deref(), Some("builder"));
5694
5695 let err = driver
5696 .prepare_request_for_gates(&mut request)
5697 .expect_err("a blocking gate on `builder` must still see `builder`");
5698 assert!(err.to_string().contains("builder"), "{err}");
5699
5700 // The same gate does *not* block a task carrying the member id, which
5701 // is exactly why stamping the id into `role` silently disabled it.
5702 let mut by_id = exact_task_request("builder");
5703 by_id.role = Some("implementer".to_string());
5704 by_id.profile = None;
5705 assert!(
5706 driver.prepare_request_for_gates(&mut by_id).is_ok(),
5707 "the profile id is not the semantic role the gate keys on"
5708 );
5709 }
5710
5711 /// A task whose `role` and `profile` name different members is rejected
5712 /// rather than resolved by precedence.
5713 #[test]
5714 fn exact_fleet_rejects_a_conflicting_task_role_and_profile() {
5715 let operation = exact_workflow(EXACT_GLM_FLEET);
5716 let mut request = exact_task_request("reviewer");
5717 request.profile = Some("implementer".to_string());
5718
5719 let err = bind_exact_fleet_task_request(&operation, exact_session(), &mut request)
5720 .expect_err("conflicting identity");
5721 let message = format!("{err:?}");
5722 assert!(message.contains("different members"), "{message}");
5723 }
5724
5725 #[test]
5726 fn exact_fleet_rejects_task_level_route_overrides() {
5727 let operation = exact_workflow(EXACT_GLM_FLEET);
5728
5729 for mutate in [
5730 (|request: &mut TaskRequest| request.model = Some("glm-4".to_string()))
5731 as fn(&mut TaskRequest),
5732 |request: &mut TaskRequest| request.model_strength = Some("faster".to_string()),
5733 |request: &mut TaskRequest| request.thinking = Some("off".to_string()),
5734 ] {
5735 let mut request = exact_task_request("builder");
5736 mutate(&mut request);
5737 let err = bind_exact_fleet_task_request(&operation, exact_session(), &mut request)
5738 .expect_err("an exact fleet member may not be re-routed per task");
5739 let message = format!("{err:?}");
5740 assert!(
5741 message.contains("not allowed"),
5742 "override must be rejected, not ignored: {message}"
5743 );
5744 }
5745 }
5746
5747 /// A task must not be able to widen a member's saved ceiling by asking for
5748 /// a different agent type, a broader tool surface, or write authority.
5749 #[test]
5750 fn exact_fleet_rejects_task_level_posture_widening() {
5751 let operation = exact_workflow(EXACT_GLM_FLEET);
5752
5753 for (field, mutate) in [
5754 (
5755 "subagent_type",
5756 (|request: &mut TaskRequest| {
5757 request.subagent_type = Some("general".to_string());
5758 }) as fn(&mut TaskRequest),
5759 ),
5760 ("allowed_tools", |request: &mut TaskRequest| {
5761 request.allowed_tools = Some(vec!["shell".to_string()]);
5762 }),
5763 ("write_authority", |request: &mut TaskRequest| {
5764 request.write_authority = Some("workspace_write".to_string());
5765 }),
5766 ] {
5767 // The read-only auditor is the interesting victim: its saved
5768 // ceiling is the narrowest in the fleet.
5769 let mut request = exact_task_request("reviewer");
5770 mutate(&mut request);
5771 let err = bind_exact_fleet_task_request(&operation, exact_session(), &mut request)
5772 .expect_err("an exact ceiling must win over a task option");
5773 let message = format!("{err:?}");
5774 assert!(
5775 message.contains(field) && message.contains("not allowed"),
5776 "{field} must be rejected: {message}"
5777 );
5778 }
5779
5780 // And with no task options at all, the saved ceiling is what lands.
5781 let mut clean = exact_task_request("reviewer");
5782 bind_exact_fleet_task_request(&operation, exact_session(), &mut clean)
5783 .expect("clean launch");
5784 assert_eq!(clean.write_authority.as_deref(), Some("read_only"));
5785 assert_eq!(clean.subagent_type, None);
5786 }
5787
5788 /// The saved ceiling becomes a real tool policy on the spawn request: a
5789 /// member with no network tool carries a deny list the child enforces.
5790 #[test]
5791 fn exact_fleet_ceilings_reach_the_spawn_request_as_a_tool_policy() {
5792 let operation = exact_workflow(EXACT_GLM_FLEET);
5793 let mut request = exact_task_request("reviewer");
5794
5795 bind_exact_fleet_task_request(&operation, exact_session(), &mut request).expect("bind");
5796
5797 // `read_only` has tools but no network tool.
5798 assert_eq!(
5799 request.allowed_tools, None,
5800 "a tool-using member keeps full inheritance, narrowed by the deny list"
5801 );
5802 for denied in ["Web", "web_search", "fetch_url", "mcp*"] {
5803 assert!(
5804 request.disallowed_tools.iter().any(|name| name == denied),
5805 "{denied} must be denied: {:?}",
5806 request.disallowed_tools
5807 );
5808 }
5809 }
5810
5811 /// A Router decision is paid for before the child exists. If the spawn then
5812 /// fails, the receipt is the only record that tokens were spent and — for a
5813 /// cross-provider Router — that a bounded summary already left the host. It
5814 /// must survive the failure rather than being dropped with it.
5815 #[tokio::test]
5816 async fn a_routing_receipt_survives_a_failed_spawn() {
5817 let tmp = tempfile::tempdir().expect("tempdir");
5818 let ctx = ToolContext::new(tmp.path().to_path_buf());
5819 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
5820 let runtime = SubAgentRuntime::new(
5821 stub_client(),
5822 "deepseek-v4-flash".to_string(),
5823 ctx.clone(),
5824 true,
5825 None,
5826 manager.clone(),
5827 );
5828 let state = WorkflowWorkspaceState::open(tmp.path());
5829 let run_id = "workflow_orphaned_receipt".to_string();
5830 state.runs.lock().expect("runs").insert(
5831 run_id.clone(),
5832 WorkflowRunRecord::new(run_id.clone(), None, None, None),
5833 );
5834 let driver = SubAgentWorkflowDriver::new(
5835 run_id.clone(),
5836 manager,
5837 runtime,
5838 state.clone(),
5839 None,
5840 WorkflowFleetBinding::None,
5841 Vec::new(),
5842 );
5843
5844 let operation = exact_workflow(EXACT_GLM_FLEET);
5845 let mut request = exact_write_task_request("builder");
5846 let binding =
5847 bind_exact_fleet_task_request(&operation, exact_session(), &mut request).expect("bind");
5848 let receipt = route_admitted_exact_task(&operation, &binding, &mut request)
5849 .await
5850 .expect("routing");
5851
5852 driver.record_orphaned_fleet_receipt(&receipt, "Sub-agent depth limit reached");
5853
5854 let events = state
5855 .runs
5856 .lock()
5857 .expect("runs")
5858 .get(&run_id)
5859 .expect("run")
5860 .events
5861 .clone();
5862 let logged = events
5863 .iter()
5864 .filter_map(|event| match &event.kind {
5865 WorkflowUiEventKind::Log { message } => Some(message.clone()),
5866 _ => None,
5867 })
5868 .find(|message| message.contains("spawn_failed=true"))
5869 .expect("the receipt must outlive the failed spawn");
5870
5871 assert!(logged.contains("member=implementer"), "{logged}");
5872 assert!(logged.contains("source=fleet_router"), "{logged}");
5873 assert!(
5874 logged.contains("reasoning_router:workspace/luna-low"),
5875 "{logged}"
5876 );
5877 assert!(logged.contains("Sub-agent depth limit reached"), "{logged}");
5878 // Still content-free: a failure line is no excuse to echo the task.
5879 assert!(!logged.contains("land the fix"), "{logged}");
5880 }
5881
5882 /// A member's semantic role is what the operator named and what gates key
5883 /// on; the roster profile's role is the permission **posture** the clamped
5884 /// ceiling permits. The started event must show the first, not the second.
5885 #[tokio::test]
5886 async fn a_started_event_shows_the_members_role_not_its_permission_posture() {
5887 const AUDIT_FLEET: &str = r#"
5888 name = "glm-pair"
5889 schema = "exact"
5890
5891 [[members]]
5892 id = "auditor"
5893 role = "auditor"
5894 provider = "zai"
5895 model = "glm-5"
5896 reasoning = "high"
5897 permissions = "read_only"
5898 "#;
5899 let operation = exact_workflow_with(AUDIT_FLEET, None);
5900 let mut request = exact_task_request("auditor");
5901 let binding =
5902 bind_exact_fleet_task_request(&operation, exact_session(), &mut request).expect("bind");
5903 let receipt = route_admitted_exact_task(&operation, &binding, &mut request)
5904 .await
5905 .expect("routing");
5906
5907 // The roster profile — and therefore the spawn metadata — carries the
5908 // posture role, because that is what picked the child's tool surface.
5909 let posture = operation
5910 .roster()
5911 .get("auditor")
5912 .expect("roster entry")
5913 .profile
5914 .role
5915 .name
5916 .clone();
5917 assert_eq!(posture, "scout");
5918 assert_eq!(receipt.posture_role.as_deref(), Some("scout"));
5919
5920 // What the run displays is the member's role, not that posture.
5921 assert_eq!(
5922 displayed_resolved_role(Some(&receipt), Some(&posture), request.role.as_deref()),
5923 Some("auditor".to_string()),
5924 "the panel must not rename the operator's member to its posture"
5925 );
5926
5927 // A non-Fleet task keeps the previous precedence untouched.
5928 assert_eq!(
5929 displayed_resolved_role(None, Some("builder"), Some("reviewer")),
5930 Some("builder".to_string())
5931 );
5932 assert_eq!(
5933 displayed_resolved_role(None, None, Some("reviewer")),
5934 Some("reviewer".to_string())
5935 );
5936 }
5937
5938 /// The spawn boundary refuses an unbounded write claim. A task that will
5939 /// hit that refusal must be stopped while it is still free — before the
5940 /// Router is asked anything — or the operator pays for a routing decision
5941 /// about work that could never have started.
5942 #[test]
5943 fn a_predictably_invalid_write_scope_is_rejected_before_the_router_runs() {
5944 let router = crate::fleet::exact::StaticFleetRouter::new(r#"{"reasoning":"max"}"#);
5945 let operation = exact_workflow_with(EXACT_GLM_FLEET, Some(router.clone()));
5946
5947 // Write-capable member, no declared scope: refused at the spawn
5948 // boundary, so refused here first.
5949 let mut unbounded = exact_task_request("builder");
5950 let err = bind_exact_fleet_task_request(&operation, exact_session(), &mut unbounded)
5951 .expect_err("an unbounded write claim never reaches a spawn");
5952 let message = format!("{err:?}");
5953 assert!(message.contains("write_roots"), "{message}");
5954
5955 // Read-only member declaring a write scope is the mirror error.
5956 let mut scoped_read_only = exact_task_request("reviewer");
5957 scoped_read_only.exact_files = vec!["crates/tui/src/main.rs".to_string()];
5958 let err = bind_exact_fleet_task_request(&operation, exact_session(), &mut scoped_read_only)
5959 .expect_err("a read-only member may not claim files");
5960 assert!(format!("{err:?}").contains("read-only"), "{err:?}");
5961
5962 // Neither spent a routing request.
5963 assert_eq!(
5964 router.call_count(),
5965 0,
5966 "validation that the spawn will fail must precede the router call"
5967 );
5968
5969 // The same task with a declared scope binds cleanly.
5970 let mut bounded = exact_write_task_request("builder");
5971 bind_exact_fleet_task_request(&operation, exact_session(), &mut bounded)
5972 .expect("a bounded write claim is valid");
5973 }
5974
5975 /// The parent posture wins over the saved Fleet, in the request the child
5976 /// actually receives.
5977 #[test]
5978 fn a_read_only_session_narrows_a_write_capable_exact_member() {
5979 let operation = exact_workflow(EXACT_GLM_FLEET);
5980 let session = codewhale_workflow::PermissionCeiling {
5981 write: false,
5982 network_tool: false,
5983 shell: codewhale_workflow::ShellCeiling::ReadOnly,
5984 delegation_depth: 0,
5985 tools: true,
5986 };
5987
5988 let mut request = exact_task_request("builder");
5989 bind_exact_fleet_task_request(&operation, session, &mut request).expect("bind");
5990
5991 assert_eq!(
5992 request.write_authority.as_deref(),
5993 Some("read_only"),
5994 "a saved read_write member must not write inside a read-only session"
5995 );
5996 assert_eq!(request.max_depth, Some(0));
5997 }
5998
5999 /// A task that never reaches admission must never reach the Router.
6000 #[test]
6001 fn a_rejected_or_unadmitted_task_spends_no_router_call() {
6002 let router = crate::fleet::exact::StaticFleetRouter::new(r#"{"reasoning":"max"}"#);
6003 let operation = exact_workflow_with(EXACT_GLM_FLEET, Some(router.clone()));
6004
6005 // Rejected by an override check, before the member is even resolved.
6006 let mut overridden = exact_task_request("builder");
6007 overridden.model = Some("glm-4".to_string());
6008 assert!(
6009 bind_exact_fleet_task_request(&operation, exact_session(), &mut overridden).is_err()
6010 );
6011
6012 // Rejected by member resolution.
6013 let mut unknown = exact_task_request("wizard");
6014 assert!(bind_exact_fleet_task_request(&operation, exact_session(), &mut unknown).is_err());
6015
6016 // Admitted-shaped but never routed: the capacity-blocked case.
6017 let mut queued = exact_write_task_request("builder");
6018 bind_exact_fleet_task_request(&operation, exact_session(), &mut queued).expect("bind");
6019
6020 assert_eq!(
6021 router.call_count(),
6022 0,
6023 "binding must never contact the reasoning router"
6024 );
6025 }
6026
6027 /// The routing decision must survive the run as a durable, visible receipt
6028 /// that carries no task content.
6029 #[tokio::test]
6030 async fn an_exact_fleet_launch_produces_a_durable_routing_receipt() {
6031 let operation = exact_workflow(EXACT_GLM_FLEET);
6032 let mut request = exact_write_task_request("builder");
6033
6034 let binding =
6035 bind_exact_fleet_task_request(&operation, exact_session(), &mut request).expect("bind");
6036 let receipt = route_admitted_exact_task(&operation, &binding, &mut request)
6037 .await
6038 .expect("routing");
6039
6040 assert_eq!(receipt.fleet, "workspace/glm-pair");
6041 assert_eq!(receipt.member_id, "implementer");
6042 assert_eq!(receipt.member_role, "builder");
6043 assert_eq!(receipt.provider, "zai");
6044 assert_eq!(receipt.model, "glm-5");
6045 assert_eq!(receipt.requested_reasoning, "auto");
6046 assert_eq!(receipt.effective_reasoning, "max");
6047 assert_eq!(receipt.selection_source, "fleet_router");
6048 let router = receipt.router.as_ref().expect("router identity");
6049 assert_eq!(router.service_kind, "reasoning_router");
6050 assert_eq!(router.qualified(), "workspace/luna-low");
6051 let call = router.call.as_ref().expect("call disclosure");
6052 assert_eq!(call.requested, "low");
6053 assert_eq!(call.effective, "low");
6054
6055 // It rides the durable task_started event, and older consumers that
6056 // never saw this field still deserialize.
6057 let event = WorkflowUiEvent::new(WorkflowUiEventKind::TaskStarted(Box::new(
6058 WorkflowTaskStartedEvent {
6059 task_id: "t1".to_string(),
6060 label: None,
6061 role: request.role.clone(),
6062 profile: request.profile.clone(),
6063 model: None,
6064 strength: None,
6065 thinking: request.thinking.clone(),
6066 // The #4039 reasoning fields carry the same requested →
6067 // effective pair the receipt records, so the event and its
6068 // receipt cannot disagree about what the child ran at.
6069 requested_reasoning: Some(receipt.requested_reasoning.clone()),
6070 effective_reasoning: Some(receipt.effective_reasoning.clone()),
6071 resolved_role: None,
6072 resolved_profile: None,
6073 resolved_provider: "zai".to_string(),
6074 resolved_model: "glm-5".to_string(),
6075 route_source: "fleet".to_string(),
6076 worktree: false,
6077 workspace: None,
6078 git_branch: None,
6079 parent_task_id: None,
6080 depth: 0,
6081 workflow_run_id: None,
6082 workflow_phase_id: None,
6083 workflow_task_label: None,
6084 workflow_child_index: None,
6085 fleet_receipt: Some(receipt.clone()),
6086 },
6087 )));
6088 let payload = serde_json::to_value(&event).expect("serialize");
6089 let rendered = payload.to_string();
6090 for expected in [
6091 "fleet_receipt",
6092 "\"selection_source\":\"fleet_router\"",
6093 "\"provider_effective_reasoning\"",
6094 "\"requested_reasoning\":\"auto\"",
6095 "gpt-5.6-luna",
6096 "\"service_kind\":\"reasoning_router\"",
6097 ] {
6098 assert!(
6099 rendered.contains(expected),
6100 "{expected} missing: {rendered}"
6101 );
6102 }
6103 // No absolute paths, secrets, or task text on a durable event.
6104 for forbidden in ["/Users/", "/home/", ".toml", "api_key", "land the fix"] {
6105 assert!(!rendered.contains(forbidden), "{forbidden} in {rendered}");
6106 }
6107
6108 // The visible one-line form names every side of the decision.
6109 let line = receipt.line();
6110 for expected in [
6111 "requested=auto",
6112 "effective=max",
6113 "source=fleet_router",
6114 "reasoning_router:workspace/luna-low",
6115 "router_call_requested=low",
6116 ] {
6117 assert!(line.contains(expected), "{expected} missing from {line}");
6118 }
6119 }
6120
6121 #[test]
6122 fn exact_fleet_rejects_an_unknown_member() {
6123 let operation = exact_workflow(EXACT_GLM_FLEET);
6124 let mut request = exact_task_request("wizard");
6125
6126 let err = bind_exact_fleet_task_request(&operation, exact_session(), &mut request)
6127 .expect_err("unknown member");
6128 let message = format!("{err:?}");
6129 assert!(message.contains("wizard"), "{message}");
6130 assert!(message.contains("implementer"), "{message}");
6131
6132 // The Reasoning Router is never dispatchable as a worker.
6133 let mut router_request = exact_task_request("luna-low");
6134 assert!(
6135 bind_exact_fleet_task_request(&operation, exact_session(), &mut router_request)
6136 .is_err(),
6137 "the reasoning router must not be launchable as a worker"
6138 );
6139 }
6140
6141 /// One saved Router profile, referenced by two different Fleets.
6142 #[tokio::test]
6143 async fn one_reasoning_router_profile_serves_two_fleets() {
6144 let first = exact_workflow(EXACT_GLM_FLEET);
6145 let second = {
6146 let text = EXACT_GLM_FLEET.replace("name = \"glm-pair\"", "name = \"glm-solo\"");
6147 let document =
6148 codewhale_workflow::FleetDocument::parse(&text).expect("second fleet parses");
6149 crate::fleet::exact::ExactFleetWorkflow::for_tests(
6150 &document,
6151 codewhale_workflow::QualifiedFleetId {
6152 name: "glm-solo".to_string(),
6153 origin: "workspace".to_string(),
6154 },
6155 Some(crate::fleet::exact::StaticFleetRouter::new(
6156 r#"{"reasoning":"low"}"#,
6157 )),
6158 )
6159 };
6160
6161 assert_eq!(
6162 first.snapshot().router(),
6163 second.snapshot().router(),
6164 "both fleets reference the identical captured router service"
6165 );
6166 assert_ne!(
6167 first.snapshot().fleet().qualified(),
6168 second.snapshot().fleet().qualified()
6169 );
6170
6171 // Both actually route through it, and both receipts name it.
6172 for (operation, expected) in [(&first, "max"), (&second, "low")] {
6173 let mut request = exact_write_task_request("builder");
6174 let binding = bind_exact_fleet_task_request(operation, exact_session(), &mut request)
6175 .expect("bind");
6176 let receipt = route_admitted_exact_task(operation, &binding, &mut request)
6177 .await
6178 .expect("routing");
6179 assert_eq!(receipt.effective_reasoning, expected);
6180 assert_eq!(
6181 receipt.router.as_ref().expect("router").qualified(),
6182 "workspace/luna-low"
6183 );
6184 }
6185 }
6186
6187 /// Editing the saved Fleet mid-run must not move the running Workflow's
6188 /// routes. The snapshot owns copies; only the next Workflow sees the edit.
6189 #[tokio::test]
6190 async fn editing_the_fleet_file_after_start_does_not_move_a_running_route() {
6191 let tmp = tempfile::tempdir().expect("tmp");
6192 let fleets = tmp.path().join("fleets");
6193 std::fs::create_dir_all(&fleets).expect("fleets dir");
6194 let path = fleets.join("glm-pair.toml");
6195 std::fs::write(&path, EXACT_GLM_FLEET).expect("write fleet");
6196
6197 let roots = vec![codewhale_workflow::FleetSearchRoot::new(
6198 "workspace",
6199 tmp.path(),
6200 )];
6201 let (document, id) =
6202 codewhale_workflow::FleetDocument::load_by_name("glm-pair", &roots).expect("load");
6203 let operation = crate::fleet::exact::ExactFleetWorkflow::for_tests(
6204 &document,
6205 id,
6206 Some(crate::fleet::exact::StaticFleetRouter::new(
6207 r#"{"reasoning":"max"}"#,
6208 )),
6209 );
6210 let started_hash = operation.snapshot().content_hash().to_string();
6211
6212 // The operator rewrites the saved Fleet mid-run.
6213 std::fs::write(
6214 &path,
6215 EXACT_GLM_FLEET
6216 .replace(
6217 "model = \"glm-5\"\nreasoning = \"auto\"",
6218 "model = \"glm-4\"\nreasoning = \"off\"",
6219 )
6220 .replace("permissions = \"read_write\"", "permissions = \"full\""),
6221 )
6222 .expect("rewrite fleet");
6223
6224 let mut request = exact_write_task_request("builder");
6225 let binding = bind_exact_fleet_task_request(&operation, exact_session(), &mut request)
6226 .expect("bind after the edit");
6227 route_admitted_exact_task(&operation, &binding, &mut request)
6228 .await
6229 .expect("launch after the edit");
6230
6231 let member = operation.roster().get("implementer").expect("roster");
6232 assert_eq!(
6233 member.profile.model.as_deref(),
6234 Some("glm-5"),
6235 "the in-flight snapshot must keep the model it started with"
6236 );
6237 assert_eq!(request.thinking.as_deref(), Some("max"));
6238 assert_eq!(
6239 request.write_authority.as_deref(),
6240 Some("workspace_write"),
6241 "the edit must not widen the running ceiling to `full`"
6242 );
6243 assert_eq!(operation.snapshot().content_hash(), started_hash);
6244
6245 // A fresh Workflow does see the edit.
6246 let (reloaded, reloaded_id) =
6247 codewhale_workflow::FleetDocument::load_by_name("glm-pair", &roots).expect("reload");
6248 let next = crate::fleet::exact::ExactFleetWorkflow::for_tests(
6249 &reloaded,
6250 reloaded_id,
6251 Some(crate::fleet::exact::StaticFleetRouter::new(
6252 r#"{"reasoning":"max"}"#,
6253 )),
6254 );
6255 assert_eq!(
6256 next.roster()
6257 .get("implementer")
6258 .expect("roster")
6259 .profile
6260 .model
6261 .as_deref(),
6262 Some("glm-4")
6263 );
6264 assert_ne!(next.snapshot().content_hash(), started_hash);
6265 }
6266
6267 #[test]
6268 fn named_fleet_rejects_unknown_workflow_role_before_spawn() {
6269 let fleet = FleetRoleMap::from_pairs([("scout", "scout")]).expect("fleet");
6270 let mut request = TaskRequest {
6271 description: "fix it".to_string(),
6272 subagent_type: None,
6273 role: Some("wizard".to_string()),
6274 profile: None,
6275 model: None,
6276 model_strength: None,
6277 thinking: None,
6278 cwd: None,
6279 worktree: false,
6280 write_authority: Some("read_only".to_string()),
6281 write_roots: Vec::new(),
6282 exact_files: Vec::new(),
6283 coordination_contracts: Vec::new(),
6284 dependencies: Vec::new(),
6285 acceptance: Vec::new(),
6286 allowed_tools: None,
6287 disallowed_tools: Vec::new(),
6288 max_depth: None,
6289 token_budget: None,
6290 max_steps: None,
6291 wall_time_secs: None,
6292 response_schema: None,
6293 label: None,
6294 phase: None,
6295 };
6296
6297 let err = apply_named_fleet_to_task_request(Some(&fleet), &mut request)
6298 .expect_err("unknown role should fail");
6299 assert!(
6300 err.to_string().contains("unknown fleet role `wizard`"),
6301 "{err}"
6302 );
6303 }
6304
6305 #[test]
6306 fn declarative_leaf_budget_reaches_task_runtime_options() {
6307 let source = r#"
6308 workflow({
6309 "goal": "bound one child",
6310 "nodes": [{
6311 "agent": {
6312 "id": "bounded",
6313 "prompt": "Inspect bounded evidence.",
6314 "budget": { "max_tokens": 5000, "max_steps": 4, "timeout_secs": 90 }
6315 }
6316 }]
6317 });
6318 "#;
6319
6320 let adapted = adapt_workflow_source(source, None).expect("lower bounded leaf");
6321 assert!(
6322 adapted.source.contains("tokenBudget: 5000"),
6323 "{}",
6324 adapted.source
6325 );
6326 assert!(adapted.source.contains("maxSteps: 4"), "{}", adapted.source);
6327 assert!(
6328 adapted.source.contains("wallTimeSecs: 90"),
6329 "{}",
6330 adapted.source
6331 );
6332 }
6333
6334 #[tokio::test]
6335 #[allow(clippy::await_holding_lock)]
6336 async fn declarative_max_steps_zero_stops_before_provider_call() {
6337 let _retry_guard = workflow_test_retry_guard();
6338 let tmp = tempfile::tempdir().expect("tempdir");
6339 let ctx = ToolContext::new(tmp.path().to_path_buf());
6340 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
6341 let (client, calls) = fake_chat_client("must not be called").await;
6342 let runtime = SubAgentRuntime::new(
6343 client,
6344 "deepseek-v4-flash".to_string(),
6345 ctx.clone(),
6346 true,
6347 None,
6348 manager.clone(),
6349 );
6350 let tool = WorkflowTool::new(manager, runtime);
6351
6352 let result = tool
6353 .execute(
6354 json!({
6355 "action": "run",
6356 "script": r#"
6357 workflow({
6358 "goal": "prove the child step cap reaches runtime",
6359 "nodes": [{
6360 "agent": {
6361 "id": "zero-step",
6362 "prompt": "Do not start a model turn.",
6363 "budget": { "max_steps": 0, "timeout_secs": 90 }
6364 }
6365 }]
6366 });
6367 "#
6368 }),
6369 &ctx,
6370 )
6371 .await
6372 .expect("failed workflow still returns its terminal receipt");
6373 let payload: Value = serde_json::from_str(&result.content).expect("workflow JSON");
6374
6375 assert_eq!(
6376 calls.load(Ordering::SeqCst),
6377 0,
6378 "provider must not be called"
6379 );
6380 assert_eq!(payload["status"], "failed", "{payload}");
6381 assert_eq!(
6382 payload["execution"]["leaf_results"][0]["status"], "failed",
6383 "{payload}"
6384 );
6385 }
6386
6387 #[tokio::test]
6388 #[allow(clippy::await_holding_lock)]
6389 async fn role_only_leaf_omits_type_and_resolves_through_named_fleet() {
6390 let _retry_guard = workflow_test_retry_guard();
6391 let _env_lock = crate::test_support::lock_test_env();
6392 let tmp = tempfile::tempdir().expect("tempdir");
6393 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
6394 let fleet_dir = tmp.path().join("fleets");
6395 std::fs::create_dir_all(&fleet_dir).expect("fleet dir");
6396 std::fs::write(
6397 fleet_dir.join("role-only-test.toml"),
6398 r#"
6399 name = "role-only-test"
6400
6401 [roles]
6402 scout = "scout"
6403 reviewer = "reviewer"
6404 "#,
6405 )
6406 .expect("role-only fleet");
6407 let source = r#"
6408 export default workflow({
6409 "goal": "resolve a role-only child",
6410 "nodes": [
6411 {
6412 "agent": {
6413 "id": "scout-source",
6414 "prompt": "Inspect the source without editing.",
6415 "role": "scout",
6416 "mode": "read_only"
6417 }
6418 }
6419 ]
6420 });
6421 "#;
6422
6423 let adapted = adapt_workflow_source(source, None).expect("lower role-only workflow");
6424 assert!(adapted.source.contains("role: \"scout\""));
6425 assert!(
6426 !adapted.source.contains("type:"),
6427 "Fleet-addressed leaves must defer runtime type to the roster:\n{}",
6428 adapted.source
6429 );
6430 let non_role = adapt_workflow_source(
6431 r#"workflow({
6432 "goal": "default non-role child",
6433 "nodes": [{ "agent": { "id": "audit", "prompt": "Audit only." } }]
6434 });"#,
6435 None,
6436 )
6437 .expect("lower non-role workflow");
6438 assert!(
6439 non_role.source.contains("type: \"review\""),
6440 "non-role read-only leaves retain the review default:\n{}",
6441 non_role.source
6442 );
6443 let explicit_type_source = r#"
6444 workflow({
6445 "goal": "preserve an authored role type",
6446 "nodes": [{
6447 "agent": {
6448 "id": "review-source",
6449 "prompt": "Review the source without editing.",
6450 "agent_type": "review",
6451 "role": "reviewer",
6452 "mode": "read_only"
6453 }
6454 }]
6455 });
6456 "#;
6457 let explicit_type = adapt_workflow_source(explicit_type_source, None)
6458 .expect("lower explicitly typed Fleet role");
6459 assert!(
6460 explicit_type.source.contains("type: \"review\""),
6461 "an authored non-General type must remain a validated override:\n{}",
6462 explicit_type.source
6463 );
6464
6465 let ctx = ToolContext::new(tmp.path().to_path_buf());
6466 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
6467 let (client, calls) = fake_chat_client("scout evidence").await;
6468 let runtime = SubAgentRuntime::new(
6469 client,
6470 "deepseek-v4-flash".to_string(),
6471 ctx.clone(),
6472 true,
6473 None,
6474 manager,
6475 );
6476 let tool = WorkflowTool::new(runtime.manager.clone(), runtime);
6477 let result = tool
6478 .execute(
6479 json!({
6480 "action": "run",
6481 "script": source,
6482 "fleet": "role-only-test"
6483 }),
6484 &ctx,
6485 )
6486 .await
6487 .expect("role-only workflow should resolve through its named Fleet");
6488 let payload: Value = serde_json::from_str(&result.content).expect("workflow JSON");
6489
6490 assert_eq!(payload["status"], "completed", "{payload}");
6491 assert_eq!(calls.load(Ordering::SeqCst), 1);
6492 let started = payload["events"]
6493 .as_array()
6494 .expect("typed events")
6495 .iter()
6496 .find(|event| event["type"] == "task_started")
6497 .expect("task_started receipt");
6498 assert_eq!(started["role"], "scout");
6499 assert_eq!(started["profile"], "scout");
6500 assert_eq!(started["resolved_profile"], "scout");
6501
6502 let explicit_result = tool
6503 .execute(
6504 json!({
6505 "action": "run",
6506 "script": explicit_type_source,
6507 "fleet": "role-only-test"
6508 }),
6509 &ctx,
6510 )
6511 .await
6512 .expect("matching explicit role type should remain valid");
6513 let explicit_payload: Value =
6514 serde_json::from_str(&explicit_result.content).expect("workflow JSON");
6515 assert_eq!(
6516 explicit_payload["status"], "completed",
6517 "{explicit_payload}"
6518 );
6519 assert_eq!(calls.load(Ordering::SeqCst), 2);
6520
6521 let conflicting_result = tool
6522 .execute(
6523 json!({
6524 "action": "run",
6525 "script": r#"workflow({
6526 "goal": "reject a conflicting authored type",
6527 "nodes": [{ "agent": {
6528 "id": "bad-scout",
6529 "prompt": "Review as a scout.",
6530 "agent_type": "review",
6531 "role": "scout",
6532 "mode": "read_only"
6533 } }]
6534 });"#,
6535 "fleet": "role-only-test"
6536 }),
6537 &ctx,
6538 )
6539 .await
6540 .expect("conflicting type returns a terminal workflow record");
6541 let conflicting_payload: Value =
6542 serde_json::from_str(&conflicting_result.content).expect("workflow JSON");
6543 assert_eq!(
6544 conflicting_payload["status"], "failed",
6545 "{conflicting_payload}"
6546 );
6547 assert!(
6548 conflicting_payload["error"]
6549 .as_str()
6550 .is_some_and(|error| error
6551 .contains("Fleet role conflicts with the explicit legacy agent type")),
6552 "{conflicting_payload}"
6553 );
6554 assert_eq!(
6555 calls.load(Ordering::SeqCst),
6556 2,
6557 "conflicting explicit type must fail before the provider"
6558 );
6559 }
6560
6561 #[test]
6562 fn parallel_write_children_default_to_worktree_isolation() {
6563 // #4120: write-capable parallel leaves get worktree: true by default.
6564 let source = r#"
6565 export default workflow({
6566 "goal": "parallel write isolation default",
6567 "nodes": [
6568 {
6569 "branch": {
6570 "id": "implement",
6571 "parallel": true,
6572 "children": [
6573 {
6574 "agent": {
6575 "id": "left",
6576 "prompt": "Patch left lane",
6577 "agent_type": "implementer",
6578 "mode": "read_write",
6579 "file_scope": ["src/left.rs"]
6580 }
6581 },
6582 {
6583 "agent": {
6584 "id": "right",
6585 "prompt": "Patch right lane",
6586 "agent_type": "implementer",
6587 "mode": "read_write",
6588 "file_scope": ["src/right.rs"]
6589 }
6590 }
6591 ]
6592 }
6593 }
6594 ]
6595 });
6596 "#;
6597 let adapted = adapt_workflow_source(source, None).expect("lower parallel write workflow");
6598 let spec = adapted.spec.expect("declarative spec");
6599 let WorkflowNode::BranchSet(branch) = &spec.nodes[0] else {
6600 panic!("expected branch_set");
6601 };
6602 assert!(branch.parallel);
6603 for child in &branch.children {
6604 let WorkflowNode::Leaf(leaf) = child else {
6605 panic!("expected leaf");
6606 };
6607 assert!(leaf_is_write_capable(leaf));
6608 assert!(
6609 leaf_wants_worktree(leaf, true),
6610 "parallel write leaf {} should default to worktree",
6611 leaf.id
6612 );
6613 assert_eq!(leaf.isolation, IsolationMode::Auto);
6614 }
6615 assert!(
6616 adapted.source.contains("worktree: true"),
6617 "lowered JS should request worktree isolation:\n{}",
6618 adapted.source
6619 );
6620 // Both parallel children should carry the worktree flag.
6621 assert_eq!(
6622 adapted.source.matches("worktree: true").count(),
6623 2,
6624 "each parallel write child should get worktree: true:\n{}",
6625 adapted.source
6626 );
6627 assert_eq!(
6628 adapted
6629 .source
6630 .matches("writeAuthority: \"worktree_write\"")
6631 .count(),
6632 2,
6633 "each isolated writer should carry enforced worktree authority:\n{}",
6634 adapted.source
6635 );
6636 assert!(adapted.source.contains("writeRoots: [\"src/left.rs\"]"));
6637 assert!(adapted.source.contains("writeRoots: [\"src/right.rs\"]"));
6638 }
6639
6640 #[test]
6641 fn parallel_write_same_worktree_requires_explicit_shared_isolation() {
6642 // #4120: isolation: shared is the approved same-worktree override.
6643 let source = r#"
6644 export default workflow({
6645 "goal": "parallel write same-worktree override",
6646 "nodes": [
6647 {
6648 "branch": {
6649 "id": "implement",
6650 "parallel": true,
6651 "children": [
6652 {
6653 "agent": {
6654 "id": "shared-writer",
6655 "prompt": "Patch in the parent checkout",
6656 "agent_type": "implementer",
6657 "mode": "read_write",
6658 "isolation": "shared",
6659 "file_scope": ["src/shared.rs"]
6660 }
6661 },
6662 {
6663 "agent": {
6664 "id": "isolated-writer",
6665 "prompt": "Patch in a worktree",
6666 "agent_type": "implementer",
6667 "mode": "read_write",
6668 "isolation": "worktree",
6669 "file_scope": ["src/isolated.rs"]
6670 }
6671 }
6672 ]
6673 }
6674 }
6675 ]
6676 });
6677 "#;
6678 let adapted =
6679 adapt_workflow_source(source, None).expect("lower same-worktree override workflow");
6680 let spec = adapted.spec.expect("declarative spec");
6681 let WorkflowNode::BranchSet(branch) = &spec.nodes[0] else {
6682 panic!("expected branch_set");
6683 };
6684 let leaves: Vec<&LeafSpec> = branch
6685 .children
6686 .iter()
6687 .map(|child| match child {
6688 WorkflowNode::Leaf(leaf) => leaf,
6689 _ => panic!("expected leaf"),
6690 })
6691 .collect();
6692 assert_eq!(leaves[0].isolation, IsolationMode::Shared);
6693 assert!(
6694 !leaf_wants_worktree(leaves[0], true),
6695 "explicit shared should keep same-worktree"
6696 );
6697 assert_eq!(leaves[1].isolation, IsolationMode::Worktree);
6698 assert!(leaf_wants_worktree(leaves[1], true));
6699
6700 // Only the explicit worktree child should emit worktree: true.
6701 assert_eq!(
6702 adapted.source.matches("worktree: true").count(),
6703 1,
6704 "same-worktree override must not force worktree on shared leaf:\n{}",
6705 adapted.source
6706 );
6707 assert!(
6708 adapted.source.contains("shared-writer") && adapted.source.contains("isolated-writer"),
6709 "both children should still be lowered:\n{}",
6710 adapted.source
6711 );
6712 assert!(
6713 adapted
6714 .source
6715 .contains("writeAuthority: \"workspace_write\"")
6716 );
6717 assert!(
6718 adapted
6719 .source
6720 .contains("writeAuthority: \"worktree_write\"")
6721 );
6722 }
6723
6724 #[test]
6725 fn parallel_read_only_children_do_not_default_to_worktree() {
6726 let source = r#"
6727 export default workflow({
6728 "goal": "parallel read-only stays shared",
6729 "nodes": [
6730 {
6731 "branch": {
6732 "id": "audit",
6733 "parallel": true,
6734 "children": [
6735 {
6736 "agent": {
6737 "id": "review-a",
6738 "prompt": "Review A",
6739 "agent_type": "review",
6740 "mode": "read_only"
6741 }
6742 },
6743 {
6744 "agent": {
6745 "id": "review-b",
6746 "prompt": "Review B",
6747 "agent_type": "verifier",
6748 "mode": "read_only"
6749 }
6750 }
6751 ]
6752 }
6753 }
6754 ]
6755 });
6756 "#;
6757 let adapted = adapt_workflow_source(source, None).expect("lower parallel read-only");
6758 assert!(
6759 !adapted.source.contains("worktree: true"),
6760 "read-only parallel children should not get worktree isolation:\n{}",
6761 adapted.source
6762 );
6763 assert_eq!(
6764 adapted
6765 .source
6766 .matches("writeAuthority: \"read_only\"")
6767 .count(),
6768 2,
6769 "read-only mode must reach the task authority contract:\n{}",
6770 adapted.source
6771 );
6772 }
6773
6774 #[test]
6775 fn sequential_write_children_do_not_default_to_worktree() {
6776 let source = r#"
6777 export default workflow({
6778 "goal": "sequential write stays shared by default",
6779 "nodes": [
6780 {
6781 "sequence": {
6782 "id": "implement",
6783 "children": [
6784 {
6785 "agent": {
6786 "id": "writer",
6787 "prompt": "Patch sequentially",
6788 "agent_type": "implementer",
6789 "mode": "read_write",
6790 "file_scope": ["src/main.rs"]
6791 }
6792 }
6793 ]
6794 }
6795 }
6796 ]
6797 });
6798 "#;
6799 let adapted = adapt_workflow_source(source, None).expect("lower sequential write");
6800 assert!(
6801 !adapted.source.contains("worktree: true"),
6802 "sequential writes should not default to worktree:\n{}",
6803 adapted.source
6804 );
6805 assert!(
6806 adapted
6807 .source
6808 .contains("writeAuthority: \"workspace_write\"")
6809 );
6810 assert!(adapted.source.contains("writeRoots: [\"src/main.rs\"]"));
6811 }
6812
6813 #[test]
6814 fn write_scope_suffix_globs_lower_to_enforceable_roots() {
6815 let source = r#"workflow({
6816 "goal": "bounded auth patch",
6817 "nodes": [{ "agent": {
6818 "id": "writer",
6819 "prompt": "Patch auth",
6820 "agent_type": "implementer",
6821 "mode": "read_write",
6822 "file_scope": ["./src/auth/**"]
6823 }}]
6824 });"#;
6825 let adapted = adapt_workflow_source(source, None).expect("lower trailing glob scope");
6826 assert!(
6827 adapted.source.contains("writeRoots: [\"src/auth\"]"),
6828 "runtime claim must contain src/auth/login.rs:\n{}",
6829 adapted.source
6830 );
6831
6832 let unsupported = r#"workflow({
6833 "goal": "reject ambiguous glob",
6834 "nodes": [{ "agent": {
6835 "id": "writer",
6836 "prompt": "Patch auth",
6837 "agent_type": "implementer",
6838 "mode": "read_write",
6839 "file_scope": ["src/*/auth"]
6840 }}]
6841 });"#;
6842 let error = match adapt_workflow_source(unsupported, None) {
6843 Ok(_) => panic!("internal globs cannot become literal runtime roots"),
6844 Err(error) => error.to_string(),
6845 };
6846 assert!(error.contains("unsupported file_scope"), "{error}");
6847 }
6848
6849 #[test]
6850 fn write_leaves_require_scope_and_reduce_stays_read_only() {
6851 let unscoped_writer = r#"workflow({
6852 "goal": "reject an unbounded writer",
6853 "nodes": [{ "agent": {
6854 "id": "writer",
6855 "prompt": "Patch it",
6856 "agent_type": "implementer",
6857 "mode": "read_write"
6858 }}]
6859 });"#;
6860 let error = match adapt_workflow_source(unscoped_writer, None) {
6861 Ok(_) => panic!("write-capable leaves must declare file_scope"),
6862 Err(error) => error.to_string(),
6863 };
6864 assert!(error.contains("declares no file_scope"), "{error}");
6865
6866 let reduce = r#"workflow({
6867 "goal": "reduce read-only evidence",
6868 "nodes": [{ "reduce": {
6869 "id": "summary",
6870 "inputs": [],
6871 "prompt": "Summarize the evidence"
6872 }}]
6873 });"#;
6874 let adapted = adapt_workflow_source(reduce, None).expect("lower reduce");
6875 assert!(adapted.source.contains("type: \"plan\""));
6876 assert!(adapted.source.contains("writeAuthority: \"read_only\""));
6877 }
6878
6879 #[test]
6880 fn inline_script_and_source_path_are_mutually_exclusive() {
6881 let ctx = ToolContext::new(".");
6882 let err = workflow_source(
6883 &json!({
6884 "script": "return 1;",
6885 "source_path": "workflow.js"
6886 }),
6887 &ctx,
6888 )
6889 .unwrap_err();
6890 assert!(
6891 err.to_string()
6892 .contains("exactly one of script, source_path, or plan"),
6893 "{err}"
6894 );
6895 }
6896
6897 #[test]
6898 fn structured_plan_lowers_to_parallel_not_promise_all() {
6899 // #4124: planner plan → JS with parallel() partial-success semantics.
6900 let ctx = ToolContext::new(".");
6901 let source = workflow_source(
6902 &json!({
6903 "plan": {
6904 "goal": "audit two independent scopes",
6905 "risk": "read_only",
6906 "max_children": 8,
6907 "token_budget": 120000,
6908 "phases": [{
6909 "id": "scout",
6910 "title": "Scout",
6911 "children": [
6912 {
6913 "id": "left",
6914 "label": "left-lane",
6915 "prompt": "Inspect crates/left",
6916 "type": "explore"
6917 },
6918 {
6919 "id": "right",
6920 "prompt": "Inspect crates/right",
6921 "type": "explore"
6922 }
6923 ]
6924 }]
6925 }
6926 }),
6927 &ctx,
6928 )
6929 .expect("structured plan should lower");
6930
6931 assert!(
6932 source.source.contains("await parallel(["),
6933 "lowered JS must use parallel():\n{}",
6934 source.source
6935 );
6936 assert!(
6937 !source.source.contains("Promise.all"),
6938 "lowered JS must not use raw Promise.all:\n{}",
6939 source.source
6940 );
6941 assert!(
6942 source.source.contains("() => task("),
6943 "parallel slots should be thunks:\n{}",
6944 source.source
6945 );
6946 let spec = source.spec.expect("plan should produce WorkflowSpec");
6947 assert_eq!(spec.goal, "audit two independent scopes");
6948 assert_eq!(spec.budget.max_tokens, Some(120000));
6949 assert_eq!(spec.nodes.len(), 1);
6950 let WorkflowNode::BranchSet(branch) = &spec.nodes[0] else {
6951 panic!("expected parallel branch for multi-child phase");
6952 };
6953 assert!(branch.parallel);
6954 assert_eq!(branch.children.len(), 2);
6955 }
6956
6957 #[test]
6958 fn structured_plan_validation_errors_are_typed() {
6959 let ctx = ToolContext::new(".");
6960 let missing_goal = workflow_source(
6961 &json!({
6962 "plan": {
6963 "goal": " ",
6964 "children": [{ "prompt": "do work" }]
6965 }
6966 }),
6967 &ctx,
6968 )
6969 .unwrap_err();
6970 assert!(missing_goal.to_string().contains("goal"), "{missing_goal}");
6971
6972 let over_limit = workflow_source(
6973 &json!({
6974 "plan": {
6975 "goal": "too many children",
6976 "max_children": 1,
6977 "children": [
6978 { "id": "a", "prompt": "one" },
6979 { "id": "b", "prompt": "two" }
6980 ]
6981 }
6982 }),
6983 &ctx,
6984 )
6985 .unwrap_err();
6986 assert!(
6987 over_limit.to_string().contains("max_children"),
6988 "{over_limit}"
6989 );
6990
6991 let bad_type = workflow_source(
6992 &json!({
6993 "plan": {
6994 "goal": "bad type",
6995 "children": [{ "prompt": "x", "type": "wizard" }]
6996 }
6997 }),
6998 &ctx,
6999 )
7000 .unwrap_err();
7001 assert!(
7002 bad_type
7003 .to_string()
7004 .contains("Invalid sub-agent type 'wizard'"),
7005 "{bad_type}"
7006 );
7007
7008 let exclusive = workflow_source(
7009 &json!({
7010 "script": "return 1;",
7011 "plan": { "goal": "x", "children": [{ "prompt": "y" }] }
7012 }),
7013 &ctx,
7014 )
7015 .unwrap_err();
7016 assert!(
7017 exclusive
7018 .to_string()
7019 .contains("exactly one of script, source_path, or plan"),
7020 "{exclusive}"
7021 );
7022 }
7023
7024 #[test]
7025 fn plan_child_type_shares_the_agent_tool_option_vocabulary() {
7026 // #5035: type values accepted by direct Agent dispatch must not be
7027 // rejected by Workflow plan authoring; aliases normalize onto the IR.
7028 for (alias, expected) in [
7029 ("worker", AgentType::General),
7030 ("delegate", AgentType::General),
7031 ("scout", AgentType::Explore),
7032 ("Explorer", AgentType::Explore),
7033 ("planner", AgentType::Plan),
7034 ("awaiter", AgentType::Plan),
7035 ("reviewer", AgentType::Review),
7036 ("consultant", AgentType::Review),
7037 ("oracle", AgentType::Review),
7038 ("advisor", AgentType::Review),
7039 ("builder", AgentType::Implementer),
7040 ("verifier", AgentType::Verifier),
7041 ] {
7042 assert_eq!(
7043 parse_plan_agent_type(Some(alias))
7044 .unwrap_or_else(|err| panic!("{alias} rejected: {err}")),
7045 expected,
7046 "{alias}"
7047 );
7048 }
7049
7050 // Typos fail with the Agent tool's error contract and the full set.
7051 let typo = parse_plan_agent_type(Some("wizard"))
7052 .unwrap_err()
7053 .to_string();
7054 assert!(typo.contains("Invalid sub-agent type 'wizard'"), "{typo}");
7055 assert!(
7056 typo.contains("worker, scout, planner, reviewer, builder"),
7057 "{typo}"
7058 );
7059 assert!(
7060 typo.contains("consultant/oracle/advisor"),
7061 "accepted advisory aliases must remain visible in the guidance: {typo}"
7062 );
7063
7064 // `custom` is Agent-only; the rejection says why and what to use.
7065 let custom = parse_plan_agent_type(Some("custom"))
7066 .unwrap_err()
7067 .to_string();
7068 assert!(
7069 custom.contains("Invalid sub-agent type 'custom'"),
7070 "{custom}"
7071 );
7072 assert!(custom.contains("allowed_tools"), "{custom}");
7073 }
7074
7075 #[test]
7076 fn declarative_parallel_branch_uses_parallel_helper() {
7077 let source = r#"
7078 export default workflow({
7079 "goal": "partial success fan-out",
7080 "nodes": [
7081 {
7082 "branch": {
7083 "id": "fan",
7084 "parallel": true,
7085 "children": [
7086 { "agent": { "id": "a", "prompt": "A", "agent_type": "explore", "mode": "read_only" } },
7087 { "agent": { "id": "b", "prompt": "B", "agent_type": "explore", "mode": "read_only" } }
7088 ]
7089 }
7090 }
7091 ]
7092 });
7093 "#;
7094 let adapted = adapt_workflow_source(source, None).expect("lower declarative");
7095 assert!(
7096 adapted.source.contains("await parallel(["),
7097 "declarative parallel must lower via parallel():\n{}",
7098 adapted.source
7099 );
7100 assert!(
7101 !adapted.source.contains("Promise.all"),
7102 "must not emit raw Promise.all:\n{}",
7103 adapted.source
7104 );
7105 }
7106
7107 #[test]
7108 fn source_path_must_stay_inside_workspace_without_trust_mode() {
7109 let workspace = tempfile::tempdir().expect("workspace tempdir");
7110 let outside = tempfile::tempdir().expect("outside tempdir");
7111 let outside_path = outside.path().join("outside.workflow.js");
7112 std::fs::write(&outside_path, "return 1;").expect("outside workflow source");
7113 let ctx = ToolContext::new(workspace.path().to_path_buf());
7114
7115 let err = workflow_source(
7116 &json!({
7117 "source_path": outside_path
7118 }),
7119 &ctx,
7120 )
7121 .expect_err("outside source_path should be denied");
7122
7123 assert!(
7124 err.to_string().contains("must stay inside the workspace"),
7125 "{err}"
7126 );
7127 }
7128
7129 #[test]
7130 fn subagent_tool_surface_registers_workflow_and_agent() {
7131 let tmp = tempfile::tempdir().expect("tempdir");
7132 let ctx = ToolContext::new(tmp.path().to_path_buf());
7133 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
7134 let runtime = SubAgentRuntime::new(
7135 stub_client(),
7136 "deepseek-v4-flash".to_string(),
7137 ctx.clone(),
7138 true,
7139 None,
7140 manager.clone(),
7141 );
7142 let registry = ToolRegistryBuilder::new()
7143 .with_subagent_tools(manager, runtime)
7144 .build(ctx);
7145
7146 assert!(registry.contains("workflow"));
7147 assert!(registry.contains("agent"));
7148 assert!(registry.contains("agents/list"));
7149 assert!(registry.contains("agents/message"));
7150 assert!(registry.contains("agents/followup"));
7151 assert!(registry.contains("agents/interrupt"));
7152 assert!(registry.contains("agents/wait"));
7153 assert!(
7154 registry
7155 .to_api_tools()
7156 .iter()
7157 .any(|tool| tool.name == "workflow")
7158 );
7159 }
7160
7161 #[tokio::test]
7162 #[allow(clippy::await_holding_lock)]
7163 async fn workflow_run_dispatches_task_through_subagent_manager() {
7164 let _retry_guard = workflow_test_retry_guard();
7165 let tmp = tempfile::tempdir().expect("tempdir");
7166 let ctx = ToolContext::new(tmp.path().to_path_buf());
7167 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
7168 let (client, calls) = fake_chat_client("child done").await;
7169 let runtime = SubAgentRuntime::new(
7170 client,
7171 "deepseek-v4-flash".to_string(),
7172 ctx.clone(),
7173 true,
7174 None,
7175 manager.clone(),
7176 );
7177 let tool = WorkflowTool::new(manager.clone(), runtime);
7178
7179 let result = tool
7180 .execute(
7181 json!({
7182 "action": "run",
7183 "script": "phase('dispatch'); log('starting child'); const out = await task({ description: 'say done', type: 'explore', allowedTools: [], label: 'inspect-child', model: 'deepseek-v4-flash', modelStrength: 'same', thinking: 'low' }); return { out };"
7184 }),
7185 &ctx,
7186 )
7187 .await
7188 .expect("workflow run should complete");
7189 let payload: Value = serde_json::from_str(&result.content).expect("json result");
7190
7191 assert_eq!(payload["status"], "completed", "{payload}");
7192 assert_eq!(payload["result"]["out"], "child done");
7193 assert_eq!(payload["child_ids"].as_array().unwrap().len(), 1);
7194 assert_eq!(calls.load(Ordering::SeqCst), 1);
7195
7196 let child_id = payload["child_ids"][0].as_str().unwrap();
7197 let events = payload["events"].as_array().expect("events array");
7198 assert!(
7199 events
7200 .iter()
7201 .any(|event| event["type"] == "phase_started" && event["title"] == "dispatch"),
7202 "{events:#?}"
7203 );
7204 assert!(
7205 events
7206 .iter()
7207 .any(|event| event["type"] == "log" && event["message"] == "starting child"),
7208 "{events:#?}"
7209 );
7210 assert!(
7211 events.iter().any(|event| event["type"] == "budget_updated"),
7212 "{events:#?}"
7213 );
7214 let task_started = events
7215 .iter()
7216 .find(|event| event["type"] == "task_started")
7217 .expect("task_started event");
7218 assert_eq!(task_started["task_id"], child_id);
7219 assert_eq!(task_started["label"], "inspect-child");
7220 assert!(task_started["profile"].is_null());
7221 assert_eq!(task_started["model"], "deepseek-v4-flash");
7222 assert_eq!(task_started["strength"], "same");
7223 assert_eq!(task_started["thinking"], "low");
7224 assert_eq!(task_started["requested_reasoning"], "low");
7225 assert!(
7226 task_started["effective_reasoning"]
7227 .as_str()
7228 .is_some_and(|value| !value.is_empty()),
7229 "{task_started}"
7230 );
7231 assert_eq!(task_started["resolved_provider"], "deepseek");
7232 assert_eq!(task_started["resolved_model"], "deepseek-v4-flash");
7233 assert_eq!(task_started["route_source"], "task.model");
7234 assert_eq!(task_started["worktree"], false);
7235 assert!(task_started["parent_task_id"].is_null());
7236 assert_eq!(task_started["depth"], 1);
7237 // #4119: workflow identity on spawn / task_started metadata.
7238 assert_eq!(
7239 task_started["workflow_run_id"].as_str(),
7240 payload["run_id"].as_str()
7241 );
7242 assert_eq!(task_started["workflow_phase_id"], "dispatch");
7243 assert_eq!(task_started["workflow_task_label"], "inspect-child");
7244 assert_eq!(task_started["workflow_child_index"], 0);
7245 assert!(
7246 events.iter().any(|event| event["type"] == "task_completed"
7247 && event["task_id"] == child_id
7248 && event["status"] == "succeeded"),
7249 "{events:#?}"
7250 );
7251 let child = manager
7252 .read()
7253 .await
7254 .get_result(child_id)
7255 .expect("child result");
7256 assert_eq!(child.status, SubAgentStatus::Completed);
7257 assert_eq!(child.result.as_deref(), Some("child done"));
7258
7259 // Full receipt chain: the spawn-minted event survives the JSONL
7260 // journal reload, hydrates the live projection, and round-trips into
7261 // history without following a later route or inventing missing usage.
7262 let reloaded = WorkflowWorkspaceState::open(tmp.path());
7263 let persisted = reloaded
7264 .runs
7265 .lock()
7266 .expect("reloaded workflow runs")
7267 .get(payload["run_id"].as_str().expect("run id"))
7268 .cloned()
7269 .expect("persisted run");
7270 let persisted_json = serde_json::to_value(&persisted).expect("persisted run JSON");
7271 let persisted_started = persisted_json["events"]
7272 .as_array()
7273 .and_then(|events| events.iter().find(|event| event["type"] == "task_started"))
7274 .expect("persisted task_started receipt");
7275 assert_eq!(persisted_started["requested_reasoning"], "low");
7276 assert_eq!(
7277 persisted_started["effective_reasoning"],
7278 task_started["effective_reasoning"]
7279 );
7280 assert_eq!(persisted_started["resolved_provider"], "deepseek");
7281 assert_eq!(persisted_started["resolved_model"], "deepseek-v4-flash");
7282 assert_eq!(persisted_started["route_source"], "task.model");
7283
7284 let mut panel =
7285 crate::tui::widgets::workflow_panel::WorkflowPanel::from_run_json(&persisted_json)
7286 .expect("journal should hydrate workflow panel");
7287 let original_receipt = panel
7288 .phases
7289 .iter()
7290 .flat_map(|phase| phase.rows.iter())
7291 .find(|row| row.task_id == child_id)
7292 .map(crate::tui::widgets::workflow_panel::row_receipt_text)
7293 .expect("spawned child receipt");
7294 assert!(original_receipt.contains("deepseek/deepseek-v4-flash"));
7295 assert!(original_receipt.contains("reasoning low→"));
7296 assert!(original_receipt.contains("via task.model"));
7297
7298 panel.apply_json_event(&json!({
7299 "type": "task_started",
7300 "at_ms": 9_000,
7301 "task_id": "later-route",
7302 "label": "later-route",
7303 "resolved_role": "consultant",
7304 "resolved_provider": "moonshot",
7305 "resolved_model": "kimi-k3",
7306 "requested_reasoning": "auto",
7307 "effective_reasoning": "medium",
7308 "route_source": "agent_profile.model",
7309 "worktree": false,
7310 }));
7311 panel.apply_json_event(&json!({
7312 "type": "task_completed",
7313 "at_ms": 9_100,
7314 "task_id": "later-route",
7315 "status": "succeeded"
7316 }));
7317 let unchanged = panel
7318 .phases
7319 .iter()
7320 .flat_map(|phase| phase.rows.iter())
7321 .find(|row| row.task_id == child_id)
7322 .map(crate::tui::widgets::workflow_panel::row_receipt_text)
7323 .expect("original child after later route");
7324 assert_eq!(unchanged, original_receipt);
7325
7326 let history =
7327 crate::tui::widgets::workflow_panel::WorkflowPanel::from_run_json(&panel.to_run_json())
7328 .expect("history receipt round trip");
7329 let later_receipt = history
7330 .phases
7331 .iter()
7332 .flat_map(|phase| phase.rows.iter())
7333 .find(|row| row.task_id == "later-route")
7334 .map(crate::tui::widgets::workflow_panel::row_receipt_text)
7335 .expect("later route history receipt");
7336 assert!(later_receipt.contains("moonshot/kimi-k3"));
7337 assert!(later_receipt.contains("reasoning auto→medium"));
7338 assert!(later_receipt.contains("tokens unknown"));
7339 assert!(!later_receipt.contains("tokens 0"));
7340 }
7341
7342 #[tokio::test]
7343 #[allow(clippy::await_holding_lock)]
7344 async fn named_fleet_run_emits_role_resolved_receipt_and_rejects_unknown_before_provider() {
7345 let _retry_guard = workflow_test_retry_guard();
7346 let _env_lock = crate::test_support::lock_test_env();
7347 let tmp = tempfile::tempdir().expect("tempdir");
7348 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
7349 std::fs::create_dir_all(tmp.path().join("fleets")).expect("fleets dir");
7350 std::fs::write(
7351 tmp.path().join("fleets/offline.toml"),
7352 r#"
7353 name = "offline"
7354 [roles]
7355 reviewer = "reviewer"
7356 "#,
7357 )
7358 .expect("named fleet fixture");
7359
7360 let ctx = ToolContext::new(tmp.path().to_path_buf());
7361 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
7362 let (client, calls) = fake_chat_client("role-resolved child").await;
7363 let runtime = SubAgentRuntime::new(
7364 client,
7365 "deepseek-v4-flash".to_string(),
7366 ctx.clone(),
7367 true,
7368 None,
7369 manager.clone(),
7370 );
7371 let tool = WorkflowTool::new(manager, runtime);
7372
7373 let completed = tool
7374 .execute(
7375 json!({
7376 "action": "run",
7377 "fleet": "offline",
7378 "script": "return await task({ description: 'review it', type: 'review', role: 'reviewer', label: 'offline-review' });"
7379 }),
7380 &ctx,
7381 )
7382 .await
7383 .expect("named fleet workflow");
7384 let payload: Value = serde_json::from_str(&completed.content).expect("workflow JSON");
7385 assert_eq!(payload["status"], "completed", "{payload}");
7386 assert_eq!(payload["result"], "role-resolved child");
7387 assert_eq!(calls.load(Ordering::SeqCst), 1);
7388 let started = payload["events"]
7389 .as_array()
7390 .and_then(|events| events.iter().find(|event| event["type"] == "task_started"))
7391 .expect("task_started receipt");
7392 assert_eq!(started["role"], "reviewer");
7393 assert_eq!(started["profile"], "reviewer");
7394 assert_eq!(started["resolved_role"], "reviewer");
7395 assert_eq!(started["resolved_profile"], "reviewer");
7396 assert_eq!(started["resolved_provider"], "deepseek");
7397 assert_eq!(started["resolved_model"], "deepseek-v4-flash");
7398 assert_eq!(started["route_source"], "run.model");
7399 assert!(
7400 payload["events"]
7401 .as_array()
7402 .is_some_and(|events| events.iter().any(|event| event["type"] == "task_completed"))
7403 );
7404
7405 let rejected = tool
7406 .execute(
7407 json!({
7408 "action": "run",
7409 "fleet": "offline",
7410 "script": "return await task({ description: 'must not launch', type: 'review', role: 'wizard' });"
7411 }),
7412 &ctx,
7413 )
7414 .await
7415 .expect("rejected workflow still returns its terminal record");
7416 let rejected: Value = serde_json::from_str(&rejected.content).expect("rejected JSON");
7417 assert_eq!(rejected["status"], "failed", "{rejected}");
7418 assert!(
7419 rejected["error"]
7420 .as_str()
7421 .is_some_and(|error| error.contains("unknown fleet role `wizard`")),
7422 "{rejected}"
7423 );
7424 assert_eq!(
7425 calls.load(Ordering::SeqCst),
7426 1,
7427 "unknown role must fail before a second provider call"
7428 );
7429 }
7430
7431 #[tokio::test]
7432 #[allow(clippy::await_holding_lock)]
7433 async fn workflow_spawn_records_carry_child_index_and_phase_metadata() {
7434 // #4119: sequential children get monotonic workflow_child_index and
7435 // inherit the active phase when task options omit `phase`.
7436 let _retry_guard = workflow_test_retry_guard();
7437 let tmp = tempfile::tempdir().expect("tempdir");
7438 let ctx = ToolContext::new(tmp.path().to_path_buf());
7439 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 4);
7440 let (client, calls) = fake_chat_client("ok").await;
7441 let runtime = SubAgentRuntime::new(
7442 client,
7443 "deepseek-v4-flash".to_string(),
7444 ctx.clone(),
7445 true,
7446 None,
7447 manager.clone(),
7448 );
7449 let tool = WorkflowTool::new(manager.clone(), runtime);
7450
7451 let result = tool
7452 .execute(
7453 json!({
7454 "action": "run",
7455 "script": "phase('alpha'); await task({ description: 'first', type: 'explore', allowedTools: [], label: 'one' }); phase('beta'); await task({ description: 'second', type: 'explore', allowedTools: [], label: 'two', phase: 'beta-explicit' }); return { ok: true };"
7456 }),
7457 &ctx,
7458 )
7459 .await
7460 .expect("workflow run should complete");
7461 let payload: Value = serde_json::from_str(&result.content).expect("json result");
7462 assert_eq!(payload["status"], "completed", "{payload}");
7463 assert_eq!(payload["child_ids"].as_array().unwrap().len(), 2);
7464 assert_eq!(calls.load(Ordering::SeqCst), 2);
7465
7466 let mut started: Vec<&Value> = payload["events"]
7467 .as_array()
7468 .expect("events")
7469 .iter()
7470 .filter(|event| event["type"] == "task_started")
7471 .collect();
7472 started.sort_by_key(|event| event["workflow_child_index"].as_u64().unwrap_or(u64::MAX));
7473 assert_eq!(started.len(), 2, "{started:#?}");
7474
7475 assert_eq!(started[0]["workflow_run_id"], payload["run_id"]);
7476 assert_eq!(started[0]["workflow_phase_id"], "alpha");
7477 assert_eq!(started[0]["workflow_task_label"], "one");
7478 assert_eq!(started[0]["workflow_child_index"], 0);
7479 assert_eq!(started[0]["label"], "one");
7480
7481 assert_eq!(started[1]["workflow_run_id"], payload["run_id"]);
7482 // Explicit task phase wins over the driver's current phase.
7483 assert_eq!(started[1]["workflow_phase_id"], "beta-explicit");
7484 assert_eq!(started[1]["workflow_task_label"], "two");
7485 assert_eq!(started[1]["workflow_child_index"], 1);
7486 assert_eq!(started[1]["label"], "two");
7487 }
7488
7489 #[tokio::test]
7490 #[allow(clippy::await_holding_lock)]
7491 async fn declarative_parallel_spawn_failure_nulls_slot_and_continues() {
7492 // #4124: parallel() is all-settled — a rejected spawn becomes a null slot
7493 // (with a breadcrumb) instead of aborting the rest of the script the way
7494 // raw Promise.all would. Downstream reduce still runs on partial results.
7495 let _retry_guard = workflow_test_retry_guard();
7496 let tmp = tempfile::tempdir().expect("tempdir");
7497 let ctx = ToolContext::new(tmp.path().to_path_buf());
7498 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
7499 let (client, calls) = fake_chat_client("reduce-with-partial").await;
7500 let runtime = SubAgentRuntime::new(
7501 client,
7502 "deepseek-v4-flash".to_string(),
7503 ctx.clone(),
7504 true,
7505 None,
7506 manager,
7507 );
7508 let tool = WorkflowTool::new(runtime.manager.clone(), runtime);
7509
7510 let result = tool
7511 .execute(
7512 json!({
7513 "action": "run",
7514 "script": r#"export default workflow({
7515 "goal": "partial success fan-out",
7516 "nodes": [
7517 {
7518 "branch": {
7519 "id": "parallel",
7520 "parallel": true,
7521 "children": [
7522 {
7523 "agent": {
7524 "id": "bad-profile",
7525 "prompt": "This child should be rejected before model execution.",
7526 "profile": "missing-profile"
7527 }
7528 }
7529 ]
7530 }
7531 },
7532 {
7533 "reduce": {
7534 "id": "summary",
7535 "inputs": ["bad-profile"],
7536 "prompt": "Summarize whatever survived the parallel fan-out."
7537 }
7538 }
7539 ]
7540 });"#
7541 }),
7542 &ctx,
7543 )
7544 .await
7545 .expect("partial-success workflow still returns run record");
7546 let payload: Value = serde_json::from_str(&result.content).expect("json result");
7547
7548 // Receipt honesty (morning-report issue #2): the run keeps its output
7549 // and the reduce still runs, but a dropped slot means the status is
7550 // degraded, never a plain completed.
7551 assert_eq!(payload["status"], "degraded", "{payload}");
7552 let degradation = payload["error"].as_str().expect("degradation surfaced");
7553 assert!(
7554 degradation.contains("result may be partial"),
7555 "{degradation}"
7556 );
7557 assert!(
7558 payload["result"]["bad-profile"].is_null(),
7559 "failed parallel slot should be null: {}",
7560 payload["result"]
7561 );
7562 assert_eq!(payload["result"]["summary"], "reduce-with-partial");
7563 let progress = payload["progress"]
7564 .as_array()
7565 .expect("progress array")
7566 .iter()
7567 .filter_map(|v| v.as_str())
7568 .collect::<Vec<_>>()
7569 .join("\n");
7570 assert!(
7571 progress.contains("missing-profile") && progress.contains("dropped a failed slot"),
7572 "breadcrumb should surface the spawn rejection:\n{progress}"
7573 );
7574 // #5035: partial success is explicit — the rejected slot lands in the
7575 // run record as a structured dispatch failure, not only a log line.
7576 let failures = payload["dispatch_failures"]
7577 .as_array()
7578 .expect("dispatch failures surfaced on the run record");
7579 assert_eq!(failures.len(), 1, "{payload}");
7580 assert!(
7581 failures[0]["message"]
7582 .as_str()
7583 .unwrap_or_default()
7584 .contains("missing-profile"),
7585 "{failures:?}"
7586 );
7587 assert_eq!(
7588 result.metadata.as_ref().expect("metadata")["dispatch_failure_count"],
7589 1
7590 );
7591 assert!(
7592 calls.load(Ordering::SeqCst) >= 1,
7593 "reduce should still run after a null parallel slot"
7594 );
7595 }
7596
7597 #[tokio::test]
7598 #[allow(clippy::await_holding_lock)]
7599 async fn parallel_slots_rejected_by_the_vm_cannot_report_plain_success() {
7600 // Morning-report issue #2: options that fail VM validation throw
7601 // before the driver ever sees a dispatch, and parallel() collapses
7602 // those throws into null slots. The run must classify against the
7603 // slot ledger instead of reporting completed with [null, ...].
7604 let _retry_guard = workflow_test_retry_guard();
7605 let tmp = tempfile::tempdir().expect("tempdir");
7606 let ctx = ToolContext::new(tmp.path().to_path_buf());
7607 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
7608 let (client, calls) = fake_chat_client("unused").await;
7609 let runtime = SubAgentRuntime::new(
7610 client,
7611 "deepseek-v4-flash".to_string(),
7612 ctx.clone(),
7613 true,
7614 None,
7615 manager,
7616 );
7617 let tool = WorkflowTool::new(runtime.manager.clone(), runtime);
7618
7619 let result = tool
7620 .execute(
7621 json!({
7622 "action": "run",
7623 "script": "return await parallel([() => task({ description: 'bad a', type: 'explore', allowedTools: [], cwd: '/absolute/a' }), () => task({ description: 'bad b', type: 'explore', allowedTools: [], cwd: '/absolute/b' })]);"
7624 }),
7625 &ctx,
7626 )
7627 .await
7628 .expect("vm-rejected fan-out still returns the run record");
7629 let payload: Value = serde_json::from_str(&result.content).expect("json result");
7630
7631 assert_eq!(payload["status"], "failed", "{payload}");
7632 let error = payload["error"].as_str().expect("error surfaced");
7633 assert!(
7634 error.contains("all 2 task dispatch(es) were rejected"),
7635 "error should name the total rejection: {error}"
7636 );
7637 let failures = payload["dispatch_failures"]
7638 .as_array()
7639 .expect("collected dispatch failures");
7640 assert_eq!(failures.len(), 2, "{payload}");
7641 for failure in failures {
7642 let message = failure["message"].as_str().expect("failure message");
7643 assert!(message.contains("bounded repo-relative paths"), "{message}");
7644 }
7645 assert_eq!(
7646 calls.load(Ordering::SeqCst),
7647 0,
7648 "no provider call should be spent on vm-rejected slots"
7649 );
7650 }
7651
7652 #[tokio::test]
7653 #[allow(clippy::await_holding_lock)]
7654 async fn partially_dropped_parallel_slots_degrade_the_run() {
7655 // One slot completes, one is rejected before dispatch: the run keeps
7656 // its output but must report degraded, not completed.
7657 let _retry_guard = workflow_test_retry_guard();
7658 let tmp = tempfile::tempdir().expect("tempdir");
7659 let ctx = ToolContext::new(tmp.path().to_path_buf());
7660 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
7661 let (client, calls) = fake_chat_client("child done").await;
7662 let runtime = SubAgentRuntime::new(
7663 client,
7664 "deepseek-v4-flash".to_string(),
7665 ctx.clone(),
7666 true,
7667 None,
7668 manager,
7669 );
7670 let tool = WorkflowTool::new(runtime.manager.clone(), runtime);
7671
7672 let result = tool
7673 .execute(
7674 json!({
7675 "action": "run",
7676 "script": "return await parallel([() => task({ description: 'say done', type: 'explore', allowedTools: [] }), () => task({ description: 'bad slot', type: 'explore', allowedTools: [], cwd: '/absolute/path' })]);"
7677 }),
7678 &ctx,
7679 )
7680 .await
7681 .expect("partially dropped fan-out still returns the run record");
7682 let payload: Value = serde_json::from_str(&result.content).expect("json result");
7683
7684 assert_eq!(payload["status"], "degraded", "{payload}");
7685 let error = payload["error"].as_str().expect("degradation surfaced");
7686 assert!(
7687 error.contains("1 dispatch(es) were rejected"),
7688 "error should count the dropped slot: {error}"
7689 );
7690 assert!(
7691 error.contains("result may be partial"),
7692 "error should warn the result is partial: {error}"
7693 );
7694 let results = payload["result"].as_array().expect("run kept its output");
7695 assert_eq!(results.len(), 2, "{payload}");
7696 assert!(results[1].is_null(), "the rejected slot stays null");
7697 assert!(
7698 calls.load(Ordering::SeqCst) >= 1,
7699 "the healthy slot still ran"
7700 );
7701 }
7702
7703 #[tokio::test]
7704 #[allow(clippy::await_holding_lock)]
7705 async fn parallel_fan_out_with_every_dispatch_rejected_fails_the_run() {
7706 // #5035: when every parallel slot is rejected before dispatch, the run
7707 // must not report overall success that ran nothing — the collected
7708 // per-slot failures surface and the run fails loudly.
7709 let _retry_guard = workflow_test_retry_guard();
7710 let tmp = tempfile::tempdir().expect("tempdir");
7711 let ctx = ToolContext::new(tmp.path().to_path_buf());
7712 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
7713 let (client, calls) = fake_chat_client("unused").await;
7714 let runtime = SubAgentRuntime::new(
7715 client,
7716 "deepseek-v4-flash".to_string(),
7717 ctx.clone(),
7718 true,
7719 None,
7720 manager,
7721 );
7722 let tool = WorkflowTool::new(runtime.manager.clone(), runtime);
7723
7724 let result = tool
7725 .execute(
7726 json!({
7727 "action": "run",
7728 "script": r#"export default workflow({
7729 "goal": "total dispatch failure fan-out",
7730 "nodes": [
7731 {
7732 "branch": {
7733 "id": "parallel",
7734 "parallel": true,
7735 "children": [
7736 {
7737 "agent": {
7738 "id": "bad-one",
7739 "prompt": "Rejected before model execution.",
7740 "profile": "missing-profile"
7741 }
7742 },
7743 {
7744 "agent": {
7745 "id": "bad-two",
7746 "prompt": "Also rejected before model execution.",
7747 "profile": "missing-profile"
7748 }
7749 }
7750 ]
7751 }
7752 }
7753 ]
7754 });"#
7755 }),
7756 &ctx,
7757 )
7758 .await
7759 .expect("total dispatch failure still returns the run record");
7760 let payload: Value = serde_json::from_str(&result.content).expect("json result");
7761
7762 assert_eq!(payload["status"], "failed", "{payload}");
7763 let error = payload["error"].as_str().expect("error surfaced");
7764 assert!(
7765 error.contains("all 2 task dispatch(es) were rejected"),
7766 "error should name the total dispatch failure: {error}"
7767 );
7768 let failures = payload["dispatch_failures"]
7769 .as_array()
7770 .expect("collected dispatch failures");
7771 assert_eq!(failures.len(), 2, "{payload}");
7772 for failure in failures {
7773 let message = failure["message"].as_str().expect("failure message");
7774 assert!(message.contains("missing-profile"), "{message}");
7775 }
7776 assert_eq!(payload["child_ids"].as_array().unwrap().len(), 0);
7777 assert_eq!(
7778 result.metadata.as_ref().expect("metadata")["dispatch_failure_count"],
7779 2
7780 );
7781 assert_eq!(
7782 calls.load(Ordering::SeqCst),
7783 0,
7784 "no provider call should be spent on an all-rejected fan-out"
7785 );
7786 }
7787
7788 #[tokio::test]
7789 #[allow(clippy::await_holding_lock)]
7790 async fn declarative_dependency_results_are_forwarded_to_downstream_prompt() {
7791 let _retry_guard = workflow_test_retry_guard();
7792 let tmp = tempfile::tempdir().expect("tempdir");
7793 let ctx = ToolContext::new(tmp.path().to_path_buf());
7794 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
7795 let (client, calls, bodies) = fake_chat_client_capturing("upstream-output").await;
7796 let runtime = SubAgentRuntime::new(
7797 client,
7798 "deepseek-v4-flash".to_string(),
7799 ctx.clone(),
7800 true,
7801 None,
7802 manager,
7803 );
7804 let tool = WorkflowTool::new(runtime.manager.clone(), runtime);
7805
7806 let result = tool
7807 .execute(
7808 json!({
7809 "action": "run",
7810 "script": r#"export default workflow({
7811 "goal": "dependency forwarding",
7812 "nodes": [
7813 {
7814 "agent": {
7815 "id": "first",
7816 "prompt": "Produce the upstream finding.",
7817 "agent_type": "review"
7818 }
7819 },
7820 {
7821 "agent": {
7822 "id": "second",
7823 "prompt": "Use the upstream finding.",
7824 "agent_type": "review",
7825 "depends_on_results": ["first"]
7826 }
7827 }
7828 ]
7829 });"#
7830 }),
7831 &ctx,
7832 )
7833 .await
7834 .expect("dependency workflow should complete");
7835 let payload: Value = serde_json::from_str(&result.content).expect("json result");
7836
7837 assert_eq!(payload["status"], "completed", "{payload}");
7838 assert_eq!(payload["execution"]["status"], "succeeded");
7839 assert_eq!(
7840 payload["execution"]["leaf_results"][0]["output"],
7841 "upstream-output"
7842 );
7843 assert_eq!(
7844 payload["execution"]["leaf_results"][1]["output"],
7845 "upstream-output"
7846 );
7847 assert_eq!(calls.load(Ordering::SeqCst), 2);
7848 let bodies = bodies.lock().expect("captured bodies");
7849 let second_body = bodies.get(1).expect("second provider call").to_string();
7850 assert!(second_body.contains("--- first ---"), "{second_body}");
7851 assert!(second_body.contains("upstream-output"), "{second_body}");
7852 }
7853
7854 #[tokio::test]
7855 #[allow(clippy::await_holding_lock)]
7856 async fn workflow_runtime_gates_promote_handoff_and_block_downstream_role() {
7857 let tmp = tempfile::tempdir().expect("tempdir");
7858 let ctx = ToolContext::new(tmp.path().to_path_buf());
7859 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
7860 let runtime = SubAgentRuntime::new(
7861 stub_client(),
7862 "deepseek-v4-flash".to_string(),
7863 ctx.clone(),
7864 true,
7865 None,
7866 manager.clone(),
7867 );
7868 let state = WorkflowWorkspaceState::open(tmp.path());
7869 let run_id = "workflow_gate".to_string();
7870 let gates = vec![GateSpec {
7871 id: "scout-findings".to_string(),
7872 role: "scout".to_string(),
7873 on: GateOn::RoleComplete,
7874 gate: GateKind::Approve,
7875 on_fail: codewhale_workflow::GateOnFail::Block,
7876 blocks_role: Some("implementer".to_string()),
7877 max_retries: 0,
7878 artifact_kind: Some("findings".to_string()),
7879 require_explicit_verdict: false,
7880 }];
7881 let spec = WorkflowSpec {
7882 id: Some("gate-fixture".to_string()),
7883 goal: "gate fixture".to_string(),
7884 description: None,
7885 budget: BudgetSpec::default(),
7886 permissions: Default::default(),
7887 model_policy: Default::default(),
7888 promotion_policy: Default::default(),
7889 gates: gates.clone(),
7890 nodes: Vec::new(),
7891 };
7892 state.runs.lock().expect("runs").insert(
7893 run_id.clone(),
7894 WorkflowRunRecord::new(run_id.clone(), None, None, Some(&spec)),
7895 );
7896 let driver = SubAgentWorkflowDriver::new(
7897 run_id.clone(),
7898 manager,
7899 runtime,
7900 state.clone(),
7901 None,
7902 WorkflowFleetBinding::None,
7903 gates,
7904 );
7905
7906 driver.evaluate_gates_for_completed_role(&RuntimeTaskRecord {
7907 agent_id: "scout-agent".to_string(),
7908 label: Some("scout".to_string()),
7909 role: Some("scout".to_string()),
7910 status: IrWorkflowRunStatus::Succeeded,
7911 output: Some("findings: inspect tui exit path".to_string()),
7912 schema_error: None,
7913 usage: None,
7914 });
7915
7916 let mut implementer = TaskRequest {
7917 description: "Use the findings.".to_string(),
7918 subagent_type: Some("implementer".to_string()),
7919 role: Some("implementer".to_string()),
7920 profile: None,
7921 model: None,
7922 model_strength: None,
7923 thinking: None,
7924 cwd: None,
7925 worktree: false,
7926 write_authority: Some("workspace_write".to_string()),
7927 write_roots: vec!["src".to_string()],
7928 exact_files: Vec::new(),
7929 coordination_contracts: vec!["test-contract".to_string()],
7930 dependencies: Vec::new(),
7931 acceptance: Vec::new(),
7932 allowed_tools: Some(Vec::new()),
7933 disallowed_tools: Vec::new(),
7934 max_depth: None,
7935 token_budget: None,
7936 max_steps: None,
7937 wall_time_secs: None,
7938 response_schema: None,
7939 label: Some("fix".to_string()),
7940 phase: None,
7941 };
7942 let handoffs = driver
7943 .prepare_request_for_gates(&mut implementer)
7944 .expect("passed gate should admit implementer");
7945 assert_eq!(handoffs.len(), 1, "{handoffs:?}");
7946 assert_eq!(handoffs[0].kind, "findings");
7947 assert_eq!(handoffs[0].from_role, "scout");
7948 assert_eq!(handoffs[0].to_role, "implementer");
7949 assert!(
7950 implementer
7951 .description
7952 .contains("Workflow handoff artifacts available"),
7953 "{}",
7954 implementer.description
7955 );
7956 assert!(
7957 implementer.description.contains("inspect tui exit path"),
7958 "{}",
7959 implementer.description
7960 );
7961
7962 driver.evaluate_gates_for_completed_role(&RuntimeTaskRecord {
7963 agent_id: "scout-agent-2".to_string(),
7964 label: Some("scout".to_string()),
7965 role: Some("scout".to_string()),
7966 status: IrWorkflowRunStatus::Failed,
7967 output: Some("scout incomplete".to_string()),
7968 schema_error: None,
7969 usage: None,
7970 });
7971 let mut blocked = TaskRequest {
7972 description: "Try after block.".to_string(),
7973 role: Some("implementer".to_string()),
7974 ..implementer.clone()
7975 };
7976 let err = driver
7977 .prepare_request_for_gates(&mut blocked)
7978 .expect_err("blocked gate should reject downstream role");
7979 assert!(err.to_string().contains("scout incomplete"), "{err}");
7980
7981 let run = state
7982 .runs
7983 .lock()
7984 .expect("runs")
7985 .get(&run_id)
7986 .cloned()
7987 .expect("run");
7988 assert!(
7989 run.gate_status
7990 .iter()
7991 .any(|line| line.gate_id == "scout-findings"
7992 && line.state == "blocked"
7993 && line.blocked_reason.as_deref() == Some("scout incomplete")),
7994 "{:?}",
7995 run.gate_status
7996 );
7997 assert!(
7998 run.events
7999 .iter()
8000 .any(|event| event.event_type() == "gate_updated"),
8001 "{:?}",
8002 run.events
8003 );
8004 assert_eq!(
8005 run.events
8006 .iter()
8007 .filter(|event| event.event_type() == "handoff_promoted")
8008 .count(),
8009 1,
8010 "a later blocked gate must not publish another handoff: {:?}",
8011 run.events
8012 );
8013 assert!(
8014 run.events
8015 .iter()
8016 .all(|event| event.event_type() != "handoff_consumed"),
8017 "request preparation alone must not claim consumption: {:?}",
8018 run.events
8019 );
8020 }
8021
8022 #[tokio::test]
8023 async fn workflow_handoff_is_delivered_to_exactly_one_task() {
8024 // LaneGateBoard.artifacts used to be append-only: every same-role
8025 // task re-received up to 4 prior handoff payloads while
8026 // HandoffConsumed receipts fired as if they were spent.
8027 let tmp = tempfile::tempdir().expect("tempdir");
8028 let ctx = ToolContext::new(tmp.path().to_path_buf());
8029 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
8030 let runtime = SubAgentRuntime::new(
8031 stub_client(),
8032 "deepseek-v4-flash".to_string(),
8033 ctx.clone(),
8034 true,
8035 None,
8036 manager.clone(),
8037 );
8038 let state = WorkflowWorkspaceState::open(tmp.path());
8039 let run_id = "workflow_handoff_once".to_string();
8040 let gates = vec![GateSpec {
8041 id: "scout-findings".to_string(),
8042 role: "scout".to_string(),
8043 on: GateOn::RoleComplete,
8044 gate: GateKind::Approve,
8045 on_fail: codewhale_workflow::GateOnFail::Block,
8046 blocks_role: Some("implementer".to_string()),
8047 max_retries: 0,
8048 artifact_kind: Some("findings".to_string()),
8049 require_explicit_verdict: false,
8050 }];
8051 let spec = WorkflowSpec {
8052 id: Some("handoff-once-fixture".to_string()),
8053 goal: "handoff delivered once".to_string(),
8054 description: None,
8055 budget: BudgetSpec::default(),
8056 permissions: Default::default(),
8057 model_policy: Default::default(),
8058 promotion_policy: Default::default(),
8059 gates: gates.clone(),
8060 nodes: Vec::new(),
8061 };
8062 state.runs.lock().expect("runs").insert(
8063 run_id.clone(),
8064 WorkflowRunRecord::new(run_id.clone(), None, None, Some(&spec)),
8065 );
8066 let driver = SubAgentWorkflowDriver::new(
8067 run_id.clone(),
8068 manager,
8069 runtime,
8070 state.clone(),
8071 None,
8072 WorkflowFleetBinding::None,
8073 gates,
8074 );
8075
8076 driver.evaluate_gates_for_completed_role(&RuntimeTaskRecord {
8077 agent_id: "scout-agent".to_string(),
8078 label: Some("scout".to_string()),
8079 role: Some("scout".to_string()),
8080 status: IrWorkflowRunStatus::Succeeded,
8081 output: Some("findings: exactly once".to_string()),
8082 schema_error: None,
8083 usage: None,
8084 });
8085
8086 let implementer = TaskRequest {
8087 description: "Use the findings.".to_string(),
8088 subagent_type: Some("implementer".to_string()),
8089 role: Some("implementer".to_string()),
8090 profile: None,
8091 model: None,
8092 model_strength: None,
8093 thinking: None,
8094 cwd: None,
8095 worktree: false,
8096 write_authority: Some("workspace_write".to_string()),
8097 write_roots: vec!["src".to_string()],
8098 exact_files: Vec::new(),
8099 coordination_contracts: Vec::new(),
8100 dependencies: Vec::new(),
8101 acceptance: Vec::new(),
8102 allowed_tools: Some(Vec::new()),
8103 disallowed_tools: Vec::new(),
8104 max_depth: None,
8105 token_budget: None,
8106 max_steps: None,
8107 wall_time_secs: None,
8108 response_schema: None,
8109 label: Some("fix".to_string()),
8110 phase: None,
8111 };
8112
8113 let mut first = implementer.clone();
8114 let handoffs = driver
8115 .prepare_request_for_gates(&mut first)
8116 .expect("passed gate should admit first implementer");
8117 assert_eq!(handoffs.len(), 1, "{handoffs:?}");
8118 assert!(first.description.contains("exactly once"));
8119
8120 // A second same-role task must not re-receive the spent handoff.
8121 let mut second = TaskRequest {
8122 description: "Second implementer task.".to_string(),
8123 ..implementer
8124 };
8125 let handoffs = driver
8126 .prepare_request_for_gates(&mut second)
8127 .expect("passed gate should admit second implementer");
8128 assert!(
8129 handoffs.is_empty(),
8130 "handoff already consumed must not re-deliver: {handoffs:?}"
8131 );
8132 assert!(
8133 !second
8134 .description
8135 .contains("Workflow handoff artifacts available"),
8136 "{}",
8137 second.description
8138 );
8139 }
8140
8141 #[tokio::test]
8142 async fn workflow_gate_evaluation_error_persists_blocked_and_denies_target_role() {
8143 let tmp = tempfile::tempdir().expect("tempdir");
8144 let ctx = ToolContext::new(tmp.path().to_path_buf());
8145 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
8146 let runtime = SubAgentRuntime::new(
8147 stub_client(),
8148 "deepseek-v4-flash".to_string(),
8149 ctx,
8150 true,
8151 None,
8152 manager.clone(),
8153 );
8154 let state = WorkflowWorkspaceState::open(tmp.path());
8155 let run_id = "workflow_malformed_gate".to_string();
8156 let gates = vec![GateSpec {
8157 id: String::new(),
8158 role: "scout".to_string(),
8159 on: GateOn::RoleComplete,
8160 gate: GateKind::Approve,
8161 on_fail: codewhale_workflow::GateOnFail::Block,
8162 blocks_role: Some("implementer".to_string()),
8163 max_retries: 0,
8164 artifact_kind: Some("findings".to_string()),
8165 require_explicit_verdict: false,
8166 }];
8167 let spec = WorkflowSpec {
8168 id: Some("malformed-gate-fixture".to_string()),
8169 goal: "malformed gate must fail closed".to_string(),
8170 description: None,
8171 budget: BudgetSpec::default(),
8172 permissions: Default::default(),
8173 model_policy: Default::default(),
8174 promotion_policy: Default::default(),
8175 gates: gates.clone(),
8176 nodes: Vec::new(),
8177 };
8178 state.runs.lock().expect("runs").insert(
8179 run_id.clone(),
8180 WorkflowRunRecord::new(run_id.clone(), None, None, Some(&spec)),
8181 );
8182 let driver = SubAgentWorkflowDriver::new(
8183 run_id.clone(),
8184 manager,
8185 runtime,
8186 state.clone(),
8187 None,
8188 WorkflowFleetBinding::None,
8189 gates,
8190 );
8191
8192 driver.evaluate_gates_for_completed_role(&RuntimeTaskRecord {
8193 agent_id: "scout-agent".to_string(),
8194 label: Some("scout".to_string()),
8195 role: Some("scout".to_string()),
8196 status: IrWorkflowRunStatus::Succeeded,
8197 output: Some("findings".to_string()),
8198 schema_error: None,
8199 usage: None,
8200 });
8201
8202 let mut request = TaskRequest {
8203 description: "Must not be admitted.".to_string(),
8204 subagent_type: Some("implementer".to_string()),
8205 role: Some("implementer".to_string()),
8206 profile: None,
8207 model: None,
8208 model_strength: None,
8209 thinking: None,
8210 cwd: None,
8211 worktree: false,
8212 write_authority: Some("workspace_write".to_string()),
8213 write_roots: vec!["src".to_string()],
8214 exact_files: Vec::new(),
8215 coordination_contracts: vec!["test-contract".to_string()],
8216 dependencies: Vec::new(),
8217 acceptance: Vec::new(),
8218 allowed_tools: Some(Vec::new()),
8219 disallowed_tools: Vec::new(),
8220 max_depth: None,
8221 token_budget: None,
8222 max_steps: None,
8223 wall_time_secs: None,
8224 response_schema: None,
8225 label: Some("blocked".to_string()),
8226 phase: None,
8227 };
8228 let error = driver
8229 .prepare_request_for_gates(&mut request)
8230 .expect_err("malformed gate must deny its target role");
8231 assert!(
8232 error.to_string().contains("gate id must not be empty"),
8233 "{error}"
8234 );
8235 let board = driver.gate_board.lock().expect("gate board");
8236 assert!(matches!(
8237 board.gates.get(""),
8238 Some(GateState::Blocked { reason }) if reason.contains("gate id must not be empty")
8239 ));
8240 assert!(board.artifacts.is_empty(), "{:?}", board.artifacts);
8241 drop(board);
8242 let run = state
8243 .runs
8244 .lock()
8245 .expect("runs")
8246 .get(&run_id)
8247 .cloned()
8248 .expect("run");
8249 assert!(run.gate_status.iter().any(|line| {
8250 line.gate_id.is_empty()
8251 && line.state == "blocked"
8252 && line
8253 .blocked_reason
8254 .as_deref()
8255 .is_some_and(|reason| reason.contains("gate id must not be empty"))
8256 }));
8257 assert!(
8258 run.events
8259 .iter()
8260 .all(|event| event.event_type() != "handoff_promoted"),
8261 "{:?}",
8262 run.events
8263 );
8264 }
8265
8266 #[tokio::test]
8267 async fn workflow_handoff_record_error_changes_pass_to_blocked() {
8268 let tmp = tempfile::tempdir().expect("tempdir");
8269 let ctx = ToolContext::new(tmp.path().to_path_buf());
8270 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
8271 let runtime = SubAgentRuntime::new(
8272 stub_client(),
8273 "deepseek-v4-flash".to_string(),
8274 ctx,
8275 true,
8276 None,
8277 manager.clone(),
8278 );
8279 let state = WorkflowWorkspaceState::open(tmp.path());
8280 let run_id = "workflow_handoff_record_error".to_string();
8281 let gates = vec![GateSpec {
8282 id: "scout-findings".to_string(),
8283 role: "scout".to_string(),
8284 on: GateOn::RoleComplete,
8285 gate: GateKind::Approve,
8286 on_fail: codewhale_workflow::GateOnFail::Block,
8287 blocks_role: Some("implementer".to_string()),
8288 max_retries: 0,
8289 artifact_kind: Some("findings".to_string()),
8290 require_explicit_verdict: false,
8291 }];
8292 let spec = WorkflowSpec {
8293 id: Some("handoff-record-error-fixture".to_string()),
8294 goal: "failed handoff recording must fail closed".to_string(),
8295 description: None,
8296 budget: BudgetSpec::default(),
8297 permissions: Default::default(),
8298 model_policy: Default::default(),
8299 promotion_policy: Default::default(),
8300 gates: gates.clone(),
8301 nodes: Vec::new(),
8302 };
8303 state.runs.lock().expect("runs").insert(
8304 run_id.clone(),
8305 WorkflowRunRecord::new(run_id.clone(), None, None, Some(&spec)),
8306 );
8307 let driver = SubAgentWorkflowDriver::new(
8308 run_id.clone(),
8309 manager,
8310 runtime,
8311 state.clone(),
8312 None,
8313 WorkflowFleetBinding::None,
8314 gates,
8315 );
8316 driver.gate_board.lock().expect("gate board").lane_id = "wrong-lane".to_string();
8317
8318 driver.evaluate_gates_for_completed_role(&RuntimeTaskRecord {
8319 agent_id: "scout-agent".to_string(),
8320 label: Some("scout".to_string()),
8321 role: Some("scout".to_string()),
8322 status: IrWorkflowRunStatus::Succeeded,
8323 output: Some("findings".to_string()),
8324 schema_error: None,
8325 usage: None,
8326 });
8327
8328 let mut request = TaskRequest {
8329 description: "Must not be admitted.".to_string(),
8330 subagent_type: Some("implementer".to_string()),
8331 role: Some("implementer".to_string()),
8332 profile: None,
8333 model: None,
8334 model_strength: None,
8335 thinking: None,
8336 cwd: None,
8337 worktree: false,
8338 write_authority: Some("workspace_write".to_string()),
8339 write_roots: vec!["src".to_string()],
8340 exact_files: Vec::new(),
8341 coordination_contracts: vec!["test-contract".to_string()],
8342 dependencies: Vec::new(),
8343 acceptance: Vec::new(),
8344 allowed_tools: Some(Vec::new()),
8345 disallowed_tools: Vec::new(),
8346 max_depth: None,
8347 token_budget: None,
8348 max_steps: None,
8349 wall_time_secs: None,
8350 response_schema: None,
8351 label: Some("blocked".to_string()),
8352 phase: None,
8353 };
8354 let error = driver
8355 .prepare_request_for_gates(&mut request)
8356 .expect_err("unrecorded handoff must deny its target role");
8357 assert!(
8358 error.to_string().contains("handoff could not be recorded"),
8359 "{error}"
8360 );
8361 let board = driver.gate_board.lock().expect("gate board");
8362 assert!(matches!(
8363 board.gates.get("scout-findings"),
8364 Some(GateState::Blocked { reason }) if reason.contains("does not match board lane")
8365 ));
8366 assert!(board.artifacts.is_empty(), "{:?}", board.artifacts);
8367 drop(board);
8368 let run = state
8369 .runs
8370 .lock()
8371 .expect("runs")
8372 .get(&run_id)
8373 .cloned()
8374 .expect("run");
8375 assert!(run.events.iter().any(|event| {
8376 matches!(
8377 &event.kind,
8378 WorkflowUiEventKind::GateUpdated {
8379 state,
8380 blocked_reason: Some(reason),
8381 ..
8382 } if state == "blocked" && reason.contains("handoff could not be recorded")
8383 )
8384 }));
8385 assert!(
8386 run.events
8387 .iter()
8388 .all(|event| event.event_type() != "handoff_promoted"),
8389 "{:?}",
8390 run.events
8391 );
8392 }
8393
8394 #[test]
8395 fn explicit_gate_verdict_only_reads_first_standalone_token() {
8396 assert_eq!(
8397 explicit_gate_verdict(Some("\n APPROVE \nreview complete")),
8398 Some(ExplicitGateVerdict::Approve)
8399 );
8400 assert_eq!(
8401 explicit_gate_verdict(Some("PASS\nverification complete")),
8402 Some(ExplicitGateVerdict::Approve)
8403 );
8404 assert_eq!(
8405 explicit_gate_verdict(Some("BLOCK\nmissing receipt")),
8406 Some(ExplicitGateVerdict::Reject)
8407 );
8408 assert_eq!(
8409 explicit_gate_verdict(Some("\nFAIL\nmissing receipt")),
8410 Some(ExplicitGateVerdict::Reject)
8411 );
8412 assert_eq!(
8413 explicit_gate_verdict(Some("Review result: BLOCK")),
8414 None,
8415 "prose remains backward-compatible success output"
8416 );
8417 assert_eq!(
8418 explicit_gate_verdict(Some("review notes\nBLOCK")),
8419 None,
8420 "later verdict words must not override the first meaningful line"
8421 );
8422 }
8423
8424 #[test]
8425 fn required_explicit_gate_verdict_fails_closed_when_missing_or_malformed() {
8426 let mut record = RuntimeTaskRecord {
8427 agent_id: "reviewer-malformed".to_string(),
8428 label: Some("reviewer".to_string()),
8429 role: Some("reviewer".to_string()),
8430 status: IrWorkflowRunStatus::Succeeded,
8431 output: Some("Review result: BLOCK".to_string()),
8432 schema_error: None,
8433 usage: None,
8434 };
8435
8436 match gate_outcome_for_completed_role(&record, true, None) {
8437 GateOutcome::Fail { reason } => {
8438 assert!(
8439 reason.contains("required first-line gate verdict"),
8440 "{reason}"
8441 );
8442 }
8443 outcome => panic!("required malformed verdict must fail closed: {outcome:?}"),
8444 }
8445 assert_eq!(
8446 gate_outcome_for_completed_role(&record, false, None),
8447 GateOutcome::Pass,
8448 "legacy gates retain pass-on-success behavior"
8449 );
8450
8451 record.output = None;
8452 assert!(matches!(
8453 gate_outcome_for_completed_role(&record, true, None),
8454 GateOutcome::Fail { .. }
8455 ));
8456 }
8457
8458 #[test]
8459 fn required_gate_artifact_rejects_bare_or_placeholder_approval() {
8460 let mut record = RuntimeTaskRecord {
8461 agent_id: "implementer-bare".to_string(),
8462 label: Some("implementer".to_string()),
8463 role: Some("implementer".to_string()),
8464 status: IrWorkflowRunStatus::Succeeded,
8465 output: Some("APPROVE".to_string()),
8466 schema_error: None,
8467 usage: None,
8468 };
8469
8470 match gate_outcome_for_completed_role(&record, true, Some("verification_plan")) {
8471 GateOutcome::Fail { reason } => {
8472 assert!(
8473 reason.contains("verification_plan artifact body"),
8474 "{reason}"
8475 );
8476 }
8477 outcome => panic!("bare approval must not promote an empty artifact: {outcome:?}"),
8478 }
8479
8480 record.output = Some("APPROVE\nacceptance evidence".to_string());
8481 match gate_outcome_for_completed_role(&record, true, Some("verification_plan")) {
8482 GateOutcome::Fail { reason } => {
8483 assert!(
8484 reason.contains("verification_plan artifact body"),
8485 "{reason}"
8486 );
8487 }
8488 outcome => {
8489 panic!("one placeholder line must not count as an artifact: {outcome:?}");
8490 }
8491 }
8492
8493 record.output = Some("APPROVE\nPLAN\n- verify the typed receipt".to_string());
8494 assert_eq!(
8495 gate_outcome_for_completed_role(&record, true, Some("verification_plan")),
8496 GateOutcome::Pass
8497 );
8498 }
8499
8500 #[tokio::test]
8501 #[allow(clippy::await_holding_lock)]
8502 async fn terminal_blocked_gate_fails_workflow_finalization() {
8503 let _retry_guard = workflow_test_retry_guard();
8504 let tmp = tempfile::tempdir().expect("tempdir");
8505 let ctx = ToolContext::new(tmp.path().to_path_buf());
8506 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
8507 let (client, calls) =
8508 fake_chat_client("BLOCK\nFINAL RECEIPT\n- missing terminal evidence").await;
8509 let runtime = SubAgentRuntime::new(
8510 client,
8511 "deepseek-v4-flash".to_string(),
8512 ctx.clone(),
8513 true,
8514 None,
8515 manager,
8516 );
8517 let tool = WorkflowTool::new(runtime.manager.clone(), runtime);
8518
8519 let result = tool
8520 .execute(
8521 json!({
8522 "action": "run",
8523 "script": r#"export default workflow({
8524 "goal": "fail closed on the terminal release verdict",
8525 "gates": [
8526 {
8527 "id": "terminal-release",
8528 "role": "release_lead",
8529 "on": "role_complete",
8530 "gate": "approve",
8531 "on_fail": "block",
8532 "max_retries": 0,
8533 "artifact_kind": "final_receipt",
8534 "require_explicit_verdict": true
8535 }
8536 ],
8537 "nodes": [
8538 {
8539 "agent": {
8540 "id": "release-receipt",
8541 "prompt": "Return the terminal verdict and receipt.",
8542 "agent_type": "general",
8543 "role": "release_lead",
8544 "mode": "read_only",
8545 "permissions": { "deny_all_tools": true },
8546 "budget": { "max_steps": 1 }
8547 }
8548 }
8549 ]
8550 });"#
8551 }),
8552 &ctx,
8553 )
8554 .await
8555 .expect("blocked terminal gate should return its failed run record");
8556 let payload: Value = serde_json::from_str(&result.content).expect("workflow JSON");
8557
8558 assert_eq!(calls.load(Ordering::SeqCst), 1, "{payload}");
8559 assert_eq!(payload["status"], "failed", "{payload}");
8560 assert_eq!(payload["execution"]["status"], "failed", "{payload}");
8561 assert!(
8562 payload["error"]
8563 .as_str()
8564 .is_some_and(|error| error.contains("terminal-release")
8565 && error.contains("ended blocked")
8566 && error.contains("missing terminal evidence")),
8567 "{payload}"
8568 );
8569 assert!(payload["gate_status"].as_array().is_some_and(|gates| {
8570 gates
8571 .iter()
8572 .any(|gate| gate["gate_id"] == "terminal-release" && gate["state"] == "blocked")
8573 }));
8574 assert!(payload["events"].as_array().is_some_and(|events| {
8575 events
8576 .iter()
8577 .any(|event| event["type"] == "run_completed" && event["status"] == "failed")
8578 }));
8579 }
8580
8581 #[tokio::test]
8582 async fn workflow_runtime_gate_honors_explicit_reviewer_verdicts() {
8583 let tmp = tempfile::tempdir().expect("tempdir");
8584 let ctx = ToolContext::new(tmp.path().to_path_buf());
8585 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
8586 let runtime = SubAgentRuntime::new(
8587 stub_client(),
8588 "deepseek-v4-flash".to_string(),
8589 ctx,
8590 true,
8591 None,
8592 manager.clone(),
8593 );
8594 let state = WorkflowWorkspaceState::open(tmp.path());
8595 let run_id = "workflow_explicit_verdict".to_string();
8596 let gates = vec![GateSpec {
8597 id: "review-findings".to_string(),
8598 role: "reviewer".to_string(),
8599 on: GateOn::RoleComplete,
8600 gate: GateKind::Review,
8601 on_fail: codewhale_workflow::GateOnFail::Block,
8602 blocks_role: Some("verifier".to_string()),
8603 max_retries: 0,
8604 artifact_kind: Some("review_report".to_string()),
8605 require_explicit_verdict: true,
8606 }];
8607 let spec = WorkflowSpec {
8608 id: Some("explicit-verdict-fixture".to_string()),
8609 goal: "honor reviewer verdict".to_string(),
8610 description: None,
8611 budget: BudgetSpec::default(),
8612 permissions: Default::default(),
8613 model_policy: Default::default(),
8614 promotion_policy: Default::default(),
8615 gates: gates.clone(),
8616 nodes: Vec::new(),
8617 };
8618 state.runs.lock().expect("runs").insert(
8619 run_id.clone(),
8620 WorkflowRunRecord::new(run_id.clone(), None, None, Some(&spec)),
8621 );
8622 let driver = SubAgentWorkflowDriver::new(
8623 run_id,
8624 manager,
8625 runtime,
8626 state,
8627 None,
8628 WorkflowFleetBinding::None,
8629 gates,
8630 );
8631
8632 driver.evaluate_gates_for_completed_role(&RuntimeTaskRecord {
8633 agent_id: "reviewer-block".to_string(),
8634 label: Some("reviewer".to_string()),
8635 role: Some("reviewer".to_string()),
8636 status: IrWorkflowRunStatus::Succeeded,
8637 output: Some("\nBLOCK\nmissing terminal receipt".to_string()),
8638 schema_error: None,
8639 usage: None,
8640 });
8641
8642 let verifier_request = || TaskRequest {
8643 description: "Verify the accepted review.".to_string(),
8644 subagent_type: Some("verifier".to_string()),
8645 role: Some("verifier".to_string()),
8646 profile: None,
8647 model: None,
8648 model_strength: None,
8649 thinking: None,
8650 cwd: None,
8651 worktree: false,
8652 write_authority: Some("read_only".to_string()),
8653 write_roots: Vec::new(),
8654 exact_files: Vec::new(),
8655 coordination_contracts: Vec::new(),
8656 dependencies: Vec::new(),
8657 acceptance: Vec::new(),
8658 allowed_tools: Some(Vec::new()),
8659 disallowed_tools: Vec::new(),
8660 max_depth: None,
8661 token_budget: None,
8662 max_steps: None,
8663 wall_time_secs: None,
8664 response_schema: None,
8665 label: Some("verify".to_string()),
8666 phase: None,
8667 };
8668 let mut blocked_verifier = verifier_request();
8669 let error = driver
8670 .prepare_request_for_gates(&mut blocked_verifier)
8671 .expect_err("successful reviewer BLOCK must not admit verifier");
8672 assert!(error.to_string().contains("BLOCK"), "{error}");
8673 {
8674 let board = driver.gate_board.lock().expect("gate board");
8675 assert!(
8676 board.artifacts.is_empty(),
8677 "rejected output must not produce a handoff: {:?}",
8678 board.artifacts
8679 );
8680 assert!(matches!(
8681 board.gates.get("review-findings"),
8682 Some(GateState::Blocked { .. })
8683 ));
8684 }
8685
8686 driver.evaluate_gates_for_completed_role(&RuntimeTaskRecord {
8687 agent_id: "reviewer-approve".to_string(),
8688 label: Some("reviewer".to_string()),
8689 role: Some("reviewer".to_string()),
8690 status: IrWorkflowRunStatus::Succeeded,
8691 output: Some("APPROVE\nEVIDENCE REVIEW\n- all receipt owners confirmed".to_string()),
8692 schema_error: None,
8693 usage: None,
8694 });
8695
8696 let mut admitted_verifier = verifier_request();
8697 driver
8698 .prepare_request_for_gates(&mut admitted_verifier)
8699 .expect("explicit reviewer APPROVE should admit verifier");
8700 assert!(
8701 admitted_verifier
8702 .description
8703 .contains("all receipt owners confirmed"),
8704 "{}",
8705 admitted_verifier.description
8706 );
8707 let board = driver.gate_board.lock().expect("gate board");
8708 assert!(
8709 board.artifacts.is_empty(),
8710 "the admitted verifier consumed the handoff; spent artifacts leave the board: {:?}",
8711 board.artifacts
8712 );
8713 assert!(matches!(
8714 board.gates.get("review-findings"),
8715 Some(GateState::Passed)
8716 ));
8717 }
8718
8719 #[tokio::test]
8720 #[allow(clippy::await_holding_lock)]
8721 async fn workflow_status_lists_compact_typed_receipts() {
8722 let _retry_guard = workflow_test_retry_guard();
8723 let tmp = tempfile::tempdir().expect("tempdir");
8724 let ctx = ToolContext::new(tmp.path().to_path_buf());
8725 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
8726 let (client, _calls) = fake_chat_client("status-output").await;
8727 let runtime = SubAgentRuntime::new(
8728 client,
8729 "deepseek-v4-flash".to_string(),
8730 ctx.clone(),
8731 true,
8732 None,
8733 manager,
8734 );
8735 let tool = WorkflowTool::new(runtime.manager.clone(), runtime);
8736
8737 let run = tool
8738 .execute(
8739 json!({
8740 "action": "run",
8741 "script": r#"export default workflow({
8742 "id": "status-fixture",
8743 "goal": "status summary",
8744 "nodes": [
8745 {
8746 "agent": {
8747 "id": "inspect",
8748 "prompt": "Inspect the code.",
8749 "agent_type": "review"
8750 }
8751 }
8752 ]
8753 });"#
8754 }),
8755 &ctx,
8756 )
8757 .await
8758 .expect("workflow run");
8759 let run_payload: Value = serde_json::from_str(&run.content).expect("run json");
8760
8761 let status = tool
8762 .execute(json!({"action": "status"}), &ctx)
8763 .await
8764 .expect("workflow status");
8765 let status_payload: Value = serde_json::from_str(&status.content).expect("status json");
8766 let summary = &status_payload["runs"][0];
8767
8768 assert_eq!(status_payload["count"], 1);
8769 assert_eq!(summary["run_id"], run_payload["run_id"]);
8770 assert_eq!(summary["workflow_id"], "status-fixture");
8771 assert_eq!(summary["workflow_goal"], "status summary");
8772 assert_eq!(summary["status"], "completed");
8773 assert_eq!(summary["execution_status"], "succeeded");
8774 assert_eq!(summary["child_count"], 1);
8775 assert_eq!(summary["leaf_count"], 1);
8776 assert_eq!(summary["branch_count"], 0);
8777 assert_eq!(summary["control_count"], 0);
8778 assert!(summary["event_count"].as_u64().unwrap_or_default() >= 3);
8779 assert_eq!(summary["last_event_type"], "run_completed");
8780 assert!(summary.get("result").is_none());
8781 assert!(summary.get("execution").is_none());
8782 }
8783
8784 #[tokio::test]
8785 #[allow(clippy::await_holding_lock)]
8786 async fn workflow_status_survives_tool_rebuild_via_journal() {
8787 let _retry_guard = workflow_test_retry_guard();
8788 let tmp = tempfile::tempdir().expect("tempdir");
8789 let ctx = ToolContext::new(tmp.path().to_path_buf());
8790 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
8791 let (client, _calls) = fake_chat_client("journal-output").await;
8792 let runtime = SubAgentRuntime::new(
8793 client,
8794 "deepseek-v4-flash".to_string(),
8795 ctx.clone(),
8796 true,
8797 None,
8798 manager.clone(),
8799 );
8800 let tool = WorkflowTool::new(manager.clone(), runtime.clone());
8801
8802 let run = tool
8803 .execute(
8804 json!({
8805 "action": "run",
8806 "script": "return { ok: true };"
8807 }),
8808 &ctx,
8809 )
8810 .await
8811 .expect("workflow run");
8812 let run_payload: Value = serde_json::from_str(&run.content).expect("run json");
8813 let run_id = run_payload["run_id"].as_str().expect("run id");
8814
8815 let journal_path = tmp.path().join(".codewhale/workflow-runs.jsonl");
8816 assert!(
8817 journal_path.exists(),
8818 "journal should be created under workspace"
8819 );
8820
8821 let rebuilt = WorkflowTool::new(
8822 manager.clone(),
8823 SubAgentRuntime::new(
8824 stub_client(),
8825 "deepseek-v4-flash".to_string(),
8826 ctx.clone(),
8827 true,
8828 None,
8829 manager,
8830 ),
8831 );
8832 let status = rebuilt
8833 .execute(json!({"action": "status", "run_id": run_id}), &ctx)
8834 .await
8835 .expect("workflow status after rebuild");
8836 let status_payload: Value = serde_json::from_str(&status.content).expect("status json");
8837 assert_eq!(status_payload["run_id"], run_id);
8838 assert_eq!(status_payload["status"], "completed");
8839 }
8840
8841 #[tokio::test]
8842 #[allow(clippy::await_holding_lock)]
8843 async fn workflow_status_surfaces_schema_failure_instead_of_null_success() {
8844 let _retry_guard = workflow_test_retry_guard();
8845 let tmp = tempfile::tempdir().expect("tempdir");
8846 let ctx = ToolContext::new(tmp.path().to_path_buf());
8847 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
8848 let (client, _calls) = fake_chat_client(r#"{"refuted":"yes"}"#).await;
8849 let runtime = SubAgentRuntime::new(
8850 client,
8851 "deepseek-v4-flash".to_string(),
8852 ctx.clone(),
8853 true,
8854 None,
8855 manager.clone(),
8856 );
8857 let tool = WorkflowTool::new(manager, runtime);
8858
8859 let run = tool
8860 .execute(
8861 json!({
8862 "action": "run",
8863 "script": r#"
8864 return await parallel([
8865 () => task({
8866 description: "Return the schema fixture.",
8867 responseSchema: {
8868 type: "object",
8869 properties: { refuted: { type: "boolean" } },
8870 required: ["refuted"],
8871 },
8872 }),
8873 ]);
8874 "#
8875 }),
8876 &ctx,
8877 )
8878 .await
8879 .expect("workflow run returns a record");
8880 let run_payload: Value = serde_json::from_str(&run.content).expect("run json");
8881
8882 assert_eq!(run_payload["status"], "failed");
8883 assert!(run_payload["result"].is_null());
8884 assert!(
8885 run_payload["error"]
8886 .as_str()
8887 .unwrap()
8888 .contains("responseSchema validation")
8889 );
8890 assert!(
8891 run_payload["progress"]
8892 .as_array()
8893 .unwrap()
8894 .iter()
8895 .any(|message| message
8896 .as_str()
8897 .is_some_and(|message| message.contains("schema validation failed"))),
8898 "schema validation error should be visible in the run receipt: {run_payload}"
8899 );
8900 assert!(
8901 run_payload["events"]
8902 .as_array()
8903 .unwrap()
8904 .iter()
8905 .any(|event| event["type"] == "task_schema_validation_failed"
8906 && event["message"]
8907 .as_str()
8908 .is_some_and(|message| message.contains("responseSchema validation"))),
8909 "schema validation event should be visible in the typed receipt: {run_payload}"
8910 );
8911 }
8912
8913 #[tokio::test]
8914 #[allow(clippy::await_holding_lock)]
8915 async fn declarative_issue_audit_fixture_runs_through_subagent_driver() {
8916 let _retry_guard = workflow_test_retry_guard();
8917 let tmp = tempfile::tempdir().expect("tempdir");
8918 let workflow_dir = tmp.path().join("workflows");
8919 std::fs::create_dir_all(&workflow_dir).expect("workflow dir");
8920 let fixture = std::fs::read_to_string(
8921 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
8922 .join("../../workflows/issue_audit.workflow.js"),
8923 )
8924 .expect("issue audit fixture");
8925 std::fs::write(workflow_dir.join("issue_audit.workflow.js"), fixture)
8926 .expect("write fixture into workspace");
8927
8928 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
8929 ctx.runtime.work = Some(crate::work_graph::new_shared_work_runtime(
8930 crate::tools::todo::new_shared_todo_list(),
8931 crate::tools::plan::new_shared_plan_state(),
8932 ));
8933 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 4);
8934 let (client, calls) = fake_chat_client("audited").await;
8935 let runtime = SubAgentRuntime::new(
8936 client,
8937 "deepseek-v4-flash".to_string(),
8938 ctx.clone(),
8939 true,
8940 None,
8941 manager,
8942 );
8943 let tool = WorkflowTool::new(runtime.manager.clone(), runtime);
8944
8945 let result = tool
8946 .execute(
8947 json!({
8948 "action": "run",
8949 "source_path": "workflows/issue_audit.workflow.js"
8950 }),
8951 &ctx,
8952 )
8953 .await
8954 .expect("declarative workflow should complete");
8955 let payload: Value = serde_json::from_str(&result.content).expect("json result");
8956
8957 assert_eq!(payload["status"], "completed", "{payload}");
8958 assert_eq!(payload["result"]["code-audit"], "audited");
8959 assert_eq!(payload["result"]["test-audit"], "audited");
8960 assert_eq!(payload["result"]["docs-audit"], "audited");
8961 assert_eq!(payload["result"]["synthesize-release-risk"], "audited");
8962 assert_eq!(payload["execution"]["status"], "succeeded");
8963 assert_eq!(
8964 payload["execution"]["leaf_results"]
8965 .as_array()
8966 .expect("leaf results")
8967 .len(),
8968 3
8969 );
8970 assert_eq!(
8971 payload["execution"]["branch_results"][0]["branch_id"],
8972 "parallel-audit"
8973 );
8974 assert!(
8975 payload["execution"]["control_node_results"]
8976 .as_array()
8977 .expect("control results")
8978 .iter()
8979 .any(|result| result["node_id"] == "synthesize-release-risk"
8980 && result["kind"] == "reduce"
8981 && result["status"] == "succeeded")
8982 );
8983 assert_eq!(payload["child_ids"].as_array().unwrap().len(), 4);
8984 assert_eq!(calls.load(Ordering::SeqCst), 4);
8985 assert!(
8986 payload["progress"]
8987 .as_array()
8988 .unwrap()
8989 .iter()
8990 .any(|message| message == "phase: parallel-audit")
8991 );
8992
8993 // Operate projects Workflow fan-out/fan-in and its children through
8994 // the same canonical Work Graph. The Workflow remains the accountable
8995 // operation identity while the worker bindings stay inspectable; no
8996 // second plan/strategy lifecycle is created for the reduce step.
8997 let work = ctx.runtime.work.as_ref().expect("work runtime");
8998 let graph = work
8999 .capture(Some(&ctx.state_namespace))
9000 .expect("capture workflow work")
9001 .expect("workflow graph")
9002 .graph;
9003 let workflow_external = format!(
9004 "workflow:{}",
9005 payload["run_id"].as_str().expect("workflow run id")
9006 );
9007 let workflow_operations = graph
9008 .nodes
9009 .iter()
9010 .filter(|node| {
9011 node.binding
9012 .as_ref()
9013 .is_some_and(|binding| binding.external == workflow_external)
9014 })
9015 .collect::<Vec<_>>();
9016 assert_eq!(
9017 workflow_operations.len(),
9018 1,
9019 "one accountable Workflow operation: {graph:#?}"
9020 );
9021 assert_eq!(
9022 workflow_operations[0].state,
9023 crate::work_graph::NodeState::Completed
9024 );
9025 for child_id in payload["child_ids"].as_array().expect("workflow child ids") {
9026 let worker_external = format!(
9027 "worker:{}",
9028 child_id.as_str().expect("workflow child id string")
9029 );
9030 assert!(
9031 graph.nodes.iter().any(|node| {
9032 node.binding
9033 .as_ref()
9034 .is_some_and(|binding| binding.external == worker_external)
9035 }),
9036 "Workflow worker {worker_external} must remain inspectable in the same graph: {graph:#?}"
9037 );
9038 }
9039 }
9040
9041 #[tokio::test]
9042 #[allow(clippy::await_holding_lock)]
9043 async fn stopship_acceptance_fixture_emits_role_gate_and_terminal_receipts() {
9044 let _retry_guard = workflow_test_retry_guard();
9045 let _env_lock = crate::test_support::lock_test_env();
9046 let tmp = tempfile::tempdir().expect("tempdir");
9047 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
9048 let workflow_dir = tmp.path().join("workflows");
9049 let fleet_dir = tmp.path().join("fleets");
9050 std::fs::create_dir_all(&workflow_dir).expect("workflow dir");
9051 std::fs::create_dir_all(&fleet_dir).expect("fleet dir");
9052 let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
9053 std::fs::copy(
9054 repo_root.join("workflows/stopship.workflow.js"),
9055 workflow_dir.join("stopship.workflow.js"),
9056 )
9057 .expect("copy stopship acceptance fixture");
9058 std::fs::copy(
9059 repo_root.join("fleets/stopship.toml"),
9060 fleet_dir.join("stopship.toml"),
9061 )
9062 .expect("copy stopship fleet");
9063
9064 let source = std::fs::read_to_string(workflow_dir.join("stopship.workflow.js"))
9065 .expect("read stopship acceptance fixture");
9066 let compiled =
9067 codewhale_workflow::compile_javascript_workflow("stopship.workflow.js", &source)
9068 .expect("compile stopship acceptance fixture");
9069 let codewhale_workflow::WorkflowNode::Sequence(sequence) = &compiled.nodes[0] else {
9070 panic!("stopship fixture should be one ordered role chain");
9071 };
9072 for (index, node) in sequence.children.iter().enumerate() {
9073 let codewhale_workflow::WorkflowNode::Leaf(leaf) = node else {
9074 panic!("stopship role chain must contain only leaves");
9075 };
9076 let tools = leaf_allowed_tools(leaf).expect("lower stopship child tools");
9077 if index == 0 {
9078 assert!(tools.as_ref().is_some_and(|tools| !tools.is_empty()));
9079 } else {
9080 assert_eq!(
9081 tools,
9082 Some(Vec::<String>::new()),
9083 "downstream handoff consumer {} must receive no tools",
9084 leaf.id
9085 );
9086 }
9087 }
9088
9089 let ctx = ToolContext::new(tmp.path().to_path_buf());
9090 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 8);
9091 let responses = [
9092 r#"APPROVE
9093 SOURCE EVIDENCE
9094 - crates/cli/src/lib.rs: load_named_fleet
9095 - crates/workflow/src/role_resolve.rs: resolve_workflow_agent
9096 - crates/cli/src/lib.rs: start_lane
9097 - crates/tui/src/tools/workflow.rs: record_task_started
9098 - crates/tui/src/tools/workflow.rs: WorkflowUiEventKind::GateUpdated
9099 - crates/tui/src/tools/workflow.rs: WorkflowUiEventKind::RunCompleted -> terminal_completed_receipt
9100 - crates/lane/src/runtime.rs: process_exit_receipt -> lane_reconciled"#,
9101 r#"APPROVE
9102 PLAN
9103 - fleets/stopship.toml: name = "stopship" -> named Fleet loading
9104 - crates/workflow/src/role_resolve.rs: resolve_workflow_agent -> role resolution
9105 - crates/cli/src/lib.rs: start_lane -> tmux Lane launch
9106 - crates/tui/src/tools/workflow.rs: record_task_started -> typed task_started
9107 - crates/tui/src/tools/workflow.rs: WorkflowUiEventKind::GateUpdated -> gate promotion
9108 - crates/tui/src/tools/workflow.rs: WorkflowUiEventKind::RunCompleted -> terminal_completed_receipt
9109 - crates/lane/src/runtime.rs: process_exit_receipt -> tmux Lane reconciliation"#,
9110 r#"APPROVE
9111 EVIDENCE REVIEW
9112 - fleets/stopship.toml: name = "stopship"
9113 - crates/workflow/src/role_resolve.rs: resolve_workflow_agent
9114 - crates/cli/src/lib.rs: start_lane
9115 - crates/tui/src/tools/workflow.rs: record_task_started
9116 - crates/tui/src/tools/workflow.rs: WorkflowUiEventKind::GateUpdated
9117 - crates/tui/src/tools/workflow.rs: WorkflowUiEventKind::RunCompleted -> terminal_completed_receipt
9118 - crates/lane/src/runtime.rs: process_exit_receipt -> lane_reconciled"#,
9119 r#"APPROVE
9120 EVIDENCE MATRIX
9121 - fleet_load: fleets/stopship.toml: name = "stopship"
9122 - role_resolution: crates/workflow/src/role_resolve.rs: resolve_workflow_agent
9123 - lane_launch: crates/cli/src/lib.rs: start_lane
9124 - task_started: crates/tui/src/tools/workflow.rs: record_task_started
9125 - gate_updated: crates/tui/src/tools/workflow.rs: WorkflowUiEventKind::GateUpdated
9126 - run_completed: crates/tui/src/tools/workflow.rs: WorkflowUiEventKind::RunCompleted -> terminal_completed_receipt
9127 - lane_exit: crates/lane/src/runtime.rs: process_exit_receipt -> lane_reconciled"#,
9128 r#"APPROVE
9129 FINAL RECEIPT
9130 - fleet_load: fleets/stopship.toml: name = "stopship"
9131 - role_resolution: crates/workflow/src/role_resolve.rs: resolve_workflow_agent
9132 - lane_launch: crates/cli/src/lib.rs: start_lane
9133 - task_started: crates/tui/src/tools/workflow.rs: record_task_started
9134 - gate_updated: crates/tui/src/tools/workflow.rs: WorkflowUiEventKind::GateUpdated
9135 - run_completed: crates/tui/src/tools/workflow.rs: WorkflowUiEventKind::RunCompleted -> terminal_completed_receipt
9136 - lane_exit: crates/lane/src/runtime.rs: process_exit_receipt -> lane_reconciled"#,
9137 ];
9138 let (client, calls) = fake_chat_client_responses(&responses).await;
9139 let runtime = SubAgentRuntime::new(
9140 client,
9141 "deepseek-v4-flash".to_string(),
9142 ctx.clone(),
9143 true,
9144 None,
9145 manager,
9146 );
9147 let tool = WorkflowTool::new(runtime.manager.clone(), runtime);
9148
9149 let result = tool
9150 .execute(
9151 json!({
9152 "action": "run",
9153 "source_path": "workflows/stopship.workflow.js",
9154 "fleet": "stopship",
9155 "token_budget": 60_000
9156 }),
9157 &ctx,
9158 )
9159 .await
9160 .expect("stopship acceptance workflow returns a terminal record");
9161 let payload: Value = serde_json::from_str(&result.content).expect("workflow JSON");
9162
9163 assert_eq!(payload["status"], "completed", "{payload}");
9164 assert_eq!(payload["execution"]["status"], "succeeded", "{payload}");
9165 assert_eq!(calls.load(Ordering::SeqCst), 5, "one child per Fleet role");
9166 let approval = &payload["plan_approval"];
9167 assert_eq!(approval["decision"], "auto_read_only", "{approval}");
9168 assert_eq!(approval["token_budget"], 60_000, "{approval}");
9169 assert_eq!(approval["writes"], false, "{approval}");
9170 assert_eq!(approval["shell"], false, "{approval}");
9171 assert_eq!(approval["network"], false, "{approval}");
9172 assert_eq!(approval["high_budget"], false, "{approval}");
9173 assert_eq!(approval["elevated"], false, "{approval}");
9174 assert!(
9175 approval["reasons"].as_array().is_some_and(Vec::is_empty),
9176 "{approval}"
9177 );
9178
9179 let events = payload["events"].as_array().expect("typed events");
9180 let started = events
9181 .iter()
9182 .filter(|event| event["type"] == "task_started")
9183 .collect::<Vec<_>>();
9184 let expected_roles = [
9185 ("scout", "scout"),
9186 ("implementer", "builder"),
9187 ("reviewer", "reviewer"),
9188 ("verifier", "verifier"),
9189 ("release_lead", "manager"),
9190 ];
9191 assert_eq!(started.len(), expected_roles.len(), "{started:#?}");
9192 for (event, (role, profile)) in started.iter().zip(expected_roles) {
9193 assert_eq!(event["role"], role);
9194 assert_eq!(event["profile"], profile);
9195 assert_eq!(event["resolved_profile"], profile);
9196 assert_eq!(event["workflow_run_id"], payload["run_id"]);
9197 }
9198
9199 let gates = events
9200 .iter()
9201 .filter(|event| event["type"] == "gate_updated")
9202 .collect::<Vec<_>>();
9203 assert_eq!(gates.len(), 5, "{gates:#?}");
9204 assert!(gates.iter().all(|event| event["state"] == "passed"));
9205 assert_eq!(gates[0]["role"], "scout");
9206 assert_eq!(gates[0]["blocked_role"], "implementer");
9207 assert_eq!(gates[3]["role"], "verifier");
9208 assert_eq!(gates[3]["blocked_role"], "release_lead");
9209 assert_eq!(gates[4]["role"], "release_lead");
9210 assert!(gates[4]["blocked_role"].is_null());
9211
9212 let promoted = events
9213 .iter()
9214 .filter(|event| event["type"] == "handoff_promoted")
9215 .collect::<Vec<_>>();
9216 let consumed = events
9217 .iter()
9218 .filter(|event| event["type"] == "handoff_consumed")
9219 .collect::<Vec<_>>();
9220 let expected_handoffs = [
9221 ("scout", "implementer", "source_evidence"),
9222 ("implementer", "reviewer", "verification_plan"),
9223 ("reviewer", "verifier", "review_report"),
9224 ("verifier", "release_lead", "verification_report"),
9225 ];
9226 assert_eq!(promoted.len(), expected_handoffs.len(), "{promoted:#?}");
9227 assert_eq!(consumed.len(), expected_handoffs.len(), "{consumed:#?}");
9228 let artifact_ids = promoted
9229 .iter()
9230 .map(|event| {
9231 event["artifact_id"]
9232 .as_str()
9233 .filter(|id| id.starts_with("handoff_") && id.len() > "handoff_".len())
9234 .expect("opaque non-empty handoff artifact id")
9235 })
9236 .collect::<std::collections::HashSet<_>>();
9237 assert_eq!(
9238 artifact_ids.len(),
9239 promoted.len(),
9240 "every promotion must have a unique artifact id: {promoted:#?}"
9241 );
9242 for (index, (from_role, to_role, kind)) in expected_handoffs.into_iter().enumerate() {
9243 assert_eq!(promoted[index]["from_role"], from_role);
9244 assert_eq!(promoted[index]["to_role"], to_role);
9245 assert_eq!(promoted[index]["kind"], kind);
9246 assert_eq!(promoted[index]["gate_id"], gates[index]["gate_id"]);
9247 assert_eq!(
9248 promoted[index]["producer_task_id"],
9249 started[index]["task_id"]
9250 );
9251 assert!(
9252 promoted[index].get("payload").is_none(),
9253 "{:#?}",
9254 promoted[index]
9255 );
9256
9257 assert_eq!(
9258 consumed[index]["artifact_id"],
9259 promoted[index]["artifact_id"]
9260 );
9261 assert_eq!(consumed[index]["from_role"], from_role);
9262 assert_eq!(consumed[index]["to_role"], to_role);
9263 assert_eq!(consumed[index]["kind"], kind);
9264 assert_eq!(
9265 consumed[index]["consumer_task_id"],
9266 started[index + 1]["task_id"]
9267 );
9268 assert!(
9269 consumed[index].get("payload").is_none(),
9270 "{:#?}",
9271 consumed[index]
9272 );
9273
9274 let producer_task_id = promoted[index]["producer_task_id"]
9275 .as_str()
9276 .expect("producer task id");
9277 let consumer_task_id = consumed[index]["consumer_task_id"]
9278 .as_str()
9279 .expect("consumer task id");
9280 let gate_id = promoted[index]["gate_id"].as_str().expect("gate id");
9281 let artifact_id = promoted[index]["artifact_id"]
9282 .as_str()
9283 .expect("artifact id");
9284 let task_completed_index = events
9285 .iter()
9286 .position(|event| {
9287 event["type"] == "task_completed" && event["task_id"] == producer_task_id
9288 })
9289 .expect("producer completion receipt");
9290 let gate_updated_index = events
9291 .iter()
9292 .position(|event| event["type"] == "gate_updated" && event["gate_id"] == gate_id)
9293 .expect("gate update receipt");
9294 let promoted_index = events
9295 .iter()
9296 .position(|event| {
9297 event["type"] == "handoff_promoted" && event["artifact_id"] == artifact_id
9298 })
9299 .expect("handoff promotion receipt");
9300 let consumer_started_index = events
9301 .iter()
9302 .position(|event| {
9303 event["type"] == "task_started" && event["task_id"] == consumer_task_id
9304 })
9305 .expect("consumer start receipt");
9306 let consumed_index = events
9307 .iter()
9308 .position(|event| {
9309 event["type"] == "handoff_consumed" && event["artifact_id"] == artifact_id
9310 })
9311 .expect("handoff consumption receipt");
9312 assert!(
9313 task_completed_index < gate_updated_index
9314 && gate_updated_index < promoted_index
9315 && promoted_index < consumer_started_index
9316 && consumer_started_index < consumed_index,
9317 "causal receipt order must be task_completed -> gate_updated -> handoff_promoted -> task_started -> handoff_consumed: {events:#?}"
9318 );
9319 }
9320 let terminal_completed_receipt = events
9321 .iter()
9322 .any(|event| event["type"] == "run_completed" && event["status"] == "completed");
9323 assert!(terminal_completed_receipt, "{events:#?}");
9324 }
9325
9326 #[tokio::test]
9327 async fn completion_from_manager_fails_closed_when_status_stays_running() {
9328 let tmp = tempfile::tempdir().expect("tempdir");
9329 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
9330
9331 let (completion, usage) =
9332 completion_from_manager(manager, "missing_agent", "fallback".to_string()).await;
9333 assert!(usage.is_none(), "fail-closed path carries no telemetry");
9334 match completion {
9335 TaskCompletion::Failed { message } => {
9336 assert!(
9337 message.contains("did not report a terminal status"),
9338 "{message}"
9339 );
9340 }
9341 other => panic!("expected timeout failure, got {other:?}"),
9342 }
9343 }
9344
9345 #[tokio::test]
9346 #[allow(clippy::await_holding_lock)]
9347 async fn task_completed_and_run_completed_carry_usage_telemetry() {
9348 let _retry_guard = workflow_test_retry_guard();
9349 let tmp = tempfile::tempdir().expect("tempdir");
9350 let ctx = ToolContext::new(tmp.path().to_path_buf());
9351 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
9352 let (client, calls) = fake_chat_client("telemetry-output").await;
9353 let runtime = SubAgentRuntime::new(
9354 client,
9355 "deepseek-v4-flash".to_string(),
9356 ctx.clone(),
9357 true,
9358 None,
9359 manager,
9360 );
9361 let tool = WorkflowTool::new(runtime.manager.clone(), runtime);
9362
9363 let run = tool
9364 .execute(
9365 json!({
9366 "action": "run",
9367 "script": r#"export default workflow({
9368 "id": "telemetry-fixture",
9369 "goal": "usage telemetry",
9370 "nodes": [
9371 {
9372 "agent": {
9373 "id": "inspect",
9374 "prompt": "Inspect the code.",
9375 "agent_type": "review"
9376 }
9377 }
9378 ]
9379 });"#
9380 }),
9381 &ctx,
9382 )
9383 .await
9384 .expect("workflow run");
9385 let payload: Value = serde_json::from_str(&run.content).expect("run json");
9386 assert_eq!(payload["status"], "completed", "{payload}");
9387 assert_eq!(calls.load(Ordering::SeqCst), 1, "{payload}");
9388
9389 let events = payload["events"].as_array().expect("typed events");
9390 let task_completed = events
9391 .iter()
9392 .find(|event| event["type"] == "task_completed")
9393 .expect("task_completed event");
9394 let usage = &task_completed["usage"];
9395 let input = usage["input_tokens"].as_u64().expect("worker input tokens");
9396 let output = usage["output_tokens"]
9397 .as_u64()
9398 .expect("worker output tokens");
9399 let total = usage["total_tokens"].as_u64().expect("worker total tokens");
9400 assert!(total >= input + output, "{task_completed}");
9401 assert!(
9402 usage["tool_calls"].as_u64().unwrap_or_default() >= 1,
9403 "{task_completed}"
9404 );
9405 assert!(usage["duration_ms"].is_u64(), "{task_completed}");
9406 // #4039: a row may only label tokens as provider-reported when the
9407 // worker ledger actually received them.
9408 assert_eq!(
9409 usage["token_source"], "provider_reported",
9410 "{task_completed}"
9411 );
9412 assert!(
9413 usage["result_ref"]
9414 .as_str()
9415 .is_some_and(|target| target.starts_with("agent:")),
9416 "{task_completed}"
9417 );
9418
9419 // Run totals reconcile exactly with the per-task telemetry.
9420 let run_completed = events
9421 .iter()
9422 .find(|event| event["type"] == "run_completed")
9423 .expect("run_completed event");
9424 let run_usage = &run_completed["usage"];
9425 assert_eq!(run_usage["total_tokens"], total, "{run_completed}");
9426 assert_eq!(run_usage["input_tokens"], input, "{run_completed}");
9427 assert_eq!(run_usage["output_tokens"], output, "{run_completed}");
9428 assert_eq!(
9429 run_usage["tool_calls"], usage["tool_calls"],
9430 "{run_completed}"
9431 );
9432 assert_eq!(run_usage["tasks_reported"], 1, "{run_completed}");
9433
9434 // Totals also land on the persisted record and execution receipt.
9435 assert_eq!(payload["usage"]["total_tokens"], total, "{payload}");
9436 assert_eq!(
9437 payload["execution"]["usage"]["input_tokens"], input,
9438 "{payload}"
9439 );
9440 assert_eq!(
9441 payload["execution"]["leaf_results"][0]["usage"]["input_tokens"], input,
9442 "{payload}"
9443 );
9444 }
9445
9446 #[test]
9447 fn provider_usage_presence_preserves_a_real_zero_receipt() {
9448 assert!(!provider_usage_was_reported(None, None, None));
9449 assert!(provider_usage_was_reported(Some(0), Some(0), Some(0)));
9450 assert!(provider_usage_was_reported(None, Some(0), None));
9451 }
9452
9453 #[test]
9454 fn run_usage_totals_reconcile_task_telemetry() {
9455 let task_usage = |total: u64, calls: u32| WorkflowTaskUsage {
9456 input_tokens: Some(total / 2),
9457 output_tokens: Some(total - total / 2),
9458 total_tokens: Some(total),
9459 cost_microusd: Some(total),
9460 tool_calls: Some(calls),
9461 duration_ms: Some(7),
9462 result_ref: None,
9463 token_source: Some(WorkflowTokenSource::ProviderReported),
9464 };
9465 let record = |agent_id: &str, usage: Option<WorkflowTaskUsage>| RuntimeTaskRecord {
9466 agent_id: agent_id.to_string(),
9467 label: None,
9468 role: None,
9469 status: IrWorkflowRunStatus::Succeeded,
9470 output: None,
9471 schema_error: None,
9472 usage,
9473 };
9474 let records = vec![
9475 record("a", Some(task_usage(100, 2))),
9476 record("b", Some(task_usage(60, 1))),
9477 record("c", None),
9478 ];
9479 let totals = run_usage_totals(&records).expect("totals");
9480 assert_eq!(totals.total_tokens, Some(160));
9481 assert_eq!(totals.cost_microusd, Some(160));
9482 assert_eq!(totals.input_tokens, Some(80));
9483 assert_eq!(totals.output_tokens, Some(80));
9484 assert_eq!(totals.tool_calls, Some(3));
9485 assert_eq!(totals.tasks_reported, 2);
9486
9487 assert!(run_usage_totals(&[]).is_none());
9488 assert!(run_usage_totals(&[record("d", None)]).is_none());
9489 }
9490
9491 #[test]
9492 fn run_and_ir_usage_keep_unknown_distinct_from_reported_zero() {
9493 let record = |agent_id: &str, usage: WorkflowTaskUsage| RuntimeTaskRecord {
9494 agent_id: agent_id.to_string(),
9495 label: Some(agent_id.to_string()),
9496 role: None,
9497 status: IrWorkflowRunStatus::Succeeded,
9498 output: None,
9499 schema_error: None,
9500 usage: Some(usage),
9501 };
9502 let unknown = WorkflowTaskUsage {
9503 tool_calls: Some(1),
9504 duration_ms: Some(4),
9505 ..WorkflowTaskUsage::default()
9506 };
9507 let reported_zero = WorkflowTaskUsage {
9508 input_tokens: Some(0),
9509 output_tokens: Some(0),
9510 total_tokens: Some(0),
9511 cost_microusd: Some(0),
9512 tool_calls: Some(0),
9513 duration_ms: Some(0),
9514 token_source: Some(WorkflowTokenSource::ProviderReported),
9515 ..WorkflowTaskUsage::default()
9516 };
9517
9518 let unknown_totals = run_usage_totals(&[record("unknown", unknown.clone())])
9519 .expect("tool/duration receipt still creates run usage");
9520 assert_eq!(unknown_totals.input_tokens, None);
9521 assert_eq!(unknown_totals.output_tokens, None);
9522 assert_eq!(unknown_totals.total_tokens, None);
9523 assert_eq!(unknown_totals.tool_calls, Some(1));
9524
9525 let zero_totals = run_usage_totals(&[record("zero", reported_zero.clone())])
9526 .expect("reported zero receipt");
9527 assert_eq!(zero_totals.input_tokens, Some(0));
9528 assert_eq!(zero_totals.output_tokens, Some(0));
9529 assert_eq!(zero_totals.total_tokens, Some(0));
9530 assert_eq!(zero_totals.cost_microusd, Some(0));
9531 assert_eq!(zero_totals.tool_calls, Some(0));
9532
9533 let unknown_ir = workflow_usage_from_task(&unknown);
9534 assert_eq!(unknown_ir.input_tokens, None);
9535 assert_eq!(unknown_ir.output_tokens, None);
9536 assert_eq!(unknown_ir.cost_microusd, None);
9537 let zero_ir = workflow_usage_from_task(&reported_zero);
9538 assert_eq!(zero_ir.input_tokens, Some(0));
9539 assert_eq!(zero_ir.output_tokens, Some(0));
9540 assert_eq!(zero_ir.cost_microusd, Some(0));
9541
9542 let mixed = run_usage_totals(&[record("zero", reported_zero), record("unknown", unknown)])
9543 .expect("mixed receipts");
9544 assert_eq!(
9545 mixed.total_tokens,
9546 Some(0),
9547 "a missing contributor keeps the observed subtotal"
9548 );
9549 assert_eq!(mixed.input_tokens, Some(0));
9550 assert_eq!(mixed.output_tokens, Some(0));
9551 assert_eq!(mixed.cost_microusd, Some(0));
9552 assert_eq!(mixed.tool_calls, Some(1));
9553 }
9554
9555 #[test]
9556 fn workflow_ui_event_usage_telemetry_serde_round_trip() {
9557 let event = WorkflowUiEvent::at(
9558 7,
9559 WorkflowUiEventKind::TaskCompleted {
9560 task_id: "child-1".to_string(),
9561 status: IrWorkflowRunStatus::Succeeded,
9562 usage: Some(WorkflowTaskUsage {
9563 input_tokens: Some(128),
9564 output_tokens: Some(32),
9565 total_tokens: Some(160),
9566 cost_microusd: Some(42),
9567 tool_calls: Some(2),
9568 duration_ms: Some(42),
9569 result_ref: Some("agent:child-1".to_string()),
9570 token_source: Some(WorkflowTokenSource::ProviderReported),
9571 }),
9572 },
9573 );
9574 let json = serde_json::to_value(&event).expect("serialize");
9575 assert_eq!(json["usage"]["total_tokens"], 160);
9576 let parsed: WorkflowUiEvent = serde_json::from_value(json).expect("deserialize round trip");
9577 match parsed.kind {
9578 WorkflowUiEventKind::TaskCompleted {
9579 usage: Some(usage), ..
9580 } => {
9581 assert_eq!(usage.total_tokens, Some(160));
9582 assert_eq!(usage.cost_microusd, Some(42));
9583 assert_eq!(usage.tool_calls, Some(2));
9584 assert_eq!(usage.duration_ms, Some(42));
9585 }
9586 other => panic!("expected task_completed with usage, got {other:?}"),
9587 }
9588
9589 // Journals written before #2974 carry no usage fields; they must
9590 // still parse with `usage == None`.
9591 let legacy_task: WorkflowUiEvent = serde_json::from_str(
9592 r#"{"at_ms":5,"type":"task_completed","task_id":"child-1","status":"succeeded"}"#,
9593 )
9594 .expect("legacy task_completed parses");
9595 match legacy_task.kind {
9596 WorkflowUiEventKind::TaskCompleted { usage: None, .. } => {}
9597 other => panic!("expected legacy task_completed without usage, got {other:?}"),
9598 }
9599 let legacy_run: WorkflowUiEvent = serde_json::from_str(
9600 r#"{"at_ms":6,"type":"run_completed","status":"completed","error":null}"#,
9601 )
9602 .expect("legacy run_completed parses");
9603 match legacy_run.kind {
9604 WorkflowUiEventKind::RunCompleted { usage: None, .. } => {}
9605 other => panic!("expected legacy run_completed without usage, got {other:?}"),
9606 }
9607
9608 // A telemetry-less event serializes without a `usage` key, so old
9609 // consumers see a byte-compatible shape.
9610 let plain = serde_json::to_value(WorkflowUiEvent::at(
9611 8,
9612 WorkflowUiEventKind::RunCompleted {
9613 status: WorkflowRunStatus::Completed,
9614 error: None,
9615 usage: None,
9616 },
9617 ))
9618 .expect("serialize plain run_completed");
9619 assert!(plain.get("usage").is_none(), "{plain}");
9620 }
9621
9622 #[test]
9623 fn run_record_event_retention_is_bounded() {
9624 let mut record = WorkflowRunRecord::new("workflow_tail".to_string(), None, None, None);
9625 for index in 0..(WORKFLOW_RUN_EVENTS_MAX_RETAINED + 5) {
9626 record.push_event(WorkflowUiEvent::at(
9627 index as u64,
9628 WorkflowUiEventKind::Log {
9629 message: format!("log {index}"),
9630 },
9631 ));
9632 }
9633 assert_eq!(record.events.len(), WORKFLOW_RUN_EVENTS_MAX_RETAINED);
9634 assert_eq!(record.events_dropped, 5);
9635 assert_eq!(
9636 record.events_total,
9637 (WORKFLOW_RUN_EVENTS_MAX_RETAINED + 5) as u64
9638 );
9639 // The retained window is the newest tail.
9640 let first = &record.events[0];
9641 assert_eq!(first.at_ms, 5);
9642 // Summaries report the truthful total, not the retained tail length.
9643 let summary = record.summary();
9644 assert_eq!(summary.event_count, WORKFLOW_RUN_EVENTS_MAX_RETAINED + 5);
9645 assert_eq!(summary.events_dropped, 5);
9646 assert_eq!(summary.last_event_type.as_deref(), Some("log"));
9647 }
9648
9649 #[test]
9650 fn workflow_status_payload_bounds_oversized_run_records() {
9651 let tmp = tempfile::tempdir().expect("tempdir");
9652 let state = WorkflowWorkspaceState::open(tmp.path());
9653 let run_id = "workflow_big_run".to_string();
9654 let mut record = WorkflowRunRecord::new(run_id.clone(), None, None, None);
9655 record.status = WorkflowRunStatus::Completed;
9656 // A high-fan-out run: far more events/progress than the model needs.
9657 for index in 0..200u64 {
9658 record.push_event(WorkflowUiEvent::at(
9659 index,
9660 WorkflowUiEventKind::Log {
9661 message: format!("fan-out event {index}"),
9662 },
9663 ));
9664 }
9665 for index in 0..60 {
9666 record.progress.push(format!("progress line {index}"));
9667 }
9668 for index in 0..40u64 {
9669 record.dispatch_failures.push(WorkflowDispatchFailure {
9670 at_ms: index,
9671 label: Some(format!("rejected-{index}")),
9672 phase: Some("fan-out".to_string()),
9673 message: format!("dispatch rejected {index}: {}", "x".repeat(2_000)),
9674 });
9675 }
9676 record.result = Some(json!({ "blob": "r".repeat(10_000) }));
9677 record.execution = Some(IrWorkflowExecution {
9678 status: IrWorkflowRunStatus::Succeeded,
9679 usage: WorkflowUsage::default(),
9680 memo_usage: WorkflowMemoUsage::default(),
9681 leaf_results: vec![LeafResult {
9682 leaf_id: "inspect".to_string(),
9683 task_id: "agent_1".to_string(),
9684 role: None,
9685 profile: None,
9686 status: IrWorkflowRunStatus::Succeeded,
9687 usage: WorkflowUsage::default(),
9688 memo_usage: WorkflowMemoUsage::default(),
9689 output: Some("o".repeat(5_000)),
9690 artifacts: Vec::new(),
9691 schema_error: None,
9692 }],
9693 branch_results: Vec::new(),
9694 control_node_results: Vec::new(),
9695 });
9696 state
9697 .runs
9698 .lock()
9699 .expect("runs")
9700 .insert(run_id.clone(), record);
9701
9702 let result = workflow_result_for(&run_id, state).expect("status result");
9703 assert!(
9704 result.content.len() < WORKFLOW_RESULT_MAX_CHARS,
9705 "bounded payload must stay under {WORKFLOW_RESULT_MAX_CHARS} chars, got {}",
9706 result.content.len()
9707 );
9708 let payload: Value = serde_json::from_str(&result.content).expect("payload json");
9709
9710 let events = payload["events"].as_array().expect("events");
9711 assert_eq!(events.len(), WORKFLOW_RESULT_EVENTS_TAIL, "{payload}");
9712 assert!(
9713 payload["events_note"]
9714 .as_str()
9715 .is_some_and(|note| note.contains("workflow-runs.jsonl")),
9716 "{payload}"
9717 );
9718 // The retained window is the newest events.
9719 assert_eq!(events[0]["at_ms"], 150);
9720
9721 let progress = payload["progress"].as_array().expect("progress");
9722 assert_eq!(progress.len(), WORKFLOW_RESULT_PROGRESS_TAIL, "{payload}");
9723 assert!(payload.get("progress_note").is_some(), "{payload}");
9724
9725 let dispatch_failures = payload["dispatch_failures"]
9726 .as_array()
9727 .expect("dispatch failures");
9728 assert_eq!(
9729 dispatch_failures.len(),
9730 WORKFLOW_RESULT_DISPATCH_FAILURES_TAIL,
9731 "{payload}"
9732 );
9733 assert_eq!(dispatch_failures[0]["at_ms"], 28);
9734 assert!(
9735 dispatch_failures.iter().all(|failure| failure["message"]
9736 .as_str()
9737 .is_some_and(|message| message.chars().count()
9738 <= WORKFLOW_RESULT_DISPATCH_FAILURE_FIELD_MAX_CHARS)),
9739 "{dispatch_failures:?}"
9740 );
9741 assert!(
9742 payload["dispatch_failures_note"]
9743 .as_str()
9744 .is_some_and(|note| note.contains("workflow-runs.jsonl")),
9745 "{payload}"
9746 );
9747
9748 // Oversized VM result collapses to a preview with a journal pointer.
9749 assert_eq!(payload["result"]["truncated"], true, "{payload}");
9750 assert!(
9751 payload["result"]["preview"]
9752 .as_str()
9753 .is_some_and(|preview| preview.chars().count() <= WORKFLOW_RESULT_VALUE_MAX_CHARS),
9754 "{payload}"
9755 );
9756 assert!(
9757 payload["result"]["full_detail"]
9758 .as_str()
9759 .is_some_and(|path| path.contains("workflow-runs.jsonl")),
9760 "{payload}"
9761 );
9762
9763 // Leaf outputs carry a bounded preview instead of full child text.
9764 let leaf_output = payload["execution"]["leaf_results"][0]["output"]
9765 .as_str()
9766 .expect("leaf output");
9767 assert!(
9768 leaf_output.contains("leaf output truncated"),
9769 "{leaf_output}"
9770 );
9771 assert!(leaf_output.len() < 1_000, "{leaf_output}");
9772
9773 let metadata = result.metadata.expect("metadata");
9774 assert_eq!(metadata["events_returned"], WORKFLOW_RESULT_EVENTS_TAIL);
9775 assert_eq!(metadata["events_omitted"], 150);
9776 assert_eq!(metadata["event_count"], 200);
9777 assert_eq!(
9778 metadata["dispatch_failures_returned"],
9779 WORKFLOW_RESULT_DISPATCH_FAILURES_TAIL
9780 );
9781 assert_eq!(metadata["dispatch_failures_omitted"], 28);
9782 assert_eq!(metadata["truncated"], true);
9783 assert!(
9784 metadata["journal_path"]
9785 .as_str()
9786 .is_some_and(|path| path.contains("workflow-runs.jsonl")),
9787 "{metadata}"
9788 );
9789 }
9790
9791 #[test]
9792 fn workflow_status_payload_keeps_small_records_intact() {
9793 let tmp = tempfile::tempdir().expect("tempdir");
9794 let state = WorkflowWorkspaceState::open(tmp.path());
9795 let run_id = "workflow_small_run".to_string();
9796 let mut record = WorkflowRunRecord::new(run_id.clone(), None, None, None);
9797 record.status = WorkflowRunStatus::Completed;
9798 record.push_event(WorkflowUiEvent::at(
9799 1,
9800 WorkflowUiEventKind::PhaseStarted {
9801 title: "scan".to_string(),
9802 },
9803 ));
9804 record.result = Some(json!({ "ok": true }));
9805 state
9806 .runs
9807 .lock()
9808 .expect("runs")
9809 .insert(run_id.clone(), record);
9810
9811 let result = workflow_result_for(&run_id, state).expect("status result");
9812 let payload: Value = serde_json::from_str(&result.content).expect("payload json");
9813 assert_eq!(payload["events"].as_array().map(Vec::len), Some(1));
9814 assert_eq!(payload["result"], json!({ "ok": true }));
9815 assert!(payload.get("events_note").is_none(), "{payload}");
9816 assert!(payload.get("progress_note").is_none(), "{payload}");
9817 let metadata = result.metadata.expect("metadata");
9818 assert_eq!(metadata["truncated"], false);
9819 assert_eq!(metadata["events_omitted"], 0);
9820 assert_eq!(metadata["events_returned"], 1);
9821 }
9822 #[tokio::test]
9823 #[allow(clippy::await_holding_lock)]
9824 async fn workflow_cancel_interrupts_vm_and_blocks_further_spawns() {
9825 let _retry_guard = workflow_test_retry_guard();
9826 let tmp = tempfile::tempdir().expect("tempdir");
9827 let ctx = ToolContext::new(tmp.path().to_path_buf());
9828 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 4);
9829 let (client, calls) = fake_chat_client("child done").await;
9830 let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(256);
9831 let runtime = SubAgentRuntime::new(
9832 client,
9833 "deepseek-v4-flash".to_string(),
9834 ctx.clone(),
9835 true,
9836 Some(event_tx),
9837 manager.clone(),
9838 );
9839 let tool = WorkflowTool::new(manager.clone(), runtime);
9840
9841 let started = tool
9842 .execute(
9843 json!({
9844 "action": "start",
9845 "script": r#"
9846 let n = 0;
9847 while (n < 20) {
9848 await task({ description: `task ${n}`, type: 'explore', allowedTools: [] });
9849 n++;
9850 }
9851 return n;
9852 "#
9853 }),
9854 &ctx,
9855 )
9856 .await
9857 .expect("workflow start");
9858 let run_id = started
9859 .metadata
9860 .as_ref()
9861 .and_then(|metadata| metadata.get("run_id"))
9862 .and_then(Value::as_str)
9863 .expect("run_id metadata");
9864
9865 tokio::time::timeout(std::time::Duration::from_secs(3), async {
9866 while calls.load(Ordering::SeqCst) == 0 {
9867 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
9868 }
9869 })
9870 .await
9871 .expect("workflow should spawn at least one child before cancel");
9872 let calls_before_cancel = calls.load(Ordering::SeqCst);
9873 assert!(calls_before_cancel >= 1);
9874
9875 let cancelled = tool
9876 .execute(json!({"action": "cancel", "run_id": run_id}), &ctx)
9877 .await
9878 .expect("workflow cancel");
9879 let cancelled_payload: Value =
9880 serde_json::from_str(&cancelled.content).expect("cancel json");
9881 assert_eq!(cancelled_payload["status"], "cancelled");
9882 assert!(
9883 cancelled_payload["events"]
9884 .as_array()
9885 .is_some_and(|events| events.iter().any(|event| event["type"] == "run_cancelled")),
9886 "cancel receipt must include the authoritative terminal event: {cancelled_payload}"
9887 );
9888 let mut streamed_cancel = false;
9889 while let Ok(event) = event_rx.try_recv() {
9890 if let Event::WorkflowUi { event, .. } = event
9891 && event["type"] == "run_cancelled"
9892 {
9893 streamed_cancel = true;
9894 }
9895 }
9896 assert!(
9897 streamed_cancel,
9898 "cancel must stream a terminal UI event after any racing completion"
9899 );
9900 let first_event_count = cancelled_payload["events"]
9901 .as_array()
9902 .expect("events")
9903 .len();
9904 let first_completed_at = cancelled_payload["completed_at_ms"].clone();
9905 let cancelled_again = tool
9906 .execute(json!({"action": "cancel", "run_id": run_id}), &ctx)
9907 .await
9908 .expect("second workflow cancel is a no-op");
9909 let cancelled_again_payload: Value =
9910 serde_json::from_str(&cancelled_again.content).expect("second cancel json");
9911 assert_eq!(cancelled_again_payload["status"], "cancelled");
9912 assert_eq!(
9913 cancelled_again_payload["events"]
9914 .as_array()
9915 .expect("events")
9916 .len(),
9917 first_event_count,
9918 "second cancel must not append a duplicate terminal event"
9919 );
9920 assert_eq!(
9921 cancelled_again_payload["completed_at_ms"], first_completed_at,
9922 "second cancel must preserve the original completion time"
9923 );
9924
9925 tokio::time::sleep(std::time::Duration::from_millis(700)).await;
9926 let calls_after_cancel = calls.load(Ordering::SeqCst);
9927 assert!(
9928 calls_after_cancel <= calls_before_cancel + 1,
9929 "cancelled workflow kept spawning children: before={calls_before_cancel} after={calls_after_cancel}"
9930 );
9931 }
9932
9933 #[tokio::test]
9934 #[allow(clippy::await_holding_lock)]
9935 async fn workflow_budget_spent_delegates_to_manager_scope() {
9936 let _retry_guard = workflow_test_retry_guard();
9937 let tmp = tempfile::tempdir().expect("tempdir");
9938 let ctx = ToolContext::new(tmp.path().to_path_buf());
9939 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
9940 let (client, _calls) = fake_chat_client("budgeted").await;
9941 let runtime = SubAgentRuntime::new(
9942 client,
9943 "deepseek-v4-flash".to_string(),
9944 ctx.clone(),
9945 true,
9946 None,
9947 manager.clone(),
9948 );
9949 let tool = WorkflowTool::new(manager.clone(), runtime);
9950
9951 let result = tool
9952 .execute(
9953 json!({
9954 "action": "run",
9955 "token_budget": 1000,
9956 "script": r#"
9957 await task({ description: 'budgeted work', type: 'explore', allowedTools: [] });
9958 return { spent: budget.spent(), total: budget.total, remaining: budget.remaining() };
9959 "#
9960 }),
9961 &ctx,
9962 )
9963 .await
9964 .expect("budget workflow should complete");
9965 let payload: Value = serde_json::from_str(&result.content).expect("json result");
9966
9967 assert_eq!(payload["status"], "completed", "{payload}");
9968 assert_eq!(payload["result"]["spent"], 2);
9969 assert_eq!(payload["result"]["total"], 1000);
9970 assert_eq!(payload["result"]["remaining"], 998);
9971 }
9972
9973 #[tokio::test]
9974 async fn identical_budget_snapshots_emit_a_live_heartbeat_without_journal_duplication() {
9975 let tmp = tempfile::tempdir().expect("tempdir");
9976 let ctx = ToolContext::new(tmp.path().to_path_buf());
9977 let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2);
9978 let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(8);
9979 let runtime = SubAgentRuntime::new(
9980 stub_client(),
9981 "deepseek-v4-flash".to_string(),
9982 ctx,
9983 true,
9984 Some(event_tx),
9985 manager.clone(),
9986 );
9987 let state = WorkflowWorkspaceState::open(tmp.path());
9988 let run_id = "workflow_budget_heartbeat".to_string();
9989 state.runs.lock().expect("runs").insert(
9990 run_id.clone(),
9991 WorkflowRunRecord::new(run_id.clone(), None, None, None),
9992 );
9993 let driver = SubAgentWorkflowDriver::new(
9994 run_id.clone(),
9995 manager,
9996 runtime,
9997 state.clone(),
9998 Some(1_000),
9999 WorkflowFleetBinding::None,
10000 Vec::new(),
10001 );
10002 let snapshot = BudgetSnapshot {
10003 total: Some(1_000),
10004 spent: 0,
10005 };
10006
10007 driver.record_budget_snapshot(snapshot);
10008 driver.record_budget_snapshot(snapshot);
10009
10010 let mut streamed = 0;
10011 while let Ok(Event::WorkflowUi { event, .. }) = event_rx.try_recv() {
10012 if event["type"] == "budget_updated" {
10013 streamed += 1;
10014 }
10015 }
10016 assert_eq!(
10017 streamed, 2,
10018 "unchanged budget still refreshes the live panel"
10019 );
10020 let recorded = state
10021 .runs
10022 .lock()
10023 .expect("runs")
10024 .get(&run_id)
10025 .expect("run")
10026 .events
10027 .iter()
10028 .filter(|event| event.event_type() == "budget_updated")
10029 .count();
10030 assert_eq!(recorded, 1, "heartbeat must not grow the durable journal");
10031 }
10032
10033 fn stub_client() -> DeepSeekClient {
10034 let _ = rustls::crypto::ring::default_provider().install_default();
10035 let config = crate::config::Config {
10036 api_key: Some("test-key".to_string()),
10037 ..crate::config::Config::default()
10038 };
10039 DeepSeekClient::new(&config).expect("stub client should construct")
10040 }
10041
10042 async fn fake_chat_client(response_text: &str) -> (DeepSeekClient, Arc<AtomicUsize>) {
10043 let (client, calls, _) = fake_chat_client_capturing(response_text).await;
10044 (client, calls)
10045 }
10046
10047 async fn fake_chat_client_responses(
10048 response_texts: &[&str],
10049 ) -> (DeepSeekClient, Arc<AtomicUsize>) {
10050 let (client, calls, _) = fake_chat_client_capturing_responses(response_texts).await;
10051 (client, calls)
10052 }
10053
10054 async fn fake_chat_client_capturing(
10055 response_text: &str,
10056 ) -> (DeepSeekClient, Arc<AtomicUsize>, Arc<Mutex<Vec<Value>>>) {
10057 fake_chat_client_capturing_responses(&[response_text]).await
10058 }
10059
10060 async fn fake_chat_client_capturing_responses(
10061 response_texts: &[&str],
10062 ) -> (DeepSeekClient, Arc<AtomicUsize>, Arc<Mutex<Vec<Value>>>) {
10063 assert!(
10064 !response_texts.is_empty(),
10065 "fake chat client needs at least one response"
10066 );
10067 let calls = Arc::new(AtomicUsize::new(0));
10068 let bodies = Arc::new(Mutex::new(Vec::new()));
10069 let response_texts = Arc::new(
10070 response_texts
10071 .iter()
10072 .map(|response| (*response).to_string())
10073 .collect::<Vec<_>>(),
10074 );
10075 let app = Router::new().route(
10076 "/{*path}",
10077 post({
10078 let calls = Arc::clone(&calls);
10079 let bodies = Arc::clone(&bodies);
10080 let response_texts = Arc::clone(&response_texts);
10081 move |Json(body): Json<Value>| {
10082 let calls = Arc::clone(&calls);
10083 let bodies = Arc::clone(&bodies);
10084 let response_texts = Arc::clone(&response_texts);
10085 async move {
10086 bodies.lock().expect("capture body").push(body);
10087 let attempt = calls.fetch_add(1, Ordering::SeqCst) + 1;
10088 let response_text = if response_texts.len() == 1 {
10089 response_texts[0].clone()
10090 } else {
10091 response_texts
10092 .get(attempt - 1)
10093 .unwrap_or_else(|| {
10094 panic!(
10095 "fake chat server received call {attempt} but only {} responses were supplied",
10096 response_texts.len()
10097 )
10098 })
10099 .clone()
10100 };
10101 Json(json!({
10102 "id": format!("chatcmpl-workflow-test-{attempt}"),
10103 "model": "deepseek-v4-flash",
10104 "choices": [{
10105 "index": 0,
10106 "message": {
10107 "role": "assistant",
10108 "content": response_text
10109 },
10110 "finish_reason": "stop"
10111 }],
10112 "usage": {
10113 "prompt_tokens": 1,
10114 "completion_tokens": 1,
10115 "total_tokens": 2
10116 }
10117 }))
10118 }
10119 }
10120 }),
10121 );
10122
10123 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
10124 .await
10125 .expect("bind fake chat server");
10126 let addr = listener.local_addr().expect("fake chat server addr");
10127 tokio::spawn(async move {
10128 let _ = axum::serve(listener, app).await;
10129 });
10130
10131 let config = crate::config::Config {
10132 api_key: Some("test-key".to_string()),
10133 base_url: Some(format!("http://{addr}/v1")),
10134 ..crate::config::Config::default()
10135 };
10136 (
10137 DeepSeekClient::new(&config).expect("fake chat client"),
10138 calls,
10139 bodies,
10140 )
10141 }
10142
10143 fn workflow_test_retry_guard() -> std::sync::MutexGuard<'static, ()> {
10144 let guard = crate::retry_status::test_guard();
10145 crate::retry_status::clear();
10146 crate::retry_status::clear_rate_limit();
10147 guard
10148 }
10149 }
10150
10150 lines RUST