1pub mod acceptance;
9pub mod ask;
10pub mod attempt;
11pub mod converge;
12pub mod host;
13pub mod model_config;
14pub mod monitor;
15pub mod near_miss;
16pub mod propose;
17pub mod provenance;
18pub mod run_config;
19pub mod shape;
20
21use std::path::Path;
22use std::sync::Arc;
23
24use agentplane::core::CorrelationKey;
25use agentplane::core::{ProtectedField, SourceId};
26use agentplane::manifest::{Budgets, Manifest, ModelRef, Models, ToolGrant};
27use agentplane::model::ModelProvider;
28use agentplane::prelude::*;
29use agentplane::runtime::{Agent, RunOutcome, RunTerms};
30use serde_json::Value;
31
32use self::model_config::ModelConfig;
33use self::propose::{CAPABILITY, Propose};
34
35pub const AGENTPLANE_VERSION: &str = "0.57.0";
38
39pub const JOURNAL: &str = "journal.redb";
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct PlaneError(pub String);
45
46pub fn manifest(model: &ModelConfig) -> Result<Manifest, PlaneError> {
49 manifest_with(model, &[])
50}
51
52pub fn manifest_with(
55 model: &ModelConfig,
56 mcp: &[crate::mcp::McpTool],
57) -> Result<Manifest, PlaneError> {
58 Manifest::builder("speccoder", env!("CARGO_PKG_VERSION"))
59 .configure(|spec| {
60 spec.capabilities.provides.push(CAPABILITY.to_owned());
61 spec.capabilities
62 .provides
63 .push(attempt::CAPABILITY.to_owned());
64 spec.capabilities
65 .provides
66 .push(acceptance::CAPABILITY.to_owned());
67 spec.capabilities
68 .provides
69 .push(converge::CAPABILITY.to_owned());
70 spec.capabilities
71 .provides
72 .push(monitor::CAPABILITY.to_owned());
73 spec.capabilities
74 .provides
75 .push(near_miss::CAPABILITY.to_owned());
76 spec.capabilities
77 .provides
78 .push(shape::CAPABILITY.to_owned());
79 spec.capabilities.provides.push(host::CAPABILITY.to_owned());
80 spec.models = Some(Models {
81 privileged: Some(ModelRef {
82 max_tokens: Some(model.max_output_tokens),
83 ..ModelRef::new(model.provider.clone(), model.model.clone())
84 }),
85 ..Models::default()
86 });
87 spec.security.max_sensitivity_egress = Some(Sensitivity::Internal);
89 spec.budgets = Some(Budgets {
90 max_tokens: Some(model.max_run_tokens),
91 ..Budgets::default()
92 });
93 let author =
100 agentplane::model::ModelId::new(model.provider.clone(), model.model.clone())
101 .source();
102 let catalog = crate::tools::catalog();
103 let mut sources: Vec<SourceId> = vec![
104 author,
105 SourceId::new(provenance::VERIFIER),
106 SourceId::new(provenance::WORKTREE),
107 SourceId::new(provenance::PERSON),
109 ];
110 sources.extend(catalog.iter().map(|t| provenance::tool(t.server, t.name)));
111 sources.extend(mcp.iter().map(|t| provenance::tool(&t.server, &t.tool)));
112 for t in catalog.iter().chain(crate::tools::internal().iter()) {
115 let mut grant = ToolGrant {
116 description: Some(t.description.to_owned()),
117 max_sensitivity: Some(Sensitivity::Internal),
118 mutates: t.mutates,
119 ..ToolGrant::new(agentplane::tools::ToolId::new(t.server, t.name).reference())
120 };
121 grant.protected_fields = t
122 .model_fields
123 .iter()
124 .map(|f| ProtectedField::from_sources(*f, sources.clone()))
125 .collect();
126 spec.tools.push(grant);
127 }
128 for t in mcp {
129 let mut grant = ToolGrant {
130 description: Some(t.description.clone()),
131 max_sensitivity: Some(Sensitivity::Internal),
132 mutates: true,
133 ..ToolGrant::new(agentplane::tools::ToolId::new(&t.server, &t.tool).reference())
134 };
135 grant.protected_fields = if t.fields.is_empty() {
136 vec![ProtectedField::one_of("", [serde_json::json!({})])]
139 } else {
140 t.fields
141 .iter()
142 .map(|f| ProtectedField::from_sources(f.as_str(), sources.clone()))
143 .collect()
144 };
145 spec.tools.push(grant);
146 }
147 })
148 .build()
149 .map_err(|e| PlaneError(format!("speccoder manifest: {e}")))
150}
151
152#[derive(Debug, Clone, Default)]
156pub struct Hooks {
157 pub progress: Option<Arc<dyn crate::progress::Progress>>,
159 pub mcp: Vec<(String, Arc<dyn agentplane::tools::ToolClient>)>,
161 pub mcp_tools: Vec<crate::mcp::McpTool>,
163 pub format: Option<&'static dyn crate::format::Adapter>,
165 pub present: crate::approvals::PresentContext,
167}
168
169#[derive(Debug, Clone, Default)]
171pub struct RecordedCalls {
172 pub calls: Vec<RecordedCall>,
174 pub unreadable: Vec<String>,
176}
177
178#[derive(Debug, Clone)]
180pub struct RecordedCall {
181 pub arguments: Value,
183 pub at: u64,
185}
186
187#[derive(Debug)]
189pub struct Inline {
190 pub outcome: RunOutcome,
192 pub waiting: Option<agentplane::core::Task>,
194 pub always: Vec<Always>,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct Always {
201 pub task: String,
203 pub call: String,
205 pub via: speccoder_core::actions::Via,
207}
208
209pub struct Plane {
211 runtime: Arc<Runtime>,
212 pub replaced_journal: Option<std::path::PathBuf>,
214 _run: Option<RunClaim>,
216}
217
218static RUNS: std::sync::Mutex<Vec<std::path::PathBuf>> = std::sync::Mutex::new(Vec::new());
221
222struct RunClaim(std::path::PathBuf);
223
224impl RunClaim {
225 fn take(dir: &Path) -> Result<Self, PlaneError> {
226 let path = dir.join(JOURNAL);
227 let mut runs = RUNS
228 .lock()
229 .map_err(|_| PlaneError("the run registry is poisoned".to_owned()))?;
230 if runs.contains(&path) {
231 return Err(PlaneError(format!(
232 "another speccoder is working on this project (a run in this process holds its journal in {})",
233 dir.display()
234 )));
235 }
236 runs.push(path.clone());
237 Ok(Self(path))
238 }
239}
240
241impl Drop for RunClaim {
242 fn drop(&mut self) {
243 if let Ok(mut runs) = RUNS.lock() {
244 runs.retain(|p| *p != self.0);
245 }
246 }
247}
248
249static OPEN: std::sync::Mutex<Vec<(std::path::PathBuf, std::sync::Weak<RedbStore>)>> =
253 std::sync::Mutex::new(Vec::new());
254
255fn open_or_replace(dir: &Path) -> Result<(Arc<RedbStore>, Option<std::path::PathBuf>), PlaneError> {
262 std::fs::create_dir_all(dir).map_err(|e| PlaneError(format!("{}: {e}", dir.display())))?;
263 let path = dir.join(JOURNAL);
264 let mut open = OPEN
265 .lock()
266 .map_err(|_| PlaneError("the journal registry is poisoned".to_owned()))?;
267 open.retain(|(_, w)| w.strong_count() > 0);
268 if let Some(store) = open
269 .iter()
270 .find(|(p, _)| *p == path)
271 .and_then(|(_, w)| w.upgrade())
272 {
273 return Ok((store, None));
274 }
275 let (store, aside) = open_fresh(dir, &path)?;
276 open.push((path, Arc::downgrade(&store)));
277 Ok((store, aside))
278}
279
280fn open_fresh(
281 dir: &Path,
282 path: &Path,
283) -> Result<(Arc<RedbStore>, Option<std::path::PathBuf>), PlaneError> {
284 match RedbStore::open_shared(path) {
285 Ok(store) => Ok((Arc::new(store), None)),
286 Err(agentplane::core::StoreError::InUse(e)) => Err(PlaneError(format!(
288 "another speccoder is working on this project (its journal in {} is in use): {e}",
289 dir.display()
290 ))),
291 Err(agentplane::core::StoreError::Unreadable(e)) => {
294 let stamp = std::time::SystemTime::now()
295 .duration_since(std::time::UNIX_EPOCH)
296 .map_or(0, |d| d.as_secs());
297 let aside = dir.join(format!("journal.{stamp}.unreadable.redb"));
298 std::fs::rename(path, &aside).map_err(|r| {
299 PlaneError(format!(
300 "the journal is unreadable ({e}) and cannot be moved aside: {r}"
301 ))
302 })?;
303 RedbStore::open_shared(path)
304 .map(|store| (Arc::new(store), Some(aside)))
305 .map_err(|e| PlaneError(format!("cannot open a fresh journal: {e}")))
306 }
307 Err(e) => Err(PlaneError(format!(
309 "cannot open the journal in {}: {e}",
310 dir.display()
311 ))),
312 }
313}
314
315pub fn open_reader(dir: &Path) -> Result<Option<Arc<RedbStore>>, PlaneError> {
324 let path = dir.join(JOURNAL);
325 if !path.exists() {
326 return Ok(None);
327 }
328 match RedbStore::open_read_only(&path) {
329 Ok(store) => Ok(Some(Arc::new(store))),
330 Err(agentplane::core::StoreError::InUse(e)) => Err(PlaneError(format!(
331 "another process holds the journal in {} alone (a speccoder build that does not share it): {e}",
332 dir.display()
333 ))),
334 Err(agentplane::core::StoreError::NeedsRepair(e)) => Err(PlaneError(format!(
337 "the last run ended without closing its journal in {}; the next run or decision repairs it ({e})",
338 dir.display()
339 ))),
340 Err(e) => Err(PlaneError(format!(
341 "cannot read the journal in {}: {e}",
342 dir.display()
343 ))),
344 }
345}
346
347fn holds_a_pre_054_decision(value: &Value) -> bool {
350 match value {
351 Value::Object(o) => {
352 (o.contains_key("approved") && o.contains_key("decided") && !o.contains_key("verdict"))
353 || o.values().any(holds_a_pre_054_decision)
354 }
355 Value::Array(a) => a.iter().any(holds_a_pre_054_decision),
356 Value::String(s) if s.contains("\"approved\"") => {
357 serde_json::from_str::<Value>(s).is_ok_and(|v| holds_a_pre_054_decision(&v))
358 }
359 _ => false,
360 }
361}
362
363pub const PRE_054_DECISION: &str = "this attempt waited on a decision recorded by speccoder 0.2.0, which this build cannot read (agentplane 0.54 records a decision as a verdict); the attempt is set aside — run the task again to start it afresh";
365
366#[must_use]
369pub fn provider_refusal(failure: &str) -> Option<String> {
370 let start = failure.find("refused the request: ")? + "refused the request: ".len();
371 let rest = &failure[start..];
372 let end = rest
373 .find("); ")
374 .or_else(|| rest.rfind(')'))
375 .unwrap_or(rest.len());
376 Some(rest[..end].trim_end_matches(['"', ')']).trim().to_owned())
377}
378
379#[must_use]
382pub fn exhausted(limit: &agentplane::core::BudgetExceeded) -> String {
383 let said = match limit {
384 agentplane::core::BudgetExceeded::Recorded { limit, .. } => limit.clone(),
385 other => other.to_string(),
386 };
387 let tokens = limit.ceiling().map_or_else(
390 || said.starts_with("token"),
391 |c| c == agentplane::core::Ceiling::Tokens,
392 );
393 let raise = if tokens {
394 "; raise `[model] max_run_tokens`, or split the task"
395 } else {
396 ""
397 };
398 format!("the attempt's budget ran out: {said}{raise}")
399}
400
401#[must_use]
406pub fn why_not(status: &RunStatus) -> String {
407 let RunStatus::Failed(failure) = status else {
408 return status.as_str().to_owned();
409 };
410 let said = ["consumed resources and failed: ", "failed: "]
411 .iter()
412 .find_map(|m| failure.find(m).map(|i| &failure[i + m.len()..]))
413 .or_else(|| {
414 failure
415 .split_once(" of ")
416 .and_then(|(_, r)| r.split_once(": "))
417 .map(|(_, r)| r)
418 })
419 .unwrap_or(failure);
420 let cut: String = said.chars().take(300).collect();
421 if cut.len() < said.len() {
422 format!("{cut}…")
423 } else {
424 cut
425 }
426}
427
428pub async fn refused_account(
439 dir: &Path,
440) -> Result<Option<agentplane::journal::FailedEffect>, PlaneError> {
441 use agentplane::journal::JournalStore as _;
442 let Some(store) = open_reader(dir)? else {
443 return Ok(None);
444 };
445 let mut after: Option<(u64, agentplane::core::RunId)> = None;
446 loop {
447 let page = store
448 .recent_runs(after, 500)
449 .await
450 .map_err(|e| PlaneError(format!("listing the journal's runs: {e}")))?;
451 let Some(last) = page.last().cloned() else {
452 return Ok(None);
453 };
454 for (run, _) in &page {
455 let records = store
456 .read(*run, 0)
457 .await
458 .map_err(|e| PlaneError(format!("run {run}: {e}")))?;
459 if let Some(failed) = agentplane::journal::failed_effect(&records)
460 .filter(agentplane::journal::FailedEffect::refuses_the_account)
461 {
462 return Ok(Some(failed));
463 }
464 }
465 after = Some((last.1, last.0));
466 }
467}
468
469fn builder(
473 store: Arc<RedbStore>,
474 model: &ModelConfig,
475 hooks: Hooks,
476) -> Result<(agentplane::runtime::RuntimeBuilder, Manifest), PlaneError> {
477 let manifest = manifest_with(model, &hooks.mcp_tools)?;
478 let format = hooks.format.unwrap_or_else(crate::format::reference);
479 let agent = Agent::new(&manifest)
480 .skill(Propose {
481 cache_ttl: model.cache_ttl,
482 style: model.prompt_style(),
483 format,
484 })
485 .skill(attempt::Attempt {
486 style: model.prompt_style(),
487 cache_ttl: model.cache_ttl,
488 progress: hooks.progress.clone(),
489 format,
490 present: hooks.present.clone(),
491 delivered: std::sync::Arc::default(),
492 })
493 .skill(host::Host {
494 style: model.prompt_style(),
495 progress: hooks.progress,
496 format,
497 present: hooks.present,
498 })
499 .skill(acceptance::Acceptance {
500 style: model.prompt_style(),
501 cache_ttl: model.cache_ttl,
502 format,
503 })
504 .skill(converge::Converge {
505 style: model.prompt_style(),
506 cache_ttl: model.cache_ttl,
507 format,
508 })
509 .skill(monitor::Monitor {
510 style: model.prompt_style(),
511 cache_ttl: model.cache_ttl,
512 format,
513 })
514 .skill(near_miss::NearMiss {
515 style: model.prompt_style(),
516 cache_ttl: model.cache_ttl,
517 format,
518 })
519 .skill(shape::Shape {
520 style: model.prompt_style(),
521 cache_ttl: model.cache_ttl,
522 });
523 let builder = Runtime::builder_on(store).agent(agent);
526 Ok((builder, manifest))
527}
528
529fn build(
530 store: Arc<RedbStore>,
531 model: &ModelConfig,
532 provider: Arc<dyn ModelProvider>,
533 tools: Option<Arc<crate::tools::WorkspaceTools>>,
534 hooks: Hooks,
535) -> Result<Arc<Runtime>, PlaneError> {
536 let mcp = hooks.mcp.clone();
537 let (mut builder, _) = builder(store, model, hooks)?;
538 builder = builder.provider(model.provider.clone(), provider);
539 let client: Arc<dyn agentplane::tools::ToolClient> = match tools {
542 Some(t) => t,
543 None => Arc::new(Unbound),
544 };
545 for server in ["fs", "proc", "git"] {
546 builder = builder.tool_server(server, Arc::clone(&client));
547 }
548 for (server, client) in &mcp {
549 builder = builder.tool_server(server.as_str(), Arc::clone(client));
550 }
551 builder
552 .try_build()
553 .map_err(|e| PlaneError(format!("building the plane: {e}")))
554}
555
556impl Plane {
557 pub fn open(
560 dir: &Path,
561 model: &ModelConfig,
562 provider: Arc<dyn ModelProvider>,
563 ) -> Result<Self, PlaneError> {
564 Self::open_with_tools(dir, model, provider, None)
565 }
566
567 pub fn open_with_tools(
569 dir: &Path,
570 model: &ModelConfig,
571 provider: Arc<dyn ModelProvider>,
572 tools: Option<Arc<crate::tools::WorkspaceTools>>,
573 ) -> Result<Self, PlaneError> {
574 Self::open_with(dir, model, provider, tools, Hooks::default())
575 }
576
577 pub fn open_with(
579 dir: &Path,
580 model: &ModelConfig,
581 provider: Arc<dyn ModelProvider>,
582 tools: Option<Arc<crate::tools::WorkspaceTools>>,
583 hooks: Hooks,
584 ) -> Result<Self, PlaneError> {
585 let claim = RunClaim::take(dir)?;
586 let (store, replaced_journal) = open_or_replace(dir)?;
587 Ok(Self {
588 runtime: build(store, model, provider, tools, hooks)?,
589 replaced_journal,
590 _run: Some(claim),
591 })
592 }
593
594 pub fn open_for_decisions(dir: &Path) -> Result<Self, PlaneError> {
598 let (store, replaced_journal) = open_or_replace(dir)?;
599 let runtime = Runtime::builder_on(store)
600 .try_build()
601 .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
602 Ok(Self {
603 runtime,
604 replaced_journal,
605 _run: None,
606 })
607 }
608
609 pub fn open_for_reading(dir: &Path) -> Result<Option<Self>, PlaneError> {
618 let Some(store) = open_reader(dir)? else {
619 return Ok(None);
620 };
621 let runtime = Runtime::builder_on(store)
622 .try_build()
623 .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
624 Ok(Some(Self {
625 runtime,
626 replaced_journal: None,
627 _run: None,
628 }))
629 }
630
631 pub async fn expire_due(&self, now: agentplane::core::Timestamp) -> Result<(), PlaneError> {
636 self.runtime
637 .sweep(now, std::time::Duration::from_secs(7 * 86_400))
638 .await
639 .map(|_| ())
640 .map_err(|e| PlaneError(e.to_string()))
641 }
642
643 pub async fn withdraw(
646 &self,
647 task: &agentplane::core::Task,
648 by: &agentplane::core::Operator,
649 reason: &str,
650 ) -> Result<(), PlaneError> {
651 let err = |e: String| PlaneError(e);
652 match self.runtime.request_cancel(task.run, by, reason).await {
653 Ok(_) => {}
654 Err(agentplane::core::RuntimeError::CannotUnwind { .. }) => self
657 .runtime
658 .record_quarantine_decision(
659 task.run,
660 by,
661 reason,
662 agentplane::core::QuarantineDecision::Abandon,
663 )
664 .await
665 .map_err(|e| err(e.to_string()))?,
666 Err(e) => return Err(err(e.to_string())),
667 }
668 if let Some(tasks) = self.runtime.tasks() {
669 tasks
670 .withdraw_run(task.run, &[task.id])
671 .await
672 .map_err(|e| err(e.to_string()))?;
673 }
674 Ok(())
675 }
676
677 pub async fn waiting_on(
679 &self,
680 outcome: &RunOutcome,
681 ) -> Result<Option<agentplane::core::Task>, PlaneError> {
682 let RunStatus::Suspended(reason) = &outcome.status else {
683 return Ok(None);
684 };
685 let Some(id) = reason.awaited_task() else {
686 return Ok(None);
687 };
688 self.task(id).await
689 }
690
691 pub async fn task(
693 &self,
694 id: agentplane::core::TaskId,
695 ) -> Result<Option<agentplane::core::Task>, PlaneError> {
696 let Some(tasks) = self.runtime.tasks() else {
697 return Ok(None);
698 };
699 tasks.task(id).await.map_err(|e| PlaneError(e.to_string()))
700 }
701
702 pub async fn tool_calls(&self, server: &str, tool: &str) -> Result<RecordedCalls, PlaneError> {
707 let journal = self.runtime.journal();
708 let mut out = RecordedCalls::default();
709 let mut after: Option<(u64, agentplane::core::RunId)> = None;
710 loop {
711 let page = journal
712 .recent_runs(after, 500)
713 .await
714 .map_err(|e| PlaneError(format!("listing the journal's runs: {e}")))?;
715 let Some(last) = page.last().cloned() else {
716 break;
717 };
718 for (run, at) in &page {
719 let records = match journal.read(*run, 0).await {
720 Ok(r) => r,
721 Err(e) => {
722 out.unreadable.push(format!("run {run}: {e}"));
723 continue;
724 }
725 };
726 let mut seen = std::collections::BTreeSet::new();
727 for record in records {
728 let agentplane::RecordKind::EffectStarted { descriptor, .. } = record.kind()
729 else {
730 continue;
731 };
732 let args = &descriptor.args;
733 if descriptor.kind != "tool.call"
734 || args["server"] != server
735 || args["tool"] != tool
736 {
737 continue;
738 }
739 let key = record
741 .body
742 .effect_key
743 .as_ref()
744 .map_or_else(|| record.body.seq.to_string(), |k| format!("{k:?}"));
745 if seen.insert(key) {
746 out.calls.push(RecordedCall {
747 arguments: args["arguments"].clone(),
748 at: *at,
749 });
750 }
751 }
752 }
753 after = Some((last.1, last.0));
754 }
755 Ok(out)
756 }
757
758 pub async fn holds_a_pre_054_decision(&self, run: agentplane::core::RunId) -> bool {
763 let Ok(records) = self.runtime.journal().read(run, 0).await else {
764 return false;
765 };
766 records.iter().any(|r| {
767 serde_json::to_value(r.kind())
768 .ok()
769 .is_some_and(|v| holds_a_pre_054_decision(&v))
770 })
771 }
772
773 pub async fn open_tasks(&self) -> Result<Vec<agentplane::core::Task>, PlaneError> {
775 let Some(tasks) = self.runtime.tasks() else {
776 return Ok(Vec::new());
777 };
778 let mut open = tasks
779 .queue(&[], 1000)
780 .await
781 .map_err(|e| PlaneError(e.to_string()))?;
782 open.sort_by_key(|t| t.created_at);
783 Ok(open)
784 }
785
786 pub async fn decide(
789 &self,
790 task: agentplane::core::TaskId,
791 decision: &agentplane::core::Decision,
792 ) -> Result<agentplane::core::Delivery, PlaneError> {
793 self.decide_at(task, decision, None).await
794 }
795
796 pub async fn decide_at(
799 &self,
800 task: agentplane::core::TaskId,
801 decision: &agentplane::core::Decision,
802 expected: Option<agentplane::core::Digest>,
803 ) -> Result<agentplane::core::Delivery, PlaneError> {
804 self.runtime
805 .decide_task_at(task, decision, &[], expected)
806 .await
807 .map_err(|e| PlaneError(e.to_string()))
808 }
809
810 pub async fn decide_inline(
818 &self,
819 mut outcome: RunOutcome,
820 approver: Option<&dyn crate::approvals::Approver>,
821 context: &crate::approvals::PresentContext,
822 ) -> Result<Inline, PlaneError> {
823 let mut always = Vec::new();
824 while let Some(task) = self.waiting_on(&outcome).await? {
825 let Some((decision, answer)) =
826 approver.and_then(|a| crate::approvals::tasks::ask(a, &task, context))
827 else {
828 return Ok(Inline {
829 outcome,
830 waiting: Some(task),
831 always,
832 });
833 };
834 self.decide(task.id, &decision).await?;
835 if answer.always && decision.approved() {
836 let via = answer
837 .by
838 .as_ref()
839 .and_then(|b| speccoder_core::actions::Via::read(&b.via))
840 .map_or(speccoder_core::actions::Via::Terminal, |(v, _)| v);
841 crate::approvals::answered_always(&task.id.to_hex());
842 always.push(Always {
843 task: crate::approvals::tasks::request_of(&task.justification.proposed_action)
844 .map(|r| r.task)
845 .unwrap_or_default(),
846 call: task.id.to_hex(),
847 via,
848 });
849 }
850 match self.outcome(outcome.run_id).await? {
851 Some(o) => outcome = o,
852 None => break,
853 }
854 }
855 Ok(Inline {
856 outcome,
857 waiting: None,
858 always,
859 })
860 }
861
862 pub async fn outcome(
866 &self,
867 run: agentplane::core::RunId,
868 ) -> Result<Option<RunOutcome>, PlaneError> {
869 if self
870 .runtime
871 .recorded_outcome(run)
872 .await
873 .map_err(|e| PlaneError(e.to_string()))?
874 .is_none()
875 {
876 return Ok(None);
877 }
878 self.runtime
879 .replay(run, agentplane::runtime::Mode::Strict)
880 .await
881 .map(Some)
882 .map_err(|e| PlaneError(e.to_string()))
883 }
884
885 #[doc(hidden)]
888 pub fn on_store(
889 store: Arc<RedbStore>,
890 model: &ModelConfig,
891 provider: Arc<dyn ModelProvider>,
892 tools: Option<Arc<crate::tools::WorkspaceTools>>,
893 ) -> Result<Self, PlaneError> {
894 Ok(Self {
895 runtime: build(store, model, provider, tools, Hooks::default())?,
896 replaced_journal: None,
897 _run: None,
898 })
899 }
900
901 #[doc(hidden)]
904 pub async fn resume(
905 &self,
906 run: agentplane::core::RunId,
907 ) -> Result<RunOutcome, agentplane::RuntimeError> {
908 self.runtime
909 .replay(run, agentplane::runtime::Mode::Resume)
910 .await
911 }
912
913 pub async fn converge(
915 &self,
916 feature: &str,
917 input: Tainted<Value>,
918 ) -> Result<RunOutcome, PlaneError> {
919 self.runtime
920 .start(converge::CAPABILITY, input)
921 .terms(RunTerms::default().correlated(
922 "feature",
923 &[CorrelationKey::new("feature", feature.to_owned())],
924 ))
925 .run()
926 .await
927 .map_err(|e| PlaneError(e.to_string()))
928 }
929
930 pub async fn shape(&self, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
933 self.runtime
934 .start(shape::CAPABILITY, input)
935 .run()
936 .await
937 .map_err(|e| PlaneError(e.to_string()))
938 }
939
940 pub async fn skill(&self, name: &str, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
942 self.runtime
943 .start(host::CAPABILITY, input)
944 .terms(
945 RunTerms::default()
946 .correlated("skill", &[CorrelationKey::new("skill", name.to_owned())]),
947 )
948 .run()
949 .await
950 .map_err(|e| PlaneError(e.to_string()))
951 }
952
953 pub async fn monitor(
957 &self,
958 feature: &str,
959 task: &str,
960 input: Tainted<Value>,
961 ) -> Result<RunOutcome, PlaneError> {
962 self.judge_once(monitor::CAPABILITY, feature, task, input)
963 .await
964 }
965
966 pub async fn near_miss(
970 &self,
971 feature: &str,
972 task: &str,
973 input: Tainted<Value>,
974 ) -> Result<RunOutcome, PlaneError> {
975 self.judge_once(near_miss::CAPABILITY, feature, task, input)
976 .await
977 }
978
979 async fn judge_once(
983 &self,
984 capability: &str,
985 feature: &str,
986 task: &str,
987 input: Tainted<Value>,
988 ) -> Result<RunOutcome, PlaneError> {
989 use agentplane::runtime::{Admission, Mode};
990 use sha2::Digest as _;
991 let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
992 let digest = sha2::Sha256::digest(
993 format!("{capability}\n{feature}/{task}\n{}", input.peek()).as_bytes(),
994 );
995 let key: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect();
996 let key = format!("judge-{key}");
997 let key = key.as_str();
998 let terms = || {
999 RunTerms::default().correlated(
1000 "task",
1001 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1002 )
1003 };
1004 let admitted = self
1005 .runtime
1006 .start(capability, input.clone())
1007 .terms(terms())
1008 .once(key)
1009 .run()
1010 .await
1011 .map_err(err)?;
1012 match admitted {
1013 Admission::Fresh(o) => Ok(o),
1014 Admission::Replayed(o) if !matches!(o.status, RunStatus::Succeeded) => self
1017 .runtime
1018 .start(capability, input)
1019 .terms(terms())
1020 .run()
1021 .await
1022 .map_err(err),
1023 Admission::Replayed(o) => self
1024 .runtime
1025 .replay(o.run_id, Mode::Strict)
1026 .await
1027 .map_err(err),
1028 Admission::InFlight(run) => self.runtime.replay(run, Mode::Resume).await.map_err(err),
1029 }
1030 }
1031
1032 pub async fn acceptance(
1034 &self,
1035 feature: &str,
1036 task: &str,
1037 input: Tainted<Value>,
1038 ) -> Result<RunOutcome, PlaneError> {
1039 self.runtime
1040 .start(acceptance::CAPABILITY, input)
1041 .terms(RunTerms::default().correlated(
1042 "task",
1043 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1044 ))
1045 .run()
1046 .await
1047 .map_err(|e| PlaneError(e.to_string()))
1048 }
1049
1050 pub async fn attempt(
1052 &self,
1053 feature: &str,
1054 task: &str,
1055 input: Tainted<Value>,
1056 ) -> Result<RunOutcome, PlaneError> {
1057 self.runtime
1058 .start(attempt::CAPABILITY, input)
1059 .terms(RunTerms::default().correlated(
1060 "task",
1061 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1062 ))
1063 .run()
1064 .await
1065 .map_err(|e| PlaneError(e.to_string()))
1066 }
1067
1068 pub async fn attempt_keyed(
1076 &self,
1077 feature: &str,
1078 task: &str,
1079 input: Tainted<Value>,
1080 key: &str,
1081 ) -> Result<(RunOutcome, bool), PlaneError> {
1082 use agentplane::runtime::{Admission, Mode};
1083 let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
1084 let admitted = self
1085 .runtime
1086 .start(attempt::CAPABILITY, input)
1087 .terms(RunTerms::default().correlated(
1088 "task",
1089 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1090 ))
1091 .once(key)
1092 .run()
1093 .await
1094 .map_err(err)?;
1095 match admitted {
1096 Admission::Fresh(o) => Ok((o, false)),
1097 Admission::Replayed(o) if matches!(o.status, RunStatus::Suspended(_)) => self
1102 .runtime
1103 .replay(o.run_id, Mode::Resume)
1104 .await
1105 .map(|o| (o, true))
1106 .map_err(err),
1107 Admission::Replayed(o) => self
1108 .runtime
1109 .replay(o.run_id, Mode::Strict)
1110 .await
1111 .map(|o| (o, true))
1112 .map_err(err),
1113 Admission::InFlight(run) => self
1114 .runtime
1115 .replay(run, Mode::Resume)
1116 .await
1117 .map(|o| (o, true))
1118 .map_err(err),
1119 }
1120 }
1121
1122 pub async fn propose(
1124 &self,
1125 feature: &str,
1126 task: &str,
1127 input: Tainted<Value>,
1128 ) -> Result<RunOutcome, PlaneError> {
1129 self.runtime
1130 .start(CAPABILITY, input)
1131 .terms(RunTerms::default().correlated(
1132 "task",
1133 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1134 ))
1135 .run()
1136 .await
1137 .map_err(|e| PlaneError(e.to_string()))
1138 }
1139}
1140
1141pub async fn admitted(
1144 dir: &Path,
1145 run: &str,
1146) -> Result<Option<agentplane::core::Tainted<Value>>, PlaneError> {
1147 let id = agentplane::core::RunId::parse(run)
1148 .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
1149 let Some(store) = open_reader(dir)? else {
1150 return Ok(None);
1151 };
1152 let history = store
1153 .read(id, 1)
1154 .await
1155 .map_err(|e| PlaneError(e.to_string()))?;
1156 Ok(history
1157 .iter()
1158 .find_map(agentplane::journal::Record::admitted_input))
1159}
1160
1161pub async fn replay_strict(
1169 dir: &Path,
1170 model: &ModelConfig,
1171 run: &str,
1172 format: &'static dyn crate::format::Adapter,
1173) -> Result<Option<agentplane::runtime::Verdict>, PlaneError> {
1174 let id = agentplane::core::RunId::parse(run)
1175 .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
1176 let Some(store) = open_reader(dir)? else {
1177 return Ok(None);
1178 };
1179 let history = store
1180 .read(id, 1)
1181 .await
1182 .map_err(|e| PlaneError(e.to_string()))?;
1183 if history.is_empty() {
1184 return Ok(None);
1185 }
1186 if history.iter().any(|r| {
1189 serde_json::to_value(r.kind())
1190 .ok()
1191 .is_some_and(|v| holds_a_pre_054_decision(&v))
1192 }) {
1193 return Err(PlaneError(format!(
1194 "run {run} read a person's decision recorded by speccoder 0.2.0, which this build cannot replay (agentplane 0.54 records a decision as a verdict); its ticks and attempt refs in git are unaffected"
1195 )));
1196 }
1197 let mcp_tools: Vec<crate::mcp::McpTool> = history
1201 .iter()
1202 .find_map(agentplane::journal::Record::admitted_input)
1203 .and_then(|a| serde_json::from_value(a.peek()["settings"]["mcp"].clone()).ok())
1204 .unwrap_or_default();
1205 let hooks = Hooks {
1206 mcp_tools,
1207 format: Some(format),
1208 ..Hooks::default()
1209 };
1210 let (builder, manifest) = builder(store, model, hooks)?;
1211 let runtime = agentplane::runtime::replay_only::wire(builder, &[manifest], &history)
1212 .try_build()
1213 .map_err(|e| PlaneError(format!("building the replay plane: {e}")))?;
1214 runtime
1215 .verify(id)
1216 .await
1217 .map(Some)
1218 .map_err(|e| PlaneError(e.to_string()))
1219}
1220
1221#[must_use]
1226pub fn said(completion: &agentplane::model::Completion) -> Option<String> {
1227 let commentary = completion.commentary.as_deref().map(str::trim);
1228 let text = (!completion.tool_calls.is_empty()).then(|| completion.text.trim());
1229 let parts: Vec<&str> = [commentary, text]
1230 .into_iter()
1231 .flatten()
1232 .filter(|t| !t.is_empty())
1233 .collect();
1234 (!parts.is_empty()).then(|| parts.join("\n\n"))
1235}
1236
1237#[must_use]
1241pub fn answered(completion: &agentplane::model::Completion) -> Option<String> {
1242 if let Some(blocked) = completion
1243 .tool_calls
1244 .iter()
1245 .find(|c| c.name == attempt::REPORT_BLOCKED)
1246 {
1247 let reason = blocked.arguments["reason"].as_str().unwrap_or_default();
1248 return Some(match blocked.arguments["kind"].as_str() {
1249 Some(kind) => format!("Blocked ({kind}): {reason}"),
1250 None => format!("Blocked: {reason}"),
1251 });
1252 }
1253 if !completion.tool_calls.is_empty() {
1254 return None;
1255 }
1256 let summary = completion
1257 .structured
1258 .as_ref()
1259 .and_then(|s| s["summary"].as_str());
1260 let text = summary.unwrap_or(&completion.text).trim();
1261 (!text.is_empty()).then(|| text.to_owned())
1262}
1263
1264#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1268pub struct AccountTurn {
1269 pub turn: u32,
1271 pub reasoning: Option<String>,
1273 pub said: Option<String>,
1275 pub answer: Option<String>,
1277}
1278
1279pub async fn account(dir: &Path, run: &str) -> Result<Vec<AccountTurn>, PlaneError> {
1286 let Ok(id) = agentplane::core::RunId::parse(run) else {
1287 return Err(PlaneError(format!("{run} is not a run id")));
1288 };
1289 let lanes = std::fs::read_dir(dir.join("lanes"))
1293 .into_iter()
1294 .flatten()
1295 .flatten()
1296 .map(|e| e.path());
1297 let mut any = false;
1298 let mut history = Vec::new();
1299 for d in std::iter::once(dir.to_path_buf()).chain(lanes) {
1300 let Some(store) = open_reader(&d)? else {
1301 continue;
1302 };
1303 any = true;
1304 history = store
1305 .read(id, 1)
1306 .await
1307 .map_err(|e| PlaneError(e.to_string()))?;
1308 if !history.is_empty() {
1309 break;
1310 }
1311 }
1312 if !any {
1313 return Err(PlaneError(
1314 "no journal of this project's runs is here".to_owned(),
1315 ));
1316 }
1317 if history.is_empty() {
1318 return Err(PlaneError(format!(
1319 "run {run} is not in this project's journal"
1320 )));
1321 }
1322 let text = |t: &str| {
1323 let t = t.trim();
1324 (!t.is_empty()).then(|| crate::approvals::present::escape(t))
1325 };
1326 Ok(agentplane::model::turns(&history)
1327 .into_iter()
1328 .zip(1u32..)
1329 .filter_map(|(t, turn)| {
1330 let c = t.completion?;
1331 let said = if c.tool_calls.is_empty() {
1334 c.commentary.as_deref().and_then(text)
1335 } else {
1336 said(&c).as_deref().and_then(text)
1337 };
1338 let reasoning = c.reasoning.as_deref().and_then(text);
1339 let answer = answered(&c).as_deref().and_then(text);
1340 (said.is_some() || reasoning.is_some() || answer.is_some()).then_some(AccountTurn {
1341 turn,
1342 reasoning,
1343 said,
1344 answer,
1345 })
1346 })
1347 .collect())
1348}
1349
1350pub async fn spent(dir: &Path, run: &str) -> Result<Option<Value>, PlaneError> {
1358 let Ok(id) = agentplane::core::RunId::parse(run) else {
1359 return Ok(None);
1360 };
1361 let Some(store) = open_reader(dir)? else {
1362 return Ok(None);
1363 };
1364 let history = store
1365 .read(id, 1)
1366 .await
1367 .map_err(|e| PlaneError(e.to_string()))?;
1368 let used: Vec<agentplane::model::Usage> = agentplane::model::turns(&history)
1369 .into_iter()
1370 .filter_map(|t| t.completion.map(|c| c.usage))
1371 .collect();
1372 Ok((!used.is_empty()).then(|| {
1373 let sum = |f: fn(&agentplane::model::Usage) -> u64| used.iter().map(f).sum::<u64>();
1374 serde_json::json!({
1375 "input_tokens": sum(|u| u.input_tokens),
1376 "output_tokens": sum(|u| u.output_tokens),
1377 "cache_read_tokens": sum(|u| u.cache_read_tokens),
1378 "cache_write_tokens": sum(|u| u.cache_write_tokens),
1379 })
1380 }))
1381}
1382
1383#[derive(Debug)]
1385struct Unbound;
1386
1387#[async_trait]
1388impl agentplane::tools::ToolClient for Unbound {
1389 async fn call(
1390 &self,
1391 tool: &agentplane::tools::ToolId,
1392 _arguments: &Value,
1393 _provenance: Option<&agentplane::core::Provenance>,
1394 ) -> Result<Value, agentplane::tools::ToolError> {
1395 Err(agentplane::tools::ToolError::Refused {
1396 tool: tool.clone(),
1397 detail: "no worktree is bound to this plane".to_owned(),
1398 })
1399 }
1400
1401 fn destination(&self, _tool: &agentplane::tools::ToolId) -> agentplane::tools::Destination {
1402 agentplane::tools::Destination::Local
1403 }
1404}
1405
1406#[cfg(test)]
1407mod tests {
1408 use super::*;
1409
1410 #[test]
1411 fn the_agentplane_version_named_in_summaries_is_the_pinned_one() {
1412 let workspace = include_str!("../../../../Cargo.toml");
1413 assert!(
1414 workspace.contains(&format!(
1415 "agentplane = {{ version = \"={AGENTPLANE_VERSION}\""
1416 )),
1417 "AGENTPLANE_VERSION is not the workspace's pin"
1418 );
1419 }
1420
1421 #[test]
1422 fn an_attempt_ends_in_its_answer_s_summary_or_why_it_stopped() {
1423 use agentplane::model::{Completion, ToolCall};
1424 let report = serde_json::json!({ "summary": "Added the parser.", "status": "done" });
1425 let done = Completion {
1426 text: report.to_string(),
1427 structured: Some(report),
1428 ..Completion::default()
1429 };
1430 assert_eq!(answered(&done).as_deref(), Some("Added the parser."));
1431 let plain = Completion {
1432 text: " All done. ".to_owned(),
1433 ..Completion::default()
1434 };
1435 assert_eq!(answered(&plain).as_deref(), Some("All done."));
1436 let call = |name: &str, arguments: Value| Completion {
1437 tool_calls: vec![ToolCall {
1438 id: "c".to_owned(),
1439 name: name.to_owned(),
1440 arguments,
1441 }],
1442 ..Completion::default()
1443 };
1444 let blocked = call(
1445 attempt::REPORT_BLOCKED,
1446 serde_json::json!({ "kind": "wrong_test", "evidence": "tests/a.py", "reason": "it asserts the old format" }),
1447 );
1448 assert_eq!(
1449 answered(&blocked).as_deref(),
1450 Some("Blocked (wrong_test): it asserts the old format")
1451 );
1452 assert_eq!(answered(&call("read_file", serde_json::json!({}))), None);
1453 }
1454
1455 #[test]
1456 fn a_provider_refusal_is_read_from_the_run_failure() {
1457 let failure = "Failed(\"effect ek:21 was refused on attempt 1, and the refusal is an answer rather than a fault (effect refused: 'openai/gpt-5.4-mini' refused the request: credit_balance_exhausted: You have no credits remaining. Add credits at https://platform.openai.com/settings/organization/billing/.); no retry would change it\")";
1458 let r = provider_refusal(failure).unwrap();
1459 assert_eq!(
1460 r,
1461 "credit_balance_exhausted: You have no credits remaining. Add credits at https://platform.openai.com/settings/organization/billing/."
1462 );
1463 let own = "effect ek:21 was refused on attempt 1, and the refusal is an answer rather than a fault ('openai/gpt-5.4-mini' refused the request: insufficient_quota: You exceeded your current quota.); no retry would change it";
1465 assert_eq!(
1466 provider_refusal(own).as_deref(),
1467 Some("insufficient_quota: You exceeded your current quota.")
1468 );
1469 assert_eq!(provider_refusal("Failed(\"timeout\")"), None);
1470 }
1471
1472 #[test]
1473 fn a_failed_run_says_why_in_the_failure_s_own_words() {
1474 let unusable = RunStatus::Failed("effect ek:774b failed on attempt 1 of 1: effect consumed resources and failed: 'openai/gpt-5.4-mini' returned an unusable answer: a schema was required and the answer is not JSON: trailing characters at line 1 column 2531".to_owned());
1475 assert_eq!(
1476 why_not(&unusable),
1477 "'openai/gpt-5.4-mini' returned an unusable answer: a schema was required and the answer is not JSON: trailing characters at line 1 column 2531"
1478 );
1479 let timeout =
1480 RunStatus::Failed("effect ek:9 failed on attempt 2 of 2: timed out".to_owned());
1481 assert_eq!(why_not(&timeout), "timed out");
1482 let own = RunStatus::Failed("effect ek:774b failed on attempt 1 of 1: 'openai/gpt-5.4-mini' returned an unusable answer: not JSON".to_owned());
1483 assert_eq!(
1484 why_not(&own),
1485 "'openai/gpt-5.4-mini' returned an unusable answer: not JSON"
1486 );
1487 assert_eq!(why_not(&RunStatus::Succeeded), "succeeded");
1488 }
1489
1490 #[test]
1491 fn an_unreadable_journal_is_moved_aside_and_a_locked_one_is_an_error() {
1492 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1493 std::fs::write(dir.path().join(JOURNAL), b"written by another format")
1494 .unwrap_or_else(|e| panic!("{e}"));
1495 let (store, aside) = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1496 let aside = aside.unwrap_or_else(|| panic!("moved aside"));
1497 assert!(aside.exists() && dir.path().join(JOURNAL).exists());
1498 let shared = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1500 assert!(Arc::ptr_eq(&store, &shared.0) && shared.1.is_none());
1501 drop((store, shared));
1502 let held = RedbStore::open(dir.path().join(JOURNAL)).unwrap_or_else(|e| panic!("{e}"));
1505 let started = std::time::Instant::now();
1506 let second = open_or_replace(dir.path())
1507 .err()
1508 .map(|e| e.0)
1509 .unwrap_or_default();
1510 assert!(second.contains("another speccoder"), "{second}");
1511 assert!(started.elapsed() < std::time::Duration::from_millis(500));
1512 assert!(open_reader(dir.path()).is_err());
1515 drop(held);
1516 assert!(
1517 open_or_replace(dir.path())
1518 .unwrap_or_else(|e| panic!("{}", e.0))
1519 .1
1520 .is_none()
1521 );
1522 }
1523
1524 #[test]
1525 fn a_reader_creates_nothing_and_reads_beside_this_process_s_writer() {
1526 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1527 assert!(
1529 open_reader(dir.path())
1530 .unwrap_or_else(|e| panic!("{}", e.0))
1531 .is_none()
1532 );
1533 assert!(
1534 Plane::open_for_reading(dir.path())
1535 .unwrap_or_else(|e| panic!("{}", e.0))
1536 .is_none()
1537 );
1538 assert!(
1539 !dir.path().join(JOURNAL).exists(),
1540 "a read created a journal"
1541 );
1542 let model =
1545 model_config::parse("[model]\nprovider = \"openai\"\nmodel = \"gpt-5.4-mini\"\n")
1546 .unwrap_or_else(|e| panic!("{e}"))
1547 .unwrap_or_else(|| panic!("no model"));
1548 let fake: Arc<dyn ModelProvider> = agentplane::model::fake::FakeProvider::new();
1549 let run = Plane::open(dir.path(), &model, Arc::clone(&fake))
1550 .unwrap_or_else(|e| panic!("{}", e.0));
1551 let reader = open_reader(dir.path())
1552 .unwrap_or_else(|e| panic!("{}", e.0))
1553 .unwrap_or_else(|| panic!("no journal"));
1554 assert!(reader.is_read_only());
1555 assert!(Plane::open(dir.path(), &model, fake).is_err());
1556 drop(run);
1557 }
1558}