1use anyhow::{Result, anyhow, ensure};
2use serde_json::{Map, Value, json};
3use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7
8use licoup_workflow_store::{
9 CommittedTransition, StrategyStore, TransitionDecorator, TransitionObserver,
10};
11
12use crate::assistant::sha256_hex;
13use crate::ports::{HostPorts, SharedSnapshotAuthority, StrategyEffectPermit, host_ports};
14use crate::{
15 ASSISTANT_TEMPORARY_DEFINITION_PREFIX, AssistantPreflight, BindingCandidate, BindingValue,
16 PreflightFailure, StrategyDefinition, StrategyPackageImporter, preflight_assistant_graph,
17};
18use licoup_workflow::machine::{effect_input_for, fallback_reason};
19use licoup_workflow::{
20 BindingKind, CallbackDecisionKind, CommandKind, CommandStatus, CompiledWorkflow, FailureClass,
21 GraphState, GraphStateKind, PendingCallback, PreflightDiagnostic, ReducerEvent, RunCommand,
22 RunSnapshot, StrategyRunStatus, TransitionEvent, WorkflowDiagnosticCode,
23 WorkflowDiagnosticRecovery, WorkflowDiagnosticStage, WorkflowValidationFailure,
24 compile_workflow, compile_workflow_value,
25};
26
27const MAX_PACKAGE_SOURCE_BYTES: u64 = 8 * 1024 * 1024;
28const MAX_DRIVE_EFFECTS_PER_CALL: usize = 512;
29const EFFECT_LEASE_DURATION_MS: i64 = 5 * 60 * 1000;
30const CANCEL_OWNER_LOOKUP_BOUND: std::time::Duration = std::time::Duration::from_secs(2);
34const CANCEL_OWNER_LOOKUP_STEP: std::time::Duration = std::time::Duration::from_millis(5);
35
36pub use acativa::runtime::service::{ActorTurnPort, TurnCancelDisposition};
44use acativa::runtime::service::{
45 DriveReservation, LiveTurnOwner, LiveTurnRegistration, cancellation_pending, live_turn_owner,
46 mark_run_cancelled, unmark_run_cancelled,
47};
48
49pub type AssistantWakePort = acativa::runtime::service::RunWakePort;
56
57#[derive(Clone)]
58struct PostCommitDispatcher {
59 portable_root: PathBuf,
60 assistant_wake: Arc<Mutex<Option<Arc<AssistantWakePort>>>>,
61}
62
63impl PostCommitDispatcher {
64 fn new(portable_root: PathBuf) -> Self {
65 Self {
66 portable_root,
67 assistant_wake: Arc::new(Mutex::new(None)),
68 }
69 }
70
71 fn set_assistant_wake(&self, assistant_wake: AssistantWakePort) {
72 let mut slot = self
73 .assistant_wake
74 .lock()
75 .unwrap_or_else(|poison| poison.into_inner());
76 *slot = Some(Arc::new(assistant_wake));
77 }
78
79 fn assistant_wake(&self) -> Option<Arc<AssistantWakePort>> {
80 self.assistant_wake
81 .lock()
82 .unwrap_or_else(|poison| poison.into_inner())
83 .clone()
84 }
85}
86
87impl TransitionObserver for PostCommitDispatcher {
88 fn after_commit(&self, transition: &CommittedTransition) -> Result<()> {
89 let wake = self
90 .assistant_wake
91 .lock()
92 .unwrap_or_else(|poison| poison.into_inner())
93 .clone();
94 StrategyService::report_master_gates(
95 &self.portable_root,
96 wake.as_deref(),
97 Some(&transition.before),
98 &transition.after,
99 )
100 }
101}
102
103#[derive(Clone)]
104pub struct StrategyService {
105 store: StrategyStore,
106 importer: StrategyPackageImporter,
107 portable_root: PathBuf,
108 actor_port: Option<Arc<ActorTurnPort>>,
109 transition: TransitionDecorator,
110 post_commit: Arc<PostCommitDispatcher>,
111 ports: Arc<HostPorts>,
112}
113
114impl std::fmt::Debug for StrategyService {
115 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 formatter
117 .debug_struct("StrategyService")
118 .field("portable_root", &self.portable_root)
119 .finish_non_exhaustive()
120 }
121}
122
123#[cfg(test)]
126struct SyntheticDrivingEffect;
127
128#[cfg(test)]
129impl crate::ports::EffectPort for SyntheticDrivingEffect {
130 fn runtime_descriptors(&self) -> Vec<Value> {
131 Vec::new()
132 }
133
134 fn compatible_runtime_id(
135 &self,
136 _kind: licoup_workflow::RuntimeKind,
137 _version_requirement: &str,
138 ) -> Option<String> {
139 None
140 }
141
142 fn resolve_runtime(
143 &self,
144 _runtime_id: &str,
145 _kind: licoup_workflow::RuntimeKind,
146 _version_requirement: &str,
147 ) -> Result<Arc<dyn crate::ports::ResolvedRuntime>> {
148 Err(anyhow!("workflow_host_port_unavailable"))
149 }
150
151 fn actor_fingerprint(
152 &self,
153 value_id: &str,
154 model: &str,
155 reasoning_effort: &str,
156 ) -> Result<String> {
157 Ok(sha256_hex(
158 format!(
159 "licoup-strategy-actor-v1\0{value_id}\0{}\0{}",
160 model.trim(),
161 reasoning_effort.trim()
162 )
163 .as_bytes(),
164 ))
165 }
166
167 fn actor_capabilities(&self, _value_id: &str) -> Result<Value> {
168 Ok(json!({"ok": true}))
169 }
170
171 fn admit_strategy_cwd(&self, _cwd: &str) -> Result<()> {
172 Err(anyhow!("workflow_host_port_unavailable"))
173 }
174
175 fn execute_script(
176 &self,
177 _command: &RunCommand,
178 _authorization_digest: &str,
179 _runtime: &Arc<dyn crate::ports::ResolvedRuntime>,
180 _revision_content: &Path,
181 _runtime_state_root: &Path,
182 _permit: &mut StrategyEffectPermit,
183 ) -> Result<Value> {
184 Err(anyhow!("workflow_host_port_unavailable"))
185 }
186
187 fn execute_actor(
188 &self,
189 _command: &RunCommand,
190 _authorization_digest: &str,
191 _binding: &BindingValue,
192 _permit: &mut StrategyEffectPermit,
193 _cwd: Option<&str>,
194 ) -> Result<Value> {
195 Err(anyhow!("workflow_host_port_unavailable"))
196 }
197
198 fn predecessor_locator(&self, _facts: &Value) -> Value {
199 Value::Null
200 }
201
202 fn dispatch_lane_operation(&self, _operation: &str, _params: &Value) -> Result<Value> {
203 Err(anyhow!("workflow_host_port_unavailable"))
204 }
205
206 fn new_correlation_id(&self) -> String {
207 "correlation-test".to_owned()
208 }
209
210 fn record_run_stop(
211 &self,
212 portable_root: &Path,
213 correlation_id: &str,
214 confirmed: bool,
215 ) -> Result<()> {
216 let directory = portable_root.join("client-state").join("activity");
220 fs::create_dir_all(&directory)?;
221 let mut file = fs::OpenOptions::new()
222 .create(true)
223 .append(true)
224 .open(directory.join("activity.jsonl"))?;
225 let outcome = if confirmed {
226 "work.stop.accepted"
227 } else {
228 "work.stop.unconfirmed"
229 };
230 for event_type in ["work.stop.requested", outcome] {
231 let line = serde_json::to_string(&json!({
232 "type": event_type,
233 "correlationId": correlation_id,
234 }))?;
235 use std::io::Write;
236 writeln!(file, "{line}")?;
237 }
238 Ok(())
239 }
240}
241
242impl StrategyService {
243 pub fn open(portable_root: &Path) -> Result<Self> {
244 let store = StrategyStore::open(portable_root)?;
245 let service = Self::from_parts(
246 portable_root.to_path_buf(),
247 store,
248 StrategyPackageImporter::open(portable_root)?,
249 );
250 service.refresh_runtime_bindings()?;
251 let _ = service.transition.reconcile_pending();
252 Ok(service)
253 }
254
255 pub fn from_parts(
256 portable_root: PathBuf,
257 store: StrategyStore,
258 importer: StrategyPackageImporter,
259 ) -> Self {
260 let post_commit = Arc::new(PostCommitDispatcher::new(portable_root.clone()));
261 let transition = TransitionDecorator::new(store.clone()).with_observer(post_commit.clone());
262 Self {
263 store,
264 importer,
265 portable_root,
266 actor_port: None,
267 transition,
268 post_commit,
269 ports: Arc::new(host_ports().clone()),
272 }
273 }
274
275 pub fn with_host_ports(mut self, ports: HostPorts) -> Self {
277 self.ports = Arc::new(ports);
278 self
279 }
280
281 pub fn host_ports(&self) -> &HostPorts {
283 &self.ports
284 }
285
286 pub fn with_actor_turn_port(mut self, actor_port: ActorTurnPort) -> Self {
287 self.actor_port = Some(Arc::new(actor_port));
288 #[cfg(test)]
293 {
294 let mut ports = (*self.ports).clone();
295 ports.effect = Arc::new(SyntheticDrivingEffect);
296 self.ports = Arc::new(ports);
297 }
298 self
299 }
300
301 pub fn with_assistant_wake_port(self, assistant_wake: AssistantWakePort) -> Self {
302 self.post_commit.set_assistant_wake(assistant_wake);
303 self
304 }
305
306 pub fn with_profile_snapshot_authority(mut self, authority: SharedSnapshotAuthority) -> Self {
307 let mut ports = (*self.ports).clone();
308 ports.profile = authority;
309 self.ports = Arc::new(ports);
310 self
311 }
312
313 pub fn store(&self) -> &StrategyStore {
314 &self.store
315 }
316
317 pub fn execute(&self, request: Value) -> Result<Value> {
323 let conversation_id = request
324 .get("conversationId")
325 .and_then(Value::as_str)
326 .map(str::trim)
327 .filter(|value| !value.is_empty())
328 .map(str::to_owned);
329 let action = request
330 .get("action")
331 .and_then(Value::as_str)
332 .unwrap_or("")
333 .to_owned();
334 match self.execute_inner(request) {
335 Ok(result) => Ok(json!({"ok": true, "result": result})),
336 Err(error) => {
337 let projected = error_projection(&error);
338 let message = error.to_string();
339 let settled_start = message.contains("strategy_run_start_failed")
340 || message.contains("strategy_actor_dispatch_failed");
341 if (matches!(
342 action.as_str(),
343 "strategy.package.prepare-import" | "strategy.package.commit-import"
344 ) || (action == "strategy.run.start" && !settled_start))
345 && let Some(conversation_id) = conversation_id.as_deref()
346 {
347 let _ = self.report_imported_lifecycle_failure(conversation_id, &projected);
348 }
349 Ok(json!({"ok": false, "error": projected}))
350 }
351 }
352 }
353
354 fn execute_inner(&self, request: Value) -> Result<Value> {
355 let object = request
356 .as_object()
357 .ok_or_else(|| anyhow!("invalid_request"))?;
358 let action = required_string(object, "action")?;
359 ensure_allowed_fields(action, object)?;
360 match action {
361 "strategy.package.prepare-import" => {
362 validate_selection_token(required_string(object, "selectionToken")?)?;
363 let source = Path::new(required_string(object, "sourcePath")?);
364 let metadata =
365 fs::symlink_metadata(source).map_err(|_| anyhow!("package_unavailable"))?;
366 ensure!(
367 metadata.is_file()
368 && !metadata.file_type().is_symlink()
369 && metadata.len() <= MAX_PACKAGE_SOURCE_BYTES,
370 "package_unavailable"
371 );
372 let bytes = fs::read(source).map_err(|_| anyhow!("package_unavailable"))?;
373 let prepared = self.importer.prepare_bytes(&bytes)?;
374 Ok(serde_json::to_value(prepared)?)
375 }
376 "strategy.package.commit-import" => {
377 let committed = self.importer.commit(
378 required_string(object, "preparationId")?,
379 required_string(object, "expectedRevisionDigest")?,
380 )?;
381 let definition = self.store.register_definition(
382 &committed.prepared.revision_digest,
383 &committed.prepared.semantics_digest,
384 &committed.workflow,
385 committed.prepared.asset_count,
386 committed.prepared.prepared_at_unix_ms,
387 )?;
388 self.bind_detected_runtimes(&definition.summary.revision_digest)?;
389 Ok(serde_json::to_value(self.store.definition_by_revision(
390 &definition.summary.revision_digest,
391 )?)?)
392 }
393 "strategy.definition.list" => Ok(serde_json::to_value(self.store.list_definitions()?)?),
394 "strategy.definition.inspect" => {
395 let revision = required_string(object, "revisionDigest")?;
396 self.admit_revision_identity(revision)?;
397 self.bind_detected_runtimes(revision)?;
398 let definition = self.store.definition_by_revision(revision)?;
399 let projection = self.store.projection_for_definition(revision)?;
400 let mut model_display_names = BTreeMap::new();
404 for binding in &projection.bindings {
405 if !binding.model.trim().is_empty() {
406 model_display_names
407 .entry(binding.model.clone())
408 .or_insert_with(|| self.ports.model.model_display_name(&binding.model));
409 }
410 }
411 Ok(json!({
412 "projection": projection,
413 "workflow": definition.workflow,
414 "modelDisplayNames": model_display_names,
415 }))
416 }
417 "strategy.runtime.discover" | "strategy.runtime.list" => {
418 Ok(Value::Array(self.ports.effect.runtime_descriptors()))
419 }
420 "strategy.binding.update" => {
421 let revision = required_string(object, "revisionDigest")?;
422 self.admit_revision_identity(revision)?;
423 let slot = required_string(object, "slotId")?;
424 let value = required_string(object, "valueId")?;
425 self.validate_binding(revision, slot, value)?;
426 Ok(serde_json::to_value(self.store.update_binding(
427 revision,
428 slot,
429 value,
430 optional_string(object, "model")?,
431 optional_string(object, "reasoningEffort")?,
432 object.get("expectedRevision").and_then(Value::as_u64),
433 )?)?)
434 }
435 "strategy.binding.replace" => {
436 let revision = required_string(object, "revisionDigest")?;
437 self.admit_revision_identity(revision)?;
438 let slot = required_string(object, "slotId")?;
439 let candidates = parse_binding_candidates(object)?;
440 for candidate in &candidates {
441 self.validate_binding(revision, slot, &candidate.value_id)?;
442 }
443 Ok(serde_json::to_value(self.store.replace_slot_bindings(
444 revision,
445 slot,
446 &candidates,
447 object.get("expectedRevision").and_then(Value::as_u64),
448 )?)?)
449 }
450 "strategy.binding.remove" => {
451 let revision = required_string(object, "revisionDigest")?;
452 self.admit_revision_identity(revision)?;
453 let slot = required_string(object, "slotId")?;
454 let definition = self.store.definition_by_revision(revision)?;
455 ensure!(
456 definition
457 .workflow
458 .actor_slots
459 .iter()
460 .any(|value| value.id == slot),
461 "strategy_binding_slot_unknown"
462 );
463 self.store.remove_binding(
464 revision,
465 slot,
466 object.get("expectedRevision").and_then(Value::as_u64),
467 )?;
468 Ok(json!({"removed": true}))
469 }
470 "strategy.authorization.preview" => {
471 let revision = required_string(object, "revisionDigest")?;
472 self.admit_revision_identity(revision)?;
473 Ok(serde_json::to_value(
474 self.store.authorization_preview(revision)?,
475 )?)
476 }
477 "strategy.authorization.grant" => {
478 ensure!(
479 object.get("confirmed").and_then(Value::as_bool) == Some(true),
480 "permit_denied"
481 );
482 let revision = required_string(object, "revisionDigest")?;
483 self.admit_revision_identity(revision)?;
484 Ok(serde_json::to_value(self.store.grant_authorization(
485 revision,
486 required_string(object, "authorizationDigest")?,
487 )?)?)
488 }
489 "strategy.authorization.revoke" => {
490 let revision = required_string(object, "revisionDigest")?;
491 self.admit_revision_identity(revision)?;
492 self.store.revoke_authorization(revision)?;
493 Ok(json!({"revoked": true}))
494 }
495 "strategy.run.start" => {
496 self.require_actor_port()?;
497 let revision = required_string(object, "revisionDigest")?;
498 self.admit_revision_identity(revision)?;
499 let conversation_id = optional_string(object, "conversationId")?;
500 let cwd = optional_cwd(&self.ports, object)?;
501 let snapshot = self.store.start_run(
502 revision,
503 object.get("input").cloned().unwrap_or_else(|| json!({})),
504 required_string(object, "idempotencyKey")?,
505 if conversation_id.is_empty() {
506 None
507 } else {
508 Some(conversation_id)
509 },
510 cwd,
511 )?;
512 self.record_graph_usage(&snapshot, None)?;
513 let _ = self.transition.reconcile_pending();
514 let entry_turn = self.start_drive(&snapshot)?;
515 let mut value =
516 serde_json::to_value(self.store.projection_for_run(&snapshot.run_id)?)?;
517 if let Some(entry_turn) = entry_turn {
518 value["entryTurn"] = entry_turn;
519 }
520 Ok(value)
521 }
522 "strategy.run.inspect" => {
523 let run_id = required_string(object, "runId")?;
524 self.admit_run_identity(run_id)?;
525 Ok(serde_json::to_value(
526 self.store.projection_for_run(run_id)?,
527 )?)
528 }
529 "strategy.run.active" => {
530 let revision = required_string(object, "revisionDigest")?;
531 self.admit_revision_identity(revision)?;
532 let conversation_id = required_string(object, "conversationId")?;
533 match self
534 .store
535 .active_run_for_conversation(revision, conversation_id)?
536 {
537 Some(snapshot) => Ok(serde_json::to_value(
538 self.store.projection_for_run(&snapshot.run_id)?,
539 )?),
540 None => Ok(json!({"runId": null})),
541 }
542 }
543 "strategy.run.resume" => {
544 self.require_actor_port()?;
545 let run_id = required_string(object, "runId")?;
546 self.admit_run_identity(run_id)?;
547 let conversation_id = optional_string(object, "conversationId")?;
548 if !conversation_id.is_empty() {
549 self.store
550 .bind_conversation_if_absent(run_id, conversation_id)?;
551 }
552 let decision = parse_callback_decision(object)?;
553 let mut snapshot = self.store.run(run_id)?;
554 let mut decided = false;
555 if let Some((state_id, state_visit, kind)) = decision {
556 ensure!(
557 !snapshot.pending_callbacks.is_empty(),
558 "strategy_callback_stale"
559 );
560 snapshot = self.apply_run_event(
561 run_id,
562 ReducerEvent::CallbackDecision {
563 state_id,
564 state_visit,
565 decision: kind,
566 },
567 )?;
568 decided = true;
569 }
570 if !decided {
571 match snapshot.status {
572 StrategyRunStatus::AuthorizationRequired => {
573 let definition = self
574 .store
575 .definition_by_revision(&snapshot.definition_digest)?;
576 let authorization = definition
577 .authorization
578 .filter(|authorization| authorization.active)
579 .ok_or_else(|| anyhow!("authorization_required"))?;
580 if snapshot.commands.values().any(|command| {
581 command.kind == CommandKind::Authorization
582 && command.status == CommandStatus::Pending
583 }) {
584 self.apply_run_event(
585 run_id,
586 ReducerEvent::AuthorizationGranted {
587 semantics_digest: authorization.semantics_digest,
588 },
589 )?;
590 } else {
591 let command = snapshot
592 .commands
593 .values()
594 .find(|command| {
595 command.status == CommandStatus::Retryable
596 && command.failure_class
597 == Some(FailureClass::Authority)
598 })
599 .ok_or_else(|| anyhow!("run_not_retryable"))?;
600 self.apply_run_event(
601 run_id,
602 ReducerEvent::RetryRequested {
603 command_id: command.id.clone(),
604 },
605 )?;
606 }
607 }
608 StrategyRunStatus::RuntimeMissing | StrategyRunStatus::Retryable => {
609 let command = snapshot
610 .commands
611 .values()
612 .find(|command| command.status == CommandStatus::Retryable)
613 .ok_or_else(|| anyhow!("run_not_retryable"))?;
614 self.apply_run_event(
615 run_id,
616 ReducerEvent::RetryRequested {
617 command_id: command.id.clone(),
618 },
619 )?;
620 }
621 StrategyRunStatus::Waiting | StrategyRunStatus::Running => {}
622 _ => return Err(anyhow!("run_not_retryable")),
623 }
624 }
625 let snapshot = self.store.run(run_id)?;
626 let entry_turn = self.start_drive(&snapshot)?;
627 let mut value = serde_json::to_value(self.store.projection_for_run(run_id)?)?;
628 if let Some(entry_turn) = entry_turn {
629 value["entryTurn"] = entry_turn;
630 }
631 Ok(value)
632 }
633 "strategy.run.cancel" => {
634 let run_id = required_string(object, "runId")?;
635 self.admit_run_identity(run_id)?;
636 self.cancel_run(run_id, object)?;
637 let snapshot = self.store.run(run_id)?;
638 self.refresh_graph_usage(&snapshot, None);
639 Ok(serde_json::to_value(
640 self.store.projection_for_run(run_id)?,
641 )?)
642 }
643 "strategy.run.retry" => {
644 self.require_actor_port()?;
645 let run_id = required_string(object, "runId")?;
646 self.admit_run_identity(run_id)?;
647 let snapshot = self.store.run(run_id)?;
648 let command = snapshot
649 .commands
650 .values()
651 .find(|command| command.status == CommandStatus::Retryable)
652 .ok_or_else(|| anyhow!("run_not_retryable"))?;
653 self.apply_run_event(
654 run_id,
655 ReducerEvent::RetryRequested {
656 command_id: command.id.clone(),
657 },
658 )?;
659 let snapshot = self.store.run(run_id)?;
660 let entry_turn = self.start_drive(&snapshot)?;
661 let mut value = serde_json::to_value(self.store.projection_for_run(run_id)?)?;
662 if let Some(entry_turn) = entry_turn {
663 value["entryTurn"] = entry_turn;
664 }
665 Ok(value)
666 }
667 "strategy.assistant.workflow.execute" => {
668 self.require_actor_port()?;
669 let conversation_id = required_string(object, "conversationId")?;
670 let membership_id = required_string(object, "membershipId")?;
671 let field = "idempotencyKey";
672 let idempotency_key = required_string(object, field)?;
673 let workflow = object
674 .get("workflow")
675 .cloned()
676 .ok_or_else(|| anyhow!("invalid_request"))?;
677 let bindings: Vec<BindingValue> = serde_json::from_value(
678 object.get("bindings").cloned().unwrap_or_else(|| json!([])),
679 )?;
680 let filters: crate::CandidateFilters = serde_json::from_value(
681 object.get("filters").cloned().unwrap_or_else(|| json!({})),
682 )?;
683 let decision = parse_callback_decision(object)?;
684 if let Some(run_id) = self.store.run_id_by_idempotency_key(idempotency_key)? {
685 return self.replay_assistant_workflow(
686 &run_id,
687 conversation_id,
688 membership_id,
689 &workflow,
690 &bindings,
691 object.get("input").cloned().unwrap_or_else(|| json!({})),
692 decision,
693 );
694 }
695 ensure!(decision.is_none(), "invalid_request");
696 let selection_policy_revision =
701 required_string(object, "selectionPolicyRevision")?.to_owned();
702 let admitted = match self.preflight_assistant_workflow(
703 conversation_id,
704 membership_id,
705 &workflow,
706 &bindings,
707 &filters,
708 &selection_policy_revision,
709 ) {
710 Ok(admitted) => admitted,
711 Err(failure) => {
712 return Ok(json!({"accepted": false, "error": failure}));
713 }
714 };
715 match self.revalidate_assistant_admission(conversation_id, membership_id, &admitted)
716 {
717 Ok(()) => {}
718 Err(failure) => {
719 return Ok(json!({"accepted": false, "error": failure}));
720 }
721 }
722 let canonical = serde_json::to_vec(&admitted.definition)?;
723 let semantics_digest = sha256_hex(&canonical);
724 let revision_digest = admitted.receipt.workflow_digest.clone();
725 let input = object.get("input").cloned().unwrap_or_else(|| json!({}));
726 let (snapshot, _fresh) = self.store.admit_assistant_run(
727 &revision_digest,
728 &semantics_digest,
729 &admitted.definition,
730 &admitted.bindings,
731 input,
732 idempotency_key,
733 conversation_id,
734 membership_id,
735 admitted.receipt.route_receipt.clone(),
736 )?;
737 self.record_graph_usage(&snapshot, None)?;
738 self.start_drive(&snapshot)?;
739 self.wait_for_assistant_terminal(&snapshot.run_id, Some(&admitted.receipt))
740 }
741 "strategy.assistant.workflow.inspect" => {
742 let run_id = required_string(object, "runId")?;
743 let snapshot = self.admit_assistant_run_identity(run_id)?;
744 let mut value = self.assistant_run_projection(&snapshot)?;
745 if let Some(terminal) = terminal_projection(&value) {
746 value["terminal"] = terminal;
747 }
748 Ok(value)
749 }
750 "strategy.assistant.workflow.cancel" => {
751 let run_id = required_string(object, "runId")?;
752 self.admit_assistant_run_identity(run_id)?;
753 self.cancel_run(run_id, object)?;
754 let snapshot = self.store.run(run_id)?;
755 self.refresh_graph_usage(&snapshot, None);
756 let mut value = self.assistant_run_projection(&snapshot)?;
757 if let Some(terminal) = terminal_projection(&value) {
758 value["terminal"] = terminal;
759 }
760 Ok(value)
761 }
762 _ => Err(anyhow!("unsupported_action")),
763 }
764 }
765
766 fn preflight_assistant_workflow(
767 &self,
768 conversation_id: &str,
769 assistant_membership_id: &str,
770 workflow: &Value,
771 bindings: &[BindingValue],
772 filters: &crate::CandidateFilters,
773 selection_policy_revision: &str,
774 ) -> std::result::Result<AssistantPreflight, PreflightFailure> {
775 let store = licoup_conversation::ConversationStore::open(&self.portable_root)
776 .map_err(|_| assistant_state_failure("conversation_state_unavailable", None))?;
777 let conversation = store
778 .get(conversation_id)
779 .map_err(|_| assistant_state_failure("conversation_not_found", None))?;
780 if conversation.assistant_membership_id.as_deref() != Some(assistant_membership_id)
781 || !conversation.memberships.iter().any(|membership| {
782 membership.id == assistant_membership_id
783 && membership.status == licoup_conversation::MembershipStatus::Active
784 && membership.principal.kind == licoup_conversation::PrincipalKind::Agent
785 })
786 {
787 return Err(assistant_state_failure(
788 "graph_assistant_designation_rejected",
789 Some(assistant_membership_id),
790 ));
791 }
792 let profiles = store
793 .membership_profiles(conversation_id)
794 .map_err(|_| assistant_state_failure("conversation_state_unavailable", None))?;
795 let pairs = profiles
796 .into_iter()
797 .map(|(membership, intent)| {
798 let designated = membership.id == assistant_membership_id;
799 (membership, intent, designated)
800 })
801 .collect::<Vec<_>>();
802 let snapshots = crate::project_profile_snapshots(
803 conversation_id,
804 &pairs,
805 &self.ports.profile,
806 self.ports.model.as_ref(),
807 );
808 preflight_assistant_graph(
809 conversation_id,
810 assistant_membership_id,
811 workflow,
812 bindings,
813 &snapshots,
814 filters,
815 self.ports.model.as_ref(),
816 selection_policy_revision,
817 )
818 }
819
820 fn revalidate_assistant_admission(
824 &self,
825 conversation_id: &str,
826 assistant_membership_id: &str,
827 admitted: &AssistantPreflight,
828 ) -> std::result::Result<(), PreflightFailure> {
829 let store = licoup_conversation::ConversationStore::open(&self.portable_root)
830 .map_err(|_| assistant_state_failure("conversation_state_unavailable", None))?;
831 let conversation = store
832 .get(conversation_id)
833 .map_err(|_| assistant_state_failure("conversation_not_found", None))?;
834 if conversation.assistant_membership_id.as_deref() != Some(assistant_membership_id) {
835 return Err(assistant_state_failure(
836 "graph_route_stale",
837 Some(assistant_membership_id),
838 ));
839 }
840 let profiles = store
841 .membership_profiles(conversation_id)
842 .map_err(|_| assistant_state_failure("conversation_state_unavailable", None))?
843 .into_iter()
844 .map(|(membership, profile)| (membership.id.clone(), (membership, profile)))
845 .collect::<BTreeMap<_, _>>();
846 for (membership_id, expected_revision) in &admitted.profile_revisions {
847 let Some((membership, profile)) = profiles.get(membership_id) else {
848 return Err(assistant_state_failure(
849 "graph_route_stale",
850 Some(membership_id),
851 ));
852 };
853 if membership.status != licoup_conversation::MembershipStatus::Active
854 || membership.principal.kind != licoup_conversation::PrincipalKind::Agent
855 || profile.revision != *expected_revision
856 {
857 return Err(assistant_state_failure(
858 "graph_route_stale",
859 Some(membership_id),
860 ));
861 }
862 }
863 Ok(())
864 }
865
866 #[allow(clippy::too_many_arguments)]
867 fn replay_assistant_workflow(
868 &self,
869 run_id: &str,
870 conversation_id: &str,
871 assistant_membership_id: &str,
872 workflow: &Value,
873 bindings: &[BindingValue],
874 input: Value,
875 decision: Option<(String, u64, CallbackDecisionKind)>,
876 ) -> Result<Value> {
877 let snapshot = self.admit_assistant_run_identity(run_id)?;
878 ensure!(
879 snapshot.conversation_id.as_deref() == Some(conversation_id)
880 && snapshot.assistant_membership_id.as_deref() == Some(assistant_membership_id)
881 && snapshot.input == input,
882 "strategy_idempotency_conflict"
883 );
884 let submitted = compile_workflow_value(workflow)?.into_definition();
885 let stored = self
886 .store
887 .definition_by_revision(&snapshot.definition_digest)?;
888 ensure!(
889 submitted == stored.workflow,
890 "strategy_idempotency_conflict"
891 );
892 let mut submitted_bindings = bindings
893 .iter()
894 .map(|binding| {
895 (
896 binding.slot_id.as_str(),
897 binding.ordinal,
898 binding.value_id.as_str(),
899 binding.model.as_str(),
900 binding.reasoning_effort.as_str(),
901 )
902 })
903 .collect::<Vec<_>>();
904 submitted_bindings.sort();
905 let mut stored_bindings = stored
906 .bindings
907 .iter()
908 .map(|binding| {
909 (
910 binding.slot_id.as_str(),
911 binding.ordinal,
912 binding.value_id.as_str(),
913 binding.model.as_str(),
914 binding.reasoning_effort.as_str(),
915 )
916 })
917 .collect::<Vec<_>>();
918 stored_bindings.sort();
919 ensure!(
920 submitted_bindings.len() == stored_bindings.len()
921 && submitted_bindings
922 .iter()
923 .zip(&stored_bindings)
924 .all(|(submitted, stored)| {
925 submitted.0 == stored.0
926 && submitted.1 == stored.1
927 && submitted.2 == stored.2
928 && (submitted.3.is_empty() || submitted.3 == stored.3)
929 && submitted.4 == stored.4
930 }),
931 "strategy_idempotency_conflict"
932 );
933 let snapshot = match decision {
937 Some((state_id, state_visit, kind)) => self.apply_run_event(
938 run_id,
939 ReducerEvent::CallbackDecision {
940 state_id,
941 state_visit,
942 decision: kind,
943 },
944 )?,
945 None => snapshot,
946 };
947 if !assistant_run_terminal(snapshot.status) {
948 self.start_drive(&snapshot)?;
949 }
950 self.wait_for_assistant_terminal(run_id, None)
951 }
952
953 fn wait_for_assistant_terminal(
954 &self,
955 run_id: &str,
956 receipt: Option<&super::PreflightReceipt>,
957 ) -> Result<Value> {
958 loop {
959 let snapshot = self.admit_assistant_run_identity(run_id)?;
960 if assistant_run_terminal(snapshot.status) || !snapshot.pending_callbacks.is_empty() {
964 self.refresh_graph_usage(&snapshot, None);
965 let mut value = self.assistant_run_projection(&snapshot)?;
966 if let Some(receipt) = receipt {
967 value["preflight"] = json!({"accepted": true, "receipt": receipt});
968 }
969 if let Some(terminal) = terminal_projection(&value) {
970 value["terminal"] = terminal;
971 }
972 return Ok(value);
973 }
974 if !DriveReservation::is_active(run_id) {
975 self.start_drive(&snapshot)?;
976 }
977 std::thread::park_timeout(std::time::Duration::from_millis(20));
978 }
979 }
980
981 fn assistant_run_projection(&self, snapshot: &RunSnapshot) -> Result<Value> {
988 let generic = serde_json::to_value(self.store.projection_for_run(&snapshot.run_id)?)?;
989 let mut value = json!({
990 "schema": "licoup.assistant-workflow.run.v1",
991 "runId": snapshot.run_id,
992 "status": generic.get("status").cloned().unwrap_or(Value::Null),
993 "currentStates": generic.get("currentStates").cloned().unwrap_or_else(|| json!([])),
994 "neighborStates": generic.get("neighborStates").cloned().unwrap_or_else(|| json!([])),
995 "bindings": generic.get("bindings").cloned().unwrap_or_else(|| json!([])),
996 "historyCount": generic.get("historyCount").cloned().unwrap_or_else(|| json!(0)),
997 "conversationId": snapshot.conversation_id,
998 "assistantMembershipId": snapshot.assistant_membership_id,
999 "routeReceipt": snapshot.route_receipt,
1000 "pendingCallbacks": generic
1001 .get("pendingCallbacks")
1002 .cloned()
1003 .unwrap_or_else(|| json!([])),
1004 });
1005 if let Some(code) = generic.pointer("/diagnostic/code").and_then(Value::as_str) {
1006 value["diagnostic"] = json!({
1007 "code": code,
1008 "stage": "assistant-workflow/runtime",
1009 "retryable": false,
1010 "recoveryClass": "assistant-direct-or-new-graph",
1011 });
1012 }
1013 Ok(value)
1014 }
1015
1016 fn refresh_runtime_bindings(&self) -> Result<()> {
1017 for definition in self.store.list_definitions()? {
1018 self.bind_detected_runtimes(&definition.revision_digest)?;
1019 }
1020 Ok(())
1021 }
1022
1023 fn bind_detected_runtimes(&self, revision: &str) -> Result<()> {
1024 let definition = self.store.definition_by_revision(revision)?;
1025 for slot in definition
1026 .workflow
1027 .actor_slots
1028 .iter()
1029 .filter(|slot| slot.kind == BindingKind::Runtime)
1030 {
1031 let requirement = definition
1032 .workflow
1033 .runtimes
1034 .iter()
1035 .find(|runtime| runtime.id == slot.id)
1036 .ok_or_else(|| anyhow!("runtime_unavailable"))?;
1037 let current = definition
1038 .bindings
1039 .iter()
1040 .find(|binding| binding.slot_id == slot.id);
1041 if let Some(runtime_id) = self
1042 .ports
1043 .effect
1044 .compatible_runtime_id(requirement.kind.clone(), &requirement.version_requirement)
1045 {
1046 if current.is_none_or(|binding| binding.value_id != runtime_id) {
1047 self.store.update_binding(
1048 revision,
1049 &slot.id,
1050 &runtime_id,
1051 "",
1052 "",
1053 current.map(|binding| binding.revision),
1054 )?;
1055 }
1056 } else if let Some(binding) = current {
1057 self.store
1058 .remove_binding(revision, &slot.id, Some(binding.revision))?;
1059 }
1060 }
1061 Ok(())
1062 }
1063
1064 fn admit_revision_identity(&self, revision: &str) -> Result<()> {
1065 let definition = self.store.definition_by_revision(revision)?;
1066 ensure!(
1067 !definition
1068 .summary
1069 .definition_id
1070 .starts_with(ASSISTANT_TEMPORARY_DEFINITION_PREFIX),
1071 "strategy_definition_not_found"
1072 );
1073 Ok(())
1074 }
1075
1076 fn admit_run_identity(&self, run_id: &str) -> Result<()> {
1077 let snapshot = self.store.run(run_id)?;
1078 self.admit_revision_identity(&snapshot.definition_digest)
1079 }
1080
1081 fn admit_assistant_run_identity(&self, run_id: &str) -> Result<RunSnapshot> {
1082 let snapshot = self.store.run(run_id)?;
1083 let definition = self
1084 .store
1085 .definition_by_revision(&snapshot.definition_digest)?;
1086 ensure!(
1087 definition
1088 .summary
1089 .definition_id
1090 .starts_with(ASSISTANT_TEMPORARY_DEFINITION_PREFIX)
1091 && snapshot.assistant_membership_id.is_some()
1092 && snapshot.route_receipt.is_some(),
1093 "strategy_run_not_found"
1094 );
1095 Ok(snapshot)
1096 }
1097
1098 fn require_actor_port(&self) -> Result<&Arc<ActorTurnPort>> {
1102 self.actor_port
1103 .as_ref()
1104 .ok_or_else(|| anyhow!(licoup_conversation::PERSISTENT_TRANSPORT_REQUIRED))
1105 }
1106
1107 fn register_entry_turn(&self, snapshot: &RunSnapshot) -> Result<Option<EntryTurnRegistration>> {
1114 let Some(port) = self.actor_port.as_ref() else {
1115 return Ok(None);
1116 };
1117 let conversation_id = snapshot
1118 .conversation_id
1119 .as_deref()
1120 .filter(|value| !value.is_empty());
1121 let Some(conversation_id) = conversation_id else {
1122 return Ok(None);
1123 };
1124 let definition = self
1125 .store
1126 .definition_by_revision(&snapshot.definition_digest)?;
1127 let Some(slot) = definition
1128 .workflow
1129 .actor_slots
1130 .iter()
1131 .find(|slot| slot.kind == BindingKind::Actor && slot.entry)
1132 else {
1133 return Ok(None);
1134 };
1135 let entry_command = snapshot.commands.values().find(|command| {
1136 matches!(command.kind, CommandKind::Actor | CommandKind::WorksetItem)
1137 && command.binding_id.as_deref() == Some(slot.id.as_str())
1138 && command.state_visit == 1
1139 && command.binding_ordinal == 0
1140 });
1141 let (input, resume_session_id) = match entry_command {
1142 Some(command) => {
1143 if command.status != CommandStatus::Pending {
1144 return Ok(None);
1146 }
1147 (command.input.clone(), command.resume_session_id.clone())
1148 }
1149 None => {
1150 let workflow = compile_workflow(definition.workflow.clone())?;
1151 let Some(state) = entry_state_for_start(&workflow, &slot.id) else {
1152 return Ok(None);
1153 };
1154 (
1155 effect_input_for(&workflow, &state.id, snapshot.input.clone())?,
1156 None,
1157 )
1158 }
1159 };
1160 let Some(binding) = definition
1161 .bindings
1162 .iter()
1163 .find(|binding| binding.slot_id == slot.id && binding.ordinal == 0)
1164 else {
1165 return Ok(None);
1166 };
1167 let target = group_actor_target(conversation_id, &binding.value_id, &self.portable_root)
1168 .map_err(|_| anyhow!("strategy_actor_dispatch_failed"))?;
1169 let params = group_actor_params(
1170 conversation_id,
1171 &target.membership_id,
1172 &target.agent_id,
1173 binding,
1174 &input,
1175 &snapshot.run_id,
1176 resume_session_id.as_deref(),
1177 snapshot.cwd.as_deref(),
1178 )
1179 .map_err(|_| anyhow!("strategy_actor_dispatch_failed"))?;
1180 let handle = (port.open)(¶ms).map_err(|_| anyhow!("strategy_actor_dispatch_failed"))?;
1181 let projection = json!({
1182 "turnHandle": handle,
1183 "conversationId": conversation_id,
1184 "membershipId": target.membership_id,
1185 "agent": target.agent_id,
1186 });
1187 Ok(Some(EntryTurnRegistration {
1188 slot_id: slot.id.clone(),
1189 handle,
1190 params,
1191 projection,
1192 port: Arc::clone(port),
1193 armed: true,
1194 }))
1195 }
1196
1197 fn validate_binding(&self, revision: &str, slot_id: &str, value_id: &str) -> Result<()> {
1198 let definition = self.store.definition_by_revision(revision)?;
1199 let slot = definition
1200 .workflow
1201 .actor_slots
1202 .iter()
1203 .find(|slot| slot.id == slot_id)
1204 .ok_or_else(|| anyhow!("binding_incomplete"))?;
1205 match slot.kind {
1206 BindingKind::Runtime => {
1207 let requirement = definition
1208 .workflow
1209 .runtimes
1210 .iter()
1211 .find(|runtime| runtime.id == slot.id)
1212 .ok_or_else(|| anyhow!("runtime_unavailable"))?;
1213 self.ports.effect.resolve_runtime(
1214 value_id,
1215 requirement.kind.clone(),
1216 &requirement.version_requirement,
1217 )?;
1218 }
1219 BindingKind::Actor => {
1220 self.ports.effect.actor_fingerprint(value_id, "", "")?;
1221 let capabilities = self
1222 .ports
1223 .effect
1224 .actor_capabilities(value_id)
1225 .map_err(|_| anyhow!("runtime_unavailable"))?;
1226 ensure!(
1227 capabilities.get("ok").and_then(Value::as_bool) == Some(true),
1228 "runtime_unavailable"
1229 );
1230 }
1231 BindingKind::Workspace => {
1232 ensure!(value_id.starts_with("workspace-"), "binding_incomplete");
1233 }
1234 }
1235 Ok(())
1236 }
1237
1238 fn record_graph_usage(
1242 &self,
1243 snapshot: &RunSnapshot,
1244 settled: Option<(&RunCommand, &Value)>,
1245 ) -> Result<()> {
1246 let definition = self
1247 .store
1248 .definition_by_revision(&snapshot.definition_digest)?;
1249 let status = wire_enum(snapshot.status)?;
1250 self.ports.usage.begin_graph_run(&json!({
1251 "stateRoot": self.portable_root,
1252 "runId": snapshot.run_id,
1253 "revisionDigest": snapshot.definition_digest,
1254 "conversationId": snapshot.conversation_id,
1255 "assistantMembershipId": snapshot.assistant_membership_id,
1256 "status": status,
1257 }))?;
1258 for command in snapshot.commands.values() {
1259 let binding = command.binding_id.as_deref().and_then(|slot_id| {
1260 definition.bindings.iter().find(|binding| {
1261 binding.slot_id == slot_id && binding.ordinal == command.binding_ordinal
1262 })
1263 });
1264 let (membership_id, agent_id) = match (
1265 snapshot.conversation_id.as_deref(),
1266 binding,
1267 matches!(command.kind, CommandKind::Actor | CommandKind::WorksetItem),
1268 ) {
1269 (Some(conversation_id), Some(binding), true) => {
1270 let target = group_actor_target(
1271 conversation_id,
1272 &binding.value_id,
1273 &self.portable_root,
1274 )?;
1275 (Some(target.membership_id), Some(target.agent_id))
1276 }
1277 (None, Some(binding), true) => (None, Some(binding.value_id.clone())),
1278 _ => (None, None),
1279 };
1280 let usage = settled
1281 .filter(|(settled_command, _)| settled_command.id == command.id)
1282 .and_then(|(_, output)| numeric_usage_projection(output));
1283 self.ports.usage.record_graph_command(&json!({
1284 "stateRoot": self.portable_root,
1285 "runId": snapshot.run_id,
1286 "commandId": command.id,
1287 "stateId": command.state_id,
1288 "membershipId": membership_id,
1289 "kind": wire_enum(command.kind)?,
1290 "status": wire_enum(command.status)?,
1291 "attempt": command.attempt,
1292 "agentId": agent_id,
1293 "model": binding.map(|binding| binding.model.as_str()).filter(|model| !model.is_empty()),
1294 "usage": usage,
1295 }))?;
1296 }
1297 Ok(())
1298 }
1299
1300 fn refresh_graph_usage(&self, snapshot: &RunSnapshot, settled: Option<(&RunCommand, &Value)>) {
1308 let _ = self.record_graph_usage(snapshot, settled);
1309 }
1310
1311 fn start_drive(&self, snapshot: &RunSnapshot) -> Result<Option<Value>> {
1312 let _ = self.transition.reconcile_pending();
1313 self.record_graph_usage(snapshot, None)?;
1316 let Some(reservation) = DriveReservation::acquire(&snapshot.run_id) else {
1317 return Ok(None);
1318 };
1319 let assistant_owned = snapshot.assistant_membership_id.is_some();
1320 let entry = match self.register_entry_turn(snapshot) {
1321 Ok(entry) => entry,
1322 Err(error) => {
1323 self.settle_start_failure(snapshot, &error.to_string())?;
1324 return if assistant_owned {
1325 Ok(None)
1326 } else {
1327 Err(error)
1328 };
1329 }
1330 };
1331 let projection = entry
1332 .as_ref()
1333 .map(|registration| registration.projection.clone());
1334 let run_id = snapshot.run_id.clone();
1335 let service = self.clone();
1336 let spawned = std::thread::Builder::new()
1337 .name("strategy-drive".to_owned())
1338 .spawn(move || {
1339 let _reservation = reservation;
1340 if service.drive_run(&run_id, entry).is_err() {
1341 let _ = service.settle_drive_failure(&run_id);
1342 }
1343 });
1344 if spawned.is_err() {
1345 self.settle_start_failure(snapshot, "strategy_run_start_failed")?;
1346 if assistant_owned {
1347 return Ok(None);
1348 }
1349 return Err(anyhow!("strategy_run_start_failed"));
1350 }
1351 Ok(projection)
1352 }
1353
1354 fn settle_start_failure(&self, snapshot: &RunSnapshot, message: &str) -> Result<()> {
1355 let Some(command) = snapshot.commands.values().find(|command| {
1356 matches!(command.kind, CommandKind::Actor | CommandKind::WorksetItem)
1357 && command.status == CommandStatus::Pending
1358 }) else {
1359 return self.settle_drive_failure(&snapshot.run_id);
1360 };
1361 let (class, code) = classify_effect_error(message);
1362 let updated = self.apply_run_event(
1363 &snapshot.run_id,
1364 ReducerEvent::AssistantEffectFailed {
1365 command_id: command.id.clone(),
1366 attempt_token: command.attempt_token.clone(),
1367 class,
1368 code: code.to_owned(),
1369 },
1370 )?;
1371 self.refresh_graph_usage(&updated, None);
1372 Ok(())
1373 }
1374
1375 fn drive_run(&self, run_id: &str, entry: Option<EntryTurnRegistration>) -> Result<()> {
1376 let result = self.drive_run_inner(run_id, entry);
1377 unmark_run_cancelled(run_id);
1378 result
1379 }
1380
1381 fn drive_run_inner(
1382 &self,
1383 run_id: &str,
1384 mut entry: Option<EntryTurnRegistration>,
1385 ) -> Result<()> {
1386 self.recover_expired_commands(run_id)?;
1391 let _ = self.transition.reconcile_pending();
1392 let recovered = self.store.run(run_id)?;
1393 let assistant_owned = recovered.assistant_membership_id.is_some();
1394 let mut executed = 0usize;
1395 'drive: while executed < MAX_DRIVE_EFFECTS_PER_CALL {
1396 if !assistant_owned {
1397 self.recover_persisted_effects(run_id)?;
1398 }
1399 let snapshot = self.store.run(run_id)?;
1400 if snapshot.status == StrategyRunStatus::AuthorizationRequired {
1401 let definition = self
1402 .store
1403 .definition_by_revision(&snapshot.definition_digest)?;
1404 let Some(authorization) = definition
1405 .authorization
1406 .filter(|authorization| authorization.active)
1407 else {
1408 break;
1409 };
1410 if snapshot.commands.values().any(|command| {
1411 command.kind == CommandKind::Authorization
1412 && command.status == CommandStatus::Pending
1413 }) {
1414 self.apply_run_event(
1415 run_id,
1416 ReducerEvent::AuthorizationGranted {
1417 semantics_digest: authorization.semantics_digest,
1418 },
1419 )?;
1420 } else if let Some(command) = snapshot.commands.values().find(|command| {
1421 command.status == CommandStatus::Retryable
1422 && command.failure_class == Some(FailureClass::Authority)
1423 }) {
1424 self.apply_run_event(
1425 run_id,
1426 ReducerEvent::RetryRequested {
1427 command_id: command.id.clone(),
1428 },
1429 )?;
1430 } else {
1431 break;
1432 }
1433 executed = executed.saturating_add(1);
1434 continue;
1435 }
1436 let mut commands = Vec::new();
1437 let capacity = licoup_workflow::MAX_ACTIVE_EFFECTS
1438 .min(MAX_DRIVE_EFFECTS_PER_CALL.saturating_sub(executed));
1439 for index in 0..capacity {
1440 let claimant = format!(
1441 "scheduler-{}-{index}",
1442 &run_id.chars().take(24).collect::<String>()
1443 );
1444 let Some(command) = self.store.claim_next_command(
1445 run_id,
1446 &claimant,
1447 now_ms().saturating_add(EFFECT_LEASE_DURATION_MS),
1448 )?
1449 else {
1450 break;
1451 };
1452 let live =
1456 LiveTurnRegistration::reserve(run_id, &command.id, &command.attempt_token);
1457 self.apply_run_event(
1458 run_id,
1459 ReducerEvent::CommandStarted {
1460 command_id: command.id.clone(),
1461 attempt_token: command.attempt_token.clone(),
1462 },
1463 )?;
1464 commands.push((command, claimant, live));
1465 }
1466 if commands.is_empty() {
1467 break;
1468 }
1469 let mut outcomes = std::thread::scope(|scope| -> Result<Vec<_>> {
1470 let (sender, receiver) = std::sync::mpsc::channel();
1471 let leases = commands
1472 .iter()
1473 .map(|(command, claimant, _)| (command.id.clone(), claimant.clone()))
1474 .collect::<Vec<_>>();
1475 for (command, claimant, live) in commands {
1476 let registration = if entry
1477 .as_ref()
1478 .is_some_and(|registration| registration.matches(&command))
1479 {
1480 entry.take()
1481 } else {
1482 None
1483 };
1484 let service = self.clone();
1485 let run_id = run_id.to_owned();
1486 let sender = sender.clone();
1487 scope.spawn(move || {
1488 let result = service.execute_command(
1489 &run_id,
1490 &command,
1491 &claimant,
1492 registration,
1493 live,
1494 );
1495 let _ = sender.send((command, result));
1496 });
1497 }
1498 drop(sender);
1499 let mut outcomes = Vec::with_capacity(leases.len());
1500 while outcomes.len() < leases.len() {
1501 match receiver.recv_timeout(std::time::Duration::from_secs(30)) {
1502 Ok(outcome) => outcomes.push(outcome),
1503 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
1504 let lease_until = now_ms().saturating_add(EFFECT_LEASE_DURATION_MS);
1505 for (command_id, claimant) in &leases {
1506 self.store.renew_command_lease(
1507 command_id,
1508 claimant,
1509 lease_until,
1510 )?;
1511 }
1512 }
1513 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
1514 return Err(anyhow!("strategy_effect_thread_failed"));
1515 }
1516 }
1517 }
1518 Ok(outcomes)
1519 })?;
1520 executed = executed.saturating_add(outcomes.len());
1521 outcomes.sort_by(|left, right| left.0.id.cmp(&right.0.id));
1522 let cancellation = cancellation_pending(run_id).then(|| self.store.run(run_id));
1527 let claimable = |command: &RunCommand| match cancellation.as_ref() {
1528 None => true,
1529 Some(Ok(current)) => current.commands.get(&command.id).is_some_and(|command| {
1530 matches!(
1531 command.status,
1532 CommandStatus::Pending | CommandStatus::Claimed | CommandStatus::Running
1533 )
1534 }),
1535 Some(Err(_)) => true,
1536 };
1537 let mut assistant_failures = Vec::new();
1538 for (command, result) in outcomes {
1539 if !claimable(&command) {
1540 continue;
1541 }
1542 match result {
1543 Ok((output, group_streamed)) => {
1544 if let Some((class, code)) = actor_output_failure(&command, &output) {
1545 if assistant_owned {
1546 assistant_failures.push((
1547 command,
1548 class,
1549 code.to_owned(),
1550 Some(output),
1551 ));
1552 continue;
1553 }
1554 let updated = self.apply_run_event(
1555 run_id,
1556 ReducerEvent::CommandFailed {
1557 command_id: command.id.clone(),
1558 attempt_token: command.attempt_token.clone(),
1559 class,
1560 code: code.into(),
1561 },
1562 )?;
1563 self.refresh_graph_usage(&updated, None);
1564 self.recover_failed_effect(run_id, &command.id)?;
1565 continue;
1566 }
1567 let updated = self.apply_run_event(
1568 run_id,
1569 ReducerEvent::CommandSucceeded {
1570 command_id: command.id.clone(),
1571 attempt_token: command.attempt_token.clone(),
1572 output: output.clone(),
1573 },
1574 )?;
1575 self.refresh_graph_usage(&updated, Some((&command, &output)));
1576 if !group_streamed {
1577 let _ = self.project_membership_event(run_id, &command, &output);
1578 }
1579 }
1580 Err(error) => {
1581 let (class, code) = classify_effect_error(&error.to_string());
1582 if assistant_owned {
1583 assistant_failures.push((command, class, code.to_owned(), None));
1584 continue;
1585 }
1586 let updated = self.apply_run_event(
1587 run_id,
1588 ReducerEvent::CommandFailed {
1589 command_id: command.id.clone(),
1590 attempt_token: command.attempt_token.clone(),
1591 class,
1592 code: code.into(),
1593 },
1594 )?;
1595 self.refresh_graph_usage(&updated, None);
1596 self.recover_failed_effect(run_id, &command.id)?;
1597 }
1598 }
1599 }
1600 if !assistant_failures.is_empty() {
1601 for (command, class, code, output) in assistant_failures {
1602 let updated = self.apply_run_event(
1603 run_id,
1604 ReducerEvent::AssistantEffectFailed {
1605 command_id: command.id.clone(),
1606 attempt_token: command.attempt_token.clone(),
1607 class,
1608 code,
1609 },
1610 )?;
1611 self.refresh_graph_usage(
1612 &updated,
1613 output.as_ref().map(|output| (&command, output)),
1614 );
1615 }
1616 break 'drive;
1617 }
1618 }
1619 Ok(())
1620 }
1621
1622 fn settle_drive_failure(&self, run_id: &str) -> Result<()> {
1623 let snapshot = self.store.run(run_id)?;
1624 if assistant_run_terminal(snapshot.status) {
1625 return Ok(());
1626 }
1627 let updated = self.apply_run_event(
1628 run_id,
1629 ReducerEvent::AssistantDriveFailed {
1630 code: "assistant_drive_outcome_unknown".to_owned(),
1631 },
1632 )?;
1633 self.refresh_graph_usage(&updated, None);
1634 Ok(())
1635 }
1636
1637 fn recover_expired_commands(&self, run_id: &str) -> Result<()> {
1638 while self.store.recover_next_expired_command(run_id)? {}
1639 Ok(())
1640 }
1641
1642 fn recover_persisted_effects(&self, run_id: &str) -> Result<()> {
1643 loop {
1644 let snapshot = self.store.run(run_id)?;
1645 let definition = self
1646 .store
1647 .definition_by_revision(&snapshot.definition_digest)?;
1648 let workflow = compile_workflow(definition.workflow.clone())?;
1649 let candidate = snapshot.commands.values().find(|command| {
1650 matches!(command.kind, CommandKind::Actor | CommandKind::WorksetItem)
1651 && ((command.status == CommandStatus::Retryable
1652 && command.failure_class == Some(FailureClass::Transient))
1653 || fallback_reason(&workflow, &snapshot, command).is_some())
1654 });
1655 let Some(command_id) = candidate.map(|command| command.id.clone()) else {
1656 return Ok(());
1657 };
1658 ensure!(
1659 self.recover_failed_effect(run_id, &command_id)?,
1660 "strategy_recovery_state_conflict"
1661 );
1662 }
1663 }
1664
1665 fn execute_command(
1666 &self,
1667 run_id: &str,
1668 command: &RunCommand,
1669 claimant: &str,
1670 registration: Option<EntryTurnRegistration>,
1671 mut live: LiveTurnRegistration,
1672 ) -> Result<(Value, bool)> {
1673 let snapshot = self.store.run(run_id)?;
1674 let definition = self
1675 .store
1676 .definition_by_revision(&snapshot.definition_digest)?;
1677 let recheck_ctx = super::evolution::EffectRecheckContext::for_run_command(
1683 run_id,
1684 &snapshot,
1685 &definition,
1686 command,
1687 claimant,
1688 );
1689 super::evolution::recheck_before_effect(&recheck_ctx)
1690 .map_err(|failure| anyhow!(failure.code()))?;
1691 let authorization = definition
1692 .authorization
1693 .as_ref()
1694 .ok_or_else(|| anyhow!("authorization_required"))?;
1695 match command.kind {
1696 CommandKind::Actor | CommandKind::WorksetItem => {
1697 let binding = binding_for(
1698 &definition,
1699 command
1700 .binding_id
1701 .as_deref()
1702 .ok_or_else(|| anyhow!("binding_incomplete"))?,
1703 command.binding_ordinal,
1704 )?;
1705 let fingerprint_value_id = match snapshot.conversation_id.as_deref() {
1706 Some(conversation_id) if !conversation_id.is_empty() => {
1707 group_actor_target(conversation_id, &binding.value_id, &self.portable_root)?
1708 .agent_id
1709 }
1710 _ => binding.value_id.clone(),
1711 };
1712 let fingerprint = self.ports.effect.actor_fingerprint(
1713 &fingerprint_value_id,
1714 &binding.model,
1715 &binding.reasoning_effort,
1716 )?;
1717 self.store.authorize_effect(
1718 run_id,
1719 &command.id,
1720 &command.attempt_token,
1721 &authorization.authorization_digest,
1722 claimant,
1723 now_ms().saturating_add(EFFECT_LEASE_DURATION_MS),
1724 )?;
1725 let mut permit = StrategyEffectPermit::issue(
1726 &command.id,
1727 &authorization.authorization_digest,
1728 &fingerprint,
1729 )?;
1730 self.execute_actor_into_group(
1731 run_id,
1732 command,
1733 &authorization.authorization_digest,
1734 binding,
1735 &mut permit,
1736 snapshot.cwd.as_deref(),
1737 snapshot.conversation_id.as_deref(),
1738 registration,
1739 &fingerprint,
1740 &mut live,
1741 )
1742 }
1743 CommandKind::Script => {
1744 let requirement_id = command
1745 .runtime_id
1746 .as_deref()
1747 .ok_or_else(|| anyhow!("runtime_unavailable"))?;
1748 let requirement = definition
1749 .workflow
1750 .runtimes
1751 .iter()
1752 .find(|requirement| requirement.id == requirement_id)
1753 .ok_or_else(|| anyhow!("runtime_unavailable"))?;
1754 let runtime_id = &binding_for(&definition, requirement_id, 0)?.value_id;
1755 let runtime = self.ports.effect.resolve_runtime(
1756 runtime_id,
1757 requirement.kind.clone(),
1758 &requirement.version_requirement,
1759 )?;
1760 let revision_content = self.importer.verified_revision_content(
1761 &definition.summary.revision_digest,
1762 &definition.summary.semantics_digest,
1763 )?;
1764 self.store.authorize_effect(
1765 run_id,
1766 &command.id,
1767 &command.attempt_token,
1768 &authorization.authorization_digest,
1769 claimant,
1770 now_ms().saturating_add(EFFECT_LEASE_DURATION_MS),
1771 )?;
1772 let mut permit = StrategyEffectPermit::issue(
1773 &command.id,
1774 &authorization.authorization_digest,
1775 runtime.fingerprint(),
1776 )?;
1777 let runtime_state = self
1778 .portable_root
1779 .join("client-state")
1780 .join("adaptive-flywheel")
1781 .join("runtime")
1782 .join(run_id);
1783 self.ports
1784 .effect
1785 .execute_script(
1786 command,
1787 &authorization.authorization_digest,
1788 &runtime,
1789 &revision_content,
1790 &runtime_state,
1791 &mut permit,
1792 )
1793 .map(|value| (value, false))
1794 }
1795 CommandKind::Authorization => Err(anyhow!("authorization_required")),
1796 }
1797 }
1798
1799 fn recover_failed_effect(&self, run_id: &str, command_id: &str) -> Result<bool> {
1800 let snapshot = self.store.run(run_id)?;
1801 let current = snapshot
1802 .commands
1803 .get(command_id)
1804 .cloned()
1805 .ok_or_else(|| anyhow!("strategy_callback_stale"))?;
1806 if !matches!(current.kind, CommandKind::Actor | CommandKind::WorksetItem) {
1807 return Ok(false);
1808 }
1809 if current.status == CommandStatus::Retryable
1810 && current.failure_class == Some(FailureClass::Transient)
1811 {
1812 self.apply_run_event(
1813 run_id,
1814 ReducerEvent::RetryRequested {
1815 command_id: current.id,
1816 },
1817 )?;
1818 return Ok(true);
1819 }
1820 let Some(slot_id) = current.binding_id.as_deref() else {
1821 return Ok(false);
1822 };
1823 let definition = self
1824 .store
1825 .definition_by_revision(&snapshot.definition_digest)?;
1826 let workflow = compile_workflow(definition.workflow.clone())?;
1827 let Some(reason) = fallback_reason(&workflow, &snapshot, ¤t) else {
1828 return Ok(false);
1829 };
1830 let next_ordinal = current.binding_ordinal.saturating_add(1);
1831 let next = definition
1832 .bindings
1833 .iter()
1834 .find(|binding| binding.slot_id == slot_id && binding.ordinal == next_ordinal)
1835 .ok_or_else(|| anyhow!("binding_incomplete"))?;
1836 let previous = definition
1837 .bindings
1838 .iter()
1839 .find(|binding| {
1840 binding.slot_id == slot_id && binding.ordinal == current.binding_ordinal
1841 })
1842 .ok_or_else(|| anyhow!("binding_incomplete"))?;
1843 let mut facts = current.input.clone();
1844 if let Some(session) = current.resume_session_id.as_deref()
1845 && let Value::Object(ref mut object) = facts
1846 {
1847 object
1848 .entry("nativeSessionId")
1849 .or_insert_with(|| Value::String(session.to_owned()));
1850 }
1851 self.apply_run_event(
1852 run_id,
1853 ReducerEvent::FallbackIssued {
1854 failed_command_id: current.id,
1855 next_ordinal,
1856 locator: self.ports.effect.predecessor_locator(&facts),
1857 from_value_id: previous.value_id.clone(),
1858 to_value_id: next.value_id.clone(),
1859 reason: reason.into(),
1860 attempts: current.attempt,
1861 },
1862 )?;
1863 Ok(true)
1864 }
1865
1866 fn execute_actor_into_group(
1867 &self,
1868 run_id: &str,
1869 command: &RunCommand,
1870 authorization_digest: &str,
1871 binding: &BindingValue,
1872 permit: &mut StrategyEffectPermit,
1873 cwd: Option<&str>,
1874 conversation_id: Option<&str>,
1875 registration: Option<EntryTurnRegistration>,
1876 expected_fingerprint: &str,
1877 live: &mut LiveTurnRegistration,
1878 ) -> Result<(Value, bool)> {
1879 let Some(conversation_id) = conversation_id.filter(|value| !value.is_empty()) else {
1880 return self
1881 .ports
1882 .effect
1883 .execute_actor(command, authorization_digest, binding, permit, cwd)
1884 .map(|value| (value, false));
1885 };
1886 let target = group_actor_target(conversation_id, &binding.value_id, &self.portable_root)?;
1890 let Some(port) = self.actor_port.as_ref() else {
1891 return Err(anyhow!("strategy_actor_dispatch_failed"));
1892 };
1893 let params = match registration.as_ref() {
1894 Some(registration) => registration.params.clone(),
1895 None => group_actor_params(
1896 conversation_id,
1897 &target.membership_id,
1898 &target.agent_id,
1899 binding,
1900 &command.input,
1901 run_id,
1902 command.resume_session_id.as_deref(),
1903 cwd,
1904 )?,
1905 };
1906 permit.consume(command, authorization_digest, expected_fingerprint)?;
1907 let registration = match registration {
1908 Some(registration) => registration,
1909 None => {
1910 let handle = (port.open)(¶ms)
1911 .map_err(|error| anyhow!("strategy_actor_dispatch_failed:{error}"))?;
1912 EntryTurnRegistration::ad_hoc(handle, params.clone(), Arc::clone(port))
1913 }
1914 };
1915 let (handle, params) = registration.into_run();
1916 live.attach(&handle);
1919 match (port.run)(&handle, ¶ms) {
1920 Ok(value) => {
1921 if actor_output_failure(command, &value).is_some() {
1922 return Err(anyhow!("strategy_actor_failed"));
1923 }
1924 Ok((value, true))
1925 }
1926 Err(error) => Err(anyhow!("strategy_actor_dispatch_failed:{error}")),
1927 }
1928 }
1929
1930 fn cancel_run(&self, run_id: &str, object: &Map<String, Value>) -> Result<()> {
1938 let correlation_id = object
1939 .get("correlationId")
1940 .and_then(Value::as_str)
1941 .map(str::to_owned)
1942 .unwrap_or_else(|| self.ports.effect.new_correlation_id());
1943 let cancelled = self
1944 .store
1945 .apply_event(run_id, ReducerEvent::CancelRequested)?;
1946 mark_run_cancelled(run_id);
1947 let in_flight = cancelled
1948 .commands
1949 .values()
1950 .filter(|command| command.status == CommandStatus::CancelRequested)
1951 .map(|command| (command.id.clone(), command.attempt_token.clone()))
1952 .collect::<Vec<_>>();
1953 let mut unacknowledged = 0usize;
1954 for (command_id, attempt_token) in in_flight {
1955 if self.command_owner_acknowledges(run_id, &command_id, &attempt_token) {
1956 self.apply_run_event(
1957 run_id,
1958 ReducerEvent::CancellationAcknowledged {
1959 command_id,
1960 attempt_token,
1961 },
1962 )?;
1963 } else {
1964 unacknowledged = unacknowledged.saturating_add(1);
1965 self.apply_run_event(
1966 run_id,
1967 ReducerEvent::CancellationUnknown {
1968 command_id,
1969 attempt_token,
1970 },
1971 )?;
1972 }
1973 }
1974 self.ports.effect.record_run_stop(
1975 &self.portable_root,
1976 &correlation_id,
1977 unacknowledged == 0,
1978 )?;
1979 Ok(())
1980 }
1981
1982 fn command_owner_acknowledges(
1986 &self,
1987 run_id: &str,
1988 command_id: &str,
1989 attempt_token: &str,
1990 ) -> bool {
1991 let Some(port) = self.actor_port.as_ref() else {
1992 return false;
1993 };
1994 let deadline = std::time::Instant::now() + CANCEL_OWNER_LOOKUP_BOUND;
1995 loop {
1996 match live_turn_owner(run_id, command_id, attempt_token) {
1997 LiveTurnOwner::Ready(handle) => {
1998 return matches!((port.cancel)(&handle), TurnCancelDisposition::Accepted);
1999 }
2000 LiveTurnOwner::Absent => return false,
2003 LiveTurnOwner::Preparing => {
2004 if std::time::Instant::now() >= deadline {
2005 return false;
2006 }
2007 std::thread::sleep(CANCEL_OWNER_LOOKUP_STEP);
2008 }
2009 }
2010 }
2011 }
2012
2013 fn apply_run_event(&self, run_id: &str, event: ReducerEvent) -> Result<RunSnapshot> {
2017 self.transition.apply_event(run_id, event)
2018 }
2019
2020 fn report_master_gates(
2031 portable_root: &Path,
2032 assistant_wake: Option<&AssistantWakePort>,
2033 before: Option<&RunSnapshot>,
2034 after: &RunSnapshot,
2035 ) -> Result<()> {
2036 let Some(conversation_id) = after
2037 .conversation_id
2038 .as_deref()
2039 .filter(|value| !value.is_empty())
2040 else {
2041 return Ok(());
2042 };
2043 let known: BTreeSet<(&str, u64)> = before
2044 .map(|snapshot| {
2045 snapshot
2046 .pending_callbacks
2047 .iter()
2048 .map(|pending| (pending.state_id.as_str(), pending.state_visit))
2049 .collect()
2050 })
2051 .unwrap_or_default();
2052 let newly_parked: Vec<&PendingCallback> = after
2053 .pending_callbacks
2054 .iter()
2055 .filter(|pending| !known.contains(&(pending.state_id.as_str(), pending.state_visit)))
2056 .collect();
2057 let failure_terminal = after.assistant_membership_id.is_none()
2058 && failure_terminal_status(after.status)
2059 && !before.is_some_and(|snapshot| failure_terminal_status(snapshot.status));
2060 let flow_settled: Vec<(&String, u64)> = if newly_parked.is_empty() && !failure_terminal {
2065 after
2066 .completed_states
2067 .iter()
2068 .filter(|state| {
2069 !before.is_some_and(|snapshot| snapshot.completed_states.contains(*state))
2070 })
2071 .map(|state| (state, after.state_visits.get(state).copied().unwrap_or(0)))
2072 .collect()
2073 } else {
2074 Vec::new()
2075 };
2076 if newly_parked.is_empty() && flow_settled.is_empty() && !failure_terminal {
2077 return Ok(());
2078 }
2079 let answer_channel = if after.assistant_membership_id.is_some() {
2080 "lico_assistant_workflow_execute"
2081 } else {
2082 "strategy.run.resume"
2083 };
2084 let enricher = super::evolution::CallbackEvolutionEnricher::new(portable_root);
2085 for pending in newly_parked {
2086 let evolution = enricher.enrich_callback_request(after, pending, answer_channel);
2087 Self::append_master_report(
2088 portable_root,
2089 assistant_wake,
2090 conversation_id,
2091 after.assistant_membership_id.as_deref(),
2092 Some(&after.run_id),
2093 json!({
2094 "kind": "strategy-callback-request",
2095 "schema": "licoup.adaptive-flywheel.callback.v1",
2096 "runId": after.run_id,
2097 "stateId": pending.state_id,
2098 "stateVisit": pending.state_visit,
2099 "transitionId": pending.transition_id,
2100 "event": pending.event.as_str(),
2101 "target": pending.target,
2102 "decisions": ["advance", "return", "terminate"],
2103 "answerChannel": answer_channel,
2104 "answerFields": ["decision", "callbackStateId", "callbackStateVisit"],
2105 "evolution": evolution,
2106 "facts": evolution.facts,
2107 "suggestions": evolution.suggestions,
2108 }),
2109 )?;
2110 }
2111 if failure_terminal {
2112 let evolution = enricher.enrich_terminal_outcome(after);
2113 Self::append_master_report(
2114 portable_root,
2115 assistant_wake,
2116 conversation_id,
2117 after.assistant_membership_id.as_deref(),
2118 Some(&after.run_id),
2119 json!({
2120 "kind": "strategy-terminal-outcome",
2121 "schema": "licoup.adaptive-flywheel.callback.v1",
2122 "runId": after.run_id,
2123 "status": wire_enum(after.status)?,
2124 "diagnostic": after.diagnostic_code,
2125 "evolution": evolution,
2126 "facts": evolution.facts,
2127 "suggestions": evolution.suggestions,
2128 }),
2129 )?;
2130 }
2131 for (state_id, state_visit) in flow_settled {
2132 let evolution = enricher.enrich_flow_settled(after, state_id, state_visit);
2133 Self::append_master_report(
2134 portable_root,
2135 assistant_wake,
2136 conversation_id,
2137 after.assistant_membership_id.as_deref(),
2138 Some(&after.run_id),
2139 json!({
2140 "kind": "strategy-flow-settled",
2141 "schema": "licoup.adaptive-flywheel.callback.v1",
2142 "runId": after.run_id,
2143 "stateId": state_id,
2144 "stateVisit": state_visit,
2145 "mode": "flow",
2146 "evolution": evolution,
2147 "facts": evolution.facts,
2148 "suggestions": evolution.suggestions,
2149 }),
2150 )?;
2151 }
2152 Ok(())
2153 }
2154
2155 fn report_imported_lifecycle_failure(
2159 &self,
2160 conversation_id: &str,
2161 projected: &Value,
2162 ) -> Result<()> {
2163 let wake = self.post_commit.assistant_wake();
2164 Self::append_master_report(
2165 &self.portable_root,
2166 wake.as_deref(),
2167 conversation_id,
2168 None,
2169 None,
2170 json!({
2171 "kind": "strategy-terminal-outcome",
2172 "schema": "licoup.adaptive-flywheel.callback.v1",
2173 "status": "failed",
2174 "diagnostic": projected.get("code").cloned().unwrap_or(Value::Null),
2175 "stage": projected.get("stage").cloned().unwrap_or(Value::Null),
2176 }),
2177 )
2178 }
2179
2180 fn append_master_report(
2181 portable_root: &Path,
2182 assistant_wake: Option<&AssistantWakePort>,
2183 conversation_id: &str,
2184 preferred_master_id: Option<&str>,
2185 causation_id: Option<&str>,
2186 part: Value,
2187 ) -> Result<()> {
2188 let store = licoup_conversation::ConversationStore::open(portable_root)?;
2189 let conversation = store.get(conversation_id)?;
2190 let master_id = preferred_master_id.or(conversation.assistant_membership_id.as_deref());
2191 let Some(master) = master_id
2192 .and_then(|master_id| {
2193 conversation.memberships.iter().find(|membership| {
2194 membership.id == master_id
2195 && membership.status == licoup_conversation::MembershipStatus::Active
2196 })
2197 })
2198 .map(|membership| membership.id.clone())
2199 else {
2200 return Ok(());
2201 };
2202 store.append_event(
2203 conversation_id,
2204 Some(&master),
2205 licoup_conversation::EventKind::Message,
2206 &[licoup_conversation::NewEventPart {
2207 id: String::new(),
2208 kind: licoup_conversation::EventPartKind::Metadata,
2209 content: part.to_string(),
2210 }],
2211 None,
2212 causation_id,
2213 true,
2214 )?;
2215 if let Some(port) = assistant_wake {
2220 let _ = (port.wake)(conversation_id, &master, &part);
2221 }
2222 Ok(())
2223 }
2224
2225 fn project_membership_event(
2226 &self,
2227 run_id: &str,
2228 command: &RunCommand,
2229 output: &Value,
2230 ) -> Result<()> {
2231 if !matches!(command.kind, CommandKind::Actor | CommandKind::WorksetItem) {
2232 return Ok(());
2233 }
2234 let snapshot = self.store.run(run_id)?;
2235 let Some(conversation_id) = snapshot.conversation_id.as_deref() else {
2236 return Ok(());
2237 };
2238 let definition = self
2239 .store
2240 .definition_by_revision(&snapshot.definition_digest)?;
2241 let Some(slot_id) = command.binding_id.as_deref() else {
2242 return Ok(());
2243 };
2244 let Some(binding) = definition.bindings.iter().find(|binding| {
2245 binding.slot_id == slot_id && binding.ordinal == command.binding_ordinal
2246 }) else {
2247 return Ok(());
2248 };
2249 let store = licoup_conversation::ConversationStore::open(&self.portable_root)?;
2250 let conversation = store.get(conversation_id)?;
2251 let Some(membership) = conversation.memberships.iter().find(|membership| {
2252 membership.id == binding.value_id
2253 && membership.status == licoup_conversation::MembershipStatus::Active
2254 }) else {
2255 return Ok(());
2256 };
2257 let parts = group_actor_event_parts(output);
2258 if parts.is_empty() {
2259 return Ok(());
2260 }
2261 store.append_event(
2262 conversation_id,
2263 Some(&membership.id),
2264 licoup_conversation::EventKind::Message,
2265 &parts,
2266 None,
2267 Some(run_id),
2268 true,
2269 )?;
2270 Ok(())
2271 }
2272}
2273
2274struct EntryTurnRegistration {
2279 slot_id: String,
2280 handle: String,
2281 params: Value,
2282 projection: Value,
2283 port: Arc<ActorTurnPort>,
2284 armed: bool,
2285}
2286
2287impl EntryTurnRegistration {
2288 fn ad_hoc(handle: String, params: Value, port: Arc<ActorTurnPort>) -> Self {
2291 Self {
2292 slot_id: String::new(),
2293 handle,
2294 params,
2295 projection: Value::Null,
2296 port,
2297 armed: true,
2298 }
2299 }
2300
2301 fn matches(&self, command: &RunCommand) -> bool {
2302 !self.slot_id.is_empty()
2303 && matches!(command.kind, CommandKind::Actor | CommandKind::WorksetItem)
2304 && command.binding_id.as_deref() == Some(self.slot_id.as_str())
2305 && command.state_visit == 1
2306 && command.binding_ordinal == 0
2307 }
2308
2309 fn into_run(mut self) -> (String, Value) {
2312 self.armed = false;
2313 (self.handle.clone(), self.params.clone())
2314 }
2315}
2316
2317impl Drop for EntryTurnRegistration {
2318 fn drop(&mut self) {
2319 if self.armed {
2320 (self.port.abandon)(&self.handle);
2321 }
2322 }
2323}
2324
2325fn entry_state_for_start<'a>(
2329 workflow: &'a CompiledWorkflow,
2330 slot_id: &str,
2331) -> Option<&'a GraphState> {
2332 let initial = workflow.state(&workflow.definition().initial)?;
2333 if initial.binding.as_deref() == Some(slot_id)
2334 && matches!(
2335 initial.kind,
2336 GraphStateKind::Actor | GraphStateKind::Workset
2337 )
2338 {
2339 return Some(initial);
2340 }
2341 if initial.kind == GraphStateKind::Authorization {
2342 let target = workflow
2343 .transitions(&initial.id, TransitionEvent::Success)
2344 .next()?;
2345 if target.mode == licoup_workflow::TransitionMode::Callback {
2348 return None;
2349 }
2350 let state = workflow.state(&target.to)?;
2351 if state.binding.as_deref() == Some(slot_id)
2352 && matches!(state.kind, GraphStateKind::Actor | GraphStateKind::Workset)
2353 {
2354 return Some(state);
2355 }
2356 }
2357 None
2358}
2359
2360struct GroupActorTarget {
2363 membership_id: String,
2364 agent_id: String,
2365}
2366
2367fn group_actor_target(
2368 conversation_id: &str,
2369 binding_identity: &str,
2370 portable_root: &Path,
2371) -> Result<GroupActorTarget> {
2372 let store = licoup_conversation::ConversationStore::open(portable_root)
2373 .map_err(|_| anyhow!("strategy_actor_dispatch_failed"))?;
2374 let conversation = store
2375 .get(conversation_id)
2376 .map_err(|_| anyhow!("strategy_actor_dispatch_failed"))?;
2377 conversation
2378 .memberships
2379 .iter()
2380 .find(|membership| {
2381 membership.id == binding_identity
2382 && membership.status == licoup_conversation::MembershipStatus::Active
2383 })
2384 .and_then(|membership| {
2385 membership
2386 .principal
2387 .agent_id
2388 .as_ref()
2389 .map(|agent_id| GroupActorTarget {
2390 membership_id: membership.id.clone(),
2391 agent_id: agent_id.clone(),
2392 })
2393 })
2394 .ok_or_else(|| anyhow!("strategy_actor_dispatch_failed"))
2395}
2396
2397fn group_actor_params(
2401 conversation_id: &str,
2402 membership_id: &str,
2403 agent_id: &str,
2404 binding: &BindingValue,
2405 input: &Value,
2406 run_id: &str,
2407 resume_session_id: Option<&str>,
2408 cwd: Option<&str>,
2409) -> Result<Value> {
2410 let prompt = group_actor_prompt(input);
2411 if prompt.trim().is_empty() {
2412 return Err(anyhow!("strategy_actor_dispatch_failed"));
2413 }
2414 let mut params = json!({
2415 "agent": agent_id,
2416 "agentId": agent_id,
2417 "text": prompt,
2418 "message": prompt,
2419 "streamEvents": true,
2420 "timeoutMs": 0,
2421 "conversationId": conversation_id,
2422 "membershipId": membership_id,
2423 "causationId": run_id,
2424 });
2425 if let Some(session_id) = resume_session_id.filter(|value| !value.trim().is_empty()) {
2426 params["sessionId"] = json!(session_id);
2427 }
2428 if !binding.model.is_empty() {
2429 params["model"] = json!(binding.model);
2430 }
2431 if !binding.reasoning_effort.is_empty() {
2432 params["reasoningEffort"] = json!(binding.reasoning_effort);
2433 }
2434 if let Some(cwd) = cwd {
2435 params["cwd"] = json!(cwd);
2436 params["workingDirectory"] = json!(cwd);
2437 }
2438 Ok(params)
2439}
2440
2441fn json_string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
2442 value
2443 .get(key)
2444 .and_then(Value::as_str)
2445 .map(str::trim)
2446 .filter(|text| !text.is_empty())
2447}
2448
2449fn group_actor_prompt(input: &Value) -> String {
2455 let context_message = input.get("context").and_then(|context| {
2456 json_string_field(context, "message").or_else(|| json_string_field(context, "text"))
2457 });
2458 let input_message =
2459 json_string_field(input, "message").or_else(|| json_string_field(input, "text"));
2460 let human = context_message.or(input_message);
2461 let prompt = json_string_field(input, "prompt");
2462 match (prompt, human) {
2463 (Some(prompt), Some(human)) if prompt.trim_start().starts_with('{') => human.to_owned(),
2464 (Some(prompt), Some(human)) if prompt.contains(human) => prompt.to_owned(),
2465 (Some(prompt), Some(human)) => format!("{prompt}\n\n{human}"),
2466 (Some(prompt), None) if prompt.trim_start().starts_with('{') => String::new(),
2467 (Some(prompt), None) => prompt.to_owned(),
2468 (None, Some(human)) => human.to_owned(),
2469 (None, None) => String::new(),
2470 }
2471}
2472
2473fn group_actor_event_parts(output: &Value) -> Vec<licoup_conversation::NewEventPart> {
2474 use licoup_conversation::{EventPartKind, NewEventPart};
2475
2476 let raw = output
2477 .get("output")
2478 .and_then(Value::as_str)
2479 .map(str::to_owned)
2480 .unwrap_or_else(|| serde_json::to_string(output).unwrap_or_default());
2481 if raw.trim().is_empty() {
2482 return Vec::new();
2483 }
2484 let parsed = output
2485 .get("output")
2486 .and_then(Value::as_str)
2487 .and_then(|value| serde_json::from_str::<Value>(value).ok())
2488 .or_else(|| match output {
2489 Value::Object(map)
2490 if map.contains_key("userreply")
2491 || map.contains_key("userReply")
2492 || map.contains_key("designGoal")
2493 || map.contains_key("worksets") =>
2494 {
2495 Some(output.clone())
2496 }
2497 _ => serde_json::from_str(&raw).ok(),
2498 });
2499 if let Some(Value::Object(map)) = parsed {
2500 if let Some(reply) = user_facing_actor_reply(&map) {
2501 return vec![
2502 NewEventPart {
2503 id: String::new(),
2504 kind: EventPartKind::Text,
2505 content: reply,
2506 },
2507 NewEventPart {
2508 id: String::new(),
2509 kind: EventPartKind::Metadata,
2510 content: raw,
2511 },
2512 ];
2513 }
2514 if is_framework_contract(&map) || looks_like_wrapped_prompt(&raw) {
2515 return vec![NewEventPart {
2516 id: String::new(),
2517 kind: EventPartKind::Metadata,
2518 content: raw,
2519 }];
2520 }
2521 }
2522 if looks_like_wrapped_prompt(&raw) {
2523 return vec![NewEventPart {
2524 id: String::new(),
2525 kind: EventPartKind::Metadata,
2526 content: raw,
2527 }];
2528 }
2529 vec![NewEventPart {
2530 id: String::new(),
2531 kind: EventPartKind::Text,
2532 content: raw,
2533 }]
2534}
2535
2536fn user_facing_actor_reply(map: &Map<String, Value>) -> Option<String> {
2537 for key in ["userreply", "userReply", "user_reply"] {
2538 if let Some(reply) = map
2539 .get(key)
2540 .and_then(Value::as_str)
2541 .map(str::trim)
2542 .filter(|value| !value.is_empty())
2543 {
2544 return Some(reply.to_owned());
2545 }
2546 }
2547 None
2548}
2549
2550fn is_framework_contract(map: &Map<String, Value>) -> bool {
2551 let has_goal = map.contains_key("designGoal") || map.contains_key("design_goal");
2552 let has_worksets = map.contains_key("worksets");
2553 let has_constraints = map.contains_key("constraints");
2554 let has_route = map.get("route").and_then(Value::as_str).is_some();
2555 (has_goal || has_worksets) && (has_constraints || has_route)
2556}
2557
2558fn looks_like_wrapped_prompt(raw: &str) -> bool {
2559 raw.contains("Input JSON:") && raw.contains("State:")
2560}
2561
2562fn binding_for<'a>(
2563 definition: &'a StrategyDefinition,
2564 slot: &str,
2565 ordinal: u8,
2566) -> Result<&'a BindingValue> {
2567 definition
2568 .bindings
2569 .iter()
2570 .find(|binding| binding.slot_id == slot && binding.ordinal == ordinal)
2571 .ok_or_else(|| anyhow!("binding_incomplete"))
2572}
2573
2574fn ensure_allowed_fields(action: &str, object: &Map<String, Value>) -> Result<()> {
2575 let allowed: &[&str] = match action {
2576 "strategy.package.prepare-import" => {
2577 &["action", "sourcePath", "selectionToken", "conversationId"]
2578 }
2579 "strategy.package.commit-import" => &[
2580 "action",
2581 "preparationId",
2582 "expectedRevisionDigest",
2583 "conversationId",
2584 ],
2585 "strategy.definition.list" | "strategy.runtime.discover" | "strategy.runtime.list" => {
2586 &["action"]
2587 }
2588 "strategy.definition.inspect"
2589 | "strategy.authorization.preview"
2590 | "strategy.authorization.revoke" => &["action", "revisionDigest"],
2591 "strategy.binding.update" => &[
2592 "action",
2593 "revisionDigest",
2594 "slotId",
2595 "valueId",
2596 "model",
2597 "reasoningEffort",
2598 "expectedRevision",
2599 ],
2600 "strategy.binding.replace" => &[
2601 "action",
2602 "revisionDigest",
2603 "slotId",
2604 "candidates",
2605 "expectedRevision",
2606 ],
2607 "strategy.binding.remove" => &["action", "revisionDigest", "slotId", "expectedRevision"],
2608 "strategy.authorization.grant" => &[
2609 "action",
2610 "revisionDigest",
2611 "authorizationDigest",
2612 "confirmed",
2613 ],
2614 "strategy.run.start" => &[
2615 "action",
2616 "revisionDigest",
2617 "input",
2618 "idempotencyKey",
2619 "conversationId",
2620 "cwd",
2621 ],
2622 "strategy.run.active" => &["action", "revisionDigest", "conversationId"],
2623 "strategy.run.inspect" | "strategy.run.retry" => &["action", "runId"],
2624 "strategy.run.cancel" => &["action", "runId", "correlationId"],
2628 "strategy.assistant.workflow.execute" => &[
2629 "action",
2630 "conversationId",
2631 "membershipId",
2632 "workflow",
2633 "bindings",
2634 "filters",
2635 "input",
2636 "idempotencyKey",
2637 "decision",
2638 "callbackStateId",
2639 "callbackStateVisit",
2640 "selectionPolicyRevision",
2641 ],
2642 "strategy.assistant.workflow.inspect" => &["action", "runId"],
2643 "strategy.assistant.workflow.cancel" => &["action", "runId", "correlationId"],
2644 "strategy.run.resume" => &[
2645 "action",
2646 "runId",
2647 "conversationId",
2648 "decision",
2649 "callbackStateId",
2650 "callbackStateVisit",
2651 ],
2652 _ => return Err(anyhow!("unsupported_action")),
2653 };
2654 ensure!(
2655 object.keys().all(|key| allowed.contains(&key.as_str())),
2656 "invalid_request"
2657 );
2658 Ok(())
2659}
2660
2661fn required_string<'a>(object: &'a Map<String, Value>, key: &str) -> Result<&'a str> {
2662 object
2663 .get(key)
2664 .and_then(Value::as_str)
2665 .filter(|value| value == &value.trim() && !value.is_empty() && value.len() <= 4096)
2666 .ok_or_else(|| anyhow!("invalid_request"))
2667}
2668
2669fn optional_string<'a>(object: &'a Map<String, Value>, key: &str) -> Result<&'a str> {
2670 match object.get(key) {
2671 None => Ok(""),
2672 Some(Value::String(value))
2673 if value == value.trim()
2674 && value.len() <= 160
2675 && !value.chars().any(char::is_control) =>
2676 {
2677 Ok(value)
2678 }
2679 _ => Err(anyhow!("invalid_request")),
2680 }
2681}
2682
2683fn wire_enum(value: impl serde::Serialize) -> Result<String> {
2684 serde_json::to_value(value)?
2685 .as_str()
2686 .map(str::to_owned)
2687 .ok_or_else(|| anyhow!("strategy_enum_invalid"))
2688}
2689
2690fn numeric_usage_projection(output: &Value) -> Option<Value> {
2693 let source = [
2694 output.pointer("/usage"),
2695 output.pointer("/result/usage"),
2696 output.pointer("/terminal/usage"),
2697 output.pointer("/payload/usage"),
2698 Some(output),
2699 ]
2700 .into_iter()
2701 .flatten()
2702 .find_map(Value::as_object)?;
2703 let number = |keys: &[&str]| {
2704 keys.iter()
2705 .find_map(|key| source.get(*key).and_then(Value::as_u64))
2706 };
2707 let prompt = number(&[
2708 "promptTokens",
2709 "prompt_tokens",
2710 "inputTokens",
2711 "input_tokens",
2712 ]);
2713 let cached = number(&[
2714 "cachedInputTokens",
2715 "cached_input_tokens",
2716 "cacheReadInputTokens",
2717 "cache_read_input_tokens",
2718 ]);
2719 let completion = number(&[
2720 "completionTokens",
2721 "completion_tokens",
2722 "outputTokens",
2723 "output_tokens",
2724 ]);
2725 if prompt.is_none() && cached.is_none() && completion.is_none() {
2726 return None;
2727 }
2728 let prompt = prompt.unwrap_or(0);
2729 let completion = completion.unwrap_or(0);
2730 let total = prompt.checked_add(completion)?;
2731 let accuracy = source
2732 .get("accuracy")
2733 .or_else(|| source.get("usageAccuracy"))
2734 .and_then(Value::as_str)
2735 .filter(|value| matches!(*value, "exact" | "estimated" | "unknown"))
2736 .unwrap_or("exact");
2737 Some(json!({
2738 "promptTokens": prompt,
2739 "cachedInputTokens": cached.unwrap_or(0),
2740 "completionTokens": completion,
2741 "totalTokens": total,
2742 "accuracy": accuracy,
2743 }))
2744}
2745
2746fn optional_cwd(ports: &HostPorts, object: &Map<String, Value>) -> Result<Option<String>> {
2747 match object.get("cwd") {
2748 None => Ok(None),
2749 Some(Value::String(value)) if value.trim().is_empty() => Ok(None),
2750 Some(Value::String(value)) => {
2751 ports.effect.admit_strategy_cwd(value)?;
2752 Ok(Some(value.clone()))
2753 }
2754 _ => Err(anyhow!("invalid_request")),
2755 }
2756}
2757
2758fn parse_callback_decision(
2762 object: &Map<String, Value>,
2763) -> Result<Option<(String, u64, CallbackDecisionKind)>> {
2764 let Some(decision) = object.get("decision") else {
2765 return Ok(None);
2766 };
2767 let kind = match decision.as_str() {
2768 Some("advance") => CallbackDecisionKind::Advance,
2769 Some("return") => CallbackDecisionKind::Return,
2770 Some("terminate") => CallbackDecisionKind::Terminate,
2771 _ => return Err(anyhow!("invalid_request")),
2772 };
2773 let state_id = required_string(object, "callbackStateId")?.to_owned();
2774 let state_visit = object
2775 .get("callbackStateVisit")
2776 .and_then(Value::as_u64)
2777 .filter(|visit| *visit >= 1)
2778 .ok_or_else(|| anyhow!("invalid_request"))?;
2779 Ok(Some((state_id, state_visit, kind)))
2780}
2781
2782fn failure_terminal_status(status: StrategyRunStatus) -> bool {
2785 matches!(
2786 status,
2787 StrategyRunStatus::Failed | StrategyRunStatus::Blocked | StrategyRunStatus::CancelInDoubt
2788 )
2789}
2790
2791fn parse_binding_candidates(object: &Map<String, Value>) -> Result<Vec<BindingCandidate>> {
2792 let values = object
2793 .get("candidates")
2794 .and_then(Value::as_array)
2795 .ok_or_else(|| anyhow!("invalid_request"))?;
2796 ensure!(values.len() <= 16, "strategy_binding_limit");
2797 values
2798 .iter()
2799 .map(|value| {
2800 let candidate = value
2801 .as_object()
2802 .ok_or_else(|| anyhow!("invalid_request"))?;
2803 Ok(BindingCandidate {
2804 value_id: required_string(candidate, "valueId")?.to_owned(),
2805 model: optional_string(candidate, "model")?.to_owned(),
2806 reasoning_effort: optional_string(candidate, "reasoningEffort")?.to_owned(),
2807 })
2808 })
2809 .collect()
2810}
2811
2812fn validate_selection_token(value: &str) -> Result<()> {
2813 ensure!(
2814 value.len() <= 96
2815 && value.chars().all(
2816 |character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')
2817 ),
2818 "permit_denied"
2819 );
2820 Ok(())
2821}
2822
2823fn classify_effect_error(message: &str) -> (FailureClass, &'static str) {
2824 if message.contains("runtime_unavailable") || message.contains("runtime_drifted") {
2825 (FailureClass::Runtime, "runtime_unavailable")
2826 } else if message.contains("sandbox") {
2827 (FailureClass::Sandbox, "sandbox_unavailable")
2828 } else if message.contains("authorization") || message.contains("permit") {
2829 (FailureClass::Authority, "authorization_required")
2830 } else if let Some(code) = recheck_failure_code(message) {
2831 (FailureClass::Permanent, code)
2836 } else if message.contains("usage_limit_exceeded")
2837 || message.contains("quota_exhausted")
2838 || message.contains("strategy_actor_quota_exhausted")
2839 {
2840 (FailureClass::Permanent, "quota_exhausted")
2841 } else if message.contains("strategy_actor_failed") || message.contains("turn_not_completed") {
2842 (FailureClass::Permanent, "effect_failed")
2846 } else if message.contains("timed_out")
2847 || message.contains("timeout")
2848 || message.contains("dispatch_failed")
2849 {
2850 (FailureClass::Transient, "effect_temporarily_unavailable")
2851 } else if message.contains("outcome_unknown") {
2852 (FailureClass::InDoubt, "effect_outcome_unknown")
2853 } else {
2854 (FailureClass::Permanent, "effect_failed")
2855 }
2856}
2857
2858const RECHECK_FAILURE_CODES: [&str; 5] = [
2863 "strategy_recheck_version_mismatch",
2864 "strategy_recheck_stale_generation",
2865 "strategy_recheck_session_writer_conflict",
2866 "strategy_recheck_token_budget_exhausted",
2867 "strategy_recheck_resource_unavailable",
2868];
2869
2870fn recheck_failure_code(message: &str) -> Option<&'static str> {
2871 RECHECK_FAILURE_CODES
2872 .into_iter()
2873 .find(|code| message.contains(code))
2874}
2875
2876fn actor_output_failure(
2881 command: &RunCommand,
2882 output: &Value,
2883) -> Option<(FailureClass, &'static str)> {
2884 if !matches!(command.kind, CommandKind::Actor | CommandKind::WorksetItem) {
2885 return None;
2886 }
2887 if output.get("ok").and_then(Value::as_bool) == Some(true) {
2888 return None;
2889 }
2890 let error = output.get("error").unwrap_or(output);
2891 let code = error.get("code").and_then(Value::as_str).unwrap_or("");
2892 let turn_status = error
2893 .get("turnStatus")
2894 .or_else(|| output.get("turnStatus"))
2895 .and_then(Value::as_str)
2896 .unwrap_or("");
2897 Some(classify_effect_error(&format!(
2898 "{code}\n{turn_status}\nstrategy_actor_failed"
2899 )))
2900}
2901
2902fn error_projection(error: &anyhow::Error) -> Value {
2903 if let Some(failure) = error.downcast_ref::<WorkflowValidationFailure>() {
2904 return json!({
2905 "code": "workflow_invalid",
2906 "stage": "workflow/compile",
2907 "component": "strategy_graph",
2908 "retryable": false,
2909 "recovery": "correct_workflow_and_retry",
2910 "diagnostics": failure.diagnostics,
2911 "presentationArgs": {}
2912 });
2913 }
2914 let message = error.to_string();
2915 let (code, stage, component, retryable, recovery) =
2916 if message.contains(licoup_conversation::PERSISTENT_TRANSPORT_REQUIRED) {
2917 (
2918 licoup_conversation::PERSISTENT_TRANSPORT_REQUIRED,
2919 "run/dispatch",
2920 "strategy_runtime",
2921 true,
2922 "Open the client so the persistent runtime can drive the run, then retry.",
2923 )
2924 } else if message.contains("strategy_actor_dispatch_failed")
2925 || message.contains("strategy_run_start_failed")
2926 {
2927 (
2928 "strategy_run_start_failed",
2929 "strategy/start",
2930 "strategy_runtime",
2931 true,
2932 "Retry after the persistent Conversation runtime is available.",
2933 )
2934 } else if message.contains("package_unavailable") {
2935 (
2936 "package_unavailable",
2937 "package/read",
2938 "strategy_package",
2939 true,
2940 "Choose the ZIP package again.",
2941 )
2942 } else if message.contains("package_too_large") {
2943 (
2944 "package_too_large",
2945 "package/read",
2946 "strategy_package",
2947 false,
2948 "Use a package within the published size limit.",
2949 )
2950 } else if message.contains("package_duplicate") {
2951 (
2952 "package_duplicate_entry",
2953 "package/validate",
2954 "strategy_package",
2955 false,
2956 "Remove duplicate or case-colliding package entries.",
2957 )
2958 } else if message.contains("package_layout") {
2959 (
2960 "package_layout_invalid",
2961 "package/validate",
2962 "strategy_package",
2963 false,
2964 "Keep workflow.json at the root and helpers below scripts/.",
2965 )
2966 } else if message.contains("package_entry") {
2967 (
2968 "package_entry_invalid",
2969 "package/validate",
2970 "strategy_package",
2971 false,
2972 "Remove invalid or path-escaping package entries.",
2973 )
2974 } else if message.contains("package_resource") {
2975 (
2976 "package_resource_limit",
2977 "package/validate",
2978 "strategy_package",
2979 false,
2980 "Reduce package entries or extracted bytes.",
2981 )
2982 } else if message.contains("workflow") {
2983 (
2984 "workflow_invalid",
2985 "workflow/compile",
2986 "strategy_graph",
2987 false,
2988 "Correct the workflow Graph and import a new revision.",
2989 )
2990 } else if message.contains("definition_not_found") {
2991 (
2992 "definition_not_found",
2993 "definition/read",
2994 "strategy_store",
2995 false,
2996 "Select an available strategy definition.",
2997 )
2998 } else if message.contains("preparation_not_found") {
2999 (
3000 "preparation_not_found",
3001 "package/commit",
3002 "strategy_package",
3003 true,
3004 "Prepare the package again.",
3005 )
3006 } else if message.contains("revision_conflict") || message.contains("authorization_stale") {
3007 (
3008 "revision_conflict",
3009 "authority/revalidate",
3010 "strategy_authority",
3011 true,
3012 "Refresh the definition and authorize its current semantics.",
3013 )
3014 } else if message.contains("binding") {
3015 (
3016 "binding_incomplete",
3017 "binding/validate",
3018 "strategy_binding",
3019 true,
3020 "Bind every required actor and runtime slot.",
3021 )
3022 } else if message.contains("runtime") {
3023 (
3024 "runtime_unavailable",
3025 "runtime/discover",
3026 "strategy_runtime",
3027 true,
3028 "Install or bind a verified local runtime.",
3029 )
3030 } else if message.contains("sandbox") {
3031 (
3032 "sandbox_unavailable",
3033 "runtime/sandbox",
3034 "strategy_runtime",
3035 true,
3036 "Use a platform with the required reliable sandbox.",
3037 )
3038 } else if message.contains("authorization") || message.contains("permit") {
3039 (
3040 "authorization_required",
3041 "authority/check",
3042 "strategy_authority",
3043 true,
3044 "Review and authorize the current semantics.",
3045 )
3046 } else if message.contains("run_not_found") {
3047 (
3048 "run_not_found",
3049 "run/read",
3050 "strategy_store",
3051 false,
3052 "Select an existing run.",
3053 )
3054 } else if message.contains("callback_stale") || message.contains("callback_conflict") {
3055 (
3056 if message.contains("callback_conflict") {
3057 "callback_conflict"
3058 } else {
3059 "callback_stale"
3060 },
3061 "run/callback",
3062 "strategy_reducer",
3063 false,
3064 "Inspect the run projection and decide a currently pending callback.",
3065 )
3066 } else if message.contains("not_retryable") {
3067 (
3068 "run_not_retryable",
3069 "run/retry",
3070 "strategy_reducer",
3071 false,
3072 "Inspect the run state before retrying.",
3073 )
3074 } else if message.contains("unsupported_action") {
3075 (
3076 "unsupported_action",
3077 "bridge/admission",
3078 "strategy_bridge",
3079 false,
3080 "Update the client and retry.",
3081 )
3082 } else if message.contains("invalid_request") {
3083 (
3084 "invalid_request",
3085 "bridge/admission",
3086 "strategy_bridge",
3087 false,
3088 "Correct the request fields.",
3089 )
3090 } else {
3091 (
3092 "strategy_operation_failed",
3093 "strategy/operation",
3094 "adaptive_flywheel",
3095 false,
3096 "Inspect the strategy diagnostics.",
3097 )
3098 };
3099 json!({
3100 "code": code,
3101 "stage": stage,
3102 "component": component,
3103 "retryable": retryable,
3104 "recovery": recovery,
3105 "presentationArgs": {}
3106 })
3107}
3108
3109fn terminal_projection(value: &Value) -> Option<Value> {
3110 let status = value.get("status").and_then(Value::as_str)?;
3111 if value
3112 .get("pendingCallbacks")
3113 .and_then(Value::as_array)
3114 .is_some_and(|callbacks| !callbacks.is_empty())
3115 {
3116 return Some(json!({
3117 "code": "callback_decision_required",
3118 "status": status,
3119 "stage": "assistant-workflow/terminal",
3120 "retryable": true,
3121 "recoveryClass": "assistant-callback-decision",
3122 }));
3123 }
3124 if !matches!(
3125 status,
3126 "authorization-required"
3127 | "runtime-missing"
3128 | "retryable"
3129 | "completed"
3130 | "failed"
3131 | "cancelled"
3132 | "blocked"
3133 | "cancel-in-doubt"
3134 ) {
3135 return None;
3136 }
3137 let code = value
3138 .pointer("/diagnostic/code")
3139 .and_then(Value::as_str)
3140 .filter(|code| !code.is_empty())
3141 .unwrap_or(match status {
3142 "completed" => "graph_completed",
3143 "cancelled" => "graph_cancelled",
3144 "cancel-in-doubt" => "cancellation_outcome_unknown",
3145 "authorization-required" => "authorization_required",
3146 "runtime-missing" => "runtime_unavailable",
3147 "retryable" => "runtime_retryable",
3148 "blocked" => "graph_blocked",
3149 _ => "graph_failed",
3150 });
3151 Some(json!({
3152 "code": code,
3153 "status": status,
3154 "stage": "assistant-workflow/terminal",
3155 "retryable": false,
3156 "recoveryClass": "assistant-direct-or-new-graph",
3157 }))
3158}
3159
3160fn assistant_state_failure(code: &str, membership_id: Option<&str>) -> PreflightFailure {
3161 PreflightFailure::new(
3162 "graph_preflight_rejected",
3163 vec![PreflightDiagnostic {
3164 code: WorkflowDiagnosticCode::from_wire(code)
3165 .unwrap_or(WorkflowDiagnosticCode::WorkflowInvalid),
3166 stage: WorkflowDiagnosticStage::AssistantWorkflowRevalidate,
3167 path: membership_id.map(|_| "/membershipId".to_owned()),
3168 related_paths: Vec::new(),
3169 membership_id: membership_id.map(str::to_owned),
3170 actual: None,
3171 limit: None,
3172 expected: None,
3173 actual_kind: None,
3174 recovery: Some(WorkflowDiagnosticRecovery::RefreshConversationState),
3175 line: None,
3176 column: None,
3177 }],
3178 )
3179}
3180
3181fn assistant_run_terminal(status: StrategyRunStatus) -> bool {
3182 matches!(
3183 status,
3184 StrategyRunStatus::AuthorizationRequired
3185 | StrategyRunStatus::RuntimeMissing
3186 | StrategyRunStatus::Retryable
3187 | StrategyRunStatus::Completed
3188 | StrategyRunStatus::Failed
3189 | StrategyRunStatus::Cancelled
3190 | StrategyRunStatus::Blocked
3191 | StrategyRunStatus::CancelInDoubt
3192 )
3193}
3194
3195fn now_ms() -> i64 {
3196 std::time::SystemTime::now()
3197 .duration_since(std::time::UNIX_EPOCH)
3198 .map(|duration| duration.as_millis() as i64)
3199 .unwrap_or(0)
3200}
3201
3202#[cfg(test)]
3203mod tests {
3204 use super::*;
3205 use crate::ports::ActorTurnError;
3206
3207 const FIXTURE_SELECTION_POLICY_REVISION: &str = "selection-policy-fixture-1";
3210
3211 fn root() -> PathBuf {
3212 let path = std::env::temp_dir().join(format!(
3213 "lico-strategy-service-test-{}",
3214 uuid::Uuid::new_v4()
3215 ));
3216 fs::create_dir_all(&path).unwrap();
3217 path
3218 }
3219
3220 fn remove_root(path: PathBuf) {
3221 let mut stack = vec![path.clone()];
3222 while let Some(current) = stack.pop() {
3223 if let Ok(metadata) = fs::symlink_metadata(¤t) {
3224 make_writable(¤t, metadata.permissions());
3225 if metadata.is_dir()
3226 && let Ok(entries) = fs::read_dir(¤t)
3227 {
3228 stack.extend(entries.flatten().map(|entry| entry.path()));
3229 }
3230 }
3231 }
3232 fs::remove_dir_all(path).unwrap();
3233 }
3234
3235 fn make_writable(path: &Path, mut permissions: fs::Permissions) {
3236 #[cfg(unix)]
3237 {
3238 use std::os::unix::fs::PermissionsExt;
3239 permissions.set_mode(permissions.mode() | 0o200);
3240 }
3241 #[cfg(not(unix))]
3242 permissions.set_readonly(false);
3243 fs::set_permissions(path, permissions).unwrap();
3244 }
3245
3246 #[test]
3247 fn definition_inspection_labels_saved_models_without_changing_bindings() {
3248 let root = root();
3249 let (store, revision) = authorized_entry_store(&root, "claude-code");
3250 let model = "deepseek-v4-flash-vision-exp";
3251 store
3252 .replace_slot_bindings(
3253 &revision,
3254 "entry",
3255 &[BindingCandidate {
3256 value_id: "claude-code".to_owned(),
3257 model: model.to_owned(),
3258 reasoning_effort: String::new(),
3259 }],
3260 None,
3261 )
3262 .unwrap();
3263 let authorization = store.authorization_preview(&revision).unwrap();
3264 let authorization = store
3265 .grant_authorization(&revision, &authorization.authorization_digest)
3266 .unwrap();
3267 let before = store.projection_for_definition(&revision).unwrap();
3268 let service = StrategyService::from_parts(
3269 root.clone(),
3270 store.clone(),
3271 StrategyPackageImporter::open(&root).unwrap(),
3272 );
3273 let response = service
3274 .execute(json!({
3275 "action": "strategy.definition.inspect",
3276 "revisionDigest": revision,
3277 }))
3278 .unwrap();
3279 assert_eq!(response["ok"], true);
3280 assert_eq!(
3281 response["result"]["modelDisplayNames"],
3282 json!({model: "DeepSeek V4 Flash Vision Exp"})
3283 );
3284 assert_eq!(
3285 response["result"]["projection"],
3286 serde_json::to_value(&before).unwrap()
3287 );
3288 assert_eq!(store.projection_for_definition(&revision).unwrap(), before);
3289 assert_eq!(
3290 store
3291 .definition_by_revision(&revision)
3292 .unwrap()
3293 .authorization,
3294 Some(authorization),
3295 );
3296 remove_root(root);
3297 }
3298
3299 #[test]
3300 fn catalog_starts_empty_until_a_package_is_imported() {
3301 let root = root();
3302 let service = StrategyService::open(&root).unwrap();
3303 let listed = service
3304 .execute(json!({"action": "strategy.definition.list"}))
3305 .unwrap();
3306 assert_eq!(listed["result"].as_array().unwrap().len(), 0);
3307
3308 let zip_path = root.join("fixture.zip");
3309 fs::write(&zip_path, crate::synthetic_fixture_package_bytes().unwrap()).unwrap();
3310 let prepared = service
3311 .execute(json!({
3312 "action": "strategy.package.prepare-import",
3313 "sourcePath": zip_path.to_string_lossy(),
3314 "selectionToken": String::from("selection-test")
3315 }))
3316 .unwrap();
3317 assert_eq!(prepared["ok"], true);
3318 let commit = service
3319 .execute(json!({
3320 "action": "strategy.package.commit-import",
3321 "preparationId": prepared["result"]["preparationId"],
3322 "expectedRevisionDigest": prepared["result"]["revisionDigest"]
3323 }))
3324 .unwrap();
3325 assert_eq!(commit["ok"], true);
3326 assert_eq!(commit["result"]["definitionId"], "fixture-entry-worker");
3327
3328 let reopened = StrategyService::open(&root).unwrap();
3329 let relisted = reopened
3330 .execute(json!({"action": "strategy.definition.list"}))
3331 .unwrap();
3332 assert_eq!(relisted["result"].as_array().unwrap().len(), 1);
3333 remove_root(root);
3334 }
3335
3336 #[test]
3337 fn persisted_fallback_recovery_is_resumable_and_idempotent() {
3338 use licoup_workflow::{
3339 ActorSlot, GraphState, GraphStateKind, RetryPolicy, Transition, TransitionEvent,
3340 TransitionMode, WorkflowDefinition, WorkflowLimits, WorkflowMetadata,
3341 };
3342
3343 let root = root();
3344 let store = StrategyStore::open(&root).unwrap();
3345 let mut slot = ActorSlot::required_actor("worker", "Worker");
3346 slot.fallback.after_transient_attempts = 1;
3347 let workflow = WorkflowDefinition {
3348 schema: licoup_workflow::WORKFLOW_SCHEMA_VERSION.into(),
3349 metadata: WorkflowMetadata {
3350 id: "fallback-recovery".into(),
3351 name: "Fallback recovery".into(),
3352 version: "1".into(),
3353 description: String::new(),
3354 },
3355 limits: WorkflowLimits {
3356 max_parallelism: 1,
3357 max_workset_items: 1,
3358 max_attempts: 2,
3359 },
3360 actor_slots: vec![slot],
3361 runtimes: vec![],
3362 worksets: vec![],
3363 initial: "work".into(),
3364 states: vec![
3365 GraphState {
3366 id: "work".into(),
3367 kind: GraphStateKind::Actor,
3368 label: "Work".into(),
3369 instruction: String::new(),
3370 binding: Some("worker".into()),
3371 runtime: None,
3372 entry: None,
3373 workset: None,
3374 retry: RetryPolicy {
3375 max_attempts: 1,
3376 transient_only: true,
3377 },
3378 },
3379 GraphState {
3380 id: "done".into(),
3381 kind: GraphStateKind::Succeed,
3382 label: "Done".into(),
3383 instruction: String::new(),
3384 binding: None,
3385 runtime: None,
3386 entry: None,
3387 workset: None,
3388 retry: RetryPolicy::default(),
3389 },
3390 GraphState {
3391 id: "failed".into(),
3392 kind: GraphStateKind::Fail,
3393 label: "Failed".into(),
3394 instruction: String::new(),
3395 binding: None,
3396 runtime: None,
3397 entry: None,
3398 workset: None,
3399 retry: RetryPolicy::default(),
3400 },
3401 ],
3402 transitions: vec![
3403 Transition {
3404 id: "succeeded".into(),
3405 from: "work".into(),
3406 to: "done".into(),
3407 event: TransitionEvent::Success,
3408 mode: TransitionMode::Flow,
3409 guard: None,
3410 },
3411 Transition {
3412 id: "failed".into(),
3413 from: "work".into(),
3414 to: "failed".into(),
3415 event: TransitionEvent::Failure,
3416 mode: TransitionMode::Flow,
3417 guard: None,
3418 },
3419 ],
3420 };
3421 let revision = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
3422 store
3423 .register_definition(revision, revision, &workflow, 1, 1)
3424 .unwrap();
3425 store
3426 .replace_slot_bindings(
3427 revision,
3428 "worker",
3429 &[
3430 BindingCandidate {
3431 value_id: "agent:primary".into(),
3432 model: String::new(),
3433 reasoning_effort: String::new(),
3434 },
3435 BindingCandidate {
3436 value_id: "agent:fallback".into(),
3437 model: String::new(),
3438 reasoning_effort: String::new(),
3439 },
3440 ],
3441 None,
3442 )
3443 .unwrap();
3444 let preview = store.authorization_preview(revision).unwrap();
3445 store
3446 .grant_authorization(revision, &preview.authorization_digest)
3447 .unwrap();
3448 let run = store
3449 .start_run(revision, json!({}), "fallback-recovery-run", None, None)
3450 .unwrap();
3451 let command = run.commands.values().next().unwrap().clone();
3452 let failed = store
3453 .apply_event(
3454 &run.run_id,
3455 ReducerEvent::CommandFailed {
3456 command_id: command.id.clone(),
3457 attempt_token: command.attempt_token,
3458 class: FailureClass::Transient,
3459 code: "effect_temporarily_unavailable".into(),
3460 },
3461 )
3462 .unwrap();
3463 assert_eq!(failed.status, StrategyRunStatus::Running);
3464 assert_eq!(failed.commands[&command.id].status, CommandStatus::Failed);
3465
3466 let service = StrategyService::from_parts(
3467 root.clone(),
3468 store.clone(),
3469 StrategyPackageImporter::open(&root).unwrap(),
3470 );
3471 service.recover_persisted_effects(&run.run_id).unwrap();
3472 let recovered = store.run(&run.run_id).unwrap();
3473 assert_eq!(
3474 recovered.commands[&command.id].status,
3475 CommandStatus::Cancelled
3476 );
3477 assert_eq!(recovered.fallbacks.len(), 1);
3478 assert!(recovered.commands.values().any(|candidate| {
3479 candidate.binding_ordinal == 1 && candidate.status == CommandStatus::Pending
3480 }));
3481 service.recover_persisted_effects(&run.run_id).unwrap();
3482 assert_eq!(store.run(&run.run_id).unwrap(), recovered);
3483 remove_root(root);
3484 }
3485
3486 #[test]
3487 fn actor_userreply_json_is_the_visible_group_text() {
3488 let parts = super::group_actor_event_parts(&json!({
3489 "output": "{\"userreply\":\"你好,我在。\",\"evidence\":{\"status\":\"done\"}}"
3490 }));
3491 let text: Vec<_> = parts
3492 .iter()
3493 .filter(|part| part.kind == licoup_conversation::EventPartKind::Text)
3494 .collect();
3495 assert_eq!(text.len(), 1);
3496 assert_eq!(text[0].content, "你好,我在。");
3497 assert!(
3498 parts
3499 .iter()
3500 .any(|part| { part.kind == licoup_conversation::EventPartKind::Metadata })
3501 );
3502 }
3503
3504 #[test]
3505 fn actor_design_contract_without_userreply_is_metadata_only() {
3506 let parts = super::group_actor_event_parts(&json!({
3507 "route": "ordinary",
3508 "intent": "greeting",
3509 "designGoal": "用简体中文友好地回应用户问候",
3510 "constraints": ["只回应问候"],
3511 "worksets": {"tasks": []}
3512 }));
3513 assert!(
3514 parts
3515 .iter()
3516 .all(|part| { part.kind == licoup_conversation::EventPartKind::Metadata })
3517 );
3518 assert!(
3519 parts
3520 .iter()
3521 .all(|part| { part.kind != licoup_conversation::EventPartKind::Text })
3522 );
3523 }
3524
3525 #[test]
3526 fn group_actor_prompt_prefers_the_human_message_over_a_json_dump() {
3527 assert_eq!(super::group_actor_prompt(&json!({"message": "hi"})), "hi");
3528 assert_eq!(
3529 super::group_actor_prompt(&json!({
3530 "prompt": "{\"message\":\"hi\"}",
3531 "message": "hi"
3532 })),
3533 "hi"
3534 );
3535 assert_eq!(
3536 super::group_actor_prompt(&json!({
3537 "prompt": "Greet the user.",
3538 "context": {"message": "hi"}
3539 })),
3540 "Greet the user.\n\nhi"
3541 );
3542 let wrapped = "Greet the user.\n\nState: entry\nInput JSON:\n{\"message\":\"hi\"}";
3543 assert_eq!(
3544 super::group_actor_prompt(&json!({
3545 "prompt": wrapped,
3546 "context": {"message": "hi"}
3547 })),
3548 wrapped
3549 );
3550 assert!(super::group_actor_prompt(&json!({"prompt": "{\"unrelated\":true}"})).is_empty());
3551 }
3552
3553 fn actor_command() -> RunCommand {
3554 serde_json::from_value(json!({
3555 "id": "command:test",
3556 "stateId": "schedule",
3557 "kind": "actor",
3558 "status": "running",
3559 "attempt": 1,
3560 "attemptToken": "token",
3561 "inputDigest": "digest"
3562 }))
3563 .unwrap()
3564 }
3565
3566 #[test]
3567 fn failed_persistent_turn_is_a_permanent_effect_failure() {
3568 let command = actor_command();
3569 let failed = super::actor_output_failure(
3570 &command,
3571 &json!({
3572 "ok": false,
3573 "error": {
3574 "code": "codex_turn_not_completed",
3575 "turnStatus": "failed"
3576 }
3577 }),
3578 )
3579 .expect("failed turn must not settle as success");
3580 assert_eq!(failed, (FailureClass::Permanent, "effect_failed"));
3581 assert!(super::actor_output_failure(&command, &json!({"ok": true})).is_none());
3582 assert_eq!(
3583 super::classify_effect_error("strategy_actor_failed"),
3584 (FailureClass::Permanent, "effect_failed")
3585 );
3586 assert_eq!(
3587 super::classify_effect_error("strategy_actor_dispatch_failed"),
3588 (FailureClass::Transient, "effect_temporarily_unavailable")
3589 );
3590 for code in super::RECHECK_FAILURE_CODES {
3594 assert_eq!(
3595 super::classify_effect_error(&format!("pre_effect_recheck:{code}")),
3596 (FailureClass::Permanent, code)
3597 );
3598 }
3599 assert_eq!(
3602 super::classify_effect_error("authorization_required"),
3603 (FailureClass::Authority, "authorization_required")
3604 );
3605 assert_eq!(
3606 super::actor_output_failure(
3607 &command,
3608 &json!({
3609 "ok": false,
3610 "error": {"code": "codex_usage_limit_exceeded"}
3611 }),
3612 ),
3613 Some((FailureClass::Permanent, "quota_exhausted"))
3614 );
3615 }
3616
3617 use licoup_conversation::{MembershipAccess, Principal, PrincipalKind};
3618
3619 fn entry_workflow() -> licoup_workflow::WorkflowDefinition {
3620 use licoup_workflow::{
3621 ActorSlot, GraphState, GraphStateKind, RetryPolicy, Transition, TransitionEvent,
3622 TransitionMode, WorkflowDefinition, WorkflowLimits, WorkflowMetadata,
3623 };
3624 let mut slot = ActorSlot::required_actor("entry", "Entry");
3625 slot.entry = true;
3626 WorkflowDefinition {
3627 schema: licoup_workflow::WORKFLOW_SCHEMA_VERSION.into(),
3628 metadata: WorkflowMetadata {
3629 id: "entry-greeter".into(),
3630 name: "Entry greeter".into(),
3631 version: "1".into(),
3632 description: String::new(),
3633 },
3634 limits: WorkflowLimits {
3635 max_parallelism: 1,
3636 max_workset_items: 1,
3637 max_attempts: 2,
3638 },
3639 actor_slots: vec![slot],
3640 runtimes: vec![],
3641 worksets: vec![],
3642 initial: "greet".into(),
3643 states: vec![
3644 GraphState {
3645 id: "greet".into(),
3646 kind: GraphStateKind::Actor,
3647 label: "Greet".into(),
3648 instruction: String::new(),
3649 binding: Some("entry".into()),
3650 runtime: None,
3651 entry: None,
3652 workset: None,
3653 retry: RetryPolicy {
3654 max_attempts: 2,
3655 transient_only: true,
3656 },
3657 },
3658 GraphState {
3659 id: "done".into(),
3660 kind: GraphStateKind::Succeed,
3661 label: "Done".into(),
3662 instruction: String::new(),
3663 binding: None,
3664 runtime: None,
3665 entry: None,
3666 workset: None,
3667 retry: RetryPolicy::default(),
3668 },
3669 GraphState {
3670 id: "failed".into(),
3671 kind: GraphStateKind::Fail,
3672 label: "Failed".into(),
3673 instruction: String::new(),
3674 binding: None,
3675 runtime: None,
3676 entry: None,
3677 workset: None,
3678 retry: RetryPolicy::default(),
3679 },
3680 ],
3681 transitions: vec![
3682 Transition {
3683 id: "greeted".into(),
3684 from: "greet".into(),
3685 to: "done".into(),
3686 event: TransitionEvent::Success,
3687 mode: TransitionMode::Flow,
3688 guard: None,
3689 },
3690 Transition {
3691 id: "greet-failed".into(),
3692 from: "greet".into(),
3693 to: "failed".into(),
3694 event: TransitionEvent::Failure,
3695 mode: TransitionMode::Flow,
3696 guard: None,
3697 },
3698 ],
3699 }
3700 }
3701
3702 fn conversation_bound_fixture(
3703 root: &Path,
3704 ) -> (licoup_conversation::ConversationStore, String, String) {
3705 let conversation_store = licoup_conversation::ConversationStore::open(root).unwrap();
3706 let conversation = conversation_store
3707 .create_conversation(
3708 "Group",
3709 Principal {
3710 id: "human:local".into(),
3711 kind: PrincipalKind::Human,
3712 display_name: "You".into(),
3713 agent_id: None,
3714 created_at_unix_ms: 1,
3715 },
3716 )
3717 .unwrap();
3718 let membership = conversation_store
3719 .add_member(
3720 &conversation.id,
3721 Principal {
3722 id: "agent:entry".into(),
3723 kind: PrincipalKind::Agent,
3724 display_name: "Entry".into(),
3725 agent_id: Some("entry-agent".into()),
3726 created_at_unix_ms: 1,
3727 },
3728 MembershipAccess::Member,
3729 )
3730 .unwrap();
3731 let owner_membership_id = conversation
3732 .memberships
3733 .iter()
3734 .find(|candidate| candidate.principal.kind == PrincipalKind::Human)
3735 .unwrap()
3736 .id
3737 .clone();
3738 let revision = conversation_store.get(&conversation.id).unwrap().revision;
3739 conversation_store
3740 .set_conversation_assistant(
3741 &conversation.id,
3742 &owner_membership_id,
3743 revision,
3744 Some(&membership.id),
3745 )
3746 .unwrap();
3747 (conversation_store, conversation.id, membership.id)
3748 }
3749
3750 struct FixtureProfileAuthority {
3751 readiness: &'static str,
3752 calls: Arc<Mutex<BTreeMap<&'static str, usize>>>,
3753 }
3754
3755 impl crate::ProfileSnapshotAuthority for FixtureProfileAuthority {
3756 fn target_facts(&mut self, _agent_id: &str) -> Option<crate::TargetFacts> {
3757 *self.calls.lock().unwrap().entry("target").or_default() += 1;
3758 Some(crate::TargetFacts {
3759 status: Some("available".to_owned()),
3760 model: Some("model-a".to_owned()),
3761 environment: Some("local".to_owned()),
3762 capabilities: vec!["conversationDriver:supported".to_owned()],
3763 readiness: Some(self.readiness.to_owned()),
3764 reliability_class: Some("verified".to_owned()),
3765 latency_class: Some(1),
3766 })
3767 }
3768
3769 fn model_price_usd_per_million_tokens(
3770 &mut self,
3771 _model: &str,
3772 ) -> Option<crate::PriceFacts> {
3773 *self.calls.lock().unwrap().entry("price").or_default() += 1;
3774 Some(crate::PriceFacts {
3775 input: 1.0,
3776 output: 2.0,
3777 })
3778 }
3779
3780 fn coding_score(&mut self, _agent_id: &str, _model: &str) -> Option<i64> {
3781 *self.calls.lock().unwrap().entry("score").or_default() += 1;
3782 Some(3)
3783 }
3784
3785 fn skill_names(&mut self, _agent_id: &str) -> Vec<String> {
3786 *self.calls.lock().unwrap().entry("skills").or_default() += 1;
3787 vec![licoup_conversation::LICOUP_GUIDE_SKILL_ID.to_owned()]
3788 }
3789 }
3790
3791 fn fixture_profile_authority(
3792 readiness: &'static str,
3793 calls: Arc<Mutex<BTreeMap<&'static str, usize>>>,
3794 ) -> crate::SharedSnapshotAuthority {
3795 Arc::new(Mutex::new(Box::new(FixtureProfileAuthority {
3796 readiness,
3797 calls,
3798 })))
3799 }
3800
3801 struct MutatingProfileAuthority {
3802 store: licoup_conversation::ConversationStore,
3803 conversation_id: String,
3804 membership_id: String,
3805 owner_membership_id: String,
3806 calls: Arc<Mutex<BTreeMap<&'static str, usize>>>,
3807 mutated: bool,
3808 }
3809
3810 impl crate::ProfileSnapshotAuthority for MutatingProfileAuthority {
3811 fn target_facts(&mut self, _agent_id: &str) -> Option<crate::TargetFacts> {
3812 *self.calls.lock().unwrap().entry("target").or_default() += 1;
3813 Some(crate::TargetFacts {
3814 model: Some("model-a".to_owned()),
3815 environment: Some("local".to_owned()),
3816 capabilities: vec!["conversationDriver:supported".to_owned()],
3817 readiness: Some("ready".to_owned()),
3818 ..Default::default()
3819 })
3820 }
3821
3822 fn model_price_usd_per_million_tokens(
3823 &mut self,
3824 _model: &str,
3825 ) -> Option<crate::PriceFacts> {
3826 *self.calls.lock().unwrap().entry("price").or_default() += 1;
3827 Some(crate::PriceFacts {
3828 input: 1.0,
3829 output: 2.0,
3830 })
3831 }
3832
3833 fn coding_score(&mut self, _agent_id: &str, _model: &str) -> Option<i64> {
3834 *self.calls.lock().unwrap().entry("score").or_default() += 1;
3835 Some(3)
3836 }
3837
3838 fn skill_names(&mut self, _agent_id: &str) -> Vec<String> {
3839 *self.calls.lock().unwrap().entry("skills").or_default() += 1;
3840 if !self.mutated {
3841 let revision = self
3842 .store
3843 .membership_profile(&self.membership_id)
3844 .unwrap()
3845 .unwrap()
3846 .revision;
3847 self.store
3848 .set_membership_profile(
3849 &self.conversation_id,
3850 &self.membership_id,
3851 &self.owner_membership_id,
3852 revision,
3853 &licoup_conversation::ProfileIntentUpdate {
3854 preferred_capabilities: vec!["changed-during-preflight".to_owned()],
3855 ..Default::default()
3856 },
3857 )
3858 .unwrap();
3859 self.mutated = true;
3860 }
3861 vec![licoup_conversation::LICOUP_GUIDE_SKILL_ID.to_owned()]
3862 }
3863 }
3864
3865 fn authorized_entry_store(root: &Path, membership_id: &str) -> (StrategyStore, String) {
3866 let store = StrategyStore::open(root).unwrap();
3867 let revision = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
3868 store
3869 .register_definition(revision, revision, &entry_workflow(), 1, 1)
3870 .unwrap();
3871 store
3872 .replace_slot_bindings(
3873 revision,
3874 "entry",
3875 &[BindingCandidate {
3876 value_id: membership_id.to_owned(),
3877 model: String::new(),
3878 reasoning_effort: String::new(),
3879 }],
3880 None,
3881 )
3882 .unwrap();
3883 let preview = store.authorization_preview(revision).unwrap();
3884 store
3885 .grant_authorization(revision, &preview.authorization_digest)
3886 .unwrap();
3887 (store, revision.to_owned())
3888 }
3889
3890 fn recording_port(
3891 calls: Arc<Mutex<Vec<(String, Value)>>>,
3892 run: impl Fn(&str, &Value) -> std::result::Result<Value, ActorTurnError> + Send + Sync + 'static,
3893 ) -> ActorTurnPort {
3894 let open_calls = Arc::clone(&calls);
3895 ActorTurnPort {
3896 open: Arc::new(move |params| {
3897 let mut recorded = open_calls.lock().unwrap();
3898 let handle = format!("dispatch:entry-{}", recorded.len() + 1);
3899 recorded.push(("open".to_owned(), params.clone()));
3900 Ok(handle)
3901 }),
3902 run: Arc::new(move |handle, params| {
3903 calls
3904 .lock()
3905 .unwrap()
3906 .push((format!("run:{handle}"), params.clone()));
3907 run(handle, params)
3908 }),
3909 cancel: Arc::new(|_| TurnCancelDisposition::NoActiveTurn),
3910 abandon: Arc::new(|_| {}),
3911 }
3912 }
3913
3914 fn wait_for_terminal(store: &StrategyStore, run_id: &str) -> RunSnapshot {
3915 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
3916 loop {
3917 let snapshot = store.run(run_id).unwrap();
3918 if matches!(
3919 snapshot.status,
3920 StrategyRunStatus::Completed
3921 | StrategyRunStatus::Failed
3922 | StrategyRunStatus::Blocked
3923 | StrategyRunStatus::Cancelled
3924 ) {
3925 return snapshot;
3926 }
3927 assert!(
3928 std::time::Instant::now() < deadline,
3929 "drive did not settle the run"
3930 );
3931 std::thread::sleep(std::time::Duration::from_millis(20));
3932 }
3933 }
3934
3935 fn remove_drive_root(root: PathBuf, service: StrategyService, run_ids: &[&str]) {
3939 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
3945 while run_ids
3946 .iter()
3947 .any(|run_id| DriveReservation::is_active(run_id))
3948 {
3949 assert!(
3950 std::time::Instant::now() < deadline,
3951 "drive thread did not exit"
3952 );
3953 std::thread::sleep(std::time::Duration::from_millis(20));
3954 }
3955 drop(service);
3956 for attempt in 0..100 {
3957 let root = root.clone();
3958 if std::panic::catch_unwind(|| remove_root(root)).is_ok() {
3959 return;
3960 }
3961 assert!(
3962 std::time::Instant::now() < deadline,
3963 "drive root cleanup did not settle"
3964 );
3965 std::thread::sleep(std::time::Duration::from_millis(20 + attempt.min(30)));
3966 }
3967 }
3968
3969 #[test]
3970 fn run_actions_without_the_host_runtime_are_fail_closed() {
3971 let root = root();
3972 let service = StrategyService::open(&root).unwrap();
3973 for request in [
3974 json!({
3975 "action": "strategy.run.start",
3976 "revisionDigest": "rev",
3977 "idempotencyKey": "key-1"
3978 }),
3979 json!({"action": "strategy.run.resume", "runId": "run-1"}),
3980 json!({"action": "strategy.run.retry", "runId": "run-1"}),
3981 ] {
3982 let response = service.execute(request).unwrap();
3983 assert_eq!(response["ok"], false);
3984 assert_eq!(
3985 response["error"]["code"],
3986 licoup_conversation::PERSISTENT_TRANSPORT_REQUIRED
3987 );
3988 }
3989 let store = StrategyStore::open(&root).unwrap();
3990 assert!(
3991 store
3992 .active_run_for_conversation("rev", "conversation:none")
3993 .unwrap()
3994 .is_none(),
3995 "no run was persisted without the host runtime"
3996 );
3997 remove_root(root);
3998 }
3999
4000 #[test]
4001 fn run_start_registers_the_entry_turn_before_the_drive_runs_it() {
4002 let root = root();
4003 let (_conversation_store, conversation_id, membership_id) =
4004 conversation_bound_fixture(&root);
4005 let (store, revision) = authorized_entry_store(&root, &membership_id);
4006 let calls = Arc::new(Mutex::new(Vec::<(String, Value)>::new()));
4007 let port = recording_port(Arc::clone(&calls), |_, _| {
4008 Ok(json!({"ok": true, "output": "done", "nativeSessionId": "session-1"}))
4009 });
4010 let service = StrategyService::from_parts(
4011 root.clone(),
4012 store.clone(),
4013 StrategyPackageImporter::open(&root).unwrap(),
4014 )
4015 .with_actor_turn_port(port);
4016
4017 let response = service
4018 .execute(json!({
4019 "action": "strategy.run.start",
4020 "revisionDigest": revision,
4021 "input": {"message": "hi"},
4022 "idempotencyKey": "start-1",
4023 "conversationId": conversation_id,
4024 }))
4025 .unwrap();
4026 assert_eq!(response["ok"], true);
4027 let run_id = response["result"]["runId"].as_str().unwrap().to_owned();
4028 assert_eq!(
4029 response["result"]["entryTurn"]["turnHandle"],
4030 "dispatch:entry-1"
4031 );
4032 assert_eq!(
4033 response["result"]["entryTurn"]["membershipId"],
4034 membership_id.as_str()
4035 );
4036 {
4037 let recorded = calls.lock().unwrap();
4038 assert!(!recorded.is_empty());
4039 assert_eq!(recorded[0].0, "open");
4040 assert_eq!(recorded[0].1["text"], "hi");
4041 assert_eq!(recorded[0].1["conversationId"], conversation_id.as_str());
4042 assert_eq!(recorded[0].1["membershipId"], membership_id.as_str());
4043 assert_eq!(recorded[0].1["agent"], "entry-agent");
4044 assert_eq!(recorded[0].1["causationId"], run_id.as_str());
4045 }
4046
4047 let snapshot = wait_for_terminal(&store, &run_id);
4048 assert_eq!(snapshot.status, StrategyRunStatus::Completed);
4049 let recorded = calls.lock().unwrap();
4050 assert_eq!(recorded.len(), 2);
4051 assert_eq!(recorded[1].0, "run:dispatch:entry-1");
4052 drop(recorded);
4053 remove_drive_root(root, service, &[&*run_id]);
4054 }
4055
4056 fn assistant_workflow_json() -> Value {
4057 serde_json::json!({
4058 "schema": "licoup.adaptive-flywheel.workflow.v1",
4059 "metadata": {
4060 "id": "assistant-temporary",
4061 "name": "Assistant Temporary Graph",
4062 "version": "1"
4063 },
4064 "limits": {"maxParallelism": 2, "maxWorksetItems": 16, "maxAttempts": 2},
4065 "actorSlots": [
4066 {"id": "subagent-a", "kind": "actor", "label": "Subagent A", "required": true, "entry": true}
4067 ],
4068 "runtimes": [],
4069 "worksets": [],
4070 "initial": "running",
4071 "states": [
4072 {"id": "running", "kind": "actor", "label": "Running", "binding": "subagent-a"},
4073 {"id": "done", "kind": "succeed", "label": "Done"},
4074 {"id": "failed", "kind": "fail", "label": "Failed"}
4075 ],
4076 "transitions": [
4077 {"id": "succeeded", "from": "running", "event": "success", "to": "done"},
4078 {"id": "failed", "from": "running", "event": "failure", "to": "failed"}
4079 ]
4080 })
4081 }
4082
4083 #[test]
4084 fn assistant_graph_start_preflights_and_replays_without_duplicate_effects() {
4085 let root = root();
4086 let (_conversation_store, conversation_id, membership_id) =
4087 conversation_bound_fixture(&root);
4088 let calls = Arc::new(Mutex::new(Vec::<(String, Value)>::new()));
4089 let port = recording_port(Arc::clone(&calls), |_, _| {
4090 Ok(json!({"ok": true, "output": "done", "nativeSessionId": "session-1"}))
4091 });
4092 let service = StrategyService::from_parts(
4093 root.clone(),
4094 StrategyStore::open(&root).unwrap(),
4095 StrategyPackageImporter::open(&root).unwrap(),
4096 )
4097 .with_actor_turn_port(port);
4098
4099 let workflow = assistant_workflow_json();
4100 let bindings = json!([{
4101 "slotId": "subagent-a",
4102 "ordinal": 0,
4103 "valueId": membership_id,
4104 "model": "model-a",
4105 "reasoningEffort": "",
4106 "revision": 1
4107 }]);
4108 let authority_calls = Arc::new(Mutex::new(BTreeMap::new()));
4109 let service = service.with_profile_snapshot_authority(fixture_profile_authority(
4110 "ready",
4111 Arc::clone(&authority_calls),
4112 ));
4113 let request = || {
4114 json!({
4115 "action": "strategy.assistant.workflow.execute",
4116 "selectionPolicyRevision": FIXTURE_SELECTION_POLICY_REVISION,
4117 "conversationId": conversation_id,
4118 "membershipId": membership_id,
4119 "workflow": workflow,
4120 "bindings": bindings,
4121 "input": {"message": "hi"},
4122 "idempotencyKey": String::from("assistant-start-1")
4123 })
4124 };
4125 let response = service.execute(request()).unwrap();
4126 assert_eq!(response["ok"], true, "{response}");
4127 assert_eq!(response["result"]["preflight"]["accepted"], true);
4128 assert!(response["result"].get("entrySessionId").is_none());
4129 assert!(response["result"].get("definition").is_none());
4130 assert!(response["result"].get("allowedOperations").is_none());
4131 let run_id = response["result"]["runId"].as_str().unwrap().to_owned();
4132 assert_eq!(
4133 response["result"]["preflight"]["receipt"]["membershipIds"][0],
4134 membership_id.as_str()
4135 );
4136
4137 let snapshot = wait_for_terminal(service.store(), &run_id);
4138 assert_eq!(snapshot.status, StrategyRunStatus::Completed);
4139 {
4140 let recorded = calls.lock().unwrap();
4141 assert_eq!(recorded.len(), 2);
4142 assert_eq!(recorded[0].0, "open");
4143 assert_eq!(recorded[1].0, "run:dispatch:entry-1");
4144 }
4145 assert_eq!(
4146 authority_calls.lock().unwrap().clone(),
4147 BTreeMap::from([("price", 1), ("score", 1), ("skills", 1), ("target", 1),]),
4148 "derived owners are read once for one execute request"
4149 );
4150
4151 let inspect = service
4152 .execute(json!({"action": "strategy.assistant.workflow.inspect", "runId": run_id}))
4153 .unwrap();
4154 assert_eq!(inspect["ok"], true);
4155 assert_eq!(inspect["result"]["terminal"]["status"], "completed");
4156 assert!(inspect["result"].get("entrySessionId").is_none());
4157
4158 let replay = service.execute(request()).unwrap();
4159 assert_eq!(replay["ok"], true, "{replay}");
4160 assert_eq!(replay["result"]["runId"].as_str().unwrap(), run_id.as_str());
4161 {
4162 let recorded = calls.lock().unwrap();
4163 assert_eq!(
4164 recorded.len(),
4165 2,
4166 "idempotent replay must not open a turn or duplicate an effect"
4167 );
4168 }
4169 drop(service);
4170 remove_root(root);
4171 }
4172
4173 #[test]
4174 fn assistant_runtime_failure_returns_once_without_retry_or_fallback() {
4175 let root = root();
4176 let (_conversation_store, conversation_id, membership_id) =
4177 conversation_bound_fixture(&root);
4178 let calls = Arc::new(Mutex::new(Vec::<(String, Value)>::new()));
4179 let port = recording_port(Arc::clone(&calls), |_, _| {
4180 Err(ActorTurnError::dispatch_failed(
4181 "conversation_dispatch_failed",
4182 ))
4183 });
4184 let authority_calls = Arc::new(Mutex::new(BTreeMap::new()));
4185 let service = StrategyService::from_parts(
4186 root.clone(),
4187 StrategyStore::open(&root).unwrap(),
4188 StrategyPackageImporter::open(&root).unwrap(),
4189 )
4190 .with_actor_turn_port(port)
4191 .with_profile_snapshot_authority(fixture_profile_authority("ready", authority_calls));
4192 let request = || {
4193 json!({
4194 "action": "strategy.assistant.workflow.execute",
4195 "selectionPolicyRevision": FIXTURE_SELECTION_POLICY_REVISION,
4196 "conversationId": conversation_id,
4197 "membershipId": membership_id,
4198 "workflow": assistant_workflow_json(),
4199 "bindings": [{
4200 "slotId": "subagent-a",
4201 "ordinal": 0,
4202 "valueId": membership_id,
4203 "model": "model-a",
4204 "reasoningEffort": "",
4205 "revision": 1
4206 }],
4207 "input": {"message": "hi"},
4208 "idempotencyKey": String::from("assistant-runtime-failure")
4209 })
4210 };
4211
4212 let response = service.execute(request()).unwrap();
4213 assert_eq!(response["ok"], true, "{response}");
4214 assert_eq!(response["result"]["status"], "failed");
4215 assert_eq!(response["result"]["terminal"]["status"], "failed");
4216 assert_eq!(
4217 response["result"]["terminal"]["code"],
4218 "effect_temporarily_unavailable"
4219 );
4220 assert_eq!(response["result"]["terminal"]["retryable"], false);
4221 assert_eq!(
4222 response["result"]["terminal"]["recoveryClass"],
4223 "assistant-direct-or-new-graph"
4224 );
4225 let run_id = response["result"]["runId"].as_str().unwrap();
4226 let snapshot = service.store().run(run_id).unwrap();
4227 assert!(snapshot.fallbacks.is_empty());
4228 assert!(snapshot.commands.values().all(|command| {
4229 !matches!(
4230 command.status,
4231 CommandStatus::Pending | CommandStatus::Retryable
4232 )
4233 }));
4234 assert_eq!(calls.lock().unwrap().len(), 2, "one open and one effect");
4235
4236 let replay = service.execute(request()).unwrap();
4237 assert_eq!(replay["result"]["runId"], response["result"]["runId"]);
4238 assert_eq!(
4239 calls.lock().unwrap().len(),
4240 2,
4241 "terminal replay must not duplicate the failed effect"
4242 );
4243 drop(service);
4244 remove_root(root);
4245 }
4246
4247 #[test]
4248 fn assistant_entry_registration_failure_settles_the_admitted_graph_once() {
4249 let root = root();
4250 let (_conversation_store, conversation_id, membership_id) =
4251 conversation_bound_fixture(&root);
4252 let opens = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4253 let recorded_opens = Arc::clone(&opens);
4254 let authority_calls = Arc::new(Mutex::new(BTreeMap::new()));
4255 let service = StrategyService::from_parts(
4256 root.clone(),
4257 StrategyStore::open(&root).unwrap(),
4258 StrategyPackageImporter::open(&root).unwrap(),
4259 )
4260 .with_actor_turn_port(ActorTurnPort {
4261 open: Arc::new(move |_| {
4262 recorded_opens.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4263 Err(ActorTurnError::dispatch_failed(
4264 "conversation_dispatch_failed",
4265 ))
4266 }),
4267 run: Arc::new(|_, _| panic!("a rejected registration has no effect to run")),
4268 cancel: Arc::new(|_| TurnCancelDisposition::NoActiveTurn),
4269 abandon: Arc::new(|_| {}),
4270 })
4271 .with_profile_snapshot_authority(fixture_profile_authority("ready", authority_calls));
4272 let request = || {
4273 json!({
4274 "action": "strategy.assistant.workflow.execute",
4275 "selectionPolicyRevision": FIXTURE_SELECTION_POLICY_REVISION,
4276 "conversationId": conversation_id,
4277 "membershipId": membership_id,
4278 "workflow": assistant_workflow_json(),
4279 "bindings": [{
4280 "slotId": "subagent-a",
4281 "ordinal": 0,
4282 "valueId": membership_id,
4283 "model": "model-a",
4284 "reasoningEffort": "",
4285 "revision": 1
4286 }],
4287 "input": {"message": "hi"},
4288 "idempotencyKey": String::from("assistant-entry-registration-failure")
4289 })
4290 };
4291
4292 let response = service.execute(request()).unwrap();
4293 assert_eq!(response["ok"], true, "{response}");
4294 assert_eq!(response["result"]["status"], "failed");
4295 assert_eq!(response["result"]["terminal"]["status"], "failed");
4296 assert_eq!(opens.load(std::sync::atomic::Ordering::SeqCst), 1);
4297 let run_id = response["result"]["runId"].as_str().unwrap();
4298 let snapshot = service.store().run(run_id).unwrap();
4299 assert!(snapshot.commands.values().all(|command| {
4300 matches!(
4301 command.status,
4302 CommandStatus::Failed | CommandStatus::Cancelled
4303 )
4304 }));
4305
4306 let replay = service.execute(request()).unwrap();
4307 assert_eq!(replay["result"]["runId"], response["result"]["runId"]);
4308 assert_eq!(
4309 opens.load(std::sync::atomic::Ordering::SeqCst),
4310 1,
4311 "terminal replay must not attempt registration again"
4312 );
4313 drop(service);
4314 remove_root(root);
4315 }
4316
4317 #[test]
4318 fn assistant_graph_preflight_rejects_before_any_actor_effect() {
4319 let root = root();
4320 let (_conversation_store, conversation_id, membership_id) =
4321 conversation_bound_fixture(&root);
4322 let calls = Arc::new(Mutex::new(Vec::<(String, Value)>::new()));
4323 let port = recording_port(Arc::clone(&calls), |_, _| {
4324 panic!("a rejected preflight must never reach the actor port")
4325 });
4326 let service = StrategyService::from_parts(
4327 root.clone(),
4328 StrategyStore::open(&root).unwrap(),
4329 StrategyPackageImporter::open(&root).unwrap(),
4330 )
4331 .with_actor_turn_port(port);
4332
4333 let authority_calls = Arc::new(Mutex::new(BTreeMap::new()));
4334 let service = service
4335 .with_profile_snapshot_authority(fixture_profile_authority("busy", authority_calls));
4336 let response = service
4337 .execute(json!({
4338 "action": "strategy.assistant.workflow.execute",
4339 "selectionPolicyRevision": FIXTURE_SELECTION_POLICY_REVISION,
4340 "conversationId": conversation_id,
4341 "membershipId": membership_id,
4342 "workflow": assistant_workflow_json(),
4343 "bindings": [{
4344 "slotId": "subagent-a",
4345 "ordinal": 0,
4346 "valueId": membership_id,
4347 "model": "model-a",
4348 "reasoningEffort": "",
4349 "revision": 1
4350 }],
4351 "input": {"message": "hi"},
4352 "idempotencyKey": String::from("assistant-start-rejected")
4353 }))
4354 .unwrap();
4355 assert_eq!(response["ok"], true);
4356 assert_eq!(response["result"]["accepted"], false);
4357 assert_eq!(
4358 response["result"]["error"]["code"],
4359 "graph_preflight_rejected"
4360 );
4361 assert!(
4362 response["result"]["error"]["diagnostics"]
4363 .as_array()
4364 .unwrap()
4365 .iter()
4366 .any(|check| check["code"] == "graph_readiness_rejected")
4367 );
4368 {
4369 let recorded = calls.lock().unwrap();
4370 assert!(
4371 recorded.is_empty(),
4372 "zero actor effects are allowed before preflight admission"
4373 );
4374 }
4375 drop(service);
4376 remove_root(root);
4377 }
4378
4379 #[test]
4380 fn assistant_graph_aggregates_compiler_profile_and_binding_failures_deterministically() {
4381 let root = root();
4382 let (_conversation_store, conversation_id, membership_id) =
4383 conversation_bound_fixture(&root);
4384 let calls = Arc::new(Mutex::new(Vec::<(String, Value)>::new()));
4385 let port = recording_port(Arc::clone(&calls), |_, _| {
4386 panic!("a rejected multi-pass preflight must never reach the actor port")
4387 });
4388 let strategy_store = StrategyStore::open(&root).unwrap();
4389 let service = StrategyService::from_parts(
4390 root.clone(),
4391 strategy_store.clone(),
4392 StrategyPackageImporter::open(&root).unwrap(),
4393 )
4394 .with_actor_turn_port(port)
4395 .with_profile_snapshot_authority(fixture_profile_authority(
4396 "busy",
4397 Arc::new(Mutex::new(BTreeMap::new())),
4398 ));
4399 let mut workflow = assistant_workflow_json();
4400 workflow["metadata"]["id"] = json!("imported-workflow");
4401 workflow["metadata"]["name"] = json!(" private-workspace-sentinel ");
4402 workflow["limits"]["maxParallelism"] = json!(0);
4403 workflow["states"][0]["binding"] = json!("private-workspace-sentinel");
4404 workflow["transitions"][0]["to"] = json!("private-workspace-sentinel");
4405 let request = || {
4406 json!({
4407 "action": "strategy.assistant.workflow.execute",
4408 "selectionPolicyRevision": FIXTURE_SELECTION_POLICY_REVISION,
4409 "conversationId": conversation_id,
4410 "membershipId": membership_id,
4411 "workflow": workflow,
4412 "bindings": [],
4413 "input": {"message": "hi"},
4414 "idempotencyKey": String::from("assistant-multi-pass-rejected")
4415 })
4416 };
4417
4418 let first = service.execute(request()).unwrap();
4419 let second = service.execute(request()).unwrap();
4420 assert_eq!(first, second);
4421 assert_eq!(first["ok"], true);
4422 assert_eq!(first["result"]["accepted"], false);
4423 assert_eq!(first["result"]["error"]["code"], "graph_preflight_rejected");
4424 let diagnostics = first["result"]["error"]["diagnostics"].as_array().unwrap();
4425 for code in [
4426 "workflow_metadata_name_invalid",
4427 "workflow_parallelism_invalid",
4428 "workflow_actor_binding_invalid",
4429 "workflow_transition_state_unknown",
4430 "graph_identity_not_assistant_temporary",
4431 "graph_readiness_rejected",
4432 "graph_binding_incomplete",
4433 ] {
4434 assert!(
4435 diagnostics
4436 .iter()
4437 .any(|diagnostic| diagnostic["code"] == code),
4438 "missing {code}: {diagnostics:?}"
4439 );
4440 }
4441 let serialized = serde_json::to_string(&first).unwrap();
4442 assert!(!serialized.contains("private-workspace-sentinel"));
4443 assert!(!serialized.contains("checks"));
4444 assert!(calls.lock().unwrap().is_empty());
4445 assert!(
4446 strategy_store
4447 .run_id_by_idempotency_key("assistant-multi-pass-rejected")
4448 .unwrap()
4449 .is_none()
4450 );
4451 drop(service);
4452 remove_root(root);
4453 }
4454
4455 #[test]
4456 fn workflow_error_projection_keeps_the_exact_typed_list() {
4457 let source = json!({
4458 "schema": "licoup.adaptive-flywheel.workflow.v1",
4459 "metadata": {"id": "private-workspace-sentinel", "name": 3},
4460 "initial": false,
4461 "states": [],
4462 "transitions": []
4463 });
4464 let validation = compile_workflow_value(&source).unwrap_err();
4465 let expected = serde_json::to_value(&validation.diagnostics).unwrap();
4466 let error: anyhow::Error = validation.into();
4467 let projected = error_projection(&error);
4468 assert_eq!(projected["code"], "workflow_invalid");
4469 assert_eq!(projected["diagnostics"], expected);
4470 assert!(
4471 !serde_json::to_string(&projected)
4472 .unwrap()
4473 .contains("private-workspace-sentinel")
4474 );
4475 }
4476
4477 #[test]
4478 fn assistant_route_revision_change_rejects_before_admission_or_effect() {
4479 let root = root();
4480 let (conversation_store, conversation_id, membership_id) =
4481 conversation_bound_fixture(&root);
4482 let owner_membership_id = conversation_store
4483 .get(&conversation_id)
4484 .unwrap()
4485 .memberships
4486 .into_iter()
4487 .find(|membership| membership.principal.kind == PrincipalKind::Human)
4488 .unwrap()
4489 .id;
4490 let authority_calls = Arc::new(Mutex::new(BTreeMap::new()));
4491 let authority: crate::SharedSnapshotAuthority =
4492 Arc::new(Mutex::new(Box::new(MutatingProfileAuthority {
4493 store: conversation_store,
4494 conversation_id: conversation_id.clone(),
4495 membership_id: membership_id.clone(),
4496 owner_membership_id,
4497 calls: Arc::clone(&authority_calls),
4498 mutated: false,
4499 })));
4500 let strategy_store = StrategyStore::open(&root).unwrap();
4501 let service = StrategyService::from_parts(
4502 root.clone(),
4503 strategy_store.clone(),
4504 StrategyPackageImporter::open(&root).unwrap(),
4505 )
4506 .with_actor_turn_port(ActorTurnPort {
4507 open: Arc::new(|_| panic!("a stale route must reject before registration")),
4508 run: Arc::new(|_, _| panic!("a stale route must reject before effects")),
4509 cancel: Arc::new(|_| TurnCancelDisposition::NoActiveTurn),
4510 abandon: Arc::new(|_| {}),
4511 })
4512 .with_profile_snapshot_authority(authority);
4513
4514 let response = service
4515 .execute(json!({
4516 "action": "strategy.assistant.workflow.execute",
4517 "selectionPolicyRevision": FIXTURE_SELECTION_POLICY_REVISION,
4518 "conversationId": conversation_id,
4519 "membershipId": membership_id,
4520 "workflow": assistant_workflow_json(),
4521 "bindings": [{
4522 "slotId": "subagent-a",
4523 "ordinal": 0,
4524 "valueId": membership_id,
4525 "model": "model-a",
4526 "reasoningEffort": "",
4527 "revision": 1
4528 }],
4529 "input": {"message": "hi"},
4530 "idempotencyKey": String::from("assistant-stale-route")
4531 }))
4532 .unwrap();
4533 assert_eq!(response["ok"], true, "{response}");
4534 assert_eq!(response["result"]["accepted"], false);
4535 assert!(
4536 response["result"]["error"]["diagnostics"]
4537 .as_array()
4538 .unwrap()
4539 .iter()
4540 .any(|check| check["code"] == "graph_route_stale")
4541 );
4542 assert!(
4543 strategy_store
4544 .run_id_by_idempotency_key("assistant-stale-route")
4545 .unwrap()
4546 .is_none(),
4547 "the route revision check precedes durable admission"
4548 );
4549 assert_eq!(
4550 authority_calls.lock().unwrap().clone(),
4551 BTreeMap::from([("price", 1), ("score", 1), ("skills", 1), ("target", 1),]),
4552 );
4553 drop(service);
4554 remove_root(root);
4555 }
4556
4557 #[test]
4558 fn entry_open_failure_is_a_real_typed_start_failure() {
4559 let root = root();
4560 let (conversation_store, conversation_id, membership_id) =
4561 conversation_bound_fixture(&root);
4562 let (store, revision) = authorized_entry_store(&root, &membership_id);
4563 let service = StrategyService::from_parts(
4564 root.clone(),
4565 store.clone(),
4566 StrategyPackageImporter::open(&root).unwrap(),
4567 )
4568 .with_actor_turn_port(ActorTurnPort {
4569 open: Arc::new(|_| {
4570 Err(ActorTurnError::dispatch_failed(
4571 "conversation_dispatch_failed",
4572 ))
4573 }),
4574 run: Arc::new(|_, _| panic!("a failed registration must not run")),
4575 cancel: Arc::new(|_| TurnCancelDisposition::NoActiveTurn),
4576 abandon: Arc::new(|_| {}),
4577 });
4578
4579 let response = service
4580 .execute(json!({
4581 "action": "strategy.run.start",
4582 "revisionDigest": revision,
4583 "input": {"message": "hi"},
4584 "idempotencyKey": String::from("start-open-failure"),
4585 "conversationId": conversation_id,
4586 }))
4587 .unwrap();
4588 assert_eq!(response["ok"], false);
4589 assert_eq!(response["error"]["code"], "strategy_run_start_failed");
4590 assert_eq!(response["error"]["stage"], "strategy/start");
4591 let run_id = store
4592 .run_id_by_idempotency_key("start-open-failure")
4593 .unwrap()
4594 .expect("imported start failure keeps the admitted run");
4595 let snapshot = store.run(&run_id).unwrap();
4596 assert_eq!(snapshot.status, StrategyRunStatus::Failed);
4597 let report_event = wait_for_master_report(
4598 &conversation_store,
4599 &conversation_id,
4600 "strategy-terminal-outcome",
4601 );
4602 assert_eq!(
4603 report_event.author_membership_id.as_deref(),
4604 Some(membership_id.as_str())
4605 );
4606 drop(service);
4607 remove_root(root);
4608 }
4609
4610 fn blocked_run_fixture(
4613 root: &Path,
4614 cancel: TurnCancelDisposition,
4615 ) -> (
4616 StrategyService,
4617 StrategyStore,
4618 String,
4619 std::sync::mpsc::Sender<()>,
4620 Arc<Mutex<Vec<String>>>,
4621 ) {
4622 let (_conversation_store, conversation_id, membership_id) =
4623 conversation_bound_fixture(root);
4624 let (store, revision) = authorized_entry_store(root, &membership_id);
4625 let (release, released) = std::sync::mpsc::channel::<()>();
4626 let released = Mutex::new(released);
4627 let (started, run_started) = std::sync::mpsc::channel::<()>();
4628 let started = Mutex::new(started);
4629 let cancels = Arc::new(Mutex::new(Vec::<String>::new()));
4630 let cancel_calls = Arc::clone(&cancels);
4631 let service = StrategyService::from_parts(
4632 root.to_path_buf(),
4633 store.clone(),
4634 StrategyPackageImporter::open(root).unwrap(),
4635 )
4636 .with_actor_turn_port(ActorTurnPort {
4637 open: Arc::new(|_| Ok("dispatch:live".to_owned())),
4638 run: Arc::new(move |_, _| {
4639 let _ = started.lock().unwrap().send(());
4640 let _ = released
4641 .lock()
4642 .unwrap()
4643 .recv_timeout(std::time::Duration::from_secs(10));
4644 Ok(json!({"ok": true, "output": "late-effect"}))
4645 }),
4646 cancel: Arc::new(move |handle| {
4647 cancel_calls.lock().unwrap().push(handle.to_owned());
4648 cancel
4649 }),
4650 abandon: Arc::new(|_| {}),
4651 });
4652 let response = service
4653 .execute(json!({
4654 "action": "strategy.run.start",
4655 "revisionDigest": revision,
4656 "input": {"message": "hi"},
4657 "idempotencyKey": String::from("cancel-fixture"),
4658 "conversationId": conversation_id,
4659 }))
4660 .unwrap();
4661 assert_eq!(response["ok"], true);
4662 let run_id = response["result"]["runId"].as_str().unwrap().to_owned();
4663 run_started
4666 .recv_timeout(std::time::Duration::from_secs(10))
4667 .expect("the drive reached its executing turn");
4668 (service, store, run_id, release, cancels)
4669 }
4670
4671 fn wait_for_running_command(store: &StrategyStore, run_id: &str) -> RunSnapshot {
4672 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
4673 loop {
4674 let snapshot = store.run(run_id).unwrap();
4675 if snapshot
4676 .commands
4677 .values()
4678 .any(|command| command.status == CommandStatus::Running)
4679 {
4680 return snapshot;
4681 }
4682 assert!(
4683 std::time::Instant::now() < deadline,
4684 "run never claimed its effect: {:?}",
4685 snapshot.status
4686 );
4687 std::thread::sleep(std::time::Duration::from_millis(20));
4688 }
4689 }
4690
4691 fn activity_events(root: &Path) -> Vec<Value> {
4692 let path = root.join("client-state/activity/activity.jsonl");
4693 let text = fs::read_to_string(&path).unwrap_or_default();
4694 text.lines()
4695 .filter(|line| !line.trim().is_empty())
4696 .map(|line| serde_json::from_str::<Value>(line).unwrap())
4697 .collect()
4698 }
4699
4700 #[test]
4701 fn run_cancel_reaches_the_executing_turn_and_is_not_recorded_as_unknown() {
4702 let root = root();
4703 let (service, store, run_id, release, cancels) =
4704 blocked_run_fixture(&root, TurnCancelDisposition::Accepted);
4705 wait_for_running_command(&store, &run_id);
4706
4707 let cancelled = service
4708 .execute(json!({
4709 "action": "strategy.run.cancel",
4710 "runId": run_id,
4711 }))
4712 .unwrap();
4713 assert_eq!(cancelled["ok"], true);
4714 assert_eq!(
4715 cancels.lock().unwrap().as_slice(),
4716 ["dispatch:live".to_owned()],
4717 "cancel reaches the exact live turn handle"
4718 );
4719 let snapshot = store.run(&run_id).unwrap();
4720 assert_eq!(snapshot.status, StrategyRunStatus::Cancelled);
4721 assert!(snapshot.commands.values().all(|command| {
4722 command.status == CommandStatus::Cancelled
4723 && command.failure_class != Some(FailureClass::InDoubt)
4724 }));
4725
4726 release.send(()).unwrap();
4729 let settled = wait_for_terminal(&store, &run_id);
4730 assert_eq!(settled.status, StrategyRunStatus::Cancelled);
4731 assert!(
4732 settled
4733 .commands
4734 .values()
4735 .all(|command| command.status == CommandStatus::Cancelled)
4736 );
4737
4738 let events = activity_events(&root);
4739 assert!(
4740 events
4741 .iter()
4742 .any(|event| event["type"] == "work.stop.requested"),
4743 "the request is recorded: {events:?}"
4744 );
4745 assert!(
4746 events
4747 .iter()
4748 .any(|event| event["type"] == "work.stop.accepted"),
4749 "an acknowledged normal cancellation is recorded as a control fact"
4750 );
4751 assert!(
4752 !events
4753 .iter()
4754 .any(|event| event["type"] == "work.stop.unconfirmed"),
4755 "an acknowledged cancellation is not an anomaly"
4756 );
4757 let encoded = serde_json::to_string(&events).unwrap();
4758 assert!(
4759 !encoded.contains("late-effect") && !encoded.contains("message"),
4760 "diagnostics carry no raw effect payload: {encoded}"
4761 );
4762 remove_drive_root(root, service, &[&*run_id]);
4763 }
4764
4765 #[test]
4766 fn run_cancel_without_owner_acknowledgement_keeps_the_effect_unknown() {
4767 let root = root();
4768 let (service, store, run_id, release, _cancels) =
4769 blocked_run_fixture(&root, TurnCancelDisposition::NoActiveTurn);
4770 wait_for_running_command(&store, &run_id);
4771
4772 let cancelled = service
4773 .execute(json!({
4774 "action": "strategy.run.cancel",
4775 "runId": run_id,
4776 }))
4777 .unwrap();
4778 assert_eq!(cancelled["ok"], true);
4779 let snapshot = store.run(&run_id).unwrap();
4780 assert_eq!(snapshot.status, StrategyRunStatus::CancelInDoubt);
4781 assert!(
4782 snapshot
4783 .commands
4784 .values()
4785 .all(|command| command.status == CommandStatus::InDoubt),
4786 "an unacknowledged cancellation keeps every in-flight effect unknown: {:?}",
4787 snapshot
4788 .commands
4789 .values()
4790 .map(|c| c.status)
4791 .collect::<Vec<_>>()
4792 );
4793 assert_eq!(
4794 snapshot.diagnostic_code.as_deref(),
4795 Some("cancellation_outcome_unknown")
4796 );
4797 let events = activity_events(&root);
4798 assert!(
4799 events
4800 .iter()
4801 .any(|event| event["type"] == "work.stop.unconfirmed"),
4802 "an unacknowledged request stays unconfirmed: {events:?}"
4803 );
4804 release.send(()).unwrap();
4805 remove_drive_root(root, service, &[&*run_id]);
4806 }
4807
4808 #[test]
4809 fn drive_reservation_is_atomic_for_one_run() {
4810 let run_id = format!("run-reservation-{}", uuid::Uuid::new_v4());
4811 let first = DriveReservation::acquire(&run_id).expect("first reservation");
4812 assert!(DriveReservation::acquire(&run_id).is_none());
4813 drop(first);
4814 assert!(DriveReservation::acquire(&run_id).is_some());
4815 }
4816
4817 #[test]
4818 fn drive_retry_opens_and_runs_a_fresh_turn_inline() {
4819 let root = root();
4820 let (_conversation_store, conversation_id, membership_id) =
4821 conversation_bound_fixture(&root);
4822 let (store, revision) = authorized_entry_store(&root, &membership_id);
4823 let calls = Arc::new(Mutex::new(Vec::<(String, Value)>::new()));
4824 let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4825 let run_attempts = Arc::clone(&attempts);
4826 let port = recording_port(Arc::clone(&calls), move |_, _| {
4827 if run_attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
4828 Err(ActorTurnError::dispatch_failed(
4829 "conversation_dispatch_failed",
4830 ))
4831 } else {
4832 Ok(json!({"ok": true, "output": "done"}))
4833 }
4834 });
4835 let service = StrategyService::from_parts(
4836 root.clone(),
4837 store.clone(),
4838 StrategyPackageImporter::open(&root).unwrap(),
4839 )
4840 .with_actor_turn_port(port);
4841
4842 let response = service
4843 .execute(json!({
4844 "action": "strategy.run.start",
4845 "revisionDigest": revision,
4846 "input": {"message": "hi"},
4847 "idempotencyKey": "start-2",
4848 "conversationId": conversation_id,
4849 }))
4850 .unwrap();
4851 assert_eq!(response["ok"], true);
4852 let run_id = response["result"]["runId"].as_str().unwrap().to_owned();
4853 let snapshot = wait_for_terminal(&store, &run_id);
4854 assert_eq!(snapshot.status, StrategyRunStatus::Completed);
4855 let recorded = calls.lock().unwrap();
4856 let opens = recorded
4857 .iter()
4858 .filter(|(operation, _)| operation == "open")
4859 .count();
4860 let runs = recorded
4861 .iter()
4862 .filter(|(operation, _)| operation.starts_with("run:"))
4863 .count();
4864 assert_eq!(opens, 2, "the retried command opened a fresh turn inline");
4865 assert_eq!(runs, 2);
4866 drop(recorded);
4867 remove_drive_root(root, service, &[&*run_id]);
4868 }
4869
4870 #[test]
4871 fn entry_registration_guard_abandons_only_an_unrun_turn() {
4872 let abandoned = Arc::new(Mutex::new(Vec::<String>::new()));
4873 let abandon_calls = Arc::clone(&abandoned);
4874 let port = Arc::new(ActorTurnPort {
4875 open: Arc::new(|_| Ok("dispatch:entry".to_owned())),
4876 run: Arc::new(|_, _| Ok(json!({"ok": true}))),
4877 cancel: Arc::new(|_| TurnCancelDisposition::NoActiveTurn),
4878 abandon: Arc::new(move |handle| {
4879 abandon_calls.lock().unwrap().push(handle.to_owned());
4880 }),
4881 });
4882 {
4883 let _guard = EntryTurnRegistration {
4884 slot_id: "entry".into(),
4885 handle: "dispatch:entry".into(),
4886 params: json!({}),
4887 projection: Value::Null,
4888 port: Arc::clone(&port),
4889 armed: true,
4890 };
4891 }
4892 assert_eq!(abandoned.lock().unwrap().as_slice(), ["dispatch:entry"]);
4893 let guard = EntryTurnRegistration {
4894 slot_id: "entry".into(),
4895 handle: "dispatch:entry-2".into(),
4896 params: json!({}),
4897 projection: Value::Null,
4898 port: Arc::clone(&port),
4899 armed: true,
4900 };
4901 let (handle, _) = guard.into_run();
4902 assert_eq!(handle, "dispatch:entry-2");
4903 assert_eq!(abandoned.lock().unwrap().len(), 1);
4904 }
4905
4906 fn callback_entry_workflow() -> licoup_workflow::WorkflowDefinition {
4907 let mut workflow = entry_workflow();
4908 workflow.metadata.id = "entry-review".into();
4909 workflow.metadata.name = "Entry review".into();
4910 workflow.transitions[0].mode = licoup_workflow::TransitionMode::Callback;
4911 workflow
4912 }
4913
4914 fn authorized_callback_store(root: &Path, membership_id: &str) -> (StrategyStore, String) {
4915 let store = StrategyStore::open(root).unwrap();
4916 let revision = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
4917 store
4918 .register_definition(revision, revision, &callback_entry_workflow(), 1, 1)
4919 .unwrap();
4920 store
4921 .replace_slot_bindings(
4922 revision,
4923 "entry",
4924 &[BindingCandidate {
4925 value_id: membership_id.to_owned(),
4926 model: String::new(),
4927 reasoning_effort: String::new(),
4928 }],
4929 None,
4930 )
4931 .unwrap();
4932 let preview = store.authorization_preview(revision).unwrap();
4933 store
4934 .grant_authorization(revision, &preview.authorization_digest)
4935 .unwrap();
4936 (store, revision.to_owned())
4937 }
4938
4939 fn wait_for_status(
4940 store: &StrategyStore,
4941 run_id: &str,
4942 expected: StrategyRunStatus,
4943 ) -> RunSnapshot {
4944 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
4945 loop {
4946 let snapshot = store.run(run_id).unwrap();
4947 if snapshot.status == expected {
4948 return snapshot;
4949 }
4950 assert!(
4951 std::time::Instant::now() < deadline,
4952 "run did not reach {expected:?}: {:?}",
4953 snapshot.status
4954 );
4955 std::thread::sleep(std::time::Duration::from_millis(20));
4956 }
4957 }
4958
4959 fn wait_for_master_report(
4960 conversation_store: &licoup_conversation::ConversationStore,
4961 conversation_id: &str,
4962 kind: &str,
4963 ) -> licoup_conversation::ConversationEvent {
4964 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
4965 loop {
4966 let page = conversation_store
4967 .page_events(conversation_id, None, 64)
4968 .unwrap();
4969 if let Some(event) = page.events.iter().find(|event| {
4970 event.parts.iter().any(|part| {
4971 part.kind == licoup_conversation::EventPartKind::Metadata
4972 && serde_json::from_str::<Value>(&part.content)
4973 .is_ok_and(|content| content["kind"] == json!(kind))
4974 })
4975 }) {
4976 return event.clone();
4977 }
4978 assert!(
4979 std::time::Instant::now() < deadline,
4980 "no `{kind}` master report landed in the conversation"
4981 );
4982 std::thread::sleep(std::time::Duration::from_millis(20));
4983 }
4984 }
4985
4986 #[test]
4987 fn imported_callback_edge_reports_the_master_and_waits_for_its_decision() {
4988 let root = root();
4989 let (conversation_store, conversation_id, membership_id) =
4990 conversation_bound_fixture(&root);
4991 let (store, revision) = authorized_callback_store(&root, &membership_id);
4992 let calls = Arc::new(Mutex::new(Vec::<(String, Value)>::new()));
4993 let port = recording_port(Arc::clone(&calls), |_, _| {
4994 Ok(json!({"ok": true, "output": "done", "nativeSessionId": "session-1"}))
4995 });
4996 let service = StrategyService::from_parts(
4997 root.clone(),
4998 store.clone(),
4999 StrategyPackageImporter::open(&root).unwrap(),
5000 )
5001 .with_actor_turn_port(port);
5002
5003 let response = service
5004 .execute(json!({
5005 "action": "strategy.run.start",
5006 "revisionDigest": revision,
5007 "input": {"message": "hi"},
5008 "idempotencyKey": String::from("callback-start-1"),
5009 "conversationId": conversation_id,
5010 }))
5011 .unwrap();
5012 assert_eq!(response["ok"], true, "{response}");
5013 let run_id = response["result"]["runId"].as_str().unwrap().to_owned();
5014
5015 let parked = wait_for_status(&store, &run_id, StrategyRunStatus::Waiting);
5018 assert_eq!(parked.pending_callbacks.len(), 1);
5019 assert_eq!(parked.pending_callbacks[0].state_id, "greet");
5020 assert_eq!(parked.pending_callbacks[0].transition_id, "greeted");
5021 assert_eq!(parked.pending_callbacks[0].target, "done");
5022 assert_eq!(
5023 calls.lock().unwrap().len(),
5024 2,
5025 "one open and one effect run"
5026 );
5027
5028 let report_event = wait_for_master_report(
5030 &conversation_store,
5031 &conversation_id,
5032 "strategy-callback-request",
5033 );
5034 assert_eq!(
5035 report_event.author_membership_id.as_deref(),
5036 Some(membership_id.as_str())
5037 );
5038 let part = report_event
5039 .parts
5040 .iter()
5041 .find(|part| part.kind == licoup_conversation::EventPartKind::Metadata)
5042 .unwrap();
5043 let report: Value = serde_json::from_str(&part.content).unwrap();
5044 assert_eq!(report["runId"], json!(run_id));
5045 assert_eq!(report["stateId"], json!("greet"));
5046 assert_eq!(report["target"], json!("done"));
5047 assert_eq!(
5048 report["decisions"],
5049 json!(["advance", "return", "terminate"])
5050 );
5051 assert_eq!(report["answerChannel"], json!("strategy.run.resume"));
5052 assert_eq!(
5053 report["answerFields"],
5054 json!(["decision", "callbackStateId", "callbackStateVisit"])
5055 );
5056 assert_eq!(report["facts"]["context"]["stateId"], json!("greet"));
5061 assert_eq!(report["facts"]["context"]["stateVisit"], json!(1));
5062 assert_eq!(
5063 report["facts"]["observation"]["stateVisits"]["greet"],
5064 json!(1)
5065 );
5066 assert_ne!(report["facts"]["cost"]["budgetPoolSeamActive"], json!(true));
5067 assert_eq!(
5068 report["suggestions"]["recommendedDecision"],
5069 json!("advance")
5070 );
5071 assert_eq!(
5072 report["suggestions"]["alternativeDecisions"],
5073 json!(["return", "terminate"])
5074 );
5075 assert!(report["suggestions"]["strategySuggestion"].is_null());
5078
5079 let resumed = service
5081 .execute(json!({"action": "strategy.run.resume", "runId": run_id}))
5082 .unwrap();
5083 assert_eq!(resumed["ok"], true, "{resumed}");
5084 assert_eq!(resumed["result"]["status"], "waiting");
5085 assert_eq!(
5086 resumed["result"]["pendingCallbacks"][0]["stateId"],
5087 json!("greet")
5088 );
5089 assert_eq!(calls.lock().unwrap().len(), 2);
5090
5091 let decided = service
5093 .execute(json!({
5094 "action": "strategy.run.resume",
5095 "runId": run_id,
5096 "decision": "advance",
5097 "callbackStateId": "greet",
5098 "callbackStateVisit": 1,
5099 }))
5100 .unwrap();
5101 assert_eq!(decided["ok"], true, "{decided}");
5102 let completed = wait_for_terminal(&store, &run_id);
5103 assert_eq!(completed.status, StrategyRunStatus::Completed);
5104 assert!(completed.pending_callbacks.is_empty());
5105 assert_eq!(calls.lock().unwrap().len(), 2, "no duplicate effect ran");
5106
5107 let replay = service
5109 .execute(json!({
5110 "action": "strategy.run.resume",
5111 "runId": run_id,
5112 "decision": "advance",
5113 "callbackStateId": "greet",
5114 "callbackStateVisit": 1,
5115 }))
5116 .unwrap();
5117 assert_eq!(replay["ok"], false);
5118 assert_eq!(replay["error"]["code"], "callback_stale");
5119 remove_drive_root(root, service, &[&*run_id]);
5120 }
5121
5122 fn authorized_callback_review_store(
5125 root: &Path,
5126 membership_id: &str,
5127 ) -> (StrategyStore, String) {
5128 use licoup_workflow::{
5129 ActorSlot, GraphState, GraphStateKind, RetryPolicy, Transition, TransitionEvent,
5130 TransitionMode, WorkflowDefinition, WorkflowMetadata,
5131 };
5132
5133 let actor_state = |id: &str, label: &str, slot: &str| GraphState {
5134 id: id.into(),
5135 kind: GraphStateKind::Actor,
5136 label: label.into(),
5137 instruction: String::new(),
5138 binding: Some(slot.into()),
5139 runtime: None,
5140 entry: None,
5141 workset: None,
5142 retry: RetryPolicy {
5143 max_attempts: 2,
5144 transient_only: true,
5145 },
5146 };
5147 let driver_state = |id: &str, label: &str, kind: GraphStateKind| GraphState {
5148 id: id.into(),
5149 kind,
5150 label: label.into(),
5151 instruction: String::new(),
5152 binding: None,
5153 runtime: None,
5154 entry: None,
5155 workset: None,
5156 retry: RetryPolicy::default(),
5157 };
5158 let mut entry = ActorSlot::required_actor("entry", "Entry");
5159 entry.entry = true;
5160 let mut reviewer = ActorSlot::required_actor("reviewer", "Reviewer");
5161 reviewer.entry = false;
5162 let workflow = WorkflowDefinition {
5163 schema: licoup_workflow::WORKFLOW_SCHEMA_VERSION.into(),
5164 metadata: WorkflowMetadata {
5165 id: "entry-review-callback".into(),
5166 name: "Entry review callback".into(),
5167 version: "1".into(),
5168 description: String::new(),
5169 },
5170 limits: licoup_workflow::WorkflowLimits {
5171 max_parallelism: 1,
5172 max_workset_items: 1,
5173 max_attempts: 2,
5174 },
5175 actor_slots: vec![entry, reviewer],
5176 runtimes: vec![],
5177 worksets: vec![],
5178 initial: "greet".into(),
5179 states: vec![
5180 actor_state("greet", "Greet", "entry"),
5181 actor_state("review", "Review", "reviewer"),
5182 driver_state("done", "Done", GraphStateKind::Succeed),
5183 driver_state("failed", "Failed", GraphStateKind::Fail),
5184 ],
5185 transitions: vec![
5186 Transition {
5187 id: "greeted".into(),
5188 from: "greet".into(),
5189 to: "review".into(),
5190 event: TransitionEvent::Success,
5191 mode: TransitionMode::Callback,
5192 guard: None,
5193 },
5194 Transition {
5195 id: "greet-failed".into(),
5196 from: "greet".into(),
5197 to: "failed".into(),
5198 event: TransitionEvent::Failure,
5199 mode: TransitionMode::Flow,
5200 guard: None,
5201 },
5202 Transition {
5203 id: "reviewed".into(),
5204 from: "review".into(),
5205 to: "done".into(),
5206 event: TransitionEvent::Success,
5207 mode: TransitionMode::Flow,
5208 guard: None,
5209 },
5210 Transition {
5211 id: "review-failed".into(),
5212 from: "review".into(),
5213 to: "failed".into(),
5214 event: TransitionEvent::Failure,
5215 mode: TransitionMode::Flow,
5216 guard: None,
5217 },
5218 ],
5219 };
5220
5221 let store = StrategyStore::open(root).unwrap();
5222 let revision = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd";
5223 store
5224 .register_definition(revision, revision, &workflow, 1, 1)
5225 .unwrap();
5226 for slot in ["entry", "reviewer"] {
5227 store
5228 .replace_slot_bindings(
5229 revision,
5230 slot,
5231 &[BindingCandidate {
5232 value_id: membership_id.to_owned(),
5233 model: String::new(),
5234 reasoning_effort: String::new(),
5235 }],
5236 None,
5237 )
5238 .unwrap();
5239 }
5240 let preview = store.authorization_preview(revision).unwrap();
5241 store
5242 .grant_authorization(revision, &preview.authorization_digest)
5243 .unwrap();
5244 (store, revision.to_owned())
5245 }
5246
5247 #[test]
5248 fn revoked_authorization_stops_the_effect_the_master_decision_would_start() {
5249 let root = root();
5250 let (conversation_store, conversation_id, membership_id) =
5251 conversation_bound_fixture(&root);
5252 let (store, revision) = authorized_callback_review_store(&root, &membership_id);
5253 let calls = Arc::new(Mutex::new(Vec::<(String, Value)>::new()));
5254 let port = recording_port(Arc::clone(&calls), |_, _| {
5255 Ok(json!({"ok": true, "output": "done", "nativeSessionId": "session-1"}))
5256 });
5257 let service = StrategyService::from_parts(
5258 root.clone(),
5259 store.clone(),
5260 StrategyPackageImporter::open(&root).unwrap(),
5261 )
5262 .with_actor_turn_port(port);
5263
5264 let response = service
5265 .execute(json!({
5266 "action": "strategy.run.start",
5267 "revisionDigest": revision,
5268 "input": {"message": "hi"},
5269 "idempotencyKey": String::from("revoked-callback-start-1"),
5270 "conversationId": conversation_id,
5271 }))
5272 .unwrap();
5273 assert_eq!(response["ok"], true, "{response}");
5274 let run_id = response["result"]["runId"].as_str().unwrap().to_owned();
5275
5276 let parked = wait_for_status(&store, &run_id, StrategyRunStatus::Waiting);
5277 assert_eq!(parked.pending_callbacks.len(), 1);
5278 assert_eq!(parked.pending_callbacks[0].target, "review");
5279 let effects_before_revocation = calls.lock().unwrap().len();
5280 wait_for_master_report(
5281 &conversation_store,
5282 &conversation_id,
5283 "strategy-callback-request",
5284 );
5285
5286 store.revoke_authorization(&revision).unwrap();
5295 let decided = service
5296 .execute(json!({
5297 "action": "strategy.run.resume",
5298 "runId": run_id,
5299 "decision": "advance",
5300 "callbackStateId": "greet",
5301 "callbackStateVisit": 1,
5302 }))
5303 .unwrap();
5304 assert_eq!(decided["ok"], true, "{decided}");
5305
5306 let refused = wait_for_status(&store, &run_id, StrategyRunStatus::AuthorizationRequired);
5307 assert_eq!(
5308 refused.diagnostic_code.as_deref(),
5309 Some("authorization_required")
5310 );
5311 assert_eq!(
5312 calls.lock().unwrap().len(),
5313 effects_before_revocation,
5314 "the review effect must not run without an active authorization"
5315 );
5316 remove_drive_root(root, service, &[&*run_id]);
5317 }
5318
5319 #[test]
5320 fn imported_run_terminal_failure_reports_the_master_for_decision() {
5321 let root = root();
5322 let (conversation_store, conversation_id, membership_id) =
5323 conversation_bound_fixture(&root);
5324 let (store, revision) = authorized_entry_store(&root, &membership_id);
5325 let calls = Arc::new(Mutex::new(Vec::<(String, Value)>::new()));
5326 let port = recording_port(Arc::clone(&calls), |_, _| {
5327 Err(ActorTurnError::dispatch_failed(
5328 "conversation_dispatch_failed",
5329 ))
5330 });
5331 let service = StrategyService::from_parts(
5332 root.clone(),
5333 store.clone(),
5334 StrategyPackageImporter::open(&root).unwrap(),
5335 )
5336 .with_actor_turn_port(port);
5337
5338 let response = service
5339 .execute(json!({
5340 "action": "strategy.run.start",
5341 "revisionDigest": revision,
5342 "input": {"message": "hi"},
5343 "idempotencyKey": String::from("failure-report-1"),
5344 "conversationId": conversation_id,
5345 }))
5346 .unwrap();
5347 assert_eq!(response["ok"], true, "{response}");
5348 let run_id = response["result"]["runId"].as_str().unwrap().to_owned();
5349
5350 let failed = wait_for_terminal(&store, &run_id);
5351 assert_eq!(failed.status, StrategyRunStatus::Failed);
5352 let report_event = wait_for_master_report(
5353 &conversation_store,
5354 &conversation_id,
5355 "strategy-terminal-outcome",
5356 );
5357 assert_eq!(
5358 report_event.author_membership_id.as_deref(),
5359 Some(membership_id.as_str())
5360 );
5361 let part = report_event
5362 .parts
5363 .iter()
5364 .find(|part| part.kind == licoup_conversation::EventPartKind::Metadata)
5365 .unwrap();
5366 let report: Value = serde_json::from_str(&part.content).unwrap();
5367 assert_eq!(report["runId"], json!(run_id));
5368 assert_eq!(report["status"], json!("failed"));
5369 remove_drive_root(root, service, &[&*run_id]);
5370 }
5371
5372 #[test]
5373 fn imported_prepare_import_failure_reports_the_master() {
5374 let root = root();
5375 let (conversation_store, conversation_id, membership_id) =
5376 conversation_bound_fixture(&root);
5377 let service = StrategyService::from_parts(
5378 root.clone(),
5379 StrategyStore::open(&root).unwrap(),
5380 StrategyPackageImporter::open(&root).unwrap(),
5381 );
5382 let response = service
5383 .execute(json!({
5384 "action": "strategy.package.prepare-import",
5385 "sourcePath": root.join("absent.zip").to_string_lossy(),
5386 "selectionToken": String::from("selection-missing"),
5387 "conversationId": conversation_id,
5388 }))
5389 .unwrap();
5390 assert_eq!(response["ok"], false);
5391 assert_eq!(response["error"]["code"], "package_unavailable");
5392 let report_event = wait_for_master_report(
5393 &conversation_store,
5394 &conversation_id,
5395 "strategy-terminal-outcome",
5396 );
5397 assert_eq!(
5398 report_event.author_membership_id.as_deref(),
5399 Some(membership_id.as_str())
5400 );
5401 let part = report_event
5402 .parts
5403 .iter()
5404 .find(|part| part.kind == licoup_conversation::EventPartKind::Metadata)
5405 .unwrap();
5406 let report: Value = serde_json::from_str(&part.content).unwrap();
5407 assert_eq!(
5408 report["schema"],
5409 json!("licoup.adaptive-flywheel.callback.v1")
5410 );
5411 assert_eq!(report["status"], json!("failed"));
5412 assert_eq!(report["diagnostic"], json!("package_unavailable"));
5413 assert_eq!(report["stage"], json!("package/read"));
5414 drop(service);
5415 remove_root(root);
5416 }
5417
5418 #[test]
5419 fn imported_commit_import_failure_reports_the_master() {
5420 let root = root();
5421 let (conversation_store, conversation_id, membership_id) =
5422 conversation_bound_fixture(&root);
5423 let service = StrategyService::from_parts(
5424 root.clone(),
5425 StrategyStore::open(&root).unwrap(),
5426 StrategyPackageImporter::open(&root).unwrap(),
5427 );
5428 let response = service
5429 .execute(json!({
5430 "action": "strategy.package.commit-import",
5431 "preparationId": "prep-missing",
5432 "expectedRevisionDigest": "0".repeat(64),
5433 "conversationId": conversation_id,
5434 }))
5435 .unwrap();
5436 assert_eq!(response["ok"], false);
5437 assert_eq!(response["error"]["code"], "preparation_not_found");
5438 let report_event = wait_for_master_report(
5439 &conversation_store,
5440 &conversation_id,
5441 "strategy-terminal-outcome",
5442 );
5443 assert_eq!(
5444 report_event.author_membership_id.as_deref(),
5445 Some(membership_id.as_str())
5446 );
5447 let part = report_event
5448 .parts
5449 .iter()
5450 .find(|part| part.kind == licoup_conversation::EventPartKind::Metadata)
5451 .unwrap();
5452 let report: Value = serde_json::from_str(&part.content).unwrap();
5453 assert_eq!(
5454 report["schema"],
5455 json!("licoup.adaptive-flywheel.callback.v1")
5456 );
5457 assert_eq!(report["status"], json!("failed"));
5458 assert_eq!(report["diagnostic"], json!("preparation_not_found"));
5459 assert_eq!(report["stage"], json!("package/commit"));
5460 drop(service);
5461 remove_root(root);
5462 }
5463
5464 #[test]
5465 fn imported_run_admission_failure_reports_the_master() {
5466 let root = root();
5467 let (conversation_store, conversation_id, membership_id) =
5468 conversation_bound_fixture(&root);
5469 let service = StrategyService::from_parts(
5470 root.clone(),
5471 StrategyStore::open(&root).unwrap(),
5472 StrategyPackageImporter::open(&root).unwrap(),
5473 )
5474 .with_actor_turn_port(ActorTurnPort {
5475 open: Arc::new(|_| panic!("admission failure must not open a turn")),
5476 run: Arc::new(|_, _| panic!("admission failure must not run")),
5477 cancel: Arc::new(|_| TurnCancelDisposition::NoActiveTurn),
5478 abandon: Arc::new(|_| {}),
5479 });
5480 let response = service
5481 .execute(json!({
5482 "action": "strategy.run.start",
5483 "revisionDigest": "0".repeat(64),
5484 "input": {"message": "hi"},
5485 "idempotencyKey": String::from("start-missing-revision"),
5486 "conversationId": conversation_id,
5487 }))
5488 .unwrap();
5489 assert_eq!(response["ok"], false);
5490 assert_eq!(response["error"]["code"], "definition_not_found");
5491 let report_event = wait_for_master_report(
5492 &conversation_store,
5493 &conversation_id,
5494 "strategy-terminal-outcome",
5495 );
5496 assert_eq!(
5497 report_event.author_membership_id.as_deref(),
5498 Some(membership_id.as_str())
5499 );
5500 let part = report_event
5501 .parts
5502 .iter()
5503 .find(|part| part.kind == licoup_conversation::EventPartKind::Metadata)
5504 .unwrap();
5505 let report: Value = serde_json::from_str(&part.content).unwrap();
5506 assert_eq!(
5507 report["schema"],
5508 json!("licoup.adaptive-flywheel.callback.v1")
5509 );
5510 assert_eq!(report["status"], json!("failed"));
5511 assert_eq!(report["diagnostic"], json!("definition_not_found"));
5512 drop(service);
5513 remove_root(root);
5514 }
5515
5516 #[test]
5517 fn assistant_callback_edge_returns_to_the_master_and_follows_its_decision() {
5518 let root = root();
5519 let (_conversation_store, conversation_id, membership_id) =
5520 conversation_bound_fixture(&root);
5521 let calls = Arc::new(Mutex::new(Vec::<(String, Value)>::new()));
5522 let port = recording_port(Arc::clone(&calls), |_, _| {
5523 Ok(json!({"ok": true, "output": "done", "nativeSessionId": "session-1"}))
5524 });
5525 let service = StrategyService::from_parts(
5526 root.clone(),
5527 StrategyStore::open(&root).unwrap(),
5528 StrategyPackageImporter::open(&root).unwrap(),
5529 )
5530 .with_actor_turn_port(port)
5531 .with_profile_snapshot_authority(fixture_profile_authority(
5532 "ready",
5533 Arc::new(Mutex::new(BTreeMap::new())),
5534 ));
5535 let mut workflow = assistant_workflow_json();
5536 workflow["transitions"][0]["mode"] = json!("callback");
5537 let request = || {
5538 json!({
5539 "action": "strategy.assistant.workflow.execute",
5540 "selectionPolicyRevision": FIXTURE_SELECTION_POLICY_REVISION,
5541 "conversationId": conversation_id,
5542 "membershipId": membership_id,
5543 "workflow": workflow,
5544 "bindings": [{
5545 "slotId": "subagent-a",
5546 "ordinal": 0,
5547 "valueId": membership_id,
5548 "model": "model-a",
5549 "reasoningEffort": "",
5550 "revision": 1
5551 }],
5552 "input": {"message": "hi"},
5553 "idempotencyKey": String::from("assistant-callback-1")
5554 })
5555 };
5556
5557 let response = service.execute(request()).unwrap();
5558 assert_eq!(response["ok"], true, "{response}");
5559 assert_eq!(response["result"]["status"], "waiting");
5560 assert_eq!(
5561 response["result"]["terminal"]["code"],
5562 "callback_decision_required"
5563 );
5564 let pending = &response["result"]["pendingCallbacks"][0];
5565 assert_eq!(pending["stateId"], json!("running"));
5566 assert_eq!(pending["transitionId"], json!("succeeded"));
5567 assert_eq!(pending["target"], json!("done"));
5568 let run_id = response["result"]["runId"].as_str().unwrap().to_owned();
5569
5570 let report_event = wait_for_master_report(
5573 &_conversation_store,
5574 &conversation_id,
5575 "strategy-callback-request",
5576 );
5577 assert_eq!(
5578 report_event.author_membership_id.as_deref(),
5579 Some(membership_id.as_str())
5580 );
5581 let part = report_event
5582 .parts
5583 .iter()
5584 .find(|part| part.kind == licoup_conversation::EventPartKind::Metadata)
5585 .unwrap();
5586 let report: Value = serde_json::from_str(&part.content).unwrap();
5587 assert_eq!(report["runId"], json!(run_id));
5588 assert_eq!(
5589 report["answerChannel"],
5590 json!("lico_assistant_workflow_execute")
5591 );
5592
5593 let mut decision = request();
5595 decision["decision"] = json!("advance");
5596 decision["callbackStateId"] = json!("running");
5597 decision["callbackStateVisit"] = json!(1);
5598 let decided = service.execute(decision).unwrap();
5599 assert_eq!(decided["ok"], true, "{decided}");
5600 assert_eq!(decided["result"]["runId"], json!(run_id));
5601 assert_eq!(decided["result"]["terminal"]["status"], "completed");
5602 let snapshot = service.store().run(&run_id).unwrap();
5603 assert_eq!(snapshot.status, StrategyRunStatus::Completed);
5604 assert!(snapshot.pending_callbacks.is_empty());
5605 drop(service);
5606 remove_root(root);
5607 }
5608
5609 #[test]
5610 fn a_settled_park_survives_a_later_drive_failure() {
5611 let root = root();
5616 let (_conversation_store, conversation_id, membership_id) =
5617 conversation_bound_fixture(&root);
5618 let calls = Arc::new(Mutex::new(Vec::<(String, Value)>::new()));
5619 let port = recording_port(Arc::clone(&calls), |_, _| {
5620 Ok(json!({"ok": true, "output": "done", "nativeSessionId": "session-1"}))
5621 });
5622 let service = StrategyService::from_parts(
5623 root.clone(),
5624 StrategyStore::open(&root).unwrap(),
5625 StrategyPackageImporter::open(&root).unwrap(),
5626 )
5627 .with_actor_turn_port(port)
5628 .with_profile_snapshot_authority(fixture_profile_authority(
5629 "ready",
5630 Arc::new(Mutex::new(BTreeMap::new())),
5631 ));
5632 let mut workflow = assistant_workflow_json();
5633 workflow["transitions"][0]["mode"] = json!("callback");
5634 let mut request = json!({
5635 "action": "strategy.assistant.workflow.execute",
5636 "selectionPolicyRevision": FIXTURE_SELECTION_POLICY_REVISION,
5637 "conversationId": conversation_id,
5638 "membershipId": membership_id,
5639 "workflow": workflow,
5640 "bindings": [{
5641 "slotId": "subagent-a",
5642 "ordinal": 0,
5643 "valueId": membership_id,
5644 "model": "model-a",
5645 "reasoningEffort": "",
5646 "revision": 1
5647 }],
5648 "input": {"message": "hi"},
5649 "idempotencyKey": String::from("assistant-callback-drive-failure")
5650 });
5651 let response = service.execute(request.clone()).unwrap();
5652 assert_eq!(response["result"]["status"], "waiting", "{response}");
5653 let run_id = response["result"]["runId"].as_str().unwrap().to_owned();
5654
5655 service.settle_drive_failure(&run_id).unwrap();
5657
5658 let snapshot = service.store().run(&run_id).unwrap();
5659 assert_eq!(snapshot.status, StrategyRunStatus::Waiting);
5660 assert_eq!(snapshot.pending_callbacks.len(), 1);
5661 let parked = service.wait_for_assistant_terminal(&run_id, None).unwrap();
5662 assert_eq!(parked["status"], json!("waiting"));
5663 assert_eq!(
5664 parked["terminal"]["code"],
5665 json!("callback_decision_required")
5666 );
5667
5668 request["decision"] = json!("advance");
5669 request["callbackStateId"] = json!("running");
5670 request["callbackStateVisit"] = json!(1);
5671 let decided = service.execute(request).unwrap();
5672 assert_eq!(decided["result"]["terminal"]["status"], "completed");
5673 let snapshot = service.store().run(&run_id).unwrap();
5674 assert_eq!(snapshot.status, StrategyRunStatus::Completed);
5675 remove_drive_root(root, service, &[&*run_id]);
5676 }
5677
5678 #[test]
5679 fn imported_flow_settlement_reports_the_master_and_wakes_the_assistant() {
5680 let root = root();
5681 let (conversation_store, conversation_id, membership_id) =
5682 conversation_bound_fixture(&root);
5683 let (store, revision) = authorized_entry_store(&root, &membership_id);
5684 let calls = Arc::new(Mutex::new(Vec::<(String, Value)>::new()));
5685 let port = recording_port(Arc::clone(&calls), |_, _| {
5686 Ok(json!({"ok": true, "output": "done", "nativeSessionId": "session-1"}))
5687 });
5688 let wakes = Arc::new(Mutex::new(Vec::<(String, String, Value)>::new()));
5689 let wake_log = Arc::clone(&wakes);
5690 let service = StrategyService::from_parts(
5691 root.clone(),
5692 store.clone(),
5693 StrategyPackageImporter::open(&root).unwrap(),
5694 )
5695 .with_actor_turn_port(port)
5696 .with_assistant_wake_port(AssistantWakePort {
5697 wake: Arc::new(move |conversation, master, notice| {
5698 wake_log.lock().unwrap().push((
5699 conversation.to_owned(),
5700 master.to_owned(),
5701 notice.clone(),
5702 ));
5703 Ok(())
5704 }),
5705 });
5706
5707 let response = service
5708 .execute(json!({
5709 "action": "strategy.run.start",
5710 "revisionDigest": revision,
5711 "input": {"message": "hi"},
5712 "idempotencyKey": String::from("flow-start-1"),
5713 "conversationId": conversation_id,
5714 }))
5715 .unwrap();
5716 assert_eq!(response["ok"], true, "{response}");
5717 let run_id = response["result"]["runId"].as_str().unwrap().to_owned();
5718
5719 let snapshot = wait_for_terminal(&store, &run_id);
5723 assert_eq!(snapshot.status, StrategyRunStatus::Completed);
5724 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
5725 let mut notices: Vec<Value> = Vec::new();
5726 while std::time::Instant::now() < deadline {
5727 let page = conversation_store
5728 .page_events(&conversation_id, None, 64)
5729 .unwrap();
5730 notices = page
5731 .events
5732 .iter()
5733 .flat_map(|event| {
5734 event
5735 .parts
5736 .iter()
5737 .filter(|part| part.kind == licoup_conversation::EventPartKind::Metadata)
5738 .filter_map(|part| serde_json::from_str::<Value>(&part.content).ok())
5739 })
5740 .filter(|content| content["kind"] == json!("strategy-flow-settled"))
5741 .collect();
5742 if notices
5743 .iter()
5744 .any(|notice| notice["stateId"] == json!("greet"))
5745 && notices
5746 .iter()
5747 .any(|notice| notice["stateId"] == json!("done"))
5748 {
5749 break;
5750 }
5751 std::thread::sleep(std::time::Duration::from_millis(20));
5752 }
5753 assert!(
5754 notices
5755 .iter()
5756 .any(|notice| notice["stateId"] == json!("greet")),
5757 "no notices: {notices:?}"
5758 );
5759 assert!(
5760 notices
5761 .iter()
5762 .any(|notice| notice["stateId"] == json!("done"))
5763 );
5764 for notice in ¬ices {
5765 assert_eq!(notice["runId"], json!(run_id));
5766 assert_eq!(notice["mode"], json!("flow"));
5767 }
5768
5769 let wakes = wakes.lock().unwrap();
5771 assert!(!wakes.is_empty(), "{wakes:?}");
5772 for wake in wakes.iter() {
5773 assert_eq!(wake.0, conversation_id);
5774 assert_eq!(wake.1, membership_id);
5775 assert_eq!(wake.2["kind"], json!("strategy-flow-settled"));
5776 assert_eq!(wake.2["runId"], json!(run_id));
5777 assert!(wake.2.get("stateId").and_then(Value::as_str).is_some());
5778 }
5779 drop(wakes);
5780 remove_drive_root(root, service, &[&*run_id]);
5781 }
5782
5783 #[test]
5784 fn cancel_settles_a_parked_approval_wait_without_signalling_any_owner() {
5785 let root = root();
5786 let (_conversation_store, conversation_id, membership_id) =
5787 conversation_bound_fixture(&root);
5788 let (store, revision) = authorized_callback_store(&root, &membership_id);
5789 let calls = Arc::new(Mutex::new(Vec::<(String, Value)>::new()));
5790 let port = recording_port(Arc::clone(&calls), |_, _| {
5791 Ok(json!({"ok": true, "output": "done"}))
5792 });
5793 let service = StrategyService::from_parts(
5794 root.clone(),
5795 store.clone(),
5796 StrategyPackageImporter::open(&root).unwrap(),
5797 )
5798 .with_actor_turn_port(port);
5799
5800 let response = service
5801 .execute(json!({
5802 "action": "strategy.run.start",
5803 "revisionDigest": revision,
5804 "input": {"message": "hi"},
5805 "idempotencyKey": String::from("cancel-parked"),
5806 "conversationId": conversation_id,
5807 }))
5808 .unwrap();
5809 assert_eq!(response["ok"], true, "{response}");
5810 let run_id = response["result"]["runId"].as_str().unwrap().to_owned();
5811 let parked = wait_for_status(&store, &run_id, StrategyRunStatus::Waiting);
5812 assert_eq!(parked.pending_callbacks.len(), 1);
5813 let runs_before = calls
5814 .lock()
5815 .unwrap()
5816 .iter()
5817 .filter(|(operation, _)| operation.starts_with("run:"))
5818 .count();
5819
5820 let cancelled = service
5821 .execute(json!({
5822 "action": "strategy.run.cancel",
5823 "runId": run_id,
5824 }))
5825 .unwrap();
5826 assert_eq!(cancelled["ok"], true);
5827 let snapshot = store.run(&run_id).unwrap();
5828 assert_eq!(snapshot.status, StrategyRunStatus::Cancelled);
5829 assert!(
5830 snapshot.pending_callbacks.is_empty(),
5831 "the durable wait is cleared with the cancellation"
5832 );
5833 let runs_after = calls
5834 .lock()
5835 .unwrap()
5836 .iter()
5837 .filter(|(operation, _)| operation.starts_with("run:"))
5838 .count();
5839 assert_eq!(
5840 runs_before, runs_after,
5841 "a parked wait has no executing turn, so cancelling it signals no owner"
5842 );
5843 let events = activity_events(&root);
5844 assert!(
5845 events
5846 .iter()
5847 .any(|event| event["type"] == "work.stop.accepted"),
5848 "the parked cancellation is recorded as normal control, not a fault"
5849 );
5850 remove_drive_root(root, service, &[&*run_id]);
5851 }
5852
5853 #[test]
5854 fn callback_park_reports_only_the_callback_request() {
5855 let root = root();
5856 let (conversation_store, conversation_id, membership_id) =
5857 conversation_bound_fixture(&root);
5858 let (store, revision) = authorized_callback_store(&root, &membership_id);
5859 let calls = Arc::new(Mutex::new(Vec::<(String, Value)>::new()));
5860 let port = recording_port(Arc::clone(&calls), |_, _| {
5861 Ok(json!({"ok": true, "output": "done", "nativeSessionId": "session-1"}))
5862 });
5863 let service = StrategyService::from_parts(
5864 root.clone(),
5865 store.clone(),
5866 StrategyPackageImporter::open(&root).unwrap(),
5867 )
5868 .with_actor_turn_port(port);
5869
5870 let response = service
5871 .execute(json!({
5872 "action": "strategy.run.start",
5873 "revisionDigest": revision,
5874 "input": {"message": "hi"},
5875 "idempotencyKey": String::from("callback-flow-start-1"),
5876 "conversationId": conversation_id,
5877 }))
5878 .unwrap();
5879 assert_eq!(response["ok"], true, "{response}");
5880 let run_id = response["result"]["runId"].as_str().unwrap().to_owned();
5881 let parked = wait_for_status(&store, &run_id, StrategyRunStatus::Waiting);
5882 assert_eq!(parked.pending_callbacks.len(), 1);
5883 wait_for_master_report(
5884 &conversation_store,
5885 &conversation_id,
5886 "strategy-callback-request",
5887 );
5888
5889 let page = conversation_store
5892 .page_events(&conversation_id, None, 64)
5893 .unwrap();
5894 let flow_notices = page
5895 .events
5896 .iter()
5897 .filter(|event| {
5898 event.parts.iter().any(|part| {
5899 part.kind == licoup_conversation::EventPartKind::Metadata
5900 && serde_json::from_str::<Value>(&part.content)
5901 .is_ok_and(|content| content["kind"] == json!("strategy-flow-settled"))
5902 })
5903 })
5904 .count();
5905 assert_eq!(flow_notices, 0);
5906 remove_drive_root(root, service, &[&*run_id]);
5907 }
5908}