1use super::*;
11
12impl Psp9AgentRuntime {
13 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 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 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 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 #[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 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 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 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 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 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 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 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}