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 })
492 .skill(host::Host {
493 style: model.prompt_style(),
494 progress: hooks.progress,
495 format,
496 present: hooks.present,
497 })
498 .skill(acceptance::Acceptance {
499 style: model.prompt_style(),
500 cache_ttl: model.cache_ttl,
501 format,
502 })
503 .skill(converge::Converge {
504 style: model.prompt_style(),
505 cache_ttl: model.cache_ttl,
506 format,
507 })
508 .skill(monitor::Monitor {
509 style: model.prompt_style(),
510 cache_ttl: model.cache_ttl,
511 format,
512 })
513 .skill(near_miss::NearMiss {
514 style: model.prompt_style(),
515 cache_ttl: model.cache_ttl,
516 format,
517 })
518 .skill(shape::Shape {
519 style: model.prompt_style(),
520 cache_ttl: model.cache_ttl,
521 });
522 let builder = Runtime::builder_on(store).agent(agent);
525 Ok((builder, manifest))
526}
527
528fn build(
529 store: Arc<RedbStore>,
530 model: &ModelConfig,
531 provider: Arc<dyn ModelProvider>,
532 tools: Option<Arc<crate::tools::WorkspaceTools>>,
533 hooks: Hooks,
534) -> Result<Arc<Runtime>, PlaneError> {
535 let mcp = hooks.mcp.clone();
536 let (mut builder, _) = builder(store, model, hooks)?;
537 builder = builder.provider(model.provider.clone(), provider);
538 let client: Arc<dyn agentplane::tools::ToolClient> = match tools {
541 Some(t) => t,
542 None => Arc::new(Unbound),
543 };
544 for server in ["fs", "proc", "git"] {
545 builder = builder.tool_server(server, Arc::clone(&client));
546 }
547 for (server, client) in &mcp {
548 builder = builder.tool_server(server.as_str(), Arc::clone(client));
549 }
550 builder
551 .try_build()
552 .map_err(|e| PlaneError(format!("building the plane: {e}")))
553}
554
555impl Plane {
556 pub fn open(
559 dir: &Path,
560 model: &ModelConfig,
561 provider: Arc<dyn ModelProvider>,
562 ) -> Result<Self, PlaneError> {
563 Self::open_with_tools(dir, model, provider, None)
564 }
565
566 pub fn open_with_tools(
568 dir: &Path,
569 model: &ModelConfig,
570 provider: Arc<dyn ModelProvider>,
571 tools: Option<Arc<crate::tools::WorkspaceTools>>,
572 ) -> Result<Self, PlaneError> {
573 Self::open_with(dir, model, provider, tools, Hooks::default())
574 }
575
576 pub fn open_with(
578 dir: &Path,
579 model: &ModelConfig,
580 provider: Arc<dyn ModelProvider>,
581 tools: Option<Arc<crate::tools::WorkspaceTools>>,
582 hooks: Hooks,
583 ) -> Result<Self, PlaneError> {
584 let claim = RunClaim::take(dir)?;
585 let (store, replaced_journal) = open_or_replace(dir)?;
586 Ok(Self {
587 runtime: build(store, model, provider, tools, hooks)?,
588 replaced_journal,
589 _run: Some(claim),
590 })
591 }
592
593 pub fn open_for_decisions(dir: &Path) -> Result<Self, PlaneError> {
597 let (store, replaced_journal) = open_or_replace(dir)?;
598 let runtime = Runtime::builder_on(store)
599 .try_build()
600 .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
601 Ok(Self {
602 runtime,
603 replaced_journal,
604 _run: None,
605 })
606 }
607
608 pub fn open_for_reading(dir: &Path) -> Result<Option<Self>, PlaneError> {
617 let Some(store) = open_reader(dir)? else {
618 return Ok(None);
619 };
620 let runtime = Runtime::builder_on(store)
621 .try_build()
622 .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
623 Ok(Some(Self {
624 runtime,
625 replaced_journal: None,
626 _run: None,
627 }))
628 }
629
630 pub async fn expire_due(&self, now: agentplane::core::Timestamp) -> Result<(), PlaneError> {
635 self.runtime
636 .sweep(now, std::time::Duration::from_secs(7 * 86_400))
637 .await
638 .map(|_| ())
639 .map_err(|e| PlaneError(e.to_string()))
640 }
641
642 pub async fn withdraw(
645 &self,
646 task: &agentplane::core::Task,
647 by: &agentplane::core::Operator,
648 reason: &str,
649 ) -> Result<(), PlaneError> {
650 let err = |e: String| PlaneError(e);
651 match self.runtime.request_cancel(task.run, by, reason).await {
652 Ok(_) => {}
653 Err(agentplane::core::RuntimeError::CannotUnwind { .. }) => self
656 .runtime
657 .record_quarantine_decision(
658 task.run,
659 by,
660 reason,
661 agentplane::core::QuarantineDecision::Abandon,
662 )
663 .await
664 .map_err(|e| err(e.to_string()))?,
665 Err(e) => return Err(err(e.to_string())),
666 }
667 if let Some(tasks) = self.runtime.tasks() {
668 tasks
669 .withdraw_run(task.run, &[task.id])
670 .await
671 .map_err(|e| err(e.to_string()))?;
672 }
673 Ok(())
674 }
675
676 pub async fn waiting_on(
678 &self,
679 outcome: &RunOutcome,
680 ) -> Result<Option<agentplane::core::Task>, PlaneError> {
681 let RunStatus::Suspended(reason) = &outcome.status else {
682 return Ok(None);
683 };
684 let Some(id) = reason.awaited_task() else {
685 return Ok(None);
686 };
687 self.task(id).await
688 }
689
690 pub async fn task(
692 &self,
693 id: agentplane::core::TaskId,
694 ) -> Result<Option<agentplane::core::Task>, PlaneError> {
695 let Some(tasks) = self.runtime.tasks() else {
696 return Ok(None);
697 };
698 tasks.task(id).await.map_err(|e| PlaneError(e.to_string()))
699 }
700
701 pub async fn tool_calls(&self, server: &str, tool: &str) -> Result<RecordedCalls, PlaneError> {
706 let journal = self.runtime.journal();
707 let mut out = RecordedCalls::default();
708 let mut after: Option<(u64, agentplane::core::RunId)> = None;
709 loop {
710 let page = journal
711 .recent_runs(after, 500)
712 .await
713 .map_err(|e| PlaneError(format!("listing the journal's runs: {e}")))?;
714 let Some(last) = page.last().cloned() else {
715 break;
716 };
717 for (run, at) in &page {
718 let records = match journal.read(*run, 0).await {
719 Ok(r) => r,
720 Err(e) => {
721 out.unreadable.push(format!("run {run}: {e}"));
722 continue;
723 }
724 };
725 let mut seen = std::collections::BTreeSet::new();
726 for record in records {
727 let agentplane::RecordKind::EffectStarted { descriptor, .. } = record.kind()
728 else {
729 continue;
730 };
731 let args = &descriptor.args;
732 if descriptor.kind != "tool.call"
733 || args["server"] != server
734 || args["tool"] != tool
735 {
736 continue;
737 }
738 let key = record
740 .body
741 .effect_key
742 .as_ref()
743 .map_or_else(|| record.body.seq.to_string(), |k| format!("{k:?}"));
744 if seen.insert(key) {
745 out.calls.push(RecordedCall {
746 arguments: args["arguments"].clone(),
747 at: *at,
748 });
749 }
750 }
751 }
752 after = Some((last.1, last.0));
753 }
754 Ok(out)
755 }
756
757 pub async fn holds_a_pre_054_decision(&self, run: agentplane::core::RunId) -> bool {
762 let Ok(records) = self.runtime.journal().read(run, 0).await else {
763 return false;
764 };
765 records.iter().any(|r| {
766 serde_json::to_value(r.kind())
767 .ok()
768 .is_some_and(|v| holds_a_pre_054_decision(&v))
769 })
770 }
771
772 pub async fn open_tasks(&self) -> Result<Vec<agentplane::core::Task>, PlaneError> {
774 let Some(tasks) = self.runtime.tasks() else {
775 return Ok(Vec::new());
776 };
777 let mut open = tasks
778 .queue(&[], 1000)
779 .await
780 .map_err(|e| PlaneError(e.to_string()))?;
781 open.sort_by_key(|t| t.created_at);
782 Ok(open)
783 }
784
785 pub async fn decide(
788 &self,
789 task: agentplane::core::TaskId,
790 decision: &agentplane::core::Decision,
791 ) -> Result<agentplane::core::Delivery, PlaneError> {
792 self.decide_at(task, decision, None).await
793 }
794
795 pub async fn decide_at(
798 &self,
799 task: agentplane::core::TaskId,
800 decision: &agentplane::core::Decision,
801 expected: Option<agentplane::core::Digest>,
802 ) -> Result<agentplane::core::Delivery, PlaneError> {
803 self.runtime
804 .decide_task_at(task, decision, &[], expected)
805 .await
806 .map_err(|e| PlaneError(e.to_string()))
807 }
808
809 pub async fn decide_inline(
817 &self,
818 mut outcome: RunOutcome,
819 approver: Option<&dyn crate::approvals::Approver>,
820 context: &crate::approvals::PresentContext,
821 ) -> Result<Inline, PlaneError> {
822 let mut always = Vec::new();
823 while let Some(task) = self.waiting_on(&outcome).await? {
824 let Some((decision, answer)) =
825 approver.and_then(|a| crate::approvals::tasks::ask(a, &task, context))
826 else {
827 return Ok(Inline {
828 outcome,
829 waiting: Some(task),
830 always,
831 });
832 };
833 self.decide(task.id, &decision).await?;
834 if answer.always && decision.approved() {
835 let via = answer
836 .by
837 .as_ref()
838 .and_then(|b| speccoder_core::actions::Via::read(&b.via))
839 .map_or(speccoder_core::actions::Via::Terminal, |(v, _)| v);
840 crate::approvals::answered_always(&task.id.to_hex());
841 always.push(Always {
842 task: crate::approvals::tasks::request_of(&task.justification.proposed_action)
843 .map(|r| r.task)
844 .unwrap_or_default(),
845 call: task.id.to_hex(),
846 via,
847 });
848 }
849 match self.outcome(outcome.run_id).await? {
850 Some(o) => outcome = o,
851 None => break,
852 }
853 }
854 Ok(Inline {
855 outcome,
856 waiting: None,
857 always,
858 })
859 }
860
861 pub async fn outcome(
865 &self,
866 run: agentplane::core::RunId,
867 ) -> Result<Option<RunOutcome>, PlaneError> {
868 if self
869 .runtime
870 .recorded_outcome(run)
871 .await
872 .map_err(|e| PlaneError(e.to_string()))?
873 .is_none()
874 {
875 return Ok(None);
876 }
877 self.runtime
878 .replay(run, agentplane::runtime::Mode::Strict)
879 .await
880 .map(Some)
881 .map_err(|e| PlaneError(e.to_string()))
882 }
883
884 #[doc(hidden)]
887 pub fn on_store(
888 store: Arc<RedbStore>,
889 model: &ModelConfig,
890 provider: Arc<dyn ModelProvider>,
891 tools: Option<Arc<crate::tools::WorkspaceTools>>,
892 ) -> Result<Self, PlaneError> {
893 Ok(Self {
894 runtime: build(store, model, provider, tools, Hooks::default())?,
895 replaced_journal: None,
896 _run: None,
897 })
898 }
899
900 #[doc(hidden)]
903 pub async fn resume(
904 &self,
905 run: agentplane::core::RunId,
906 ) -> Result<RunOutcome, agentplane::RuntimeError> {
907 self.runtime
908 .replay(run, agentplane::runtime::Mode::Resume)
909 .await
910 }
911
912 pub async fn converge(
914 &self,
915 feature: &str,
916 input: Tainted<Value>,
917 ) -> Result<RunOutcome, PlaneError> {
918 self.runtime
919 .start(converge::CAPABILITY, input)
920 .terms(RunTerms::default().correlated(
921 "feature",
922 &[CorrelationKey::new("feature", feature.to_owned())],
923 ))
924 .run()
925 .await
926 .map_err(|e| PlaneError(e.to_string()))
927 }
928
929 pub async fn shape(&self, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
932 self.runtime
933 .start(shape::CAPABILITY, input)
934 .run()
935 .await
936 .map_err(|e| PlaneError(e.to_string()))
937 }
938
939 pub async fn skill(&self, name: &str, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
941 self.runtime
942 .start(host::CAPABILITY, input)
943 .terms(
944 RunTerms::default()
945 .correlated("skill", &[CorrelationKey::new("skill", name.to_owned())]),
946 )
947 .run()
948 .await
949 .map_err(|e| PlaneError(e.to_string()))
950 }
951
952 pub async fn monitor(
956 &self,
957 feature: &str,
958 task: &str,
959 input: Tainted<Value>,
960 ) -> Result<RunOutcome, PlaneError> {
961 self.judge_once(monitor::CAPABILITY, feature, task, input)
962 .await
963 }
964
965 pub async fn near_miss(
969 &self,
970 feature: &str,
971 task: &str,
972 input: Tainted<Value>,
973 ) -> Result<RunOutcome, PlaneError> {
974 self.judge_once(near_miss::CAPABILITY, feature, task, input)
975 .await
976 }
977
978 async fn judge_once(
982 &self,
983 capability: &str,
984 feature: &str,
985 task: &str,
986 input: Tainted<Value>,
987 ) -> Result<RunOutcome, PlaneError> {
988 use agentplane::runtime::{Admission, Mode};
989 use sha2::Digest as _;
990 let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
991 let digest = sha2::Sha256::digest(
992 format!("{capability}\n{feature}/{task}\n{}", input.peek()).as_bytes(),
993 );
994 let key: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect();
995 let key = format!("judge-{key}");
996 let key = key.as_str();
997 let terms = || {
998 RunTerms::default().correlated(
999 "task",
1000 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1001 )
1002 };
1003 let admitted = self
1004 .runtime
1005 .start(capability, input.clone())
1006 .terms(terms())
1007 .once(key)
1008 .run()
1009 .await
1010 .map_err(err)?;
1011 match admitted {
1012 Admission::Fresh(o) => Ok(o),
1013 Admission::Replayed(o) if !matches!(o.status, RunStatus::Succeeded) => self
1016 .runtime
1017 .start(capability, input)
1018 .terms(terms())
1019 .run()
1020 .await
1021 .map_err(err),
1022 Admission::Replayed(o) => self
1023 .runtime
1024 .replay(o.run_id, Mode::Strict)
1025 .await
1026 .map_err(err),
1027 Admission::InFlight(run) => self.runtime.replay(run, Mode::Resume).await.map_err(err),
1028 }
1029 }
1030
1031 pub async fn acceptance(
1033 &self,
1034 feature: &str,
1035 task: &str,
1036 input: Tainted<Value>,
1037 ) -> Result<RunOutcome, PlaneError> {
1038 self.runtime
1039 .start(acceptance::CAPABILITY, input)
1040 .terms(RunTerms::default().correlated(
1041 "task",
1042 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1043 ))
1044 .run()
1045 .await
1046 .map_err(|e| PlaneError(e.to_string()))
1047 }
1048
1049 pub async fn attempt(
1051 &self,
1052 feature: &str,
1053 task: &str,
1054 input: Tainted<Value>,
1055 ) -> Result<RunOutcome, PlaneError> {
1056 self.runtime
1057 .start(attempt::CAPABILITY, input)
1058 .terms(RunTerms::default().correlated(
1059 "task",
1060 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1061 ))
1062 .run()
1063 .await
1064 .map_err(|e| PlaneError(e.to_string()))
1065 }
1066
1067 pub async fn attempt_keyed(
1075 &self,
1076 feature: &str,
1077 task: &str,
1078 input: Tainted<Value>,
1079 key: &str,
1080 ) -> Result<(RunOutcome, bool), PlaneError> {
1081 use agentplane::runtime::{Admission, Mode};
1082 let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
1083 let admitted = self
1084 .runtime
1085 .start(attempt::CAPABILITY, input)
1086 .terms(RunTerms::default().correlated(
1087 "task",
1088 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1089 ))
1090 .once(key)
1091 .run()
1092 .await
1093 .map_err(err)?;
1094 match admitted {
1095 Admission::Fresh(o) => Ok((o, false)),
1096 Admission::Replayed(o) if matches!(o.status, RunStatus::Suspended(_)) => self
1101 .runtime
1102 .replay(o.run_id, Mode::Resume)
1103 .await
1104 .map(|o| (o, true))
1105 .map_err(err),
1106 Admission::Replayed(o) => self
1107 .runtime
1108 .replay(o.run_id, Mode::Strict)
1109 .await
1110 .map(|o| (o, true))
1111 .map_err(err),
1112 Admission::InFlight(run) => self
1113 .runtime
1114 .replay(run, Mode::Resume)
1115 .await
1116 .map(|o| (o, true))
1117 .map_err(err),
1118 }
1119 }
1120
1121 pub async fn propose(
1123 &self,
1124 feature: &str,
1125 task: &str,
1126 input: Tainted<Value>,
1127 ) -> Result<RunOutcome, PlaneError> {
1128 self.runtime
1129 .start(CAPABILITY, input)
1130 .terms(RunTerms::default().correlated(
1131 "task",
1132 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1133 ))
1134 .run()
1135 .await
1136 .map_err(|e| PlaneError(e.to_string()))
1137 }
1138}
1139
1140pub async fn admitted(
1143 dir: &Path,
1144 run: &str,
1145) -> Result<Option<agentplane::core::Tainted<Value>>, PlaneError> {
1146 let id = agentplane::core::RunId::parse(run)
1147 .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
1148 let Some(store) = open_reader(dir)? else {
1149 return Ok(None);
1150 };
1151 let history = store
1152 .read(id, 1)
1153 .await
1154 .map_err(|e| PlaneError(e.to_string()))?;
1155 Ok(history
1156 .iter()
1157 .find_map(agentplane::journal::Record::admitted_input))
1158}
1159
1160pub async fn replay_strict(
1168 dir: &Path,
1169 model: &ModelConfig,
1170 run: &str,
1171 format: &'static dyn crate::format::Adapter,
1172) -> Result<Option<agentplane::runtime::Verdict>, PlaneError> {
1173 let id = agentplane::core::RunId::parse(run)
1174 .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
1175 let Some(store) = open_reader(dir)? else {
1176 return Ok(None);
1177 };
1178 let history = store
1179 .read(id, 1)
1180 .await
1181 .map_err(|e| PlaneError(e.to_string()))?;
1182 if history.is_empty() {
1183 return Ok(None);
1184 }
1185 if history.iter().any(|r| {
1188 serde_json::to_value(r.kind())
1189 .ok()
1190 .is_some_and(|v| holds_a_pre_054_decision(&v))
1191 }) {
1192 return Err(PlaneError(format!(
1193 "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"
1194 )));
1195 }
1196 let mcp_tools: Vec<crate::mcp::McpTool> = history
1200 .iter()
1201 .find_map(agentplane::journal::Record::admitted_input)
1202 .and_then(|a| serde_json::from_value(a.peek()["settings"]["mcp"].clone()).ok())
1203 .unwrap_or_default();
1204 let hooks = Hooks {
1205 mcp_tools,
1206 format: Some(format),
1207 ..Hooks::default()
1208 };
1209 let (builder, manifest) = builder(store, model, hooks)?;
1210 let runtime = agentplane::runtime::replay_only::wire(builder, &[manifest], &history)
1211 .try_build()
1212 .map_err(|e| PlaneError(format!("building the replay plane: {e}")))?;
1213 runtime
1214 .verify(id)
1215 .await
1216 .map(Some)
1217 .map_err(|e| PlaneError(e.to_string()))
1218}
1219
1220#[must_use]
1225pub fn said(completion: &agentplane::model::Completion) -> Option<String> {
1226 let commentary = completion.commentary.as_deref().map(str::trim);
1227 let text = (!completion.tool_calls.is_empty()).then(|| completion.text.trim());
1228 let parts: Vec<&str> = [commentary, text]
1229 .into_iter()
1230 .flatten()
1231 .filter(|t| !t.is_empty())
1232 .collect();
1233 (!parts.is_empty()).then(|| parts.join("\n\n"))
1234}
1235
1236#[must_use]
1240pub fn answered(completion: &agentplane::model::Completion) -> Option<String> {
1241 if let Some(blocked) = completion
1242 .tool_calls
1243 .iter()
1244 .find(|c| c.name == attempt::REPORT_BLOCKED)
1245 {
1246 let reason = blocked.arguments["reason"].as_str().unwrap_or_default();
1247 return Some(match blocked.arguments["kind"].as_str() {
1248 Some(kind) => format!("Blocked ({kind}): {reason}"),
1249 None => format!("Blocked: {reason}"),
1250 });
1251 }
1252 if !completion.tool_calls.is_empty() {
1253 return None;
1254 }
1255 let summary = completion
1256 .structured
1257 .as_ref()
1258 .and_then(|s| s["summary"].as_str());
1259 let text = summary.unwrap_or(&completion.text).trim();
1260 (!text.is_empty()).then(|| text.to_owned())
1261}
1262
1263#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1267pub struct AccountTurn {
1268 pub turn: u32,
1270 pub reasoning: Option<String>,
1272 pub said: Option<String>,
1274 pub answer: Option<String>,
1276}
1277
1278pub async fn account(dir: &Path, run: &str) -> Result<Vec<AccountTurn>, PlaneError> {
1285 let Ok(id) = agentplane::core::RunId::parse(run) else {
1286 return Err(PlaneError(format!("{run} is not a run id")));
1287 };
1288 let lanes = std::fs::read_dir(dir.join("lanes"))
1292 .into_iter()
1293 .flatten()
1294 .flatten()
1295 .map(|e| e.path());
1296 let mut any = false;
1297 let mut history = Vec::new();
1298 for d in std::iter::once(dir.to_path_buf()).chain(lanes) {
1299 let Some(store) = open_reader(&d)? else {
1300 continue;
1301 };
1302 any = true;
1303 history = store
1304 .read(id, 1)
1305 .await
1306 .map_err(|e| PlaneError(e.to_string()))?;
1307 if !history.is_empty() {
1308 break;
1309 }
1310 }
1311 if !any {
1312 return Err(PlaneError(
1313 "no journal of this project's runs is here".to_owned(),
1314 ));
1315 }
1316 if history.is_empty() {
1317 return Err(PlaneError(format!(
1318 "run {run} is not in this project's journal"
1319 )));
1320 }
1321 let text = |t: &str| {
1322 let t = t.trim();
1323 (!t.is_empty()).then(|| crate::approvals::present::escape(t))
1324 };
1325 Ok(agentplane::model::turns(&history)
1326 .into_iter()
1327 .zip(1u32..)
1328 .filter_map(|(t, turn)| {
1329 let c = t.completion?;
1330 let said = if c.tool_calls.is_empty() {
1333 c.commentary.as_deref().and_then(text)
1334 } else {
1335 said(&c).as_deref().and_then(text)
1336 };
1337 let reasoning = c.reasoning.as_deref().and_then(text);
1338 let answer = answered(&c).as_deref().and_then(text);
1339 (said.is_some() || reasoning.is_some() || answer.is_some()).then_some(AccountTurn {
1340 turn,
1341 reasoning,
1342 said,
1343 answer,
1344 })
1345 })
1346 .collect())
1347}
1348
1349pub async fn spent(dir: &Path, run: &str) -> Result<Option<Value>, PlaneError> {
1357 let Ok(id) = agentplane::core::RunId::parse(run) else {
1358 return Ok(None);
1359 };
1360 let Some(store) = open_reader(dir)? else {
1361 return Ok(None);
1362 };
1363 let history = store
1364 .read(id, 1)
1365 .await
1366 .map_err(|e| PlaneError(e.to_string()))?;
1367 let used: Vec<agentplane::model::Usage> = agentplane::model::turns(&history)
1368 .into_iter()
1369 .filter_map(|t| t.completion.map(|c| c.usage))
1370 .collect();
1371 Ok((!used.is_empty()).then(|| {
1372 let sum = |f: fn(&agentplane::model::Usage) -> u64| used.iter().map(f).sum::<u64>();
1373 serde_json::json!({
1374 "input_tokens": sum(|u| u.input_tokens),
1375 "output_tokens": sum(|u| u.output_tokens),
1376 "cache_read_tokens": sum(|u| u.cache_read_tokens),
1377 "cache_write_tokens": sum(|u| u.cache_write_tokens),
1378 })
1379 }))
1380}
1381
1382#[derive(Debug)]
1384struct Unbound;
1385
1386#[async_trait]
1387impl agentplane::tools::ToolClient for Unbound {
1388 async fn call(
1389 &self,
1390 tool: &agentplane::tools::ToolId,
1391 _arguments: &Value,
1392 _provenance: Option<&agentplane::core::Provenance>,
1393 ) -> Result<Value, agentplane::tools::ToolError> {
1394 Err(agentplane::tools::ToolError::Refused {
1395 tool: tool.clone(),
1396 detail: "no worktree is bound to this plane".to_owned(),
1397 })
1398 }
1399
1400 fn destination(&self, _tool: &agentplane::tools::ToolId) -> agentplane::tools::Destination {
1401 agentplane::tools::Destination::Local
1402 }
1403}
1404
1405#[cfg(test)]
1406mod tests {
1407 use super::*;
1408
1409 #[test]
1410 fn the_agentplane_version_named_in_summaries_is_the_pinned_one() {
1411 let workspace = include_str!("../../../../Cargo.toml");
1412 assert!(
1413 workspace.contains(&format!(
1414 "agentplane = {{ version = \"={AGENTPLANE_VERSION}\""
1415 )),
1416 "AGENTPLANE_VERSION is not the workspace's pin"
1417 );
1418 }
1419
1420 #[test]
1421 fn an_attempt_ends_in_its_answer_s_summary_or_why_it_stopped() {
1422 use agentplane::model::{Completion, ToolCall};
1423 let report = serde_json::json!({ "summary": "Added the parser.", "status": "done" });
1424 let done = Completion {
1425 text: report.to_string(),
1426 structured: Some(report),
1427 ..Completion::default()
1428 };
1429 assert_eq!(answered(&done).as_deref(), Some("Added the parser."));
1430 let plain = Completion {
1431 text: " All done. ".to_owned(),
1432 ..Completion::default()
1433 };
1434 assert_eq!(answered(&plain).as_deref(), Some("All done."));
1435 let call = |name: &str, arguments: Value| Completion {
1436 tool_calls: vec![ToolCall {
1437 id: "c".to_owned(),
1438 name: name.to_owned(),
1439 arguments,
1440 }],
1441 ..Completion::default()
1442 };
1443 let blocked = call(
1444 attempt::REPORT_BLOCKED,
1445 serde_json::json!({ "kind": "wrong_test", "evidence": "tests/a.py", "reason": "it asserts the old format" }),
1446 );
1447 assert_eq!(
1448 answered(&blocked).as_deref(),
1449 Some("Blocked (wrong_test): it asserts the old format")
1450 );
1451 assert_eq!(answered(&call("read_file", serde_json::json!({}))), None);
1452 }
1453
1454 #[test]
1455 fn a_provider_refusal_is_read_from_the_run_failure() {
1456 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\")";
1457 let r = provider_refusal(failure).unwrap();
1458 assert_eq!(
1459 r,
1460 "credit_balance_exhausted: You have no credits remaining. Add credits at https://platform.openai.com/settings/organization/billing/."
1461 );
1462 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";
1464 assert_eq!(
1465 provider_refusal(own).as_deref(),
1466 Some("insufficient_quota: You exceeded your current quota.")
1467 );
1468 assert_eq!(provider_refusal("Failed(\"timeout\")"), None);
1469 }
1470
1471 #[test]
1472 fn a_failed_run_says_why_in_the_failure_s_own_words() {
1473 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());
1474 assert_eq!(
1475 why_not(&unusable),
1476 "'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"
1477 );
1478 let timeout =
1479 RunStatus::Failed("effect ek:9 failed on attempt 2 of 2: timed out".to_owned());
1480 assert_eq!(why_not(&timeout), "timed out");
1481 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());
1482 assert_eq!(
1483 why_not(&own),
1484 "'openai/gpt-5.4-mini' returned an unusable answer: not JSON"
1485 );
1486 assert_eq!(why_not(&RunStatus::Succeeded), "succeeded");
1487 }
1488
1489 #[test]
1490 fn an_unreadable_journal_is_moved_aside_and_a_locked_one_is_an_error() {
1491 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1492 std::fs::write(dir.path().join(JOURNAL), b"written by another format")
1493 .unwrap_or_else(|e| panic!("{e}"));
1494 let (store, aside) = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1495 let aside = aside.unwrap_or_else(|| panic!("moved aside"));
1496 assert!(aside.exists() && dir.path().join(JOURNAL).exists());
1497 let shared = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1499 assert!(Arc::ptr_eq(&store, &shared.0) && shared.1.is_none());
1500 drop((store, shared));
1501 let held = RedbStore::open(dir.path().join(JOURNAL)).unwrap_or_else(|e| panic!("{e}"));
1504 let started = std::time::Instant::now();
1505 let second = open_or_replace(dir.path())
1506 .err()
1507 .map(|e| e.0)
1508 .unwrap_or_default();
1509 assert!(second.contains("another speccoder"), "{second}");
1510 assert!(started.elapsed() < std::time::Duration::from_millis(500));
1511 assert!(open_reader(dir.path()).is_err());
1514 drop(held);
1515 assert!(
1516 open_or_replace(dir.path())
1517 .unwrap_or_else(|e| panic!("{}", e.0))
1518 .1
1519 .is_none()
1520 );
1521 }
1522
1523 #[test]
1524 fn a_reader_creates_nothing_and_reads_beside_this_process_s_writer() {
1525 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1526 assert!(
1528 open_reader(dir.path())
1529 .unwrap_or_else(|e| panic!("{}", e.0))
1530 .is_none()
1531 );
1532 assert!(
1533 Plane::open_for_reading(dir.path())
1534 .unwrap_or_else(|e| panic!("{}", e.0))
1535 .is_none()
1536 );
1537 assert!(
1538 !dir.path().join(JOURNAL).exists(),
1539 "a read created a journal"
1540 );
1541 let model =
1544 model_config::parse("[model]\nprovider = \"openai\"\nmodel = \"gpt-5.4-mini\"\n")
1545 .unwrap_or_else(|e| panic!("{e}"))
1546 .unwrap_or_else(|| panic!("no model"));
1547 let fake: Arc<dyn ModelProvider> = agentplane::model::fake::FakeProvider::new();
1548 let run = Plane::open(dir.path(), &model, Arc::clone(&fake))
1549 .unwrap_or_else(|e| panic!("{}", e.0));
1550 let reader = open_reader(dir.path())
1551 .unwrap_or_else(|e| panic!("{}", e.0))
1552 .unwrap_or_else(|| panic!("no journal"));
1553 assert!(reader.is_read_only());
1554 assert!(Plane::open(dir.path(), &model, fake).is_err());
1555 drop(run);
1556 }
1557}