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