Skip to main content

mj_controller/session_manager/
standalone.rs

1use super::*;
2
3pub struct StandaloneSession {
4    pub(super) client: RelayClient,
5    pub(super) materialized: MaterializedSession,
6    pub(super) operational: RelayOperationalState,
7    pub(super) latest_credential_sync_signal: Option<CredentialSyncSignal>,
8    pub(super) project_memory: Option<ProjectMemorySyncTarget>,
9    pub(super) subagent_requests: Vec<mj_core::subagent::SubagentToolRequest>,
10    pub(super) subagent_results: Vec<mj_core::subagent::SubagentToolResult>,
11}
12
13impl StandaloneSession {
14    pub fn set_project_memory_target(&mut self, target: Option<ProjectMemorySyncTarget>) {
15        self.project_memory = target;
16    }
17
18    pub async fn connect(target: &RelaySessionTarget) -> Result<Self> {
19        // Reach the worker before reading the projection. A stored session can
20        // be tens of megabytes, and the reconnect loop would otherwise pay that
21        // whole synchronous read on every attempt against a worker that is down.
22        let mut client = RelayClient::connect(&target.spec, &target.session_id).await?;
23        let operational = client.status().await?;
24        let materialized = load_projection(&target.session_id).await?;
25        let mut connection = Self {
26            client,
27            materialized,
28            operational,
29            latest_credential_sync_signal: None,
30            project_memory: target.project_memory.clone(),
31            subagent_requests: Vec::new(),
32            subagent_results: Vec::new(),
33        };
34        connection.sync_in_place().await?;
35        Ok(connection)
36    }
37
38    pub async fn connect_command(spec: &CommandSpec, session_id: &str) -> Result<Self> {
39        Self::connect(&RelaySessionTarget {
40            session_id: session_id.to_owned(),
41            spec: spec.clone(),
42            worker_recovery: None,
43            project_memory: None,
44        })
45        .await
46    }
47
48    /// Protocol negotiated with the worker behind this connection. Lifecycle
49    /// operations use it to avoid sending a newly introduced command to an
50    /// older worker that cannot decode it.
51    pub fn protocol_version(&self) -> u32 {
52        self.client.protocol_version()
53    }
54
55    pub(super) async fn detach(self) -> Result<()> {
56        self.client.detach().await
57    }
58
59    pub async fn sync(&mut self) -> Result<ManagedSessionSnapshot> {
60        self.sync_in_place().await?;
61        Ok(self.snapshot())
62    }
63
64    pub(super) async fn sync_in_place(&mut self) -> Result<bool> {
65        let original_ordinal = self.materialized.applied_event_ordinal;
66        let original_digest = self.materialized.applied_event_digest.clone();
67        let original_operational = self.operational.clone();
68        let mut repaired = false;
69        let mut repaired_frontiers = std::collections::HashSet::new();
70        loop {
71            let after_ordinal = self.materialized.applied_event_ordinal;
72            match self.catch_up_fixed_frontier().await {
73                Ok(()) => break,
74                Err(error) if error.downcast_ref::<ProjectionAdvancedError>().is_some() => {
75                    let durable = load_projection(&self.materialized.session_id).await?;
76                    if durable.applied_event_ordinal <= after_ordinal {
77                        return Err(error);
78                    }
79                    self.materialized = durable;
80                    continue;
81                }
82                Err(error) if relay_desynchronized(&error) => {
83                    self.repair_projection()
84                        .await
85                        .with_context(|| {
86                            format!(
87                                "controller projection for {} cannot catch up from ordinal {after_ordinal}: {error:#}",
88                                self.materialized.session_id
89                            )
90                        })?;
91                    repaired = true;
92                    // Repair rebuilds from the same durable checkpoint every
93                    // time. If catching up from that frontier still desyncs — as
94                    // it does when relay history is unreadable past the
95                    // checkpoint — repairing again lands on the same frontier and
96                    // would loop forever. Fail loudly on the second visit instead
97                    // of hanging; recovery got everything the checkpoint covers.
98                    let frontier = self.materialized.applied_event_ordinal;
99                    if !repaired_frontiers.insert(frontier) {
100                        bail!(
101                            "controller projection for {} cannot catch up: relay history is \
102                             unreadable and rebuilding from checkpoint frontier {frontier} does \
103                             not get past it",
104                            self.materialized.session_id
105                        );
106                    }
107                    continue;
108                }
109                Err(error) => return Err(error),
110            }
111        }
112        let previous_requests = self.subagent_requests.clone();
113        let previous_results = self.subagent_results.clone();
114        (self.subagent_requests, self.subagent_results) = self.client.subagent_requests().await?;
115        let changed = repaired
116            || self.materialized.applied_event_ordinal != original_ordinal
117            || self.materialized.applied_event_digest != original_digest
118            || self.operational != original_operational
119            || self.subagent_requests != previous_requests
120            || self.subagent_results != previous_results;
121        Ok(changed)
122    }
123
124    /// Apply relay pages through the exact frontier captured by the first
125    /// response, then acknowledge that frontier once. Every projection page is
126    /// independently durable; delaying the relay's GC watermark avoids one
127    /// snapshot fsync per transport-sized page without risking redelivery.
128    pub(super) async fn catch_up_fixed_frontier(&mut self) -> Result<()> {
129        let after = RelayCursor {
130            ordinal: self.materialized.applied_event_ordinal,
131            digest: self.materialized.applied_event_digest.clone(),
132        };
133        let catch_up = self
134            .client
135            .begin_catch_up(after.ordinal, &after.digest)
136            .await?;
137        let mut cursor = self.apply_event_page(catch_up.first_page).await?;
138        let mut pages_remaining = catch_up.frontier.ordinal.saturating_sub(cursor.ordinal);
139        while cursor.ordinal < catch_up.frontier.ordinal {
140            ensure!(
141                pages_remaining > 0,
142                "relay catch-up exceeded its fixed page bound"
143            );
144            pages_remaining -= 1;
145            let page = self
146                .client
147                .next_catch_up_page(&cursor, &catch_up.frontier)
148                .await?;
149            cursor = self.apply_event_page(page).await?;
150        }
151        ensure!(
152            cursor == catch_up.frontier,
153            "controller projection did not reach the captured relay frontier"
154        );
155        if cursor.ordinal > 0 {
156            let acknowledged = self
157                .client
158                .acknowledge(cursor.ordinal, &cursor.digest)
159                .await?;
160            ensure!(
161                acknowledged == cursor,
162                "relay acknowledged cursor {}:{} instead of {}:{}",
163                acknowledged.ordinal,
164                acknowledged.digest,
165                cursor.ordinal,
166                cursor.digest,
167            );
168        }
169        let mut operational = catch_up.state;
170        operational.acknowledged_through = cursor.ordinal;
171        operational.acknowledged_digest = cursor.digest;
172        self.operational = operational;
173        Ok(())
174    }
175
176    pub(super) async fn repair_projection(&mut self) -> Result<()> {
177        let state = crate::database::load_state()?;
178        let record = state
179            .sessions
180            .get(&self.materialized.session_id)
181            .context("controller session disappeared while repairing its projection")?;
182        let Some(checkpoint) = record.checkpoint.as_ref() else {
183            let replacement = MaterializedSession::empty(&self.materialized.session_id);
184            self.client
185                .attach(
186                    replacement.applied_event_ordinal,
187                    &replacement.applied_event_digest,
188                )
189                .await
190                .context("relay cannot rebuild the projection from its genesis")?;
191            save_materialized_session(&replacement)?;
192            self.materialized = replacement;
193            return Ok(());
194        };
195        let checkpoint_path = checkpoint.archive_path.clone();
196        let archive = tokio::task::spawn_blocking(move || {
197            verify_archive_streaming(&checkpoint_path).with_context(|| {
198                format!(
199                    "verify projection repair checkpoint {}",
200                    checkpoint_path.display()
201                )
202            })
203        })
204        .await
205        .context("projection repair archive verification task failed")??;
206        ensure!(
207            archive.archive_sha256 == checkpoint.sha256,
208            "projection repair checkpoint checksum does not match controller metadata"
209        );
210        ensure!(
211            archive.manifest.session.id == self.materialized.session_id,
212            "projection repair checkpoint belongs to session {}, not {}",
213            archive.manifest.session.id,
214            self.materialized.session_id
215        );
216        let canonical = archive.canonical_session;
217        ensure!(
218            canonical.event_frontier == checkpoint.event_frontier,
219            "projection repair checkpoint metadata frontier {} does not match archive frontier {}",
220            checkpoint.event_frontier,
221            canonical.event_frontier
222        );
223
224        // Prove that the relay recognizes this exact event-chain cursor before
225        // replacing any controller state. A matching ordinal alone is not a
226        // repair proof.
227        self.client
228            .attach(canonical.event_frontier, &canonical.event_frontier_digest)
229            .await
230            .context("relay rejected the verified checkpoint repair cursor")?;
231        let replacement =
232            materialized_session_from_canonical(&self.materialized.session_id, &canonical)?;
233        save_materialized_session(&replacement)?;
234        self.materialized = replacement;
235        Ok(())
236    }
237
238    pub fn snapshot(&self) -> ManagedSessionSnapshot {
239        ManagedSessionSnapshot {
240            window: mj_core::state::ProjectionWindow::of(&self.materialized),
241            materialized: self.materialized.clone(),
242            operational: self.operational.clone(),
243            latest_credential_sync_signal: self.latest_credential_sync_signal.clone(),
244            worker_build: self.client.worker_build().map(str::to_owned),
245            subagent_requests: self.subagent_requests.clone(),
246            subagent_results: self.subagent_results.clone(),
247        }
248    }
249
250    pub async fn complete_subagent_request(
251        &mut self,
252        result: mj_core::subagent::SubagentToolResult,
253    ) -> Result<()> {
254        self.client.complete_subagent_request(result).await?;
255        (self.subagent_requests, self.subagent_results) = self.client.subagent_requests().await?;
256        Ok(())
257    }
258
259    /// Hands one command to the relay and returns the ordinal it accepted it
260    /// at, without catching the local projection up to it.
261    ///
262    /// Callers that need the projection current call [`Self::sync`] after.
263    /// Keeping the two apart matters on the prompt path: the catch-up is the
264    /// expensive half, and a caller waiting to hear that the relay took the
265    /// command should not wait for it. It also stops a failed catch-up from
266    /// looking like a failed submission to a caller that would retry.
267    pub async fn submit_accepted(
268        &mut self,
269        command_id: String,
270        command: RelayCommand,
271    ) -> Result<u64> {
272        self.client.submit(command_id, command).await
273    }
274
275    pub async fn submit(&mut self, command_id: String, command: RelayCommand) -> Result<u64> {
276        let ordinal = self.submit_accepted(command_id, command).await?;
277        self.sync_in_place().await?;
278        Ok(ordinal)
279    }
280
281    pub async fn respond_elicitation(
282        &mut self,
283        elicitation_id: String,
284        response: ElicitationResponse,
285    ) -> Result<()> {
286        self.client
287            .respond_elicitation(elicitation_id, response)
288            .await?;
289        self.sync_in_place().await?;
290        Ok(())
291    }
292
293    pub async fn stop_background_task(&mut self, background_task_id: String) -> Result<()> {
294        self.client.stop_background_task(background_task_id).await?;
295        self.sync_in_place().await?;
296        Ok(())
297    }
298
299    /// Persist relay-private context for the next real prompt. It never
300    /// contributes an event to the canonical projection.
301    pub async fn install_prompt_context(&mut self, text: String) -> Result<()> {
302        self.client.install_prompt_context(text).await
303    }
304
305    /// Apply one relay transport page in bounded durable chunks. A transport
306    /// page can contain thousands of events, but SQLite has one global writer;
307    /// regularly releasing it lets other session actors keep their views
308    /// current. The relay GC watermark advances only after the complete page.
309    pub(super) async fn apply_event_page(&mut self, page: RelayEventPage) -> Result<RelayCursor> {
310        for event in &page.events {
311            if let mj_core::relay::RelayObservation::CommandQueued {
312                command: RelayCommand::Prompt { prompt },
313                ..
314            } = &event.observation
315            {
316                for reference in mj_core::attachment::references(prompt)? {
317                    if let Err(error) = self.client.cache_attachment(&reference).await {
318                        // History remains readable even if a blob was lost. A
319                        // later submission still verifies every image before
320                        // admission, and must report missing data to the user.
321                        tracing::warn!(
322                            session_id = %self.materialized.session_id,
323                            attachment = %reference.sha256,
324                            %error,
325                            "could not cache image attachment during replay"
326                        );
327                    }
328                }
329            }
330        }
331
332        let RelayEventPage {
333            events,
334            through_ordinal,
335            through_digest,
336        } = page;
337        let event_count = events.len();
338        let transaction_count = event_count.div_ceil(PROJECTION_TRANSACTION_EVENT_BUDGET);
339        let started = Instant::now();
340        for events in events.chunks(PROJECTION_TRANSACTION_EVENT_BUDGET) {
341            let session_id = self.materialized.session_id.clone();
342            let events = events.to_vec();
343            let projection = self.materialized.clone();
344            // Projection is CPU work and its durable page uses synchronous
345            // SQLite. Keep both off the async actor runtime so independent
346            // sessions stay responsive during each bounded catch-up chunk.
347            let (projection, credential_sync_signal) = tokio::task::spawn_blocking(
348                move || -> Result<(MaterializedSession, Option<CredentialSyncSignal>)> {
349                    // The in-memory projection advances on a working copy and
350                    // is published only once its page is durable.
351                    let mut projection = projection;
352                    let mut projection_index = ProjectionIndex::new(&projection);
353                    let mut credential_sync_signal = None;
354                    let mut prepared = Vec::with_capacity(events.len());
355                    for event in &events {
356                        let mutation =
357                            project_relay_event_indexed(&projection, &projection_index, event)?
358                                .mutation;
359                        prepared.push((
360                            event.ordinal,
361                            event.previous_digest.clone(),
362                            event.digest.clone(),
363                            mutation.clone(),
364                        ));
365                        apply_committed_projection_event_indexed(
366                            &mut projection,
367                            &mut projection_index,
368                            event,
369                            mutation,
370                        )?;
371                        if let Some(reason) = relay_event_credential_sync_reason(event) {
372                            credential_sync_signal = Some(CredentialSyncSignal {
373                                ordinal: event.ordinal,
374                                reason,
375                            });
376                        }
377                    }
378                    drop(projection_index);
379                    apply_projection_page(&session_id, move |committed| {
380                        for (ordinal, previous_digest, digest, mutation) in prepared {
381                            match committed.apply(ordinal, &previous_digest, &digest, &mutation)? {
382                                ProjectionApplyOutcome::Applied => {}
383                                ProjectionApplyOutcome::AlreadyApplied => {
384                                    return Err(ProjectionAdvancedError {
385                                        event_ordinal: ordinal,
386                                    }
387                                    .into());
388                                }
389                            }
390                        }
391                        Ok((projection, credential_sync_signal))
392                    })
393                },
394            )
395            .await
396            .context("relay projection page task failed")??;
397            self.materialized = projection;
398            if let Some(signal) = credential_sync_signal {
399                self.latest_credential_sync_signal = Some(signal);
400            }
401        }
402        if transaction_count > 1 {
403            tracing::debug!(
404                session_id = self.materialized.session_id,
405                event_count,
406                transaction_count,
407                elapsed_ms = started.elapsed().as_millis(),
408                "applied a large relay page in bounded projection transactions"
409            );
410        }
411        let delivered_through = self.materialized.applied_event_ordinal;
412        ensure!(
413            delivered_through == through_ordinal,
414            "relay page claimed frontier {} but delivered through {delivered_through}",
415            through_ordinal
416        );
417        ensure!(
418            self.materialized.applied_event_digest == through_digest,
419            "relay page digest does not match its claimed frontier"
420        );
421        Ok(RelayCursor {
422            ordinal: delivered_through,
423            digest: self.materialized.applied_event_digest.clone(),
424        })
425    }
426
427    /// Reconcile this worker's project-memory replica at an explicit durable
428    /// boundary. Normal relay attachment and polling must never perform this
429    /// filesystem work: a degraded target could otherwise turn reconnects
430    /// into an unbounded queue of timed-out snapshot writes.
431    pub async fn sync_project_memory(&mut self) -> Result<()> {
432        let Some(target) = self.project_memory.clone() else {
433            return Ok(());
434        };
435        if !self.client.supports_project_memory_sync() {
436            tracing::warn!(
437                session_id = self.materialized.session_id,
438                "worker protocol predates project-memory synchronization; preserving memory through checkpoints only"
439            );
440            self.project_memory = None;
441            return Ok(());
442        }
443        let (baseline, replica) = match self.client.project_memory_snapshot().await {
444            Ok(snapshot) => snapshot,
445            Err(error)
446                if error
447                    .downcast_ref::<RelayRejected>()
448                    .is_some_and(|rejected| {
449                        rejected.0.code == mj_core::relay::RelayErrorCode::InvalidState
450                    }) =>
451            {
452                tracing::warn!(
453                    session_id = self.materialized.session_id,
454                    "worker has no project-memory endpoint; preserving memory through checkpoints only"
455                );
456                self.project_memory = None;
457                return Ok(());
458            }
459            Err(error) => return Err(error),
460        };
461        let canonical_root = target.canonical_root;
462        let session_id = self.materialized.session_id.clone();
463        let (reconciliation, worker_install_needed) = tokio::task::spawn_blocking(move || {
464            let reconciliation = mj_core::project_memory::reconcile_into_canonical(
465                &canonical_root,
466                &baseline,
467                &replica,
468                &session_id,
469            )?;
470            let worker_install_needed =
471                reconciliation.merged != baseline || reconciliation.merged != replica;
472            Ok::<_, anyhow::Error>((reconciliation, worker_install_needed))
473        })
474        .await
475        .context("project memory reconciliation task failed")??;
476        for conflict in &reconciliation.conflicts {
477            tracing::warn!(session_id = self.materialized.session_id, %conflict, "project memory conflict preserved");
478        }
479        if worker_install_needed {
480            self.client
481                .install_project_memory_snapshot(reconciliation.merged)
482                .await?;
483        }
484        Ok(())
485    }
486}
487
488pub(super) fn relay_desynchronized(error: &anyhow::Error) -> bool {
489    error.chain().any(|cause| {
490        cause
491            .downcast_ref::<RelayRejected>()
492            .is_some_and(RelayRejected::is_desynchronized)
493    })
494}
495
496pub(super) fn projection_integrity_failure(error: &anyhow::Error) -> bool {
497    error
498        .chain()
499        .any(|cause| cause.downcast_ref::<ProjectionIntegrityError>().is_some())
500}
501
502/// A stopped actor and the manager that resolves its live replacement.
503///
504/// This fixture and its constructor are compiled unconditionally and hidden
505/// from the documentation because the chat crate's tests need them, and a
506/// `#[cfg(test)]` item is invisible to another crate.
507#[cfg(test)]
508pub(super) struct ReplacementSessionTestFixture {
509    pub(super) stopped: ManagedSessionHandle,
510    pub(super) control: SessionManagerControl,
511    pub(super) submitted: mpsc::UnboundedReceiver<RelayCommand>,
512}
513
514/// A stopped actor and a manager that resolves its live replacement. Chat
515/// tests use this hand-written actor instead of mocking the session manager
516/// protocol.
517#[cfg(test)]
518pub(super) fn replacement_session_test_fixture(
519    session_id: &str,
520    accepted_ordinal: u64,
521) -> ReplacementSessionTestFixture {
522    let (stopped_commands, stopped_commands_rx) = mpsc::channel(1);
523    drop(stopped_commands_rx);
524    let (stopped_releases, stopped_releases_rx) = mpsc::unbounded_channel();
525    drop(stopped_releases_rx);
526    let (stopped_view_tx, stopped_view) = watch::channel(ManagedSessionView::default());
527    drop(stopped_view_tx);
528    let stopped = ManagedSessionHandle {
529        session_id: session_id.to_owned(),
530        commands: stopped_commands,
531        releases: stopped_releases,
532        view: stopped_view,
533    };
534
535    let (commands, mut commands_rx) = mpsc::channel(4);
536    let (releases, _releases_rx) = mpsc::unbounded_channel();
537    let (view_tx, view) = watch::channel(ManagedSessionView::default());
538    let replacement = ManagedSessionHandle {
539        session_id: session_id.to_owned(),
540        commands,
541        releases,
542        view,
543    };
544    let actor_session_id = session_id.to_owned();
545    let (submitted_tx, submitted) = mpsc::unbounded_channel();
546    tokio::spawn(async move {
547        let _view_tx = view_tx;
548        while let Some(command) = commands_rx.recv().await {
549            match command {
550                ActorCommand::Submit { command, reply, .. } => {
551                    // Tests can drop the optional observer when they only
552                    // care about acceptance/reconnection.
553                    let _ = submitted_tx.send(command);
554                    let _ = reply.send(Ok(accepted_ordinal));
555                }
556                ActorCommand::Sync { reply } => {
557                    let _ = reply.send(Ok(()));
558                }
559                command => command.reject(&actor_session_id, "unsupported test operation"),
560            }
561        }
562    });
563
564    let (manager_commands, mut manager_commands_rx) = mpsc::channel(4);
565    let manager_replacement = replacement.clone();
566    tokio::spawn(async move {
567        while let Some(ManagerCommand::Session {
568            session_id: requested,
569            reply,
570        }) = manager_commands_rx.recv().await
571        {
572            let resolved =
573                (requested == manager_replacement.session_id).then(|| manager_replacement.clone());
574            let _ = reply.send(resolved);
575        }
576    });
577    ReplacementSessionTestFixture {
578        stopped,
579        submitted,
580        control: SessionManagerControl {
581            commands: manager_commands,
582        },
583    }
584}