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 ask;
10pub mod attempt;
11pub mod converge;
12pub mod host;
13pub mod model_config;
14pub mod monitor;
15pub mod near_miss;
16pub mod propose;
17pub mod provenance;
18pub mod run_config;
19pub mod shape;
20
21use std::path::Path;
22use std::sync::Arc;
23
24use agentplane::core::CorrelationKey;
25use agentplane::core::{ProtectedField, SourceId};
26use agentplane::manifest::{Budgets, Manifest, ModelRef, Models, ToolGrant};
27use agentplane::model::ModelProvider;
28use agentplane::prelude::*;
29use agentplane::runtime::{Agent, RunOutcome, RunTerms};
30use serde_json::Value;
31
32use self::model_config::ModelConfig;
33use self::propose::{CAPABILITY, Propose};
34
35/// The agentplane release speccoder is built against (the exact pin in the
36/// workspace `Cargo.toml`), named in every verification summary.
37pub const AGENTPLANE_VERSION: &str = "0.57.0";
38
39/// The journal file inside a project's state directory.
40pub const JOURNAL: &str = "journal.redb";
41
42/// A failure to open or use the plane.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct PlaneError(pub String);
45
46/// The speccoder agent, declared in code (Manifest::builder runs the same
47/// validation as a parsed manifest).
48pub fn manifest(model: &ModelConfig) -> Result<Manifest, PlaneError> {
49    manifest_with(model, &[])
50}
51
52/// [`manifest`] with grants for the run's MCP tools: mutating
53/// (speccoder cannot know otherwise), every argument field the model's.
54pub fn manifest_with(
55    model: &ModelConfig,
56    mcp: &[crate::mcp::McpTool],
57) -> Result<Manifest, PlaneError> {
58    Manifest::builder("speccoder", env!("CARGO_PKG_VERSION"))
59        .configure(|spec| {
60            spec.capabilities.provides.push(CAPABILITY.to_owned());
61            spec.capabilities
62                .provides
63                .push(attempt::CAPABILITY.to_owned());
64            spec.capabilities
65                .provides
66                .push(acceptance::CAPABILITY.to_owned());
67            spec.capabilities
68                .provides
69                .push(converge::CAPABILITY.to_owned());
70            spec.capabilities
71                .provides
72                .push(monitor::CAPABILITY.to_owned());
73            spec.capabilities
74                .provides
75                .push(near_miss::CAPABILITY.to_owned());
76            spec.capabilities
77                .provides
78                .push(shape::CAPABILITY.to_owned());
79            spec.capabilities.provides.push(host::CAPABILITY.to_owned());
80            spec.models = Some(Models {
81                privileged: Some(ModelRef {
82                    max_tokens: Some(model.max_output_tokens),
83                    ..ModelRef::new(model.provider.clone(), model.model.clone())
84                }),
85                ..Models::default()
86            });
87            // Task context is the project's own material: internal, never public.
88            spec.security.max_sensitivity_egress = Some(Sensitivity::Internal);
89            spec.budgets = Some(Budgets {
90                max_tokens: Some(model.max_run_tokens),
91                ..Budgets::default()
92            });
93            // Mutating grants name the fields the model authors and the
94            // sources that may shape them: the model, and the
95            // material it legitimately reads — speccoder's own tools, the
96            // verifier's facts, the worktree, the project's MCP tools.
97            // Anything else (a peer, an unconfigured tool) is refused; what a
98            // command or a write may *be* is speccoder's policy's decision.
99            let author =
100                agentplane::model::ModelId::new(model.provider.clone(), model.model.clone())
101                    .source();
102            let catalog = crate::tools::catalog();
103            let mut sources: Vec<SourceId> = vec![
104                author,
105                SourceId::new(provenance::VERIFIER),
106                SourceId::new(provenance::WORKTREE),
107                // What a person answered may shape what the model writes.
108                SourceId::new(provenance::PERSON),
109            ];
110            sources.extend(catalog.iter().map(|t| provenance::tool(t.server, t.name)));
111            sources.extend(mcp.iter().map(|t| provenance::tool(&t.server, &t.tool)));
112            // speccoder's own read-only steps (`fs/resolve`) are granted
113            // too; the model is never offered them.
114            for t in catalog.iter().chain(crate::tools::internal().iter()) {
115                let mut grant = ToolGrant {
116                    description: Some(t.description.to_owned()),
117                    max_sensitivity: Some(Sensitivity::Internal),
118                    mutates: t.mutates,
119                    ..ToolGrant::new(agentplane::tools::ToolId::new(t.server, t.name).reference())
120                };
121                grant.protected_fields = t
122                    .model_fields
123                    .iter()
124                    .map(|f| ProtectedField::from_sources(*f, sources.clone()))
125                    .collect();
126                spec.tools.push(grant);
127            }
128            for t in mcp {
129                let mut grant = ToolGrant {
130                    description: Some(t.description.clone()),
131                    max_sensitivity: Some(Sensitivity::Internal),
132                    mutates: true,
133                    ..ToolGrant::new(agentplane::tools::ToolId::new(&t.server, &t.tool).reference())
134                };
135                grant.protected_fields = if t.fields.is_empty() {
136                    // No arguments: the call itself is the act, and the
137                    // only value it may carry is nothing.
138                    vec![ProtectedField::one_of("", [serde_json::json!({})])]
139                } else {
140                    t.fields
141                        .iter()
142                        .map(|f| ProtectedField::from_sources(f.as_str(), sources.clone()))
143                        .collect()
144                };
145                spec.tools.push(grant);
146            }
147        })
148        .build()
149        .map_err(|e| PlaneError(format!("speccoder manifest: {e}")))
150}
151
152/// What a live run hands its skills besides the model: someone to ask
153/// and somewhere to report progress. Neither is journaled;
154/// replay gets neither.
155#[derive(Debug, Clone, Default)]
156pub struct Hooks {
157    /// Receives live progress.
158    pub progress: Option<Arc<dyn crate::progress::Progress>>,
159    /// Running MCP servers by name.
160    pub mcp: Vec<(String, Arc<dyn agentplane::tools::ToolClient>)>,
161    /// Their tools, granted in the manifest.
162    pub mcp_tools: Vec<crate::mcp::McpTool>,
163    /// The project's spec format; the reference format when unset.
164    pub format: Option<&'static dyn crate::format::Adapter>,
165    /// The sandbox as a person asked about a command is told.
166    pub present: crate::approvals::PresentContext,
167}
168
169/// Calls the journal records, and what of it could not be read.
170#[derive(Debug, Clone, Default)]
171pub struct RecordedCalls {
172    /// The calls.
173    pub calls: Vec<RecordedCall>,
174    /// What could not be read, and why.
175    pub unreadable: Vec<String>,
176}
177
178/// One recorded call.
179#[derive(Debug, Clone)]
180pub struct RecordedCall {
181    /// Its arguments as dispatched.
182    pub arguments: Value,
183    /// When its run last wrote (seconds since the epoch).
184    pub at: u64,
185}
186
187/// What [`Plane::decide_inline`] leaves.
188#[derive(Debug)]
189pub struct Inline {
190    /// The run as it rests.
191    pub outcome: RunOutcome,
192    /// The call it still waits on.
193    pub waiting: Option<agentplane::core::Task>,
194    /// Calls answered Always Allow.
195    pub always: Vec<Always>,
196}
197
198/// A call answered Always Allow inline.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct Always {
201    /// `feature/task`.
202    pub task: String,
203    /// The waiting call's id.
204    pub call: String,
205    /// The interface it was answered through.
206    pub via: speccoder_core::actions::Via,
207}
208
209/// An open plane.
210pub struct Plane {
211    runtime: Arc<Runtime>,
212    /// Where an unreadable journal was moved before a fresh one was opened.
213    pub replaced_journal: Option<std::path::PathBuf>,
214    /// The run's claim on its journal in this process, released on drop.
215    _run: Option<RunClaim>,
216}
217
218/// The journals a run holds in this process. Reads share a journal's
219/// handle; two runs on one journal would race, so a second is refused.
220static RUNS: std::sync::Mutex<Vec<std::path::PathBuf>> = std::sync::Mutex::new(Vec::new());
221
222struct RunClaim(std::path::PathBuf);
223
224impl RunClaim {
225    fn take(dir: &Path) -> Result<Self, PlaneError> {
226        let path = dir.join(JOURNAL);
227        let mut runs = RUNS
228            .lock()
229            .map_err(|_| PlaneError("the run registry is poisoned".to_owned()))?;
230        if runs.contains(&path) {
231            return Err(PlaneError(format!(
232                "another speccoder is working on this project (a run in this process holds its journal in {})",
233                dir.display()
234            )));
235        }
236        runs.push(path.clone());
237        Ok(Self(path))
238    }
239}
240
241impl Drop for RunClaim {
242    fn drop(&mut self) {
243        if let Ok(mut runs) = RUNS.lock() {
244            runs.retain(|p| *p != self.0);
245        }
246    }
247}
248
249/// The journals this process writes, shared: a journal has one writer
250/// (redb refuses a second even in this process), so a decision the app
251/// records while its own job runs goes through the job's handle.
252static OPEN: std::sync::Mutex<Vec<(std::path::PathBuf, std::sync::Weak<RedbStore>)>> =
253    std::sync::Mutex::new(Vec::new());
254
255/// Opens the journal to write, sharing the handle this process already
256/// writes through, and sharing the file with readers in other processes
257/// (`open_shared`). A journal this build cannot read — another format
258/// after an upgrade, or damage — is moved aside and a fresh one opened:
259/// durable outcomes live in git, the journal is replaceable. A journal
260/// another process writes is refused at once.
261fn open_or_replace(dir: &Path) -> Result<(Arc<RedbStore>, Option<std::path::PathBuf>), PlaneError> {
262    std::fs::create_dir_all(dir).map_err(|e| PlaneError(format!("{}: {e}", dir.display())))?;
263    let path = dir.join(JOURNAL);
264    let mut open = OPEN
265        .lock()
266        .map_err(|_| PlaneError("the journal registry is poisoned".to_owned()))?;
267    open.retain(|(_, w)| w.strong_count() > 0);
268    if let Some(store) = open
269        .iter()
270        .find(|(p, _)| *p == path)
271        .and_then(|(_, w)| w.upgrade())
272    {
273        return Ok((store, None));
274    }
275    let (store, aside) = open_fresh(dir, &path)?;
276    open.push((path, Arc::downgrade(&store)));
277    Ok((store, aside))
278}
279
280fn open_fresh(
281    dir: &Path,
282    path: &Path,
283) -> Result<(Arc<RedbStore>, Option<std::path::PathBuf>), PlaneError> {
284    match RedbStore::open_shared(path) {
285        Ok(store) => Ok((Arc::new(store), None)),
286        // Another speccoder writes it: its run's record is live. Touch nothing.
287        Err(agentplane::core::StoreError::InUse(e)) => Err(PlaneError(format!(
288            "another speccoder is working on this project (its journal in {} is in use): {e}",
289            dir.display()
290        ))),
291        // Written by a build that cannot be read here, or damaged: moved
292        // aside (progress lives in git) and started afresh.
293        Err(agentplane::core::StoreError::Unreadable(e)) => {
294            let stamp = std::time::SystemTime::now()
295                .duration_since(std::time::UNIX_EPOCH)
296                .map_or(0, |d| d.as_secs());
297            let aside = dir.join(format!("journal.{stamp}.unreadable.redb"));
298            std::fs::rename(path, &aside).map_err(|r| {
299                PlaneError(format!(
300                    "the journal is unreadable ({e}) and cannot be moved aside: {r}"
301                ))
302            })?;
303            RedbStore::open_shared(path)
304                .map(|store| (Arc::new(store), Some(aside)))
305                .map_err(|e| PlaneError(format!("cannot open a fresh journal: {e}")))
306        }
307        // Anything else is not the journal's fault: leave it alone.
308        Err(e) => Err(PlaneError(format!(
309            "cannot open the journal in {}: {e}",
310            dir.display()
311        ))),
312    }
313}
314
315/// Opens the journal to read beside its writer, in this process or
316/// another: `None` when there is none (a read never creates one). Never
317/// registered, moved aside or written — any write through it is refused.
318///
319/// # Errors
320///
321/// It cannot be read: a writer that holds it alone (an earlier speccoder
322/// build), damage, another format.
323pub fn open_reader(dir: &Path) -> Result<Option<Arc<RedbStore>>, PlaneError> {
324    let path = dir.join(JOURNAL);
325    if !path.exists() {
326        return Ok(None);
327    }
328    match RedbStore::open_read_only(&path) {
329        Ok(store) => Ok(Some(Arc::new(store))),
330        Err(agentplane::core::StoreError::InUse(e)) => Err(PlaneError(format!(
331            "another process holds the journal in {} alone (a speccoder build that does not share it): {e}",
332            dir.display()
333        ))),
334        // A writer that died without closing it (killed, Ctrl-C, a closed
335        // terminal) leaves it to be repaired, which only a writer does.
336        Err(agentplane::core::StoreError::NeedsRepair(e)) => Err(PlaneError(format!(
337            "the last run ended without closing its journal in {}; the next run or decision repairs it ({e})",
338            dir.display()
339        ))),
340        Err(e) => Err(PlaneError(format!(
341            "cannot read the journal in {}: {e}",
342            dir.display()
343        ))),
344    }
345}
346
347/// Whether `value` holds a decision as agentplane 0.53 and earlier
348/// recorded it: `approved` and `decided`, and no `verdict`.
349fn holds_a_pre_054_decision(value: &Value) -> bool {
350    match value {
351        Value::Object(o) => {
352            (o.contains_key("approved") && o.contains_key("decided") && !o.contains_key("verdict"))
353                || o.values().any(holds_a_pre_054_decision)
354        }
355        Value::Array(a) => a.iter().any(holds_a_pre_054_decision),
356        Value::String(s) if s.contains("\"approved\"") => {
357            serde_json::from_str::<Value>(s).is_ok_and(|v| holds_a_pre_054_decision(&v))
358        }
359        _ => false,
360    }
361}
362
363/// What a run that failed on a decision speccoder 0.2.0 recorded is told.
364pub 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";
365
366/// What a model provider refused, from a run's failure: the text after
367/// agentplane's "refused the request:" — a refusal, which no retry changes.
368#[must_use]
369pub fn provider_refusal(failure: &str) -> Option<String> {
370    let start = failure.find("refused the request: ")? + "refused the request: ".len();
371    let rest = &failure[start..];
372    let end = rest
373        .find("); ")
374        .or_else(|| rest.rfind(')'))
375        .unwrap_or(rest.len());
376    Some(rest[..end].trim_end_matches(['"', ')']).trim().to_owned())
377}
378
379/// A plane budget that stopped an attempt, in words, with the setting
380/// that raises it.
381#[must_use]
382pub fn exhausted(limit: &agentplane::core::BudgetExceeded) -> String {
383    let said = match limit {
384        agentplane::core::BudgetExceeded::Recorded { limit, .. } => limit.clone(),
385        other => other.to_string(),
386    };
387    // The ceiling the plane names; a record an older build wrote names
388    // none, and its words still say which.
389    let tokens = limit.ceiling().map_or_else(
390        || said.starts_with("token"),
391        |c| c == agentplane::core::Ceiling::Tokens,
392    );
393    let raise = if tokens {
394        "; raise `[model] max_run_tokens`, or split the task"
395    } else {
396        ""
397    };
398    format!("the attempt's budget ran out: {said}{raise}")
399}
400
401/// Why a run did not succeed, for a person: a failure in its own words,
402/// without agentplane's effect key and attempt count (`'openai/gpt-5.4-mini'
403/// returned an unusable answer: …`), cut at 300 characters; else the
404/// status (`suspended`, `exhausted`, …).
405#[must_use]
406pub fn why_not(status: &RunStatus) -> String {
407    let RunStatus::Failed(failure) = status else {
408        return status.as_str().to_owned();
409    };
410    let said = ["consumed resources and failed: ", "failed: "]
411        .iter()
412        .find_map(|m| failure.find(m).map(|i| &failure[i + m.len()..]))
413        .or_else(|| {
414            failure
415                .split_once(" of ")
416                .and_then(|(_, r)| r.split_once(": "))
417                .map(|(_, r)| r)
418        })
419        .unwrap_or(failure);
420    let cut: String = said.chars().take(300).collect();
421    if cut.len() < said.len() {
422        format!("{cut}…")
423    } else {
424        cut
425    }
426}
427
428/// The first run in the journal at `dir` that ended on a provider refusing
429/// the **account** — an exhausted quota or credit, billing, a missing or
430/// invalid key, a permission the key lacks — rather than one request:
431/// every later call would be refused too. Read from the records (the
432/// effect the run failed on and the provider's code), read-only; a
433/// refusal without a code is not taken for one. `None` with no journal.
434///
435/// # Errors
436///
437/// The journal cannot be read.
438pub async fn refused_account(
439    dir: &Path,
440) -> Result<Option<agentplane::journal::FailedEffect>, PlaneError> {
441    use agentplane::journal::JournalStore as _;
442    let Some(store) = open_reader(dir)? else {
443        return Ok(None);
444    };
445    let mut after: Option<(u64, agentplane::core::RunId)> = None;
446    loop {
447        let page = store
448            .recent_runs(after, 500)
449            .await
450            .map_err(|e| PlaneError(format!("listing the journal's runs: {e}")))?;
451        let Some(last) = page.last().cloned() else {
452            return Ok(None);
453        };
454        for (run, _) in &page {
455            let records = store
456                .read(*run, 0)
457                .await
458                .map_err(|e| PlaneError(format!("run {run}: {e}")))?;
459            if let Some(failed) = agentplane::journal::failed_effect(&records)
460                .filter(agentplane::journal::FailedEffect::refuses_the_account)
461            {
462                return Ok(Some(failed));
463            }
464        }
465        after = Some((last.1, last.0));
466    }
467}
468
469/// The runtime every plane shares: the journal, the agent with speccoder's
470/// skills under its manifest, and the lease. What answers model and tool
471/// calls is registered by the caller — live drivers or replay-only ones.
472fn builder(
473    store: Arc<RedbStore>,
474    model: &ModelConfig,
475    hooks: Hooks,
476) -> Result<(agentplane::runtime::RuntimeBuilder, Manifest), PlaneError> {
477    let manifest = manifest_with(model, &hooks.mcp_tools)?;
478    let format = hooks.format.unwrap_or_else(crate::format::reference);
479    let agent = Agent::new(&manifest)
480        .skill(Propose {
481            cache_ttl: model.cache_ttl,
482            style: model.prompt_style(),
483            format,
484        })
485        .skill(attempt::Attempt {
486            style: model.prompt_style(),
487            cache_ttl: model.cache_ttl,
488            progress: hooks.progress.clone(),
489            format,
490            present: hooks.present.clone(),
491            delivered: std::sync::Arc::default(),
492        })
493        .skill(host::Host {
494            style: model.prompt_style(),
495            progress: hooks.progress,
496            format,
497            present: hooks.present,
498        })
499        .skill(acceptance::Acceptance {
500            style: model.prompt_style(),
501            cache_ttl: model.cache_ttl,
502            format,
503        })
504        .skill(converge::Converge {
505            style: model.prompt_style(),
506            cache_ttl: model.cache_ttl,
507            format,
508        })
509        .skill(monitor::Monitor {
510            style: model.prompt_style(),
511            cache_ttl: model.cache_ttl,
512            format,
513        })
514        .skill(near_miss::NearMiss {
515            style: model.prompt_style(),
516            cache_ttl: model.cache_ttl,
517            format,
518        })
519        .skill(shape::Shape {
520            style: model.prompt_style(),
521            cache_ttl: model.cache_ttl,
522        });
523    // One process owns the journal (redb locks it): opening it frees the
524    // leases a dead process left, so the next run resumes at once.
525    let builder = Runtime::builder_on(store).agent(agent);
526    Ok((builder, manifest))
527}
528
529fn build(
530    store: Arc<RedbStore>,
531    model: &ModelConfig,
532    provider: Arc<dyn ModelProvider>,
533    tools: Option<Arc<crate::tools::WorkspaceTools>>,
534    hooks: Hooks,
535) -> Result<Arc<Runtime>, PlaneError> {
536    let mcp = hooks.mcp.clone();
537    let (mut builder, _) = builder(store, model, hooks)?;
538    builder = builder.provider(model.provider.clone(), provider);
539    // The tool servers answer only when a worktree is bound; without one, a
540    // call to a granted tool is refused.
541    let client: Arc<dyn agentplane::tools::ToolClient> = match tools {
542        Some(t) => t,
543        None => Arc::new(Unbound),
544    };
545    for server in ["fs", "proc", "git"] {
546        builder = builder.tool_server(server, Arc::clone(&client));
547    }
548    for (server, client) in &mcp {
549        builder = builder.tool_server(server.as_str(), Arc::clone(client));
550    }
551    builder
552        .try_build()
553        .map_err(|e| PlaneError(format!("building the plane: {e}")))
554}
555
556impl Plane {
557    /// Opens (or creates) the journal at `dir/journal.redb` with `provider`
558    /// registered under the configured provider name.
559    pub fn open(
560        dir: &Path,
561        model: &ModelConfig,
562        provider: Arc<dyn ModelProvider>,
563    ) -> Result<Self, PlaneError> {
564        Self::open_with_tools(dir, model, provider, None)
565    }
566
567    /// [`Plane::open`] with speccoder's tools bound to a worktree.
568    pub fn open_with_tools(
569        dir: &Path,
570        model: &ModelConfig,
571        provider: Arc<dyn ModelProvider>,
572        tools: Option<Arc<crate::tools::WorkspaceTools>>,
573    ) -> Result<Self, PlaneError> {
574        Self::open_with(dir, model, provider, tools, Hooks::default())
575    }
576
577    /// [`Plane::open_with_tools`] with someone to ask inline.
578    pub fn open_with(
579        dir: &Path,
580        model: &ModelConfig,
581        provider: Arc<dyn ModelProvider>,
582        tools: Option<Arc<crate::tools::WorkspaceTools>>,
583        hooks: Hooks,
584    ) -> Result<Self, PlaneError> {
585        let claim = RunClaim::take(dir)?;
586        let (store, replaced_journal) = open_or_replace(dir)?;
587        Ok(Self {
588            runtime: build(store, model, provider, tools, hooks)?,
589            replaced_journal,
590            _run: Some(claim),
591        })
592    }
593
594    /// The journal with no skills and no model: enough to record a person's
595    /// decision on a waiting call (`speccoder task approve`), which the attempt's
596    /// own run picks up when it resumes (agentplane's `Delivery::Buffered`).
597    pub fn open_for_decisions(dir: &Path) -> Result<Self, PlaneError> {
598        let (store, replaced_journal) = open_or_replace(dir)?;
599        let runtime = Runtime::builder_on(store)
600            .try_build()
601            .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
602        Ok(Self {
603            runtime,
604            replaced_journal,
605            _run: None,
606        })
607    }
608
609    /// The journal read beside its writer, with no skills and no model:
610    /// the waiting calls and recorded calls as they stand. `None` when the
611    /// project has no journal. Nothing is written — not even an expired
612    /// call's denial, which the next writer records.
613    ///
614    /// # Errors
615    ///
616    /// The journal cannot be read ([`open_reader`]).
617    pub fn open_for_reading(dir: &Path) -> Result<Option<Self>, PlaneError> {
618        let Some(store) = open_reader(dir)? else {
619            return Ok(None);
620        };
621        let runtime = Runtime::builder_on(store)
622            .try_build()
623            .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
624        Ok(Some(Self {
625            runtime,
626            replaced_journal: None,
627            _run: None,
628        }))
629    }
630
631    /// Settles every waiting call whose deadline has passed at `now`: the
632    /// call is denied as expired, nobody's decision. Only on a plane without
633    /// skills ([`Plane::open_for_decisions`]) — the attempt resumes in its own
634    /// run, with its own worktree, the next time it runs.
635    pub async fn expire_due(&self, now: agentplane::core::Timestamp) -> Result<(), PlaneError> {
636        self.runtime
637            .sweep(now, std::time::Duration::from_secs(7 * 86_400))
638            .await
639            .map(|_| ())
640            .map_err(|e| PlaneError(e.to_string()))
641    }
642
643    /// Withdraws a waiting call whose attempt was set aside: its run is
644    /// cancelled on the record (who, why) and the call leaves the queue.
645    pub async fn withdraw(
646        &self,
647        task: &agentplane::core::Task,
648        by: &agentplane::core::Operator,
649        reason: &str,
650    ) -> Result<(), PlaneError> {
651        let err = |e: String| PlaneError(e);
652        match self.runtime.request_cancel(task.run, by, reason).await {
653            Ok(_) => {}
654            // A run holding an unknown outcome is never unwound: speccoder
655            // set its attempt aside already, so it is closed where it stands.
656            Err(agentplane::core::RuntimeError::CannotUnwind { .. }) => self
657                .runtime
658                .record_quarantine_decision(
659                    task.run,
660                    by,
661                    reason,
662                    agentplane::core::QuarantineDecision::Abandon,
663                )
664                .await
665                .map_err(|e| err(e.to_string()))?,
666            Err(e) => return Err(err(e.to_string())),
667        }
668        if let Some(tasks) = self.runtime.tasks() {
669            tasks
670                .withdraw_run(task.run, &[task.id])
671                .await
672                .map_err(|e| err(e.to_string()))?;
673        }
674        Ok(())
675    }
676
677    /// The person's task a suspended run waits on, if it waits on one.
678    pub async fn waiting_on(
679        &self,
680        outcome: &RunOutcome,
681    ) -> Result<Option<agentplane::core::Task>, PlaneError> {
682        let RunStatus::Suspended(reason) = &outcome.status else {
683            return Ok(None);
684        };
685        let Some(id) = reason.awaited_task() else {
686            return Ok(None);
687        };
688        self.task(id).await
689    }
690
691    /// One task row.
692    pub async fn task(
693        &self,
694        id: agentplane::core::TaskId,
695    ) -> Result<Option<agentplane::core::Task>, PlaneError> {
696        let Some(tasks) = self.runtime.tasks() else {
697            return Ok(None);
698        };
699        tasks.task(id).await.map_err(|e| PlaneError(e.to_string()))
700    }
701
702    /// Every call of `server/tool` the journal records as dispatched, in
703    /// every run, with the time its run last wrote and whether it was
704    /// decided on an object (a person's decision or grant) — and what could
705    /// not be read: a run unreadable is reported, never skipped silently.
706    pub async fn tool_calls(&self, server: &str, tool: &str) -> Result<RecordedCalls, PlaneError> {
707        let journal = self.runtime.journal();
708        let mut out = RecordedCalls::default();
709        let mut after: Option<(u64, agentplane::core::RunId)> = None;
710        loop {
711            let page = journal
712                .recent_runs(after, 500)
713                .await
714                .map_err(|e| PlaneError(format!("listing the journal's runs: {e}")))?;
715            let Some(last) = page.last().cloned() else {
716                break;
717            };
718            for (run, at) in &page {
719                let records = match journal.read(*run, 0).await {
720                    Ok(r) => r,
721                    Err(e) => {
722                        out.unreadable.push(format!("run {run}: {e}"));
723                        continue;
724                    }
725                };
726                let mut seen = std::collections::BTreeSet::new();
727                for record in records {
728                    let agentplane::RecordKind::EffectStarted { descriptor, .. } = record.kind()
729                    else {
730                        continue;
731                    };
732                    let args = &descriptor.args;
733                    if descriptor.kind != "tool.call"
734                        || args["server"] != server
735                        || args["tool"] != tool
736                    {
737                        continue;
738                    }
739                    // A retried effect is one call.
740                    let key = record
741                        .body
742                        .effect_key
743                        .as_ref()
744                        .map_or_else(|| record.body.seq.to_string(), |k| format!("{k:?}"));
745                    if seen.insert(key) {
746                        out.calls.push(RecordedCall {
747                            arguments: args["arguments"].clone(),
748                            at: *at,
749                        });
750                    }
751                }
752            }
753            after = Some((last.1, last.0));
754        }
755        Ok(out)
756    }
757
758    /// Whether `run` read a person's decision recorded by speccoder 0.2.0
759    /// (agentplane 0.53 and earlier): a decision with `approved`, which
760    /// agentplane 0.54 replaced by a verdict, so the run cannot read it
761    /// and fails on it. Told from the record's shape, never a message.
762    pub async fn holds_a_pre_054_decision(&self, run: agentplane::core::RunId) -> bool {
763        let Ok(records) = self.runtime.journal().read(run, 0).await else {
764            return false;
765        };
766        records.iter().any(|r| {
767            serde_json::to_value(r.kind())
768                .ok()
769                .is_some_and(|v| holds_a_pre_054_decision(&v))
770        })
771    }
772
773    /// Every task waiting for a person, oldest first.
774    pub async fn open_tasks(&self) -> Result<Vec<agentplane::core::Task>, PlaneError> {
775        let Some(tasks) = self.runtime.tasks() else {
776            return Ok(Vec::new());
777        };
778        let mut open = tasks
779            .queue(&[], 1000)
780            .await
781            .map_err(|e| PlaneError(e.to_string()))?;
782        open.sort_by_key(|t| t.created_at);
783        Ok(open)
784    }
785
786    /// Records a person's decision on `task`: the run continues at once
787    /// when this plane holds its skill, else when it next resumes.
788    pub async fn decide(
789        &self,
790        task: agentplane::core::TaskId,
791        decision: &agentplane::core::Decision,
792    ) -> Result<agentplane::core::Delivery, PlaneError> {
793        self.decide_at(task, decision, None).await
794    }
795
796    /// [`Plane::decide`], refused (nothing recorded) unless the task row
797    /// is still the one whose justification digest is `expected`.
798    pub async fn decide_at(
799        &self,
800        task: agentplane::core::TaskId,
801        decision: &agentplane::core::Decision,
802        expected: Option<agentplane::core::Digest>,
803    ) -> Result<agentplane::core::Delivery, PlaneError> {
804        self.runtime
805            .decide_task_at(task, decision, &[], expected)
806            .await
807            .map_err(|e| PlaneError(e.to_string()))
808    }
809
810    /// Asks `approver` about each call the run waits on, records the
811    /// decision and lets the run continue, until it no longer waits on a
812    /// person. Returns the run as it rests, the call still waiting, if
813    /// the person answered "later", nobody is there or the call waits in
814    /// its run's batch, and the calls answered Always Allow (approved once
815    /// here; their argv is added to the allow list by
816    /// `actions::perform(AllowAlways)`, which the caller runs).
817    pub async fn decide_inline(
818        &self,
819        mut outcome: RunOutcome,
820        approver: Option<&dyn crate::approvals::Approver>,
821        context: &crate::approvals::PresentContext,
822    ) -> Result<Inline, PlaneError> {
823        let mut always = Vec::new();
824        while let Some(task) = self.waiting_on(&outcome).await? {
825            let Some((decision, answer)) =
826                approver.and_then(|a| crate::approvals::tasks::ask(a, &task, context))
827            else {
828                return Ok(Inline {
829                    outcome,
830                    waiting: Some(task),
831                    always,
832                });
833            };
834            self.decide(task.id, &decision).await?;
835            if answer.always && decision.approved() {
836                let via = answer
837                    .by
838                    .as_ref()
839                    .and_then(|b| speccoder_core::actions::Via::read(&b.via))
840                    .map_or(speccoder_core::actions::Via::Terminal, |(v, _)| v);
841                crate::approvals::answered_always(&task.id.to_hex());
842                always.push(Always {
843                    task: crate::approvals::tasks::request_of(&task.justification.proposed_action)
844                        .map(|r| r.task)
845                        .unwrap_or_default(),
846                    call: task.id.to_hex(),
847                    via,
848                });
849            }
850            match self.outcome(outcome.run_id).await? {
851                Some(o) => outcome = o,
852                None => break,
853            }
854        }
855        Ok(Inline {
856            outcome,
857            waiting: None,
858            always,
859        })
860    }
861
862    /// The run as it rests now, its output included: the journal records
863    /// what happened, not a step's value, so the value is re-derived by
864    /// strict replay, which performs nothing.
865    pub async fn outcome(
866        &self,
867        run: agentplane::core::RunId,
868    ) -> Result<Option<RunOutcome>, PlaneError> {
869        if self
870            .runtime
871            .recorded_outcome(run)
872            .await
873            .map_err(|e| PlaneError(e.to_string()))?
874            .is_none()
875        {
876            return Ok(None);
877        }
878        self.runtime
879            .replay(run, agentplane::runtime::Mode::Strict)
880            .await
881            .map(Some)
882            .map_err(|e| PlaneError(e.to_string()))
883    }
884
885    /// A plane over `store` instead of the journal file: a crash point's
886    /// prefix of a recorded run, resumed in a test.
887    #[doc(hidden)]
888    pub fn on_store(
889        store: Arc<RedbStore>,
890        model: &ModelConfig,
891        provider: Arc<dyn ModelProvider>,
892        tools: Option<Arc<crate::tools::WorkspaceTools>>,
893    ) -> Result<Self, PlaneError> {
894        Ok(Self {
895            runtime: build(store, model, provider, tools, Hooks::default())?,
896            replaced_journal: None,
897            _run: None,
898        })
899    }
900
901    /// Resumes `run` as a restarted process would, agentplane's own answer
902    /// unchanged (for `testkit::assert_resumed_honestly`).
903    #[doc(hidden)]
904    pub async fn resume(
905        &self,
906        run: agentplane::core::RunId,
907    ) -> Result<RunOutcome, agentplane::RuntimeError> {
908        self.runtime
909            .replay(run, agentplane::runtime::Mode::Resume)
910            .await
911    }
912
913    /// Runs `speccoder.feature.converge`, correlated to the feature.
914    pub async fn converge(
915        &self,
916        feature: &str,
917        input: Tainted<Value>,
918    ) -> Result<RunOutcome, PlaneError> {
919        self.runtime
920            .start(converge::CAPABILITY, input)
921            .terms(RunTerms::default().correlated(
922                "feature",
923                &[CorrelationKey::new("feature", feature.to_owned())],
924            ))
925            .run()
926            .await
927            .map_err(|e| PlaneError(e.to_string()))
928    }
929
930    /// Runs `speccoder.shape`: a person's words made into the field's kind
931    /// of text.
932    pub async fn shape(&self, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
933        self.runtime
934            .start(shape::CAPABILITY, input)
935            .run()
936            .await
937            .map_err(|e| PlaneError(e.to_string()))
938    }
939
940    /// Runs `speccoder.skill.run` for an installed skill.
941    pub async fn skill(&self, name: &str, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
942        self.runtime
943            .start(host::CAPABILITY, input)
944            .terms(
945                RunTerms::default()
946                    .correlated("skill", &[CorrelationKey::new("skill", name.to_owned())]),
947            )
948            .run()
949            .await
950            .map_err(|e| PlaneError(e.to_string()))
951    }
952
953    /// Runs `speccoder.task.monitor`, correlated to the task, admitted once
954    /// per input: judging the same change again reads the recorded answer
955    /// back instead of asking again.
956    pub async fn monitor(
957        &self,
958        feature: &str,
959        task: &str,
960        input: Tainted<Value>,
961    ) -> Result<RunOutcome, PlaneError> {
962        self.judge_once(monitor::CAPABILITY, feature, task, input)
963            .await
964    }
965
966    /// Runs `speccoder.task.near_miss`, admitted once per input like
967    /// [`monitor`](Self::monitor): a held attempt judged again after a
968    /// person's ruling meets the same variants, not new ones.
969    pub async fn near_miss(
970        &self,
971        feature: &str,
972        task: &str,
973        input: Tainted<Value>,
974    ) -> Result<RunOutcome, PlaneError> {
975        self.judge_once(near_miss::CAPABILITY, feature, task, input)
976            .await
977    }
978
979    /// A judge-side call admitted once per task and input: the first time it
980    /// runs; afterwards the recorded run is re-derived by strict replay,
981    /// which performs nothing (an interrupted one resumes).
982    async fn judge_once(
983        &self,
984        capability: &str,
985        feature: &str,
986        task: &str,
987        input: Tainted<Value>,
988    ) -> Result<RunOutcome, PlaneError> {
989        use agentplane::runtime::{Admission, Mode};
990        use sha2::Digest as _;
991        let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
992        let digest = sha2::Sha256::digest(
993            format!("{capability}\n{feature}/{task}\n{}", input.peek()).as_bytes(),
994        );
995        let key: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect();
996        let key = format!("judge-{key}");
997        let key = key.as_str();
998        let terms = || {
999            RunTerms::default().correlated(
1000                "task",
1001                &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1002            )
1003        };
1004        let admitted = self
1005            .runtime
1006            .start(capability, input.clone())
1007            .terms(terms())
1008            .once(key)
1009            .run()
1010            .await
1011            .map_err(err)?;
1012        match admitted {
1013            Admission::Fresh(o) => Ok(o),
1014            // A call that failed (a provider error) is asked again, unkeyed:
1015            // only an answer is worth reading back.
1016            Admission::Replayed(o) if !matches!(o.status, RunStatus::Succeeded) => self
1017                .runtime
1018                .start(capability, input)
1019                .terms(terms())
1020                .run()
1021                .await
1022                .map_err(err),
1023            Admission::Replayed(o) => self
1024                .runtime
1025                .replay(o.run_id, Mode::Strict)
1026                .await
1027                .map_err(err),
1028            Admission::InFlight(run) => self.runtime.replay(run, Mode::Resume).await.map_err(err),
1029        }
1030    }
1031
1032    /// Runs `speccoder.task.acceptance`: acceptance tests derived from the spec.
1033    pub async fn acceptance(
1034        &self,
1035        feature: &str,
1036        task: &str,
1037        input: Tainted<Value>,
1038    ) -> Result<RunOutcome, PlaneError> {
1039        self.runtime
1040            .start(acceptance::CAPABILITY, input)
1041            .terms(RunTerms::default().correlated(
1042                "task",
1043                &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1044            ))
1045            .run()
1046            .await
1047            .map_err(|e| PlaneError(e.to_string()))
1048    }
1049
1050    /// Runs one `speccoder.task.attempt` for a bundle, correlated to the task.
1051    pub async fn attempt(
1052        &self,
1053        feature: &str,
1054        task: &str,
1055        input: Tainted<Value>,
1056    ) -> Result<RunOutcome, PlaneError> {
1057        self.runtime
1058            .start(attempt::CAPABILITY, input)
1059            .terms(RunTerms::default().correlated(
1060                "task",
1061                &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1062            ))
1063            .run()
1064            .await
1065            .map_err(|e| PlaneError(e.to_string()))
1066    }
1067
1068    /// [`attempt`](Self::attempt) admitted under `key`: the first time it
1069    /// runs; after a crash the same key finds the interrupted run — a run
1070    /// that had rested is re-derived by strict replay, which performs
1071    /// nothing, and one still in flight is resumed from its journal (a dead
1072    /// process's lease is freed when the store opens), so finished model and
1073    /// tool calls are read back, not repeated. The `bool` says whether this
1074    /// was such a resume: what the run's notice may claim.
1075    pub async fn attempt_keyed(
1076        &self,
1077        feature: &str,
1078        task: &str,
1079        input: Tainted<Value>,
1080        key: &str,
1081    ) -> Result<(RunOutcome, bool), PlaneError> {
1082        use agentplane::runtime::{Admission, Mode};
1083        let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
1084        let admitted = self
1085            .runtime
1086            .start(attempt::CAPABILITY, input)
1087            .terms(RunTerms::default().correlated(
1088                "task",
1089                &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1090            ))
1091            .once(key)
1092            .run()
1093            .await
1094            .map_err(err)?;
1095        match admitted {
1096            Admission::Fresh(o) => Ok((o, false)),
1097            // A run waiting for a person resumes: a decision recorded since
1098            // is delivered and the attempt continues; one still undecided
1099            // waits again. Any other rested run is re-derived, performing
1100            // nothing.
1101            Admission::Replayed(o) if matches!(o.status, RunStatus::Suspended(_)) => self
1102                .runtime
1103                .replay(o.run_id, Mode::Resume)
1104                .await
1105                .map(|o| (o, true))
1106                .map_err(err),
1107            Admission::Replayed(o) => self
1108                .runtime
1109                .replay(o.run_id, Mode::Strict)
1110                .await
1111                .map(|o| (o, true))
1112                .map_err(err),
1113            Admission::InFlight(run) => self
1114                .runtime
1115                .replay(run, Mode::Resume)
1116                .await
1117                .map(|o| (o, true))
1118                .map_err(err),
1119        }
1120    }
1121
1122    /// Runs `speccoder.task.propose` for a bundle, correlated to the task.
1123    pub async fn propose(
1124        &self,
1125        feature: &str,
1126        task: &str,
1127        input: Tainted<Value>,
1128    ) -> Result<RunOutcome, PlaneError> {
1129        self.runtime
1130            .start(CAPABILITY, input)
1131            .terms(RunTerms::default().correlated(
1132                "task",
1133                &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1134            ))
1135            .run()
1136            .await
1137            .map_err(|e| PlaneError(e.to_string()))
1138    }
1139}
1140
1141/// The input `run` was admitted with, with its root and field labels — from
1142/// the journal's `RunAdmitted` record, never from anything a tool returned.
1143pub async fn admitted(
1144    dir: &Path,
1145    run: &str,
1146) -> Result<Option<agentplane::core::Tainted<Value>>, PlaneError> {
1147    let id = agentplane::core::RunId::parse(run)
1148        .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
1149    let Some(store) = open_reader(dir)? else {
1150        return Ok(None);
1151    };
1152    let history = store
1153        .read(id, 1)
1154        .await
1155        .map_err(|e| PlaneError(e.to_string()))?;
1156    Ok(history
1157        .iter()
1158        .find_map(agentplane::journal::Record::admitted_input))
1159}
1160
1161/// Strictly re-derives a recorded run and says whether this build
1162/// reproduces it: history is consumed, nothing is
1163/// dispatched. The model driver is agentplane's replay-only provider, which
1164/// reads the request profile back from the history and refuses every call,
1165/// so no credential is needed. A faithful replay of a run that failed is
1166/// `Finding::Verified` too — the recorded ending is what is verified.
1167/// `Ok(None)` when the run is not in the journal.
1168pub async fn replay_strict(
1169    dir: &Path,
1170    model: &ModelConfig,
1171    run: &str,
1172    format: &'static dyn crate::format::Adapter,
1173) -> Result<Option<agentplane::runtime::Verdict>, PlaneError> {
1174    let id = agentplane::core::RunId::parse(run)
1175        .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
1176    let Some(store) = open_reader(dir)? else {
1177        return Ok(None);
1178    };
1179    let history = store
1180        .read(id, 1)
1181        .await
1182        .map_err(|e| PlaneError(e.to_string()))?;
1183    if history.is_empty() {
1184        return Ok(None);
1185    }
1186    // A decision speccoder 0.2.0 recorded does not read in this build:
1187    // named, never shown as a divergence or a decoding error.
1188    if history.iter().any(|r| {
1189        serde_json::to_value(r.kind())
1190            .ok()
1191            .is_some_and(|v| holds_a_pre_054_decision(&v))
1192    }) {
1193        return Err(PlaneError(format!(
1194            "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"
1195        )));
1196    }
1197    // The run's MCP tools as its admitted input lists them: the same
1198    // manifest again. agentplane's replay-only drivers answer for every
1199    // provider and tool server it names — replay dispatches nothing.
1200    let mcp_tools: Vec<crate::mcp::McpTool> = history
1201        .iter()
1202        .find_map(agentplane::journal::Record::admitted_input)
1203        .and_then(|a| serde_json::from_value(a.peek()["settings"]["mcp"].clone()).ok())
1204        .unwrap_or_default();
1205    let hooks = Hooks {
1206        mcp_tools,
1207        format: Some(format),
1208        ..Hooks::default()
1209    };
1210    let (builder, manifest) = builder(store, model, hooks)?;
1211    let runtime = agentplane::runtime::replay_only::wire(builder, &[manifest], &history)
1212        .try_build()
1213        .map_err(|e| PlaneError(format!("building the replay plane: {e}")))?;
1214    runtime
1215        .verify(id)
1216        .await
1217        .map(Some)
1218        .map_err(|e| PlaneError(e.to_string()))
1219}
1220
1221/// What the model said in a turn beside its answer — a claim, never
1222/// evidence: its commentary, where the provider marks it apart (OpenAI
1223/// Responses), then its text when the turn called tools (a turn without
1224/// calls answers with its text). `None` when it said nothing.
1225#[must_use]
1226pub fn said(completion: &agentplane::model::Completion) -> Option<String> {
1227    let commentary = completion.commentary.as_deref().map(str::trim);
1228    let text = (!completion.tool_calls.is_empty()).then(|| completion.text.trim());
1229    let parts: Vec<&str> = [commentary, text]
1230        .into_iter()
1231        .flatten()
1232        .filter(|t| !t.is_empty())
1233        .collect();
1234    (!parts.is_empty()).then(|| parts.join("\n\n"))
1235}
1236
1237/// How the model ended its attempt, in its words: the final answer's
1238/// summary (its text when the answer has no structure), or why it stopped
1239/// (`report_blocked`). `None` for a turn that did neither.
1240#[must_use]
1241pub fn answered(completion: &agentplane::model::Completion) -> Option<String> {
1242    if let Some(blocked) = completion
1243        .tool_calls
1244        .iter()
1245        .find(|c| c.name == attempt::REPORT_BLOCKED)
1246    {
1247        let reason = blocked.arguments["reason"].as_str().unwrap_or_default();
1248        return Some(match blocked.arguments["kind"].as_str() {
1249            Some(kind) => format!("Blocked ({kind}): {reason}"),
1250            None => format!("Blocked: {reason}"),
1251        });
1252    }
1253    if !completion.tool_calls.is_empty() {
1254        return None;
1255    }
1256    let summary = completion
1257        .structured
1258        .as_ref()
1259        .and_then(|s| s["summary"].as_str());
1260    let text = summary.unwrap_or(&completion.text).trim();
1261    (!text.is_empty()).then(|| text.to_owned())
1262}
1263
1264/// One model turn of an attempt, as the person is shown it afterwards: the
1265/// model's summary of its reasoning, its words beside its calls and how it
1266/// ended — its account, a claim. Control characters escaped.
1267#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1268pub struct AccountTurn {
1269    /// The turn (1-based) within the attempt.
1270    pub turn: u32,
1271    /// Its reasoning summary, when one was asked for and given.
1272    pub reasoning: Option<String>,
1273    /// Its words beside its calls.
1274    pub said: Option<String>,
1275    /// Its closing words: the final answer's summary, or why it stopped.
1276    pub answer: Option<String>,
1277}
1278
1279/// The model's account of `run`, turn by turn: every turn that reasoned,
1280/// said something or ended the attempt, in order.
1281///
1282/// # Errors
1283///
1284/// The journal cannot be read, is not here, or does not hold `run`.
1285pub async fn account(dir: &Path, run: &str) -> Result<Vec<AccountTurn>, PlaneError> {
1286    let Ok(id) = agentplane::core::RunId::parse(run) else {
1287        return Err(PlaneError(format!("{run} is not a run id")));
1288    };
1289    // The project's journal, then each lane's (a lane run keeps its own).
1290    // No journal here, or none holding the run: said, never read as a
1291    // silent model.
1292    let lanes = std::fs::read_dir(dir.join("lanes"))
1293        .into_iter()
1294        .flatten()
1295        .flatten()
1296        .map(|e| e.path());
1297    let mut any = false;
1298    let mut history = Vec::new();
1299    for d in std::iter::once(dir.to_path_buf()).chain(lanes) {
1300        let Some(store) = open_reader(&d)? else {
1301            continue;
1302        };
1303        any = true;
1304        history = store
1305            .read(id, 1)
1306            .await
1307            .map_err(|e| PlaneError(e.to_string()))?;
1308        if !history.is_empty() {
1309            break;
1310        }
1311    }
1312    if !any {
1313        return Err(PlaneError(
1314            "no journal of this project's runs is here".to_owned(),
1315        ));
1316    }
1317    if history.is_empty() {
1318        return Err(PlaneError(format!(
1319            "run {run} is not in this project's journal"
1320        )));
1321    }
1322    let text = |t: &str| {
1323        let t = t.trim();
1324        (!t.is_empty()).then(|| crate::approvals::present::escape(t))
1325    };
1326    Ok(agentplane::model::turns(&history)
1327        .into_iter()
1328        .zip(1u32..)
1329        .filter_map(|(t, turn)| {
1330            let c = t.completion?;
1331            // A final answer's text is its structure: its summary stands for
1332            // it, never the raw JSON beside it as words.
1333            let said = if c.tool_calls.is_empty() {
1334                c.commentary.as_deref().and_then(text)
1335            } else {
1336                said(&c).as_deref().and_then(text)
1337            };
1338            let reasoning = c.reasoning.as_deref().and_then(text);
1339            let answer = answered(&c).as_deref().and_then(text);
1340            (said.is_some() || reasoning.is_some() || answer.is_some()).then_some(AccountTurn {
1341                turn,
1342                reasoning,
1343                said,
1344                answer,
1345            })
1346        })
1347        .collect())
1348}
1349
1350/// What `run`'s model turns used, summed from the journal: the usage of an
1351/// attempt the plane stopped before it could report its own. `None` when
1352/// no turn completed.
1353///
1354/// # Errors
1355///
1356/// The journal cannot be read.
1357pub async fn spent(dir: &Path, run: &str) -> Result<Option<Value>, PlaneError> {
1358    let Ok(id) = agentplane::core::RunId::parse(run) else {
1359        return Ok(None);
1360    };
1361    let Some(store) = open_reader(dir)? else {
1362        return Ok(None);
1363    };
1364    let history = store
1365        .read(id, 1)
1366        .await
1367        .map_err(|e| PlaneError(e.to_string()))?;
1368    let used: Vec<agentplane::model::Usage> = agentplane::model::turns(&history)
1369        .into_iter()
1370        .filter_map(|t| t.completion.map(|c| c.usage))
1371        .collect();
1372    Ok((!used.is_empty()).then(|| {
1373        let sum = |f: fn(&agentplane::model::Usage) -> u64| used.iter().map(f).sum::<u64>();
1374        serde_json::json!({
1375            "input_tokens": sum(|u| u.input_tokens),
1376            "output_tokens": sum(|u| u.output_tokens),
1377            "cache_read_tokens": sum(|u| u.cache_read_tokens),
1378            "cache_write_tokens": sum(|u| u.cache_write_tokens),
1379        })
1380    }))
1381}
1382
1383/// The tool servers when no worktree is bound: every call is refused.
1384#[derive(Debug)]
1385struct Unbound;
1386
1387#[async_trait]
1388impl agentplane::tools::ToolClient for Unbound {
1389    async fn call(
1390        &self,
1391        tool: &agentplane::tools::ToolId,
1392        _arguments: &Value,
1393        _provenance: Option<&agentplane::core::Provenance>,
1394    ) -> Result<Value, agentplane::tools::ToolError> {
1395        Err(agentplane::tools::ToolError::Refused {
1396            tool: tool.clone(),
1397            detail: "no worktree is bound to this plane".to_owned(),
1398        })
1399    }
1400
1401    fn destination(&self, _tool: &agentplane::tools::ToolId) -> agentplane::tools::Destination {
1402        agentplane::tools::Destination::Local
1403    }
1404}
1405
1406#[cfg(test)]
1407mod tests {
1408    use super::*;
1409
1410    #[test]
1411    fn the_agentplane_version_named_in_summaries_is_the_pinned_one() {
1412        let workspace = include_str!("../../../../Cargo.toml");
1413        assert!(
1414            workspace.contains(&format!(
1415                "agentplane = {{ version = \"={AGENTPLANE_VERSION}\""
1416            )),
1417            "AGENTPLANE_VERSION is not the workspace's pin"
1418        );
1419    }
1420
1421    #[test]
1422    fn an_attempt_ends_in_its_answer_s_summary_or_why_it_stopped() {
1423        use agentplane::model::{Completion, ToolCall};
1424        let report = serde_json::json!({ "summary": "Added the parser.", "status": "done" });
1425        let done = Completion {
1426            text: report.to_string(),
1427            structured: Some(report),
1428            ..Completion::default()
1429        };
1430        assert_eq!(answered(&done).as_deref(), Some("Added the parser."));
1431        let plain = Completion {
1432            text: " All done. ".to_owned(),
1433            ..Completion::default()
1434        };
1435        assert_eq!(answered(&plain).as_deref(), Some("All done."));
1436        let call = |name: &str, arguments: Value| Completion {
1437            tool_calls: vec![ToolCall {
1438                id: "c".to_owned(),
1439                name: name.to_owned(),
1440                arguments,
1441            }],
1442            ..Completion::default()
1443        };
1444        let blocked = call(
1445            attempt::REPORT_BLOCKED,
1446            serde_json::json!({ "kind": "wrong_test", "evidence": "tests/a.py", "reason": "it asserts the old format" }),
1447        );
1448        assert_eq!(
1449            answered(&blocked).as_deref(),
1450            Some("Blocked (wrong_test): it asserts the old format")
1451        );
1452        assert_eq!(answered(&call("read_file", serde_json::json!({}))), None);
1453    }
1454
1455    #[test]
1456    fn a_provider_refusal_is_read_from_the_run_failure() {
1457        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\")";
1458        let r = provider_refusal(failure).unwrap();
1459        assert_eq!(
1460            r,
1461            "credit_balance_exhausted: You have no credits remaining. Add credits at https://platform.openai.com/settings/organization/billing/."
1462        );
1463        // As agentplane 0.57 records it: the failure in its own words.
1464        let own = "effect ek:21 was refused on attempt 1, and the refusal is an answer rather than a fault ('openai/gpt-5.4-mini' refused the request: insufficient_quota: You exceeded your current quota.); no retry would change it";
1465        assert_eq!(
1466            provider_refusal(own).as_deref(),
1467            Some("insufficient_quota: You exceeded your current quota.")
1468        );
1469        assert_eq!(provider_refusal("Failed(\"timeout\")"), None);
1470    }
1471
1472    #[test]
1473    fn a_failed_run_says_why_in_the_failure_s_own_words() {
1474        let unusable = RunStatus::Failed("effect ek:774b failed on attempt 1 of 1: effect consumed resources and failed: 'openai/gpt-5.4-mini' returned an unusable answer: a schema was required and the answer is not JSON: trailing characters at line 1 column 2531".to_owned());
1475        assert_eq!(
1476            why_not(&unusable),
1477            "'openai/gpt-5.4-mini' returned an unusable answer: a schema was required and the answer is not JSON: trailing characters at line 1 column 2531"
1478        );
1479        let timeout =
1480            RunStatus::Failed("effect ek:9 failed on attempt 2 of 2: timed out".to_owned());
1481        assert_eq!(why_not(&timeout), "timed out");
1482        let own = RunStatus::Failed("effect ek:774b failed on attempt 1 of 1: 'openai/gpt-5.4-mini' returned an unusable answer: not JSON".to_owned());
1483        assert_eq!(
1484            why_not(&own),
1485            "'openai/gpt-5.4-mini' returned an unusable answer: not JSON"
1486        );
1487        assert_eq!(why_not(&RunStatus::Succeeded), "succeeded");
1488    }
1489
1490    #[test]
1491    fn an_unreadable_journal_is_moved_aside_and_a_locked_one_is_an_error() {
1492        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1493        std::fs::write(dir.path().join(JOURNAL), b"written by another format")
1494            .unwrap_or_else(|e| panic!("{e}"));
1495        let (store, aside) = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1496        let aside = aside.unwrap_or_else(|| panic!("moved aside"));
1497        assert!(aside.exists() && dir.path().join(JOURNAL).exists());
1498        // In this process a second opener shares the handle.
1499        let shared = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1500        assert!(Arc::ptr_eq(&store, &shared.0) && shared.1.is_none());
1501        drop((store, shared));
1502        // Written elsewhere (another process's lock, here a direct open):
1503        // refused at once, and nothing is moved.
1504        let held = RedbStore::open(dir.path().join(JOURNAL)).unwrap_or_else(|e| panic!("{e}"));
1505        let started = std::time::Instant::now();
1506        let second = open_or_replace(dir.path())
1507            .err()
1508            .map(|e| e.0)
1509            .unwrap_or_default();
1510        assert!(second.contains("another speccoder"), "{second}");
1511        assert!(started.elapsed() < std::time::Duration::from_millis(500));
1512        // A writer that holds the file alone cannot be read beside: said,
1513        // never read as empty.
1514        assert!(open_reader(dir.path()).is_err());
1515        drop(held);
1516        assert!(
1517            open_or_replace(dir.path())
1518                .unwrap_or_else(|e| panic!("{}", e.0))
1519                .1
1520                .is_none()
1521        );
1522    }
1523
1524    #[test]
1525    fn a_reader_creates_nothing_and_reads_beside_this_process_s_writer() {
1526        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1527        // No journal: nothing to read, and none is created.
1528        assert!(
1529            open_reader(dir.path())
1530                .unwrap_or_else(|e| panic!("{}", e.0))
1531                .is_none()
1532        );
1533        assert!(
1534            Plane::open_for_reading(dir.path())
1535                .unwrap_or_else(|e| panic!("{}", e.0))
1536                .is_none()
1537        );
1538        assert!(
1539            !dir.path().join(JOURNAL).exists(),
1540            "a read created a journal"
1541        );
1542        // Beside a writer in this process (the app reading while its job
1543        // runs): the reader opens; a second run here is still refused.
1544        let model =
1545            model_config::parse("[model]\nprovider = \"openai\"\nmodel = \"gpt-5.4-mini\"\n")
1546                .unwrap_or_else(|e| panic!("{e}"))
1547                .unwrap_or_else(|| panic!("no model"));
1548        let fake: Arc<dyn ModelProvider> = agentplane::model::fake::FakeProvider::new();
1549        let run = Plane::open(dir.path(), &model, Arc::clone(&fake))
1550            .unwrap_or_else(|e| panic!("{}", e.0));
1551        let reader = open_reader(dir.path())
1552            .unwrap_or_else(|e| panic!("{}", e.0))
1553            .unwrap_or_else(|| panic!("no journal"));
1554        assert!(reader.is_read_only());
1555        assert!(Plane::open(dir.path(), &model, fake).is_err());
1556        drop(run);
1557    }
1558}