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;
18
19use std::path::Path;
20use std::sync::Arc;
21
22use agentplane::core::CorrelationKey;
23use agentplane::core::{ProtectedField, SourceId};
24use agentplane::manifest::{Budgets, Manifest, ModelRef, Models, ToolGrant};
25use agentplane::model::ModelProvider;
26use agentplane::prelude::*;
27use agentplane::runtime::{Agent, RunOutcome, RunTerms};
28use serde_json::Value;
29
30use self::model_config::ModelConfig;
31use self::propose::{CAPABILITY, Propose};
32
33pub const AGENTPLANE_VERSION: &str = "0.54.0";
36
37pub const JOURNAL: &str = "journal.redb";
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct PlaneError(pub String);
43
44pub fn manifest(model: &ModelConfig) -> Result<Manifest, PlaneError> {
47 manifest_with(model, &[])
48}
49
50pub fn manifest_with(
53 model: &ModelConfig,
54 mcp: &[crate::mcp::McpTool],
55) -> Result<Manifest, PlaneError> {
56 Manifest::builder("speccoder", env!("CARGO_PKG_VERSION"))
57 .configure(|spec| {
58 spec.capabilities.provides.push(CAPABILITY.to_owned());
59 spec.capabilities
60 .provides
61 .push(attempt::CAPABILITY.to_owned());
62 spec.capabilities
63 .provides
64 .push(acceptance::CAPABILITY.to_owned());
65 spec.capabilities
66 .provides
67 .push(converge::CAPABILITY.to_owned());
68 spec.capabilities
69 .provides
70 .push(monitor::CAPABILITY.to_owned());
71 spec.capabilities
72 .provides
73 .push(near_miss::CAPABILITY.to_owned());
74 spec.capabilities.provides.push(host::CAPABILITY.to_owned());
75 spec.models = Some(Models {
76 privileged: Some(ModelRef {
77 max_tokens: Some(model.max_output_tokens),
78 ..ModelRef::new(model.provider.clone(), model.model.clone())
79 }),
80 ..Models::default()
81 });
82 spec.security.max_sensitivity_egress = Some(Sensitivity::Internal);
84 spec.budgets = Some(Budgets {
85 max_tokens: Some(model.max_run_tokens),
86 ..Budgets::default()
87 });
88 let author =
95 agentplane::model::ModelId::new(model.provider.clone(), model.model.clone())
96 .source();
97 let catalog = crate::tools::catalog();
98 let mut sources: Vec<SourceId> = vec![
99 author,
100 SourceId::new(provenance::VERIFIER),
101 SourceId::new(provenance::WORKTREE),
102 SourceId::new(provenance::PERSON),
104 ];
105 sources.extend(catalog.iter().map(|t| provenance::tool(t.server, t.name)));
106 sources.extend(mcp.iter().map(|t| provenance::tool(&t.server, &t.tool)));
107 for t in catalog.iter().chain(crate::tools::internal().iter()) {
110 let mut grant = ToolGrant {
111 description: Some(t.description.to_owned()),
112 max_sensitivity: Some(Sensitivity::Internal),
113 mutates: t.mutates,
114 ..ToolGrant::new(agentplane::tools::ToolId::new(t.server, t.name).reference())
115 };
116 grant.protected_fields = t
117 .model_fields
118 .iter()
119 .map(|f| ProtectedField::from_sources(*f, sources.clone()))
120 .collect();
121 spec.tools.push(grant);
122 }
123 for t in mcp {
124 let mut grant = ToolGrant {
125 description: Some(t.description.clone()),
126 max_sensitivity: Some(Sensitivity::Internal),
127 mutates: true,
128 ..ToolGrant::new(agentplane::tools::ToolId::new(&t.server, &t.tool).reference())
129 };
130 grant.protected_fields = if t.fields.is_empty() {
131 vec![ProtectedField::one_of("", [serde_json::json!({})])]
134 } else {
135 t.fields
136 .iter()
137 .map(|f| ProtectedField::from_sources(f.as_str(), sources.clone()))
138 .collect()
139 };
140 spec.tools.push(grant);
141 }
142 })
143 .build()
144 .map_err(|e| PlaneError(format!("speccoder manifest: {e}")))
145}
146
147#[derive(Debug, Clone, Default)]
151pub struct Hooks {
152 pub progress: Option<Arc<dyn crate::progress::Progress>>,
154 pub mcp: Vec<(String, Arc<dyn agentplane::tools::ToolClient>)>,
156 pub mcp_tools: Vec<crate::mcp::McpTool>,
158 pub format: Option<&'static dyn crate::format::Adapter>,
160 pub present: crate::approvals::PresentContext,
162}
163
164#[derive(Debug, Clone, Default)]
166pub struct RecordedCalls {
167 pub calls: Vec<RecordedCall>,
169 pub unreadable: Vec<String>,
171}
172
173#[derive(Debug, Clone)]
175pub struct RecordedCall {
176 pub arguments: Value,
178 pub at: u64,
180}
181
182#[derive(Debug)]
184pub struct Inline {
185 pub outcome: RunOutcome,
187 pub waiting: Option<agentplane::core::Task>,
189 pub always: Vec<Always>,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct Always {
196 pub task: String,
198 pub call: String,
200 pub via: speccoder_core::actions::Via,
202}
203
204pub struct Plane {
206 runtime: Arc<Runtime>,
207 pub replaced_journal: Option<std::path::PathBuf>,
209 _run: Option<RunClaim>,
211}
212
213static RUNS: std::sync::Mutex<Vec<std::path::PathBuf>> = std::sync::Mutex::new(Vec::new());
216
217struct RunClaim(std::path::PathBuf);
218
219impl RunClaim {
220 fn take(dir: &Path) -> Result<Self, PlaneError> {
221 let path = dir.join(JOURNAL);
222 let mut runs = RUNS
223 .lock()
224 .map_err(|_| PlaneError("the run registry is poisoned".to_owned()))?;
225 if runs.contains(&path) {
226 return Err(PlaneError(format!(
227 "another speccoder is working on this project (a run in this process holds its journal in {})",
228 dir.display()
229 )));
230 }
231 runs.push(path.clone());
232 Ok(Self(path))
233 }
234}
235
236impl Drop for RunClaim {
237 fn drop(&mut self) {
238 if let Ok(mut runs) = RUNS.lock() {
239 runs.retain(|p| *p != self.0);
240 }
241 }
242}
243
244static OPEN: std::sync::Mutex<Vec<(std::path::PathBuf, std::sync::Weak<RedbStore>)>> =
248 std::sync::Mutex::new(Vec::new());
249
250fn open_or_replace(dir: &Path) -> Result<(Arc<RedbStore>, Option<std::path::PathBuf>), PlaneError> {
257 std::fs::create_dir_all(dir).map_err(|e| PlaneError(format!("{}: {e}", dir.display())))?;
258 let path = dir.join(JOURNAL);
259 let mut open = OPEN
260 .lock()
261 .map_err(|_| PlaneError("the journal registry is poisoned".to_owned()))?;
262 open.retain(|(_, w)| w.strong_count() > 0);
263 if let Some(store) = open
264 .iter()
265 .find(|(p, _)| *p == path)
266 .and_then(|(_, w)| w.upgrade())
267 {
268 return Ok((store, None));
269 }
270 let (store, aside) = open_fresh(dir, &path)?;
271 open.push((path, Arc::downgrade(&store)));
272 Ok((store, aside))
273}
274
275fn open_fresh(
276 dir: &Path,
277 path: &Path,
278) -> Result<(Arc<RedbStore>, Option<std::path::PathBuf>), PlaneError> {
279 match RedbStore::open_shared(path) {
280 Ok(store) => Ok((Arc::new(store), None)),
281 Err(agentplane::core::StoreError::InUse(e)) => Err(PlaneError(format!(
283 "another speccoder is working on this project (its journal in {} is in use): {e}",
284 dir.display()
285 ))),
286 Err(agentplane::core::StoreError::Unreadable(e)) => {
289 let stamp = std::time::SystemTime::now()
290 .duration_since(std::time::UNIX_EPOCH)
291 .map_or(0, |d| d.as_secs());
292 let aside = dir.join(format!("journal.{stamp}.unreadable.redb"));
293 std::fs::rename(path, &aside).map_err(|r| {
294 PlaneError(format!(
295 "the journal is unreadable ({e}) and cannot be moved aside: {r}"
296 ))
297 })?;
298 RedbStore::open_shared(path)
299 .map(|store| (Arc::new(store), Some(aside)))
300 .map_err(|e| PlaneError(format!("cannot open a fresh journal: {e}")))
301 }
302 Err(e) => Err(PlaneError(format!(
304 "cannot open the journal in {}: {e}",
305 dir.display()
306 ))),
307 }
308}
309
310pub fn open_reader(dir: &Path) -> Result<Option<Arc<RedbStore>>, PlaneError> {
319 let path = dir.join(JOURNAL);
320 if !path.exists() {
321 return Ok(None);
322 }
323 match RedbStore::open_read_only(&path) {
324 Ok(store) => Ok(Some(Arc::new(store))),
325 Err(agentplane::core::StoreError::InUse(e)) => Err(PlaneError(format!(
326 "another process holds the journal in {} alone (a speccoder build that does not share it): {e}",
327 dir.display()
328 ))),
329 Err(agentplane::core::StoreError::Unreadable(e)) if needs_repair(&e) => {
334 Err(PlaneError(format!(
335 "the last run ended without closing its journal in {}; the next run or decision repairs it ({e})",
336 dir.display()
337 )))
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
365fn needs_repair(detail: &str) -> bool {
369 detail.to_lowercase().contains("repair aborted")
370}
371
372#[must_use]
375pub fn provider_refusal(failure: &str) -> Option<String> {
376 let start = failure.find("refused the request: ")? + "refused the request: ".len();
377 let rest = &failure[start..];
378 let end = rest
379 .find("); ")
380 .or_else(|| rest.rfind(')'))
381 .unwrap_or(rest.len());
382 Some(rest[..end].trim_end_matches(['"', ')']).trim().to_owned())
383}
384
385pub async fn refused_account(
396 dir: &Path,
397) -> Result<Option<agentplane::journal::FailedEffect>, PlaneError> {
398 use agentplane::journal::JournalStore as _;
399 let Some(store) = open_reader(dir)? else {
400 return Ok(None);
401 };
402 let mut after: Option<(u64, agentplane::core::RunId)> = None;
403 loop {
404 let page = store
405 .recent_runs(after, 500)
406 .await
407 .map_err(|e| PlaneError(format!("listing the journal's runs: {e}")))?;
408 let Some(last) = page.last().cloned() else {
409 return Ok(None);
410 };
411 for (run, _) in &page {
412 let records = store
413 .read(*run, 0)
414 .await
415 .map_err(|e| PlaneError(format!("run {run}: {e}")))?;
416 if let Some(failed) = agentplane::journal::failed_effect(&records)
417 .filter(agentplane::journal::FailedEffect::refuses_the_account)
418 {
419 return Ok(Some(failed));
420 }
421 }
422 after = Some((last.1, last.0));
423 }
424}
425
426fn builder(
430 store: Arc<RedbStore>,
431 model: &ModelConfig,
432 hooks: Hooks,
433) -> Result<(agentplane::runtime::RuntimeBuilder, Manifest), PlaneError> {
434 let manifest = manifest_with(model, &hooks.mcp_tools)?;
435 let format = hooks.format.unwrap_or_else(crate::format::reference);
436 let agent = Agent::new(&manifest)
437 .skill(Propose {
438 cache_ttl: model.cache_ttl,
439 style: model.prompt_style(),
440 format,
441 })
442 .skill(attempt::Attempt {
443 style: model.prompt_style(),
444 cache_ttl: model.cache_ttl,
445 progress: hooks.progress.clone(),
446 format,
447 present: hooks.present.clone(),
448 })
449 .skill(host::Host {
450 style: model.prompt_style(),
451 progress: hooks.progress,
452 format,
453 present: hooks.present,
454 })
455 .skill(acceptance::Acceptance {
456 style: model.prompt_style(),
457 cache_ttl: model.cache_ttl,
458 format,
459 })
460 .skill(converge::Converge {
461 style: model.prompt_style(),
462 cache_ttl: model.cache_ttl,
463 format,
464 })
465 .skill(monitor::Monitor {
466 style: model.prompt_style(),
467 cache_ttl: model.cache_ttl,
468 format,
469 })
470 .skill(near_miss::NearMiss {
471 style: model.prompt_style(),
472 cache_ttl: model.cache_ttl,
473 format,
474 });
475 let builder = Runtime::builder_on(store).agent(agent);
478 Ok((builder, manifest))
479}
480
481fn build(
482 store: Arc<RedbStore>,
483 model: &ModelConfig,
484 provider: Arc<dyn ModelProvider>,
485 tools: Option<Arc<crate::tools::WorkspaceTools>>,
486 hooks: Hooks,
487) -> Result<Arc<Runtime>, PlaneError> {
488 let mcp = hooks.mcp.clone();
489 let (mut builder, _) = builder(store, model, hooks)?;
490 builder = builder.provider(model.provider.clone(), provider);
491 let client: Arc<dyn agentplane::tools::ToolClient> = match tools {
494 Some(t) => t,
495 None => Arc::new(Unbound),
496 };
497 for server in ["fs", "proc", "git"] {
498 builder = builder.tool_server(server, Arc::clone(&client));
499 }
500 for (server, client) in &mcp {
501 builder = builder.tool_server(server.as_str(), Arc::clone(client));
502 }
503 builder
504 .try_build()
505 .map_err(|e| PlaneError(format!("building the plane: {e}")))
506}
507
508impl Plane {
509 pub fn open(
512 dir: &Path,
513 model: &ModelConfig,
514 provider: Arc<dyn ModelProvider>,
515 ) -> Result<Self, PlaneError> {
516 Self::open_with_tools(dir, model, provider, None)
517 }
518
519 pub fn open_with_tools(
521 dir: &Path,
522 model: &ModelConfig,
523 provider: Arc<dyn ModelProvider>,
524 tools: Option<Arc<crate::tools::WorkspaceTools>>,
525 ) -> Result<Self, PlaneError> {
526 Self::open_with(dir, model, provider, tools, Hooks::default())
527 }
528
529 pub fn open_with(
531 dir: &Path,
532 model: &ModelConfig,
533 provider: Arc<dyn ModelProvider>,
534 tools: Option<Arc<crate::tools::WorkspaceTools>>,
535 hooks: Hooks,
536 ) -> Result<Self, PlaneError> {
537 let claim = RunClaim::take(dir)?;
538 let (store, replaced_journal) = open_or_replace(dir)?;
539 Ok(Self {
540 runtime: build(store, model, provider, tools, hooks)?,
541 replaced_journal,
542 _run: Some(claim),
543 })
544 }
545
546 pub fn open_for_decisions(dir: &Path) -> Result<Self, PlaneError> {
550 let (store, replaced_journal) = open_or_replace(dir)?;
551 let runtime = Runtime::builder_on(store)
552 .try_build()
553 .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
554 Ok(Self {
555 runtime,
556 replaced_journal,
557 _run: None,
558 })
559 }
560
561 pub fn open_for_reading(dir: &Path) -> Result<Option<Self>, PlaneError> {
570 let Some(store) = open_reader(dir)? else {
571 return Ok(None);
572 };
573 let runtime = Runtime::builder_on(store)
574 .try_build()
575 .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
576 Ok(Some(Self {
577 runtime,
578 replaced_journal: None,
579 _run: None,
580 }))
581 }
582
583 pub async fn expire_due(&self, now: agentplane::core::Timestamp) -> Result<(), PlaneError> {
588 self.runtime
589 .sweep(now, std::time::Duration::from_secs(7 * 86_400))
590 .await
591 .map(|_| ())
592 .map_err(|e| PlaneError(e.to_string()))
593 }
594
595 pub async fn withdraw(
598 &self,
599 task: &agentplane::core::Task,
600 by: &agentplane::core::Operator,
601 reason: &str,
602 ) -> Result<(), PlaneError> {
603 let err = |e: String| PlaneError(e);
604 let _ = self
605 .runtime
606 .request_cancel(task.run, by, reason)
607 .await
608 .map_err(|e| err(e.to_string()))?;
609 if let Some(tasks) = self.runtime.tasks() {
610 tasks
611 .withdraw_run(task.run, &[task.id])
612 .await
613 .map_err(|e| err(e.to_string()))?;
614 }
615 Ok(())
616 }
617
618 pub async fn waiting_on(
620 &self,
621 outcome: &RunOutcome,
622 ) -> Result<Option<agentplane::core::Task>, PlaneError> {
623 let RunStatus::Suspended(reason) = &outcome.status else {
624 return Ok(None);
625 };
626 let Some(id) = reason.awaited_task() else {
627 return Ok(None);
628 };
629 self.task(id).await
630 }
631
632 pub async fn task(
634 &self,
635 id: agentplane::core::TaskId,
636 ) -> Result<Option<agentplane::core::Task>, PlaneError> {
637 let Some(tasks) = self.runtime.tasks() else {
638 return Ok(None);
639 };
640 tasks.task(id).await.map_err(|e| PlaneError(e.to_string()))
641 }
642
643 pub async fn tool_calls(&self, server: &str, tool: &str) -> Result<RecordedCalls, PlaneError> {
648 let journal = self.runtime.journal();
649 let mut out = RecordedCalls::default();
650 let mut after: Option<(u64, agentplane::core::RunId)> = None;
651 loop {
652 let page = journal
653 .recent_runs(after, 500)
654 .await
655 .map_err(|e| PlaneError(format!("listing the journal's runs: {e}")))?;
656 let Some(last) = page.last().cloned() else {
657 break;
658 };
659 for (run, at) in &page {
660 let records = match journal.read(*run, 0).await {
661 Ok(r) => r,
662 Err(e) => {
663 out.unreadable.push(format!("run {run}: {e}"));
664 continue;
665 }
666 };
667 let mut seen = std::collections::BTreeSet::new();
668 for record in records {
669 let agentplane::RecordKind::EffectStarted { descriptor, .. } = record.kind()
670 else {
671 continue;
672 };
673 let args = &descriptor.args;
674 if descriptor.kind != "tool.call"
675 || args["server"] != server
676 || args["tool"] != tool
677 {
678 continue;
679 }
680 let key = record
682 .body
683 .effect_key
684 .as_ref()
685 .map_or_else(|| record.body.seq.to_string(), |k| format!("{k:?}"));
686 if seen.insert(key) {
687 out.calls.push(RecordedCall {
688 arguments: args["arguments"].clone(),
689 at: *at,
690 });
691 }
692 }
693 }
694 after = Some((last.1, last.0));
695 }
696 Ok(out)
697 }
698
699 pub async fn holds_a_pre_054_decision(&self, run: agentplane::core::RunId) -> bool {
704 let Ok(records) = self.runtime.journal().read(run, 0).await else {
705 return false;
706 };
707 records.iter().any(|r| {
708 serde_json::to_value(r.kind())
709 .ok()
710 .is_some_and(|v| holds_a_pre_054_decision(&v))
711 })
712 }
713
714 pub async fn open_tasks(&self) -> Result<Vec<agentplane::core::Task>, PlaneError> {
716 let Some(tasks) = self.runtime.tasks() else {
717 return Ok(Vec::new());
718 };
719 let mut open = tasks
720 .queue(&[], 1000)
721 .await
722 .map_err(|e| PlaneError(e.to_string()))?;
723 open.sort_by_key(|t| t.created_at);
724 Ok(open)
725 }
726
727 pub async fn decide(
730 &self,
731 task: agentplane::core::TaskId,
732 decision: &agentplane::core::Decision,
733 ) -> Result<agentplane::core::Delivery, PlaneError> {
734 self.decide_at(task, decision, None).await
735 }
736
737 pub async fn decide_at(
740 &self,
741 task: agentplane::core::TaskId,
742 decision: &agentplane::core::Decision,
743 expected: Option<agentplane::core::Digest>,
744 ) -> Result<agentplane::core::Delivery, PlaneError> {
745 self.runtime
746 .decide_task_at(task, decision, &[], expected)
747 .await
748 .map_err(|e| PlaneError(e.to_string()))
749 }
750
751 pub async fn decide_inline(
759 &self,
760 mut outcome: RunOutcome,
761 approver: Option<&dyn crate::approvals::Approver>,
762 context: &crate::approvals::PresentContext,
763 ) -> Result<Inline, PlaneError> {
764 let mut always = Vec::new();
765 while let Some(task) = self.waiting_on(&outcome).await? {
766 let Some((decision, answer)) =
767 approver.and_then(|a| crate::approvals::tasks::ask(a, &task, context))
768 else {
769 return Ok(Inline {
770 outcome,
771 waiting: Some(task),
772 always,
773 });
774 };
775 self.decide(task.id, &decision).await?;
776 if answer.always && decision.approved() {
777 let via = answer
778 .by
779 .as_ref()
780 .and_then(|b| speccoder_core::actions::Via::read(&b.via))
781 .map_or(speccoder_core::actions::Via::Terminal, |(v, _)| v);
782 crate::approvals::answered_always(&task.id.to_hex());
783 always.push(Always {
784 task: crate::approvals::tasks::request_of(&task.justification.proposed_action)
785 .map(|r| r.task)
786 .unwrap_or_default(),
787 call: task.id.to_hex(),
788 via,
789 });
790 }
791 match self.outcome(outcome.run_id).await? {
792 Some(o) => outcome = o,
793 None => break,
794 }
795 }
796 Ok(Inline {
797 outcome,
798 waiting: None,
799 always,
800 })
801 }
802
803 pub async fn outcome(
807 &self,
808 run: agentplane::core::RunId,
809 ) -> Result<Option<RunOutcome>, PlaneError> {
810 if self
811 .runtime
812 .recorded_outcome(run)
813 .await
814 .map_err(|e| PlaneError(e.to_string()))?
815 .is_none()
816 {
817 return Ok(None);
818 }
819 self.runtime
820 .replay(run, agentplane::runtime::Mode::Strict)
821 .await
822 .map(Some)
823 .map_err(|e| PlaneError(e.to_string()))
824 }
825
826 #[doc(hidden)]
829 pub fn on_store(
830 store: Arc<RedbStore>,
831 model: &ModelConfig,
832 provider: Arc<dyn ModelProvider>,
833 tools: Option<Arc<crate::tools::WorkspaceTools>>,
834 ) -> Result<Self, PlaneError> {
835 Ok(Self {
836 runtime: build(store, model, provider, tools, Hooks::default())?,
837 replaced_journal: None,
838 _run: None,
839 })
840 }
841
842 #[doc(hidden)]
845 pub async fn resume(
846 &self,
847 run: agentplane::core::RunId,
848 ) -> Result<RunOutcome, agentplane::RuntimeError> {
849 self.runtime
850 .replay(run, agentplane::runtime::Mode::Resume)
851 .await
852 }
853
854 pub async fn converge(
856 &self,
857 feature: &str,
858 input: Tainted<Value>,
859 ) -> Result<RunOutcome, PlaneError> {
860 self.runtime
861 .start(converge::CAPABILITY, input)
862 .terms(RunTerms::default().correlated(
863 "feature",
864 &[CorrelationKey::new("feature", feature.to_owned())],
865 ))
866 .run()
867 .await
868 .map_err(|e| PlaneError(e.to_string()))
869 }
870
871 pub async fn skill(&self, name: &str, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
873 self.runtime
874 .start(host::CAPABILITY, input)
875 .terms(
876 RunTerms::default()
877 .correlated("skill", &[CorrelationKey::new("skill", name.to_owned())]),
878 )
879 .run()
880 .await
881 .map_err(|e| PlaneError(e.to_string()))
882 }
883
884 pub async fn monitor(
888 &self,
889 feature: &str,
890 task: &str,
891 input: Tainted<Value>,
892 ) -> Result<RunOutcome, PlaneError> {
893 self.judge_once(monitor::CAPABILITY, feature, task, input)
894 .await
895 }
896
897 pub async fn near_miss(
901 &self,
902 feature: &str,
903 task: &str,
904 input: Tainted<Value>,
905 ) -> Result<RunOutcome, PlaneError> {
906 self.judge_once(near_miss::CAPABILITY, feature, task, input)
907 .await
908 }
909
910 async fn judge_once(
914 &self,
915 capability: &str,
916 feature: &str,
917 task: &str,
918 input: Tainted<Value>,
919 ) -> Result<RunOutcome, PlaneError> {
920 use agentplane::runtime::{Admission, Mode};
921 use sha2::Digest as _;
922 let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
923 let digest = sha2::Sha256::digest(
924 format!("{capability}\n{feature}/{task}\n{}", input.peek()).as_bytes(),
925 );
926 let key: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect();
927 let key = format!("judge-{key}");
928 let key = key.as_str();
929 let terms = || {
930 RunTerms::default().correlated(
931 "task",
932 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
933 )
934 };
935 let admitted = self
936 .runtime
937 .start(capability, input.clone())
938 .terms(terms())
939 .once(key)
940 .run()
941 .await
942 .map_err(err)?;
943 match admitted {
944 Admission::Fresh(o) => Ok(o),
945 Admission::Replayed(o) if !matches!(o.status, RunStatus::Succeeded) => self
948 .runtime
949 .start(capability, input)
950 .terms(terms())
951 .run()
952 .await
953 .map_err(err),
954 Admission::Replayed(o) => self
955 .runtime
956 .replay(o.run_id, Mode::Strict)
957 .await
958 .map_err(err),
959 Admission::InFlight(run) => self.runtime.replay(run, Mode::Resume).await.map_err(err),
960 }
961 }
962
963 pub async fn acceptance(
965 &self,
966 feature: &str,
967 task: &str,
968 input: Tainted<Value>,
969 ) -> Result<RunOutcome, PlaneError> {
970 self.runtime
971 .start(acceptance::CAPABILITY, input)
972 .terms(RunTerms::default().correlated(
973 "task",
974 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
975 ))
976 .run()
977 .await
978 .map_err(|e| PlaneError(e.to_string()))
979 }
980
981 pub async fn attempt(
983 &self,
984 feature: &str,
985 task: &str,
986 input: Tainted<Value>,
987 ) -> Result<RunOutcome, PlaneError> {
988 self.runtime
989 .start(attempt::CAPABILITY, input)
990 .terms(RunTerms::default().correlated(
991 "task",
992 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
993 ))
994 .run()
995 .await
996 .map_err(|e| PlaneError(e.to_string()))
997 }
998
999 pub async fn attempt_keyed(
1007 &self,
1008 feature: &str,
1009 task: &str,
1010 input: Tainted<Value>,
1011 key: &str,
1012 ) -> Result<(RunOutcome, bool), PlaneError> {
1013 use agentplane::runtime::{Admission, Mode};
1014 let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
1015 let admitted = self
1016 .runtime
1017 .start(attempt::CAPABILITY, input)
1018 .terms(RunTerms::default().correlated(
1019 "task",
1020 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1021 ))
1022 .once(key)
1023 .run()
1024 .await
1025 .map_err(err)?;
1026 match admitted {
1027 Admission::Fresh(o) => Ok((o, false)),
1028 Admission::Replayed(o) if matches!(o.status, RunStatus::Suspended(_)) => self
1033 .runtime
1034 .replay(o.run_id, Mode::Resume)
1035 .await
1036 .map(|o| (o, true))
1037 .map_err(err),
1038 Admission::Replayed(o) => self
1039 .runtime
1040 .replay(o.run_id, Mode::Strict)
1041 .await
1042 .map(|o| (o, true))
1043 .map_err(err),
1044 Admission::InFlight(run) => self
1045 .runtime
1046 .replay(run, Mode::Resume)
1047 .await
1048 .map(|o| (o, true))
1049 .map_err(err),
1050 }
1051 }
1052
1053 pub async fn propose(
1055 &self,
1056 feature: &str,
1057 task: &str,
1058 input: Tainted<Value>,
1059 ) -> Result<RunOutcome, PlaneError> {
1060 self.runtime
1061 .start(CAPABILITY, input)
1062 .terms(RunTerms::default().correlated(
1063 "task",
1064 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1065 ))
1066 .run()
1067 .await
1068 .map_err(|e| PlaneError(e.to_string()))
1069 }
1070}
1071
1072pub async fn admitted(
1075 dir: &Path,
1076 run: &str,
1077) -> Result<Option<agentplane::core::Tainted<Value>>, PlaneError> {
1078 let id = agentplane::core::RunId::parse(run)
1079 .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
1080 let Some(store) = open_reader(dir)? else {
1081 return Ok(None);
1082 };
1083 let history = store
1084 .read(id, 1)
1085 .await
1086 .map_err(|e| PlaneError(e.to_string()))?;
1087 Ok(history
1088 .iter()
1089 .find_map(agentplane::journal::Record::admitted_input))
1090}
1091
1092pub async fn replay_strict(
1100 dir: &Path,
1101 model: &ModelConfig,
1102 run: &str,
1103 format: &'static dyn crate::format::Adapter,
1104) -> Result<Option<agentplane::runtime::Verdict>, PlaneError> {
1105 let id = agentplane::core::RunId::parse(run)
1106 .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
1107 let Some(store) = open_reader(dir)? else {
1108 return Ok(None);
1109 };
1110 let history = store
1111 .read(id, 1)
1112 .await
1113 .map_err(|e| PlaneError(e.to_string()))?;
1114 if history.is_empty() {
1115 return Ok(None);
1116 }
1117 if history.iter().any(|r| {
1120 serde_json::to_value(r.kind())
1121 .ok()
1122 .is_some_and(|v| holds_a_pre_054_decision(&v))
1123 }) {
1124 return Err(PlaneError(format!(
1125 "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"
1126 )));
1127 }
1128 let mcp_tools: Vec<crate::mcp::McpTool> = history
1132 .iter()
1133 .find_map(agentplane::journal::Record::admitted_input)
1134 .and_then(|a| serde_json::from_value(a.peek()["settings"]["mcp"].clone()).ok())
1135 .unwrap_or_default();
1136 let hooks = Hooks {
1137 mcp_tools,
1138 format: Some(format),
1139 ..Hooks::default()
1140 };
1141 let (builder, manifest) = builder(store, model, hooks)?;
1142 let runtime = agentplane::runtime::replay_only::wire(builder, &[manifest], &history)
1143 .try_build()
1144 .map_err(|e| PlaneError(format!("building the replay plane: {e}")))?;
1145 runtime
1146 .verify(id)
1147 .await
1148 .map(Some)
1149 .map_err(|e| PlaneError(e.to_string()))
1150}
1151
1152#[must_use]
1157pub fn said(completion: &agentplane::model::Completion) -> Option<String> {
1158 let commentary = completion.commentary.as_deref().map(str::trim);
1159 let text = (!completion.tool_calls.is_empty()).then(|| completion.text.trim());
1160 let parts: Vec<&str> = [commentary, text]
1161 .into_iter()
1162 .flatten()
1163 .filter(|t| !t.is_empty())
1164 .collect();
1165 (!parts.is_empty()).then(|| parts.join("\n\n"))
1166}
1167
1168#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1172pub struct AccountTurn {
1173 pub turn: u32,
1175 pub reasoning: Option<String>,
1177 pub said: Option<String>,
1180}
1181
1182pub async fn account(dir: &Path, run: &str) -> Result<Vec<AccountTurn>, PlaneError> {
1189 let Ok(id) = agentplane::core::RunId::parse(run) else {
1190 return Ok(Vec::new());
1191 };
1192 let Some(store) = open_reader(dir)? else {
1193 return Ok(Vec::new());
1194 };
1195 let history = store
1196 .read(id, 1)
1197 .await
1198 .map_err(|e| PlaneError(e.to_string()))?;
1199 let text = |t: &str| {
1200 let t = t.trim();
1201 (!t.is_empty()).then(|| crate::approvals::present::escape(t))
1202 };
1203 Ok(agentplane::model::turns(&history)
1204 .into_iter()
1205 .zip(1u32..)
1206 .filter_map(|(t, turn)| {
1207 let c = t.completion?;
1208 let said = said(&c).as_deref().and_then(text);
1209 let reasoning = c.reasoning.as_deref().and_then(text);
1210 (said.is_some() || reasoning.is_some()).then_some(AccountTurn {
1211 turn,
1212 reasoning,
1213 said,
1214 })
1215 })
1216 .collect())
1217}
1218
1219#[derive(Debug)]
1221struct Unbound;
1222
1223#[async_trait]
1224impl agentplane::tools::ToolClient for Unbound {
1225 async fn call(
1226 &self,
1227 tool: &agentplane::tools::ToolId,
1228 _arguments: &Value,
1229 _provenance: Option<&agentplane::core::Provenance>,
1230 ) -> Result<Value, agentplane::tools::ToolError> {
1231 Err(agentplane::tools::ToolError::Refused {
1232 tool: tool.clone(),
1233 detail: "no worktree is bound to this plane".to_owned(),
1234 })
1235 }
1236
1237 fn destination(&self, _tool: &agentplane::tools::ToolId) -> agentplane::tools::Destination {
1238 agentplane::tools::Destination::Local
1239 }
1240}
1241
1242#[cfg(test)]
1243mod tests {
1244 use super::*;
1245
1246 #[test]
1247 fn the_agentplane_version_named_in_summaries_is_the_pinned_one() {
1248 let workspace = include_str!("../../../../Cargo.toml");
1249 assert!(
1250 workspace.contains(&format!(
1251 "agentplane = {{ version = \"={AGENTPLANE_VERSION}\""
1252 )),
1253 "AGENTPLANE_VERSION is not the workspace's pin"
1254 );
1255 }
1256
1257 #[test]
1258 fn a_provider_refusal_is_read_from_the_run_failure() {
1259 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\")";
1260 let r = provider_refusal(failure).unwrap();
1261 assert_eq!(
1262 r,
1263 "credit_balance_exhausted: You have no credits remaining. Add credits at https://platform.openai.com/settings/organization/billing/."
1264 );
1265 assert_eq!(provider_refusal("Failed(\"timeout\")"), None);
1266 }
1267
1268 #[test]
1269 fn an_unreadable_journal_is_moved_aside_and_a_locked_one_is_an_error() {
1270 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1271 std::fs::write(dir.path().join(JOURNAL), b"written by another format")
1272 .unwrap_or_else(|e| panic!("{e}"));
1273 let (store, aside) = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1274 let aside = aside.unwrap_or_else(|| panic!("moved aside"));
1275 assert!(aside.exists() && dir.path().join(JOURNAL).exists());
1276 let shared = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1278 assert!(Arc::ptr_eq(&store, &shared.0) && shared.1.is_none());
1279 drop((store, shared));
1280 let held = RedbStore::open(dir.path().join(JOURNAL)).unwrap_or_else(|e| panic!("{e}"));
1283 let started = std::time::Instant::now();
1284 let second = open_or_replace(dir.path())
1285 .err()
1286 .map(|e| e.0)
1287 .unwrap_or_default();
1288 assert!(second.contains("another speccoder"), "{second}");
1289 assert!(started.elapsed() < std::time::Duration::from_millis(500));
1290 assert!(open_reader(dir.path()).is_err());
1293 drop(held);
1294 assert!(
1295 open_or_replace(dir.path())
1296 .unwrap_or_else(|e| panic!("{}", e.0))
1297 .1
1298 .is_none()
1299 );
1300 }
1301
1302 #[test]
1303 fn a_reader_creates_nothing_and_reads_beside_this_process_s_writer() {
1304 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1305 assert!(
1307 open_reader(dir.path())
1308 .unwrap_or_else(|e| panic!("{}", e.0))
1309 .is_none()
1310 );
1311 assert!(
1312 Plane::open_for_reading(dir.path())
1313 .unwrap_or_else(|e| panic!("{}", e.0))
1314 .is_none()
1315 );
1316 assert!(
1317 !dir.path().join(JOURNAL).exists(),
1318 "a read created a journal"
1319 );
1320 let model =
1323 model_config::parse("[model]\nprovider = \"openai\"\nmodel = \"gpt-5.4-mini\"\n")
1324 .unwrap_or_else(|e| panic!("{e}"))
1325 .unwrap_or_else(|| panic!("no model"));
1326 let fake: Arc<dyn ModelProvider> = agentplane::model::fake::FakeProvider::new();
1327 let run = Plane::open(dir.path(), &model, Arc::clone(&fake))
1328 .unwrap_or_else(|e| panic!("{}", e.0));
1329 let reader = open_reader(dir.path())
1330 .unwrap_or_else(|e| panic!("{}", e.0))
1331 .unwrap_or_else(|| panic!("no journal"));
1332 assert!(reader.is_read_only());
1333 assert!(Plane::open(dir.path(), &model, fake).is_err());
1334 drop(run);
1335 }
1336}