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]
381pub fn exhausted(limit: &agentplane::core::BudgetExceeded) -> String {
382 let said = match limit {
383 agentplane::core::BudgetExceeded::Recorded { limit, .. } => limit.clone(),
384 other => other.to_string(),
385 };
386 let raise = if said.starts_with("token") {
387 "; raise `[model] max_run_tokens`, or split the task"
388 } else {
389 ""
390 };
391 format!("the attempt's budget ran out: {said}{raise}")
392}
393
394#[must_use]
399pub fn why_not(status: &RunStatus) -> String {
400 let RunStatus::Failed(failure) = status else {
401 return status.as_str().to_owned();
402 };
403 let said = ["consumed resources and failed: ", "failed: "]
404 .iter()
405 .find_map(|m| failure.find(m).map(|i| &failure[i + m.len()..]))
406 .or_else(|| {
407 failure
408 .split_once(" of ")
409 .and_then(|(_, r)| r.split_once(": "))
410 .map(|(_, r)| r)
411 })
412 .unwrap_or(failure);
413 let cut: String = said.chars().take(300).collect();
414 if cut.len() < said.len() {
415 format!("{cut}…")
416 } else {
417 cut
418 }
419}
420
421pub async fn refused_account(
432 dir: &Path,
433) -> Result<Option<agentplane::journal::FailedEffect>, PlaneError> {
434 use agentplane::journal::JournalStore as _;
435 let Some(store) = open_reader(dir)? else {
436 return Ok(None);
437 };
438 let mut after: Option<(u64, agentplane::core::RunId)> = None;
439 loop {
440 let page = store
441 .recent_runs(after, 500)
442 .await
443 .map_err(|e| PlaneError(format!("listing the journal's runs: {e}")))?;
444 let Some(last) = page.last().cloned() else {
445 return Ok(None);
446 };
447 for (run, _) in &page {
448 let records = store
449 .read(*run, 0)
450 .await
451 .map_err(|e| PlaneError(format!("run {run}: {e}")))?;
452 if let Some(failed) = agentplane::journal::failed_effect(&records)
453 .filter(agentplane::journal::FailedEffect::refuses_the_account)
454 {
455 return Ok(Some(failed));
456 }
457 }
458 after = Some((last.1, last.0));
459 }
460}
461
462fn builder(
466 store: Arc<RedbStore>,
467 model: &ModelConfig,
468 hooks: Hooks,
469) -> Result<(agentplane::runtime::RuntimeBuilder, Manifest), PlaneError> {
470 let manifest = manifest_with(model, &hooks.mcp_tools)?;
471 let format = hooks.format.unwrap_or_else(crate::format::reference);
472 let agent = Agent::new(&manifest)
473 .skill(Propose {
474 cache_ttl: model.cache_ttl,
475 style: model.prompt_style(),
476 format,
477 })
478 .skill(attempt::Attempt {
479 style: model.prompt_style(),
480 cache_ttl: model.cache_ttl,
481 progress: hooks.progress.clone(),
482 format,
483 present: hooks.present.clone(),
484 })
485 .skill(host::Host {
486 style: model.prompt_style(),
487 progress: hooks.progress,
488 format,
489 present: hooks.present,
490 })
491 .skill(acceptance::Acceptance {
492 style: model.prompt_style(),
493 cache_ttl: model.cache_ttl,
494 format,
495 })
496 .skill(converge::Converge {
497 style: model.prompt_style(),
498 cache_ttl: model.cache_ttl,
499 format,
500 })
501 .skill(monitor::Monitor {
502 style: model.prompt_style(),
503 cache_ttl: model.cache_ttl,
504 format,
505 })
506 .skill(near_miss::NearMiss {
507 style: model.prompt_style(),
508 cache_ttl: model.cache_ttl,
509 format,
510 })
511 .skill(shape::Shape {
512 style: model.prompt_style(),
513 cache_ttl: model.cache_ttl,
514 });
515 let builder = Runtime::builder_on(store).agent(agent);
518 Ok((builder, manifest))
519}
520
521fn build(
522 store: Arc<RedbStore>,
523 model: &ModelConfig,
524 provider: Arc<dyn ModelProvider>,
525 tools: Option<Arc<crate::tools::WorkspaceTools>>,
526 hooks: Hooks,
527) -> Result<Arc<Runtime>, PlaneError> {
528 let mcp = hooks.mcp.clone();
529 let (mut builder, _) = builder(store, model, hooks)?;
530 builder = builder.provider(model.provider.clone(), provider);
531 let client: Arc<dyn agentplane::tools::ToolClient> = match tools {
534 Some(t) => t,
535 None => Arc::new(Unbound),
536 };
537 for server in ["fs", "proc", "git"] {
538 builder = builder.tool_server(server, Arc::clone(&client));
539 }
540 for (server, client) in &mcp {
541 builder = builder.tool_server(server.as_str(), Arc::clone(client));
542 }
543 builder
544 .try_build()
545 .map_err(|e| PlaneError(format!("building the plane: {e}")))
546}
547
548impl Plane {
549 pub fn open(
552 dir: &Path,
553 model: &ModelConfig,
554 provider: Arc<dyn ModelProvider>,
555 ) -> Result<Self, PlaneError> {
556 Self::open_with_tools(dir, model, provider, None)
557 }
558
559 pub fn open_with_tools(
561 dir: &Path,
562 model: &ModelConfig,
563 provider: Arc<dyn ModelProvider>,
564 tools: Option<Arc<crate::tools::WorkspaceTools>>,
565 ) -> Result<Self, PlaneError> {
566 Self::open_with(dir, model, provider, tools, Hooks::default())
567 }
568
569 pub fn open_with(
571 dir: &Path,
572 model: &ModelConfig,
573 provider: Arc<dyn ModelProvider>,
574 tools: Option<Arc<crate::tools::WorkspaceTools>>,
575 hooks: Hooks,
576 ) -> Result<Self, PlaneError> {
577 let claim = RunClaim::take(dir)?;
578 let (store, replaced_journal) = open_or_replace(dir)?;
579 Ok(Self {
580 runtime: build(store, model, provider, tools, hooks)?,
581 replaced_journal,
582 _run: Some(claim),
583 })
584 }
585
586 pub fn open_for_decisions(dir: &Path) -> Result<Self, PlaneError> {
590 let (store, replaced_journal) = open_or_replace(dir)?;
591 let runtime = Runtime::builder_on(store)
592 .try_build()
593 .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
594 Ok(Self {
595 runtime,
596 replaced_journal,
597 _run: None,
598 })
599 }
600
601 pub fn open_for_reading(dir: &Path) -> Result<Option<Self>, PlaneError> {
610 let Some(store) = open_reader(dir)? else {
611 return Ok(None);
612 };
613 let runtime = Runtime::builder_on(store)
614 .try_build()
615 .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
616 Ok(Some(Self {
617 runtime,
618 replaced_journal: None,
619 _run: None,
620 }))
621 }
622
623 pub async fn expire_due(&self, now: agentplane::core::Timestamp) -> Result<(), PlaneError> {
628 self.runtime
629 .sweep(now, std::time::Duration::from_secs(7 * 86_400))
630 .await
631 .map(|_| ())
632 .map_err(|e| PlaneError(e.to_string()))
633 }
634
635 pub async fn withdraw(
638 &self,
639 task: &agentplane::core::Task,
640 by: &agentplane::core::Operator,
641 reason: &str,
642 ) -> Result<(), PlaneError> {
643 let err = |e: String| PlaneError(e);
644 let _ = self
645 .runtime
646 .request_cancel(task.run, by, reason)
647 .await
648 .map_err(|e| err(e.to_string()))?;
649 if let Some(tasks) = self.runtime.tasks() {
650 tasks
651 .withdraw_run(task.run, &[task.id])
652 .await
653 .map_err(|e| err(e.to_string()))?;
654 }
655 Ok(())
656 }
657
658 pub async fn waiting_on(
660 &self,
661 outcome: &RunOutcome,
662 ) -> Result<Option<agentplane::core::Task>, PlaneError> {
663 let RunStatus::Suspended(reason) = &outcome.status else {
664 return Ok(None);
665 };
666 let Some(id) = reason.awaited_task() else {
667 return Ok(None);
668 };
669 self.task(id).await
670 }
671
672 pub async fn task(
674 &self,
675 id: agentplane::core::TaskId,
676 ) -> Result<Option<agentplane::core::Task>, PlaneError> {
677 let Some(tasks) = self.runtime.tasks() else {
678 return Ok(None);
679 };
680 tasks.task(id).await.map_err(|e| PlaneError(e.to_string()))
681 }
682
683 pub async fn tool_calls(&self, server: &str, tool: &str) -> Result<RecordedCalls, PlaneError> {
688 let journal = self.runtime.journal();
689 let mut out = RecordedCalls::default();
690 let mut after: Option<(u64, agentplane::core::RunId)> = None;
691 loop {
692 let page = journal
693 .recent_runs(after, 500)
694 .await
695 .map_err(|e| PlaneError(format!("listing the journal's runs: {e}")))?;
696 let Some(last) = page.last().cloned() else {
697 break;
698 };
699 for (run, at) in &page {
700 let records = match journal.read(*run, 0).await {
701 Ok(r) => r,
702 Err(e) => {
703 out.unreadable.push(format!("run {run}: {e}"));
704 continue;
705 }
706 };
707 let mut seen = std::collections::BTreeSet::new();
708 for record in records {
709 let agentplane::RecordKind::EffectStarted { descriptor, .. } = record.kind()
710 else {
711 continue;
712 };
713 let args = &descriptor.args;
714 if descriptor.kind != "tool.call"
715 || args["server"] != server
716 || args["tool"] != tool
717 {
718 continue;
719 }
720 let key = record
722 .body
723 .effect_key
724 .as_ref()
725 .map_or_else(|| record.body.seq.to_string(), |k| format!("{k:?}"));
726 if seen.insert(key) {
727 out.calls.push(RecordedCall {
728 arguments: args["arguments"].clone(),
729 at: *at,
730 });
731 }
732 }
733 }
734 after = Some((last.1, last.0));
735 }
736 Ok(out)
737 }
738
739 pub async fn holds_a_pre_054_decision(&self, run: agentplane::core::RunId) -> bool {
744 let Ok(records) = self.runtime.journal().read(run, 0).await else {
745 return false;
746 };
747 records.iter().any(|r| {
748 serde_json::to_value(r.kind())
749 .ok()
750 .is_some_and(|v| holds_a_pre_054_decision(&v))
751 })
752 }
753
754 pub async fn open_tasks(&self) -> Result<Vec<agentplane::core::Task>, PlaneError> {
756 let Some(tasks) = self.runtime.tasks() else {
757 return Ok(Vec::new());
758 };
759 let mut open = tasks
760 .queue(&[], 1000)
761 .await
762 .map_err(|e| PlaneError(e.to_string()))?;
763 open.sort_by_key(|t| t.created_at);
764 Ok(open)
765 }
766
767 pub async fn decide(
770 &self,
771 task: agentplane::core::TaskId,
772 decision: &agentplane::core::Decision,
773 ) -> Result<agentplane::core::Delivery, PlaneError> {
774 self.decide_at(task, decision, None).await
775 }
776
777 pub async fn decide_at(
780 &self,
781 task: agentplane::core::TaskId,
782 decision: &agentplane::core::Decision,
783 expected: Option<agentplane::core::Digest>,
784 ) -> Result<agentplane::core::Delivery, PlaneError> {
785 self.runtime
786 .decide_task_at(task, decision, &[], expected)
787 .await
788 .map_err(|e| PlaneError(e.to_string()))
789 }
790
791 pub async fn decide_inline(
799 &self,
800 mut outcome: RunOutcome,
801 approver: Option<&dyn crate::approvals::Approver>,
802 context: &crate::approvals::PresentContext,
803 ) -> Result<Inline, PlaneError> {
804 let mut always = Vec::new();
805 while let Some(task) = self.waiting_on(&outcome).await? {
806 let Some((decision, answer)) =
807 approver.and_then(|a| crate::approvals::tasks::ask(a, &task, context))
808 else {
809 return Ok(Inline {
810 outcome,
811 waiting: Some(task),
812 always,
813 });
814 };
815 self.decide(task.id, &decision).await?;
816 if answer.always && decision.approved() {
817 let via = answer
818 .by
819 .as_ref()
820 .and_then(|b| speccoder_core::actions::Via::read(&b.via))
821 .map_or(speccoder_core::actions::Via::Terminal, |(v, _)| v);
822 crate::approvals::answered_always(&task.id.to_hex());
823 always.push(Always {
824 task: crate::approvals::tasks::request_of(&task.justification.proposed_action)
825 .map(|r| r.task)
826 .unwrap_or_default(),
827 call: task.id.to_hex(),
828 via,
829 });
830 }
831 match self.outcome(outcome.run_id).await? {
832 Some(o) => outcome = o,
833 None => break,
834 }
835 }
836 Ok(Inline {
837 outcome,
838 waiting: None,
839 always,
840 })
841 }
842
843 pub async fn outcome(
847 &self,
848 run: agentplane::core::RunId,
849 ) -> Result<Option<RunOutcome>, PlaneError> {
850 if self
851 .runtime
852 .recorded_outcome(run)
853 .await
854 .map_err(|e| PlaneError(e.to_string()))?
855 .is_none()
856 {
857 return Ok(None);
858 }
859 self.runtime
860 .replay(run, agentplane::runtime::Mode::Strict)
861 .await
862 .map(Some)
863 .map_err(|e| PlaneError(e.to_string()))
864 }
865
866 #[doc(hidden)]
869 pub fn on_store(
870 store: Arc<RedbStore>,
871 model: &ModelConfig,
872 provider: Arc<dyn ModelProvider>,
873 tools: Option<Arc<crate::tools::WorkspaceTools>>,
874 ) -> Result<Self, PlaneError> {
875 Ok(Self {
876 runtime: build(store, model, provider, tools, Hooks::default())?,
877 replaced_journal: None,
878 _run: None,
879 })
880 }
881
882 #[doc(hidden)]
885 pub async fn resume(
886 &self,
887 run: agentplane::core::RunId,
888 ) -> Result<RunOutcome, agentplane::RuntimeError> {
889 self.runtime
890 .replay(run, agentplane::runtime::Mode::Resume)
891 .await
892 }
893
894 pub async fn converge(
896 &self,
897 feature: &str,
898 input: Tainted<Value>,
899 ) -> Result<RunOutcome, PlaneError> {
900 self.runtime
901 .start(converge::CAPABILITY, input)
902 .terms(RunTerms::default().correlated(
903 "feature",
904 &[CorrelationKey::new("feature", feature.to_owned())],
905 ))
906 .run()
907 .await
908 .map_err(|e| PlaneError(e.to_string()))
909 }
910
911 pub async fn shape(&self, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
914 self.runtime
915 .start(shape::CAPABILITY, input)
916 .run()
917 .await
918 .map_err(|e| PlaneError(e.to_string()))
919 }
920
921 pub async fn skill(&self, name: &str, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
923 self.runtime
924 .start(host::CAPABILITY, input)
925 .terms(
926 RunTerms::default()
927 .correlated("skill", &[CorrelationKey::new("skill", name.to_owned())]),
928 )
929 .run()
930 .await
931 .map_err(|e| PlaneError(e.to_string()))
932 }
933
934 pub async fn monitor(
938 &self,
939 feature: &str,
940 task: &str,
941 input: Tainted<Value>,
942 ) -> Result<RunOutcome, PlaneError> {
943 self.judge_once(monitor::CAPABILITY, feature, task, input)
944 .await
945 }
946
947 pub async fn near_miss(
951 &self,
952 feature: &str,
953 task: &str,
954 input: Tainted<Value>,
955 ) -> Result<RunOutcome, PlaneError> {
956 self.judge_once(near_miss::CAPABILITY, feature, task, input)
957 .await
958 }
959
960 async fn judge_once(
964 &self,
965 capability: &str,
966 feature: &str,
967 task: &str,
968 input: Tainted<Value>,
969 ) -> Result<RunOutcome, PlaneError> {
970 use agentplane::runtime::{Admission, Mode};
971 use sha2::Digest as _;
972 let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
973 let digest = sha2::Sha256::digest(
974 format!("{capability}\n{feature}/{task}\n{}", input.peek()).as_bytes(),
975 );
976 let key: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect();
977 let key = format!("judge-{key}");
978 let key = key.as_str();
979 let terms = || {
980 RunTerms::default().correlated(
981 "task",
982 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
983 )
984 };
985 let admitted = self
986 .runtime
987 .start(capability, input.clone())
988 .terms(terms())
989 .once(key)
990 .run()
991 .await
992 .map_err(err)?;
993 match admitted {
994 Admission::Fresh(o) => Ok(o),
995 Admission::Replayed(o) if !matches!(o.status, RunStatus::Succeeded) => self
998 .runtime
999 .start(capability, input)
1000 .terms(terms())
1001 .run()
1002 .await
1003 .map_err(err),
1004 Admission::Replayed(o) => self
1005 .runtime
1006 .replay(o.run_id, Mode::Strict)
1007 .await
1008 .map_err(err),
1009 Admission::InFlight(run) => self.runtime.replay(run, Mode::Resume).await.map_err(err),
1010 }
1011 }
1012
1013 pub async fn acceptance(
1015 &self,
1016 feature: &str,
1017 task: &str,
1018 input: Tainted<Value>,
1019 ) -> Result<RunOutcome, PlaneError> {
1020 self.runtime
1021 .start(acceptance::CAPABILITY, input)
1022 .terms(RunTerms::default().correlated(
1023 "task",
1024 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1025 ))
1026 .run()
1027 .await
1028 .map_err(|e| PlaneError(e.to_string()))
1029 }
1030
1031 pub async fn attempt(
1033 &self,
1034 feature: &str,
1035 task: &str,
1036 input: Tainted<Value>,
1037 ) -> Result<RunOutcome, PlaneError> {
1038 self.runtime
1039 .start(attempt::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_keyed(
1057 &self,
1058 feature: &str,
1059 task: &str,
1060 input: Tainted<Value>,
1061 key: &str,
1062 ) -> Result<(RunOutcome, bool), PlaneError> {
1063 use agentplane::runtime::{Admission, Mode};
1064 let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
1065 let admitted = self
1066 .runtime
1067 .start(attempt::CAPABILITY, input)
1068 .terms(RunTerms::default().correlated(
1069 "task",
1070 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1071 ))
1072 .once(key)
1073 .run()
1074 .await
1075 .map_err(err)?;
1076 match admitted {
1077 Admission::Fresh(o) => Ok((o, false)),
1078 Admission::Replayed(o) if matches!(o.status, RunStatus::Suspended(_)) => self
1083 .runtime
1084 .replay(o.run_id, Mode::Resume)
1085 .await
1086 .map(|o| (o, true))
1087 .map_err(err),
1088 Admission::Replayed(o) => self
1089 .runtime
1090 .replay(o.run_id, Mode::Strict)
1091 .await
1092 .map(|o| (o, true))
1093 .map_err(err),
1094 Admission::InFlight(run) => self
1095 .runtime
1096 .replay(run, Mode::Resume)
1097 .await
1098 .map(|o| (o, true))
1099 .map_err(err),
1100 }
1101 }
1102
1103 pub async fn propose(
1105 &self,
1106 feature: &str,
1107 task: &str,
1108 input: Tainted<Value>,
1109 ) -> Result<RunOutcome, PlaneError> {
1110 self.runtime
1111 .start(CAPABILITY, input)
1112 .terms(RunTerms::default().correlated(
1113 "task",
1114 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1115 ))
1116 .run()
1117 .await
1118 .map_err(|e| PlaneError(e.to_string()))
1119 }
1120}
1121
1122pub async fn admitted(
1125 dir: &Path,
1126 run: &str,
1127) -> Result<Option<agentplane::core::Tainted<Value>>, PlaneError> {
1128 let id = agentplane::core::RunId::parse(run)
1129 .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
1130 let Some(store) = open_reader(dir)? else {
1131 return Ok(None);
1132 };
1133 let history = store
1134 .read(id, 1)
1135 .await
1136 .map_err(|e| PlaneError(e.to_string()))?;
1137 Ok(history
1138 .iter()
1139 .find_map(agentplane::journal::Record::admitted_input))
1140}
1141
1142pub async fn replay_strict(
1150 dir: &Path,
1151 model: &ModelConfig,
1152 run: &str,
1153 format: &'static dyn crate::format::Adapter,
1154) -> Result<Option<agentplane::runtime::Verdict>, PlaneError> {
1155 let id = agentplane::core::RunId::parse(run)
1156 .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
1157 let Some(store) = open_reader(dir)? else {
1158 return Ok(None);
1159 };
1160 let history = store
1161 .read(id, 1)
1162 .await
1163 .map_err(|e| PlaneError(e.to_string()))?;
1164 if history.is_empty() {
1165 return Ok(None);
1166 }
1167 if history.iter().any(|r| {
1170 serde_json::to_value(r.kind())
1171 .ok()
1172 .is_some_and(|v| holds_a_pre_054_decision(&v))
1173 }) {
1174 return Err(PlaneError(format!(
1175 "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"
1176 )));
1177 }
1178 let mcp_tools: Vec<crate::mcp::McpTool> = history
1182 .iter()
1183 .find_map(agentplane::journal::Record::admitted_input)
1184 .and_then(|a| serde_json::from_value(a.peek()["settings"]["mcp"].clone()).ok())
1185 .unwrap_or_default();
1186 let hooks = Hooks {
1187 mcp_tools,
1188 format: Some(format),
1189 ..Hooks::default()
1190 };
1191 let (builder, manifest) = builder(store, model, hooks)?;
1192 let runtime = agentplane::runtime::replay_only::wire(builder, &[manifest], &history)
1193 .try_build()
1194 .map_err(|e| PlaneError(format!("building the replay plane: {e}")))?;
1195 runtime
1196 .verify(id)
1197 .await
1198 .map(Some)
1199 .map_err(|e| PlaneError(e.to_string()))
1200}
1201
1202#[must_use]
1207pub fn said(completion: &agentplane::model::Completion) -> Option<String> {
1208 let commentary = completion.commentary.as_deref().map(str::trim);
1209 let text = (!completion.tool_calls.is_empty()).then(|| completion.text.trim());
1210 let parts: Vec<&str> = [commentary, text]
1211 .into_iter()
1212 .flatten()
1213 .filter(|t| !t.is_empty())
1214 .collect();
1215 (!parts.is_empty()).then(|| parts.join("\n\n"))
1216}
1217
1218#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1222pub struct AccountTurn {
1223 pub turn: u32,
1225 pub reasoning: Option<String>,
1227 pub said: Option<String>,
1230}
1231
1232pub async fn account(dir: &Path, run: &str) -> Result<Vec<AccountTurn>, PlaneError> {
1239 let Ok(id) = agentplane::core::RunId::parse(run) else {
1240 return Ok(Vec::new());
1241 };
1242 let Some(store) = open_reader(dir)? else {
1243 return Ok(Vec::new());
1244 };
1245 let history = store
1246 .read(id, 1)
1247 .await
1248 .map_err(|e| PlaneError(e.to_string()))?;
1249 let text = |t: &str| {
1250 let t = t.trim();
1251 (!t.is_empty()).then(|| crate::approvals::present::escape(t))
1252 };
1253 Ok(agentplane::model::turns(&history)
1254 .into_iter()
1255 .zip(1u32..)
1256 .filter_map(|(t, turn)| {
1257 let c = t.completion?;
1258 let said = said(&c).as_deref().and_then(text);
1259 let reasoning = c.reasoning.as_deref().and_then(text);
1260 (said.is_some() || reasoning.is_some()).then_some(AccountTurn {
1261 turn,
1262 reasoning,
1263 said,
1264 })
1265 })
1266 .collect())
1267}
1268
1269#[derive(Debug)]
1271struct Unbound;
1272
1273#[async_trait]
1274impl agentplane::tools::ToolClient for Unbound {
1275 async fn call(
1276 &self,
1277 tool: &agentplane::tools::ToolId,
1278 _arguments: &Value,
1279 _provenance: Option<&agentplane::core::Provenance>,
1280 ) -> Result<Value, agentplane::tools::ToolError> {
1281 Err(agentplane::tools::ToolError::Refused {
1282 tool: tool.clone(),
1283 detail: "no worktree is bound to this plane".to_owned(),
1284 })
1285 }
1286
1287 fn destination(&self, _tool: &agentplane::tools::ToolId) -> agentplane::tools::Destination {
1288 agentplane::tools::Destination::Local
1289 }
1290}
1291
1292#[cfg(test)]
1293mod tests {
1294 use super::*;
1295
1296 #[test]
1297 fn the_agentplane_version_named_in_summaries_is_the_pinned_one() {
1298 let workspace = include_str!("../../../../Cargo.toml");
1299 assert!(
1300 workspace.contains(&format!(
1301 "agentplane = {{ version = \"={AGENTPLANE_VERSION}\""
1302 )),
1303 "AGENTPLANE_VERSION is not the workspace's pin"
1304 );
1305 }
1306
1307 #[test]
1308 fn a_provider_refusal_is_read_from_the_run_failure() {
1309 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\")";
1310 let r = provider_refusal(failure).unwrap();
1311 assert_eq!(
1312 r,
1313 "credit_balance_exhausted: You have no credits remaining. Add credits at https://platform.openai.com/settings/organization/billing/."
1314 );
1315 assert_eq!(provider_refusal("Failed(\"timeout\")"), None);
1316 }
1317
1318 #[test]
1319 fn a_failed_run_says_why_in_the_failure_s_own_words() {
1320 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());
1321 assert_eq!(
1322 why_not(&unusable),
1323 "'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"
1324 );
1325 let timeout =
1326 RunStatus::Failed("effect ek:9 failed on attempt 2 of 2: timed out".to_owned());
1327 assert_eq!(why_not(&timeout), "timed out");
1328 assert_eq!(why_not(&RunStatus::Succeeded), "succeeded");
1329 }
1330
1331 #[test]
1332 fn an_unreadable_journal_is_moved_aside_and_a_locked_one_is_an_error() {
1333 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1334 std::fs::write(dir.path().join(JOURNAL), b"written by another format")
1335 .unwrap_or_else(|e| panic!("{e}"));
1336 let (store, aside) = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1337 let aside = aside.unwrap_or_else(|| panic!("moved aside"));
1338 assert!(aside.exists() && dir.path().join(JOURNAL).exists());
1339 let shared = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1341 assert!(Arc::ptr_eq(&store, &shared.0) && shared.1.is_none());
1342 drop((store, shared));
1343 let held = RedbStore::open(dir.path().join(JOURNAL)).unwrap_or_else(|e| panic!("{e}"));
1346 let started = std::time::Instant::now();
1347 let second = open_or_replace(dir.path())
1348 .err()
1349 .map(|e| e.0)
1350 .unwrap_or_default();
1351 assert!(second.contains("another speccoder"), "{second}");
1352 assert!(started.elapsed() < std::time::Duration::from_millis(500));
1353 assert!(open_reader(dir.path()).is_err());
1356 drop(held);
1357 assert!(
1358 open_or_replace(dir.path())
1359 .unwrap_or_else(|e| panic!("{}", e.0))
1360 .1
1361 .is_none()
1362 );
1363 }
1364
1365 #[test]
1366 fn a_reader_creates_nothing_and_reads_beside_this_process_s_writer() {
1367 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1368 assert!(
1370 open_reader(dir.path())
1371 .unwrap_or_else(|e| panic!("{}", e.0))
1372 .is_none()
1373 );
1374 assert!(
1375 Plane::open_for_reading(dir.path())
1376 .unwrap_or_else(|e| panic!("{}", e.0))
1377 .is_none()
1378 );
1379 assert!(
1380 !dir.path().join(JOURNAL).exists(),
1381 "a read created a journal"
1382 );
1383 let model =
1386 model_config::parse("[model]\nprovider = \"openai\"\nmodel = \"gpt-5.4-mini\"\n")
1387 .unwrap_or_else(|e| panic!("{e}"))
1388 .unwrap_or_else(|| panic!("no model"));
1389 let fake: Arc<dyn ModelProvider> = agentplane::model::fake::FakeProvider::new();
1390 let run = Plane::open(dir.path(), &model, Arc::clone(&fake))
1391 .unwrap_or_else(|e| panic!("{}", e.0));
1392 let reader = open_reader(dir.path())
1393 .unwrap_or_else(|e| panic!("{}", e.0))
1394 .unwrap_or_else(|| panic!("no journal"));
1395 assert!(reader.is_read_only());
1396 assert!(Plane::open(dir.path(), &model, fake).is_err());
1397 drop(run);
1398 }
1399}