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