Skip to main content

licoup_workflow_runtime/
service.rs

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;
30/// A cancellation that arrives while the drive is still preparing one exact
31/// turn waits this bounded moment for its identity. The wait never converts an
32/// unacknowledged request into an observed stop.
33const 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
36/// The Conversation-dispatch seam for one Conversation-bound actor command.
37/// `open` registers the turn and returns its handle, `run` executes one
38/// opened turn to its terminal state, `cancel` signals one opened turn to
39/// stop, and `abandon` settles one opened turn that will never run.
40/// Composition happens where the persistent host runtime exists; the strategy
41/// service treats the port as opaque. The port and its disposition vocabulary
42/// are the engine's drive-bookkeeping primitives.
43pub 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
49/// The notice seam for the designated Assistant. `wake` receives the
50/// Conversation id, the designated Assistant Membership id, and the
51/// identifier-only notice part already appended to the timeline; it opens one
52/// new Assistant turn for that notice only when no turn of that membership is
53/// in flight. Composition happens where the persistent host runtime exists.
54/// The seam itself is the engine's run-wake port under this host's own name.
55pub 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/// Test-only effect seam: fingerprints the actor the drive tests name and
124/// refuses every other host effect. Production builds do not include it.
125#[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        // The host's diagnostic owner writes these event types. The drive
217        // tests read that record, so the test double appends the same facts
218        // the host port is required to retain.
219        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            // The host that composes this process installs its own answers at
270            // its crate root; a process that installed none stays fail-closed.
271            ports: Arc::new(host_ports().clone()),
272        }
273    }
274
275    /// Replace the whole host composition for this service.
276    pub fn with_host_ports(mut self, ports: HostPorts) -> Self {
277        self.ports = Arc::new(ports);
278        self
279    }
280
281    /// The host composition this service reads.
282    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        // The crate's own drive tests answer the actor port directly. The
289        // effect seam still has to fingerprint that actor; this synthetic
290        // answer exists only while the crate is under test. A production
291        // build keeps whatever host port the process installed.
292        #[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    /// Execute one bridge action. Errors are converted to bounded typed facts;
318    /// raw paths, process output and adapter errors never cross this boundary.
319    /// Conversation-bound package import failures use the same master-agent
320    /// callback event as an imported run terminal: one schema, no second
321    /// protocol.
322    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                // Saved overrides may no longer occur in an Agent's selectable
401                // catalog. Their labels are read-only presentation facts, kept
402                // outside BindingValue and its persisted authorization identity.
403                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                // The admitting caller states the selection-policy revision it
697                // captured for this decision. It is required, never defaulted:
698                // a receipt that cannot name its policy revision would let
699                // provenance degrade silently.
700                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    /// Revalidate only store-owned identity immediately before durable
821    /// admission. Derived owners were already read once into the request-scoped
822    /// route snapshot and must not be queried a second time in the same request.
823    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        // A replayed execute is the Assistant facade's run-control lane: the
934        // master agent's callback decision arrives with it and the reducer
935        // applies it before the run is driven further.
936        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            // A pending callback returns control exactly like the other
961            // settled wait points: the master agent receives the durable
962            // state and decides the run's next step as a later run input.
963            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    /// Strict public projection for Assistant-temporary runs. Generic
982    /// strategy projections may carry an entry session locator, definition
983    /// labels, or generic control operations; none belong in the Assistant
984    /// facade. This allowlist retains only opaque Graph/Membership identities,
985    /// stable states, safe bindings, numeric history, and the frozen route
986    /// receipt.
987    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    /// Run actions drive Agent work on background threads, so the service is
1099    /// fail closed without the persistent host runtime: a one-shot process
1100    /// must never orphan a run or open an unattached turn.
1101    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    /// Register the entry Membership turn before the drive thread starts. The
1108    /// entry command, binding, Membership, and prompt resolve through the same
1109    /// builder the drive uses, and the returned guard hands the handle to the
1110    /// drive. An entry command that legitimately waits on authorization is not
1111    /// emitted yet, so its input is computed deterministically from the entry
1112    /// state; the run status already projects that gate.
1113    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                    // The entry command already belongs to a drive.
1145                    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)(&params).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    /// Mirror one durable reducer snapshot into the numeric-only Graph usage
1239    /// ledger. The projection is intentionally rebuilt from typed run,
1240    /// binding and command fields; input/output bodies never cross this seam.
1241    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    /// Refresh the usage ledger after a settlement.
1301    ///
1302    /// The ledger is a full rewrite of the run's commands, so a refresh that
1303    /// fails is repaired by the next one. Accounting therefore never gets to
1304    /// decide the run's domain outcome by aborting a drive whose effects are
1305    /// already persisted — only the admission calls that run before a drive
1306    /// is started stay fallible.
1307    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        // Usage admission is local and deterministic, so it completes before
1314        // registering the first Membership turn or issuing any other effect.
1315        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        // A previous host process disappearing is not lease revocation, so an
1387        // unexpired claim is left held by its recorded owner; only the persisted
1388        // lease clock resolves a claim, and only an effect that never started is
1389        // retried.
1390        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                // The reservation is visible before the command is durable as
1453                // `Running`, so a cancellation arriving in that window still
1454                // resolves this exact attempt.
1455                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            // A cancellation that committed while these effects were in flight
1523            // owns their terminal state. Settling them again would conflict
1524            // with that control transition and could fabricate a business
1525            // outcome for work the user already stopped.
1526            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        // The permission, version, and resource recheck is the only admission
1678        // gate for this effect: it re-reads the authorization from the store and
1679        // compares the claimed command's visit against the run's current visit,
1680        // so a revoked authorization or a stale command stops here rather than
1681        // inside the executor.
1682        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, &current) 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        // A Conversation-bound run must run a registered Membership
1887        // PersistentTurn. The entry command carries the pre-registered handle;
1888        // every later command opens and runs through the same port.
1889        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)(&params)
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        // The exact live turn is reachable for cancellation until its own run
1917        // returns; the reservation never outlives the effect attempt.
1918        live.attach(&handle);
1919        match (port.run)(&handle, &params) {
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    /// Cancel one durable run by signalling the owner of each in-flight effect.
1931    ///
1932    /// The durable `CancelRequested` transition is the request. Every command
1933    /// whose owner acknowledges the exact live turn settles as cancelled;
1934    /// every command whose owner does not acknowledge keeps its effect
1935    /// unknown. A request is never recorded as an observed exit, and a normal
1936    /// cancellation is not a fault.
1937    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    /// True only when the exact live turn executing this command acknowledged
1983    /// the cancellation. An absent port, an unknown handle, or an unreachable
1984    /// owner all stay unacknowledged.
1985    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                // Nothing in this process owns the attempt; the effect position
2001                // stays unknown instead of assuming a stop.
2002                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    /// Apply one run event and then surface the durable master-agent reports
2014    /// the reduction produced. The reduction commits first; the report is a
2015    /// Membership-scoped projection and never fails the drive.
2016    fn apply_run_event(&self, run_id: &str, event: ReducerEvent) -> Result<RunSnapshot> {
2017        self.transition.apply_event(run_id, event)
2018    }
2019
2020    /// Runs owe the master agent a Membership-scoped event when a
2021    /// callback-mode edge parks the run; imported runs additionally report
2022    /// any terminal failure outcome the master must decide from (an
2023    /// Assistant-owned run returns that outcome on its execute call, so it is
2024    /// not double-reported here). The durable run projection stays the
2025    /// authority; this event is its conversation surface, so payload facts
2026    /// stay identifier-only. The master is the originating Assistant
2027    /// Membership for Assistant-owned runs and the bound Conversation's
2028    /// designated Assistant Membership for imported runs; the payload names
2029    /// the answer channel that actually settles the wait.
2030    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        // Flow settlement: a state completed without parking a callback and
2061        // without a terminal failure. Both the ordinary continuation and a
2062        // run that reached terminal success through flow edges notify; a
2063        // newly parked callback is reported as a callback-request instead.
2064        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    /// Package prepare/commit never create a run. When the caller binds a
2156    /// Conversation, the same terminal-outcome event reports the typed
2157    /// failure to that Conversation's designated Assistant Membership.
2158    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        // The notice is already durable on the timeline; the wake is a
2216        // fire-and-forget surface that opens one new Assistant turn only when
2217        // no turn of that membership is in flight, so it never fails the
2218        // drive and never stacks a second turn.
2219        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
2274/// One registered entry turn whose settlement is owed to the drive that runs
2275/// it. Dropping an armed registration abandons the handle with a typed code,
2276/// so a drive that exits, fails to claim, or claims another command first can
2277/// never leave a phantom active turn keeping the pane busy.
2278struct 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    /// A turn opened inline by the drive owns the same abandonment guarantee
2289    /// as a pre-registered entry turn.
2290    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    /// Hand the registered turn to the drive's run path. From this point the
2310    /// dispatch completion authority owns its settlement.
2311    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
2325/// The state that emits the first entry-slot command. When the initial state
2326/// is an authorization gate, the entry state is its deterministic success
2327/// target: the drive grants an active authorization before claiming anything.
2328fn 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        // A callback-mode edge parks the run before the entry state exists;
2346        // its turn opens when the master decision arrives, not at start.
2347        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
2360/// Resolve the active Agent Membership one Conversation-bound command runs
2361/// against. Addressing is by Membership identity only.
2362struct 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
2397/// The single parameter builder for one Conversation-bound actor turn, shared
2398/// by entry registration and the drive so a pre-registered handle always runs
2399/// the exact prompt and routing the emitted command carries.
2400fn 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
2449/// Prompt sent on a Conversation-bound actor PersistentTurn.
2450///
2451/// Prefer the human Event text (`message` / `text` / `context.message`). Do
2452/// not fall back to a JSON dump of the command input: Codex treats that as
2453/// the user turn and often completes with `failed`.
2454fn 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        // The cancel path carries the stop control's opaque correlation id so
2625        // the request, the owner's acknowledgement, and the unconfirmed
2626        // outcome share one diagnostic identity.
2627        "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
2690/// Extract only checked numeric usage candidates from a runtime result. The
2691/// output body itself is never handed to the ledger or stored.
2692fn 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
2758/// Parse the optional master-agent decision one run-control call carries for
2759/// a callback-parked run. The callback identity (state id + visit) pins the
2760/// exact wait being settled so a replayed decision is stale, never doubled.
2761fn 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
2782/// The terminal outcomes that a master agent must decide from: genuine
2783/// failures, not deliberate cancellation.
2784fn 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        // The executor's own pre-effect recheck refused this effect. Keep the
2832        // recheck's exact typed code (revision, generation, session writer, or
2833        // budget) instead of flattening every refusal into a generic effect
2834        // failure, so the run projection says which fact changed.
2835        (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        // A PersistentTurn that finished with ok=false is a completed effect
2843        // failure. Do not classify it as dispatch_failed/transient or the
2844        // Graph will take the success edge and hand the chat to the next slot.
2845        (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
2858/// The typed codes [`super::evolution::EffectRecheckFailure::code`] produces.
2859///
2860/// They are matched structurally rather than by an equality check because the
2861/// executor wraps the refusal in its own error text on the way out.
2862const 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
2876/// Actor/workset JSON that the runtime returned as a value, not a transport Err.
2877///
2878/// A port run yields `Ok({ok:false,...})` when Codex pushes `turn/completed`
2879/// with `status != completed`. That is a failed effect, not CommandSucceeded.
2880fn 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    /// The selection-policy revision a test admits its fixture graph under.
3208    /// Every admission states it explicitly; nothing here defaults it.
3209    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(&current) {
3224                make_writable(&current, metadata.permissions());
3225                if metadata.is_dir()
3226                    && let Ok(entries) = fs::read_dir(&current)
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        // A refusal from the executor's own pre-effect recheck keeps its typed
3591        // code, so the run projection says which fact changed instead of
3592        // reporting every refusal as a generic effect failure.
3593        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        // A revoked authorization stays an authority refusal the master can
3600        // answer by re-authorizing, not a bare recheck code.
3601        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    /// A terminal snapshot is visible before the drive thread finishes its
3936    /// exit path, so root cleanup waits the drive out and retries the remove
3937    /// while SQLite drops its journal files.
3938    fn remove_drive_root(root: PathBuf, service: StrategyService, run_ids: &[&str]) {
3939        // The drive set is process-wide while these tests run in parallel, so
3940        // this bound is a liveness guarantee for every in-flight drive, not a
3941        // latency assertion about this test's own run. Only the runs this
3942        // test started can hold this root's store handles, so the wait is
3943        // scoped to their reservations.
3944        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    /// Start one conversation-bound run whose actor effect stays in flight
4611    /// until the returned release sender fires.
4612    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        // The turn is executing (and therefore has its exact handle attached)
4664        // before either test cancels; the fixture never races the cancellation.
4665        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        // The stopped effect settling late never fabricates a business success
4727        // and never rewrites the acknowledged cancellation.
4728        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        // The entry effect completes, then the callback edge parks the run
5016        // instead of entering the declared target.
5017        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        // The master agent received the Membership-scoped callback report.
5029        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        // The request carries the evolution facts and suggestions the Assistant
5057        // decides from. Spend is reported as a number or as unknown (null),
5058        // never as zero-on-failure, and no budget pool is claimed while the T04
5059        // pool seam is unwired.
5060        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        // Strategy versions advise; they never grant execution permission, and
5076        // with no candidate catalog wired the request carries no advice at all.
5077        assert!(report["suggestions"]["strategySuggestion"].is_null());
5078
5079        // A bare resume never advances a callback wait.
5080        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        // The master agent's decision arrives as a run input.
5092        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        // A replayed decision is stale and never double-enters the target.
5108        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    /// A callback edge whose target is a second actor state, so the master's
5123    /// decision starts an effect that must pass the executor's own recheck.
5124    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        // The host revokes the authorization while the run waits. The master's
5287        // decision is still accepted, but the effect it would start is refused
5288        // before any actor call is made: the executor's pre-effect recheck
5289        // reads the revoked row first, and the store's own effect authorization
5290        // refuses it independently.
5291        //
5292        // This proves the end-to-end outcome, not which of the two gates fired:
5293        // the recheck's own branches are pinned by the evolution unit tests.
5294        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        // The conversation surface carries the same callback request and
5571        // names the MCP execute replay as the master agent's answer channel.
5572        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        // The master agent's advance decision rides the idempotent replay.
5594        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        // #328 at the service boundary: a drive can report a failure after the
5612        // callback park is already durable — a usage-ledger refresh under load
5613        // did exactly that. The parked run must keep waiting with its callback
5614        // intact, and the master's decision must still land.
5615        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        // The failure a loaded drive reports after the park.
5656        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        // The entry effect settles and the Graph continues to `done` without
5720        // parking: every settled step — the entry actor and the terminal
5721        // success state — owes the master one identifier notice.
5722        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 &notices {
5765            assert_eq!(notice["runId"], json!(run_id));
5766            assert_eq!(notice["mode"], json!("flow"));
5767        }
5768
5769        // Every settlement report wakes with the same identifier event only.
5770        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        // A settle that parks reports the wait, not a flow settlement: the
5890        // step did not continue on its own.
5891        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}