返回 CodeWhale
runtime.rs
根目录 / crates / tui / src / work_graph / runtime.rs
1 //! Active-session Work Graph authority and legacy tool adapters.
2
3 use std::path::Path;
4 use std::sync::{Arc, Mutex, MutexGuard};
5
6 use crate::fleet::ledger::FleetLedger;
7 use crate::tools::plan::{PlanSnapshot, PlanState, SharedPlanState, StepStatus};
8 use crate::tools::todo::{SharedTodoList, TodoList, TodoListSnapshot, TodoStatus};
9 use codewhale_lane::LaneRegistry;
10
11 use super::{
12 BindingId, ChangeCtx, CompatPlanMetadata, CompatProjectionState, CompatTodoBinding, EdgeKind,
13 IdempotencyKey, NodeKind, NodeState, OperationBinding, OperationIntent, OperationObservation,
14 OperationOwnerSnapshot, Provenance, ReasoningEffortTier, WorkActivityEvent, WorkEdge,
15 WorkEdgeId, WorkGraph, WorkGraphChange, WorkGraphSnapshot, WorkNode, WorkNodeId, WorkNodePatch,
16 external_identity_is_well_formed, fleet_task_owner_snapshot, import_legacy,
17 lane_owner_snapshot, project_plan, project_todos, validate,
18 };
19
20 pub(crate) const ACTIVE_OPERATION_SUMMARY_START: &str =
21 "<!-- codewhale:active-work-operations:start -->";
22 pub(crate) const ACTIVE_OPERATION_SUMMARY_END: &str =
23 "<!-- codewhale:active-work-operations:end -->";
24
25 #[derive(Debug, Clone, PartialEq)]
26 pub struct WorkRuntimeSnapshot {
27 pub graph: WorkGraphSnapshot,
28 pub todos: TodoListSnapshot,
29 pub plan: PlanSnapshot,
30 }
31
32 #[derive(Debug, Default)]
33 struct ActiveGraph {
34 session_id: Option<String>,
35 snapshot: Option<WorkGraphSnapshot>,
36 pending_publish: bool,
37 }
38
39 /// One active session graph plus the read-only legacy views it publishes.
40 pub struct WorkRuntime {
41 todos: SharedTodoList,
42 plan: SharedPlanState,
43 graph: Mutex<ActiveGraph>,
44 }
45
46 pub type SharedWorkRuntime = Arc<WorkRuntime>;
47
48 impl std::fmt::Debug for WorkRuntime {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 let graph = lock_unpoisoned(&self.graph);
51 f.debug_struct("WorkRuntime")
52 .field("session_id", &graph.session_id)
53 .field("has_graph", &graph.snapshot.is_some())
54 .field("pending_publish", &graph.pending_publish)
55 .finish()
56 }
57 }
58
59 #[must_use]
60 pub fn new_shared_work_runtime(todos: SharedTodoList, plan: SharedPlanState) -> SharedWorkRuntime {
61 Arc::new(WorkRuntime {
62 todos,
63 plan,
64 graph: Mutex::new(ActiveGraph::default()),
65 })
66 }
67
68 impl WorkRuntime {
69 #[must_use]
70 pub fn matches_todos(&self, todos: &SharedTodoList) -> bool {
71 Arc::ptr_eq(&self.todos, todos)
72 }
73
74 #[must_use]
75 pub fn matches_plan(&self, plan: &SharedPlanState) -> bool {
76 Arc::ptr_eq(&self.plan, plan)
77 }
78
79 #[must_use]
80 pub fn has_operation_binding(&self, session_id: Option<&str>, external: &str) -> bool {
81 let active = lock_unpoisoned(&self.graph);
82 if let (Some(expected), Some(actual)) = (session_id, active.session_id.as_deref())
83 && expected != actual
84 {
85 return false;
86 }
87 active.snapshot.as_ref().is_some_and(|graph| {
88 graph.nodes.iter().any(|node| {
89 node.binding
90 .as_ref()
91 .is_some_and(|binding| binding.external == external)
92 })
93 })
94 }
95
96 /// Durable owner bindings that still require restore-time confirmation.
97 /// Terminal operations are excluded because their saved owner receipt is
98 /// already sufficient and must not be reopened by a missing live handle.
99 #[must_use]
100 pub fn reconcilable_durable_bindings(&self, session_id: Option<&str>) -> Vec<String> {
101 let active = lock_unpoisoned(&self.graph);
102 if let (Some(expected), Some(actual)) = (session_id, active.session_id.as_deref())
103 && expected != actual
104 {
105 return Vec::new();
106 }
107 active
108 .snapshot
109 .as_ref()
110 .into_iter()
111 .flat_map(|graph| graph.nodes.iter())
112 .filter(|node| node.state.is_live() || node.state == NodeState::Stale)
113 .filter_map(|node| node.binding.as_ref())
114 .filter(|binding| binding.durable)
115 .map(|binding| binding.external.clone())
116 .collect()
117 }
118
119 /// Register an Operation before its owner starts work. The operation is
120 /// first added inert, connected to an Objective/PlanStep, and only then
121 /// advanced to `Initializing`, so every reducer intermediate satisfies
122 /// the no-orphan invariant.
123 pub fn register_operation(
124 &self,
125 session_id: &str,
126 intent: OperationIntent,
127 ) -> Result<WorkNodeId, String> {
128 if !external_identity_is_well_formed(&intent.external) {
129 return Err(format!(
130 "invalid lifecycle binding external {:?}",
131 intent.external
132 ));
133 }
134 let todos = retry_lock(&self.todos, 100)
135 .ok_or_else(|| "To-do state is busy; operation was not registered".to_string())?;
136 let plan = retry_lock(&self.plan, 100)
137 .ok_or_else(|| "Plan state is busy; operation was not registered".to_string())?;
138 let mut active = lock_unpoisoned(&self.graph);
139 let base = graph_for_update(&mut active, session_id, &plan.snapshot(), &todos.snapshot())?;
140 if let Some(existing) = base.nodes.iter().find(|node| {
141 node.binding
142 .as_ref()
143 .is_some_and(|binding| binding.external == intent.external)
144 }) {
145 let binding = existing.binding.as_ref().expect("binding matched above");
146 if binding.durable != intent.durable {
147 return Err(format!(
148 "lifecycle binding {} changed durability",
149 intent.external
150 ));
151 }
152 return Ok(existing.id.clone());
153 }
154
155 let mut graph = WorkGraph::from_snapshot(base);
156 let parent = operation_parent(&mut graph, session_id, &intent.source)?;
157 let node_id = WorkNodeId::derive(session_id, &format!("operation:{}", intent.external));
158 let now = now_ms();
159 apply_change(
160 &mut graph,
161 session_id,
162 &intent.source,
163 WorkGraphChange::AddNode {
164 node: WorkNode {
165 id: node_id.clone(),
166 kind: NodeKind::Operation,
167 title: bounded_operation_title(&intent.title),
168 state: NodeState::Ready,
169 acceptance: intent.acceptance,
170 binding: Some(OperationBinding {
171 external: intent.external,
172 durable: intent.durable,
173 last_observation: None,
174 }),
175 evidence: None,
176 provenance: Provenance::ToolUpdate {
177 tool: intent.source.clone(),
178 call_id: intent.call_id,
179 },
180 created_at: now,
181 updated_at: now,
182 },
183 },
184 )?;
185 ensure_contains(&mut graph, session_id, &intent.source, &parent, &node_id)?;
186 let title = graph
187 .snapshot()
188 .node(&node_id)
189 .map(|node| node.title.clone())
190 .ok_or_else(|| format!("operation {node_id} disappeared during registration"))?;
191 patch_existing_node(
192 &mut graph,
193 session_id,
194 &intent.source,
195 &node_id,
196 title,
197 NodeState::Initializing,
198 )?;
199 let next = graph.into_snapshot();
200 validate_combined(&next, &project_plan(&next), &project_todos(&next))?;
201 active.snapshot = Some(next);
202 active.pending_publish = true;
203 Ok(node_id)
204 }
205
206 /// Retain a crossed approval boundary as provenance attached to an
207 /// operation. The completed Approval node is historical evidence only: it
208 /// does not grant capabilities or change the owner's runtime authority.
209 pub fn record_operation_approval(
210 &self,
211 session_id: &str,
212 external: &str,
213 reference: &str,
214 source: &str,
215 call_id: &str,
216 ) -> Result<(), String> {
217 let todos = retry_lock(&self.todos, 100)
218 .ok_or_else(|| "To-do state is busy; approval was not recorded".to_string())?;
219 let plan = retry_lock(&self.plan, 100)
220 .ok_or_else(|| "Plan state is busy; approval was not recorded".to_string())?;
221 let mut active = lock_unpoisoned(&self.graph);
222 let base = graph_for_update(&mut active, session_id, &plan.snapshot(), &todos.snapshot())?;
223 let operation = base
224 .nodes
225 .iter()
226 .find(|node| {
227 node.kind == NodeKind::Operation
228 && node
229 .binding
230 .as_ref()
231 .is_some_and(|binding| binding.external == external)
232 })
233 .map(|node| node.id.clone())
234 .ok_or_else(|| format!("operation binding {external} is not registered"))?;
235 let approval = WorkNodeId::derive(
236 session_id,
237 &format!("approval:operation:{external}:{reference}"),
238 );
239 let mut graph = WorkGraph::from_snapshot(base);
240 if graph.snapshot().node(&approval).is_none() {
241 let now = now_ms();
242 apply_change(
243 &mut graph,
244 session_id,
245 source,
246 WorkGraphChange::AddNode {
247 node: WorkNode {
248 id: approval.clone(),
249 kind: NodeKind::Approval,
250 title: bounded_operation_title(&format!(
251 "verification approved: {reference}"
252 )),
253 state: NodeState::Completed,
254 acceptance: Vec::new(),
255 binding: None,
256 evidence: None,
257 provenance: Provenance::ToolUpdate {
258 tool: source.to_string(),
259 call_id: call_id.to_string(),
260 },
261 created_at: now,
262 updated_at: now,
263 },
264 },
265 )?;
266 }
267 let edge = WorkEdgeId::derive(
268 session_id,
269 &format!(
270 "requires-approval:{}:{}",
271 operation.as_str(),
272 approval.as_str()
273 ),
274 );
275 if graph.snapshot().edge(&edge).is_none() {
276 apply_change(
277 &mut graph,
278 session_id,
279 source,
280 WorkGraphChange::AddEdge {
281 edge: WorkEdge {
282 id: edge,
283 kind: EdgeKind::RequiresApproval,
284 from: operation,
285 to: approval,
286 },
287 },
288 )?;
289 }
290 let next = graph.into_snapshot();
291 validate_combined(&next, &project_plan(&next), &project_todos(&next))?;
292 active.snapshot = Some(next);
293 active.pending_publish = true;
294 Ok(())
295 }
296
297 /// Apply one owner observation through the reducer. Unknown bindings are
298 /// rejected rather than materialized after the fact: spawn intent must be
299 /// registered before work begins.
300 pub fn reconcile_operation(
301 &self,
302 session_id: &str,
303 snapshot: OperationOwnerSnapshot,
304 ) -> Result<bool, String> {
305 let external = snapshot.external.clone();
306 self.reconcile_observation(session_id, &external, snapshot.into_observation())
307 }
308
309 pub fn reconcile_observation(
310 &self,
311 session_id: &str,
312 external: &str,
313 observation: OperationObservation,
314 ) -> Result<bool, String> {
315 let mut active = lock_unpoisoned(&self.graph);
316 if active
317 .session_id
318 .as_deref()
319 .is_some_and(|id| id != session_id)
320 {
321 return Err(format!(
322 "lifecycle observation for session {session_id} does not match active session"
323 ));
324 }
325 let base = active
326 .snapshot
327 .clone()
328 .ok_or_else(|| "lifecycle observation arrived before graph registration".to_string())?;
329 let Some(next) = apply_observation_to_snapshot(&base, session_id, external, observation)?
330 else {
331 return Ok(false);
332 };
333 active.snapshot = Some(next);
334 active.pending_publish = true;
335 Ok(true)
336 }
337
338 /// Compact graph-owned continuity injected after context compaction.
339 /// It contains identities and state only; no raw output or reasoning.
340 #[must_use]
341 pub fn active_operation_summary(&self, session_id: Option<&str>) -> Option<String> {
342 let active = lock_unpoisoned(&self.graph);
343 if let (Some(expected), Some(actual)) = (session_id, active.session_id.as_deref())
344 && expected != actual
345 {
346 return None;
347 }
348 let graph = active.snapshot.as_ref()?;
349 let operations = graph
350 .nodes
351 .iter()
352 .filter(|node| {
353 node.kind == NodeKind::Operation
354 && (node.state.is_live() || node.state == NodeState::Stale)
355 })
356 .take(24)
357 .collect::<Vec<_>>();
358 if operations.is_empty() {
359 return None;
360 }
361 let mut out = format!(
362 "{ACTIVE_OPERATION_SUMMARY_START}\n## Active Work Graph Operations\n\nOwner records remain authoritative; reconcile before acting on a restored operation.\n"
363 );
364 for node in operations {
365 let external = node
366 .binding
367 .as_ref()
368 .map_or("unbound", |binding| binding.external.as_str());
369 out.push_str(&format!(
370 "- `{}` - {} - {}\n",
371 external,
372 operation_state_label(node.state),
373 prompt_safe_title(&node.title)
374 ));
375 }
376 out.push_str(ACTIVE_OPERATION_SUMMARY_END);
377 Some(out)
378 }
379
380 /// Record one reasoning-effort configuration change as bounded graph
381 /// activity. The event contains typed tiers and a non-secret route
382 /// identity only; there is no field capable of carrying reasoning text.
383 /// When live operations exist, the most recently updated one receives the
384 /// historical link. Later terminalization does not invalidate that link.
385 #[allow(clippy::too_many_arguments)]
386 pub fn record_reasoning_effort_change(
387 &self,
388 session_id: Option<&str>,
389 requested: ReasoningEffortTier,
390 effective: ReasoningEffortTier,
391 provider_kind: crate::config::ApiProvider,
392 provider: &str,
393 endpoint_identity: Option<&str>,
394 model: Option<&str>,
395 ) -> Result<Option<WorkNodeId>, String> {
396 let todos = retry_lock(&self.todos, 100)
397 .ok_or_else(|| "To-do state is busy; effort change was not recorded".to_string())?;
398 let plan = retry_lock(&self.plan, 100)
399 .ok_or_else(|| "Plan state is busy; effort change was not recorded".to_string())?;
400 let mut active = lock_unpoisoned(&self.graph);
401 let session_id = resolved_session_id(&active, session_id);
402 let base = graph_for_update(
403 &mut active,
404 &session_id,
405 &plan.snapshot(),
406 &todos.snapshot(),
407 )?;
408 let operation = base
409 .nodes
410 .iter()
411 .filter(|node| node.kind == NodeKind::Operation && node.state.is_live())
412 .max_by(|left, right| {
413 left.updated_at
414 .cmp(&right.updated_at)
415 .then_with(|| left.id.as_str().cmp(right.id.as_str()))
416 })
417 .map(|node| node.id.clone());
418 let now = now_ms();
419 let mut graph = WorkGraph::from_snapshot(base);
420 graph
421 .apply(
422 WorkGraphChange::RecordActivity {
423 event: WorkActivityEvent::ReasoningEffortChanged {
424 requested,
425 effective,
426 provider_kind: Some(provider_kind),
427 provider: provider.to_string(),
428 endpoint_identity: endpoint_identity.map(str::to_string),
429 model: model.map(str::to_string),
430 ts: now,
431 operation: operation.clone(),
432 },
433 },
434 ChangeCtx {
435 session_id,
436 now,
437 idempotency_key: None,
438 },
439 )
440 .map_err(|err| format!("reasoning effort: {err}"))?;
441 let next = graph.into_snapshot();
442 validate_combined(&next, &project_plan(&next), &project_todos(&next))?;
443 active.snapshot = Some(next);
444 active.pending_publish = true;
445 Ok(operation)
446 }
447
448 /// Apply an `update_plan` payload through the graph and publish both
449 /// legacy projections only after the candidate graph validates.
450 pub async fn apply_plan_update(
451 &self,
452 session_id: &str,
453 tool: &str,
454 plan: &PlanSnapshot,
455 ) -> Result<PlanSnapshot, String> {
456 let todos_guard = self.todos.lock().await;
457 let plan_guard = self.plan.lock().await;
458 let mut active = lock_unpoisoned(&self.graph);
459 let base = graph_for_update(
460 &mut active,
461 session_id,
462 &plan_guard.snapshot(),
463 &todos_guard.snapshot(),
464 )?;
465 let next = update_plan_graph(base, session_id, tool, plan)?;
466 let derived_plan = project_plan(&next);
467 let derived_todos = project_todos(&next);
468 let next_plan = PlanState::from_snapshot(&derived_plan);
469 let next_todos = TodoList::from_snapshot(&derived_todos)?;
470 validate_combined(&next, &next_plan.snapshot(), &next_todos.snapshot())?;
471 active.snapshot = Some(next);
472 active.pending_publish = true;
473 Ok(derived_plan)
474 }
475
476 /// Apply a legacy To-do/checklist payload through the graph and publish
477 /// both projections from the committed candidate.
478 pub async fn apply_todo_update(
479 &self,
480 session_id: &str,
481 tool: &str,
482 todos: &TodoListSnapshot,
483 ) -> Result<TodoListSnapshot, String> {
484 let todos_guard = self.todos.lock().await;
485 let plan_guard = self.plan.lock().await;
486 let mut active = lock_unpoisoned(&self.graph);
487 let base = graph_for_update(
488 &mut active,
489 session_id,
490 &plan_guard.snapshot(),
491 &todos_guard.snapshot(),
492 )?;
493 let next = update_todo_graph(base, session_id, tool, todos)?;
494 let derived_plan = project_plan(&next);
495 let derived_todos = project_todos(&next);
496 let next_plan = PlanState::from_snapshot(&derived_plan);
497 let next_todos = TodoList::from_snapshot(&derived_todos)?;
498 validate_combined(&next, &next_plan.snapshot(), &next_todos.snapshot())?;
499 active.snapshot = Some(next);
500 active.pending_publish = true;
501 Ok(derived_todos)
502 }
503
504 /// Publish the latest validated legacy views after the caller has queued
505 /// their graph-backed session/checkpoint write.
506 pub async fn publish_pending(&self) -> Result<bool, String> {
507 let mut todos = self.todos.lock().await;
508 let mut plan = self.plan.lock().await;
509 let mut active = lock_unpoisoned(&self.graph);
510 if !active.pending_publish {
511 return Ok(false);
512 }
513 let graph = active
514 .snapshot
515 .as_ref()
516 .ok_or_else(|| "pending Work projection has no graph".to_string())?;
517 let derived_plan = project_plan(graph);
518 let derived_todos = project_todos(graph);
519 let next_plan = PlanState::from_snapshot(&derived_plan);
520 let next_todos = TodoList::from_snapshot(&derived_todos)?;
521 validate_combined(graph, &next_plan.snapshot(), &next_todos.snapshot())?;
522 *plan = next_plan;
523 *todos = next_todos;
524 active.pending_publish = false;
525 Ok(true)
526 }
527
528 /// Synchronous counterpart for explicit save/rename/fork commands that
529 /// have already completed their atomic disk write.
530 pub fn publish_pending_sync(&self) -> Result<bool, String> {
531 let mut todos = retry_lock(&self.todos, 100).ok_or_else(|| {
532 "To-do state is busy; saved Work views were not published".to_string()
533 })?;
534 let mut plan = retry_lock(&self.plan, 100)
535 .ok_or_else(|| "Plan state is busy; saved Work views were not published".to_string())?;
536 let mut active = lock_unpoisoned(&self.graph);
537 if !active.pending_publish {
538 return Ok(false);
539 }
540 let graph = active
541 .snapshot
542 .as_ref()
543 .ok_or_else(|| "pending Work projection has no graph".to_string())?;
544 let derived_plan = project_plan(graph);
545 let derived_todos = project_todos(graph);
546 let next_plan = PlanState::from_snapshot(&derived_plan);
547 let next_todos = TodoList::from_snapshot(&derived_todos)?;
548 validate_combined(graph, &next_plan.snapshot(), &next_todos.snapshot())?;
549 *plan = next_plan;
550 *todos = next_todos;
551 active.pending_publish = false;
552 Ok(true)
553 }
554
555 #[must_use]
556 pub fn has_pending_publish(&self) -> bool {
557 lock_unpoisoned(&self.graph).pending_publish
558 }
559
560 /// Latest graph-derived To-do view, including an unpublished transaction.
561 pub async fn current_todos(&self) -> Result<TodoListSnapshot, String> {
562 let projected = {
563 let active = lock_unpoisoned(&self.graph);
564 active.snapshot.as_ref().map(project_todos)
565 };
566 if let Some(projected) = projected {
567 return Ok(projected);
568 }
569 Ok(self.todos.lock().await.snapshot())
570 }
571
572 /// Capture a persistence-ready graph plus fully populated old views.
573 /// Legacy-only in-memory state is imported once and normalized in place.
574 pub fn capture(&self, session_id: Option<&str>) -> Result<Option<WorkRuntimeSnapshot>, String> {
575 self.capture_with_retries(session_id, 100)
576 }
577
578 /// Non-blocking capture for the render/event loop.
579 pub fn try_capture(
580 &self,
581 session_id: Option<&str>,
582 ) -> Result<Option<WorkRuntimeSnapshot>, String> {
583 self.capture_with_retries(session_id, 1)
584 }
585
586 fn capture_with_retries(
587 &self,
588 session_id: Option<&str>,
589 retries: u32,
590 ) -> Result<Option<WorkRuntimeSnapshot>, String> {
591 let todos = retry_lock(&self.todos, retries)
592 .ok_or_else(|| "To-do state is busy; try saving again".to_string())?;
593 let plan = retry_lock(&self.plan, retries)
594 .ok_or_else(|| "Plan state is busy; try saving again".to_string())?;
595 let mut active = lock_unpoisoned(&self.graph);
596 let todos_snapshot = todos.snapshot();
597 let plan_snapshot = plan.snapshot();
598 if todos_snapshot.is_empty()
599 && plan_snapshot.is_empty()
600 && active
601 .snapshot
602 .as_ref()
603 .is_none_or(WorkGraphSnapshot::is_empty)
604 {
605 return Ok(None);
606 }
607 let had_graph = active.snapshot.is_some();
608 let had_pending_publish = active.pending_publish;
609 let session_id = resolved_session_id(&active, session_id);
610 let graph = graph_for_update(&mut active, &session_id, &plan_snapshot, &todos_snapshot)?;
611 let derived_plan = project_plan(&graph);
612 let derived_todos = project_todos(&graph);
613 validate_combined(&graph, &derived_plan, &derived_todos)?;
614 if had_graph
615 && !had_pending_publish
616 && (derived_plan != plan_snapshot || derived_todos != todos_snapshot)
617 {
618 return Err("live Work Graph and legacy views disagree".to_string());
619 }
620 active.snapshot = Some(graph.clone());
621 if !had_graph {
622 active.pending_publish = true;
623 }
624 Ok(Some(WorkRuntimeSnapshot {
625 graph,
626 todos: derived_todos,
627 plan: derived_plan,
628 }))
629 }
630
631 /// Validate and atomically activate persisted state. Sessions without a
632 /// graph are deterministically imported from their complete old views.
633 pub fn restore(
634 &self,
635 session_id: &str,
636 graph: Option<&WorkGraphSnapshot>,
637 todos: &TodoListSnapshot,
638 plan: &PlanSnapshot,
639 ) -> Result<Option<WorkRuntimeSnapshot>, String> {
640 self.restore_internal(session_id, graph, todos, plan, None)
641 }
642
643 /// Restore a saved graph and reconcile workspace-scoped durable owners as
644 /// one candidate transaction. No live state changes until the restored
645 /// graph, owner observations, and both legacy projections all validate.
646 pub fn restore_with_workspace_owner_bindings(
647 &self,
648 session_id: &str,
649 workspace: &Path,
650 graph: Option<&WorkGraphSnapshot>,
651 todos: &TodoListSnapshot,
652 plan: &PlanSnapshot,
653 ) -> Result<Option<WorkRuntimeSnapshot>, String> {
654 self.restore_internal(session_id, graph, todos, plan, Some(workspace))
655 }
656
657 fn restore_internal(
658 &self,
659 session_id: &str,
660 graph: Option<&WorkGraphSnapshot>,
661 todos: &TodoListSnapshot,
662 plan: &PlanSnapshot,
663 workspace: Option<&Path>,
664 ) -> Result<Option<WorkRuntimeSnapshot>, String> {
665 let had_graph = graph.is_some();
666 let graph = match graph {
667 Some(graph) => {
668 validate(graph).map_err(|err| err.to_string())?;
669 graph.clone()
670 }
671 None if todos.is_empty() && plan.is_empty() => WorkGraphSnapshot::new(),
672 None => import_legacy(session_id, plan, todos)?,
673 };
674 let (mut graph, reconciled_ephemeral) =
675 mark_restored_ephemeral_operations_stale(graph, session_id)?;
676 let reconciled_workspace = if let Some(workspace) = workspace {
677 let (reconciled, changed) = reconcile_workspace_snapshot(graph, session_id, workspace)?;
678 graph = reconciled;
679 changed > 0
680 } else {
681 false
682 };
683 let derived_plan = project_plan(&graph);
684 let derived_todos = project_todos(&graph);
685 if graph.is_empty() {
686 if !todos.is_empty() || !plan.is_empty() {
687 return Err("empty Work Graph cannot carry non-empty legacy views".to_string());
688 }
689 } else if graph.import_digest.is_some() && graph.compat.is_empty() {
690 return Err("imported Work Graph is missing compatibility projections".to_string());
691 }
692 validate_combined(&graph, &derived_plan, &derived_todos)?;
693 if had_graph && (&derived_plan != plan || &derived_todos != todos) {
694 return Err("persisted Work Graph and legacy views disagree".to_string());
695 }
696 let next_plan = PlanState::from_snapshot(&derived_plan);
697 let next_todos = TodoList::from_snapshot(&derived_todos)?;
698 let mut todos_guard = retry_lock(&self.todos, 100)
699 .ok_or_else(|| "To-do state is busy; session was not restored".to_string())?;
700 let mut plan_guard = retry_lock(&self.plan, 100)
701 .ok_or_else(|| "Plan state is busy; session was not restored".to_string())?;
702 let mut active = lock_unpoisoned(&self.graph);
703 *todos_guard = next_todos;
704 *plan_guard = next_plan;
705 active.session_id = Some(session_id.to_string());
706 active.snapshot = Some(graph.clone());
707 // A legacy load has already restored its complete old views, but its
708 // newly imported graph still needs one acknowledged graph-bearing
709 // write (and pre-import archive) before the migration is settled.
710 active.pending_publish =
711 reconciled_ephemeral || reconciled_workspace || (!had_graph && !graph.is_empty());
712 if graph.is_empty() {
713 Ok(None)
714 } else {
715 Ok(Some(WorkRuntimeSnapshot {
716 graph,
717 todos: derived_todos,
718 plan: derived_plan,
719 }))
720 }
721 }
722
723 /// Reconcile restored Fleet and Lane bindings from their durable owners.
724 /// Missing records fail toward `Stale`; process probes never override the
725 /// replayed ledger/registry state.
726 pub fn reconcile_workspace_owner_bindings(
727 &self,
728 session_id: &str,
729 workspace: &Path,
730 ) -> Result<usize, String> {
731 let (base, revision) = {
732 let active = lock_unpoisoned(&self.graph);
733 if active.session_id.as_deref() != Some(session_id) {
734 return Err(format!(
735 "workspace owner reconciliation does not match active session {session_id}"
736 ));
737 }
738 let base = active
739 .snapshot
740 .clone()
741 .ok_or_else(|| "workspace owner reconciliation has no active graph".to_string())?;
742 let revision = base.revision;
743 (base, revision)
744 };
745 let (next, changed) = reconcile_workspace_snapshot(base, session_id, workspace)?;
746 if changed == 0 {
747 return Ok(0);
748 }
749 let mut active = lock_unpoisoned(&self.graph);
750 if active.session_id.as_deref() != Some(session_id)
751 || active.snapshot.as_ref().map(|graph| graph.revision) != Some(revision)
752 {
753 return Err("Work Graph changed during workspace owner reconciliation".to_string());
754 }
755 active.snapshot = Some(next);
756 active.pending_publish = true;
757 Ok(changed)
758 }
759
760 pub fn clear(&self, session_id: Option<&str>) -> bool {
761 let Some(mut todos) = retry_lock(&self.todos, 100) else {
762 return false;
763 };
764 let Some(mut plan) = retry_lock(&self.plan, 100) else {
765 return false;
766 };
767 let mut active = lock_unpoisoned(&self.graph);
768 todos.clear();
769 *plan = PlanState::default();
770 active.session_id = Some(resolved_session_id(&active, session_id));
771 active.snapshot = Some(WorkGraphSnapshot::new());
772 active.pending_publish = false;
773 true
774 }
775 }
776
777 fn apply_observation_to_snapshot(
778 base: &WorkGraphSnapshot,
779 session_id: &str,
780 external: &str,
781 observation: OperationObservation,
782 ) -> Result<Option<WorkGraphSnapshot>, String> {
783 let node = base
784 .nodes
785 .iter()
786 .find(|node| {
787 node.binding
788 .as_ref()
789 .is_some_and(|binding| binding.external == external)
790 })
791 .ok_or_else(|| format!("operation binding {external} is not registered"))?;
792 let stale_owner_recovery = if let OperationObservation::OwnerReported {
793 state, seq, output, ..
794 } = &observation
795 && let Some(previous) = node
796 .binding
797 .as_ref()
798 .and_then(|binding| binding.last_observation.as_ref())
799 {
800 if *seq < previous.seq {
801 return Err(format!(
802 "operation owner {external} sequence regressed from {} to {seq}",
803 previous.seq
804 ));
805 }
806 if *seq == previous.seq {
807 if node.state != NodeState::Stale {
808 return Ok(None);
809 }
810 if *state != previous.owner_state || *output != previous.output {
811 return Err(format!(
812 "operation owner {external} changed observation at sequence {seq}"
813 ));
814 }
815 true
816 } else {
817 false
818 }
819 } else {
820 false
821 };
822 if matches!(&observation, OperationObservation::OwnerMissing { .. })
823 && node.state == NodeState::Stale
824 {
825 return Ok(None);
826 }
827 let idempotency_key = match &observation {
828 OperationObservation::OwnerReported { .. } if stale_owner_recovery => None,
829 OperationObservation::OwnerReported { seq, .. } => Some(IdempotencyKey {
830 binding: BindingId::derive(session_id, &format!("binding:{external}")),
831 seq: *seq,
832 }),
833 OperationObservation::OwnerMissing { .. } | OperationObservation::CancelUpdate { .. } => {
834 None
835 }
836 };
837 let (next, receipt) = super::reducer::apply(
838 base,
839 WorkGraphChange::ReconcileOperation {
840 node: node.id.clone(),
841 obs: observation,
842 },
843 ChangeCtx {
844 session_id: session_id.to_string(),
845 now: now_ms(),
846 idempotency_key,
847 },
848 )
849 .map_err(|err| format!("runtime reconcile: {err}"))?;
850 Ok((!receipt.no_op).then_some(next))
851 }
852
853 fn reconcile_workspace_snapshot(
854 mut graph: WorkGraphSnapshot,
855 session_id: &str,
856 workspace: &Path,
857 ) -> Result<(WorkGraphSnapshot, usize), String> {
858 let candidates = graph
859 .nodes
860 .iter()
861 .filter(|node| node.state.is_live() || node.state == NodeState::Stale)
862 .filter_map(|node| node.binding.as_ref())
863 .filter(|binding| binding.durable)
864 .map(|binding| binding.external.clone())
865 .filter(|external| external.starts_with("fleet:") || external.starts_with("lane:"))
866 .collect::<Vec<_>>();
867 if candidates.is_empty() {
868 return Ok((graph, 0));
869 }
870
871 let fleet = candidates
872 .iter()
873 .any(|external| external.starts_with("fleet:"))
874 .then(|| FleetLedger::open(workspace).and_then(|ledger| ledger.rebuild_state()));
875 if let Some(Err(err)) = fleet.as_ref() {
876 tracing::warn!(
877 workspace = %workspace.display(),
878 error = %err,
879 "Fleet owner store could not be replayed; restored bindings will be stale"
880 );
881 }
882 let lanes = candidates
883 .iter()
884 .any(|external| external.starts_with("lane:"))
885 .then(LaneRegistry::open_default);
886 if let Some(Err(err)) = lanes.as_ref() {
887 tracing::warn!(
888 error = %err,
889 "Lane owner registry could not be opened; restored bindings will be stale"
890 );
891 }
892 let observed_at = now_ms();
893 let mut changed = 0usize;
894 for external in candidates {
895 let observation = if let Some(rest) = external.strip_prefix("fleet:") {
896 rest.split_once('/')
897 .and_then(|(run_id, task_id)| {
898 fleet
899 .as_ref()
900 .and_then(|state| state.as_ref().ok())
901 .and_then(|state| state.tasks.get(&format!("{run_id}:{task_id}")))
902 })
903 .map(|record| fleet_task_owner_snapshot(record, observed_at).into_observation())
904 .unwrap_or(OperationObservation::OwnerMissing {
905 checked_at: observed_at,
906 })
907 } else if let Some(lane_id) = external.strip_prefix("lane:") {
908 lanes
909 .as_ref()
910 .and_then(|registry| registry.as_ref().ok())
911 .and_then(|registry| registry.load(lane_id).ok())
912 .map(|record| lane_owner_snapshot(&record, observed_at).into_observation())
913 .unwrap_or(OperationObservation::OwnerMissing {
914 checked_at: observed_at,
915 })
916 } else {
917 continue;
918 };
919 if let Some(next) =
920 apply_observation_to_snapshot(&graph, session_id, &external, observation)?
921 {
922 graph = next;
923 changed = changed.saturating_add(1);
924 }
925 }
926 Ok((graph, changed))
927 }
928
929 fn operation_parent(
930 graph: &mut WorkGraph,
931 session_id: &str,
932 source: &str,
933 ) -> Result<WorkNodeId, String> {
934 if let Some(parent) = graph
935 .snapshot()
936 .nodes
937 .iter()
938 .find(|node| node.kind == NodeKind::PlanStep && node.state == NodeState::Active)
939 .or_else(|| {
940 graph
941 .snapshot()
942 .nodes
943 .iter()
944 .find(|node| node.kind == NodeKind::PlanStep && node.state == NodeState::Ready)
945 })
946 .or_else(|| {
947 graph
948 .snapshot()
949 .nodes
950 .iter()
951 .find(|node| node.kind == NodeKind::Objective)
952 })
953 {
954 return Ok(parent.id.clone());
955 }
956
957 let now = now_ms();
958 let id = WorkNodeId::derive(session_id, "objective:runtime-operations");
959 apply_change(
960 graph,
961 session_id,
962 source,
963 WorkGraphChange::AddNode {
964 node: WorkNode {
965 id: id.clone(),
966 kind: NodeKind::Objective,
967 title: "Runtime operations".to_string(),
968 state: NodeState::Ready,
969 acceptance: Vec::new(),
970 binding: None,
971 evidence: None,
972 provenance: Provenance::RuntimeReconcile {
973 source: source.to_string(),
974 observed_at: now,
975 },
976 created_at: now,
977 updated_at: now,
978 },
979 },
980 )?;
981 Ok(id)
982 }
983
984 fn mark_restored_ephemeral_operations_stale(
985 graph: WorkGraphSnapshot,
986 session_id: &str,
987 ) -> Result<(WorkGraphSnapshot, bool), String> {
988 let candidates = graph
989 .nodes
990 .iter()
991 .filter_map(|node| {
992 let binding = node.binding.as_ref()?;
993 (!binding.durable && node.state.is_live()).then(|| node.id.clone())
994 })
995 .collect::<Vec<_>>();
996 if candidates.is_empty() {
997 return Ok((graph, false));
998 }
999 let mut graph = WorkGraph::from_snapshot(graph);
1000 for node in candidates {
1001 graph
1002 .apply(
1003 WorkGraphChange::ReconcileOperation {
1004 node,
1005 obs: OperationObservation::OwnerMissing {
1006 checked_at: now_ms(),
1007 },
1008 },
1009 ChangeCtx {
1010 session_id: session_id.to_string(),
1011 now: now_ms(),
1012 idempotency_key: None,
1013 },
1014 )
1015 .map_err(|err| format!("restart reconcile: {err}"))?;
1016 }
1017 Ok((graph.into_snapshot(), true))
1018 }
1019
1020 fn bounded_operation_title(title: &str) -> String {
1021 let normalized = title.split_whitespace().collect::<Vec<_>>().join(" ");
1022 let mut chars = normalized.chars();
1023 let bounded = chars.by_ref().take(180).collect::<String>();
1024 if chars.next().is_some() {
1025 format!("{bounded}...")
1026 } else if bounded.is_empty() {
1027 "Runtime operation".to_string()
1028 } else {
1029 bounded
1030 }
1031 }
1032
1033 fn prompt_safe_title(title: &str) -> String {
1034 bounded_operation_title(&title.replace('`', "'"))
1035 }
1036
1037 const fn operation_state_label(state: NodeState) -> &'static str {
1038 match state {
1039 NodeState::Ready => "ready",
1040 NodeState::Initializing => "initializing",
1041 NodeState::Active => "running",
1042 NodeState::Waiting => "waiting",
1043 NodeState::Blocked => "blocked",
1044 NodeState::Completed => "completed",
1045 NodeState::Verified => "verified",
1046 NodeState::Stale => "stale",
1047 NodeState::Superseded => "superseded",
1048 NodeState::Cancelled => "cancelled",
1049 NodeState::Failed => "failed",
1050 }
1051 }
1052
1053 fn graph_for_update(
1054 active: &mut ActiveGraph,
1055 session_id: &str,
1056 plan: &PlanSnapshot,
1057 todos: &TodoListSnapshot,
1058 ) -> Result<WorkGraphSnapshot, String> {
1059 match active.session_id.as_deref() {
1060 // App session transitions are already blocked while runtime work is
1061 // active. Rebind the authority namespace without re-keying graph IDs
1062 // so save-as/fork/new-session flows keep one coherent snapshot.
1063 Some(active_id) if active_id != session_id => {
1064 active.session_id = Some(session_id.to_string());
1065 }
1066 None => active.session_id = Some(session_id.to_string()),
1067 Some(_) => {}
1068 }
1069 if let Some(snapshot) = active.snapshot.as_ref() {
1070 validate(snapshot).map_err(|err| err.to_string())?;
1071 return Ok(snapshot.clone());
1072 }
1073 let graph = if plan.is_empty() && todos.is_empty() {
1074 WorkGraphSnapshot::new()
1075 } else {
1076 import_legacy(session_id, plan, todos)?
1077 };
1078 active.snapshot = Some(graph.clone());
1079 Ok(graph)
1080 }
1081
1082 fn update_plan_graph(
1083 base: WorkGraphSnapshot,
1084 session_id: &str,
1085 tool: &str,
1086 plan: &PlanSnapshot,
1087 ) -> Result<WorkGraphSnapshot, String> {
1088 let mut graph = WorkGraph::from_snapshot(base);
1089 let objective = ensure_objective(&mut graph, session_id, tool, plan)?;
1090 let desired_active_alias = plan.items.iter().enumerate().find_map(|(index, item)| {
1091 (item.status == StepStatus::InProgress)
1092 .then(|| graph.snapshot().compat.plan_order.get(index).cloned())
1093 .flatten()
1094 .filter(|node| {
1095 graph
1096 .snapshot()
1097 .compat
1098 .todos
1099 .iter()
1100 .any(|binding| &binding.node == node)
1101 })
1102 });
1103 if desired_active_alias.is_some() {
1104 deactivate_projected_todos(&mut graph, session_id, tool)?;
1105 }
1106
1107 let mut order = Vec::with_capacity(plan.items.len());
1108 for (index, item) in plan.items.iter().enumerate() {
1109 let id = graph
1110 .snapshot()
1111 .compat
1112 .plan_order
1113 .get(index)
1114 .cloned()
1115 .unwrap_or_else(|| WorkNodeId::derive(session_id, &format!("plan:{index}")));
1116 let provenance = tool_provenance(graph.snapshot(), tool);
1117 upsert_node(
1118 &mut graph,
1119 session_id,
1120 tool,
1121 WorkNode {
1122 id: id.clone(),
1123 kind: NodeKind::PlanStep,
1124 title: item.step.trim().to_string(),
1125 state: plan_node_state(&item.status),
1126 acceptance: Vec::new(),
1127 binding: None,
1128 evidence: None,
1129 provenance,
1130 created_at: now_ms(),
1131 updated_at: now_ms(),
1132 },
1133 )?;
1134 ensure_contains(&mut graph, session_id, tool, &objective, &id)?;
1135 order.push(id);
1136 }
1137 let mut compat = graph.snapshot().compat.clone();
1138 compat.plan = CompatPlanMetadata::from_plan_snapshot(plan);
1139 compat.plan_order = order;
1140 compat.todos.retain(|binding| {
1141 binding.plan_index.is_none_or(|index| {
1142 usize::try_from(index)
1143 .ok()
1144 .is_some_and(|i| i < plan.items.len())
1145 })
1146 });
1147 for binding in &mut compat.todos {
1148 if let Some(index) = binding.plan_index
1149 && let Some(node) = compat
1150 .plan_order
1151 .get(usize::try_from(index).unwrap_or(usize::MAX))
1152 {
1153 binding.node.clone_from(node);
1154 }
1155 }
1156 apply_change(
1157 &mut graph,
1158 session_id,
1159 tool,
1160 WorkGraphChange::ReplaceCompatProjection { compat },
1161 )?;
1162 Ok(graph.into_snapshot())
1163 }
1164
1165 fn update_todo_graph(
1166 base: WorkGraphSnapshot,
1167 session_id: &str,
1168 tool: &str,
1169 todos: &TodoListSnapshot,
1170 ) -> Result<WorkGraphSnapshot, String> {
1171 let mut graph = WorkGraph::from_snapshot(base);
1172 deactivate_projected_todos(&mut graph, session_id, tool)?;
1173 let current_plan = project_plan(graph.snapshot());
1174 let objective = ensure_objective(&mut graph, session_id, tool, &current_plan)?;
1175 let plan_order = graph.snapshot().compat.plan_order.clone();
1176 let mut bindings = Vec::with_capacity(todos.items.len());
1177 for item in &todos.items {
1178 let title = item.content.trim().to_string();
1179 let alias = graph
1180 .snapshot()
1181 .compat
1182 .todos
1183 .iter()
1184 .find(|binding| binding.legacy_id == item.id)
1185 .and_then(|binding| {
1186 binding
1187 .plan_index
1188 .map(|index| (index, binding.node.clone()))
1189 })
1190 .filter(|(index, node)| {
1191 plan_order.get(usize::try_from(*index).unwrap_or(usize::MAX)) == Some(node)
1192 });
1193 let (node, plan_index) = if let Some((index, node)) = alias {
1194 patch_existing_node(
1195 &mut graph,
1196 session_id,
1197 tool,
1198 &node,
1199 title,
1200 todo_node_state(item.status),
1201 )?;
1202 (node, Some(index))
1203 } else {
1204 let node = graph
1205 .snapshot()
1206 .compat
1207 .todos
1208 .iter()
1209 .find(|binding| binding.legacy_id == item.id && binding.plan_index.is_none())
1210 .map(|binding| binding.node.clone())
1211 .unwrap_or_else(|| WorkNodeId::derive(session_id, &format!("todo:{}", item.id)));
1212 let desired = todo_node_state(item.status);
1213 let provenance = tool_provenance(graph.snapshot(), tool);
1214 upsert_node(
1215 &mut graph,
1216 session_id,
1217 tool,
1218 WorkNode {
1219 id: node.clone(),
1220 kind: NodeKind::PlanStep,
1221 title,
1222 state: if desired == NodeState::Active {
1223 NodeState::Ready
1224 } else {
1225 desired
1226 },
1227 acceptance: Vec::new(),
1228 binding: None,
1229 evidence: None,
1230 provenance,
1231 created_at: now_ms(),
1232 updated_at: now_ms(),
1233 },
1234 )?;
1235 ensure_contains(&mut graph, session_id, tool, &objective, &node)?;
1236 if desired == NodeState::Active {
1237 let clean_title = graph
1238 .snapshot()
1239 .node(&node)
1240 .map(|node| node.title.clone())
1241 .ok_or_else(|| format!("node {node} not found after insert"))?;
1242 patch_existing_node(&mut graph, session_id, tool, &node, clean_title, desired)?;
1243 }
1244 (node, None)
1245 };
1246 bindings.push(CompatTodoBinding {
1247 legacy_id: item.id,
1248 node,
1249 plan_index,
1250 });
1251 }
1252 let mut compat = graph.snapshot().compat.clone();
1253 compat.todos = bindings;
1254 apply_change(
1255 &mut graph,
1256 session_id,
1257 tool,
1258 WorkGraphChange::ReplaceCompatProjection { compat },
1259 )?;
1260 Ok(graph.into_snapshot())
1261 }
1262
1263 fn ensure_objective(
1264 graph: &mut WorkGraph,
1265 session_id: &str,
1266 tool: &str,
1267 plan: &PlanSnapshot,
1268 ) -> Result<WorkNodeId, String> {
1269 let id = graph
1270 .snapshot()
1271 .nodes
1272 .iter()
1273 .find(|node| node.kind == NodeKind::Objective)
1274 .map(|node| node.id.clone())
1275 .unwrap_or_else(|| WorkNodeId::derive(session_id, "objective"));
1276 let title = plan
1277 .objective
1278 .as_deref()
1279 .or(plan.title.as_deref())
1280 .unwrap_or("Session work")
1281 .to_string();
1282 upsert_node(
1283 graph,
1284 session_id,
1285 tool,
1286 WorkNode {
1287 id: id.clone(),
1288 kind: NodeKind::Objective,
1289 title,
1290 state: NodeState::Ready,
1291 acceptance: Vec::new(),
1292 binding: None,
1293 evidence: None,
1294 provenance: tool_provenance(graph.snapshot(), tool),
1295 created_at: now_ms(),
1296 updated_at: now_ms(),
1297 },
1298 )?;
1299 Ok(id)
1300 }
1301
1302 fn upsert_node(
1303 graph: &mut WorkGraph,
1304 session_id: &str,
1305 tool: &str,
1306 node: WorkNode,
1307 ) -> Result<(), String> {
1308 if let Some(existing) = graph.snapshot().node(&node.id) {
1309 if existing.kind != node.kind {
1310 return Err(format!("node {} changed kind", node.id));
1311 }
1312 patch_existing_node(graph, session_id, tool, &node.id, node.title, node.state)
1313 } else {
1314 apply_change(graph, session_id, tool, WorkGraphChange::AddNode { node })
1315 }
1316 }
1317
1318 fn patch_existing_node(
1319 graph: &mut WorkGraph,
1320 session_id: &str,
1321 tool: &str,
1322 id: &WorkNodeId,
1323 title: String,
1324 state: NodeState,
1325 ) -> Result<(), String> {
1326 let current = graph
1327 .snapshot()
1328 .node(id)
1329 .ok_or_else(|| format!("node {id} not found"))?;
1330 if current.title == title && current.state == state {
1331 // Semantic no-ops must stay no-ops. In particular, terminal nodes are
1332 // immutable, so refreshing another To-do item must not try to rewrite
1333 // a settled sibling merely to stamp newer tool provenance.
1334 return Ok(());
1335 }
1336 let provenance = tool_provenance(graph.snapshot(), tool);
1337 apply_change(
1338 graph,
1339 session_id,
1340 tool,
1341 WorkGraphChange::UpdateNode {
1342 id: id.clone(),
1343 patch: WorkNodePatch {
1344 title: Some(title),
1345 state: Some(state),
1346 provenance: Some(provenance),
1347 ..WorkNodePatch::default()
1348 },
1349 },
1350 )
1351 }
1352
1353 fn ensure_contains(
1354 graph: &mut WorkGraph,
1355 session_id: &str,
1356 tool: &str,
1357 parent: &WorkNodeId,
1358 child: &WorkNodeId,
1359 ) -> Result<(), String> {
1360 let id = WorkEdgeId::derive(
1361 session_id,
1362 &format!("contains:{}:{}", parent.as_str(), child.as_str()),
1363 );
1364 if graph.snapshot().edge(&id).is_some() {
1365 return Ok(());
1366 }
1367 apply_change(
1368 graph,
1369 session_id,
1370 tool,
1371 WorkGraphChange::AddEdge {
1372 edge: WorkEdge {
1373 id,
1374 kind: EdgeKind::Contains,
1375 from: parent.clone(),
1376 to: child.clone(),
1377 },
1378 },
1379 )
1380 }
1381
1382 fn deactivate_projected_todos(
1383 graph: &mut WorkGraph,
1384 session_id: &str,
1385 tool: &str,
1386 ) -> Result<(), String> {
1387 let active = graph
1388 .snapshot()
1389 .compat
1390 .todos
1391 .iter()
1392 .filter_map(|binding| {
1393 graph
1394 .snapshot()
1395 .node(&binding.node)
1396 .filter(|node| node.state == NodeState::Active)
1397 .map(|node| (node.id.clone(), node.title.clone()))
1398 })
1399 .collect::<Vec<_>>();
1400 for (id, title) in active {
1401 patch_existing_node(graph, session_id, tool, &id, title, NodeState::Ready)?;
1402 }
1403 Ok(())
1404 }
1405
1406 fn apply_change(
1407 graph: &mut WorkGraph,
1408 session_id: &str,
1409 tool: &str,
1410 change: WorkGraphChange,
1411 ) -> Result<(), String> {
1412 graph
1413 .apply(
1414 change,
1415 ChangeCtx {
1416 session_id: session_id.to_string(),
1417 now: now_ms(),
1418 idempotency_key: None,
1419 },
1420 )
1421 .map(|_| ())
1422 .map_err(|err| format!("{tool}: {err}"))
1423 }
1424
1425 fn validate_combined(
1426 graph: &WorkGraphSnapshot,
1427 plan: &PlanSnapshot,
1428 todos: &TodoListSnapshot,
1429 ) -> Result<(), String> {
1430 validate(graph).map_err(|err| err.to_string())?;
1431 if &project_plan(graph) != plan {
1432 return Err("Work Graph Plan projection is inconsistent".to_string());
1433 }
1434 if &project_todos(graph) != todos {
1435 return Err("Work Graph To-do projection is inconsistent".to_string());
1436 }
1437 TodoList::from_snapshot(todos)?;
1438 Ok(())
1439 }
1440
1441 fn tool_provenance(snapshot: &WorkGraphSnapshot, tool: &str) -> Provenance {
1442 Provenance::ToolUpdate {
1443 tool: tool.to_string(),
1444 call_id: format!("{tool}:{}", snapshot.revision.saturating_add(1)),
1445 }
1446 }
1447
1448 fn plan_node_state(status: &StepStatus) -> NodeState {
1449 match status {
1450 StepStatus::Pending => NodeState::Ready,
1451 StepStatus::InProgress => NodeState::Active,
1452 StepStatus::Completed => NodeState::Completed,
1453 }
1454 }
1455
1456 fn todo_node_state(status: TodoStatus) -> NodeState {
1457 match status {
1458 TodoStatus::Pending => NodeState::Ready,
1459 TodoStatus::InProgress => NodeState::Active,
1460 TodoStatus::Completed => NodeState::Completed,
1461 TodoStatus::Cancelled => NodeState::Cancelled,
1462 }
1463 }
1464
1465 fn resolved_session_id(active: &ActiveGraph, requested: Option<&str>) -> String {
1466 requested
1467 .map(str::to_string)
1468 .or_else(|| active.session_id.clone())
1469 .unwrap_or_else(|| "unsaved-work".to_string())
1470 }
1471
1472 fn now_ms() -> i64 {
1473 chrono::Utc::now().timestamp_millis()
1474 }
1475
1476 fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
1477 mutex
1478 .lock()
1479 .unwrap_or_else(std::sync::PoisonError::into_inner)
1480 }
1481
1482 fn retry_lock<T>(
1483 mutex: &tokio::sync::Mutex<T>,
1484 retries: u32,
1485 ) -> Option<tokio::sync::MutexGuard<'_, T>> {
1486 for _ in 0..retries {
1487 if let Ok(guard) = mutex.try_lock() {
1488 return Some(guard);
1489 }
1490 std::thread::sleep(std::time::Duration::from_millis(1));
1491 }
1492 None
1493 }
1494
1495 #[cfg(test)]
1496 mod tests {
1497 use super::*;
1498 use crate::tools::plan::PlanItemArg;
1499 use crate::work_graph::{EvidenceKind, EvidenceRef, OwnerState};
1500
1501 #[test]
1502 fn operation_lifecycle_is_registered_idempotent_and_receipt_only() {
1503 let runtime = new_shared_work_runtime(
1504 crate::tools::todo::new_shared_todo_list(),
1505 crate::tools::plan::new_shared_plan_state(),
1506 );
1507 let intent = OperationIntent::new(
1508 "shell:shell_test",
1509 "silent `owner` command",
1510 false,
1511 "exec_shell",
1512 "shell_test",
1513 );
1514 let node_id = runtime
1515 .register_operation("session", intent.clone())
1516 .expect("register before spawn");
1517 assert_eq!(
1518 runtime.register_operation("session", intent),
1519 Ok(node_id.clone()),
1520 "repeat spawn intent must not duplicate the operation"
1521 );
1522 let initialized = runtime
1523 .capture(Some("session"))
1524 .expect("capture")
1525 .expect("graph");
1526 let node = initialized.graph.node(&node_id).expect("operation node");
1527 assert_eq!(node.state, NodeState::Initializing);
1528 assert!(
1529 initialized
1530 .graph
1531 .edges
1532 .iter()
1533 .any(|edge| { edge.kind == EdgeKind::Contains && edge.to == node_id })
1534 );
1535
1536 let output = EvidenceRef::new(
1537 EvidenceKind::Receipt {
1538 owner: "shell".to_string(),
1539 },
1540 "shell:shell_test:output",
1541 Some(4_096),
1542 true,
1543 )
1544 .expect("safe logical receipt");
1545 assert_eq!(
1546 runtime.reconcile_operation(
1547 "session",
1548 OperationOwnerSnapshot::new("shell:shell_test", OwnerState::Running, 7, 10,)
1549 .with_output(output),
1550 ),
1551 Ok(true)
1552 );
1553 assert_eq!(
1554 runtime.reconcile_operation(
1555 "session",
1556 OperationOwnerSnapshot::new("shell:shell_test", OwnerState::Completed, 7, 11,),
1557 ),
1558 Ok(false),
1559 "the same binding sequence is an idempotent no-op"
1560 );
1561 assert!(
1562 runtime
1563 .reconcile_operation(
1564 "session",
1565 OperationOwnerSnapshot::new("shell:unknown", OwnerState::Running, 1, 12,),
1566 )
1567 .expect_err("unknown owner must not materialize after spawn")
1568 .contains("not registered")
1569 );
1570 let running = runtime
1571 .capture(Some("session"))
1572 .expect("capture running")
1573 .expect("graph");
1574 let binding = running
1575 .graph
1576 .node(&node_id)
1577 .and_then(|node| node.binding.as_ref())
1578 .expect("binding");
1579 assert_eq!(
1580 running.graph.node(&node_id).map(|node| node.state),
1581 Some(NodeState::Active)
1582 );
1583 assert_eq!(
1584 binding
1585 .last_observation
1586 .as_ref()
1587 .and_then(|obs| obs.output.as_ref())
1588 .and_then(EvidenceRef::raw_bytes),
1589 Some(4_096)
1590 );
1591 let summary = runtime
1592 .active_operation_summary(Some("session"))
1593 .expect("compaction re-anchor");
1594 assert!(summary.contains("shell:shell_test"), "{summary}");
1595 assert!(summary.contains("silent 'owner' command"), "{summary}");
1596 assert!(!summary.contains("4,096"), "{summary}");
1597
1598 assert_eq!(
1599 runtime.record_reasoning_effort_change(
1600 Some("session"),
1601 ReasoningEffortTier::Low,
1602 ReasoningEffortTier::High,
1603 crate::config::ApiProvider::Moonshot,
1604 "moonshot",
1605 Some(crate::config::DEFAULT_MOONSHOT_BASE_URL),
1606 Some("kimi-k2.5"),
1607 ),
1608 Ok(Some(node_id.clone()))
1609 );
1610 let activity = runtime
1611 .capture(Some("session"))
1612 .expect("capture effort activity")
1613 .expect("graph")
1614 .graph
1615 .activities
1616 .last()
1617 .cloned()
1618 .expect("effort activity");
1619 let ts = match &activity {
1620 WorkActivityEvent::ReasoningEffortChanged { ts, .. } => *ts,
1621 };
1622 assert_eq!(
1623 activity,
1624 WorkActivityEvent::ReasoningEffortChanged {
1625 requested: ReasoningEffortTier::Low,
1626 effective: ReasoningEffortTier::High,
1627 provider_kind: Some(crate::config::ApiProvider::Moonshot),
1628 provider: "moonshot".to_string(),
1629 endpoint_identity: Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
1630 model: Some("kimi-k2.5".to_string()),
1631 ts,
1632 operation: Some(node_id.clone()),
1633 }
1634 );
1635
1636 runtime
1637 .record_operation_approval(
1638 "session",
1639 "shell:shell_test",
1640 "operate-verification:shell_test",
1641 "exec_shell",
1642 "approval_test",
1643 )
1644 .expect("approval provenance");
1645 let approved = runtime
1646 .capture(Some("session"))
1647 .expect("capture approval")
1648 .expect("graph");
1649 assert!(
1650 approved
1651 .graph
1652 .nodes
1653 .iter()
1654 .any(|node| node.kind == NodeKind::Approval)
1655 );
1656 assert!(
1657 approved
1658 .graph
1659 .edges
1660 .iter()
1661 .any(|edge| edge.kind == EdgeKind::RequiresApproval)
1662 );
1663
1664 runtime
1665 .reconcile_operation(
1666 "session",
1667 OperationOwnerSnapshot::new("shell:shell_test", OwnerState::Completed, 8, 13),
1668 )
1669 .expect("terminal owner report");
1670 runtime
1671 .reconcile_observation(
1672 "session",
1673 "shell:shell_test",
1674 OperationObservation::CancelUpdate {
1675 outcome: super::super::CancelOutcome::AlreadyFinished,
1676 at: 14,
1677 },
1678 )
1679 .expect("already-finished cancellation receipt");
1680 assert_eq!(
1681 runtime
1682 .capture(Some("session"))
1683 .expect("capture terminal")
1684 .expect("graph")
1685 .graph
1686 .node(&node_id)
1687 .map(|node| node.state),
1688 Some(NodeState::Completed),
1689 "already-finished cancellation must not rewrite owner state"
1690 );
1691 }
1692
1693 #[test]
1694 fn restore_stales_only_live_ephemeral_operations() {
1695 let runtime = new_shared_work_runtime(
1696 crate::tools::todo::new_shared_todo_list(),
1697 crate::tools::plan::new_shared_plan_state(),
1698 );
1699 for id in ["shell:live", "shell:done"] {
1700 runtime
1701 .register_operation(
1702 "session",
1703 OperationIntent::new(id, id, false, "exec_shell", id),
1704 )
1705 .expect("register shell");
1706 }
1707 runtime
1708 .reconcile_operation(
1709 "session",
1710 OperationOwnerSnapshot::new("shell:live", OwnerState::Running, 1, 1),
1711 )
1712 .expect("live shell");
1713 runtime
1714 .reconcile_operation(
1715 "session",
1716 OperationOwnerSnapshot::new("shell:done", OwnerState::Completed, 1, 1),
1717 )
1718 .expect("completed shell");
1719 let saved = runtime
1720 .capture(Some("session"))
1721 .expect("capture")
1722 .expect("saved graph");
1723
1724 let restored = new_shared_work_runtime(
1725 crate::tools::todo::new_shared_todo_list(),
1726 crate::tools::plan::new_shared_plan_state(),
1727 );
1728 restored
1729 .restore("session", Some(&saved.graph), &saved.todos, &saved.plan)
1730 .expect("restore graph");
1731 let graph = restored
1732 .capture(Some("session"))
1733 .expect("capture restored")
1734 .expect("restored graph")
1735 .graph;
1736 let state_for = |external: &str| {
1737 graph
1738 .nodes
1739 .iter()
1740 .find(|node| {
1741 node.binding
1742 .as_ref()
1743 .is_some_and(|binding| binding.external == external)
1744 })
1745 .map(|node| node.state)
1746 };
1747 assert_eq!(state_for("shell:live"), Some(NodeState::Stale));
1748 assert_eq!(state_for("shell:done"), Some(NodeState::Completed));
1749 assert!(restored.has_pending_publish());
1750 }
1751
1752 #[test]
1753 fn durable_owner_same_sequence_recovers_stale_once_and_rejects_regression() {
1754 let runtime = new_shared_work_runtime(
1755 crate::tools::todo::new_shared_todo_list(),
1756 crate::tools::plan::new_shared_plan_state(),
1757 );
1758 runtime
1759 .register_operation(
1760 "session",
1761 OperationIntent::new(
1762 "task:task_restore",
1763 "restored task",
1764 true,
1765 "task_create",
1766 "task_restore",
1767 ),
1768 )
1769 .expect("register durable owner");
1770 let running = OperationOwnerSnapshot::new("task:task_restore", OwnerState::Running, 7, 10);
1771 assert_eq!(
1772 runtime.reconcile_operation("session", running.clone()),
1773 Ok(true)
1774 );
1775 assert_eq!(
1776 runtime.reconcile_observation(
1777 "session",
1778 "task:task_restore",
1779 OperationObservation::OwnerMissing { checked_at: 11 },
1780 ),
1781 Ok(true)
1782 );
1783 assert_eq!(
1784 runtime
1785 .capture(Some("session"))
1786 .expect("capture stale")
1787 .expect("graph")
1788 .graph
1789 .nodes
1790 .iter()
1791 .find(|node| {
1792 node.binding
1793 .as_ref()
1794 .is_some_and(|binding| binding.external == "task:task_restore")
1795 })
1796 .map(|node| node.state),
1797 Some(NodeState::Stale)
1798 );
1799
1800 let replay = OperationOwnerSnapshot::new("task:task_restore", OwnerState::Running, 7, 12);
1801 assert_eq!(
1802 runtime.reconcile_operation("session", replay.clone()),
1803 Ok(true)
1804 );
1805 assert_eq!(
1806 runtime.reconcile_operation("session", replay),
1807 Ok(false),
1808 "same-sequence recovery must happen only while the node is stale"
1809 );
1810 assert_eq!(
1811 runtime
1812 .capture(Some("session"))
1813 .expect("capture recovered")
1814 .expect("graph")
1815 .graph
1816 .nodes
1817 .iter()
1818 .find(|node| {
1819 node.binding
1820 .as_ref()
1821 .is_some_and(|binding| binding.external == "task:task_restore")
1822 })
1823 .map(|node| node.state),
1824 Some(NodeState::Active)
1825 );
1826 assert_eq!(
1827 runtime.reconcile_operation(
1828 "session",
1829 OperationOwnerSnapshot::new("task:task_restore", OwnerState::Waiting, 7, 13,),
1830 ),
1831 Ok(false),
1832 "ordinary same-key duplicates retain the reducer no-op contract"
1833 );
1834 runtime
1835 .reconcile_observation(
1836 "session",
1837 "task:task_restore",
1838 OperationObservation::OwnerMissing { checked_at: 14 },
1839 )
1840 .expect("mark owner missing again");
1841 assert!(
1842 runtime
1843 .reconcile_operation(
1844 "session",
1845 OperationOwnerSnapshot::new("task:task_restore", OwnerState::Waiting, 7, 15,),
1846 )
1847 .expect_err("inconsistent replay cannot revive a stale node")
1848 .contains("changed observation")
1849 );
1850 assert!(
1851 runtime
1852 .reconcile_operation(
1853 "session",
1854 OperationOwnerSnapshot::new("task:task_restore", OwnerState::Running, 6, 16,),
1855 )
1856 .expect_err("owner sequence cannot regress")
1857 .contains("sequence regressed")
1858 );
1859 }
1860
1861 #[test]
1862 fn legacy_restore_stays_pending_until_first_graph_bearing_write() {
1863 let todos = crate::tools::todo::new_shared_todo_list();
1864 let plan = crate::tools::plan::new_shared_plan_state();
1865 let runtime = new_shared_work_runtime(todos.clone(), plan.clone());
1866 let legacy_plan = PlanSnapshot {
1867 items: vec![PlanItemArg {
1868 step: "Migrate once".to_string(),
1869 status: StepStatus::InProgress,
1870 }],
1871 ..PlanSnapshot::default()
1872 };
1873
1874 runtime
1875 .restore(
1876 "legacy-session",
1877 None,
1878 &TodoListSnapshot::default(),
1879 &legacy_plan,
1880 )
1881 .expect("restore legacy state");
1882 assert!(runtime.has_pending_publish());
1883 let captured = runtime
1884 .capture(Some("legacy-session"))
1885 .expect("capture imported graph")
1886 .expect("state");
1887 assert!(captured.graph.import_digest.is_some());
1888 assert_eq!(plan.blocking_lock().snapshot(), legacy_plan);
1889 assert_eq!(runtime.publish_pending_sync(), Ok(true));
1890 assert!(!runtime.has_pending_publish());
1891 assert!(todos.blocking_lock().snapshot().is_empty());
1892 }
1893 }
1894
1894 lines RUST