Skip to main content

perspt_agent/runtime/
resume.rs

1//! Session resume (PSP-9 resolved decision 6; PSP-10 Gate AA).
2//!
3//! A single-node session continues its interrupted loop from the newest
4//! durable candidate checkpoint with exactly the remaining budgets. A
5//! multi-node session resumes through graph dispatch: the staging root is
6//! reconstructed from the ledger and every winner reaches the user
7//! workspace only through the global integration gate — never through
8//! single-node direct promotion.
9
10use super::*;
11
12impl Psp9AgentRuntime {
13    /// Continue an interrupted model loop from its newest durable candidate
14    /// checkpoint (PSP-9 resolved decision 6). Nothing live is deserialized:
15    /// the accepted candidate is rebuilt from content-addressed artifacts, a
16    /// *fresh* capability is minted from the grant intersection at the
17    /// current durable authority epoch, and the loop re-enters with exactly
18    /// the checkpoint's remaining budgets. A bumped epoch refuses the resume.
19    pub async fn resume_session(mut self, session_id: String) -> Result<Psp9RunSummary> {
20        let recorder = Psp9Recorder::attach(
21            &session_id,
22            self.database_path.as_deref(),
23            self.shared_store.clone(),
24            self.event_sender.clone(),
25        )?;
26        let (control, seed) = load_candidate_checkpoint(&recorder, &session_id)?;
27
28        // The checkpoint's real node identity (legacy checkpoints predate
29        // the field and keep the historical single-node name).
30        let node_id = if control.node_id.is_empty() {
31            "implement-1".to_string()
32        } else {
33            control.node_id.clone()
34        };
35        self.seed_search_state(&recorder, &session_id)?;
36        let task = control.goal.clone();
37        let running_graph = resumed_running_graph(
38            &recorder,
39            &control.graph_revision,
40            &node_id,
41            control.node_generation,
42        )?;
43        // Gate AA: an interrupted multi-node graph never resumes into
44        // single-node direct promotion. Route state carries over; per-node
45        // loop budgets stay the configured ones — a graph resume
46        // re-dispatches nodes as fresh attempts (the ledger fold still
47        // preserves search usage and no-goods).
48        if running_graph.nodes.len() > 1 {
49            self.adopt_route_state(&control)?;
50            return self
51                .resume_graph_session(
52                    &recorder,
53                    &session_id,
54                    running_graph,
55                    &node_id,
56                    control.node_generation,
57                    seed,
58                )
59                .await;
60        }
61        // Single-node continuation: adopt the checkpoint's exact remaining
62        // budgets — a resumed loop never refills what it already spent.
63        self.adopt_checkpoint(&recorder, &control, &seed)?;
64        self.resume_single_node(
65            &recorder,
66            session_id,
67            &task,
68            &node_id,
69            &control,
70            seed,
71            running_graph,
72        )
73        .await
74    }
75
76    /// The single-node continuation: re-enter the interrupted loop with
77    /// the restored seed, conclude, and promote through the ordinary gate.
78    #[allow(clippy::too_many_arguments)]
79    async fn resume_single_node(
80        &self,
81        recorder: &Psp9Recorder,
82        session_id: String,
83        task: &str,
84        node_id: &str,
85        control: &perspt_sdk::ControlFrame,
86        seed: CandidateSeed,
87        running_graph: WorkGraphRevision,
88    ) -> Result<Psp9RunSummary> {
89        let attempt = match self
90            .attempt_node(
91                recorder,
92                &session_id,
93                task,
94                node_id,
95                control.node_generation,
96                &self.model.clone(),
97                &running_graph,
98                Some(&seed),
99                self.config.rejection_budget,
100            )
101            .await
102        {
103            Ok(attempt) => attempt,
104            Err(error) => return Err(self.fail_session(recorder, error)),
105        };
106        let verdict = match self
107            .conclude_attempt(recorder, &attempt, node_id, task)
108            .await
109        {
110            Ok(verdict) => verdict,
111            Err(error) => return Err(self.fail_session(recorder, error)),
112        };
113        let (final_outcome, status, promoted_paths) = verdict;
114        if let Err(error) =
115            self.finish_node(recorder, &running_graph, node_id, &final_outcome, status)
116        {
117            return Err(self.fail_session(recorder, error));
118        }
119        Ok(Psp9RunSummary {
120            session_id,
121            node_id: node_id.to_string(),
122            outcome: final_outcome,
123            turns_used: attempt.outcome.turns_used,
124            ledger_head: recorder.head(),
125            promoted_paths,
126        })
127    }
128
129    /// Adopt only the checkpoint's route state (graph resume): the model
130    /// and failover chain carry over, per-node loop budgets do not.
131    fn adopt_route_state(&mut self, control: &perspt_sdk::ControlFrame) -> Result<()> {
132        self.model = control.active_model.clone();
133        self.fallback_models = control.remaining_fallback_models.clone();
134        anyhow::ensure!(
135            self.transport.capabilities(&self.model).tool_calling,
136            "checkpoint model route {} no longer supports native tool calling",
137            self.model
138        );
139        Ok(())
140    }
141
142    /// The resumed graph revision: staged nodes are stable, everything
143    /// else resets to pending at its recorded generation.
144    fn resumed_revision(
145        graph: &WorkGraphRevision,
146        staging: &integrate::StagingRoot,
147    ) -> Result<WorkGraphRevision> {
148        let mut nodes = graph.nodes.clone();
149        for node in &mut nodes {
150            node.state = if staging.contributions.contains_key(&node.node_id) {
151                WorkNodeState::Stable
152            } else {
153                WorkNodeState::Pending
154            };
155        }
156        WorkGraphRevision::build(
157            graph.sequence + 1,
158            Some(graph.revision_id.clone()),
159            perspt_sdk::GraphRevisionReason::ExecutionUpdate,
160            nodes,
161            graph.edges.clone(),
162        )
163        .map_err(|error| anyhow::anyhow!(error.to_string()))
164    }
165
166    /// The newest recorded state of the graph: the unique linear
167    /// `graph_revision` chain after `base` in ledger order.
168    /// The checkpoint binds the resumed node to its own revision, but a
169    /// sibling's terminal fold — recorded in a *later* revision — must
170    /// still be seen: judging terminality against the checkpoint-era
171    /// snapshot would silently retry a node erratum 12 requires refusing.
172    fn latest_graph_state(
173        recorder: &Psp9Recorder,
174        session_id: &str,
175        base: &WorkGraphRevision,
176    ) -> Result<WorkGraphRevision> {
177        let mut lineage: std::collections::BTreeSet<String> =
178            [base.revision_id.clone()].into_iter().collect();
179        let mut latest = base.clone();
180        let mut saw_base = false;
181        for row in recorder.store.get_psp9_events(session_id)? {
182            let Ok(perspt_sdk::LedgerEvent::Custom { kind, payload }) =
183                serde_json::from_str(&row.event_json)
184            else {
185                continue;
186            };
187            if kind != "graph_revision" {
188                continue;
189            }
190            let graph: WorkGraphRevision = serde_json::from_value(payload)?;
191            if graph.revision_id == base.revision_id {
192                saw_base = true;
193                continue;
194            }
195            if !saw_base {
196                continue;
197            }
198            let Some(parent) = graph.parent_revision_id.as_deref() else {
199                continue;
200            };
201            if parent == latest.revision_id {
202                anyhow::ensure!(
203                    graph.sequence == latest.sequence + 1,
204                    "graph revision {} skips sequence after {}; refusing ambiguous resume",
205                    graph.revision_id,
206                    latest.revision_id
207                );
208                anyhow::ensure!(
209                    lineage.insert(graph.revision_id.clone()),
210                    "graph revision {} repeats in the resume lineage",
211                    graph.revision_id
212                );
213                latest = graph;
214            } else if lineage.contains(parent) {
215                anyhow::bail!(
216                    "graph history forks from revision {parent}; refusing ambiguous resume"
217                );
218            }
219        }
220        anyhow::ensure!(
221            saw_base,
222            "checkpoint graph revision is absent from the ledger"
223        );
224        Ok(latest)
225    }
226
227    /// Resume an interrupted multi-node graph (Gate AA): rebuild the
228    /// staging root from the durable ledger, mark staged nodes stable,
229    /// reset the rest to pending, and re-enter ordinary dispatch — the
230    /// interrupted node continues from its candidate checkpoint when it
231    /// inherits nothing, and every winner reaches the user workspace only
232    /// through the global integration gate. Sibling terminality and
233    /// staging are judged against the **latest** recorded descendant of
234    /// the checkpoint's revision: a graph with a terminally failed node
235    /// refuses resume — its staged winners were already discarded by the
236    /// sibling-failure rule.
237    async fn resume_graph_session(
238        &self,
239        recorder: &Psp9Recorder,
240        session_id: &str,
241        graph: WorkGraphRevision,
242        resumed_node: &str,
243        resumed_generation: u32,
244        seed: node::CandidateSeed,
245    ) -> Result<Psp9RunSummary> {
246        let latest = Self::latest_graph_state(recorder, session_id, &graph)?;
247        if let Some(failed) = latest.nodes.iter().find(|node| {
248            !matches!(
249                node.state,
250                WorkNodeState::Pending
251                    | WorkNodeState::Ready
252                    | WorkNodeState::Running
253                    | WorkNodeState::Stable
254            )
255        }) {
256            recorder.finish("FAILED_PSP9").ok();
257            anyhow::bail!(
258                "node {} is terminal ({:?}); an interrupted graph with a failed \
259                 node cannot be resumed — its staged winners were discarded",
260                failed.node_id,
261                failed.state
262            );
263        }
264        let staging = integrate::fold_staging(recorder, session_id, &latest)?;
265        let resumed = Self::resumed_revision(&latest, &staging)?;
266        recorder.record_custom("graph_revision", serde_json::to_value(&resumed)?)?;
267        recorder.record_custom(
268            "graph_resumed",
269            serde_json::json!({
270                "resumed_node": resumed_node,
271                "staged": staging.contributions.keys().collect::<Vec<_>>(),
272                "staging_root": staging.digest(),
273                "latest_revision": latest.revision_id,
274            }),
275        )?;
276        let mut resume_seeds = std::collections::BTreeMap::new();
277        // The mid-loop checkpoint continues only a node the latest state
278        // still shows unchanged (same generation, not yet staged): a node
279        // refined or folded after the checkpoint re-runs fresh.
280        let unchanged = latest.node(resumed_node).is_some_and(|node| {
281            node.generation == resumed_generation && node.state == WorkNodeState::Running
282        });
283        if unchanged && !staging.contributions.contains_key(resumed_node) {
284            resume_seeds.insert(resumed_node.to_string(), seed);
285        }
286        let dispatched = match self
287            .run_dispatched_with(recorder, session_id, resumed, staging, resume_seeds)
288            .await
289        {
290            Ok(dispatched) => dispatched,
291            Err(error) => {
292                recorder.finish("FAILED_PSP9").ok();
293                return Err(error);
294            }
295        };
296        recorder.finish(dispatched.status)?;
297        self.emit(perspt_core::AgentEvent::Complete {
298            success: matches!(dispatched.outcome, NodeTerminalOutcome::HardPass),
299            message: format!("PSP-9 outcome: {:?}", dispatched.outcome),
300        });
301        Ok(Psp9RunSummary {
302            session_id: session_id.to_string(),
303            node_id: "graph".into(),
304            outcome: dispatched.outcome,
305            turns_used: dispatched.turns_used,
306            ledger_head: recorder.head(),
307            promoted_paths: dispatched.promoted_paths,
308        })
309    }
310
311    /// Adopt a durable checkpoint's exact remaining budgets and route state.
312    /// A resume never refills what the interrupted loop already spent.
313    fn adopt_checkpoint(
314        &mut self,
315        recorder: &Psp9Recorder,
316        control: &perspt_sdk::ControlFrame,
317        seed: &CandidateSeed,
318    ) -> Result<()> {
319        anyhow::ensure!(
320            control.remaining_turns > 0,
321            "the durable checkpoint has no model-turn budget remaining; resume cannot refill it"
322        );
323        self.config.max_turns = control.remaining_turns;
324        self.config.rejection_budget = control.remaining_rejection_budget;
325        self.model = control.active_model.clone();
326        self.fallback_models = control.remaining_fallback_models.clone();
327        anyhow::ensure!(
328            self.transport.capabilities(&self.model).tool_calling,
329            "checkpoint model route {} no longer supports native tool calling",
330            self.model
331        );
332        recorder.record_custom(
333            "session_resumed",
334            serde_json::json!({
335                "accepted_state_root": control.accepted_state_root,
336                "remaining_turns": self.config.max_turns,
337                "remaining_rejection_budget": self.config.rejection_budget,
338                "seed_files": seed.files.len(),
339                "model": self.model,
340                "remaining_fallback_models": self.fallback_models,
341                "activated_tools": control.activated_tools,
342            }),
343        )?;
344        Ok(())
345    }
346
347    /// Fold the recorded search state: exact no-goods stay suppressed and
348    /// an interrupted forest's consumption is not silently refilled
349    /// (spec :2340-2341, by ledger fold).
350    fn seed_search_state(&self, recorder: &Psp9Recorder, session_id: &str) -> Result<()> {
351        let fold = search::fold::fold_search_ledger(&recorder.store.get_psp9_events(session_id)?)?;
352        let prior = fold.interrupted;
353        *self.search_seed.lock().unwrap() = Some(search::SearchSeed {
354            no_goods: fold.no_goods,
355            prior_usage: prior.as_ref().map(|forest| forest.last_usage.clone()),
356            resumed_from: prior.as_ref().map(|forest| forest.forest_id.clone()),
357            node_id: prior.as_ref().map(|forest| forest.node_id.clone()),
358            generation: prior.as_ref().map(|forest| forest.generation),
359            accepted_root: prior.map(|forest| forest.accepted_root),
360        });
361        Ok(())
362    }
363}