Skip to main content

speccoder_runtime/substrate/
mod.rs

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