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