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.53.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
244fn open_store(dir: &Path) -> Result<Arc<RedbStore>, PlaneError> {
245    open_or_replace(dir).map(|(store, _)| store)
246}
247
248/// The journals this process has open, shared: redb locks the file per
249/// process, so a second open here (the web host answering two requests, a
250/// run beside a read) would refuse itself.
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, sharing the handle this process already has. A
255/// journal this build cannot read — another format after an upgrade, or
256/// damage — is moved aside and a fresh one opened: durable outcomes live in
257/// git, the journal is replaceable. A journal another process holds is
258/// waited for briefly (a read holds it for milliseconds), then an error.
259fn open_or_replace(dir: &Path) -> Result<(Arc<RedbStore>, Option<std::path::PathBuf>), PlaneError> {
260    std::fs::create_dir_all(dir).map_err(|e| PlaneError(format!("{}: {e}", dir.display())))?;
261    let path = dir.join(JOURNAL);
262    let mut open = OPEN
263        .lock()
264        .map_err(|_| PlaneError("the journal registry is poisoned".to_owned()))?;
265    open.retain(|(_, w)| w.strong_count() > 0);
266    if let Some(store) = open
267        .iter()
268        .find(|(p, _)| *p == path)
269        .and_then(|(_, w)| w.upgrade())
270    {
271        return Ok((store, None));
272    }
273    let (store, aside) = open_fresh(dir, &path)?;
274    open.push((path, Arc::downgrade(&store)));
275    Ok((store, aside))
276}
277
278fn open_fresh(
279    dir: &Path,
280    path: &Path,
281) -> Result<(Arc<RedbStore>, Option<std::path::PathBuf>), PlaneError> {
282    let mut opened = RedbStore::open(path);
283    for _ in 0..20 {
284        if !matches!(opened, Err(agentplane::core::StoreError::InUse(_))) {
285            break;
286        }
287        std::thread::sleep(std::time::Duration::from_millis(100));
288        opened = RedbStore::open(path);
289    }
290    match opened {
291        Ok(store) => Ok((Arc::new(store), None)),
292        // Another speccoder holds it: its run's record is live. Touch nothing.
293        Err(agentplane::core::StoreError::InUse(e)) => Err(PlaneError(format!(
294            "another speccoder is working on this project (its journal in {} is in use): {e}",
295            dir.display()
296        ))),
297        // Written by a build that cannot be read here, or damaged: moved
298        // aside (progress lives in git) and started afresh.
299        Err(agentplane::core::StoreError::Unreadable(e)) => {
300            let stamp = std::time::SystemTime::now()
301                .duration_since(std::time::UNIX_EPOCH)
302                .map_or(0, |d| d.as_secs());
303            let aside = dir.join(format!("journal.{stamp}.unreadable.redb"));
304            std::fs::rename(path, &aside).map_err(|r| {
305                PlaneError(format!(
306                    "the journal is unreadable ({e}) and cannot be moved aside: {r}"
307                ))
308            })?;
309            RedbStore::open(path)
310                .map(|store| (Arc::new(store), Some(aside)))
311                .map_err(|e| PlaneError(format!("cannot open a fresh journal: {e}")))
312        }
313        // Anything else is not the journal's fault: leave it alone.
314        Err(e) => Err(PlaneError(format!(
315            "cannot open the journal in {}: {e}",
316            dir.display()
317        ))),
318    }
319}
320
321/// What a model provider refused, from a run's failure: the text after
322/// agentplane's "refused the request:" — a refusal, which no retry changes.
323#[must_use]
324pub fn provider_refusal(failure: &str) -> Option<String> {
325    let start = failure.find("refused the request: ")? + "refused the request: ".len();
326    let rest = &failure[start..];
327    let end = rest
328        .find("); ")
329        .or_else(|| rest.rfind(')'))
330        .unwrap_or(rest.len());
331    Some(rest[..end].trim_end_matches(['"', ')']).trim().to_owned())
332}
333
334/// Whether a provider refusal concerns the account (no credits, quota,
335/// a key it does not accept) rather than one request: every later call
336/// would be refused too.
337#[must_use]
338pub fn account_refusal(refusal: &str) -> bool {
339    let r = refusal.to_lowercase();
340    [
341        "credit",
342        "billing",
343        "quota",
344        "insufficient",
345        "api key",
346        "api_key",
347        "authentication",
348        "unauthorized",
349    ]
350    .iter()
351    .any(|w| r.contains(w))
352}
353
354/// The runtime every plane shares: the journal, the agent with speccoder's
355/// skills under its manifest, and the lease. What answers model and tool
356/// calls is registered by the caller — live drivers or replay-only ones.
357fn builder(
358    store: Arc<RedbStore>,
359    model: &ModelConfig,
360    hooks: Hooks,
361) -> Result<(agentplane::runtime::RuntimeBuilder, Manifest), PlaneError> {
362    let manifest = manifest_with(model, &hooks.mcp_tools)?;
363    let format = hooks.format.unwrap_or_else(crate::format::reference);
364    let agent = Agent::new(&manifest)
365        .skill(Propose {
366            cache_ttl: model.cache_ttl,
367            style: model.prompt_style(),
368            format,
369        })
370        .skill(attempt::Attempt {
371            style: model.prompt_style(),
372            cache_ttl: model.cache_ttl,
373            progress: hooks.progress.clone(),
374            format,
375            present: hooks.present.clone(),
376        })
377        .skill(host::Host {
378            style: model.prompt_style(),
379            progress: hooks.progress,
380            format,
381            present: hooks.present,
382        })
383        .skill(acceptance::Acceptance {
384            style: model.prompt_style(),
385            cache_ttl: model.cache_ttl,
386            format,
387        })
388        .skill(converge::Converge {
389            style: model.prompt_style(),
390            cache_ttl: model.cache_ttl,
391            format,
392        })
393        .skill(monitor::Monitor {
394            style: model.prompt_style(),
395            cache_ttl: model.cache_ttl,
396            format,
397        })
398        .skill(near_miss::NearMiss {
399            style: model.prompt_style(),
400            cache_ttl: model.cache_ttl,
401            format,
402        });
403    // One process owns the journal (redb locks it): opening it frees the
404    // leases a dead process left, so the next run resumes at once.
405    let builder = Runtime::builder_on(store).agent(agent);
406    Ok((builder, manifest))
407}
408
409fn build(
410    store: Arc<RedbStore>,
411    model: &ModelConfig,
412    provider: Arc<dyn ModelProvider>,
413    tools: Option<Arc<crate::tools::WorkspaceTools>>,
414    hooks: Hooks,
415) -> Result<Arc<Runtime>, PlaneError> {
416    let mcp = hooks.mcp.clone();
417    let (mut builder, _) = builder(store, model, hooks)?;
418    builder = builder.provider(model.provider.clone(), provider);
419    // The tool servers answer only when a worktree is bound; without one, a
420    // call to a granted tool is refused.
421    let client: Arc<dyn agentplane::tools::ToolClient> = match tools {
422        Some(t) => t,
423        None => Arc::new(Unbound),
424    };
425    for server in ["fs", "proc", "git"] {
426        builder = builder.tool_server(server, Arc::clone(&client));
427    }
428    for (server, client) in &mcp {
429        builder = builder.tool_server(server.as_str(), Arc::clone(client));
430    }
431    builder
432        .try_build()
433        .map_err(|e| PlaneError(format!("building the plane: {e}")))
434}
435
436impl Plane {
437    /// Opens (or creates) the journal at `dir/journal.redb` with `provider`
438    /// registered under the configured provider name.
439    pub fn open(
440        dir: &Path,
441        model: &ModelConfig,
442        provider: Arc<dyn ModelProvider>,
443    ) -> Result<Self, PlaneError> {
444        Self::open_with_tools(dir, model, provider, None)
445    }
446
447    /// [`Plane::open`] with speccoder's tools bound to a worktree.
448    pub fn open_with_tools(
449        dir: &Path,
450        model: &ModelConfig,
451        provider: Arc<dyn ModelProvider>,
452        tools: Option<Arc<crate::tools::WorkspaceTools>>,
453    ) -> Result<Self, PlaneError> {
454        Self::open_with(dir, model, provider, tools, Hooks::default())
455    }
456
457    /// [`Plane::open_with_tools`] with someone to ask inline.
458    pub fn open_with(
459        dir: &Path,
460        model: &ModelConfig,
461        provider: Arc<dyn ModelProvider>,
462        tools: Option<Arc<crate::tools::WorkspaceTools>>,
463        hooks: Hooks,
464    ) -> Result<Self, PlaneError> {
465        let claim = RunClaim::take(dir)?;
466        let (store, replaced_journal) = open_or_replace(dir)?;
467        Ok(Self {
468            runtime: build(store, model, provider, tools, hooks)?,
469            replaced_journal,
470            _run: Some(claim),
471        })
472    }
473
474    /// The journal with no skills and no model: enough to record a person's
475    /// decision on a waiting call (`speccoder task approve`), which the attempt's
476    /// own run picks up when it resumes (agentplane's `Delivery::Buffered`).
477    pub fn open_for_decisions(dir: &Path) -> Result<Self, PlaneError> {
478        let (store, replaced_journal) = open_or_replace(dir)?;
479        let runtime = Runtime::builder_on(store)
480            .try_build()
481            .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
482        Ok(Self {
483            runtime,
484            replaced_journal,
485            _run: None,
486        })
487    }
488
489    /// Settles every waiting call whose deadline has passed at `now`: the
490    /// call is denied as expired, nobody's decision. Only on a plane without
491    /// skills ([`Plane::open_for_decisions`]) — the attempt resumes in its own
492    /// run, with its own worktree, the next time it runs.
493    pub async fn expire_due(&self, now: agentplane::core::Timestamp) -> Result<(), PlaneError> {
494        self.runtime
495            .sweep(now, std::time::Duration::from_secs(7 * 86_400))
496            .await
497            .map(|_| ())
498            .map_err(|e| PlaneError(e.to_string()))
499    }
500
501    /// Withdraws a waiting call whose attempt was set aside: its run is
502    /// cancelled on the record (who, why) and the call leaves the queue.
503    pub async fn withdraw(
504        &self,
505        task: &agentplane::core::Task,
506        by: &agentplane::core::Operator,
507        reason: &str,
508    ) -> Result<(), PlaneError> {
509        let err = |e: String| PlaneError(e);
510        let _ = self
511            .runtime
512            .request_cancel(task.run, by, reason)
513            .await
514            .map_err(|e| err(e.to_string()))?;
515        if let Some(tasks) = self.runtime.tasks() {
516            tasks
517                .withdraw_run(task.run, &[task.id])
518                .await
519                .map_err(|e| err(e.to_string()))?;
520        }
521        Ok(())
522    }
523
524    /// The person's task a suspended run waits on, if it waits on one.
525    pub async fn waiting_on(
526        &self,
527        outcome: &RunOutcome,
528    ) -> Result<Option<agentplane::core::Task>, PlaneError> {
529        let RunStatus::Suspended(reason) = &outcome.status else {
530            return Ok(None);
531        };
532        let Some(id) = reason.awaited_task() else {
533            return Ok(None);
534        };
535        self.task(id).await
536    }
537
538    /// One task row.
539    pub async fn task(
540        &self,
541        id: agentplane::core::TaskId,
542    ) -> Result<Option<agentplane::core::Task>, PlaneError> {
543        let Some(tasks) = self.runtime.tasks() else {
544            return Ok(None);
545        };
546        tasks.task(id).await.map_err(|e| PlaneError(e.to_string()))
547    }
548
549    /// Every call of `server/tool` the journal records as dispatched, in
550    /// every run, with the time its run last wrote and whether it was
551    /// decided on an object (a person's decision or grant) — and what could
552    /// not be read: a run unreadable is reported, never skipped silently.
553    pub async fn tool_calls(&self, server: &str, tool: &str) -> Result<RecordedCalls, PlaneError> {
554        let journal = self.runtime.journal();
555        let mut out = RecordedCalls::default();
556        let mut after: Option<(u64, agentplane::core::RunId)> = None;
557        loop {
558            let page = journal
559                .recent_runs(after, 500)
560                .await
561                .map_err(|e| PlaneError(format!("listing the journal's runs: {e}")))?;
562            let Some(last) = page.last().cloned() else {
563                break;
564            };
565            for (run, at) in &page {
566                let records = match journal.read(*run, 0).await {
567                    Ok(r) => r,
568                    Err(e) => {
569                        out.unreadable.push(format!("run {run}: {e}"));
570                        continue;
571                    }
572                };
573                let mut seen = std::collections::BTreeSet::new();
574                for record in records {
575                    let agentplane::RecordKind::EffectStarted { descriptor, .. } = record.kind()
576                    else {
577                        continue;
578                    };
579                    let args = &descriptor.args;
580                    if descriptor.kind != "tool.call"
581                        || args["server"] != server
582                        || args["tool"] != tool
583                    {
584                        continue;
585                    }
586                    // A retried effect is one call.
587                    let key = record
588                        .body
589                        .effect_key
590                        .as_ref()
591                        .map_or_else(|| record.body.seq.to_string(), |k| format!("{k:?}"));
592                    if seen.insert(key) {
593                        out.calls.push(RecordedCall {
594                            arguments: args["arguments"].clone(),
595                            at: *at,
596                        });
597                    }
598                }
599            }
600            after = Some((last.1, last.0));
601        }
602        Ok(out)
603    }
604
605    /// Every task waiting for a person, oldest first.
606    pub async fn open_tasks(&self) -> Result<Vec<agentplane::core::Task>, PlaneError> {
607        let Some(tasks) = self.runtime.tasks() else {
608            return Ok(Vec::new());
609        };
610        let mut open = tasks
611            .queue(&[], 1000)
612            .await
613            .map_err(|e| PlaneError(e.to_string()))?;
614        open.sort_by_key(|t| t.created_at);
615        Ok(open)
616    }
617
618    /// Records a person's decision on `task`: the run continues at once
619    /// when this plane holds its skill, else when it next resumes.
620    pub async fn decide(
621        &self,
622        task: agentplane::core::TaskId,
623        decision: &agentplane::core::Decision,
624    ) -> Result<agentplane::core::Delivery, PlaneError> {
625        self.runtime
626            .decide_task(task, decision, &[])
627            .await
628            .map_err(|e| PlaneError(e.to_string()))
629    }
630
631    /// Asks `approver` about each call the run waits on, records the
632    /// decision and lets the run continue, until it no longer waits on a
633    /// person. Returns the run as it rests, the call still waiting, if
634    /// the person answered "later", nobody is there or the call waits in
635    /// its run's batch, and the calls answered Always Allow (approved once
636    /// here; their argv is added to the allow list by
637    /// `actions::perform(AllowAlways)`, which the caller runs).
638    pub async fn decide_inline(
639        &self,
640        mut outcome: RunOutcome,
641        approver: Option<&dyn crate::approvals::Approver>,
642        context: &crate::approvals::PresentContext,
643    ) -> Result<Inline, PlaneError> {
644        let mut always = Vec::new();
645        while let Some(task) = self.waiting_on(&outcome).await? {
646            let Some((decision, answer)) =
647                approver.and_then(|a| crate::approvals::tasks::ask(a, &task, context))
648            else {
649                return Ok(Inline {
650                    outcome,
651                    waiting: Some(task),
652                    always,
653                });
654            };
655            self.decide(task.id, &decision).await?;
656            if answer.always && decision.approved {
657                let via = answer
658                    .by
659                    .as_ref()
660                    .and_then(|b| speccoder_core::actions::Via::read(&b.via))
661                    .map_or(speccoder_core::actions::Via::Terminal, |(v, _)| v);
662                crate::approvals::answered_always(&task.id.to_hex());
663                always.push(Always {
664                    task: crate::approvals::tasks::request_of(&task.justification.proposed_action)
665                        .map(|r| r.task)
666                        .unwrap_or_default(),
667                    call: task.id.to_hex(),
668                    via,
669                });
670            }
671            match self.outcome(outcome.run_id).await? {
672                Some(o) => outcome = o,
673                None => break,
674            }
675        }
676        Ok(Inline {
677            outcome,
678            waiting: None,
679            always,
680        })
681    }
682
683    /// The run as it rests now, its output included: the journal records
684    /// what happened, not a step's value, so the value is re-derived by
685    /// strict replay, which performs nothing.
686    pub async fn outcome(
687        &self,
688        run: agentplane::core::RunId,
689    ) -> Result<Option<RunOutcome>, PlaneError> {
690        if self
691            .runtime
692            .recorded_outcome(run)
693            .await
694            .map_err(|e| PlaneError(e.to_string()))?
695            .is_none()
696        {
697            return Ok(None);
698        }
699        self.runtime
700            .replay(run, agentplane::runtime::Mode::Strict)
701            .await
702            .map(Some)
703            .map_err(|e| PlaneError(e.to_string()))
704    }
705
706    /// A plane over `store` instead of the journal file: a crash point's
707    /// prefix of a recorded run, resumed in a test.
708    #[doc(hidden)]
709    pub fn on_store(
710        store: Arc<RedbStore>,
711        model: &ModelConfig,
712        provider: Arc<dyn ModelProvider>,
713        tools: Option<Arc<crate::tools::WorkspaceTools>>,
714    ) -> Result<Self, PlaneError> {
715        Ok(Self {
716            runtime: build(store, model, provider, tools, Hooks::default())?,
717            replaced_journal: None,
718            _run: None,
719        })
720    }
721
722    /// Resumes `run` as a restarted process would, agentplane's own answer
723    /// unchanged (for `testkit::assert_resumed_honestly`).
724    #[doc(hidden)]
725    pub async fn resume(
726        &self,
727        run: agentplane::core::RunId,
728    ) -> Result<RunOutcome, agentplane::RuntimeError> {
729        self.runtime
730            .replay(run, agentplane::runtime::Mode::Resume)
731            .await
732    }
733
734    /// Runs `speccoder.feature.converge`, correlated to the feature.
735    pub async fn converge(
736        &self,
737        feature: &str,
738        input: Tainted<Value>,
739    ) -> Result<RunOutcome, PlaneError> {
740        self.runtime
741            .start(converge::CAPABILITY, input)
742            .terms(RunTerms::default().correlated(
743                "feature",
744                &[CorrelationKey::new("feature", feature.to_owned())],
745            ))
746            .run()
747            .await
748            .map_err(|e| PlaneError(e.to_string()))
749    }
750
751    /// Runs `speccoder.skill.run` for an installed skill.
752    pub async fn skill(&self, name: &str, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
753        self.runtime
754            .start(host::CAPABILITY, input)
755            .terms(
756                RunTerms::default()
757                    .correlated("skill", &[CorrelationKey::new("skill", name.to_owned())]),
758            )
759            .run()
760            .await
761            .map_err(|e| PlaneError(e.to_string()))
762    }
763
764    /// Runs `speccoder.task.monitor`, correlated to the task, admitted once
765    /// per input: judging the same change again reads the recorded answer
766    /// back instead of asking again.
767    pub async fn monitor(
768        &self,
769        feature: &str,
770        task: &str,
771        input: Tainted<Value>,
772    ) -> Result<RunOutcome, PlaneError> {
773        self.judge_once(monitor::CAPABILITY, feature, task, input)
774            .await
775    }
776
777    /// Runs `speccoder.task.near_miss`, admitted once per input like
778    /// [`monitor`](Self::monitor): a held attempt judged again after a
779    /// person's ruling meets the same variants, not new ones.
780    pub async fn near_miss(
781        &self,
782        feature: &str,
783        task: &str,
784        input: Tainted<Value>,
785    ) -> Result<RunOutcome, PlaneError> {
786        self.judge_once(near_miss::CAPABILITY, feature, task, input)
787            .await
788    }
789
790    /// A judge-side call admitted once per task and input: the first time it
791    /// runs; afterwards the recorded run is re-derived by strict replay,
792    /// which performs nothing (an interrupted one resumes).
793    async fn judge_once(
794        &self,
795        capability: &str,
796        feature: &str,
797        task: &str,
798        input: Tainted<Value>,
799    ) -> Result<RunOutcome, PlaneError> {
800        use agentplane::runtime::{Admission, Mode};
801        use sha2::Digest as _;
802        let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
803        let digest = sha2::Sha256::digest(
804            format!("{capability}\n{feature}/{task}\n{}", input.peek()).as_bytes(),
805        );
806        let key: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect();
807        let key = format!("judge-{key}");
808        let key = key.as_str();
809        let terms = || {
810            RunTerms::default().correlated(
811                "task",
812                &[CorrelationKey::new("task", format!("{feature}/{task}"))],
813            )
814        };
815        let admitted = self
816            .runtime
817            .start(capability, input.clone())
818            .terms(terms())
819            .once(key)
820            .run()
821            .await
822            .map_err(err)?;
823        match admitted {
824            Admission::Fresh(o) => Ok(o),
825            // A call that failed (a provider error) is asked again, unkeyed:
826            // only an answer is worth reading back.
827            Admission::Replayed(o) if !matches!(o.status, RunStatus::Succeeded) => self
828                .runtime
829                .start(capability, input)
830                .terms(terms())
831                .run()
832                .await
833                .map_err(err),
834            Admission::Replayed(o) => self
835                .runtime
836                .replay(o.run_id, Mode::Strict)
837                .await
838                .map_err(err),
839            Admission::InFlight(run) => self.runtime.replay(run, Mode::Resume).await.map_err(err),
840        }
841    }
842
843    /// Runs `speccoder.task.acceptance`: acceptance tests derived from the spec.
844    pub async fn acceptance(
845        &self,
846        feature: &str,
847        task: &str,
848        input: Tainted<Value>,
849    ) -> Result<RunOutcome, PlaneError> {
850        self.runtime
851            .start(acceptance::CAPABILITY, input)
852            .terms(RunTerms::default().correlated(
853                "task",
854                &[CorrelationKey::new("task", format!("{feature}/{task}"))],
855            ))
856            .run()
857            .await
858            .map_err(|e| PlaneError(e.to_string()))
859    }
860
861    /// Runs one `speccoder.task.attempt` for a bundle, correlated to the task.
862    pub async fn attempt(
863        &self,
864        feature: &str,
865        task: &str,
866        input: Tainted<Value>,
867    ) -> Result<RunOutcome, PlaneError> {
868        self.runtime
869            .start(attempt::CAPABILITY, input)
870            .terms(RunTerms::default().correlated(
871                "task",
872                &[CorrelationKey::new("task", format!("{feature}/{task}"))],
873            ))
874            .run()
875            .await
876            .map_err(|e| PlaneError(e.to_string()))
877    }
878
879    /// [`attempt`](Self::attempt) admitted under `key`: the first time it
880    /// runs; after a crash the same key finds the interrupted run — a run
881    /// that had rested is re-derived by strict replay, which performs
882    /// nothing, and one still in flight is resumed from its journal (a dead
883    /// process's lease is freed when the store opens), so finished model and
884    /// tool calls are read back, not repeated. The `bool` says whether this
885    /// was such a resume: what the run's notice may claim.
886    pub async fn attempt_keyed(
887        &self,
888        feature: &str,
889        task: &str,
890        input: Tainted<Value>,
891        key: &str,
892    ) -> Result<(RunOutcome, bool), PlaneError> {
893        use agentplane::runtime::{Admission, Mode};
894        let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
895        let admitted = self
896            .runtime
897            .start(attempt::CAPABILITY, input)
898            .terms(RunTerms::default().correlated(
899                "task",
900                &[CorrelationKey::new("task", format!("{feature}/{task}"))],
901            ))
902            .once(key)
903            .run()
904            .await
905            .map_err(err)?;
906        match admitted {
907            Admission::Fresh(o) => Ok((o, false)),
908            // A run waiting for a person resumes: a decision recorded since
909            // is delivered and the attempt continues; one still undecided
910            // waits again. Any other rested run is re-derived, performing
911            // nothing.
912            Admission::Replayed(o) if matches!(o.status, RunStatus::Suspended(_)) => self
913                .runtime
914                .replay(o.run_id, Mode::Resume)
915                .await
916                .map(|o| (o, true))
917                .map_err(err),
918            Admission::Replayed(o) => self
919                .runtime
920                .replay(o.run_id, Mode::Strict)
921                .await
922                .map(|o| (o, true))
923                .map_err(err),
924            Admission::InFlight(run) => self
925                .runtime
926                .replay(run, Mode::Resume)
927                .await
928                .map(|o| (o, true))
929                .map_err(err),
930        }
931    }
932
933    /// Runs `speccoder.task.propose` for a bundle, correlated to the task.
934    pub async fn propose(
935        &self,
936        feature: &str,
937        task: &str,
938        input: Tainted<Value>,
939    ) -> Result<RunOutcome, PlaneError> {
940        self.runtime
941            .start(CAPABILITY, input)
942            .terms(RunTerms::default().correlated(
943                "task",
944                &[CorrelationKey::new("task", format!("{feature}/{task}"))],
945            ))
946            .run()
947            .await
948            .map_err(|e| PlaneError(e.to_string()))
949    }
950}
951
952/// The input `run` was admitted with, with its root and field labels — from
953/// the journal's `RunAdmitted` record, never from anything a tool returned.
954pub async fn admitted(
955    dir: &Path,
956    run: &str,
957) -> Result<Option<agentplane::core::Tainted<Value>>, PlaneError> {
958    let id = agentplane::core::RunId::parse(run)
959        .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
960    let store = open_store(dir)?;
961    let history = store
962        .read(id, 1)
963        .await
964        .map_err(|e| PlaneError(e.to_string()))?;
965    Ok(history
966        .iter()
967        .find_map(agentplane::journal::Record::admitted_input))
968}
969
970/// Strictly re-derives a recorded run and says whether this build
971/// reproduces it: history is consumed, nothing is
972/// dispatched. The model driver is agentplane's replay-only provider, which
973/// reads the request profile back from the history and refuses every call,
974/// so no credential is needed. A faithful replay of a run that failed is
975/// `Finding::Verified` too — the recorded ending is what is verified.
976/// `Ok(None)` when the run is not in the journal.
977pub async fn replay_strict(
978    dir: &Path,
979    model: &ModelConfig,
980    run: &str,
981    format: &'static dyn crate::format::Adapter,
982) -> Result<Option<agentplane::runtime::Verdict>, PlaneError> {
983    let id = agentplane::core::RunId::parse(run)
984        .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
985    let store = open_store(dir)?;
986    let history = store
987        .read(id, 1)
988        .await
989        .map_err(|e| PlaneError(e.to_string()))?;
990    if history.is_empty() {
991        return Ok(None);
992    }
993    // The run's MCP tools as its admitted input lists them: the same
994    // manifest again. agentplane's replay-only drivers answer for every
995    // provider and tool server it names — replay dispatches nothing.
996    let mcp_tools: Vec<crate::mcp::McpTool> = history
997        .iter()
998        .find_map(agentplane::journal::Record::admitted_input)
999        .and_then(|a| serde_json::from_value(a.peek()["settings"]["mcp"].clone()).ok())
1000        .unwrap_or_default();
1001    let hooks = Hooks {
1002        mcp_tools,
1003        format: Some(format),
1004        ..Hooks::default()
1005    };
1006    let (builder, manifest) = builder(store, model, hooks)?;
1007    let runtime = agentplane::runtime::replay_only::wire(builder, &[manifest], &history)
1008        .try_build()
1009        .map_err(|e| PlaneError(format!("building the replay plane: {e}")))?;
1010    runtime
1011        .verify(id)
1012        .await
1013        .map(Some)
1014        .map_err(|e| PlaneError(e.to_string()))
1015}
1016
1017/// One model turn of an attempt, as the person is shown it afterwards: the
1018/// model's summary of its reasoning and its words beside its calls — its
1019/// account, a claim. Control characters escaped.
1020#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1021pub struct AccountTurn {
1022    /// The turn (1-based) within the attempt.
1023    pub turn: u32,
1024    /// Its reasoning summary, when one was asked for and given.
1025    pub reasoning: Option<String>,
1026    /// Its words beside its calls (a turn without calls is the final
1027    /// answer, reported as such).
1028    pub said: Option<String>,
1029}
1030
1031/// The model's account of `run`, turn by turn: every turn that reasoned or
1032/// said something, in order.
1033///
1034/// # Errors
1035///
1036/// The journal cannot be read.
1037pub async fn account(dir: &Path, run: &str) -> Result<Vec<AccountTurn>, PlaneError> {
1038    let Ok(id) = agentplane::core::RunId::parse(run) else {
1039        return Ok(Vec::new());
1040    };
1041    let store = open_store(dir)?;
1042    let history = store
1043        .read(id, 1)
1044        .await
1045        .map_err(|e| PlaneError(e.to_string()))?;
1046    let text = |t: &str| {
1047        let t = t.trim();
1048        (!t.is_empty()).then(|| crate::approvals::present::escape(t))
1049    };
1050    Ok(agentplane::model::turns(&history)
1051        .into_iter()
1052        .zip(1u32..)
1053        .filter_map(|(t, turn)| {
1054            let c = t.completion?;
1055            let said = (!c.tool_calls.is_empty()).then(|| text(&c.text)).flatten();
1056            let reasoning = c.reasoning.as_deref().and_then(text);
1057            (said.is_some() || reasoning.is_some()).then_some(AccountTurn {
1058                turn,
1059                reasoning,
1060                said,
1061            })
1062        })
1063        .collect())
1064}
1065
1066/// The tool servers when no worktree is bound: every call is refused.
1067#[derive(Debug)]
1068struct Unbound;
1069
1070#[async_trait]
1071impl agentplane::tools::ToolClient for Unbound {
1072    async fn call(
1073        &self,
1074        tool: &agentplane::tools::ToolId,
1075        _arguments: &Value,
1076        _provenance: Option<&agentplane::core::Provenance>,
1077    ) -> Result<Value, agentplane::tools::ToolError> {
1078        Err(agentplane::tools::ToolError::Refused {
1079            tool: tool.clone(),
1080            detail: "no worktree is bound to this plane".to_owned(),
1081        })
1082    }
1083
1084    fn destination(&self, _tool: &agentplane::tools::ToolId) -> agentplane::tools::Destination {
1085        agentplane::tools::Destination::Local
1086    }
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091    use super::*;
1092
1093    #[test]
1094    fn the_agentplane_version_named_in_summaries_is_the_pinned_one() {
1095        let workspace = include_str!("../../../../Cargo.toml");
1096        assert!(
1097            workspace.contains(&format!(
1098                "agentplane = {{ version = \"={AGENTPLANE_VERSION}\""
1099            )),
1100            "AGENTPLANE_VERSION is not the workspace's pin"
1101        );
1102    }
1103
1104    #[test]
1105    fn a_provider_refusal_is_read_from_the_run_failure() {
1106        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\")";
1107        let r = provider_refusal(failure).unwrap();
1108        assert_eq!(
1109            r,
1110            "credit_balance_exhausted: You have no credits remaining. Add credits at https://platform.openai.com/settings/organization/billing/."
1111        );
1112        assert!(account_refusal(&r));
1113        assert!(!account_refusal(
1114            "the continuation was not an Anthropic assistant-content array"
1115        ));
1116        assert_eq!(provider_refusal("Failed(\"timeout\")"), None);
1117    }
1118
1119    #[test]
1120    fn an_unreadable_journal_is_moved_aside_and_a_locked_one_is_an_error() {
1121        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1122        std::fs::write(dir.path().join(JOURNAL), b"written by another format")
1123            .unwrap_or_else(|e| panic!("{e}"));
1124        let (store, aside) = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1125        let aside = aside.unwrap_or_else(|| panic!("moved aside"));
1126        assert!(aside.exists() && dir.path().join(JOURNAL).exists());
1127        // In this process a second opener shares the handle.
1128        let shared = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1129        assert!(Arc::ptr_eq(&store, &shared.0) && shared.1.is_none());
1130        drop((store, shared));
1131        // Held elsewhere (another process's lock, here a direct open): a
1132        // brief wait, then refused, and nothing is moved.
1133        let held = RedbStore::open(dir.path().join(JOURNAL)).unwrap_or_else(|e| panic!("{e}"));
1134        let second = open_or_replace(dir.path())
1135            .err()
1136            .map(|e| e.0)
1137            .unwrap_or_default();
1138        assert!(second.contains("another speccoder"), "{second}");
1139        drop(held);
1140        assert!(
1141            open_or_replace(dir.path())
1142                .unwrap_or_else(|e| panic!("{}", e.0))
1143                .1
1144                .is_none()
1145        );
1146    }
1147}