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 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 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 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 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 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 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 pub async fn install_prompt_context(&mut self, text: String) -> Result<()> {
302 self.client.install_prompt_context(text).await
303 }
304
305 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 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 let (projection, credential_sync_signal) = tokio::task::spawn_blocking(
348 move || -> Result<(MaterializedSession, Option<CredentialSyncSignal>)> {
349 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 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#[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#[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 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}