返回 CodeWhale
task_spec.rs
根目录 / crates / tui / src / fleet / task_spec.rs
1 //! Typed task-spec loading, artifact refs, deterministic scorers, and receipts.
2
3 #![allow(dead_code)]
4
5 use std::collections::{BTreeMap, BTreeSet};
6 use std::path::{Path, PathBuf};
7
8 use anyhow::{Context, Result, bail};
9 use chrono::{SecondsFormat, Utc};
10 use codewhale_protocol::fleet::*;
11 use regex::Regex;
12 use serde::{Deserialize, Serialize};
13 use serde_json::{Value, json};
14
15 use super::ledger::FleetLedger;
16
17 const MAX_SCORER_READ_BYTES: u64 = 1_000_000;
18 const MAX_FLEET_ID_BYTES: usize = 128;
19 const MAX_FLEET_NAME_BYTES: usize = 256;
20
21 #[derive(Debug, Clone, Serialize, Deserialize)]
22 pub struct FleetTaskSpecDocument {
23 #[serde(default)]
24 pub name: Option<String>,
25 #[serde(default)]
26 pub labels: BTreeMap<String, String>,
27 #[serde(default)]
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub security_policy: Option<FleetSecurityPolicy>,
30 #[serde(default, alias = "worker_specs")]
31 pub workers: Vec<FleetWorkerSpec>,
32 #[serde(default)]
33 pub tasks: Vec<FleetTaskSpec>,
34 }
35
36 #[derive(Debug, Clone, Deserialize)]
37 #[serde(untagged)]
38 enum FleetTaskSpecFile {
39 Document(FleetTaskSpecDocument),
40 Tasks(Vec<FleetTaskSpec>),
41 Single(Box<FleetTaskSpec>),
42 }
43
44 impl FleetTaskSpecFile {
45 fn into_document(self, fallback_name: String) -> FleetTaskSpecDocument {
46 match self {
47 Self::Document(mut doc) => {
48 if doc.name.as_deref().is_none_or(str::is_empty) {
49 doc.name = Some(fallback_name);
50 }
51 doc
52 }
53 Self::Tasks(tasks) => FleetTaskSpecDocument {
54 name: Some(fallback_name),
55 labels: BTreeMap::new(),
56 security_policy: None,
57 workers: Vec::new(),
58 tasks,
59 },
60 Self::Single(task) => FleetTaskSpecDocument {
61 name: Some(fallback_name),
62 labels: BTreeMap::new(),
63 security_policy: None,
64 workers: Vec::new(),
65 tasks: vec![*task],
66 },
67 }
68 }
69 }
70
71 #[derive(Debug, Clone)]
72 pub struct FleetTaskVerificationInput {
73 pub run_id: FleetRunId,
74 pub task_id: String,
75 pub worker_id: String,
76 /// Durable lease generation whose result is being verified.
77 pub attempt: u32,
78 pub exit_code: Option<i32>,
79 pub artifacts: Vec<FleetArtifactRef>,
80 /// Resolved-route snapshot to persist on the receipt (#3154).
81 pub resolved_route: Option<FleetResolvedRoute>,
82 /// Effective worker authority snapshot to persist on the receipt (#3211).
83 pub effective_permissions: Option<FleetEffectivePermissions>,
84 }
85
86 #[derive(Debug, Clone)]
87 pub struct FleetTaskVerification {
88 pub result: FleetTaskResult,
89 pub failure_kind: Option<FleetTaskFailureKind>,
90 pub score: FleetScore,
91 pub evidence: Vec<String>,
92 }
93
94 pub fn load_task_spec_document(path: &Path) -> Result<FleetTaskSpecDocument> {
95 let raw = std::fs::read_to_string(path)
96 .with_context(|| format!("reading fleet task spec {}", path.display()))?;
97 let fallback_name = path
98 .file_stem()
99 .and_then(|s| s.to_str())
100 .filter(|s| !s.is_empty())
101 .unwrap_or("fleet-run")
102 .to_string();
103 let parsed = match path.extension().and_then(|s| s.to_str()) {
104 Some("toml") => toml::from_str::<FleetTaskSpecFile>(&raw)
105 .with_context(|| format!("parsing TOML fleet task spec {}", path.display()))?,
106 _ => serde_json::from_str::<FleetTaskSpecFile>(&raw)
107 .with_context(|| format!("parsing JSON fleet task spec {}", path.display()))?,
108 };
109 let doc = parsed.into_document(fallback_name);
110 validate_task_spec_document(&doc)?;
111 Ok(doc)
112 }
113
114 pub fn validate_task_spec_document(doc: &FleetTaskSpecDocument) -> Result<()> {
115 if doc.tasks.is_empty() {
116 bail!("fleet task spec must include at least one task");
117 }
118 let mut ids = BTreeSet::new();
119 for task in &doc.tasks {
120 validate_fleet_identity("task id", &task.id)?;
121 if !ids.insert(task.id.clone()) {
122 bail!("duplicate fleet task id {}", task.id);
123 }
124 validate_fleet_name(&format!("task {} name", task.id), &task.name)?;
125 if task.instructions.trim().is_empty() {
126 bail!("fleet task {} instructions cannot be empty", task.id);
127 }
128 if let Some(objective) = &task.objective
129 && objective.trim().is_empty()
130 {
131 bail!("fleet task {} objective cannot be empty", task.id);
132 }
133 validate_worker_profile(&task.id, task.worker.as_ref())?;
134 validate_tags(&task.id, &task.tags)?;
135 validate_workspace_requirements(task)?;
136 }
137 let mut worker_ids = BTreeSet::new();
138 for worker in &doc.workers {
139 validate_fleet_identity("worker id", &worker.id)?;
140 if !worker_ids.insert(worker.id.clone()) {
141 bail!("duplicate fleet worker id {}", worker.id);
142 }
143 validate_fleet_name(&format!("worker {} name", worker.id), &worker.name)?;
144 }
145 Ok(())
146 }
147
148 fn validate_fleet_identity(field: &str, value: &str) -> Result<()> {
149 if value.is_empty() {
150 bail!("fleet {field} cannot be empty");
151 }
152 if value.len() > MAX_FLEET_ID_BYTES || !value.chars().all(is_worker_token_char) {
153 bail!(
154 "fleet {field} must be a simple ASCII token no longer than {MAX_FLEET_ID_BYTES} bytes"
155 );
156 }
157 Ok(())
158 }
159
160 fn validate_fleet_name(field: &str, value: &str) -> Result<()> {
161 if value.trim().is_empty() {
162 bail!("fleet {field} cannot be empty");
163 }
164 if value.len() > MAX_FLEET_NAME_BYTES || value.chars().any(char::is_control) {
165 bail!(
166 "fleet {field} must be one printable line no longer than {MAX_FLEET_NAME_BYTES} bytes"
167 );
168 }
169 Ok(())
170 }
171
172 fn validate_worker_profile(task_id: &str, worker: Option<&FleetTaskWorkerProfile>) -> Result<()> {
173 let Some(worker) = worker else {
174 return Ok(());
175 };
176 validate_worker_token(
177 task_id,
178 "worker.agent_profile",
179 worker.agent_profile.as_deref(),
180 )?;
181 validate_worker_token(task_id, "worker.loadout", worker.loadout.as_deref())?;
182 validate_worker_token(task_id, "worker.model_class", worker.model_class.as_deref())?;
183 validate_worker_model(task_id, worker.model.as_deref())?;
184 Ok(())
185 }
186
187 fn validate_worker_token(task_id: &str, field: &str, value: Option<&str>) -> Result<()> {
188 let Some(value) = value else {
189 return Ok(());
190 };
191 let trimmed = value.trim();
192 if trimmed.is_empty() {
193 bail!("fleet task {task_id} {field} cannot be empty");
194 }
195 if trimmed != value || !trimmed.chars().all(is_worker_token_char) {
196 bail!(
197 "fleet task {task_id} {field} must be a simple token, not a path or provider/model id"
198 );
199 }
200 Ok(())
201 }
202
203 fn is_worker_token_char(ch: char) -> bool {
204 ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')
205 }
206
207 fn validate_worker_model(task_id: &str, value: Option<&str>) -> Result<()> {
208 let Some(value) = value else {
209 return Ok(());
210 };
211 let trimmed = value.trim();
212 if trimmed.is_empty() {
213 bail!("fleet task {task_id} worker.model cannot be empty");
214 }
215 if trimmed != value
216 || !trimmed
217 .chars()
218 .all(|ch| ch.is_ascii_graphic() && !matches!(ch, '=' | '\'' | '"'))
219 {
220 bail!(
221 "fleet task {task_id} worker.model must be a visible model id without whitespace or secrets"
222 );
223 }
224 Ok(())
225 }
226
227 #[allow(clippy::too_many_arguments)]
228 pub fn write_fleet_artifact_ref(
229 workspace: &Path,
230 run_id: &FleetRunId,
231 task_id: &str,
232 worker_id: &str,
233 kind: FleetArtifactKind,
234 filename: &str,
235 contents: &[u8],
236 mime_type: Option<&str>,
237 ) -> Result<FleetArtifactRef> {
238 let rel_path = PathBuf::from(".codewhale")
239 .join("fleet")
240 .join(safe_path_segment(&run_id.0))
241 .join(safe_path_segment(task_id))
242 .join(safe_path_segment(worker_id))
243 .join(safe_path_segment(filename));
244 let abs_path = workspace.join(&rel_path);
245 if let Some(parent) = abs_path.parent() {
246 std::fs::create_dir_all(parent)
247 .with_context(|| format!("creating fleet artifact dir {}", parent.display()))?;
248 }
249 std::fs::write(&abs_path, contents)
250 .with_context(|| format!("writing fleet artifact {}", abs_path.display()))?;
251 Ok(FleetArtifactRef {
252 kind,
253 path: rel_path,
254 checksum: Some(format!("sha256:{}", crate::hashing::sha256_hex(contents))),
255 mime_type: mime_type.map(str::to_string),
256 size_bytes: Some(contents.len() as u64),
257 })
258 }
259
260 pub fn verify_task_result(
261 workspace: &Path,
262 task: &FleetTaskSpec,
263 input: &FleetTaskVerificationInput,
264 ) -> FleetTaskVerification {
265 match &task.scorer {
266 Some(FleetScorerSpec::ExitCode) => verify_exit_code(input.exit_code),
267 Some(FleetScorerSpec::FileExists { path }) => verify_file_exists(workspace, path),
268 Some(FleetScorerSpec::RegexMatch { path, pattern }) => {
269 verify_regex_match(workspace, path, pattern)
270 }
271 Some(FleetScorerSpec::JsonPath { path, expression }) => {
272 verify_json_path(workspace, path, expression)
273 }
274 Some(FleetScorerSpec::Command { command, .. }) => partial(
275 format!("external scorer command configured: {command}"),
276 "run the configured scorer command to finalize this receipt",
277 ),
278 Some(FleetScorerSpec::CodeWhaleVerifierPrompt { .. }) => partial(
279 "Codewhale verifier prompt configured",
280 "run a verifier prompt pass to finalize this receipt",
281 ),
282 Some(FleetScorerSpec::Manual) => partial(
283 "manual scorer configured",
284 "manual verification is required to finalize this receipt",
285 ),
286 None if !has_verifiable_artifact(input) => partial(
287 "no scorer configured and no verifiable artifacts recorded",
288 "worker exited successfully but produced no verifiable output",
289 ),
290 None => partial(
291 "no scorer configured",
292 "task has artifacts but no deterministic scorer",
293 ),
294 }
295 }
296
297 pub fn prepare_verification_receipt(
298 workspace: &Path,
299 input: &FleetTaskVerificationInput,
300 verification: FleetTaskVerification,
301 ) -> Result<FleetReceipt> {
302 let evidence = json!({
303 "run_id": input.run_id.0.clone(),
304 "task_id": input.task_id.clone(),
305 "worker_id": input.worker_id.clone(),
306 "attempt": input.attempt,
307 "result": verification.result.clone(),
308 "failure_kind": verification.failure_kind.clone(),
309 "score": verification.score.clone(),
310 "evidence": verification.evidence.clone(),
311 "artifacts": input.artifacts.clone(),
312 });
313 let bytes =
314 serde_json::to_vec_pretty(&evidence).context("serializing fleet receipt evidence")?;
315 // Content-address the evidence as well as namespacing it by attempt. A
316 // stale verifier may finish after a retry has started; it is allowed to
317 // leave an orphaned evidence file, but it must never overwrite the file a
318 // winning attempt's durable receipt references.
319 let evidence_hash = crate::hashing::sha256_hex(&bytes);
320 let filename = format!(
321 "verification-receipt-attempt-{:010}-{}.json",
322 input.attempt, evidence_hash
323 );
324 let receipt_artifact = write_fleet_artifact_ref(
325 workspace,
326 &input.run_id,
327 &input.task_id,
328 &input.worker_id,
329 FleetArtifactKind::Receipt,
330 &filename,
331 &bytes,
332 Some("application/json"),
333 )?;
334 let mut artifacts = input.artifacts.clone();
335 artifacts.push(receipt_artifact);
336 let receipt = FleetReceipt {
337 run_id: input.run_id.clone(),
338 task_id: input.task_id.clone(),
339 worker_id: input.worker_id.clone(),
340 attempt: Some(input.attempt),
341 terminal_seq: None,
342 completed_at: timestamp(),
343 result: verification.result,
344 failure_kind: verification.failure_kind,
345 artifacts,
346 score: Some(verification.score),
347 resolved_route: input.resolved_route.clone(),
348 effective_permissions: input.effective_permissions.clone(),
349 };
350 Ok(receipt)
351 }
352
353 pub fn record_verification_receipt(
354 ledger: &FleetLedger,
355 workspace: &Path,
356 input: &FleetTaskVerificationInput,
357 verification: FleetTaskVerification,
358 ) -> Result<FleetReceipt> {
359 let receipt = prepare_verification_receipt(workspace, input, verification)?;
360 ledger.record_receipt(receipt.clone())?;
361 Ok(receipt)
362 }
363
364 fn validate_tags(task_id: &str, tags: &[String]) -> Result<()> {
365 let mut seen = BTreeSet::new();
366 for tag in tags {
367 if tag.trim().is_empty() {
368 bail!("fleet task {task_id} tag cannot be empty");
369 }
370 if !seen.insert(tag) {
371 bail!("fleet task {task_id} has duplicate tag {tag}");
372 }
373 }
374 Ok(())
375 }
376
377 fn validate_workspace_requirements(task: &FleetTaskSpec) -> Result<()> {
378 let Some(workspace) = &task.workspace else {
379 return Ok(());
380 };
381 let env = workspace.environment.as_ref();
382 for name in env
383 .into_iter()
384 .flat_map(|env| env.required.iter().chain(env.allowlist.iter()))
385 {
386 if name.trim().is_empty() {
387 bail!(
388 "fleet task {} environment variable name cannot be empty",
389 task.id
390 );
391 }
392 }
393 Ok(())
394 }
395
396 fn verify_exit_code(exit_code: Option<i32>) -> FleetTaskVerification {
397 match exit_code {
398 Some(0) => pass("exit_code=0"),
399 Some(code) => fail(
400 FleetTaskFailureKind::Task,
401 0.0,
402 format!("exit_code={code}"),
403 "worker task exited unsuccessfully",
404 ),
405 None => fail(
406 FleetTaskFailureKind::Transport,
407 0.0,
408 "missing exit code",
409 "worker transport did not report a process result",
410 ),
411 }
412 }
413
414 fn verify_file_exists(workspace: &Path, path: &Path) -> FleetTaskVerification {
415 let abs_path = resolve_workspace_path(workspace, path);
416 if abs_path.is_file() {
417 pass(format!("file exists: {}", path.display()))
418 } else {
419 fail(
420 FleetTaskFailureKind::Task,
421 0.0,
422 format!("missing file: {}", path.display()),
423 "expected artifact file was not produced",
424 )
425 }
426 }
427
428 fn verify_regex_match(workspace: &Path, path: &Path, pattern: &str) -> FleetTaskVerification {
429 let regex = match Regex::new(pattern) {
430 Ok(regex) => regex,
431 Err(err) => {
432 return fail(
433 FleetTaskFailureKind::Verifier,
434 0.0,
435 format!("invalid regex: {err}"),
436 "regex scorer could not be compiled",
437 );
438 }
439 };
440 let contents = match read_bounded_to_string(workspace, path) {
441 Ok(contents) => contents,
442 Err(err) => {
443 return fail(
444 err.failure_kind,
445 0.0,
446 err.evidence,
447 "regex scorer could not read bounded evidence",
448 );
449 }
450 };
451 if regex.is_match(&contents) {
452 pass(format!("regex matched {}: {pattern}", path.display()))
453 } else {
454 fail(
455 FleetTaskFailureKind::Task,
456 0.0,
457 format!("regex did not match {}: {pattern}", path.display()),
458 "worker output did not satisfy the regex scorer",
459 )
460 }
461 }
462
463 fn verify_json_path(workspace: &Path, path: &Path, expression: &str) -> FleetTaskVerification {
464 let Some(segments) = json_path_segments(expression) else {
465 return fail(
466 FleetTaskFailureKind::Verifier,
467 0.0,
468 format!("unsupported JSON path expression: {expression}"),
469 "json_path scorer supports $.field or .field paths",
470 );
471 };
472 let contents = match read_bounded_to_string(workspace, path) {
473 Ok(contents) => contents,
474 Err(err) => {
475 return fail(
476 err.failure_kind,
477 0.0,
478 err.evidence,
479 "json_path scorer could not read bounded evidence",
480 );
481 }
482 };
483 let value: Value = match serde_json::from_str(&contents) {
484 Ok(value) => value,
485 Err(err) => {
486 return fail(
487 FleetTaskFailureKind::Task,
488 0.0,
489 format!("invalid JSON in {}: {err}", path.display()),
490 "worker artifact was not valid JSON",
491 );
492 }
493 };
494 match json_path_lookup(&value, &segments) {
495 Some(found) if json_truthy(found) => pass(format!(
496 "json_path matched {}: {expression}",
497 path.display()
498 )),
499 _ => fail(
500 FleetTaskFailureKind::Task,
501 0.0,
502 format!(
503 "json_path missing or false in {}: {expression}",
504 path.display()
505 ),
506 "worker JSON artifact did not satisfy the scorer",
507 ),
508 }
509 }
510
511 fn pass(evidence: impl Into<String>) -> FleetTaskVerification {
512 let evidence = evidence.into();
513 FleetTaskVerification {
514 result: FleetTaskResult::Pass,
515 failure_kind: None,
516 score: FleetScore {
517 value: 1.0,
518 max: Some(1.0),
519 notes: Some(evidence.clone()),
520 },
521 evidence: vec![evidence],
522 }
523 }
524
525 fn partial(evidence: impl Into<String>, notes: impl Into<String>) -> FleetTaskVerification {
526 let evidence = evidence.into();
527 let notes = notes.into();
528 FleetTaskVerification {
529 result: FleetTaskResult::Partial,
530 failure_kind: None,
531 score: FleetScore {
532 value: 0.5,
533 max: Some(1.0),
534 notes: Some(notes),
535 },
536 evidence: vec![evidence],
537 }
538 }
539
540 fn fail(
541 failure_kind: FleetTaskFailureKind,
542 value: f64,
543 evidence: impl Into<String>,
544 notes: impl Into<String>,
545 ) -> FleetTaskVerification {
546 let evidence = evidence.into();
547 FleetTaskVerification {
548 result: FleetTaskResult::Fail,
549 failure_kind: Some(failure_kind),
550 score: FleetScore {
551 value,
552 max: Some(1.0),
553 notes: Some(notes.into()),
554 },
555 evidence: vec![evidence],
556 }
557 }
558
559 fn has_verifiable_artifact(input: &FleetTaskVerificationInput) -> bool {
560 input.artifacts.iter().any(|artifact| {
561 !matches!(
562 artifact.kind,
563 FleetArtifactKind::Log | FleetArtifactKind::Receipt
564 )
565 })
566 }
567
568 #[derive(Debug)]
569 struct EvidenceReadError {
570 failure_kind: FleetTaskFailureKind,
571 evidence: String,
572 }
573
574 fn read_bounded_to_string(
575 workspace: &Path,
576 path: &Path,
577 ) -> std::result::Result<String, EvidenceReadError> {
578 let abs_path = resolve_workspace_path(workspace, path);
579 let metadata = std::fs::metadata(&abs_path).map_err(|err| EvidenceReadError {
580 failure_kind: if err.kind() == std::io::ErrorKind::NotFound {
581 FleetTaskFailureKind::Task
582 } else {
583 FleetTaskFailureKind::Verifier
584 },
585 evidence: format!("cannot read {}: {err}", path.display()),
586 })?;
587 if metadata.len() > MAX_SCORER_READ_BYTES {
588 return Err(EvidenceReadError {
589 failure_kind: FleetTaskFailureKind::Verifier,
590 evidence: format!(
591 "refusing to read oversized evidence {}: {} bytes",
592 path.display(),
593 metadata.len()
594 ),
595 });
596 }
597 std::fs::read_to_string(&abs_path).map_err(|err| EvidenceReadError {
598 failure_kind: FleetTaskFailureKind::Verifier,
599 evidence: format!("cannot decode {} as UTF-8: {err}", path.display()),
600 })
601 }
602
603 fn resolve_workspace_path(workspace: &Path, path: &Path) -> PathBuf {
604 if path.is_absolute() {
605 path.to_path_buf()
606 } else {
607 workspace.join(path)
608 }
609 }
610
611 fn json_path_segments(expression: &str) -> Option<Vec<&str>> {
612 let trimmed = expression.trim();
613 let path = trimmed
614 .strip_prefix("$.")
615 .or_else(|| trimmed.strip_prefix('.'))?;
616 if path.is_empty() {
617 return None;
618 }
619 let segments: Vec<_> = path.split('.').collect();
620 if segments.iter().any(|segment| segment.is_empty()) {
621 return None;
622 }
623 Some(segments)
624 }
625
626 fn json_path_lookup<'a>(value: &'a Value, segments: &[&str]) -> Option<&'a Value> {
627 let mut current = value;
628 for segment in segments {
629 current = current.as_object()?.get(*segment)?;
630 }
631 Some(current)
632 }
633
634 fn json_truthy(value: &Value) -> bool {
635 !matches!(value, Value::Null | Value::Bool(false))
636 }
637
638 fn timestamp() -> String {
639 Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
640 }
641
642 fn safe_path_segment(value: &str) -> String {
643 value
644 .chars()
645 .map(|ch| {
646 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
647 ch
648 } else {
649 '_'
650 }
651 })
652 .collect()
653 }
654
655 #[cfg(test)]
656 mod tests {
657 use super::*;
658 use serde_json::json;
659 use tempfile::TempDir;
660
661 fn task(id: &str, scorer: Option<FleetScorerSpec>) -> FleetTaskSpec {
662 FleetTaskSpec {
663 id: id.to_string(),
664 name: id.to_string(),
665 description: None,
666 objective: Some(format!("Verify {id}")),
667 instructions: format!("do {id}"),
668 worker: Some(FleetTaskWorkerProfile {
669 agent_profile: None,
670 role: Some("reviewer".to_string()),
671 loadout: None,
672 model_class: None,
673 model: None,
674 tool_profile: Some("read-only".to_string()),
675 tools: vec!["git".to_string()],
676 capabilities: vec!["rust".to_string()],
677 }),
678 workspace: Some(FleetWorkspaceRequirements {
679 root: Some(PathBuf::from(".")),
680 required_files: vec![PathBuf::from("Cargo.toml")],
681 writable_paths: vec![PathBuf::from(".codewhale/fleet")],
682 environment: Some(FleetEnvironmentRequirements {
683 required: vec!["PATH".to_string()],
684 allowlist: vec!["RUST_LOG".to_string()],
685 }),
686 }),
687 input_files: vec![PathBuf::from("Cargo.toml")],
688 context: vec!["fleet verifier test".to_string()],
689 budget: Some(FleetTaskBudget {
690 max_tokens: Some(4000),
691 max_tool_calls: Some(12),
692 max_seconds: Some(120),
693 }),
694 expected_artifacts: vec![FleetArtifactKind::Log, FleetArtifactKind::Receipt],
695 scorer,
696 retry_policy: Some(FleetRetryPolicy::default()),
697 alert_policy: None,
698 timeout_seconds: Some(120),
699 tags: vec!["review".to_string()],
700 metadata: BTreeMap::new(),
701 }
702 }
703
704 #[test]
705 fn fleet_task_spec_document_parses_multi_task_verified_shape() {
706 let tmp = TempDir::new().unwrap();
707 let path = tmp.path().join("fleet-tasks.json");
708 let doc = json!({
709 "name": "release triage",
710 "labels": {"milestone": "v0.8.60"},
711 "tasks": [
712 task("release-notes", Some(FleetScorerSpec::ExitCode)),
713 task("risk-review", Some(FleetScorerSpec::Manual))
714 ]
715 });
716 std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap()).unwrap();
717
718 let parsed = load_task_spec_document(&path).unwrap();
719
720 assert_eq!(parsed.name.as_deref(), Some("release triage"));
721 assert_eq!(parsed.tasks.len(), 2);
722 assert_eq!(
723 parsed.tasks[0].objective.as_deref(),
724 Some("Verify release-notes")
725 );
726 assert_eq!(
727 parsed.tasks[0].worker.as_ref().unwrap().role.as_deref(),
728 Some("reviewer")
729 );
730 assert_eq!(parsed.tasks[1].tags, vec!["review"]);
731 }
732
733 #[test]
734 fn fleet_task_spec_document_parses_worker_profile_loadout_intent() {
735 let tmp = TempDir::new().unwrap();
736 let path = tmp.path().join("fleet-profile-task.json");
737 let doc = json!({
738 "name": "profile loadout smoke",
739 "tasks": [{
740 "id": "review",
741 "name": "review",
742 "instructions": "review the patch",
743 "worker": {
744 "profile": "adversarial_reviewer",
745 "role": "reviewer",
746 "loadout": "auto",
747 "model_class": "balanced",
748 "model": "deepseek-v4-pro",
749 "tool_profile": "read-only",
750 "tools": ["read_file", "grep_files"],
751 "capabilities": ["rust"]
752 }
753 }]
754 });
755 std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap()).unwrap();
756
757 let parsed = load_task_spec_document(&path).unwrap();
758 let worker = parsed.tasks[0].worker.as_ref().unwrap();
759
760 assert_eq!(
761 worker.agent_profile.as_deref(),
762 Some("adversarial_reviewer")
763 );
764 assert_eq!(worker.role.as_deref(), Some("reviewer"));
765 assert_eq!(worker.loadout.as_deref(), Some("auto"));
766 assert_eq!(worker.model_class.as_deref(), Some("balanced"));
767 assert_eq!(worker.model.as_deref(), Some("deepseek-v4-pro"));
768 assert_eq!(worker.tool_profile.as_deref(), Some("read-only"));
769 }
770
771 #[test]
772 fn fleet_task_spec_rejects_unsafe_worker_profile_intent_tokens() {
773 let tmp = TempDir::new().unwrap();
774 let path = tmp.path().join("unsafe-profile-task.json");
775 let doc = json!({
776 "tasks": [{
777 "id": "review",
778 "name": "review",
779 "instructions": "review the patch",
780 "worker": {
781 "profile": "../secrets",
782 "loadout": "openrouter/deepseek",
783 "model_class": "",
784 "model": "deepseek/deepseek-v4-pro"
785 }
786 }]
787 });
788 std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap()).unwrap();
789
790 let err = load_task_spec_document(&path).unwrap_err().to_string();
791
792 assert!(
793 err.contains("worker.agent_profile must be a simple token"),
794 "unexpected error: {err}"
795 );
796 }
797
798 #[test]
799 fn fleet_task_spec_rejects_secret_like_worker_model() {
800 let tmp = TempDir::new().unwrap();
801 let path = tmp.path().join("unsafe-worker-model.json");
802 let doc = json!({
803 "tasks": [{
804 "id": "review",
805 "name": "review",
806 "instructions": "review the patch",
807 "worker": {
808 "model": "deepseek-v4-pro api_key=secret"
809 }
810 }]
811 });
812 std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap()).unwrap();
813
814 let err = load_task_spec_document(&path).unwrap_err().to_string();
815
816 assert!(
817 err.contains("worker.model must be a visible model id"),
818 "unexpected error: {err}"
819 );
820 }
821
822 #[test]
823 fn fleet_task_spec_rejects_unbounded_or_multiline_task_and_worker_identities() {
824 let tmp = TempDir::new().unwrap();
825 let path = tmp.path().join("unsafe-identities.json");
826 let doc = json!({
827 "workers": [{
828 "id": "worker\r\nforged",
829 "name": "forged worker",
830 "host": {"kind": "local"}
831 }],
832 "tasks": [{
833 "id": "review",
834 "name": "review",
835 "instructions": "review the patch"
836 }]
837 });
838 std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap()).unwrap();
839
840 let err = load_task_spec_document(&path).unwrap_err().to_string();
841 assert!(
842 err.contains("worker id must be a simple ASCII token"),
843 "unexpected error: {err}"
844 );
845
846 let mut doc = task("review", None);
847 doc.id = "a".repeat(MAX_FLEET_ID_BYTES + 1);
848 let err = validate_task_spec_document(&FleetTaskSpecDocument {
849 name: None,
850 labels: BTreeMap::new(),
851 security_policy: None,
852 workers: Vec::new(),
853 tasks: vec![doc],
854 })
855 .unwrap_err()
856 .to_string();
857 assert!(
858 err.contains("task id must be a simple ASCII token"),
859 "unexpected error: {err}"
860 );
861 }
862
863 #[test]
864 fn fleet_task_spec_artifact_refs_are_bounded_paths() {
865 let tmp = TempDir::new().unwrap();
866 let artifact = write_fleet_artifact_ref(
867 tmp.path(),
868 &FleetRunId::from("run-1"),
869 "task-a",
870 "worker-1",
871 FleetArtifactKind::Log,
872 "worker.log",
873 b"this is artifact content",
874 Some("text/plain"),
875 )
876 .unwrap();
877
878 let json = serde_json::to_string(&artifact).unwrap();
879 assert!(!json.contains("this is artifact content"));
880 assert!(json.contains("worker.log"));
881 assert_eq!(artifact.size_bytes, Some(24));
882 assert!(artifact.checksum.as_deref().unwrap().starts_with("sha256:"));
883 assert!(tmp.path().join(&artifact.path).exists());
884 }
885
886 #[test]
887 fn fleet_task_spec_scorers_record_pass_fail_partial_evidence() {
888 let tmp = TempDir::new().unwrap();
889 std::fs::write(tmp.path().join("result.txt"), "status=ok\n").unwrap();
890 std::fs::write(tmp.path().join("result.json"), r#"{"status":"ok"}"#).unwrap();
891 let input = FleetTaskVerificationInput {
892 run_id: FleetRunId::from("run-1"),
893 task_id: "task-a".to_string(),
894 worker_id: "worker-1".to_string(),
895 attempt: 1,
896 exit_code: Some(0),
897 artifacts: vec![],
898 resolved_route: None,
899 effective_permissions: None,
900 };
901
902 let pass = verify_task_result(
903 tmp.path(),
904 &task("exit", Some(FleetScorerSpec::ExitCode)),
905 &input,
906 );
907 assert_eq!(pass.result, FleetTaskResult::Pass);
908 assert_eq!(pass.failure_kind, None);
909
910 let regex = verify_task_result(
911 tmp.path(),
912 &task(
913 "regex",
914 Some(FleetScorerSpec::RegexMatch {
915 path: PathBuf::from("result.txt"),
916 pattern: "status=ok".to_string(),
917 }),
918 ),
919 &input,
920 );
921 assert_eq!(regex.result, FleetTaskResult::Pass);
922
923 let json_path = verify_task_result(
924 tmp.path(),
925 &task(
926 "json",
927 Some(FleetScorerSpec::JsonPath {
928 path: PathBuf::from("result.json"),
929 expression: "$.status".to_string(),
930 }),
931 ),
932 &input,
933 );
934 assert_eq!(json_path.result, FleetTaskResult::Pass);
935
936 let manual = verify_task_result(
937 tmp.path(),
938 &task("manual", Some(FleetScorerSpec::Manual)),
939 &input,
940 );
941 assert_eq!(manual.result, FleetTaskResult::Partial);
942
943 let no_scorer_empty = verify_task_result(tmp.path(), &task("unscored", None), &input);
944 assert_eq!(no_scorer_empty.result, FleetTaskResult::Partial);
945 assert!(
946 no_scorer_empty
947 .score
948 .notes
949 .as_deref()
950 .unwrap_or_default()
951 .contains("no verifiable output")
952 );
953
954 let failed = verify_task_result(
955 tmp.path(),
956 &task(
957 "missing",
958 Some(FleetScorerSpec::FileExists {
959 path: PathBuf::from("missing.txt"),
960 }),
961 ),
962 &input,
963 );
964 assert_eq!(failed.result, FleetTaskResult::Fail);
965 assert_eq!(failed.failure_kind, Some(FleetTaskFailureKind::Task));
966
967 let verifier_failed = verify_task_result(
968 tmp.path(),
969 &task(
970 "bad-regex",
971 Some(FleetScorerSpec::RegexMatch {
972 path: PathBuf::from("result.txt"),
973 pattern: "[".to_string(),
974 }),
975 ),
976 &input,
977 );
978 assert_eq!(verifier_failed.result, FleetTaskResult::Fail);
979 assert_eq!(
980 verifier_failed.failure_kind,
981 Some(FleetTaskFailureKind::Verifier)
982 );
983 }
984
985 #[test]
986 fn fleet_task_spec_receipt_records_artifacts_scores_and_failure_kind() {
987 let tmp = TempDir::new().unwrap();
988 let ledger = FleetLedger::open(tmp.path()).unwrap();
989 let log = write_fleet_artifact_ref(
990 tmp.path(),
991 &FleetRunId::from("run-1"),
992 "task-a",
993 "worker-1",
994 FleetArtifactKind::Log,
995 "worker.log",
996 b"exit_code=1",
997 Some("text/plain"),
998 )
999 .unwrap();
1000 let input = FleetTaskVerificationInput {
1001 run_id: FleetRunId::from("run-1"),
1002 task_id: "task-a".to_string(),
1003 worker_id: "worker-1".to_string(),
1004 attempt: 3,
1005 exit_code: Some(1),
1006 artifacts: vec![log],
1007 resolved_route: None,
1008 effective_permissions: Some(FleetEffectivePermissions {
1009 write: false,
1010 network: false,
1011 shell: "read_only".to_string(),
1012 tool_scope: "explicit".to_string(),
1013 tools: vec!["read_file".to_string()],
1014 background: true,
1015 max_spawn_depth: 0,
1016 profile_id: None,
1017 profile_origin: None,
1018 source: "worker_runtime_profile".to_string(),
1019 }),
1020 };
1021 let verification = verify_task_result(
1022 tmp.path(),
1023 &task("task-a", Some(FleetScorerSpec::ExitCode)),
1024 &input,
1025 );
1026
1027 let receipt =
1028 record_verification_receipt(&ledger, tmp.path(), &input, verification).unwrap();
1029
1030 assert_eq!(receipt.result, FleetTaskResult::Fail);
1031 assert_eq!(receipt.failure_kind, Some(FleetTaskFailureKind::Task));
1032 assert_eq!(receipt.attempt, Some(3));
1033 assert_eq!(receipt.terminal_seq, None);
1034 assert_eq!(receipt.effective_permissions, input.effective_permissions);
1035 assert_eq!(receipt.artifacts.len(), 2);
1036 assert!(matches!(
1037 receipt.artifacts.last().unwrap().kind,
1038 FleetArtifactKind::Receipt
1039 ));
1040 assert!(
1041 receipt
1042 .artifacts
1043 .last()
1044 .unwrap()
1045 .path
1046 .to_string_lossy()
1047 .contains("verification-receipt-attempt-0000000003-")
1048 );
1049 let state = ledger.rebuild_state().unwrap();
1050 assert_eq!(
1051 state.receipts["run-1:task-a"].failure_kind,
1052 Some(FleetTaskFailureKind::Task)
1053 );
1054 }
1055
1056 #[test]
1057 fn verification_evidence_is_attempt_and_content_addressed() {
1058 let tmp = TempDir::new().unwrap();
1059 let mut input = FleetTaskVerificationInput {
1060 run_id: FleetRunId::from("run-1"),
1061 task_id: "task-a".to_string(),
1062 worker_id: "worker-1".to_string(),
1063 attempt: 1,
1064 exit_code: Some(1),
1065 artifacts: Vec::new(),
1066 resolved_route: None,
1067 effective_permissions: None,
1068 };
1069 let scorer = task("task-a", Some(FleetScorerSpec::ExitCode));
1070 let stale_verification = verify_task_result(tmp.path(), &scorer, &input);
1071 let stale = prepare_verification_receipt(tmp.path(), &input, stale_verification).unwrap();
1072
1073 input.attempt = 2;
1074 input.exit_code = Some(0);
1075 let winning_verification = verify_task_result(tmp.path(), &scorer, &input);
1076 let winning =
1077 prepare_verification_receipt(tmp.path(), &input, winning_verification).unwrap();
1078
1079 let stale_path = &stale.artifacts.last().unwrap().path;
1080 let winning_path = &winning.artifacts.last().unwrap().path;
1081 assert_ne!(stale_path, winning_path);
1082 assert!(stale_path.to_string_lossy().contains("attempt-0000000001-"));
1083 assert!(
1084 winning_path
1085 .to_string_lossy()
1086 .contains("attempt-0000000002-")
1087 );
1088 assert!(tmp.path().join(stale_path).is_file());
1089 assert!(tmp.path().join(winning_path).is_file());
1090 assert_eq!(stale.result, FleetTaskResult::Fail);
1091 assert_eq!(winning.result, FleetTaskResult::Pass);
1092 }
1093 }
1094
1094 lines RUST