1pub mod acceptance;
9pub mod attempt;
10pub mod converge;
11pub mod host;
12pub mod model_config;
13pub mod monitor;
14pub mod near_miss;
15pub mod propose;
16pub mod provenance;
17pub mod run_config;
18pub mod shape;
19
20use std::path::Path;
21use std::sync::Arc;
22
23use agentplane::core::CorrelationKey;
24use agentplane::core::{ProtectedField, SourceId};
25use agentplane::manifest::{Budgets, Manifest, ModelRef, Models, ToolGrant};
26use agentplane::model::ModelProvider;
27use agentplane::prelude::*;
28use agentplane::runtime::{Agent, RunOutcome, RunTerms};
29use serde_json::Value;
30
31use self::model_config::ModelConfig;
32use self::propose::{CAPABILITY, Propose};
33
34pub const AGENTPLANE_VERSION: &str = "0.56.1";
37
38pub const JOURNAL: &str = "journal.redb";
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct PlaneError(pub String);
44
45pub fn manifest(model: &ModelConfig) -> Result<Manifest, PlaneError> {
48 manifest_with(model, &[])
49}
50
51pub fn manifest_with(
54 model: &ModelConfig,
55 mcp: &[crate::mcp::McpTool],
56) -> Result<Manifest, PlaneError> {
57 Manifest::builder("speccoder", env!("CARGO_PKG_VERSION"))
58 .configure(|spec| {
59 spec.capabilities.provides.push(CAPABILITY.to_owned());
60 spec.capabilities
61 .provides
62 .push(attempt::CAPABILITY.to_owned());
63 spec.capabilities
64 .provides
65 .push(acceptance::CAPABILITY.to_owned());
66 spec.capabilities
67 .provides
68 .push(converge::CAPABILITY.to_owned());
69 spec.capabilities
70 .provides
71 .push(monitor::CAPABILITY.to_owned());
72 spec.capabilities
73 .provides
74 .push(near_miss::CAPABILITY.to_owned());
75 spec.capabilities
76 .provides
77 .push(shape::CAPABILITY.to_owned());
78 spec.capabilities.provides.push(host::CAPABILITY.to_owned());
79 spec.models = Some(Models {
80 privileged: Some(ModelRef {
81 max_tokens: Some(model.max_output_tokens),
82 ..ModelRef::new(model.provider.clone(), model.model.clone())
83 }),
84 ..Models::default()
85 });
86 spec.security.max_sensitivity_egress = Some(Sensitivity::Internal);
88 spec.budgets = Some(Budgets {
89 max_tokens: Some(model.max_run_tokens),
90 ..Budgets::default()
91 });
92 let author =
99 agentplane::model::ModelId::new(model.provider.clone(), model.model.clone())
100 .source();
101 let catalog = crate::tools::catalog();
102 let mut sources: Vec<SourceId> = vec![
103 author,
104 SourceId::new(provenance::VERIFIER),
105 SourceId::new(provenance::WORKTREE),
106 SourceId::new(provenance::PERSON),
108 ];
109 sources.extend(catalog.iter().map(|t| provenance::tool(t.server, t.name)));
110 sources.extend(mcp.iter().map(|t| provenance::tool(&t.server, &t.tool)));
111 for t in catalog.iter().chain(crate::tools::internal().iter()) {
114 let mut grant = ToolGrant {
115 description: Some(t.description.to_owned()),
116 max_sensitivity: Some(Sensitivity::Internal),
117 mutates: t.mutates,
118 ..ToolGrant::new(agentplane::tools::ToolId::new(t.server, t.name).reference())
119 };
120 grant.protected_fields = t
121 .model_fields
122 .iter()
123 .map(|f| ProtectedField::from_sources(*f, sources.clone()))
124 .collect();
125 spec.tools.push(grant);
126 }
127 for t in mcp {
128 let mut grant = ToolGrant {
129 description: Some(t.description.clone()),
130 max_sensitivity: Some(Sensitivity::Internal),
131 mutates: true,
132 ..ToolGrant::new(agentplane::tools::ToolId::new(&t.server, &t.tool).reference())
133 };
134 grant.protected_fields = if t.fields.is_empty() {
135 vec![ProtectedField::one_of("", [serde_json::json!({})])]
138 } else {
139 t.fields
140 .iter()
141 .map(|f| ProtectedField::from_sources(f.as_str(), sources.clone()))
142 .collect()
143 };
144 spec.tools.push(grant);
145 }
146 })
147 .build()
148 .map_err(|e| PlaneError(format!("speccoder manifest: {e}")))
149}
150
151#[derive(Debug, Clone, Default)]
155pub struct Hooks {
156 pub progress: Option<Arc<dyn crate::progress::Progress>>,
158 pub mcp: Vec<(String, Arc<dyn agentplane::tools::ToolClient>)>,
160 pub mcp_tools: Vec<crate::mcp::McpTool>,
162 pub format: Option<&'static dyn crate::format::Adapter>,
164 pub present: crate::approvals::PresentContext,
166}
167
168#[derive(Debug, Clone, Default)]
170pub struct RecordedCalls {
171 pub calls: Vec<RecordedCall>,
173 pub unreadable: Vec<String>,
175}
176
177#[derive(Debug, Clone)]
179pub struct RecordedCall {
180 pub arguments: Value,
182 pub at: u64,
184}
185
186#[derive(Debug)]
188pub struct Inline {
189 pub outcome: RunOutcome,
191 pub waiting: Option<agentplane::core::Task>,
193 pub always: Vec<Always>,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct Always {
200 pub task: String,
202 pub call: String,
204 pub via: speccoder_core::actions::Via,
206}
207
208pub struct Plane {
210 runtime: Arc<Runtime>,
211 pub replaced_journal: Option<std::path::PathBuf>,
213 _run: Option<RunClaim>,
215}
216
217static RUNS: std::sync::Mutex<Vec<std::path::PathBuf>> = std::sync::Mutex::new(Vec::new());
220
221struct RunClaim(std::path::PathBuf);
222
223impl RunClaim {
224 fn take(dir: &Path) -> Result<Self, PlaneError> {
225 let path = dir.join(JOURNAL);
226 let mut runs = RUNS
227 .lock()
228 .map_err(|_| PlaneError("the run registry is poisoned".to_owned()))?;
229 if runs.contains(&path) {
230 return Err(PlaneError(format!(
231 "another speccoder is working on this project (a run in this process holds its journal in {})",
232 dir.display()
233 )));
234 }
235 runs.push(path.clone());
236 Ok(Self(path))
237 }
238}
239
240impl Drop for RunClaim {
241 fn drop(&mut self) {
242 if let Ok(mut runs) = RUNS.lock() {
243 runs.retain(|p| *p != self.0);
244 }
245 }
246}
247
248static OPEN: std::sync::Mutex<Vec<(std::path::PathBuf, std::sync::Weak<RedbStore>)>> =
252 std::sync::Mutex::new(Vec::new());
253
254fn open_or_replace(dir: &Path) -> Result<(Arc<RedbStore>, Option<std::path::PathBuf>), PlaneError> {
261 std::fs::create_dir_all(dir).map_err(|e| PlaneError(format!("{}: {e}", dir.display())))?;
262 let path = dir.join(JOURNAL);
263 let mut open = OPEN
264 .lock()
265 .map_err(|_| PlaneError("the journal registry is poisoned".to_owned()))?;
266 open.retain(|(_, w)| w.strong_count() > 0);
267 if let Some(store) = open
268 .iter()
269 .find(|(p, _)| *p == path)
270 .and_then(|(_, w)| w.upgrade())
271 {
272 return Ok((store, None));
273 }
274 let (store, aside) = open_fresh(dir, &path)?;
275 open.push((path, Arc::downgrade(&store)));
276 Ok((store, aside))
277}
278
279fn open_fresh(
280 dir: &Path,
281 path: &Path,
282) -> Result<(Arc<RedbStore>, Option<std::path::PathBuf>), PlaneError> {
283 match RedbStore::open_shared(path) {
284 Ok(store) => Ok((Arc::new(store), None)),
285 Err(agentplane::core::StoreError::InUse(e)) => Err(PlaneError(format!(
287 "another speccoder is working on this project (its journal in {} is in use): {e}",
288 dir.display()
289 ))),
290 Err(agentplane::core::StoreError::Unreadable(e)) => {
293 let stamp = std::time::SystemTime::now()
294 .duration_since(std::time::UNIX_EPOCH)
295 .map_or(0, |d| d.as_secs());
296 let aside = dir.join(format!("journal.{stamp}.unreadable.redb"));
297 std::fs::rename(path, &aside).map_err(|r| {
298 PlaneError(format!(
299 "the journal is unreadable ({e}) and cannot be moved aside: {r}"
300 ))
301 })?;
302 RedbStore::open_shared(path)
303 .map(|store| (Arc::new(store), Some(aside)))
304 .map_err(|e| PlaneError(format!("cannot open a fresh journal: {e}")))
305 }
306 Err(e) => Err(PlaneError(format!(
308 "cannot open the journal in {}: {e}",
309 dir.display()
310 ))),
311 }
312}
313
314pub fn open_reader(dir: &Path) -> Result<Option<Arc<RedbStore>>, PlaneError> {
323 let path = dir.join(JOURNAL);
324 if !path.exists() {
325 return Ok(None);
326 }
327 match RedbStore::open_read_only(&path) {
328 Ok(store) => Ok(Some(Arc::new(store))),
329 Err(agentplane::core::StoreError::InUse(e)) => Err(PlaneError(format!(
330 "another process holds the journal in {} alone (a speccoder build that does not share it): {e}",
331 dir.display()
332 ))),
333 Err(agentplane::core::StoreError::NeedsRepair(e)) => Err(PlaneError(format!(
336 "the last run ended without closing its journal in {}; the next run or decision repairs it ({e})",
337 dir.display()
338 ))),
339 Err(e) => Err(PlaneError(format!(
340 "cannot read the journal in {}: {e}",
341 dir.display()
342 ))),
343 }
344}
345
346fn holds_a_pre_054_decision(value: &Value) -> bool {
349 match value {
350 Value::Object(o) => {
351 (o.contains_key("approved") && o.contains_key("decided") && !o.contains_key("verdict"))
352 || o.values().any(holds_a_pre_054_decision)
353 }
354 Value::Array(a) => a.iter().any(holds_a_pre_054_decision),
355 Value::String(s) if s.contains("\"approved\"") => {
356 serde_json::from_str::<Value>(s).is_ok_and(|v| holds_a_pre_054_decision(&v))
357 }
358 _ => false,
359 }
360}
361
362pub 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";
364
365#[must_use]
368pub fn provider_refusal(failure: &str) -> Option<String> {
369 let start = failure.find("refused the request: ")? + "refused the request: ".len();
370 let rest = &failure[start..];
371 let end = rest
372 .find("); ")
373 .or_else(|| rest.rfind(')'))
374 .unwrap_or(rest.len());
375 Some(rest[..end].trim_end_matches(['"', ')']).trim().to_owned())
376}
377
378#[must_use]
383pub fn why_not(status: &RunStatus) -> String {
384 let RunStatus::Failed(failure) = status else {
385 return status.as_str().to_owned();
386 };
387 let said = ["consumed resources and failed: ", "failed: "]
388 .iter()
389 .find_map(|m| failure.find(m).map(|i| &failure[i + m.len()..]))
390 .or_else(|| {
391 failure
392 .split_once(" of ")
393 .and_then(|(_, r)| r.split_once(": "))
394 .map(|(_, r)| r)
395 })
396 .unwrap_or(failure);
397 let cut: String = said.chars().take(300).collect();
398 if cut.len() < said.len() {
399 format!("{cut}…")
400 } else {
401 cut
402 }
403}
404
405pub async fn refused_account(
416 dir: &Path,
417) -> Result<Option<agentplane::journal::FailedEffect>, PlaneError> {
418 use agentplane::journal::JournalStore as _;
419 let Some(store) = open_reader(dir)? else {
420 return Ok(None);
421 };
422 let mut after: Option<(u64, agentplane::core::RunId)> = None;
423 loop {
424 let page = store
425 .recent_runs(after, 500)
426 .await
427 .map_err(|e| PlaneError(format!("listing the journal's runs: {e}")))?;
428 let Some(last) = page.last().cloned() else {
429 return Ok(None);
430 };
431 for (run, _) in &page {
432 let records = store
433 .read(*run, 0)
434 .await
435 .map_err(|e| PlaneError(format!("run {run}: {e}")))?;
436 if let Some(failed) = agentplane::journal::failed_effect(&records)
437 .filter(agentplane::journal::FailedEffect::refuses_the_account)
438 {
439 return Ok(Some(failed));
440 }
441 }
442 after = Some((last.1, last.0));
443 }
444}
445
446fn builder(
450 store: Arc<RedbStore>,
451 model: &ModelConfig,
452 hooks: Hooks,
453) -> Result<(agentplane::runtime::RuntimeBuilder, Manifest), PlaneError> {
454 let manifest = manifest_with(model, &hooks.mcp_tools)?;
455 let format = hooks.format.unwrap_or_else(crate::format::reference);
456 let agent = Agent::new(&manifest)
457 .skill(Propose {
458 cache_ttl: model.cache_ttl,
459 style: model.prompt_style(),
460 format,
461 })
462 .skill(attempt::Attempt {
463 style: model.prompt_style(),
464 cache_ttl: model.cache_ttl,
465 progress: hooks.progress.clone(),
466 format,
467 present: hooks.present.clone(),
468 })
469 .skill(host::Host {
470 style: model.prompt_style(),
471 progress: hooks.progress,
472 format,
473 present: hooks.present,
474 })
475 .skill(acceptance::Acceptance {
476 style: model.prompt_style(),
477 cache_ttl: model.cache_ttl,
478 format,
479 })
480 .skill(converge::Converge {
481 style: model.prompt_style(),
482 cache_ttl: model.cache_ttl,
483 format,
484 })
485 .skill(monitor::Monitor {
486 style: model.prompt_style(),
487 cache_ttl: model.cache_ttl,
488 format,
489 })
490 .skill(near_miss::NearMiss {
491 style: model.prompt_style(),
492 cache_ttl: model.cache_ttl,
493 format,
494 })
495 .skill(shape::Shape {
496 style: model.prompt_style(),
497 cache_ttl: model.cache_ttl,
498 });
499 let builder = Runtime::builder_on(store).agent(agent);
502 Ok((builder, manifest))
503}
504
505fn build(
506 store: Arc<RedbStore>,
507 model: &ModelConfig,
508 provider: Arc<dyn ModelProvider>,
509 tools: Option<Arc<crate::tools::WorkspaceTools>>,
510 hooks: Hooks,
511) -> Result<Arc<Runtime>, PlaneError> {
512 let mcp = hooks.mcp.clone();
513 let (mut builder, _) = builder(store, model, hooks)?;
514 builder = builder.provider(model.provider.clone(), provider);
515 let client: Arc<dyn agentplane::tools::ToolClient> = match tools {
518 Some(t) => t,
519 None => Arc::new(Unbound),
520 };
521 for server in ["fs", "proc", "git"] {
522 builder = builder.tool_server(server, Arc::clone(&client));
523 }
524 for (server, client) in &mcp {
525 builder = builder.tool_server(server.as_str(), Arc::clone(client));
526 }
527 builder
528 .try_build()
529 .map_err(|e| PlaneError(format!("building the plane: {e}")))
530}
531
532impl Plane {
533 pub fn open(
536 dir: &Path,
537 model: &ModelConfig,
538 provider: Arc<dyn ModelProvider>,
539 ) -> Result<Self, PlaneError> {
540 Self::open_with_tools(dir, model, provider, None)
541 }
542
543 pub fn open_with_tools(
545 dir: &Path,
546 model: &ModelConfig,
547 provider: Arc<dyn ModelProvider>,
548 tools: Option<Arc<crate::tools::WorkspaceTools>>,
549 ) -> Result<Self, PlaneError> {
550 Self::open_with(dir, model, provider, tools, Hooks::default())
551 }
552
553 pub fn open_with(
555 dir: &Path,
556 model: &ModelConfig,
557 provider: Arc<dyn ModelProvider>,
558 tools: Option<Arc<crate::tools::WorkspaceTools>>,
559 hooks: Hooks,
560 ) -> Result<Self, PlaneError> {
561 let claim = RunClaim::take(dir)?;
562 let (store, replaced_journal) = open_or_replace(dir)?;
563 Ok(Self {
564 runtime: build(store, model, provider, tools, hooks)?,
565 replaced_journal,
566 _run: Some(claim),
567 })
568 }
569
570 pub fn open_for_decisions(dir: &Path) -> Result<Self, PlaneError> {
574 let (store, replaced_journal) = open_or_replace(dir)?;
575 let runtime = Runtime::builder_on(store)
576 .try_build()
577 .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
578 Ok(Self {
579 runtime,
580 replaced_journal,
581 _run: None,
582 })
583 }
584
585 pub fn open_for_reading(dir: &Path) -> Result<Option<Self>, PlaneError> {
594 let Some(store) = open_reader(dir)? else {
595 return Ok(None);
596 };
597 let runtime = Runtime::builder_on(store)
598 .try_build()
599 .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
600 Ok(Some(Self {
601 runtime,
602 replaced_journal: None,
603 _run: None,
604 }))
605 }
606
607 pub async fn expire_due(&self, now: agentplane::core::Timestamp) -> Result<(), PlaneError> {
612 self.runtime
613 .sweep(now, std::time::Duration::from_secs(7 * 86_400))
614 .await
615 .map(|_| ())
616 .map_err(|e| PlaneError(e.to_string()))
617 }
618
619 pub async fn withdraw(
622 &self,
623 task: &agentplane::core::Task,
624 by: &agentplane::core::Operator,
625 reason: &str,
626 ) -> Result<(), PlaneError> {
627 let err = |e: String| PlaneError(e);
628 let _ = self
629 .runtime
630 .request_cancel(task.run, by, reason)
631 .await
632 .map_err(|e| err(e.to_string()))?;
633 if let Some(tasks) = self.runtime.tasks() {
634 tasks
635 .withdraw_run(task.run, &[task.id])
636 .await
637 .map_err(|e| err(e.to_string()))?;
638 }
639 Ok(())
640 }
641
642 pub async fn waiting_on(
644 &self,
645 outcome: &RunOutcome,
646 ) -> Result<Option<agentplane::core::Task>, PlaneError> {
647 let RunStatus::Suspended(reason) = &outcome.status else {
648 return Ok(None);
649 };
650 let Some(id) = reason.awaited_task() else {
651 return Ok(None);
652 };
653 self.task(id).await
654 }
655
656 pub async fn task(
658 &self,
659 id: agentplane::core::TaskId,
660 ) -> Result<Option<agentplane::core::Task>, PlaneError> {
661 let Some(tasks) = self.runtime.tasks() else {
662 return Ok(None);
663 };
664 tasks.task(id).await.map_err(|e| PlaneError(e.to_string()))
665 }
666
667 pub async fn tool_calls(&self, server: &str, tool: &str) -> Result<RecordedCalls, PlaneError> {
672 let journal = self.runtime.journal();
673 let mut out = RecordedCalls::default();
674 let mut after: Option<(u64, agentplane::core::RunId)> = None;
675 loop {
676 let page = journal
677 .recent_runs(after, 500)
678 .await
679 .map_err(|e| PlaneError(format!("listing the journal's runs: {e}")))?;
680 let Some(last) = page.last().cloned() else {
681 break;
682 };
683 for (run, at) in &page {
684 let records = match journal.read(*run, 0).await {
685 Ok(r) => r,
686 Err(e) => {
687 out.unreadable.push(format!("run {run}: {e}"));
688 continue;
689 }
690 };
691 let mut seen = std::collections::BTreeSet::new();
692 for record in records {
693 let agentplane::RecordKind::EffectStarted { descriptor, .. } = record.kind()
694 else {
695 continue;
696 };
697 let args = &descriptor.args;
698 if descriptor.kind != "tool.call"
699 || args["server"] != server
700 || args["tool"] != tool
701 {
702 continue;
703 }
704 let key = record
706 .body
707 .effect_key
708 .as_ref()
709 .map_or_else(|| record.body.seq.to_string(), |k| format!("{k:?}"));
710 if seen.insert(key) {
711 out.calls.push(RecordedCall {
712 arguments: args["arguments"].clone(),
713 at: *at,
714 });
715 }
716 }
717 }
718 after = Some((last.1, last.0));
719 }
720 Ok(out)
721 }
722
723 pub async fn holds_a_pre_054_decision(&self, run: agentplane::core::RunId) -> bool {
728 let Ok(records) = self.runtime.journal().read(run, 0).await else {
729 return false;
730 };
731 records.iter().any(|r| {
732 serde_json::to_value(r.kind())
733 .ok()
734 .is_some_and(|v| holds_a_pre_054_decision(&v))
735 })
736 }
737
738 pub async fn open_tasks(&self) -> Result<Vec<agentplane::core::Task>, PlaneError> {
740 let Some(tasks) = self.runtime.tasks() else {
741 return Ok(Vec::new());
742 };
743 let mut open = tasks
744 .queue(&[], 1000)
745 .await
746 .map_err(|e| PlaneError(e.to_string()))?;
747 open.sort_by_key(|t| t.created_at);
748 Ok(open)
749 }
750
751 pub async fn decide(
754 &self,
755 task: agentplane::core::TaskId,
756 decision: &agentplane::core::Decision,
757 ) -> Result<agentplane::core::Delivery, PlaneError> {
758 self.decide_at(task, decision, None).await
759 }
760
761 pub async fn decide_at(
764 &self,
765 task: agentplane::core::TaskId,
766 decision: &agentplane::core::Decision,
767 expected: Option<agentplane::core::Digest>,
768 ) -> Result<agentplane::core::Delivery, PlaneError> {
769 self.runtime
770 .decide_task_at(task, decision, &[], expected)
771 .await
772 .map_err(|e| PlaneError(e.to_string()))
773 }
774
775 pub async fn decide_inline(
783 &self,
784 mut outcome: RunOutcome,
785 approver: Option<&dyn crate::approvals::Approver>,
786 context: &crate::approvals::PresentContext,
787 ) -> Result<Inline, PlaneError> {
788 let mut always = Vec::new();
789 while let Some(task) = self.waiting_on(&outcome).await? {
790 let Some((decision, answer)) =
791 approver.and_then(|a| crate::approvals::tasks::ask(a, &task, context))
792 else {
793 return Ok(Inline {
794 outcome,
795 waiting: Some(task),
796 always,
797 });
798 };
799 self.decide(task.id, &decision).await?;
800 if answer.always && decision.approved() {
801 let via = answer
802 .by
803 .as_ref()
804 .and_then(|b| speccoder_core::actions::Via::read(&b.via))
805 .map_or(speccoder_core::actions::Via::Terminal, |(v, _)| v);
806 crate::approvals::answered_always(&task.id.to_hex());
807 always.push(Always {
808 task: crate::approvals::tasks::request_of(&task.justification.proposed_action)
809 .map(|r| r.task)
810 .unwrap_or_default(),
811 call: task.id.to_hex(),
812 via,
813 });
814 }
815 match self.outcome(outcome.run_id).await? {
816 Some(o) => outcome = o,
817 None => break,
818 }
819 }
820 Ok(Inline {
821 outcome,
822 waiting: None,
823 always,
824 })
825 }
826
827 pub async fn outcome(
831 &self,
832 run: agentplane::core::RunId,
833 ) -> Result<Option<RunOutcome>, PlaneError> {
834 if self
835 .runtime
836 .recorded_outcome(run)
837 .await
838 .map_err(|e| PlaneError(e.to_string()))?
839 .is_none()
840 {
841 return Ok(None);
842 }
843 self.runtime
844 .replay(run, agentplane::runtime::Mode::Strict)
845 .await
846 .map(Some)
847 .map_err(|e| PlaneError(e.to_string()))
848 }
849
850 #[doc(hidden)]
853 pub fn on_store(
854 store: Arc<RedbStore>,
855 model: &ModelConfig,
856 provider: Arc<dyn ModelProvider>,
857 tools: Option<Arc<crate::tools::WorkspaceTools>>,
858 ) -> Result<Self, PlaneError> {
859 Ok(Self {
860 runtime: build(store, model, provider, tools, Hooks::default())?,
861 replaced_journal: None,
862 _run: None,
863 })
864 }
865
866 #[doc(hidden)]
869 pub async fn resume(
870 &self,
871 run: agentplane::core::RunId,
872 ) -> Result<RunOutcome, agentplane::RuntimeError> {
873 self.runtime
874 .replay(run, agentplane::runtime::Mode::Resume)
875 .await
876 }
877
878 pub async fn converge(
880 &self,
881 feature: &str,
882 input: Tainted<Value>,
883 ) -> Result<RunOutcome, PlaneError> {
884 self.runtime
885 .start(converge::CAPABILITY, input)
886 .terms(RunTerms::default().correlated(
887 "feature",
888 &[CorrelationKey::new("feature", feature.to_owned())],
889 ))
890 .run()
891 .await
892 .map_err(|e| PlaneError(e.to_string()))
893 }
894
895 pub async fn shape(&self, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
898 self.runtime
899 .start(shape::CAPABILITY, input)
900 .run()
901 .await
902 .map_err(|e| PlaneError(e.to_string()))
903 }
904
905 pub async fn skill(&self, name: &str, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
907 self.runtime
908 .start(host::CAPABILITY, input)
909 .terms(
910 RunTerms::default()
911 .correlated("skill", &[CorrelationKey::new("skill", name.to_owned())]),
912 )
913 .run()
914 .await
915 .map_err(|e| PlaneError(e.to_string()))
916 }
917
918 pub async fn monitor(
922 &self,
923 feature: &str,
924 task: &str,
925 input: Tainted<Value>,
926 ) -> Result<RunOutcome, PlaneError> {
927 self.judge_once(monitor::CAPABILITY, feature, task, input)
928 .await
929 }
930
931 pub async fn near_miss(
935 &self,
936 feature: &str,
937 task: &str,
938 input: Tainted<Value>,
939 ) -> Result<RunOutcome, PlaneError> {
940 self.judge_once(near_miss::CAPABILITY, feature, task, input)
941 .await
942 }
943
944 async fn judge_once(
948 &self,
949 capability: &str,
950 feature: &str,
951 task: &str,
952 input: Tainted<Value>,
953 ) -> Result<RunOutcome, PlaneError> {
954 use agentplane::runtime::{Admission, Mode};
955 use sha2::Digest as _;
956 let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
957 let digest = sha2::Sha256::digest(
958 format!("{capability}\n{feature}/{task}\n{}", input.peek()).as_bytes(),
959 );
960 let key: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect();
961 let key = format!("judge-{key}");
962 let key = key.as_str();
963 let terms = || {
964 RunTerms::default().correlated(
965 "task",
966 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
967 )
968 };
969 let admitted = self
970 .runtime
971 .start(capability, input.clone())
972 .terms(terms())
973 .once(key)
974 .run()
975 .await
976 .map_err(err)?;
977 match admitted {
978 Admission::Fresh(o) => Ok(o),
979 Admission::Replayed(o) if !matches!(o.status, RunStatus::Succeeded) => self
982 .runtime
983 .start(capability, input)
984 .terms(terms())
985 .run()
986 .await
987 .map_err(err),
988 Admission::Replayed(o) => self
989 .runtime
990 .replay(o.run_id, Mode::Strict)
991 .await
992 .map_err(err),
993 Admission::InFlight(run) => self.runtime.replay(run, Mode::Resume).await.map_err(err),
994 }
995 }
996
997 pub async fn acceptance(
999 &self,
1000 feature: &str,
1001 task: &str,
1002 input: Tainted<Value>,
1003 ) -> Result<RunOutcome, PlaneError> {
1004 self.runtime
1005 .start(acceptance::CAPABILITY, input)
1006 .terms(RunTerms::default().correlated(
1007 "task",
1008 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1009 ))
1010 .run()
1011 .await
1012 .map_err(|e| PlaneError(e.to_string()))
1013 }
1014
1015 pub async fn attempt(
1017 &self,
1018 feature: &str,
1019 task: &str,
1020 input: Tainted<Value>,
1021 ) -> Result<RunOutcome, PlaneError> {
1022 self.runtime
1023 .start(attempt::CAPABILITY, input)
1024 .terms(RunTerms::default().correlated(
1025 "task",
1026 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1027 ))
1028 .run()
1029 .await
1030 .map_err(|e| PlaneError(e.to_string()))
1031 }
1032
1033 pub async fn attempt_keyed(
1041 &self,
1042 feature: &str,
1043 task: &str,
1044 input: Tainted<Value>,
1045 key: &str,
1046 ) -> Result<(RunOutcome, bool), PlaneError> {
1047 use agentplane::runtime::{Admission, Mode};
1048 let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
1049 let admitted = self
1050 .runtime
1051 .start(attempt::CAPABILITY, input)
1052 .terms(RunTerms::default().correlated(
1053 "task",
1054 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1055 ))
1056 .once(key)
1057 .run()
1058 .await
1059 .map_err(err)?;
1060 match admitted {
1061 Admission::Fresh(o) => Ok((o, false)),
1062 Admission::Replayed(o) if matches!(o.status, RunStatus::Suspended(_)) => self
1067 .runtime
1068 .replay(o.run_id, Mode::Resume)
1069 .await
1070 .map(|o| (o, true))
1071 .map_err(err),
1072 Admission::Replayed(o) => self
1073 .runtime
1074 .replay(o.run_id, Mode::Strict)
1075 .await
1076 .map(|o| (o, true))
1077 .map_err(err),
1078 Admission::InFlight(run) => self
1079 .runtime
1080 .replay(run, Mode::Resume)
1081 .await
1082 .map(|o| (o, true))
1083 .map_err(err),
1084 }
1085 }
1086
1087 pub async fn propose(
1089 &self,
1090 feature: &str,
1091 task: &str,
1092 input: Tainted<Value>,
1093 ) -> Result<RunOutcome, PlaneError> {
1094 self.runtime
1095 .start(CAPABILITY, input)
1096 .terms(RunTerms::default().correlated(
1097 "task",
1098 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1099 ))
1100 .run()
1101 .await
1102 .map_err(|e| PlaneError(e.to_string()))
1103 }
1104}
1105
1106pub async fn admitted(
1109 dir: &Path,
1110 run: &str,
1111) -> Result<Option<agentplane::core::Tainted<Value>>, PlaneError> {
1112 let id = agentplane::core::RunId::parse(run)
1113 .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
1114 let Some(store) = open_reader(dir)? else {
1115 return Ok(None);
1116 };
1117 let history = store
1118 .read(id, 1)
1119 .await
1120 .map_err(|e| PlaneError(e.to_string()))?;
1121 Ok(history
1122 .iter()
1123 .find_map(agentplane::journal::Record::admitted_input))
1124}
1125
1126pub async fn replay_strict(
1134 dir: &Path,
1135 model: &ModelConfig,
1136 run: &str,
1137 format: &'static dyn crate::format::Adapter,
1138) -> Result<Option<agentplane::runtime::Verdict>, PlaneError> {
1139 let id = agentplane::core::RunId::parse(run)
1140 .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
1141 let Some(store) = open_reader(dir)? else {
1142 return Ok(None);
1143 };
1144 let history = store
1145 .read(id, 1)
1146 .await
1147 .map_err(|e| PlaneError(e.to_string()))?;
1148 if history.is_empty() {
1149 return Ok(None);
1150 }
1151 if history.iter().any(|r| {
1154 serde_json::to_value(r.kind())
1155 .ok()
1156 .is_some_and(|v| holds_a_pre_054_decision(&v))
1157 }) {
1158 return Err(PlaneError(format!(
1159 "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"
1160 )));
1161 }
1162 let mcp_tools: Vec<crate::mcp::McpTool> = history
1166 .iter()
1167 .find_map(agentplane::journal::Record::admitted_input)
1168 .and_then(|a| serde_json::from_value(a.peek()["settings"]["mcp"].clone()).ok())
1169 .unwrap_or_default();
1170 let hooks = Hooks {
1171 mcp_tools,
1172 format: Some(format),
1173 ..Hooks::default()
1174 };
1175 let (builder, manifest) = builder(store, model, hooks)?;
1176 let runtime = agentplane::runtime::replay_only::wire(builder, &[manifest], &history)
1177 .try_build()
1178 .map_err(|e| PlaneError(format!("building the replay plane: {e}")))?;
1179 runtime
1180 .verify(id)
1181 .await
1182 .map(Some)
1183 .map_err(|e| PlaneError(e.to_string()))
1184}
1185
1186#[must_use]
1191pub fn said(completion: &agentplane::model::Completion) -> Option<String> {
1192 let commentary = completion.commentary.as_deref().map(str::trim);
1193 let text = (!completion.tool_calls.is_empty()).then(|| completion.text.trim());
1194 let parts: Vec<&str> = [commentary, text]
1195 .into_iter()
1196 .flatten()
1197 .filter(|t| !t.is_empty())
1198 .collect();
1199 (!parts.is_empty()).then(|| parts.join("\n\n"))
1200}
1201
1202#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1206pub struct AccountTurn {
1207 pub turn: u32,
1209 pub reasoning: Option<String>,
1211 pub said: Option<String>,
1214}
1215
1216pub async fn account(dir: &Path, run: &str) -> Result<Vec<AccountTurn>, PlaneError> {
1223 let Ok(id) = agentplane::core::RunId::parse(run) else {
1224 return Ok(Vec::new());
1225 };
1226 let Some(store) = open_reader(dir)? else {
1227 return Ok(Vec::new());
1228 };
1229 let history = store
1230 .read(id, 1)
1231 .await
1232 .map_err(|e| PlaneError(e.to_string()))?;
1233 let text = |t: &str| {
1234 let t = t.trim();
1235 (!t.is_empty()).then(|| crate::approvals::present::escape(t))
1236 };
1237 Ok(agentplane::model::turns(&history)
1238 .into_iter()
1239 .zip(1u32..)
1240 .filter_map(|(t, turn)| {
1241 let c = t.completion?;
1242 let said = said(&c).as_deref().and_then(text);
1243 let reasoning = c.reasoning.as_deref().and_then(text);
1244 (said.is_some() || reasoning.is_some()).then_some(AccountTurn {
1245 turn,
1246 reasoning,
1247 said,
1248 })
1249 })
1250 .collect())
1251}
1252
1253#[derive(Debug)]
1255struct Unbound;
1256
1257#[async_trait]
1258impl agentplane::tools::ToolClient for Unbound {
1259 async fn call(
1260 &self,
1261 tool: &agentplane::tools::ToolId,
1262 _arguments: &Value,
1263 _provenance: Option<&agentplane::core::Provenance>,
1264 ) -> Result<Value, agentplane::tools::ToolError> {
1265 Err(agentplane::tools::ToolError::Refused {
1266 tool: tool.clone(),
1267 detail: "no worktree is bound to this plane".to_owned(),
1268 })
1269 }
1270
1271 fn destination(&self, _tool: &agentplane::tools::ToolId) -> agentplane::tools::Destination {
1272 agentplane::tools::Destination::Local
1273 }
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278 use super::*;
1279
1280 #[test]
1281 fn the_agentplane_version_named_in_summaries_is_the_pinned_one() {
1282 let workspace = include_str!("../../../../Cargo.toml");
1283 assert!(
1284 workspace.contains(&format!(
1285 "agentplane = {{ version = \"={AGENTPLANE_VERSION}\""
1286 )),
1287 "AGENTPLANE_VERSION is not the workspace's pin"
1288 );
1289 }
1290
1291 #[test]
1292 fn a_provider_refusal_is_read_from_the_run_failure() {
1293 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\")";
1294 let r = provider_refusal(failure).unwrap();
1295 assert_eq!(
1296 r,
1297 "credit_balance_exhausted: You have no credits remaining. Add credits at https://platform.openai.com/settings/organization/billing/."
1298 );
1299 assert_eq!(provider_refusal("Failed(\"timeout\")"), None);
1300 }
1301
1302 #[test]
1303 fn a_failed_run_says_why_in_the_failure_s_own_words() {
1304 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());
1305 assert_eq!(
1306 why_not(&unusable),
1307 "'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"
1308 );
1309 let timeout =
1310 RunStatus::Failed("effect ek:9 failed on attempt 2 of 2: timed out".to_owned());
1311 assert_eq!(why_not(&timeout), "timed out");
1312 assert_eq!(why_not(&RunStatus::Succeeded), "succeeded");
1313 }
1314
1315 #[test]
1316 fn an_unreadable_journal_is_moved_aside_and_a_locked_one_is_an_error() {
1317 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1318 std::fs::write(dir.path().join(JOURNAL), b"written by another format")
1319 .unwrap_or_else(|e| panic!("{e}"));
1320 let (store, aside) = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1321 let aside = aside.unwrap_or_else(|| panic!("moved aside"));
1322 assert!(aside.exists() && dir.path().join(JOURNAL).exists());
1323 let shared = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1325 assert!(Arc::ptr_eq(&store, &shared.0) && shared.1.is_none());
1326 drop((store, shared));
1327 let held = RedbStore::open(dir.path().join(JOURNAL)).unwrap_or_else(|e| panic!("{e}"));
1330 let started = std::time::Instant::now();
1331 let second = open_or_replace(dir.path())
1332 .err()
1333 .map(|e| e.0)
1334 .unwrap_or_default();
1335 assert!(second.contains("another speccoder"), "{second}");
1336 assert!(started.elapsed() < std::time::Duration::from_millis(500));
1337 assert!(open_reader(dir.path()).is_err());
1340 drop(held);
1341 assert!(
1342 open_or_replace(dir.path())
1343 .unwrap_or_else(|e| panic!("{}", e.0))
1344 .1
1345 .is_none()
1346 );
1347 }
1348
1349 #[test]
1350 fn a_reader_creates_nothing_and_reads_beside_this_process_s_writer() {
1351 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1352 assert!(
1354 open_reader(dir.path())
1355 .unwrap_or_else(|e| panic!("{}", e.0))
1356 .is_none()
1357 );
1358 assert!(
1359 Plane::open_for_reading(dir.path())
1360 .unwrap_or_else(|e| panic!("{}", e.0))
1361 .is_none()
1362 );
1363 assert!(
1364 !dir.path().join(JOURNAL).exists(),
1365 "a read created a journal"
1366 );
1367 let model =
1370 model_config::parse("[model]\nprovider = \"openai\"\nmodel = \"gpt-5.4-mini\"\n")
1371 .unwrap_or_else(|e| panic!("{e}"))
1372 .unwrap_or_else(|| panic!("no model"));
1373 let fake: Arc<dyn ModelProvider> = agentplane::model::fake::FakeProvider::new();
1374 let run = Plane::open(dir.path(), &model, Arc::clone(&fake))
1375 .unwrap_or_else(|e| panic!("{}", e.0));
1376 let reader = open_reader(dir.path())
1377 .unwrap_or_else(|e| panic!("{}", e.0))
1378 .unwrap_or_else(|| panic!("no journal"));
1379 assert!(reader.is_read_only());
1380 assert!(Plane::open(dir.path(), &model, fake).is_err());
1381 drop(run);
1382 }
1383}