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.52.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 {
108 let mut grant = ToolGrant {
109 description: Some(t.description.to_owned()),
110 max_sensitivity: Some(Sensitivity::Internal),
111 mutates: t.mutates,
112 ..ToolGrant::new(agentplane::tools::ToolId::new(t.server, t.name).reference())
113 };
114 grant.protected_fields = t
115 .model_fields
116 .iter()
117 .map(|f| ProtectedField::from_sources(*f, sources.clone()))
118 .collect();
119 spec.tools.push(grant);
120 }
121 for t in mcp {
122 let mut grant = ToolGrant {
123 description: Some(t.description.clone()),
124 max_sensitivity: Some(Sensitivity::Internal),
125 mutates: true,
126 ..ToolGrant::new(agentplane::tools::ToolId::new(&t.server, &t.tool).reference())
127 };
128 grant.protected_fields = if t.fields.is_empty() {
129 vec![ProtectedField::one_of("", [serde_json::json!({})])]
132 } else {
133 t.fields
134 .iter()
135 .map(|f| ProtectedField::from_sources(f.as_str(), sources.clone()))
136 .collect()
137 };
138 spec.tools.push(grant);
139 }
140 })
141 .build()
142 .map_err(|e| PlaneError(format!("speccoder manifest: {e}")))
143}
144
145#[derive(Debug, Clone, Default)]
149pub struct Hooks {
150 pub progress: Option<Arc<dyn crate::progress::Progress>>,
152 pub mcp: Vec<(String, Arc<dyn agentplane::tools::ToolClient>)>,
154 pub mcp_tools: Vec<crate::mcp::McpTool>,
156 pub format: Option<&'static dyn crate::format::Adapter>,
158 pub present: crate::approvals::PresentContext,
160}
161
162pub struct Plane {
164 runtime: Arc<Runtime>,
165 pub replaced_journal: Option<std::path::PathBuf>,
167}
168
169fn open_store(dir: &Path) -> Result<Arc<RedbStore>, PlaneError> {
170 open_or_replace(dir).map(|(store, _)| store)
171}
172
173fn open_or_replace(dir: &Path) -> Result<(Arc<RedbStore>, Option<std::path::PathBuf>), PlaneError> {
178 std::fs::create_dir_all(dir).map_err(|e| PlaneError(format!("{}: {e}", dir.display())))?;
179 let path = dir.join(JOURNAL);
180 match RedbStore::open(&path) {
181 Ok(store) => Ok((Arc::new(store), None)),
182 Err(agentplane::core::StoreError::InUse(e)) => Err(PlaneError(format!(
184 "another speccoder is working on this project (its journal in {} is in use): {e}",
185 dir.display()
186 ))),
187 Err(agentplane::core::StoreError::Unreadable(e)) => {
190 let stamp = std::time::SystemTime::now()
191 .duration_since(std::time::UNIX_EPOCH)
192 .map_or(0, |d| d.as_secs());
193 let aside = dir.join(format!("journal.{stamp}.unreadable.redb"));
194 std::fs::rename(&path, &aside).map_err(|r| {
195 PlaneError(format!(
196 "the journal is unreadable ({e}) and cannot be moved aside: {r}"
197 ))
198 })?;
199 RedbStore::open(&path)
200 .map(|store| (Arc::new(store), Some(aside)))
201 .map_err(|e| PlaneError(format!("cannot open a fresh journal: {e}")))
202 }
203 Err(e) => Err(PlaneError(format!(
205 "cannot open the journal in {}: {e}",
206 dir.display()
207 ))),
208 }
209}
210
211#[must_use]
214pub fn provider_refusal(failure: &str) -> Option<String> {
215 let start = failure.find("refused the request: ")? + "refused the request: ".len();
216 let rest = &failure[start..];
217 let end = rest
218 .find("); ")
219 .or_else(|| rest.rfind(')'))
220 .unwrap_or(rest.len());
221 Some(rest[..end].trim_end_matches(['"', ')']).trim().to_owned())
222}
223
224#[must_use]
228pub fn account_refusal(refusal: &str) -> bool {
229 let r = refusal.to_lowercase();
230 [
231 "credit",
232 "billing",
233 "quota",
234 "insufficient",
235 "api key",
236 "api_key",
237 "authentication",
238 "unauthorized",
239 ]
240 .iter()
241 .any(|w| r.contains(w))
242}
243
244fn builder(
248 store: Arc<RedbStore>,
249 model: &ModelConfig,
250 hooks: Hooks,
251) -> Result<(agentplane::runtime::RuntimeBuilder, Manifest), PlaneError> {
252 let manifest = manifest_with(model, &hooks.mcp_tools)?;
253 let format = hooks.format.unwrap_or_else(crate::format::reference);
254 let agent = Agent::new(&manifest)
255 .skill(Propose {
256 cache_ttl: model.cache_ttl,
257 style: model.prompt_style(),
258 format,
259 })
260 .skill(attempt::Attempt {
261 style: model.prompt_style(),
262 cache_ttl: model.cache_ttl,
263 progress: hooks.progress.clone(),
264 format,
265 present: hooks.present.clone(),
266 })
267 .skill(host::Host {
268 style: model.prompt_style(),
269 progress: hooks.progress,
270 format,
271 present: hooks.present,
272 })
273 .skill(acceptance::Acceptance {
274 style: model.prompt_style(),
275 cache_ttl: model.cache_ttl,
276 format,
277 })
278 .skill(converge::Converge {
279 style: model.prompt_style(),
280 cache_ttl: model.cache_ttl,
281 format,
282 })
283 .skill(monitor::Monitor {
284 style: model.prompt_style(),
285 cache_ttl: model.cache_ttl,
286 format,
287 })
288 .skill(near_miss::NearMiss {
289 style: model.prompt_style(),
290 cache_ttl: model.cache_ttl,
291 format,
292 });
293 let builder = Runtime::builder_on(store).agent(agent);
296 Ok((builder, manifest))
297}
298
299fn build(
300 store: Arc<RedbStore>,
301 model: &ModelConfig,
302 provider: Arc<dyn ModelProvider>,
303 tools: Option<Arc<crate::tools::WorkspaceTools>>,
304 hooks: Hooks,
305) -> Result<Arc<Runtime>, PlaneError> {
306 let mcp = hooks.mcp.clone();
307 let (mut builder, _) = builder(store, model, hooks)?;
308 builder = builder.provider(model.provider.clone(), provider);
309 let client: Arc<dyn agentplane::tools::ToolClient> = match tools {
312 Some(t) => t,
313 None => Arc::new(Unbound),
314 };
315 for server in ["fs", "proc", "git"] {
316 builder = builder.tool_server(server, Arc::clone(&client));
317 }
318 for (server, client) in &mcp {
319 builder = builder.tool_server(server.as_str(), Arc::clone(client));
320 }
321 builder
322 .try_build()
323 .map_err(|e| PlaneError(format!("building the plane: {e}")))
324}
325
326impl Plane {
327 pub fn open(
330 dir: &Path,
331 model: &ModelConfig,
332 provider: Arc<dyn ModelProvider>,
333 ) -> Result<Self, PlaneError> {
334 Self::open_with_tools(dir, model, provider, None)
335 }
336
337 pub fn open_with_tools(
339 dir: &Path,
340 model: &ModelConfig,
341 provider: Arc<dyn ModelProvider>,
342 tools: Option<Arc<crate::tools::WorkspaceTools>>,
343 ) -> Result<Self, PlaneError> {
344 Self::open_with(dir, model, provider, tools, Hooks::default())
345 }
346
347 pub fn open_with(
349 dir: &Path,
350 model: &ModelConfig,
351 provider: Arc<dyn ModelProvider>,
352 tools: Option<Arc<crate::tools::WorkspaceTools>>,
353 hooks: Hooks,
354 ) -> Result<Self, PlaneError> {
355 let (store, replaced_journal) = open_or_replace(dir)?;
356 Ok(Self {
357 runtime: build(store, model, provider, tools, hooks)?,
358 replaced_journal,
359 })
360 }
361
362 pub fn open_for_decisions(dir: &Path) -> Result<Self, PlaneError> {
366 let (store, replaced_journal) = open_or_replace(dir)?;
367 let runtime = Runtime::builder_on(store)
368 .try_build()
369 .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
370 Ok(Self {
371 runtime,
372 replaced_journal,
373 })
374 }
375
376 pub async fn expire_due(&self, now: agentplane::core::Timestamp) -> Result<(), PlaneError> {
381 self.runtime
382 .sweep(now, std::time::Duration::from_secs(7 * 86_400))
383 .await
384 .map(|_| ())
385 .map_err(|e| PlaneError(e.to_string()))
386 }
387
388 pub async fn withdraw(
391 &self,
392 task: &agentplane::core::Task,
393 by: &agentplane::core::Operator,
394 reason: &str,
395 ) -> Result<(), PlaneError> {
396 let err = |e: String| PlaneError(e);
397 let _ = self
398 .runtime
399 .request_cancel(task.run, by, reason)
400 .await
401 .map_err(|e| err(e.to_string()))?;
402 if let Some(tasks) = self.runtime.tasks() {
403 tasks
404 .withdraw_run(task.run, &[task.id])
405 .await
406 .map_err(|e| err(e.to_string()))?;
407 }
408 Ok(())
409 }
410
411 pub async fn waiting_on(
413 &self,
414 outcome: &RunOutcome,
415 ) -> Result<Option<agentplane::core::Task>, PlaneError> {
416 let RunStatus::Suspended(agentplane::core::SuspendReason::AwaitingEvent {
417 correlation,
418 ..
419 }) = &outcome.status
420 else {
421 return Ok(None);
422 };
423 let Some(id) = correlation
424 .iter()
425 .find(|k| k.namespace == "task")
426 .and_then(|k| agentplane::core::TaskId::parse(&k.value).ok())
427 else {
428 return Ok(None);
429 };
430 self.task(id).await
431 }
432
433 pub async fn task(
435 &self,
436 id: agentplane::core::TaskId,
437 ) -> Result<Option<agentplane::core::Task>, PlaneError> {
438 let Some(tasks) = self.runtime.tasks() else {
439 return Ok(None);
440 };
441 tasks.task(id).await.map_err(|e| PlaneError(e.to_string()))
442 }
443
444 pub async fn open_tasks(&self) -> Result<Vec<agentplane::core::Task>, PlaneError> {
446 let Some(tasks) = self.runtime.tasks() else {
447 return Ok(Vec::new());
448 };
449 let mut open = tasks
450 .queue(&[], 1000)
451 .await
452 .map_err(|e| PlaneError(e.to_string()))?;
453 open.sort_by_key(|t| t.created_at);
454 Ok(open)
455 }
456
457 pub async fn decide(
460 &self,
461 task: agentplane::core::TaskId,
462 decision: &agentplane::core::Decision,
463 ) -> Result<agentplane::core::Delivery, PlaneError> {
464 self.runtime
465 .decide_task(task, decision, &[])
466 .await
467 .map_err(|e| PlaneError(e.to_string()))
468 }
469
470 pub async fn decide_inline(
475 &self,
476 mut outcome: RunOutcome,
477 approver: Option<&dyn crate::approvals::Approver>,
478 context: &crate::approvals::PresentContext,
479 ) -> Result<(RunOutcome, Option<agentplane::core::Task>), PlaneError> {
480 while let Some(task) = self.waiting_on(&outcome).await? {
481 let Some(decision) =
482 approver.and_then(|a| crate::approvals::tasks::ask(a, &task, context))
483 else {
484 return Ok((outcome, Some(task)));
485 };
486 self.decide(task.id, &decision).await?;
487 match self.outcome(outcome.run_id).await? {
488 Some(o) => outcome = o,
489 None => break,
490 }
491 }
492 Ok((outcome, None))
493 }
494
495 pub async fn outcome(
499 &self,
500 run: agentplane::core::RunId,
501 ) -> Result<Option<RunOutcome>, PlaneError> {
502 if self
503 .runtime
504 .recorded_outcome(run)
505 .await
506 .map_err(|e| PlaneError(e.to_string()))?
507 .is_none()
508 {
509 return Ok(None);
510 }
511 self.runtime
512 .replay(run, agentplane::runtime::Mode::Strict)
513 .await
514 .map(Some)
515 .map_err(|e| PlaneError(e.to_string()))
516 }
517
518 #[doc(hidden)]
521 pub fn on_store(
522 store: Arc<RedbStore>,
523 model: &ModelConfig,
524 provider: Arc<dyn ModelProvider>,
525 tools: Option<Arc<crate::tools::WorkspaceTools>>,
526 ) -> Result<Self, PlaneError> {
527 Ok(Self {
528 runtime: build(store, model, provider, tools, Hooks::default())?,
529 replaced_journal: None,
530 })
531 }
532
533 #[doc(hidden)]
536 pub async fn resume(
537 &self,
538 run: agentplane::core::RunId,
539 ) -> Result<RunOutcome, agentplane::RuntimeError> {
540 self.runtime
541 .replay(run, agentplane::runtime::Mode::Resume)
542 .await
543 }
544
545 pub async fn converge(
547 &self,
548 feature: &str,
549 input: Tainted<Value>,
550 ) -> Result<RunOutcome, PlaneError> {
551 self.runtime
552 .start(converge::CAPABILITY, input)
553 .terms(RunTerms::default().correlated(
554 "feature",
555 &[CorrelationKey::new("feature", feature.to_owned())],
556 ))
557 .run()
558 .await
559 .map_err(|e| PlaneError(e.to_string()))
560 }
561
562 pub async fn skill(&self, name: &str, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
564 self.runtime
565 .start(host::CAPABILITY, input)
566 .terms(
567 RunTerms::default()
568 .correlated("skill", &[CorrelationKey::new("skill", name.to_owned())]),
569 )
570 .run()
571 .await
572 .map_err(|e| PlaneError(e.to_string()))
573 }
574
575 pub async fn monitor(
579 &self,
580 feature: &str,
581 task: &str,
582 input: Tainted<Value>,
583 ) -> Result<RunOutcome, PlaneError> {
584 self.judge_once(monitor::CAPABILITY, feature, task, input)
585 .await
586 }
587
588 pub async fn near_miss(
592 &self,
593 feature: &str,
594 task: &str,
595 input: Tainted<Value>,
596 ) -> Result<RunOutcome, PlaneError> {
597 self.judge_once(near_miss::CAPABILITY, feature, task, input)
598 .await
599 }
600
601 async fn judge_once(
605 &self,
606 capability: &str,
607 feature: &str,
608 task: &str,
609 input: Tainted<Value>,
610 ) -> Result<RunOutcome, PlaneError> {
611 use agentplane::runtime::{Admission, Mode};
612 use sha2::Digest as _;
613 let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
614 let digest = sha2::Sha256::digest(
615 format!("{capability}\n{feature}/{task}\n{}", input.peek()).as_bytes(),
616 );
617 let key: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect();
618 let key = format!("judge-{key}");
619 let key = key.as_str();
620 let terms = || {
621 RunTerms::default().correlated(
622 "task",
623 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
624 )
625 };
626 let admitted = self
627 .runtime
628 .start(capability, input.clone())
629 .terms(terms())
630 .once(key)
631 .run()
632 .await
633 .map_err(err)?;
634 match admitted {
635 Admission::Fresh(o) => Ok(o),
636 Admission::Replayed(o) if !matches!(o.status, RunStatus::Succeeded) => self
639 .runtime
640 .start(capability, input)
641 .terms(terms())
642 .run()
643 .await
644 .map_err(err),
645 Admission::Replayed(o) => self
646 .runtime
647 .replay(o.run_id, Mode::Strict)
648 .await
649 .map_err(err),
650 Admission::InFlight(run) => self.runtime.replay(run, Mode::Resume).await.map_err(err),
651 }
652 }
653
654 pub async fn acceptance(
656 &self,
657 feature: &str,
658 task: &str,
659 input: Tainted<Value>,
660 ) -> Result<RunOutcome, PlaneError> {
661 self.runtime
662 .start(acceptance::CAPABILITY, input)
663 .terms(RunTerms::default().correlated(
664 "task",
665 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
666 ))
667 .run()
668 .await
669 .map_err(|e| PlaneError(e.to_string()))
670 }
671
672 pub async fn attempt(
674 &self,
675 feature: &str,
676 task: &str,
677 input: Tainted<Value>,
678 ) -> Result<RunOutcome, PlaneError> {
679 self.runtime
680 .start(attempt::CAPABILITY, input)
681 .terms(RunTerms::default().correlated(
682 "task",
683 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
684 ))
685 .run()
686 .await
687 .map_err(|e| PlaneError(e.to_string()))
688 }
689
690 pub async fn attempt_keyed(
698 &self,
699 feature: &str,
700 task: &str,
701 input: Tainted<Value>,
702 key: &str,
703 ) -> Result<(RunOutcome, bool), PlaneError> {
704 use agentplane::runtime::{Admission, Mode};
705 let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
706 let admitted = self
707 .runtime
708 .start(attempt::CAPABILITY, input)
709 .terms(RunTerms::default().correlated(
710 "task",
711 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
712 ))
713 .once(key)
714 .run()
715 .await
716 .map_err(err)?;
717 match admitted {
718 Admission::Fresh(o) => Ok((o, false)),
719 Admission::Replayed(o) if matches!(o.status, RunStatus::Suspended(_)) => self
724 .runtime
725 .replay(o.run_id, Mode::Resume)
726 .await
727 .map(|o| (o, true))
728 .map_err(err),
729 Admission::Replayed(o) => self
730 .runtime
731 .replay(o.run_id, Mode::Strict)
732 .await
733 .map(|o| (o, true))
734 .map_err(err),
735 Admission::InFlight(run) => self
736 .runtime
737 .replay(run, Mode::Resume)
738 .await
739 .map(|o| (o, true))
740 .map_err(err),
741 }
742 }
743
744 pub async fn propose(
746 &self,
747 feature: &str,
748 task: &str,
749 input: Tainted<Value>,
750 ) -> Result<RunOutcome, PlaneError> {
751 self.runtime
752 .start(CAPABILITY, input)
753 .terms(RunTerms::default().correlated(
754 "task",
755 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
756 ))
757 .run()
758 .await
759 .map_err(|e| PlaneError(e.to_string()))
760 }
761}
762
763pub async fn admitted(
766 dir: &Path,
767 run: &str,
768) -> Result<Option<agentplane::core::Tainted<Value>>, PlaneError> {
769 let id = agentplane::core::RunId::parse(run)
770 .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
771 let store = open_store(dir)?;
772 let history = store
773 .read(id, 1)
774 .await
775 .map_err(|e| PlaneError(e.to_string()))?;
776 Ok(history
777 .iter()
778 .find_map(agentplane::journal::Record::admitted_input))
779}
780
781pub async fn replay_strict(
789 dir: &Path,
790 model: &ModelConfig,
791 run: &str,
792 format: &'static dyn crate::format::Adapter,
793) -> Result<Option<agentplane::runtime::Verdict>, PlaneError> {
794 let id = agentplane::core::RunId::parse(run)
795 .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
796 let store = open_store(dir)?;
797 let history = store
798 .read(id, 1)
799 .await
800 .map_err(|e| PlaneError(e.to_string()))?;
801 if history.is_empty() {
802 return Ok(None);
803 }
804 let mcp_tools: Vec<crate::mcp::McpTool> = history
808 .iter()
809 .find_map(agentplane::journal::Record::admitted_input)
810 .and_then(|a| serde_json::from_value(a.peek()["settings"]["mcp"].clone()).ok())
811 .unwrap_or_default();
812 let hooks = Hooks {
813 mcp_tools,
814 format: Some(format),
815 ..Hooks::default()
816 };
817 let (builder, manifest) = builder(store, model, hooks)?;
818 let runtime = agentplane::runtime::replay_only::wire(builder, &[manifest], &history)
819 .try_build()
820 .map_err(|e| PlaneError(format!("building the replay plane: {e}")))?;
821 runtime
822 .verify(id)
823 .await
824 .map(Some)
825 .map_err(|e| PlaneError(e.to_string()))
826}
827
828pub async fn narration(dir: &Path, run: &str) -> Result<Vec<String>, PlaneError> {
836 let Ok(id) = agentplane::core::RunId::parse(run) else {
837 return Ok(Vec::new());
838 };
839 let store = open_store(dir)?;
840 let history = store
841 .read(id, 1)
842 .await
843 .map_err(|e| PlaneError(e.to_string()))?;
844 let mut out = Vec::new();
845 let mut model = false;
846 for r in &history {
847 match &r.body.kind {
848 agentplane::journal::RecordKind::EffectStarted { descriptor, .. } => {
849 model = descriptor.kind == "model.complete";
850 }
851 agentplane::journal::RecordKind::EffectDone { output, .. } if model => {
852 model = false;
853 let calls = output["tool_calls"]
855 .as_array()
856 .is_some_and(|c| !c.is_empty());
857 if let Some(text) = calls
858 .then(|| output["text"].as_str())
859 .flatten()
860 .map(str::trim)
861 .filter(|t| !t.is_empty())
862 {
863 out.push(crate::approvals::present::escape(text));
864 }
865 }
866 _ => {}
867 }
868 }
869 Ok(out)
870}
871
872#[derive(Debug)]
874struct Unbound;
875
876#[async_trait]
877impl agentplane::tools::ToolClient for Unbound {
878 async fn call(
879 &self,
880 tool: &agentplane::tools::ToolId,
881 _arguments: &Value,
882 _provenance: Option<&agentplane::core::Provenance>,
883 ) -> Result<Value, agentplane::tools::ToolError> {
884 Err(agentplane::tools::ToolError::Refused {
885 tool: tool.clone(),
886 detail: "no worktree is bound to this plane".to_owned(),
887 })
888 }
889
890 fn destination(&self, _tool: &agentplane::tools::ToolId) -> agentplane::tools::Destination {
891 agentplane::tools::Destination::Local
892 }
893}
894
895#[cfg(test)]
896mod tests {
897 use super::*;
898
899 #[test]
900 fn the_agentplane_version_named_in_summaries_is_the_pinned_one() {
901 let workspace = include_str!("../../../../Cargo.toml");
902 assert!(
903 workspace.contains(&format!(
904 "agentplane = {{ version = \"={AGENTPLANE_VERSION}\""
905 )),
906 "AGENTPLANE_VERSION is not the workspace's pin"
907 );
908 }
909
910 #[test]
911 fn a_provider_refusal_is_read_from_the_run_failure() {
912 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\")";
913 let r = provider_refusal(failure).unwrap();
914 assert_eq!(
915 r,
916 "credit_balance_exhausted: You have no credits remaining. Add credits at https://platform.openai.com/settings/organization/billing/."
917 );
918 assert!(account_refusal(&r));
919 assert!(!account_refusal(
920 "the continuation was not an Anthropic assistant-content array"
921 ));
922 assert_eq!(provider_refusal("Failed(\"timeout\")"), None);
923 }
924
925 #[test]
926 fn an_unreadable_journal_is_moved_aside_and_a_locked_one_is_an_error() {
927 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
928 std::fs::write(dir.path().join(JOURNAL), b"written by another format")
929 .unwrap_or_else(|e| panic!("{e}"));
930 let (store, aside) = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
931 let aside = aside.unwrap_or_else(|| panic!("moved aside"));
932 assert!(aside.exists() && dir.path().join(JOURNAL).exists());
933 let second = open_or_replace(dir.path())
935 .err()
936 .map(|e| e.0)
937 .unwrap_or_default();
938 assert!(second.contains("another speccoder"), "{second}");
939 drop(store);
940 assert!(
941 open_or_replace(dir.path())
942 .unwrap_or_else(|e| panic!("{}", e.0))
943 .1
944 .is_none()
945 );
946 }
947}