返回 CodeWhale
operate.rs
根目录 / crates / tui / src / operate.rs
1 //! Operate: always-on fleet operation matching landed CWC `OperateRecord`
2 //! (`Hmbown/cwc` `20de981`, PR #284).
3 //!
4 //! One schema for `cw · operate` and CWC `/operate`. Burn rate is optional
5 //! (`null` = unbounded). The lead plans before workers. Pace throttles or
6 //! widens; it never stops the operation. Supervisor over nested instances —
7 //! no second `Engine::run_turn`.
8
9 use std::fs;
10 use std::path::{Component, Path, PathBuf};
11 use std::process::{Command, Stdio};
12
13 use anyhow::{Context, Result, bail};
14 use chrono::Utc;
15 use serde::{Deserialize, Serialize};
16 use uuid::Uuid;
17
18 use crate::automation_manager::{AutomationManager, AutomationRecord, AutomationStatus};
19
20 pub const CWC_OPERATE_SCHEMA_VERSION: u32 = 1;
21 pub const OPERATE_MAX_WRITERS: usize = 3;
22 /// `hold` admits no new writers past this budget (the 8% band is met; hold
23 /// the current width instead of widening).
24 pub const OPERATE_HOLD_WRITERS: usize = 2;
25 /// `throttle` (observed more than 8% over target) cuts worker concurrency to
26 /// one writer. Pace throttles; it never stops the operation.
27 pub const OPERATE_THROTTLE_WRITERS: usize = 1;
28 pub const OPERATE_KEEPALIVE_ID: &str = "cw-operate";
29 /// Follow-up lead runs recur hourly; the first lead-plan step is kicked to
30 /// the next scheduler tick instead of waiting for the first recurrence.
31 pub const OPERATE_KEEPALIVE_RRULE: &str = "FREQ=HOURLY;INTERVAL=1";
32 pub const AUTO_MERGE_CHECKER_ENV: &str = "CODEWHALE_AUTO_MERGE_CHECKER";
33 pub const DIRECTION_PATH_ENV: &str = "CODEWHALE_DIRECTION_PATH";
34 pub const CHECK_AUTO_MERGE_SCRIPT: &str = "scripts/check-auto-merge.py";
35 pub const AUTO_MERGE_SCRIPT: &str = "scripts/auto_merge.py";
36 pub const AUTO_MERGE_PR_SCRIPT: &str = "scripts/auto-merge-pr.py";
37
38 const PACE_BAND: f64 = 0.08;
39
40 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41 #[serde(rename_all = "camelCase")]
42 pub struct OperateBurnRate {
43 pub kind: String,
44 pub amount_usd_per_hour: f64,
45 }
46
47 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48 #[serde(rename_all = "snake_case")]
49 pub enum OperateStatus {
50 Planning,
51 Running,
52 #[serde(rename = "idle_blocked")]
53 IdleBlocked,
54 Cancelled,
55 }
56
57 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58 #[serde(rename_all = "snake_case")]
59 pub enum OperateIdleReason {
60 MissingCredentials,
61 AwaitingLeadPlan,
62 DirectionEmpty,
63 HumanGated,
64 }
65
66 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67 #[serde(rename_all = "snake_case")]
68 pub enum OperatePace {
69 Unbounded,
70 Hold,
71 Throttle,
72 Widen,
73 }
74
75 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76 #[serde(rename_all = "camelCase")]
77 pub struct OperateRosterMember {
78 pub id: String,
79 pub display_name: String,
80 pub role: String,
81 /// Saved selection for the lead; empty when no model has been assigned.
82 pub model: String,
83 pub state: String,
84 }
85
86 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87 #[serde(rename_all = "camelCase")]
88 pub struct OperatePlanSlice {
89 pub id: String,
90 pub title: String,
91 pub owner_id: String,
92 pub depends_on: Vec<String>,
93 pub est_cost_usd: f64,
94 pub start_offset_sec: u32,
95 pub duration_sec: u32,
96 }
97
98 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99 pub struct OperateLeadPlan {
100 pub slices: Vec<OperatePlanSlice>,
101 }
102
103 /// Landed CWC `OperateRecord` (packages/contracts/src/operate.js @ 20de981).
104 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105 #[serde(rename_all = "camelCase")]
106 pub struct Operation {
107 pub id: String,
108 pub schema_version: u32,
109 pub direction: String,
110 pub burn_rate: Option<OperateBurnRate>,
111 pub lead_operator: OperateRosterMember,
112 pub roster: Vec<OperateRosterMember>,
113 pub lead_plan: Option<OperateLeadPlan>,
114 pub status: OperateStatus,
115 pub idle_blocked_reason: Option<OperateIdleReason>,
116 pub pace: OperatePace,
117 pub writers_in_flight: usize,
118 pub workers_admitted: bool,
119 pub spent_usd: f64,
120 pub observed_burn_usd_per_hour: Option<f64>,
121 pub credentials_present: bool,
122 pub human_gated: bool,
123 pub human_gate: String,
124 pub created_at: String,
125 pub updated_at: String,
126 pub last_keep_alive_at: String,
127 #[serde(default)]
128 pub cancelled_at: String,
129 }
130
131 impl Operation {
132 /// Update display metadata from the saved selection without assigning a
133 /// route to planned workers or claiming that an executor is running.
134 pub(crate) fn set_lead_model(&mut self, model: &str) {
135 self.lead_operator.model = model.to_string();
136 for member in &mut self.roster {
137 if member.id == self.lead_operator.id {
138 member.model = model.to_string();
139 }
140 }
141 }
142
143 #[must_use]
144 pub fn new(direction: impl Into<String>, burn_usd_per_hour: Option<f64>) -> Self {
145 let now = Utc::now().to_rfc3339();
146 let lead = OperateRosterMember {
147 id: "lead".to_string(),
148 display_name: "Lead operator".to_string(),
149 role: "lead".to_string(),
150 model: String::new(),
151 state: "planning".to_string(),
152 };
153 let mut op = Self {
154 id: format!("op_{}", Uuid::new_v4()),
155 schema_version: CWC_OPERATE_SCHEMA_VERSION,
156 direction: normalize_direction(direction.into()),
157 burn_rate: normalize_burn_rate(burn_usd_per_hour),
158 lead_operator: lead.clone(),
159 roster: vec![lead],
160 lead_plan: None,
161 status: OperateStatus::Planning,
162 idle_blocked_reason: None,
163 pace: OperatePace::Unbounded,
164 writers_in_flight: 0,
165 workers_admitted: false,
166 spent_usd: 0.0,
167 observed_burn_usd_per_hour: None,
168 credentials_present: false,
169 human_gated: false,
170 human_gate: String::new(),
171 created_at: now.clone(),
172 updated_at: now.clone(),
173 last_keep_alive_at: now,
174 cancelled_at: String::new(),
175 };
176 op.project();
177 op
178 }
179
180 pub fn plan_from_direction(&mut self) {
181 self.lead_plan = slices_from_direction(&self.direction);
182 sync_plan_owners(self);
183 self.project();
184 }
185
186 pub fn project(&mut self) {
187 if self.status == OperateStatus::Cancelled {
188 self.idle_blocked_reason = None;
189 self.workers_admitted = false;
190 self.writers_in_flight = 0;
191 for member in &mut self.roster {
192 member.state = "idle".to_string();
193 }
194 return;
195 }
196 let (status, reason) = derive_status(self);
197 self.status = status;
198 self.idle_blocked_reason = reason;
199 self.workers_admitted = workers_admitted(self);
200 self.pace = derive_pace(self);
201 // Pace is a dispatch budget, not a label: the roster and the
202 // `writersInFlight` count the keepalive lead actually dispatches at
203 // come from `worker_dispatch_budget`, so over-target burn reduces
204 // real concurrency instead of only renaming it.
205 let budget = worker_dispatch_budget(self);
206 live_roster(self, budget);
207 self.writers_in_flight = if self.workers_admitted {
208 self.roster
209 .iter()
210 .filter(|member| member.role == "worker" && member.state == "in_flight")
211 .count()
212 } else {
213 0
214 };
215 if let Some(lead) = self.roster.iter().find(|member| member.role == "lead") {
216 self.lead_operator = lead.clone();
217 }
218 }
219 }
220
221 fn normalize_direction(value: String) -> String {
222 value.trim().chars().take(4000).collect()
223 }
224
225 fn normalize_burn_rate(amount: Option<f64>) -> Option<OperateBurnRate> {
226 parse_burn_amount(amount).ok().flatten()
227 }
228
229 /// CWC `normalizeOperateBurnRate`: number, `$/hr` object, or null.
230 pub fn parse_burn_rate(value: Option<&serde_json::Value>) -> Result<Option<OperateBurnRate>> {
231 let Some(value) = value else {
232 return Ok(None);
233 };
234 if value.is_null()
235 || value == &serde_json::Value::Bool(false)
236 || value.as_str().is_some_and(str::is_empty)
237 {
238 return Ok(None);
239 }
240 if let Some("unbounded") = value.get("kind").and_then(|kind| kind.as_str()) {
241 return Ok(None);
242 }
243 let amount = if value.is_number() || value.is_string() {
244 json_number(value)
245 } else {
246 json_number(
247 value
248 .get("amountUsdPerHour")
249 .or_else(|| value.get("usdPerHour"))
250 .or_else(|| value.get("amount"))
251 .unwrap_or(&serde_json::Value::Null),
252 )
253 };
254 if amount.is_none() {
255 anyhow::bail!("Burn rate is optional. When set, it must be a positive $/hr.");
256 }
257 parse_burn_amount(amount)
258 }
259
260 fn json_number(value: &serde_json::Value) -> Option<f64> {
261 value
262 .as_f64()
263 .or_else(|| value.as_i64().map(|n| n as f64))
264 .or_else(|| value.as_str().and_then(|s| s.parse().ok()))
265 }
266
267 fn parse_burn_amount(amount: Option<f64>) -> Result<Option<OperateBurnRate>> {
268 let Some(amount) = amount else {
269 return Ok(None);
270 };
271 if !amount.is_finite() || amount <= 0.0 {
272 anyhow::bail!("Burn rate is optional. When set, it must be a positive $/hr.");
273 }
274 if amount > 10_000.0 {
275 anyhow::bail!("Burn rate must be 10000 $/hr or less.");
276 }
277 let rounded = (amount * 100.0).round() / 100.0;
278 if rounded <= 0.0 {
279 // A sub-cent rate rounds to a $0/hr target, which the pace governor
280 // would treat as unbounded — reject it instead of silently dropping
281 // the requested cap.
282 anyhow::bail!("Burn rate must be at least $0.01/hr.");
283 }
284 Ok(Some(OperateBurnRate {
285 kind: "usd_per_hour".to_string(),
286 amount_usd_per_hour: rounded,
287 }))
288 }
289
290 fn derive_status(op: &Operation) -> (OperateStatus, Option<OperateIdleReason>) {
291 if op.status == OperateStatus::Cancelled {
292 return (OperateStatus::Cancelled, None);
293 }
294 if !op.credentials_present {
295 return (
296 OperateStatus::IdleBlocked,
297 Some(OperateIdleReason::MissingCredentials),
298 );
299 }
300 if op.direction.is_empty() {
301 return (
302 OperateStatus::IdleBlocked,
303 Some(OperateIdleReason::DirectionEmpty),
304 );
305 }
306 if op.human_gated {
307 return (
308 OperateStatus::IdleBlocked,
309 Some(OperateIdleReason::HumanGated),
310 );
311 }
312 if op
313 .lead_plan
314 .as_ref()
315 .is_none_or(|plan| plan.slices.is_empty())
316 {
317 return (
318 OperateStatus::IdleBlocked,
319 Some(OperateIdleReason::AwaitingLeadPlan),
320 );
321 }
322 (OperateStatus::Running, None)
323 }
324
325 fn workers_admitted(op: &Operation) -> bool {
326 op.status != OperateStatus::Cancelled
327 && op.credentials_present
328 && !op.direction.is_empty()
329 && op
330 .lead_plan
331 .as_ref()
332 .is_some_and(|plan| !plan.slices.is_empty())
333 && !op.human_gated
334 }
335
336 fn derive_pace(op: &Operation) -> OperatePace {
337 let Some(rate) = &op.burn_rate else {
338 return OperatePace::Unbounded;
339 };
340 let Some(observed) = op.observed_burn_usd_per_hour.filter(|value| *value > 0.0) else {
341 return OperatePace::Widen;
342 };
343 let target = rate.amount_usd_per_hour;
344 if target <= 0.0 {
345 return OperatePace::Unbounded;
346 }
347 let delta = (observed - target) / target;
348 if delta > PACE_BAND {
349 OperatePace::Throttle
350 } else if delta < -PACE_BAND {
351 OperatePace::Widen
352 } else {
353 OperatePace::Hold
354 }
355 }
356
357 /// Pace-driven worker-dispatch budget within the 8% band semantics.
358 ///
359 /// `widen`/`unbounded` opens the full writer width, `hold` admits no new
360 /// writers past the hold width, and `throttle` cuts concurrency to one
361 /// writer. While workers are admitted the budget is never zero — pace
362 /// throttles spend, it never stops the operation.
363 #[must_use]
364 pub fn worker_dispatch_budget(op: &Operation) -> usize {
365 if !op.workers_admitted {
366 return 0;
367 }
368 match op.pace {
369 OperatePace::Unbounded | OperatePace::Widen => OPERATE_MAX_WRITERS,
370 OperatePace::Hold => OPERATE_HOLD_WRITERS,
371 OperatePace::Throttle => OPERATE_THROTTLE_WRITERS,
372 }
373 }
374
375 fn live_roster(op: &mut Operation, budget: usize) {
376 let admitted = op.workers_admitted;
377 let cancelled = op.status == OperateStatus::Cancelled;
378 let has_plan = op
379 .lead_plan
380 .as_ref()
381 .is_some_and(|plan| !plan.slices.is_empty());
382 // Only the first `budget` workers in roster (plan) order dispatch; the
383 // rest stay idle until a slot frees or pace widens.
384 let mut worker_slot = 0usize;
385 for member in &mut op.roster {
386 if cancelled {
387 member.state = "idle".to_string();
388 } else if !op.credentials_present {
389 member.state = "blocked".to_string();
390 } else if member.role == "lead" && !has_plan {
391 member.state = "planning".to_string();
392 } else if !admitted {
393 member.state = if member.role == "lead" {
394 "planning".to_string()
395 } else {
396 "idle".to_string()
397 };
398 } else if member.role == "worker" {
399 worker_slot += 1;
400 member.state = if worker_slot <= budget {
401 "in_flight".to_string()
402 } else {
403 "idle".to_string()
404 };
405 } else {
406 member.state = "planning".to_string();
407 }
408 }
409 }
410
411 fn slices_from_direction(direction: &str) -> Option<OperateLeadPlan> {
412 let items = direction_items(direction);
413 if items.is_empty() {
414 return None;
415 }
416 let mut cursor = 0u32;
417 let slices = items
418 .into_iter()
419 .enumerate()
420 .map(|(index, title)| {
421 let duration_sec = 1800;
422 let slice = OperatePlanSlice {
423 id: format!("slice-{}", index + 1),
424 title,
425 owner_id: if index == 0 {
426 "lead".to_string()
427 } else {
428 format!("worker-{index}")
429 },
430 depends_on: if index == 0 {
431 Vec::new()
432 } else {
433 vec![format!("slice-{index}")]
434 },
435 est_cost_usd: 0.25,
436 start_offset_sec: cursor,
437 duration_sec,
438 };
439 cursor = cursor.saturating_add(duration_sec);
440 slice
441 })
442 .collect();
443 Some(OperateLeadPlan { slices })
444 }
445
446 fn direction_items(direction: &str) -> Vec<String> {
447 let mut items = Vec::new();
448 for raw in direction.lines() {
449 let line = raw.trim();
450 if line.is_empty() {
451 continue;
452 }
453 let item = line
454 .trim_start_matches(|c: char| {
455 c.is_ascii_digit() || c == '.' || c == ')' || c == '-' || c == '*' || c == '#'
456 })
457 .trim();
458 if !item.is_empty() {
459 items.push(item.to_string());
460 }
461 }
462 if items.is_empty() && !direction.trim().is_empty() {
463 items.push(direction.trim().to_string());
464 }
465 items
466 }
467
468 #[must_use]
469 pub fn render_plan_board(op: &Operation) -> String {
470 render_plan_board_locale(op, codewhale_localization::Locale::En)
471 }
472
473 /// Plan board with locale-aware chrome. Contract tokens (status / pace enum
474 /// values, slice ids, owner ids) stay verbatim; the surrounding prose comes
475 /// from the TUI locale packs.
476 #[must_use]
477 pub fn render_plan_board_locale(op: &Operation, locale: codewhale_localization::Locale) -> String {
478 use codewhale_localization::{MessageId, tr};
479 let tr_line = |id: MessageId| tr(locale, id).into_owned();
480
481 let mut out = String::new();
482 out.push_str(
483 &tr_line(MessageId::OperateBoardHeader)
484 .replace("{id}", &op.id)
485 .replace("{status}", &status_label(op))
486 .replace("{pace}", pace_label(op.pace))
487 .replace("{writers}", &op.writers_in_flight.to_string()),
488 );
489 out.push('\n');
490 match &op.burn_rate {
491 Some(rate) => out.push_str(
492 &tr_line(MessageId::OperateBoardBurnObserved)
493 .replace(
494 "{actual}",
495 &format!("{}", op.observed_burn_usd_per_hour.unwrap_or(0.0)),
496 )
497 .replace("{target}", &format!("{}", rate.amount_usd_per_hour)),
498 ),
499 None => out.push_str(&tr_line(MessageId::OperateBoardBurnNoCap)),
500 }
501 out.push('\n');
502 if op.direction.is_empty() {
503 out.push_str(&tr_line(MessageId::OperateBoardDirectionEmpty));
504 } else {
505 out.push_str(
506 &tr_line(MessageId::OperateBoardDirectionLine)
507 .replace("{line}", op.direction.lines().next().unwrap_or("")),
508 );
509 }
510 out.push('\n');
511 let Some(plan) = &op.lead_plan else {
512 out.push_str(&tr_line(MessageId::OperateBoardPlanMissing));
513 out.push('\n');
514 return out;
515 };
516 out.push_str(&tr_line(MessageId::OperateBoardPlanHeader));
517 out.push('\n');
518 for slice in &plan.slices {
519 out.push_str(&format!(
520 " {:<9} {:<8} {:>5} {:>5} {:>6.2} {:<7} {}\n",
521 slice.id,
522 slice.owner_id,
523 slice.start_offset_sec,
524 slice.duration_sec,
525 slice.est_cost_usd,
526 if slice.depends_on.is_empty() {
527 "-".to_string()
528 } else {
529 slice.depends_on.join(",")
530 },
531 slice.title
532 ));
533 }
534 out.push_str(&render_timeline(&plan.slices, locale));
535 out
536 }
537
538 fn render_timeline(slices: &[OperatePlanSlice], locale: codewhale_localization::Locale) -> String {
539 use codewhale_localization::{MessageId, tr};
540 let max_end = slices
541 .iter()
542 .map(|slice| slice.start_offset_sec.saturating_add(slice.duration_sec))
543 .max()
544 .unwrap_or(0)
545 .max(1);
546 let width = 24u32;
547 let mut out = format!("{}\n", tr(locale, MessageId::OperateBoardGantt));
548 for slice in slices {
549 let start = (slice.start_offset_sec.saturating_mul(width)) / max_end;
550 let end = (slice
551 .start_offset_sec
552 .saturating_add(slice.duration_sec)
553 .saturating_mul(width))
554 / max_end;
555 let end = end.max(start.saturating_add(1)).min(width);
556 let mut bar = vec!['.'; width as usize];
557 for idx in start..end {
558 if let Some(cell) = bar.get_mut(idx as usize) {
559 *cell = '#';
560 }
561 }
562 out.push_str(&format!(
563 " {:<9} {}\n",
564 slice.id,
565 bar.into_iter().collect::<String>()
566 ));
567 }
568 out
569 }
570
571 fn status_label(op: &Operation) -> String {
572 match (op.status, op.idle_blocked_reason) {
573 (OperateStatus::Cancelled, _) => "cancelled".to_string(),
574 (OperateStatus::Running, _) => "running".to_string(),
575 (OperateStatus::Planning, _) => "planning".to_string(),
576 (_, Some(OperateIdleReason::DirectionEmpty)) => "idle_blocked: direction_empty".to_string(),
577 (_, Some(OperateIdleReason::AwaitingLeadPlan)) => {
578 "idle_blocked: awaiting_lead_plan".to_string()
579 }
580 (_, Some(OperateIdleReason::MissingCredentials)) => {
581 "idle_blocked: missing_credentials".to_string()
582 }
583 (_, Some(OperateIdleReason::HumanGated)) => {
584 format!("idle_blocked: human_gated {}", op.human_gate)
585 }
586 (OperateStatus::IdleBlocked, None) => "idle_blocked".to_string(),
587 }
588 }
589
590 fn pace_label(pace: OperatePace) -> &'static str {
591 match pace {
592 OperatePace::Unbounded => "unbounded",
593 OperatePace::Hold => "hold",
594 OperatePace::Throttle => "throttle",
595 OperatePace::Widen => "widen",
596 }
597 }
598
599 /// Honor an explicit operator-provided path (env override) only when it is a
600 /// non-empty value without NUL bytes or `..` traversal segments that actually
601 /// names one regular file. Keeps env-provided values out of raw path
602 /// expressions (CodeQL "uncontrolled data in path" class).
603 fn explicit_file_path(raw: &str) -> Option<PathBuf> {
604 let trimmed = raw.trim();
605 if trimmed.is_empty() || trimmed.contains('\0') {
606 return None;
607 }
608 let path = PathBuf::from(trimmed);
609 if path
610 .components()
611 .any(|component| matches!(component, Component::ParentDir))
612 {
613 return None;
614 }
615 path.is_file().then_some(path)
616 }
617
618 /// Same hardening for an explicit directory (env override): no NUL bytes, no
619 /// `..` traversal segments. Existence is probed by the caller.
620 fn explicit_dir_path(raw: &str) -> Option<PathBuf> {
621 let trimmed = raw.trim();
622 if trimmed.is_empty() || trimmed.contains('\0') {
623 return None;
624 }
625 let path = PathBuf::from(trimmed);
626 if path
627 .components()
628 .any(|component| matches!(component, Component::ParentDir))
629 {
630 return None;
631 }
632 Some(path)
633 }
634
635 #[must_use]
636 pub fn discover_direction_path(workspace: &Path) -> Option<PathBuf> {
637 if let Ok(explicit) = std::env::var(DIRECTION_PATH_ENV)
638 && let Some(path) = explicit_file_path(&explicit)
639 {
640 return Some(path);
641 }
642 let local = workspace.join("DIRECTION.md");
643 if local.is_file() {
644 return Some(local);
645 }
646 materialize_ops_origin_main()
647 .ok()
648 .map(|root| root.join("DIRECTION.md"))
649 .filter(|path| path.is_file())
650 }
651
652 pub fn read_direction(workspace: &Path) -> Result<String> {
653 match discover_direction_path(workspace) {
654 Some(path) => fs::read_to_string(&path)
655 .with_context(|| format!("Failed to read direction {}", path.display())),
656 None => Ok(String::new()),
657 }
658 }
659
660 /// Resolve the saved keepalive pin, or the caller's effective session route
661 /// for a fresh/unpinned record. Auto stays a policy until Runtime admits a turn;
662 /// resolving its inventory here could run a paid classifier.
663 fn keepalive_route(
664 config: &crate::config::Config,
665 current: Option<&AutomationRecord>,
666 selection: Option<(&crate::config::ProviderIdentity, &str)>,
667 ) -> Result<(crate::config::ProviderIdentity, String, bool)> {
668 let (identity, model) = if let Some(record) = current
669 .filter(|record| record.model_provider.is_some() || record.model_provider_id.is_some())
670 {
671 let identity = config
672 .resolve_persisted_provider_identity(
673 record.model_provider.as_deref(),
674 record.model_provider_id.as_deref(),
675 )
676 .map_err(anyhow::Error::msg)?;
677 let model = record
678 .model
679 .as_deref()
680 .map(str::trim)
681 .filter(|model| !model.is_empty())
682 .context("Pinned Operate keepalive has no model; repair its saved route")?;
683 (identity, model.to_string())
684 } else if let Some((identity, model)) = selection {
685 let identity = config
686 .resolve_persisted_provider_identity(
687 Some(identity.provider.as_str()),
688 identity.persisted_id(),
689 )
690 .map_err(anyhow::Error::msg)?;
691 (identity, model.to_string())
692 } else {
693 (
694 config
695 .active_provider_identity(config.api_provider())
696 .map_err(anyhow::Error::msg)?,
697 config.default_model(),
698 )
699 };
700 if model.trim().eq_ignore_ascii_case("auto") {
701 let mut scoped = config.clone();
702 scoped.scope_to_provider_identity(&identity);
703 let credentials = crate::config::has_api_key_for(&scoped, identity.provider);
704 return Ok((identity, "auto".to_string(), credentials));
705 }
706 let route =
707 crate::route_runtime::resolve_runtime_route_for_identity(config, &identity, Some(&model))
708 .map_err(anyhow::Error::msg)?;
709 let credentials = crate::config::has_api_key_for(&route.config, route.identity.provider);
710 Ok((route.identity, route.model, credentials))
711 }
712
713 /// Inspect the same saved route used by the scheduler. Credential presence is
714 /// a local readiness observation, not proof of provider execution.
715 pub(crate) fn keepalive_readiness(
716 manager: &AutomationManager,
717 config: &crate::config::Config,
718 selection: Option<(&crate::config::ProviderIdentity, &str)>,
719 ) -> Result<(String, bool)> {
720 let mut readiness = (String::new(), false);
721 manager.edit_automation(OPERATE_KEEPALIVE_ID, |current| {
722 if current
723 .as_ref()
724 .and_then(|record| record.execution_scope.as_deref())
725 .is_some_and(|scope| Some(scope) != manager.execution_scope())
726 {
727 bail!("Operate keepalive belongs to another Runtime execution scope");
728 }
729 let (_, model, credentials) = keepalive_route(config, current.as_ref(), selection)?;
730 readiness = (model, credentials);
731 Ok(None)
732 })?;
733 Ok(readiness)
734 }
735
736 #[derive(Debug, Clone, PartialEq, Eq)]
737 pub struct AutoMergeRequest<'a> {
738 pub pr: &'a str,
739 pub role: &'a str,
740 pub repo: &'a str,
741 }
742
743 #[derive(Debug, Clone, PartialEq, Eq)]
744 pub enum AutoMergeDecision {
745 Allow,
746 Deny { reason: String },
747 }
748
749 #[must_use]
750 pub fn check_auto_merge_args(repo: &str, pr: &str, agent: &str) -> Vec<String> {
751 vec![
752 CHECK_AUTO_MERGE_SCRIPT.to_string(),
753 "--repo".to_string(),
754 repo.to_string(),
755 "--pr".to_string(),
756 pr.to_string(),
757 "--agent".to_string(),
758 agent.to_string(),
759 ]
760 }
761
762 #[must_use]
763 pub fn auto_merge_pr_args(repo: &str, pr: &str, agent: &str) -> Vec<String> {
764 vec![
765 AUTO_MERGE_PR_SCRIPT.to_string(),
766 "--repo".to_string(),
767 repo.to_string(),
768 "--pr".to_string(),
769 pr.to_string(),
770 "--agent".to_string(),
771 agent.to_string(),
772 ]
773 }
774
775 pub fn evaluate_auto_merge(
776 request: AutoMergeRequest<'_>,
777 checker: Option<&Path>,
778 ) -> AutoMergeDecision {
779 let Some(checker) = checker else {
780 return AutoMergeDecision::Deny {
781 reason: "auto-merge checker missing; fail-closed".to_string(),
782 };
783 };
784 if !checker.exists() {
785 return AutoMergeDecision::Deny {
786 reason: "auto-merge checker missing; fail-closed".to_string(),
787 };
788 }
789 match Command::new("python3")
790 .arg(checker)
791 .arg("--repo")
792 .arg(request.repo)
793 .arg("--pr")
794 .arg(request.pr)
795 .arg("--agent")
796 .arg(request.role)
797 .stdin(Stdio::null())
798 .stdout(Stdio::null())
799 .stderr(Stdio::null())
800 .status()
801 {
802 Ok(status) if status.success() => AutoMergeDecision::Allow,
803 Ok(_) => AutoMergeDecision::Deny {
804 reason: "auto-merge checker refused".to_string(),
805 },
806 Err(error) => AutoMergeDecision::Deny {
807 reason: format!("auto-merge checker failed to start: {error}"),
808 },
809 }
810 }
811
812 #[must_use]
813 pub fn discover_auto_merge_checker(_workspace: &Path) -> Option<PathBuf> {
814 if let Ok(explicit) = std::env::var(AUTO_MERGE_CHECKER_ENV)
815 && let Some(path) = explicit_file_path(&explicit)
816 {
817 return Some(path);
818 }
819 materialize_ops_origin_main()
820 .ok()
821 .map(|root| root.join(CHECK_AUTO_MERGE_SCRIPT))
822 .filter(|path| path.is_file())
823 }
824
825 fn ops_git_candidates() -> Vec<PathBuf> {
826 let mut out = Vec::new();
827 for key in ["CODEWHALE_OPS_GIT", "CODEWHALE_OPS_ROOT"] {
828 if let Ok(path) = std::env::var(key)
829 && let Some(path) = explicit_dir_path(&path)
830 {
831 out.push(path);
832 }
833 }
834 out
835 }
836
837 fn git_origin_main_sha(repo: &Path) -> Option<String> {
838 let output = Command::new("git")
839 .arg("-C")
840 .arg(repo)
841 .args(["rev-parse", "origin/main"])
842 .stdin(Stdio::null())
843 .output()
844 .ok()?;
845 if !output.status.success() {
846 return None;
847 }
848 let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
849 // The sha becomes a path segment below; only plain hex of a plausible
850 // length may flow into it.
851 if !(7..=64).contains(&sha.len()) || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
852 return None;
853 }
854 Some(sha)
855 }
856
857 pub fn materialize_ops_origin_main() -> Result<PathBuf> {
858 let repo = ops_git_candidates()
859 .into_iter()
860 .find(|path| git_origin_main_sha(path).is_some())
861 .context("no codewhale-ops git checkout with origin/main")?;
862 let sha = git_origin_main_sha(&repo).context("origin/main sha")?;
863 let dest = default_operate_dir()
864 .parent()
865 .unwrap_or(Path::new("."))
866 .join("ops-main")
867 .join(&sha[..12.min(sha.len())]);
868 let marker = dest.join(CHECK_AUTO_MERGE_SCRIPT);
869 if marker.is_file()
870 && dest.join("DIRECTION.md").is_file()
871 && dest.join(AUTO_MERGE_SCRIPT).is_file()
872 {
873 return Ok(dest);
874 }
875 fs::create_dir_all(&dest).with_context(|| format!("Failed to create {}", dest.display()))?;
876 let archive = Command::new("git")
877 .arg("-C")
878 .arg(&repo)
879 .args([
880 "archive",
881 "origin/main",
882 "--",
883 "DIRECTION.md",
884 CHECK_AUTO_MERGE_SCRIPT,
885 AUTO_MERGE_SCRIPT,
886 AUTO_MERGE_PR_SCRIPT,
887 "agent-workstreams/AUTO_MERGE.toml",
888 ])
889 .stdin(Stdio::null())
890 .output()
891 .context("git archive origin/main")?;
892 if !archive.status.success() {
893 anyhow::bail!(
894 "git archive origin/main failed: {}",
895 String::from_utf8_lossy(&archive.stderr).trim()
896 );
897 }
898 let mut child = Command::new("tar")
899 .arg("-x")
900 .arg("-C")
901 .arg(&dest)
902 .stdin(Stdio::piped())
903 .stdout(Stdio::null())
904 .stderr(Stdio::piped())
905 .spawn()
906 .context("tar extract ops origin/main")?;
907 if let Some(mut stdin) = child.stdin.take() {
908 use std::io::Write;
909 stdin.write_all(&archive.stdout)?;
910 }
911 let status = child.wait()?;
912 if !status.success() {
913 anyhow::bail!("failed to extract ops origin/main archive");
914 }
915 if !marker.is_file() {
916 anyhow::bail!("ops origin/main archive missing {CHECK_AUTO_MERGE_SCRIPT}");
917 }
918 Ok(dest)
919 }
920
921 pub fn default_operate_dir() -> PathBuf {
922 if let Ok(path) = std::env::var("CODEWHALE_OPERATE_DIR") {
923 let trimmed = path.trim();
924 if !trimmed.is_empty() {
925 return PathBuf::from(trimmed);
926 }
927 }
928 crate::automation_manager::default_automations_dir()
929 .parent()
930 .map(|parent| parent.join("operate"))
931 .unwrap_or_else(|| PathBuf::from("operate"))
932 }
933
934 pub struct OperationStore {
935 path: PathBuf,
936 lock_path: PathBuf,
937 }
938
939 impl OperationStore {
940 pub fn open(dir: impl Into<PathBuf>) -> Result<Self> {
941 let dir = dir.into();
942 fs::create_dir_all(&dir).with_context(|| format!("Failed to create {}", dir.display()))?;
943 let path = dir.join("current.json");
944 let lock_path = dir.join("current.json.lock");
945 Ok(Self { path, lock_path })
946 }
947
948 pub fn load(&self) -> Result<Option<Operation>> {
949 // Match the skill-state discipline: pure readers take the shared
950 // cross-process read lock only when one exists, so a read never
951 // fabricates a lock file.
952 if self.lock_path.exists() {
953 let file = fs::File::open(&self.lock_path)
954 .with_context(|| format!("Failed to open {}", self.lock_path.display()))?;
955 let lock = fd_lock::RwLock::new(file);
956 let _guard = lock
957 .read()
958 .with_context(|| format!("read-lock {}", self.path.display()))?;
959 return self.load_unlocked();
960 }
961 self.load_unlocked()
962 }
963
964 fn load_unlocked(&self) -> Result<Option<Operation>> {
965 if !self.path.exists() {
966 return Ok(None);
967 }
968 let raw = fs::read_to_string(&self.path)
969 .with_context(|| format!("Failed to read {}", self.path.display()))?;
970 let op: Operation = serde_json::from_str(&raw)
971 .with_context(|| format!("Failed to parse {}", self.path.display()))?;
972 Ok(Some(op))
973 }
974
975 /// Save under the cross-process writer lock with an atomic temp+rename
976 /// write, so concurrent Codewhale processes never interleave partial
977 /// records.
978 pub fn save(&self, op: &Operation) -> Result<()> {
979 let file = self.open_lock_file()?;
980 let mut lock = fd_lock::RwLock::new(file);
981 let _guard = lock
982 .write()
983 .with_context(|| format!("write-lock {}", self.path.display()))?;
984 self.save_unlocked(op)
985 }
986
987 /// Read-merge-write under the cross-process writer lock: the latest
988 /// on-disk record is reloaded *inside* the lock before `edit` runs, so a
989 /// concurrent PATCH/keepalive/plan save can no longer be silently lost by
990 /// a stale read. Returns `None` when no operation is recorded yet.
991 pub fn mutate(
992 &self,
993 edit: impl FnOnce(&mut Operation) -> Result<()>,
994 ) -> Result<Option<Operation>> {
995 let file = self.open_lock_file()?;
996 let mut lock = fd_lock::RwLock::new(file);
997 let _guard = lock
998 .write()
999 .with_context(|| format!("write-lock {}", self.path.display()))?;
1000 let Some(mut op) = self.load_unlocked()? else {
1001 return Ok(None);
1002 };
1003 edit(&mut op)?;
1004 self.save_unlocked(&op)?;
1005 Ok(Some(op))
1006 }
1007
1008 fn open_lock_file(&self) -> Result<fs::File> {
1009 fs::OpenOptions::new()
1010 .read(true)
1011 .write(true)
1012 .create(true)
1013 .truncate(false)
1014 .open(&self.lock_path)
1015 .with_context(|| format!("Failed to open {}", self.lock_path.display()))
1016 }
1017
1018 fn save_unlocked(&self, op: &Operation) -> Result<()> {
1019 codewhale_config::persistence::atomic_write_json(&self.path, op)
1020 .with_context(|| format!("Failed to write {}", self.path.display()))
1021 }
1022 }
1023
1024 pub fn start_operation(
1025 store: &OperationStore,
1026 workspace: &Path,
1027 direction: Option<String>,
1028 burn_usd_per_hour: Option<f64>,
1029 credentials_present: bool,
1030 lead_model: &str,
1031 ) -> Result<Operation> {
1032 let mut direction = match direction {
1033 Some(text) if !text.trim().is_empty() => text,
1034 _ => read_direction(workspace)?,
1035 };
1036 if direction.trim().is_empty()
1037 && let Some(existing) = store.load()?
1038 {
1039 direction = existing.direction;
1040 }
1041 let mut op = Operation::new(direction, burn_usd_per_hour);
1042 op.set_lead_model(lead_model);
1043 op.credentials_present = credentials_present;
1044 op.project();
1045 store.save(&op)?;
1046 Ok(op)
1047 }
1048
1049 /// Re-entering Operate attaches to the recorded operation — same id, spend,
1050 /// lead plan, and roster — instead of minting a fresh record that silently
1051 /// resets progress. A cancelled record (or an empty store) starts a new
1052 /// operation. An explicitly provided direction is applied through the normal
1053 /// patch rules (which invalidate a superseded lead plan).
1054 pub fn attach_or_start_operation(
1055 store: &OperationStore,
1056 workspace: &Path,
1057 direction: Option<String>,
1058 burn_usd_per_hour: Option<f64>,
1059 credentials_present: bool,
1060 lead_model: &str,
1061 ) -> Result<Operation> {
1062 if store
1063 .load()?
1064 .is_some_and(|op| op.status != OperateStatus::Cancelled)
1065 {
1066 let attached = store.mutate(|op| {
1067 if let Some(text) = direction.as_ref().filter(|text| !text.trim().is_empty()) {
1068 apply_operate_patch(op, &serde_json::json!({ "direction": text }))?;
1069 }
1070 op.set_lead_model(lead_model);
1071 op.credentials_present = credentials_present;
1072 op.project();
1073 Ok(())
1074 })?;
1075 if let Some(op) = attached {
1076 return Ok(op);
1077 }
1078 }
1079 start_operation(
1080 store,
1081 workspace,
1082 direction,
1083 burn_usd_per_hour,
1084 credentials_present,
1085 lead_model,
1086 )
1087 }
1088
1089 pub fn apply_operate_patch(op: &mut Operation, patch: &serde_json::Value) -> Result<()> {
1090 if op.status == OperateStatus::Cancelled {
1091 anyhow::bail!("A cancelled Operation cannot be edited.");
1092 }
1093 if let Some(direction) = patch.get("direction") {
1094 let next = normalize_direction(direction.as_str().map(str::to_string).unwrap_or_default());
1095 if patch.get("leadPlan").is_none() && next != op.direction {
1096 // A changed direction supersedes the recorded lead plan: workers
1097 // must stop executing slices derived from the old direction. The
1098 // operation idles `awaiting_lead_plan` until the lead re-plans
1099 // (same patch may instead carry an explicit replacement plan).
1100 op.lead_plan = None;
1101 }
1102 op.direction = next;
1103 }
1104 if patch.get("burnRate").is_some() {
1105 op.burn_rate = parse_burn_rate(patch.get("burnRate"))?;
1106 }
1107 if let Some(plan) = patch.get("leadPlan") {
1108 op.lead_plan = if plan.is_null() {
1109 None
1110 } else {
1111 Some(serde_json::from_value(plan.clone()).context("leadPlan is invalid")?)
1112 };
1113 // Plan owners are the worker roster: PUT /v1/operate/plan and a
1114 // PATCHed plan must admit their owners or workers never dispatch.
1115 sync_plan_owners(op);
1116 }
1117 if let Some(flag) = patch.get("humanGated").and_then(serde_json::Value::as_bool) {
1118 op.human_gated = flag;
1119 }
1120 if let Some(gate) = patch.get("humanGate").and_then(serde_json::Value::as_str) {
1121 op.human_gate = gate.trim().chars().take(160).collect();
1122 if human_gate_for(&op.human_gate) {
1123 op.human_gated = true;
1124 }
1125 }
1126 if let Some(flag) = patch
1127 .get("credentialsPresent")
1128 .and_then(serde_json::Value::as_bool)
1129 {
1130 op.credentials_present = flag;
1131 }
1132 op.updated_at = Utc::now().to_rfc3339();
1133 op.project();
1134 Ok(())
1135 }
1136
1137 /// Add every non-lead plan owner to the roster (idempotent) so admitted
1138 /// slices have a worker to dispatch to.
1139 fn sync_plan_owners(op: &mut Operation) {
1140 if let Some(plan) = &op.lead_plan {
1141 for slice in &plan.slices {
1142 if slice.owner_id != "lead"
1143 && !slice.owner_id.is_empty()
1144 && !op.roster.iter().any(|member| member.id == slice.owner_id)
1145 {
1146 op.roster.push(OperateRosterMember {
1147 id: slice.owner_id.clone(),
1148 display_name: slice.owner_id.clone(),
1149 role: "worker".to_string(),
1150 model: String::new(),
1151 state: "idle".to_string(),
1152 });
1153 }
1154 }
1155 }
1156 }
1157
1158 pub fn cancel_operation(store: &OperationStore) -> Result<Option<Operation>> {
1159 store.mutate(|op| {
1160 let now = Utc::now().to_rfc3339();
1161 op.status = OperateStatus::Cancelled;
1162 op.cancelled_at = now.clone();
1163 op.updated_at = now.clone();
1164 op.last_keep_alive_at = now;
1165 op.project();
1166 Ok(())
1167 })
1168 }
1169
1170 pub fn keep_alive_observation(
1171 op: &mut Operation,
1172 observed_burn_usd_per_hour: Option<f64>,
1173 spent_usd: Option<f64>,
1174 credentials_present: Option<bool>,
1175 human_gated: Option<bool>,
1176 ) {
1177 let now = Utc::now().to_rfc3339();
1178 op.last_keep_alive_at = now.clone();
1179 op.updated_at = now;
1180 if let Some(burn) = observed_burn_usd_per_hour {
1181 op.observed_burn_usd_per_hour = Some(burn.max(0.0));
1182 }
1183 if let Some(spent) = spent_usd {
1184 op.spent_usd = spent.max(0.0);
1185 }
1186 if let Some(credentials) = credentials_present {
1187 op.credentials_present = credentials;
1188 }
1189 if let Some(gated) = human_gated {
1190 op.human_gated = gated;
1191 }
1192 op.project();
1193 }
1194
1195 /// Install (or refresh) the `cw-operate` keepalive bound to `workspace`.
1196 ///
1197 /// The record is built directly under the fixed id — no create-then-delete
1198 /// id swap that could orphan an active UUID-named automation. Reuse
1199 /// refreshes the prompt and workspace while preserving an explicit saved
1200 /// model/provider pair. Fresh and legacy unpinned records capture the caller's
1201 /// effective route together, including Auto intent and exact custom identity.
1202 ///
1203 /// `kick_now` schedules the first lead-plan step for the next scheduler tick
1204 /// (a fresh operation otherwise idles up to an hour awaiting its plan); the
1205 /// hourly recurrence covers follow-ups.
1206 pub(crate) fn upsert_keepalive(
1207 manager: &AutomationManager,
1208 workspace: &Path,
1209 kick_now: bool,
1210 config: &crate::config::Config,
1211 selection: Option<(&crate::config::ProviderIdentity, &str)>,
1212 ) -> Result<(String, bool)> {
1213 let now = Utc::now();
1214 let prompt = format!(
1215 "Keep Operate alive. Read the Operate record (current.json) and its direction, refresh the lead plan, and dispatch ready slices with at most `writersInFlight` concurrent workers — that budget already encodes pace (hold/throttle/widen), so honor it instead of widening on your own. Burn rate paces spend; it never stops the operation. Workspace: {}",
1216 workspace.display()
1217 );
1218 let mut readiness = (String::new(), false);
1219 manager.edit_automation(OPERATE_KEEPALIVE_ID, |current| {
1220 let scope = manager
1221 .execution_scope()
1222 .context("Operate execution ownership is unverified")?;
1223 if current
1224 .as_ref()
1225 .and_then(|record| record.execution_scope.as_deref())
1226 .is_some_and(|bound| bound != scope)
1227 {
1228 bail!("Operate keepalive belongs to another Runtime execution scope");
1229 }
1230 let (identity, model, ready) = keepalive_route(config, current.as_ref(), selection)?;
1231 readiness = (model.clone(), ready);
1232 let mut record = current.unwrap_or_else(|| AutomationRecord {
1233 schema_version: crate::automation_manager::CURRENT_AUTOMATION_SCHEMA_VERSION,
1234 execution_scope: Some(scope.to_string()),
1235 id: OPERATE_KEEPALIVE_ID.to_string(),
1236 name: "Operate keep-alive".to_string(),
1237 prompt: prompt.clone(),
1238 rrule: OPERATE_KEEPALIVE_RRULE.to_string(),
1239 cwds: Vec::new(),
1240 model: None,
1241 model_provider: None,
1242 model_provider_id: None,
1243 mode: Some("operate".to_string()),
1244 allow_shell: Some(true),
1245 trust_mode: Some(false),
1246 auto_approve: Some(false),
1247 delivery_mode: None,
1248 status: AutomationStatus::Active,
1249 created_at: now,
1250 updated_at: now,
1251 next_run_at: None,
1252 last_run_at: None,
1253 });
1254 record.schema_version = crate::automation_manager::CURRENT_AUTOMATION_SCHEMA_VERSION;
1255 if record.execution_scope.is_none() {
1256 record.execution_scope = Some(scope.to_string());
1257 }
1258 record.name = "Operate keep-alive".to_string();
1259 record.prompt = prompt;
1260 record.rrule = OPERATE_KEEPALIVE_RRULE.to_string();
1261 record.cwds = vec![workspace.to_path_buf()];
1262 record.model = Some(model);
1263 record.model_provider = Some(identity.provider.as_str().to_string());
1264 record.model_provider_id = identity.persisted_id().map(str::to_string);
1265 record.mode = Some("operate".to_string());
1266 record.allow_shell = Some(true);
1267 record.trust_mode = Some(false);
1268 record.auto_approve = Some(false);
1269 record.delivery_mode = None;
1270 record.status = AutomationStatus::Active;
1271 record.updated_at = now;
1272 if kick_now {
1273 record.next_run_at = Some(now);
1274 }
1275 Ok(Some(record))
1276 })?;
1277 Ok(readiness)
1278 }
1279
1280 /// Cancel tears the operation down *including* its keepalive: an unattended
1281 /// hourly lead run after cancel is pure cost. The automation is paused
1282 /// (never deleted) so its run history survives and a later start reactivates
1283 /// it. A missing keepalive is not an error.
1284 pub fn pause_keepalive(manager: &AutomationManager) -> Result<()> {
1285 match manager.get_automation(OPERATE_KEEPALIVE_ID) {
1286 Ok(record) if matches!(record.status, AutomationStatus::Active) => {
1287 manager.pause_automation(OPERATE_KEEPALIVE_ID)?;
1288 Ok(())
1289 }
1290 Ok(_) => Ok(()),
1291 Err(_) => Ok(()),
1292 }
1293 }
1294
1295 /// Pull the next keepalive lead run to the next scheduler tick (for example
1296 /// after a direction PATCH invalidated the plan). No-op when the keepalive is
1297 /// absent or paused (a paused keepalive belongs to a cancelled operation).
1298 pub fn kick_keepalive(manager: &AutomationManager) -> Result<bool> {
1299 let mut kicked = false;
1300 manager.edit_automation(OPERATE_KEEPALIVE_ID, |current| {
1301 let Some(mut record) = current else {
1302 return Ok(None);
1303 };
1304 if record.status == AutomationStatus::Active {
1305 let now = Utc::now();
1306 record.next_run_at = Some(now);
1307 record.updated_at = now;
1308 kicked = true;
1309 }
1310 Ok(Some(record))
1311 })?;
1312 Ok(kicked)
1313 }
1314
1315 #[must_use]
1316 pub fn human_gate_for(action: &str) -> bool {
1317 matches!(
1318 action,
1319 "deploy" | "billing" | "force-push" | "forbidden-pr" | "red-ci"
1320 )
1321 }
1322
1323 #[cfg(test)]
1324 mod tests {
1325 use super::*;
1326 use tempfile::TempDir;
1327
1328 fn route_fixture_config() -> crate::config::Config {
1329 toml::from_str(
1330 r#"
1331 provider = "route-a"
1332 [providers.route-a]
1333 kind = "openai-compatible"
1334 base_url = "https://route-a.example.test/v1"
1335 model = "same-model"
1336 auth_mode = "none"
1337 [providers.route-b]
1338 kind = "openai-compatible"
1339 base_url = "https://route-b.example.test/v1"
1340 model = "same-model"
1341 auth_mode = "api-key"
1342 api_key_env = "CW_OPERATE_MISSING_TEST_KEY"
1343 "#,
1344 )
1345 .expect("route fixture")
1346 }
1347
1348 #[test]
1349 fn keepalive_saved_route_controls_readiness_refresh_and_auto_intent() -> Result<()> {
1350 let _env = crate::test_support::lock_test_env();
1351 let _cli = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
1352 let _missing = crate::test_support::EnvVarGuard::remove("CW_OPERATE_MISSING_TEST_KEY");
1353 let root = TempDir::new()?;
1354 let manager = AutomationManager::open_for_test(root.path().join("automations"))?;
1355 let mut config = route_fixture_config();
1356 assert!(upsert_keepalive(&manager, root.path(), true, &config, None)?.1);
1357 let first = manager.get_automation(OPERATE_KEEPALIVE_ID)?;
1358 assert_eq!(first.model.as_deref(), Some("same-model"));
1359 assert_eq!(first.model_provider.as_deref(), Some("custom"));
1360 assert_eq!(first.model_provider_id.as_deref(), Some("route-a"));
1361 assert_eq!(first.auto_approve, Some(false));
1362
1363 // Explicitly edit the saved route. Parent route-a is credential-ready;
1364 // route-b is not, even though both expose the same model spelling.
1365 let mut pinned = first.clone();
1366 pinned.model_provider_id = Some("route-b".into());
1367 manager.save_automation(&pinned)?;
1368 assert!(!keepalive_readiness(&manager, &config, None)?.1);
1369 assert!(!upsert_keepalive(&manager, root.path(), false, &config, None)?.1);
1370 assert_eq!(
1371 manager
1372 .get_automation(OPERATE_KEEPALIVE_ID)?
1373 .model_provider_id,
1374 Some("route-b".into())
1375 );
1376
1377 pinned.model = Some("auto".into());
1378 manager.save_automation(&pinned)?;
1379 assert!(!upsert_keepalive(&manager, root.path(), false, &config, None)?.1);
1380 let auto = manager.get_automation(OPERATE_KEEPALIVE_ID)?;
1381 assert_eq!(auto.model.as_deref(), Some("auto"));
1382 assert_eq!(auto.model_provider_id.as_deref(), Some("route-b"));
1383 assert_eq!(auto.created_at, first.created_at);
1384
1385 config.providers.as_mut().unwrap().custom.remove("route-b");
1386 let before = serde_json::to_value(&auto)?;
1387 assert!(keepalive_readiness(&manager, &config, None).is_err());
1388 assert!(upsert_keepalive(&manager, root.path(), true, &config, None).is_err());
1389 assert_eq!(
1390 serde_json::to_value(manager.get_automation(OPERATE_KEEPALIVE_ID)?)?,
1391 before
1392 );
1393
1394 // Legacy unpinned GLM is a previous scheduler default, not an exact
1395 // provider choice. Migration replaces all route fields together.
1396 pinned.model = Some("GLM-5.3".into());
1397 pinned.model_provider = None;
1398 pinned.model_provider_id = None;
1399 manager.save_automation(&pinned)?;
1400 assert!(upsert_keepalive(&manager, root.path(), true, &config, None)?.1);
1401 let migrated = manager.get_automation(OPERATE_KEEPALIVE_ID)?;
1402 assert_eq!(migrated.model.as_deref(), Some("same-model"));
1403 assert_eq!(migrated.model_provider.as_deref(), Some("custom"));
1404 assert_eq!(migrated.model_provider_id.as_deref(), Some("route-a"));
1405 Ok(())
1406 }
1407
1408 #[test]
1409 fn keepalive_legacy_custom_keeps_absent_exact_id() -> Result<()> {
1410 let _env = crate::test_support::lock_test_env();
1411 let _cli = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
1412 let root = TempDir::new()?;
1413 let manager = AutomationManager::open_for_test(root.path().join("automations"))?;
1414 let config = crate::config::Config {
1415 provider: Some("custom".into()),
1416 base_url: Some("https://legacy.example.test/v1".into()),
1417 default_text_model: Some("legacy-model".into()),
1418 ..Default::default()
1419 };
1420 upsert_keepalive(&manager, root.path(), false, &config, None)?;
1421 let record = manager.get_automation(OPERATE_KEEPALIVE_ID)?;
1422 assert_eq!(record.model.as_deref(), Some("legacy-model"));
1423 assert_eq!(record.model_provider.as_deref(), Some("custom"));
1424 assert_eq!(record.model_provider_id, None);
1425 upsert_keepalive(&manager, root.path(), false, &config, None)?;
1426 assert_eq!(
1427 manager
1428 .get_automation(OPERATE_KEEPALIVE_ID)?
1429 .model_provider_id,
1430 None
1431 );
1432 Ok(())
1433 }
1434
1435 struct RouteRecordingExecutor(
1436 std::sync::Arc<std::sync::Mutex<Vec<crate::runtime_threads::CreateThreadRequest>>>,
1437 );
1438
1439 #[async_trait::async_trait]
1440 impl crate::task_manager::TaskExecutor for RouteRecordingExecutor {
1441 async fn execute(
1442 &self,
1443 task: crate::task_manager::ExecutionTask,
1444 _events: tokio::sync::mpsc::Sender<crate::task_manager::TaskExecutionEvent>,
1445 _cancel: tokio_util::sync::CancellationToken,
1446 ) -> crate::task_manager::TaskExecutionResult {
1447 self.0.lock().unwrap().push(task.thread_request());
1448 crate::task_manager::TaskExecutionResult {
1449 status: crate::task_manager::TaskStatus::Completed,
1450 result_text: Some("route fixture completed".into()),
1451 error: None,
1452 terminal_reason: crate::task_manager::TaskTerminalReason::Completed,
1453 }
1454 }
1455 }
1456
1457 #[tokio::test]
1458 async fn keepalive_pin_reaches_task_and_survives_parent_change() -> Result<()> {
1459 let root = TempDir::new()?;
1460 let manager = AutomationManager::open_for_test(root.path().join("automations"))?;
1461 let mut config = route_fixture_config();
1462 upsert_keepalive(&manager, root.path(), false, &config, None)?;
1463 pause_keepalive(&manager)?;
1464 config.provider = Some("route-b".into());
1465 assert!(upsert_keepalive(&manager, root.path(), true, &config, None)?.1);
1466 let observations = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1467 let tasks = crate::task_manager::TaskManager::start_with_executor(
1468 crate::task_manager::TaskManagerConfig {
1469 data_dir: root.path().join("tasks"),
1470 worker_count: 1,
1471 default_workspace: root.path().to_path_buf(),
1472 default_model: "changed-default".into(),
1473 default_mode: "agent".into(),
1474 allow_shell: false,
1475 trust_mode: false,
1476 execution_limits: Default::default(),
1477 },
1478 std::sync::Arc::new(RouteRecordingExecutor(observations.clone())),
1479 )
1480 .await?;
1481 let shared = std::sync::Arc::new(tokio::sync::Mutex::new(manager));
1482 let run = crate::automation_manager::run_now_shared(&shared, OPERATE_KEEPALIVE_ID, &tasks)
1483 .await?;
1484 let id = run.task_id.as_deref().context("bound task")?;
1485 let task = crate::task_manager::wait_for_terminal_state(
1486 &tasks,
1487 id,
1488 std::time::Duration::from_secs(5),
1489 )
1490 .await?;
1491 assert_eq!(task.status, crate::task_manager::TaskStatus::Completed);
1492 assert_eq!(task.model, "same-model");
1493 assert_eq!(task.model_provider.as_deref(), Some("custom"));
1494 assert_eq!(task.model_provider_id.as_deref(), Some("route-a"));
1495 let observed = observations.lock().unwrap();
1496 assert_eq!(observed.len(), 1);
1497 assert_eq!(observed[0].model, Some(task.model.clone()));
1498 assert_eq!(observed[0].model_provider_id, task.model_provider_id);
1499 assert_eq!(observed[0].auto_approve, Some(false));
1500 drop(observed);
1501 let reopened = AutomationManager::open_for_test(root.path().join("automations"))?;
1502 assert!(keepalive_readiness(&reopened, &config, None)?.1);
1503 assert_eq!(
1504 reopened.list_runs(OPERATE_KEEPALIVE_ID, None)?[0]
1505 .task_id
1506 .as_deref(),
1507 Some(id)
1508 );
1509 // A subsequent explicit edit cannot rewrite an accepted task binding.
1510 let mut edited = reopened.get_automation(OPERATE_KEEPALIVE_ID)?;
1511 edited.model = Some("replacement-model".into());
1512 edited.model_provider_id = Some("route-b".into());
1513 reopened.save_automation(&edited)?;
1514 assert_eq!(
1515 tasks
1516 .read_bound_task(id)?
1517 .context("accepted task")?
1518 .model_provider_id,
1519 Some("route-a".into())
1520 );
1521 tasks.shutdown();
1522 Ok(())
1523 }
1524
1525 fn with_credentials(mut op: Operation) -> Operation {
1526 op.credentials_present = true;
1527 op.project();
1528 op
1529 }
1530
1531 #[test]
1532 fn unbounded_start_matches_cwc_contract() {
1533 let op = with_credentials(Operation::new("Keep shipping honest slices", None));
1534 assert_eq!(op.schema_version, 1);
1535 assert!(op.burn_rate.is_none());
1536 assert_eq!(op.pace, OperatePace::Unbounded);
1537 assert_eq!(op.status, OperateStatus::IdleBlocked);
1538 assert_eq!(
1539 op.idle_blocked_reason,
1540 Some(OperateIdleReason::AwaitingLeadPlan)
1541 );
1542 assert!(!op.workers_admitted);
1543 assert!(op.lead_operator.model.is_empty(), "no model was assigned");
1544 let json = serde_json::to_value(&op).expect("json");
1545 assert!(json.get("burnRate").unwrap().is_null());
1546 assert_eq!(json["leadOperator"]["model"], "");
1547 assert_eq!(json["schemaVersion"], 1);
1548 assert!(json.get("leadPlan").unwrap().is_null());
1549 assert_eq!(json["idleBlockedReason"], "awaiting_lead_plan");
1550 assert_eq!(json["workersAdmitted"], false);
1551 assert!(json["id"].as_str().unwrap().starts_with("op_"));
1552 }
1553
1554 #[test]
1555 fn operation_lead_label_follows_selection_and_planned_workers_stay_unassigned() -> Result<()> {
1556 let root = TempDir::new()?;
1557 let store = OperationStore::open(root.path())?;
1558 let operation = start_operation(
1559 &store,
1560 root.path(),
1561 Some("First bounded task\nSecond bounded task".into()),
1562 None,
1563 true,
1564 "selected-model",
1565 )?;
1566 assert_eq!(operation.lead_operator.model, "selected-model");
1567 let mut attached =
1568 attach_or_start_operation(&store, root.path(), None, None, true, "auto")?;
1569 assert_eq!(attached.id, operation.id);
1570 assert_eq!(attached.lead_operator.model, "auto");
1571 assert!(
1572 attached
1573 .roster
1574 .iter()
1575 .filter(|member| member.id == "lead")
1576 .all(|member| member.model == "auto")
1577 );
1578 attached.plan_from_direction();
1579 assert!(attached.roster.iter().any(|member| member.id != "lead"));
1580 assert!(
1581 attached
1582 .roster
1583 .iter()
1584 .filter(|member| member.id != "lead")
1585 .all(|member| member.model.is_empty())
1586 );
1587 assert!(
1588 store
1589 .load()?
1590 .context("saved operation")?
1591 .roster
1592 .iter()
1593 .filter(|member| member.id == "lead")
1594 .all(|member| member.model == "auto")
1595 );
1596 Ok(())
1597 }
1598
1599 #[test]
1600 fn create_without_credentials_fails_closed() {
1601 let op = Operation::new("Keep shipping honest slices", None);
1602 assert_eq!(op.status, OperateStatus::IdleBlocked);
1603 assert_eq!(
1604 op.idle_blocked_reason,
1605 Some(OperateIdleReason::MissingCredentials)
1606 );
1607 assert!(!op.workers_admitted);
1608 }
1609
1610 #[test]
1611 fn burn_rate_paces_and_never_stops() {
1612 let mut op = with_credentials(Operation::new(
1613 "Hold a $12/hr burn\nSecond slice\nThird slice",
1614 Some(12.0),
1615 ));
1616 op.plan_from_direction();
1617 assert_eq!(op.status, OperateStatus::Running);
1618 assert!(op.workers_admitted);
1619 keep_alive_observation(&mut op, Some(20.0), Some(80.0), None, None);
1620 assert_eq!(op.status, OperateStatus::Running);
1621 assert_eq!(op.pace, OperatePace::Throttle);
1622 // Throttle is a real dispatch cut, not a label: of the two planned
1623 // workers only one stays in flight while the operation keeps running.
1624 assert_eq!(op.writers_in_flight, OPERATE_THROTTLE_WRITERS);
1625 assert_eq!(
1626 op.roster
1627 .iter()
1628 .filter(|member| member.role == "worker" && member.state == "in_flight")
1629 .count(),
1630 OPERATE_THROTTLE_WRITERS
1631 );
1632 assert_eq!(
1633 op.roster
1634 .iter()
1635 .filter(|member| member.role == "worker" && member.state == "idle")
1636 .count(),
1637 1,
1638 "the worker past the throttle budget idles"
1639 );
1640 assert!(op.idle_blocked_reason.is_none());
1641 assert!(op.workers_admitted);
1642 let board = render_plan_board(&op);
1643 assert!(!board.contains("exhausted"));
1644 assert!(!board.contains("wallet"));
1645 assert_eq!(op.burn_rate.as_ref().unwrap().kind, "usd_per_hour");
1646 assert!((op.burn_rate.as_ref().unwrap().amount_usd_per_hour - 12.0).abs() < f64::EPSILON);
1647 }
1648
1649 #[test]
1650 fn hold_band_freezes_writer_width() {
1651 let mut op = with_credentials(Operation::new("one\ntwo\nthree", Some(12.0)));
1652 op.plan_from_direction();
1653 keep_alive_observation(&mut op, Some(12.0), None, None, None);
1654 assert_eq!(op.pace, OperatePace::Hold);
1655 assert_eq!(op.status, OperateStatus::Running);
1656 // Hold admits no new writers past the hold width; this plan has two
1657 // workers, so both stay in flight but the budget stops at two.
1658 assert_eq!(op.writers_in_flight, 2);
1659 let mut four = with_credentials(Operation::new("one\ntwo\nthree\nfour\nfive", Some(12.0)));
1660 four.plan_from_direction();
1661 keep_alive_observation(&mut four, Some(12.3), None, None, None);
1662 assert_eq!(four.pace, OperatePace::Hold);
1663 assert_eq!(
1664 four.writers_in_flight, OPERATE_HOLD_WRITERS,
1665 "hold never widens to the full writer width"
1666 );
1667 }
1668
1669 #[test]
1670 fn under_rate_widens() {
1671 let mut op = with_credentials(Operation::new("one\ntwo\nthree\nfour", Some(12.0)));
1672 op.plan_from_direction();
1673 keep_alive_observation(&mut op, Some(1.0), None, None, None);
1674 assert_eq!(op.pace, OperatePace::Widen);
1675 assert_eq!(op.status, OperateStatus::Running);
1676 assert_eq!(
1677 op.writers_in_flight, OPERATE_MAX_WRITERS,
1678 "widen opens the full writer width"
1679 );
1680 }
1681
1682 #[test]
1683 fn cancelled_field_matches_landed_cwc_contract() {
1684 // CWC `packages/contracts/src/operate.js` (`20de981`, PR #284) names
1685 // the field `cancelledAt` — `publicOperateRecord` always emits it,
1686 // as `""` before cancellation. There is no bare `cancelled` field.
1687 let dir = TempDir::new().expect("temp");
1688 let store = OperationStore::open(dir.path()).expect("store");
1689 let op = start_operation(
1690 &store,
1691 dir.path(),
1692 Some("Contract shape".into()),
1693 None,
1694 true,
1695 "selected-model",
1696 )
1697 .expect("start");
1698 let json = serde_json::to_value(&op).expect("json");
1699 assert_eq!(json["cancelledAt"], serde_json::json!(""));
1700 assert!(json.get("cancelled").is_none());
1701
1702 let cancelled = cancel_operation(&store).expect("cancel").expect("present");
1703 let json = serde_json::to_value(&cancelled).expect("json");
1704 assert!(!json["cancelledAt"].as_str().unwrap().is_empty());
1705 assert!(json.get("cancelled").is_none());
1706 }
1707
1708 #[test]
1709 fn direction_change_invalidates_stale_lead_plan() {
1710 let mut op = with_credentials(Operation::new("Old direction", None));
1711 op.plan_from_direction();
1712 assert_eq!(op.status, OperateStatus::Running);
1713
1714 apply_operate_patch(
1715 &mut op,
1716 &serde_json::json!({ "direction": "Brand new direction" }),
1717 )
1718 .expect("patch");
1719 assert_eq!(op.direction, "Brand new direction");
1720 assert!(
1721 op.lead_plan.is_none(),
1722 "a changed direction must not leave superseded slices executing"
1723 );
1724 assert_eq!(op.status, OperateStatus::IdleBlocked);
1725 assert_eq!(
1726 op.idle_blocked_reason,
1727 Some(OperateIdleReason::AwaitingLeadPlan)
1728 );
1729 assert!(!op.workers_admitted);
1730 assert_eq!(op.writers_in_flight, 0);
1731 }
1732
1733 #[test]
1734 fn same_direction_patch_keeps_plan() {
1735 let mut op = with_credentials(Operation::new("Steady", None));
1736 op.plan_from_direction();
1737 apply_operate_patch(&mut op, &serde_json::json!({ "direction": "Steady" })).expect("patch");
1738 assert!(op.lead_plan.is_some());
1739 assert_eq!(op.status, OperateStatus::Running);
1740 }
1741
1742 #[test]
1743 fn put_plan_admits_worker_owners() {
1744 let mut op = with_credentials(Operation::new("Slice it", None));
1745 let plan = serde_json::json!({
1746 "slices": [
1747 { "id": "slice-1", "title": "Scout", "ownerId": "lead",
1748 "dependsOn": [], "estCostUsd": 0.1, "startOffsetSec": 0, "durationSec": 600 },
1749 { "id": "slice-2", "title": "Build", "ownerId": "worker-7",
1750 "dependsOn": ["slice-1"], "estCostUsd": 0.2, "startOffsetSec": 600, "durationSec": 1200 }
1751 ]
1752 });
1753 apply_operate_patch(&mut op, &serde_json::json!({ "leadPlan": plan })).expect("patch");
1754 assert!(
1755 op.roster.iter().any(|member| member.id == "worker-7"),
1756 "plan owners must join the roster or workers never dispatch"
1757 );
1758 assert_eq!(op.status, OperateStatus::Running);
1759 assert!(op.workers_admitted);
1760 // Idempotent: re-applying the same plan must not duplicate the owner.
1761 let before = op.roster.len();
1762 apply_operate_patch(&mut op, &serde_json::json!({ "leadPlan": plan })).expect("patch");
1763 assert_eq!(op.roster.len(), before);
1764 }
1765
1766 #[test]
1767 fn attach_preserves_operation_record() {
1768 let dir = TempDir::new().expect("temp");
1769 let store = OperationStore::open(dir.path()).expect("store");
1770 let first = start_operation(
1771 &store,
1772 dir.path(),
1773 Some("Keep the lineage".into()),
1774 None,
1775 true,
1776 "selected-model",
1777 )
1778 .expect("start");
1779 let mut spent = first.clone();
1780 keep_alive_observation(&mut spent, Some(3.0), Some(42.0), None, None);
1781 store.save(&spent).expect("save spend");
1782
1783 let reentered =
1784 attach_or_start_operation(&store, dir.path(), None, None, true, "selected-model")
1785 .expect("attach");
1786 assert_eq!(reentered.id, first.id, "re-entry must attach, not reset");
1787 assert_eq!(reentered.spent_usd, 42.0);
1788 assert_eq!(reentered.observed_burn_usd_per_hour, Some(3.0));
1789
1790 // A cancelled record is terminal: re-entry starts a new operation.
1791 cancel_operation(&store).expect("cancel");
1792 let fresh =
1793 attach_or_start_operation(&store, dir.path(), None, None, true, "selected-model")
1794 .expect("restart");
1795 assert_ne!(fresh.id, first.id);
1796 // The fresh operation reuses the recorded direction and awaits its
1797 // own lead plan (CWC projects a plan-less record to idle_blocked).
1798 assert_eq!(fresh.direction, "Keep the lineage");
1799 assert_eq!(fresh.status, OperateStatus::IdleBlocked);
1800 assert_eq!(
1801 fresh.idle_blocked_reason,
1802 Some(OperateIdleReason::AwaitingLeadPlan)
1803 );
1804 assert_eq!(fresh.spent_usd, 0.0);
1805 }
1806
1807 #[test]
1808 fn mutate_reloads_latest_record_under_lock() {
1809 let dir = TempDir::new().expect("temp");
1810 let writer = OperationStore::open(dir.path()).expect("store a");
1811 let reader = OperationStore::open(dir.path()).expect("store b");
1812 start_operation(
1813 &writer,
1814 dir.path(),
1815 Some("First".into()),
1816 None,
1817 true,
1818 "selected-model",
1819 )
1820 .expect("start");
1821
1822 writer
1823 .mutate(|op| {
1824 apply_operate_patch(op, &serde_json::json!({ "direction": "Second" }))?;
1825 Ok(())
1826 })
1827 .expect("mutate a")
1828 .expect("present");
1829 reader
1830 .mutate(|op| {
1831 keep_alive_observation(op, Some(9.0), Some(5.0), None, None);
1832 Ok(())
1833 })
1834 .expect("mutate b")
1835 .expect("present");
1836
1837 // The second store reloaded under the lock, so the first store's
1838 // direction write survives alongside the keepalive observation.
1839 let merged = writer.load().expect("load").expect("present");
1840 assert_eq!(merged.direction, "Second");
1841 assert_eq!(merged.spent_usd, 5.0);
1842 assert_eq!(merged.observed_burn_usd_per_hour, Some(9.0));
1843 }
1844
1845 #[test]
1846 fn burn_rate_below_a_cent_is_rejected() {
1847 let err = parse_burn_rate(Some(&serde_json::json!(0.001))).expect_err("rejects");
1848 assert!(err.to_string().contains("at least $0.01/hr"));
1849 let zero_target = parse_burn_rate(Some(&serde_json::json!(0.004))).expect_err("rejects");
1850 assert!(zero_target.to_string().contains("at least $0.01/hr"));
1851 }
1852
1853 #[test]
1854 fn keepalive_reuse_refreshes_cwds_and_kicks_first_lead_run() {
1855 let dir = TempDir::new().expect("temp");
1856 let manager = AutomationManager::open_for_test(dir.path().to_path_buf()).expect("manager");
1857 let workspace_a = dir.path().join("workspace-a");
1858 let workspace_b = dir.path().join("workspace-b");
1859 fs::create_dir_all(&workspace_a).expect("dir a");
1860 fs::create_dir_all(&workspace_b).expect("dir b");
1861
1862 upsert_keepalive(&manager, &workspace_a, false, &route_fixture_config(), None)
1863 .expect("upsert a");
1864 let first = manager
1865 .get_automation(OPERATE_KEEPALIVE_ID)
1866 .expect("keepalive a");
1867 assert_eq!(first.cwds, vec![workspace_a.clone()]);
1868
1869 upsert_keepalive(&manager, &workspace_b, true, &route_fixture_config(), None)
1870 .expect("upsert b");
1871 let second = manager
1872 .get_automation(OPERATE_KEEPALIVE_ID)
1873 .expect("keepalive b");
1874 assert_eq!(
1875 second.cwds,
1876 vec![workspace_b],
1877 "reuse must retarget the workspace scheduled runs execute in"
1878 );
1879 assert_eq!(
1880 second.next_run_at.map(|at| at <= Utc::now()),
1881 Some(true),
1882 "kick schedules the first lead run for the next scheduler tick"
1883 );
1884 assert_eq!(second.model.as_deref(), Some("same-model"));
1885 assert_eq!(second.mode.as_deref(), Some("operate"));
1886 assert_eq!(second.rrule, OPERATE_KEEPALIVE_RRULE);
1887
1888 // Only one automation exists — no orphaned UUID-named twin.
1889 assert_eq!(
1890 manager
1891 .list_automations()
1892 .expect("list")
1893 .iter()
1894 .filter(|record| record.mode.as_deref() == Some("operate"))
1895 .count(),
1896 1
1897 );
1898 }
1899
1900 #[test]
1901 fn cancel_pauses_keepalive_so_no_cost_accrues() {
1902 let dir = TempDir::new().expect("temp");
1903 let manager = AutomationManager::open_for_test(dir.path().to_path_buf()).expect("manager");
1904 upsert_keepalive(&manager, dir.path(), false, &route_fixture_config(), None)
1905 .expect("upsert");
1906 pause_keepalive(&manager).expect("pause");
1907 let paused = manager
1908 .get_automation(OPERATE_KEEPALIVE_ID)
1909 .expect("keepalive");
1910 assert_eq!(paused.status, AutomationStatus::Paused);
1911 assert_eq!(paused.next_run_at, None, "nothing fires after cancel");
1912
1913 // Pausing is idempotent and a missing keepalive is not an error.
1914 pause_keepalive(&manager).expect("pause again");
1915 let empty =
1916 AutomationManager::open_for_test(dir.path().join("empty")).expect("empty manager");
1917 pause_keepalive(&empty).expect("missing keepalive is a no-op");
1918 assert!(!kick_keepalive(&empty).expect("kick missing"));
1919
1920 // A fresh start reactivates the keepalive.
1921 upsert_keepalive(&manager, dir.path(), false, &route_fixture_config(), None)
1922 .expect("reactivate");
1923 let active = manager
1924 .get_automation(OPERATE_KEEPALIVE_ID)
1925 .expect("keepalive");
1926 assert_eq!(active.status, AutomationStatus::Active);
1927
1928 // Kicks only touch an active keepalive.
1929 assert!(kick_keepalive(&manager).expect("kick"));
1930 pause_keepalive(&manager).expect("pause");
1931 assert!(!kick_keepalive(&manager).expect("kick paused"));
1932 }
1933
1934 #[test]
1935 fn explicit_env_paths_reject_traversal() {
1936 assert!(explicit_file_path(" /tmp/does-not-exist.md ").is_none());
1937 assert!(explicit_file_path("").is_none());
1938 assert!(explicit_file_path("/tmp/../etc/passwd").is_none());
1939 // Keep the temp dir alive for the whole assertion: dropping it first
1940 // would delete the file under the path.
1941 let dir = TempDir::new().expect("temp");
1942 let checker = dir.path().join("check.py");
1943 fs::write(&checker, "# marker").expect("write");
1944 let found =
1945 explicit_file_path(checker.to_str().expect("utf8")).expect("regular file accepted");
1946 assert_eq!(found, checker);
1947 assert!(explicit_dir_path("../escape").is_none());
1948 assert!(explicit_dir_path("ops/inside").is_some());
1949 }
1950
1951 #[test]
1952 fn missing_credentials_fail_closed() {
1953 let dir = TempDir::new().expect("temp");
1954 let store = OperationStore::open(dir.path()).expect("store");
1955 let op = start_operation(
1956 &store,
1957 dir.path(),
1958 Some("Do not spend silently".into()),
1959 None,
1960 false,
1961 "selected-model",
1962 )
1963 .expect("start");
1964 assert_eq!(op.status, OperateStatus::IdleBlocked);
1965 assert_eq!(
1966 op.idle_blocked_reason,
1967 Some(OperateIdleReason::MissingCredentials)
1968 );
1969 assert!(!op.workers_admitted);
1970 assert_eq!(op.writers_in_flight, 0);
1971 }
1972
1973 #[test]
1974 fn cancel_stays_cancelled_through_keep_alive() {
1975 let dir = TempDir::new().expect("temp");
1976 let store = OperationStore::open(dir.path()).expect("store");
1977 start_operation(
1978 &store,
1979 dir.path(),
1980 Some("Stop".into()),
1981 None,
1982 true,
1983 "selected-model",
1984 )
1985 .expect("start");
1986 let cancelled = cancel_operation(&store).expect("cancel").expect("present");
1987 let mut kept = cancelled;
1988 keep_alive_observation(&mut kept, Some(40.0), Some(999.0), None, None);
1989 assert_eq!(kept.status, OperateStatus::Cancelled);
1990 assert!(!kept.workers_admitted);
1991 }
1992
1993 #[test]
1994 fn lead_plan_is_the_gantt_model() {
1995 let mut op = with_credentials(Operation::new("Scout\nWrite", None));
1996 op.plan_from_direction();
1997 let plan = op.lead_plan.as_ref().expect("plan");
1998 assert_eq!(plan.slices.len(), 2);
1999 assert_eq!(plan.slices[0].owner_id, "lead");
2000 assert_eq!(plan.slices[1].depends_on, vec!["slice-1".to_string()]);
2001 assert_eq!(plan.slices[0].start_offset_sec, 0);
2002 assert!(plan.slices[0].duration_sec >= 1);
2003 let board = render_plan_board(&op);
2004 assert!(board.contains("gantt time →"), "{board}");
2005 assert!(board.contains("leadPlan"), "{board}");
2006 assert!(board.contains("No cap"), "{board}");
2007 }
2008
2009 #[test]
2010 fn empty_direction_is_idle_blocked() {
2011 let op = with_credentials(Operation::new("", None));
2012 assert_eq!(op.status, OperateStatus::IdleBlocked);
2013 assert_eq!(
2014 op.idle_blocked_reason,
2015 Some(OperateIdleReason::DirectionEmpty)
2016 );
2017 }
2018
2019 #[test]
2020 fn human_gates_do_not_include_merge() {
2021 assert!(human_gate_for("deploy"));
2022 assert!(human_gate_for("billing"));
2023 assert!(!human_gate_for("merge"));
2024 }
2025
2026 #[test]
2027 fn calls_landed_checker_flags() {
2028 assert_eq!(
2029 check_auto_merge_args("Hmbown/CodeWhale", "1234", "keel"),
2030 vec![
2031 "scripts/check-auto-merge.py",
2032 "--repo",
2033 "Hmbown/CodeWhale",
2034 "--pr",
2035 "1234",
2036 "--agent",
2037 "keel"
2038 ]
2039 );
2040 assert_eq!(
2041 auto_merge_pr_args("Hmbown/CodeWhale", "1234", "keel")[0],
2042 "scripts/auto-merge-pr.py"
2043 );
2044 let deny = evaluate_auto_merge(
2045 AutoMergeRequest {
2046 pr: "12",
2047 role: "keel",
2048 repo: "Hmbown/CodeWhale",
2049 },
2050 None,
2051 );
2052 assert!(matches!(deny, AutoMergeDecision::Deny { .. }));
2053 let _ = AUTO_MERGE_CHECKER_ENV;
2054 let _ = discover_auto_merge_checker(Path::new("/no-ops-here"));
2055 }
2056
2057 #[test]
2058 fn checker_exit_zero_allows() {
2059 let dir = TempDir::new().expect("temp");
2060 let checker = dir.path().join("check-auto-merge.py");
2061 fs::write(
2062 &checker,
2063 "#!/usr/bin/env python3\nimport argparse, sys\np=argparse.ArgumentParser()\np.add_argument('--repo')\np.add_argument('--pr')\np.add_argument('--agent', required=True)\np.parse_args()\nsys.exit(0)\n",
2064 )
2065 .expect("write");
2066 assert_eq!(
2067 evaluate_auto_merge(
2068 AutoMergeRequest {
2069 pr: "42",
2070 role: "keel",
2071 repo: "Hmbown/CodeWhale",
2072 },
2073 Some(&checker),
2074 ),
2075 AutoMergeDecision::Allow
2076 );
2077 }
2078
2079 #[test]
2080 fn plan_board_localizes_chrome_but_not_contract_tokens() {
2081 let mut op = with_credentials(Operation::new("Scout\nWrite", None));
2082 op.plan_from_direction();
2083 let english = render_plan_board_locale(&op, codewhale_localization::Locale::En);
2084 assert!(english.contains("gantt time →"), "{english}");
2085 assert!(english.contains("burn No cap"), "{english}");
2086 let japanese = render_plan_board_locale(&op, codewhale_localization::Locale::Ja);
2087 assert!(japanese.contains("ガント"), "{japanese}");
2088 // Contract tokens stay verbatim in every locale.
2089 assert!(japanese.contains("slice-1"), "{japanese}");
2090 assert!(japanese.contains(&op.id), "{japanese}");
2091 }
2092
2093 #[test]
2094 fn keepalive_automation_and_defaults() {
2095 let dir = TempDir::new().expect("temp");
2096 let manager = AutomationManager::open_for_test(dir.path().to_path_buf()).expect("manager");
2097 upsert_keepalive(&manager, dir.path(), false, &route_fixture_config(), None)
2098 .expect("upsert");
2099 let record = manager
2100 .get_automation(OPERATE_KEEPALIVE_ID)
2101 .expect("keepalive");
2102 assert_eq!(record.model.as_deref(), Some("same-model"));
2103 assert_eq!(record.mode.as_deref(), Some("operate"));
2104 assert_eq!(record.cwds, vec![dir.path().to_path_buf()]);
2105 assert_eq!(
2106 record.next_run_at, None,
2107 "without a kick the hourly recurrence owns the next run"
2108 );
2109 assert_eq!(OPERATE_MAX_WRITERS, 3);
2110 }
2111 }
2112
2112 lines RUST