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