Skip to main content

speccoder_runtime/substrate/
mod.rs

1//! The execution substrate: agentplane, embedded.
2//!
3//! This module is the only place speccoder touches agentplane. A [`Plane`]
4//! is a redb journal in speccoder's state directory, the speccoder agent
5//! declared in Rust, and the model driver. Every run is journaled; every
6//! recorded run can be replayed strictly, without the model.
7
8pub 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
33/// The agentplane release speccoder is built against (the exact pin in the
34/// workspace `Cargo.toml`), named in every verification summary.
35pub const AGENTPLANE_VERSION: &str = "0.52.0";
36
37/// The journal file inside a project's state directory.
38pub const JOURNAL: &str = "journal.redb";
39
40/// A failure to open or use the plane.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct PlaneError(pub String);
43
44/// The speccoder agent, declared in code (Manifest::builder runs the same
45/// validation as a parsed manifest).
46pub fn manifest(model: &ModelConfig) -> Result<Manifest, PlaneError> {
47    manifest_with(model, &[])
48}
49
50/// [`manifest`] with grants for the run's MCP tools: mutating
51/// (speccoder cannot know otherwise), every argument field the model's.
52pub 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            // Task context is the project's own material: internal, never public.
83            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            // Mutating grants name the fields the model authors and the
89            // sources that may shape them: the model, and the
90            // material it legitimately reads — speccoder's own tools, the
91            // verifier's facts, the worktree, the project's MCP tools.
92            // Anything else (a peer, an unconfigured tool) is refused; what a
93            // command or a write may *be* is speccoder's policy's decision.
94            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                // What a person answered may shape what the model writes.
103                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                    // No arguments: the call itself is the act, and the
130                    // only value it may carry is nothing.
131                    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/// What a live run hands its skills besides the model: someone to ask
146/// and somewhere to report progress. Neither is journaled;
147/// replay gets neither.
148#[derive(Debug, Clone, Default)]
149pub struct Hooks {
150    /// Receives live progress.
151    pub progress: Option<Arc<dyn crate::progress::Progress>>,
152    /// Running MCP servers by name.
153    pub mcp: Vec<(String, Arc<dyn agentplane::tools::ToolClient>)>,
154    /// Their tools, granted in the manifest.
155    pub mcp_tools: Vec<crate::mcp::McpTool>,
156    /// The project's spec format; the reference format when unset.
157    pub format: Option<&'static dyn crate::format::Adapter>,
158    /// The sandbox as a person asked about a command is told.
159    pub present: crate::approvals::PresentContext,
160}
161
162/// An open plane.
163pub struct Plane {
164    runtime: Arc<Runtime>,
165    /// Where an unreadable journal was moved before a fresh one was opened.
166    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
173/// Opens the journal. A journal this build cannot read — another format
174/// after an upgrade, or damage — is moved aside and a fresh one opened:
175/// durable outcomes live in git, the journal is replaceable
176///. A journal locked by another speccoder is an error.
177fn 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        // Another speccoder holds it: its run's record is live. Touch nothing.
183        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        // Written by a build that cannot be read here, or damaged: moved
188        // aside (progress lives in git) and started afresh.
189        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        // Anything else is not the journal's fault: leave it alone.
204        Err(e) => Err(PlaneError(format!(
205            "cannot open the journal in {}: {e}",
206            dir.display()
207        ))),
208    }
209}
210
211/// What a model provider refused, from a run's failure: the text after
212/// agentplane's "refused the request:" — a refusal, which no retry changes.
213#[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/// Whether a provider refusal concerns the account (no credits, quota,
225/// a key it does not accept) rather than one request: every later call
226/// would be refused too.
227#[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
244/// The runtime every plane shares: the journal, the agent with speccoder's
245/// skills under its manifest, and the lease. What answers model and tool
246/// calls is registered by the caller — live drivers or replay-only ones.
247fn 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    // One process owns the journal (redb locks it): opening it frees the
294    // leases a dead process left, so the next run resumes at once.
295    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    // The tool servers answer only when a worktree is bound; without one, a
310    // call to a granted tool is refused.
311    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    /// Opens (or creates) the journal at `dir/journal.redb` with `provider`
328    /// registered under the configured provider name.
329    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    /// [`Plane::open`] with speccoder's tools bound to a worktree.
338    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    /// [`Plane::open_with_tools`] with someone to ask inline.
348    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    /// The journal with no skills and no model: enough to record a person's
363    /// decision on a waiting call (`speccoder task approve`), which the attempt's
364    /// own run picks up when it resumes (agentplane's `Delivery::Buffered`).
365    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    /// Settles every waiting call whose deadline has passed at `now`: the
377    /// call is denied as expired, nobody's decision. Only on a plane without
378    /// skills ([`Plane::open_for_decisions`]) — the attempt resumes in its own
379    /// run, with its own worktree, the next time it runs.
380    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    /// Withdraws a waiting call whose attempt was set aside: its run is
389    /// cancelled on the record (who, why) and the call leaves the queue.
390    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    /// The person's task a suspended run waits on, if it waits on one.
412    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    /// One task row.
434    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    /// Every task waiting for a person, oldest first.
445    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    /// Records a person's decision on `task`: the run continues at once
458    /// when this plane holds its skill, else when it next resumes.
459    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    /// Asks `approver` about each call the run waits on, records the
471    /// decision and lets the run continue, until it no longer waits on a
472    /// person. Returns the run as it rests and the call still waiting, if
473    /// the person answered "later" or nobody is there.
474    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    /// The run as it rests now, its output included: the journal records
496    /// what happened, not a step's value, so the value is re-derived by
497    /// strict replay, which performs nothing.
498    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    /// A plane over `store` instead of the journal file: a crash point's
519    /// prefix of a recorded run, resumed in a test.
520    #[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    /// Resumes `run` as a restarted process would, agentplane's own answer
534    /// unchanged (for `testkit::assert_resumed_honestly`).
535    #[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    /// Runs `speccoder.feature.converge`, correlated to the feature.
546    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    /// Runs `speccoder.skill.run` for an installed skill.
563    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    /// Runs `speccoder.task.monitor`, correlated to the task, admitted once
576    /// per input: judging the same change again reads the recorded answer
577    /// back instead of asking again.
578    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    /// Runs `speccoder.task.near_miss`, admitted once per input like
589    /// [`monitor`](Self::monitor): a held attempt judged again after a
590    /// person's ruling meets the same variants, not new ones.
591    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    /// A judge-side call admitted once per task and input: the first time it
602    /// runs; afterwards the recorded run is re-derived by strict replay,
603    /// which performs nothing (an interrupted one resumes).
604    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            // A call that failed (a provider error) is asked again, unkeyed:
637            // only an answer is worth reading back.
638            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    /// Runs `speccoder.task.acceptance`: acceptance tests derived from the spec.
655    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    /// Runs one `speccoder.task.attempt` for a bundle, correlated to the task.
673    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    /// [`attempt`](Self::attempt) admitted under `key`: the first time it
691    /// runs; after a crash the same key finds the interrupted run — a run
692    /// that had rested is re-derived by strict replay, which performs
693    /// nothing, and one still in flight is resumed from its journal (a dead
694    /// process's lease is freed when the store opens), so finished model and
695    /// tool calls are read back, not repeated. The `bool` says whether this
696    /// was such a resume: what the run's notice may claim.
697    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            // A run waiting for a person resumes: a decision recorded since
720            // is delivered and the attempt continues; one still undecided
721            // waits again. Any other rested run is re-derived, performing
722            // nothing.
723            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    /// Runs `speccoder.task.propose` for a bundle, correlated to the task.
745    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
763/// The input `run` was admitted with, with its root and field labels — from
764/// the journal's `RunAdmitted` record, never from anything a tool returned.
765pub 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
781/// Strictly re-derives a recorded run and says whether this build
782/// reproduces it: history is consumed, nothing is
783/// dispatched. The model driver is agentplane's replay-only provider, which
784/// reads the request profile back from the history and refuses every call,
785/// so no credential is needed. A faithful replay of a run that failed is
786/// `Finding::Verified` too — the recorded ending is what is verified.
787/// `Ok(None)` when the run is not in the journal.
788pub 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    // The run's MCP tools as its admitted input lists them: the same
805    // manifest again. agentplane's replay-only drivers answer for every
806    // provider and tool server it names — replay dispatches nothing.
807    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
828/// What the model said in each turn of `run`, in order: the text
829/// of every `model.complete` effect the journal holds — its account, a
830/// claim. Empty turns are left out.
831///
832/// # Errors
833///
834/// The journal cannot be read.
835pub 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                // A turn without calls is the final answer, not narration.
854                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/// The tool servers when no worktree is bound: every call is refused.
873#[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        // While it is open, a second opener is refused, and nothing is moved.
934        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}