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        })
492        .skill(host::Host {
493            style: model.prompt_style(),
494            progress: hooks.progress,
495            format,
496            present: hooks.present,
497        })
498        .skill(acceptance::Acceptance {
499            style: model.prompt_style(),
500            cache_ttl: model.cache_ttl,
501            format,
502        })
503        .skill(converge::Converge {
504            style: model.prompt_style(),
505            cache_ttl: model.cache_ttl,
506            format,
507        })
508        .skill(monitor::Monitor {
509            style: model.prompt_style(),
510            cache_ttl: model.cache_ttl,
511            format,
512        })
513        .skill(near_miss::NearMiss {
514            style: model.prompt_style(),
515            cache_ttl: model.cache_ttl,
516            format,
517        })
518        .skill(shape::Shape {
519            style: model.prompt_style(),
520            cache_ttl: model.cache_ttl,
521        });
522    // One process owns the journal (redb locks it): opening it frees the
523    // leases a dead process left, so the next run resumes at once.
524    let builder = Runtime::builder_on(store).agent(agent);
525    Ok((builder, manifest))
526}
527
528fn build(
529    store: Arc<RedbStore>,
530    model: &ModelConfig,
531    provider: Arc<dyn ModelProvider>,
532    tools: Option<Arc<crate::tools::WorkspaceTools>>,
533    hooks: Hooks,
534) -> Result<Arc<Runtime>, PlaneError> {
535    let mcp = hooks.mcp.clone();
536    let (mut builder, _) = builder(store, model, hooks)?;
537    builder = builder.provider(model.provider.clone(), provider);
538    // The tool servers answer only when a worktree is bound; without one, a
539    // call to a granted tool is refused.
540    let client: Arc<dyn agentplane::tools::ToolClient> = match tools {
541        Some(t) => t,
542        None => Arc::new(Unbound),
543    };
544    for server in ["fs", "proc", "git"] {
545        builder = builder.tool_server(server, Arc::clone(&client));
546    }
547    for (server, client) in &mcp {
548        builder = builder.tool_server(server.as_str(), Arc::clone(client));
549    }
550    builder
551        .try_build()
552        .map_err(|e| PlaneError(format!("building the plane: {e}")))
553}
554
555impl Plane {
556    /// Opens (or creates) the journal at `dir/journal.redb` with `provider`
557    /// registered under the configured provider name.
558    pub fn open(
559        dir: &Path,
560        model: &ModelConfig,
561        provider: Arc<dyn ModelProvider>,
562    ) -> Result<Self, PlaneError> {
563        Self::open_with_tools(dir, model, provider, None)
564    }
565
566    /// [`Plane::open`] with speccoder's tools bound to a worktree.
567    pub fn open_with_tools(
568        dir: &Path,
569        model: &ModelConfig,
570        provider: Arc<dyn ModelProvider>,
571        tools: Option<Arc<crate::tools::WorkspaceTools>>,
572    ) -> Result<Self, PlaneError> {
573        Self::open_with(dir, model, provider, tools, Hooks::default())
574    }
575
576    /// [`Plane::open_with_tools`] with someone to ask inline.
577    pub fn open_with(
578        dir: &Path,
579        model: &ModelConfig,
580        provider: Arc<dyn ModelProvider>,
581        tools: Option<Arc<crate::tools::WorkspaceTools>>,
582        hooks: Hooks,
583    ) -> Result<Self, PlaneError> {
584        let claim = RunClaim::take(dir)?;
585        let (store, replaced_journal) = open_or_replace(dir)?;
586        Ok(Self {
587            runtime: build(store, model, provider, tools, hooks)?,
588            replaced_journal,
589            _run: Some(claim),
590        })
591    }
592
593    /// The journal with no skills and no model: enough to record a person's
594    /// decision on a waiting call (`speccoder task approve`), which the attempt's
595    /// own run picks up when it resumes (agentplane's `Delivery::Buffered`).
596    pub fn open_for_decisions(dir: &Path) -> Result<Self, PlaneError> {
597        let (store, replaced_journal) = open_or_replace(dir)?;
598        let runtime = Runtime::builder_on(store)
599            .try_build()
600            .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
601        Ok(Self {
602            runtime,
603            replaced_journal,
604            _run: None,
605        })
606    }
607
608    /// The journal read beside its writer, with no skills and no model:
609    /// the waiting calls and recorded calls as they stand. `None` when the
610    /// project has no journal. Nothing is written — not even an expired
611    /// call's denial, which the next writer records.
612    ///
613    /// # Errors
614    ///
615    /// The journal cannot be read ([`open_reader`]).
616    pub fn open_for_reading(dir: &Path) -> Result<Option<Self>, PlaneError> {
617        let Some(store) = open_reader(dir)? else {
618            return Ok(None);
619        };
620        let runtime = Runtime::builder_on(store)
621            .try_build()
622            .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
623        Ok(Some(Self {
624            runtime,
625            replaced_journal: None,
626            _run: None,
627        }))
628    }
629
630    /// Settles every waiting call whose deadline has passed at `now`: the
631    /// call is denied as expired, nobody's decision. Only on a plane without
632    /// skills ([`Plane::open_for_decisions`]) — the attempt resumes in its own
633    /// run, with its own worktree, the next time it runs.
634    pub async fn expire_due(&self, now: agentplane::core::Timestamp) -> Result<(), PlaneError> {
635        self.runtime
636            .sweep(now, std::time::Duration::from_secs(7 * 86_400))
637            .await
638            .map(|_| ())
639            .map_err(|e| PlaneError(e.to_string()))
640    }
641
642    /// Withdraws a waiting call whose attempt was set aside: its run is
643    /// cancelled on the record (who, why) and the call leaves the queue.
644    pub async fn withdraw(
645        &self,
646        task: &agentplane::core::Task,
647        by: &agentplane::core::Operator,
648        reason: &str,
649    ) -> Result<(), PlaneError> {
650        let err = |e: String| PlaneError(e);
651        let _ = self
652            .runtime
653            .request_cancel(task.run, by, reason)
654            .await
655            .map_err(|e| err(e.to_string()))?;
656        if let Some(tasks) = self.runtime.tasks() {
657            tasks
658                .withdraw_run(task.run, &[task.id])
659                .await
660                .map_err(|e| err(e.to_string()))?;
661        }
662        Ok(())
663    }
664
665    /// The person's task a suspended run waits on, if it waits on one.
666    pub async fn waiting_on(
667        &self,
668        outcome: &RunOutcome,
669    ) -> Result<Option<agentplane::core::Task>, PlaneError> {
670        let RunStatus::Suspended(reason) = &outcome.status else {
671            return Ok(None);
672        };
673        let Some(id) = reason.awaited_task() else {
674            return Ok(None);
675        };
676        self.task(id).await
677    }
678
679    /// One task row.
680    pub async fn task(
681        &self,
682        id: agentplane::core::TaskId,
683    ) -> Result<Option<agentplane::core::Task>, PlaneError> {
684        let Some(tasks) = self.runtime.tasks() else {
685            return Ok(None);
686        };
687        tasks.task(id).await.map_err(|e| PlaneError(e.to_string()))
688    }
689
690    /// Every call of `server/tool` the journal records as dispatched, in
691    /// every run, with the time its run last wrote and whether it was
692    /// decided on an object (a person's decision or grant) — and what could
693    /// not be read: a run unreadable is reported, never skipped silently.
694    pub async fn tool_calls(&self, server: &str, tool: &str) -> Result<RecordedCalls, PlaneError> {
695        let journal = self.runtime.journal();
696        let mut out = RecordedCalls::default();
697        let mut after: Option<(u64, agentplane::core::RunId)> = None;
698        loop {
699            let page = journal
700                .recent_runs(after, 500)
701                .await
702                .map_err(|e| PlaneError(format!("listing the journal's runs: {e}")))?;
703            let Some(last) = page.last().cloned() else {
704                break;
705            };
706            for (run, at) in &page {
707                let records = match journal.read(*run, 0).await {
708                    Ok(r) => r,
709                    Err(e) => {
710                        out.unreadable.push(format!("run {run}: {e}"));
711                        continue;
712                    }
713                };
714                let mut seen = std::collections::BTreeSet::new();
715                for record in records {
716                    let agentplane::RecordKind::EffectStarted { descriptor, .. } = record.kind()
717                    else {
718                        continue;
719                    };
720                    let args = &descriptor.args;
721                    if descriptor.kind != "tool.call"
722                        || args["server"] != server
723                        || args["tool"] != tool
724                    {
725                        continue;
726                    }
727                    // A retried effect is one call.
728                    let key = record
729                        .body
730                        .effect_key
731                        .as_ref()
732                        .map_or_else(|| record.body.seq.to_string(), |k| format!("{k:?}"));
733                    if seen.insert(key) {
734                        out.calls.push(RecordedCall {
735                            arguments: args["arguments"].clone(),
736                            at: *at,
737                        });
738                    }
739                }
740            }
741            after = Some((last.1, last.0));
742        }
743        Ok(out)
744    }
745
746    /// Whether `run` read a person's decision recorded by speccoder 0.2.0
747    /// (agentplane 0.53 and earlier): a decision with `approved`, which
748    /// agentplane 0.54 replaced by a verdict, so the run cannot read it
749    /// and fails on it. Told from the record's shape, never a message.
750    pub async fn holds_a_pre_054_decision(&self, run: agentplane::core::RunId) -> bool {
751        let Ok(records) = self.runtime.journal().read(run, 0).await else {
752            return false;
753        };
754        records.iter().any(|r| {
755            serde_json::to_value(r.kind())
756                .ok()
757                .is_some_and(|v| holds_a_pre_054_decision(&v))
758        })
759    }
760
761    /// Every task waiting for a person, oldest first.
762    pub async fn open_tasks(&self) -> Result<Vec<agentplane::core::Task>, PlaneError> {
763        let Some(tasks) = self.runtime.tasks() else {
764            return Ok(Vec::new());
765        };
766        let mut open = tasks
767            .queue(&[], 1000)
768            .await
769            .map_err(|e| PlaneError(e.to_string()))?;
770        open.sort_by_key(|t| t.created_at);
771        Ok(open)
772    }
773
774    /// Records a person's decision on `task`: the run continues at once
775    /// when this plane holds its skill, else when it next resumes.
776    pub async fn decide(
777        &self,
778        task: agentplane::core::TaskId,
779        decision: &agentplane::core::Decision,
780    ) -> Result<agentplane::core::Delivery, PlaneError> {
781        self.decide_at(task, decision, None).await
782    }
783
784    /// [`Plane::decide`], refused (nothing recorded) unless the task row
785    /// is still the one whose justification digest is `expected`.
786    pub async fn decide_at(
787        &self,
788        task: agentplane::core::TaskId,
789        decision: &agentplane::core::Decision,
790        expected: Option<agentplane::core::Digest>,
791    ) -> Result<agentplane::core::Delivery, PlaneError> {
792        self.runtime
793            .decide_task_at(task, decision, &[], expected)
794            .await
795            .map_err(|e| PlaneError(e.to_string()))
796    }
797
798    /// Asks `approver` about each call the run waits on, records the
799    /// decision and lets the run continue, until it no longer waits on a
800    /// person. Returns the run as it rests, the call still waiting, if
801    /// the person answered "later", nobody is there or the call waits in
802    /// its run's batch, and the calls answered Always Allow (approved once
803    /// here; their argv is added to the allow list by
804    /// `actions::perform(AllowAlways)`, which the caller runs).
805    pub async fn decide_inline(
806        &self,
807        mut outcome: RunOutcome,
808        approver: Option<&dyn crate::approvals::Approver>,
809        context: &crate::approvals::PresentContext,
810    ) -> Result<Inline, PlaneError> {
811        let mut always = Vec::new();
812        while let Some(task) = self.waiting_on(&outcome).await? {
813            let Some((decision, answer)) =
814                approver.and_then(|a| crate::approvals::tasks::ask(a, &task, context))
815            else {
816                return Ok(Inline {
817                    outcome,
818                    waiting: Some(task),
819                    always,
820                });
821            };
822            self.decide(task.id, &decision).await?;
823            if answer.always && decision.approved() {
824                let via = answer
825                    .by
826                    .as_ref()
827                    .and_then(|b| speccoder_core::actions::Via::read(&b.via))
828                    .map_or(speccoder_core::actions::Via::Terminal, |(v, _)| v);
829                crate::approvals::answered_always(&task.id.to_hex());
830                always.push(Always {
831                    task: crate::approvals::tasks::request_of(&task.justification.proposed_action)
832                        .map(|r| r.task)
833                        .unwrap_or_default(),
834                    call: task.id.to_hex(),
835                    via,
836                });
837            }
838            match self.outcome(outcome.run_id).await? {
839                Some(o) => outcome = o,
840                None => break,
841            }
842        }
843        Ok(Inline {
844            outcome,
845            waiting: None,
846            always,
847        })
848    }
849
850    /// The run as it rests now, its output included: the journal records
851    /// what happened, not a step's value, so the value is re-derived by
852    /// strict replay, which performs nothing.
853    pub async fn outcome(
854        &self,
855        run: agentplane::core::RunId,
856    ) -> Result<Option<RunOutcome>, PlaneError> {
857        if self
858            .runtime
859            .recorded_outcome(run)
860            .await
861            .map_err(|e| PlaneError(e.to_string()))?
862            .is_none()
863        {
864            return Ok(None);
865        }
866        self.runtime
867            .replay(run, agentplane::runtime::Mode::Strict)
868            .await
869            .map(Some)
870            .map_err(|e| PlaneError(e.to_string()))
871    }
872
873    /// A plane over `store` instead of the journal file: a crash point's
874    /// prefix of a recorded run, resumed in a test.
875    #[doc(hidden)]
876    pub fn on_store(
877        store: Arc<RedbStore>,
878        model: &ModelConfig,
879        provider: Arc<dyn ModelProvider>,
880        tools: Option<Arc<crate::tools::WorkspaceTools>>,
881    ) -> Result<Self, PlaneError> {
882        Ok(Self {
883            runtime: build(store, model, provider, tools, Hooks::default())?,
884            replaced_journal: None,
885            _run: None,
886        })
887    }
888
889    /// Resumes `run` as a restarted process would, agentplane's own answer
890    /// unchanged (for `testkit::assert_resumed_honestly`).
891    #[doc(hidden)]
892    pub async fn resume(
893        &self,
894        run: agentplane::core::RunId,
895    ) -> Result<RunOutcome, agentplane::RuntimeError> {
896        self.runtime
897            .replay(run, agentplane::runtime::Mode::Resume)
898            .await
899    }
900
901    /// Runs `speccoder.feature.converge`, correlated to the feature.
902    pub async fn converge(
903        &self,
904        feature: &str,
905        input: Tainted<Value>,
906    ) -> Result<RunOutcome, PlaneError> {
907        self.runtime
908            .start(converge::CAPABILITY, input)
909            .terms(RunTerms::default().correlated(
910                "feature",
911                &[CorrelationKey::new("feature", feature.to_owned())],
912            ))
913            .run()
914            .await
915            .map_err(|e| PlaneError(e.to_string()))
916    }
917
918    /// Runs `speccoder.shape`: a person's words made into the field's kind
919    /// of text.
920    pub async fn shape(&self, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
921        self.runtime
922            .start(shape::CAPABILITY, input)
923            .run()
924            .await
925            .map_err(|e| PlaneError(e.to_string()))
926    }
927
928    /// Runs `speccoder.skill.run` for an installed skill.
929    pub async fn skill(&self, name: &str, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
930        self.runtime
931            .start(host::CAPABILITY, input)
932            .terms(
933                RunTerms::default()
934                    .correlated("skill", &[CorrelationKey::new("skill", name.to_owned())]),
935            )
936            .run()
937            .await
938            .map_err(|e| PlaneError(e.to_string()))
939    }
940
941    /// Runs `speccoder.task.monitor`, correlated to the task, admitted once
942    /// per input: judging the same change again reads the recorded answer
943    /// back instead of asking again.
944    pub async fn monitor(
945        &self,
946        feature: &str,
947        task: &str,
948        input: Tainted<Value>,
949    ) -> Result<RunOutcome, PlaneError> {
950        self.judge_once(monitor::CAPABILITY, feature, task, input)
951            .await
952    }
953
954    /// Runs `speccoder.task.near_miss`, admitted once per input like
955    /// [`monitor`](Self::monitor): a held attempt judged again after a
956    /// person's ruling meets the same variants, not new ones.
957    pub async fn near_miss(
958        &self,
959        feature: &str,
960        task: &str,
961        input: Tainted<Value>,
962    ) -> Result<RunOutcome, PlaneError> {
963        self.judge_once(near_miss::CAPABILITY, feature, task, input)
964            .await
965    }
966
967    /// A judge-side call admitted once per task and input: the first time it
968    /// runs; afterwards the recorded run is re-derived by strict replay,
969    /// which performs nothing (an interrupted one resumes).
970    async fn judge_once(
971        &self,
972        capability: &str,
973        feature: &str,
974        task: &str,
975        input: Tainted<Value>,
976    ) -> Result<RunOutcome, PlaneError> {
977        use agentplane::runtime::{Admission, Mode};
978        use sha2::Digest as _;
979        let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
980        let digest = sha2::Sha256::digest(
981            format!("{capability}\n{feature}/{task}\n{}", input.peek()).as_bytes(),
982        );
983        let key: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect();
984        let key = format!("judge-{key}");
985        let key = key.as_str();
986        let terms = || {
987            RunTerms::default().correlated(
988                "task",
989                &[CorrelationKey::new("task", format!("{feature}/{task}"))],
990            )
991        };
992        let admitted = self
993            .runtime
994            .start(capability, input.clone())
995            .terms(terms())
996            .once(key)
997            .run()
998            .await
999            .map_err(err)?;
1000        match admitted {
1001            Admission::Fresh(o) => Ok(o),
1002            // A call that failed (a provider error) is asked again, unkeyed:
1003            // only an answer is worth reading back.
1004            Admission::Replayed(o) if !matches!(o.status, RunStatus::Succeeded) => self
1005                .runtime
1006                .start(capability, input)
1007                .terms(terms())
1008                .run()
1009                .await
1010                .map_err(err),
1011            Admission::Replayed(o) => self
1012                .runtime
1013                .replay(o.run_id, Mode::Strict)
1014                .await
1015                .map_err(err),
1016            Admission::InFlight(run) => self.runtime.replay(run, Mode::Resume).await.map_err(err),
1017        }
1018    }
1019
1020    /// Runs `speccoder.task.acceptance`: acceptance tests derived from the spec.
1021    pub async fn acceptance(
1022        &self,
1023        feature: &str,
1024        task: &str,
1025        input: Tainted<Value>,
1026    ) -> Result<RunOutcome, PlaneError> {
1027        self.runtime
1028            .start(acceptance::CAPABILITY, input)
1029            .terms(RunTerms::default().correlated(
1030                "task",
1031                &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1032            ))
1033            .run()
1034            .await
1035            .map_err(|e| PlaneError(e.to_string()))
1036    }
1037
1038    /// Runs one `speccoder.task.attempt` for a bundle, correlated to the task.
1039    pub async fn attempt(
1040        &self,
1041        feature: &str,
1042        task: &str,
1043        input: Tainted<Value>,
1044    ) -> Result<RunOutcome, PlaneError> {
1045        self.runtime
1046            .start(attempt::CAPABILITY, input)
1047            .terms(RunTerms::default().correlated(
1048                "task",
1049                &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1050            ))
1051            .run()
1052            .await
1053            .map_err(|e| PlaneError(e.to_string()))
1054    }
1055
1056    /// [`attempt`](Self::attempt) admitted under `key`: the first time it
1057    /// runs; after a crash the same key finds the interrupted run — a run
1058    /// that had rested is re-derived by strict replay, which performs
1059    /// nothing, and one still in flight is resumed from its journal (a dead
1060    /// process's lease is freed when the store opens), so finished model and
1061    /// tool calls are read back, not repeated. The `bool` says whether this
1062    /// was such a resume: what the run's notice may claim.
1063    pub async fn attempt_keyed(
1064        &self,
1065        feature: &str,
1066        task: &str,
1067        input: Tainted<Value>,
1068        key: &str,
1069    ) -> Result<(RunOutcome, bool), PlaneError> {
1070        use agentplane::runtime::{Admission, Mode};
1071        let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
1072        let admitted = self
1073            .runtime
1074            .start(attempt::CAPABILITY, input)
1075            .terms(RunTerms::default().correlated(
1076                "task",
1077                &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1078            ))
1079            .once(key)
1080            .run()
1081            .await
1082            .map_err(err)?;
1083        match admitted {
1084            Admission::Fresh(o) => Ok((o, false)),
1085            // A run waiting for a person resumes: a decision recorded since
1086            // is delivered and the attempt continues; one still undecided
1087            // waits again. Any other rested run is re-derived, performing
1088            // nothing.
1089            Admission::Replayed(o) if matches!(o.status, RunStatus::Suspended(_)) => self
1090                .runtime
1091                .replay(o.run_id, Mode::Resume)
1092                .await
1093                .map(|o| (o, true))
1094                .map_err(err),
1095            Admission::Replayed(o) => self
1096                .runtime
1097                .replay(o.run_id, Mode::Strict)
1098                .await
1099                .map(|o| (o, true))
1100                .map_err(err),
1101            Admission::InFlight(run) => self
1102                .runtime
1103                .replay(run, Mode::Resume)
1104                .await
1105                .map(|o| (o, true))
1106                .map_err(err),
1107        }
1108    }
1109
1110    /// Runs `speccoder.task.propose` for a bundle, correlated to the task.
1111    pub async fn propose(
1112        &self,
1113        feature: &str,
1114        task: &str,
1115        input: Tainted<Value>,
1116    ) -> Result<RunOutcome, PlaneError> {
1117        self.runtime
1118            .start(CAPABILITY, input)
1119            .terms(RunTerms::default().correlated(
1120                "task",
1121                &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1122            ))
1123            .run()
1124            .await
1125            .map_err(|e| PlaneError(e.to_string()))
1126    }
1127}
1128
1129/// The input `run` was admitted with, with its root and field labels — from
1130/// the journal's `RunAdmitted` record, never from anything a tool returned.
1131pub async fn admitted(
1132    dir: &Path,
1133    run: &str,
1134) -> Result<Option<agentplane::core::Tainted<Value>>, PlaneError> {
1135    let id = agentplane::core::RunId::parse(run)
1136        .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
1137    let Some(store) = open_reader(dir)? else {
1138        return Ok(None);
1139    };
1140    let history = store
1141        .read(id, 1)
1142        .await
1143        .map_err(|e| PlaneError(e.to_string()))?;
1144    Ok(history
1145        .iter()
1146        .find_map(agentplane::journal::Record::admitted_input))
1147}
1148
1149/// Strictly re-derives a recorded run and says whether this build
1150/// reproduces it: history is consumed, nothing is
1151/// dispatched. The model driver is agentplane's replay-only provider, which
1152/// reads the request profile back from the history and refuses every call,
1153/// so no credential is needed. A faithful replay of a run that failed is
1154/// `Finding::Verified` too — the recorded ending is what is verified.
1155/// `Ok(None)` when the run is not in the journal.
1156pub async fn replay_strict(
1157    dir: &Path,
1158    model: &ModelConfig,
1159    run: &str,
1160    format: &'static dyn crate::format::Adapter,
1161) -> Result<Option<agentplane::runtime::Verdict>, PlaneError> {
1162    let id = agentplane::core::RunId::parse(run)
1163        .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
1164    let Some(store) = open_reader(dir)? else {
1165        return Ok(None);
1166    };
1167    let history = store
1168        .read(id, 1)
1169        .await
1170        .map_err(|e| PlaneError(e.to_string()))?;
1171    if history.is_empty() {
1172        return Ok(None);
1173    }
1174    // A decision speccoder 0.2.0 recorded does not read in this build:
1175    // named, never shown as a divergence or a decoding error.
1176    if history.iter().any(|r| {
1177        serde_json::to_value(r.kind())
1178            .ok()
1179            .is_some_and(|v| holds_a_pre_054_decision(&v))
1180    }) {
1181        return Err(PlaneError(format!(
1182            "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"
1183        )));
1184    }
1185    // The run's MCP tools as its admitted input lists them: the same
1186    // manifest again. agentplane's replay-only drivers answer for every
1187    // provider and tool server it names — replay dispatches nothing.
1188    let mcp_tools: Vec<crate::mcp::McpTool> = history
1189        .iter()
1190        .find_map(agentplane::journal::Record::admitted_input)
1191        .and_then(|a| serde_json::from_value(a.peek()["settings"]["mcp"].clone()).ok())
1192        .unwrap_or_default();
1193    let hooks = Hooks {
1194        mcp_tools,
1195        format: Some(format),
1196        ..Hooks::default()
1197    };
1198    let (builder, manifest) = builder(store, model, hooks)?;
1199    let runtime = agentplane::runtime::replay_only::wire(builder, &[manifest], &history)
1200        .try_build()
1201        .map_err(|e| PlaneError(format!("building the replay plane: {e}")))?;
1202    runtime
1203        .verify(id)
1204        .await
1205        .map(Some)
1206        .map_err(|e| PlaneError(e.to_string()))
1207}
1208
1209/// What the model said in a turn beside its answer — a claim, never
1210/// evidence: its commentary, where the provider marks it apart (OpenAI
1211/// Responses), then its text when the turn called tools (a turn without
1212/// calls answers with its text). `None` when it said nothing.
1213#[must_use]
1214pub fn said(completion: &agentplane::model::Completion) -> Option<String> {
1215    let commentary = completion.commentary.as_deref().map(str::trim);
1216    let text = (!completion.tool_calls.is_empty()).then(|| completion.text.trim());
1217    let parts: Vec<&str> = [commentary, text]
1218        .into_iter()
1219        .flatten()
1220        .filter(|t| !t.is_empty())
1221        .collect();
1222    (!parts.is_empty()).then(|| parts.join("\n\n"))
1223}
1224
1225/// How the model ended its attempt, in its words: the final answer's
1226/// summary (its text when the answer has no structure), or why it stopped
1227/// (`report_blocked`). `None` for a turn that did neither.
1228#[must_use]
1229pub fn answered(completion: &agentplane::model::Completion) -> Option<String> {
1230    if let Some(blocked) = completion
1231        .tool_calls
1232        .iter()
1233        .find(|c| c.name == attempt::REPORT_BLOCKED)
1234    {
1235        let reason = blocked.arguments["reason"].as_str().unwrap_or_default();
1236        return Some(match blocked.arguments["kind"].as_str() {
1237            Some(kind) => format!("Blocked ({kind}): {reason}"),
1238            None => format!("Blocked: {reason}"),
1239        });
1240    }
1241    if !completion.tool_calls.is_empty() {
1242        return None;
1243    }
1244    let summary = completion
1245        .structured
1246        .as_ref()
1247        .and_then(|s| s["summary"].as_str());
1248    let text = summary.unwrap_or(&completion.text).trim();
1249    (!text.is_empty()).then(|| text.to_owned())
1250}
1251
1252/// One model turn of an attempt, as the person is shown it afterwards: the
1253/// model's summary of its reasoning, its words beside its calls and how it
1254/// ended — its account, a claim. Control characters escaped.
1255#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1256pub struct AccountTurn {
1257    /// The turn (1-based) within the attempt.
1258    pub turn: u32,
1259    /// Its reasoning summary, when one was asked for and given.
1260    pub reasoning: Option<String>,
1261    /// Its words beside its calls.
1262    pub said: Option<String>,
1263    /// Its closing words: the final answer's summary, or why it stopped.
1264    pub answer: Option<String>,
1265}
1266
1267/// The model's account of `run`, turn by turn: every turn that reasoned,
1268/// said something or ended the attempt, in order.
1269///
1270/// # Errors
1271///
1272/// The journal cannot be read, is not here, or does not hold `run`.
1273pub async fn account(dir: &Path, run: &str) -> Result<Vec<AccountTurn>, PlaneError> {
1274    let Ok(id) = agentplane::core::RunId::parse(run) else {
1275        return Err(PlaneError(format!("{run} is not a run id")));
1276    };
1277    // The project's journal, then each lane's (a lane run keeps its own).
1278    // No journal here, or none holding the run: said, never read as a
1279    // silent model.
1280    let lanes = std::fs::read_dir(dir.join("lanes"))
1281        .into_iter()
1282        .flatten()
1283        .flatten()
1284        .map(|e| e.path());
1285    let mut any = false;
1286    let mut history = Vec::new();
1287    for d in std::iter::once(dir.to_path_buf()).chain(lanes) {
1288        let Some(store) = open_reader(&d)? else {
1289            continue;
1290        };
1291        any = true;
1292        history = store
1293            .read(id, 1)
1294            .await
1295            .map_err(|e| PlaneError(e.to_string()))?;
1296        if !history.is_empty() {
1297            break;
1298        }
1299    }
1300    if !any {
1301        return Err(PlaneError(
1302            "no journal of this project's runs is here".to_owned(),
1303        ));
1304    }
1305    if history.is_empty() {
1306        return Err(PlaneError(format!(
1307            "run {run} is not in this project's journal"
1308        )));
1309    }
1310    let text = |t: &str| {
1311        let t = t.trim();
1312        (!t.is_empty()).then(|| crate::approvals::present::escape(t))
1313    };
1314    Ok(agentplane::model::turns(&history)
1315        .into_iter()
1316        .zip(1u32..)
1317        .filter_map(|(t, turn)| {
1318            let c = t.completion?;
1319            // A final answer's text is its structure: its summary stands for
1320            // it, never the raw JSON beside it as words.
1321            let said = if c.tool_calls.is_empty() {
1322                c.commentary.as_deref().and_then(text)
1323            } else {
1324                said(&c).as_deref().and_then(text)
1325            };
1326            let reasoning = c.reasoning.as_deref().and_then(text);
1327            let answer = answered(&c).as_deref().and_then(text);
1328            (said.is_some() || reasoning.is_some() || answer.is_some()).then_some(AccountTurn {
1329                turn,
1330                reasoning,
1331                said,
1332                answer,
1333            })
1334        })
1335        .collect())
1336}
1337
1338/// What `run`'s model turns used, summed from the journal: the usage of an
1339/// attempt the plane stopped before it could report its own. `None` when
1340/// no turn completed.
1341///
1342/// # Errors
1343///
1344/// The journal cannot be read.
1345pub async fn spent(dir: &Path, run: &str) -> Result<Option<Value>, PlaneError> {
1346    let Ok(id) = agentplane::core::RunId::parse(run) else {
1347        return Ok(None);
1348    };
1349    let Some(store) = open_reader(dir)? else {
1350        return Ok(None);
1351    };
1352    let history = store
1353        .read(id, 1)
1354        .await
1355        .map_err(|e| PlaneError(e.to_string()))?;
1356    let used: Vec<agentplane::model::Usage> = agentplane::model::turns(&history)
1357        .into_iter()
1358        .filter_map(|t| t.completion.map(|c| c.usage))
1359        .collect();
1360    Ok((!used.is_empty()).then(|| {
1361        let sum = |f: fn(&agentplane::model::Usage) -> u64| used.iter().map(f).sum::<u64>();
1362        serde_json::json!({
1363            "input_tokens": sum(|u| u.input_tokens),
1364            "output_tokens": sum(|u| u.output_tokens),
1365            "cache_read_tokens": sum(|u| u.cache_read_tokens),
1366            "cache_write_tokens": sum(|u| u.cache_write_tokens),
1367        })
1368    }))
1369}
1370
1371/// The tool servers when no worktree is bound: every call is refused.
1372#[derive(Debug)]
1373struct Unbound;
1374
1375#[async_trait]
1376impl agentplane::tools::ToolClient for Unbound {
1377    async fn call(
1378        &self,
1379        tool: &agentplane::tools::ToolId,
1380        _arguments: &Value,
1381        _provenance: Option<&agentplane::core::Provenance>,
1382    ) -> Result<Value, agentplane::tools::ToolError> {
1383        Err(agentplane::tools::ToolError::Refused {
1384            tool: tool.clone(),
1385            detail: "no worktree is bound to this plane".to_owned(),
1386        })
1387    }
1388
1389    fn destination(&self, _tool: &agentplane::tools::ToolId) -> agentplane::tools::Destination {
1390        agentplane::tools::Destination::Local
1391    }
1392}
1393
1394#[cfg(test)]
1395mod tests {
1396    use super::*;
1397
1398    #[test]
1399    fn the_agentplane_version_named_in_summaries_is_the_pinned_one() {
1400        let workspace = include_str!("../../../../Cargo.toml");
1401        assert!(
1402            workspace.contains(&format!(
1403                "agentplane = {{ version = \"={AGENTPLANE_VERSION}\""
1404            )),
1405            "AGENTPLANE_VERSION is not the workspace's pin"
1406        );
1407    }
1408
1409    #[test]
1410    fn an_attempt_ends_in_its_answer_s_summary_or_why_it_stopped() {
1411        use agentplane::model::{Completion, ToolCall};
1412        let report = serde_json::json!({ "summary": "Added the parser.", "status": "done" });
1413        let done = Completion {
1414            text: report.to_string(),
1415            structured: Some(report),
1416            ..Completion::default()
1417        };
1418        assert_eq!(answered(&done).as_deref(), Some("Added the parser."));
1419        let plain = Completion {
1420            text: " All done. ".to_owned(),
1421            ..Completion::default()
1422        };
1423        assert_eq!(answered(&plain).as_deref(), Some("All done."));
1424        let call = |name: &str, arguments: Value| Completion {
1425            tool_calls: vec![ToolCall {
1426                id: "c".to_owned(),
1427                name: name.to_owned(),
1428                arguments,
1429            }],
1430            ..Completion::default()
1431        };
1432        let blocked = call(
1433            attempt::REPORT_BLOCKED,
1434            serde_json::json!({ "kind": "wrong_test", "evidence": "tests/a.py", "reason": "it asserts the old format" }),
1435        );
1436        assert_eq!(
1437            answered(&blocked).as_deref(),
1438            Some("Blocked (wrong_test): it asserts the old format")
1439        );
1440        assert_eq!(answered(&call("read_file", serde_json::json!({}))), None);
1441    }
1442
1443    #[test]
1444    fn a_provider_refusal_is_read_from_the_run_failure() {
1445        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\")";
1446        let r = provider_refusal(failure).unwrap();
1447        assert_eq!(
1448            r,
1449            "credit_balance_exhausted: You have no credits remaining. Add credits at https://platform.openai.com/settings/organization/billing/."
1450        );
1451        // As agentplane 0.57 records it: the failure in its own words.
1452        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";
1453        assert_eq!(
1454            provider_refusal(own).as_deref(),
1455            Some("insufficient_quota: You exceeded your current quota.")
1456        );
1457        assert_eq!(provider_refusal("Failed(\"timeout\")"), None);
1458    }
1459
1460    #[test]
1461    fn a_failed_run_says_why_in_the_failure_s_own_words() {
1462        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());
1463        assert_eq!(
1464            why_not(&unusable),
1465            "'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"
1466        );
1467        let timeout =
1468            RunStatus::Failed("effect ek:9 failed on attempt 2 of 2: timed out".to_owned());
1469        assert_eq!(why_not(&timeout), "timed out");
1470        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());
1471        assert_eq!(
1472            why_not(&own),
1473            "'openai/gpt-5.4-mini' returned an unusable answer: not JSON"
1474        );
1475        assert_eq!(why_not(&RunStatus::Succeeded), "succeeded");
1476    }
1477
1478    #[test]
1479    fn an_unreadable_journal_is_moved_aside_and_a_locked_one_is_an_error() {
1480        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1481        std::fs::write(dir.path().join(JOURNAL), b"written by another format")
1482            .unwrap_or_else(|e| panic!("{e}"));
1483        let (store, aside) = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1484        let aside = aside.unwrap_or_else(|| panic!("moved aside"));
1485        assert!(aside.exists() && dir.path().join(JOURNAL).exists());
1486        // In this process a second opener shares the handle.
1487        let shared = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1488        assert!(Arc::ptr_eq(&store, &shared.0) && shared.1.is_none());
1489        drop((store, shared));
1490        // Written elsewhere (another process's lock, here a direct open):
1491        // refused at once, and nothing is moved.
1492        let held = RedbStore::open(dir.path().join(JOURNAL)).unwrap_or_else(|e| panic!("{e}"));
1493        let started = std::time::Instant::now();
1494        let second = open_or_replace(dir.path())
1495            .err()
1496            .map(|e| e.0)
1497            .unwrap_or_default();
1498        assert!(second.contains("another speccoder"), "{second}");
1499        assert!(started.elapsed() < std::time::Duration::from_millis(500));
1500        // A writer that holds the file alone cannot be read beside: said,
1501        // never read as empty.
1502        assert!(open_reader(dir.path()).is_err());
1503        drop(held);
1504        assert!(
1505            open_or_replace(dir.path())
1506                .unwrap_or_else(|e| panic!("{}", e.0))
1507                .1
1508                .is_none()
1509        );
1510    }
1511
1512    #[test]
1513    fn a_reader_creates_nothing_and_reads_beside_this_process_s_writer() {
1514        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1515        // No journal: nothing to read, and none is created.
1516        assert!(
1517            open_reader(dir.path())
1518                .unwrap_or_else(|e| panic!("{}", e.0))
1519                .is_none()
1520        );
1521        assert!(
1522            Plane::open_for_reading(dir.path())
1523                .unwrap_or_else(|e| panic!("{}", e.0))
1524                .is_none()
1525        );
1526        assert!(
1527            !dir.path().join(JOURNAL).exists(),
1528            "a read created a journal"
1529        );
1530        // Beside a writer in this process (the app reading while its job
1531        // runs): the reader opens; a second run here is still refused.
1532        let model =
1533            model_config::parse("[model]\nprovider = \"openai\"\nmodel = \"gpt-5.4-mini\"\n")
1534                .unwrap_or_else(|e| panic!("{e}"))
1535                .unwrap_or_else(|| panic!("no model"));
1536        let fake: Arc<dyn ModelProvider> = agentplane::model::fake::FakeProvider::new();
1537        let run = Plane::open(dir.path(), &model, Arc::clone(&fake))
1538            .unwrap_or_else(|e| panic!("{}", e.0));
1539        let reader = open_reader(dir.path())
1540            .unwrap_or_else(|e| panic!("{}", e.0))
1541            .unwrap_or_else(|| panic!("no journal"));
1542        assert!(reader.is_read_only());
1543        assert!(Plane::open(dir.path(), &model, fake).is_err());
1544        drop(run);
1545    }
1546}