1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
use super::*;
/// What the host's task processes, in order.
pub(super) enum HostEvent {
View {
session_id: String,
snapshot: Option<Box<MaterializedSession>>,
/// Whether this view had a prompt of ours in flight. Only a turn that
/// answered a prompt arms an automatic review.
prompt_driven: bool,
},
/// Drop the retained last-view (with its full transcript) for every session
/// no longer in the live set. Without this, `sessions` keeps a
/// `MaterializedSession` per session ever observed and never releases it.
Retain {
live: std::collections::BTreeSet<String>,
},
Start {
session_id: String,
manual: bool,
reply: Option<oneshot::Sender<Result<(), StartRefusal>>>,
},
Prepared {
session_id: String,
manual: bool,
reply: Option<oneshot::Sender<Result<(), StartRefusal>>>,
prepared: Result<Prepared, StartRefusal>,
},
RecoveryPrepared {
session_id: String,
prepared: Result<Option<Prepared>, String>,
},
StateSaved {
session_id: String,
completion: PersistenceCompletion,
result: Result<(), String>,
},
/// One asynchronous step of a review that was open when it started.
///
/// `epoch` is which review asked. mjolnir's orchestrator tags every review
/// outcome with one and drops the ones that no longer match
/// (`mj-core/src/orchestrator.rs`, `review_outcome_rx`), because a result
/// arriving after its review was cancelled would otherwise be applied to
/// whatever review is open now. Session id alone is not enough: a session
/// can start its next review immediately.
Step {
session_id: String,
epoch: u64,
step: ReviewStep,
},
Resolve {
session_id: String,
resolution: Resolution,
reply: oneshot::Sender<Result<(), String>>,
},
/// Reviews a daemon restart interrupted, so each session's conversation
/// says what happened to it.
Interrupted { interrupted: Vec<String> },
Shutdown {
reply: oneshot::Sender<Result<(), String>>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PersistenceCompletion {
Open,
Forward,
Close,
}
pub(super) enum PersistenceRequest {
SweepInterrupted,
Save {
session_id: String,
state: Box<TurnReviewState>,
completion: Option<PersistenceCompletion>,
},
ClearActive {
reply: oneshot::Sender<Result<(), String>>,
},
}
/// One asynchronous step's result, belonging to exactly one review.
pub(super) enum ReviewStep {
Delta(Result<Vec<mj_core::relay::RepoDelta>, String>),
Analysis(Result<String, String>),
RoleStarted {
role: String,
result: Result<(), String>,
},
RolePrompted {
role: String,
result: Result<(), String>,
},
PrimaryPrompted(Result<(), String>),
RoleEvents {
role: String,
result: Result<Vec<RelayEvent>, String>,
},
Dispatches(Result<Vec<mj_core::review::lanes::ReviewSubagentRequest>, String>),
}
/// Everything one blocking preparation gathered before a review can start.
pub(super) struct Prepared {
pub(super) state: TurnReviewState,
pub(super) reviewer: ReviewerIdentity,
pub(super) tier: ReviewTier,
/// Read from the live actor after the admission hold is installed and a
/// reviewer status command drains every actor command ahead of it.
pub(super) materialized: Box<MaterializedSession>,
/// Present only while startup reconciles an interrupted corrective
/// handoff. Such a review skips reviewer processes and retries the exact
/// primary command id.
pub(super) resume_forward: Option<PendingForward>,
}
pub(super) struct PendingOpen {
pub(super) epoch: u64,
pub(super) manual: bool,
pub(super) reply: Option<oneshot::Sender<Result<(), StartRefusal>>>,
pub(super) prepared: Prepared,
}
/// Which harness reviews, and how it is configured. Read from `[review]`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ReviewerIdentity {
pub(super) profile: String,
pub(super) model: Option<String>,
pub(super) effort: Option<String>,
}
/// One open review and its execution context.
pub(super) struct ReviewSlot {
/// Which review this is. Asynchronous results name it, and results that
/// name another are dropped.
pub(super) epoch: u64,
pub(super) driver: TurnReviewDriver,
/// One transcript projection per reviewing role, which is how the host
/// reads a role's answer out of its own relay journal.
pub(super) roles: BTreeMap<String, RoleTranscript>,
pub(super) reviewer: ReviewerIdentity,
pub(super) state: TurnReviewState,
/// The sidecar reads a new generation as "this is a different reviewer".
/// Fresh role launches receive a random nonce, so a later review cannot
/// reuse the native conversation left by an earlier one.
pub(super) generation: u64,
}
/// One role's journal, folded far enough to read its final answer.
#[derive(Default)]
pub(super) struct RoleTranscript {
pub(super) session: Option<MaterializedSession>,
pub(super) cursor_ordinal: u64,
pub(super) cursor_digest: String,
}
impl RoleTranscript {
pub(super) fn apply(&mut self, session_id: &str, events: &[RelayEvent]) {
let session = self
.session
.get_or_insert_with(|| MaterializedSession::empty(session_id));
for event in events {
let Ok(projected) = mj_transcript::projection::project_relay_event(session, event)
else {
continue;
};
if mj_transcript::projection::apply_committed_projection_event(
session,
event,
projected.mutation,
)
.is_err()
{
continue;
}
self.cursor_ordinal = event.ordinal;
self.cursor_digest.clone_from(&event.digest);
}
}
/// The role's latest complete answer, which is what the driver reads. Tool
/// logs and reasoning are deliberately not part of it.
pub(super) fn latest_answer(&self) -> Option<String> {
let session = self.session.as_ref()?;
session
.transcript
.iter()
.rev()
.find(|item| item.is_nonempty_agent_message())
.and_then(|item| {
let mj_core::state::TranscriptBody::Agent { chunks, .. } = &item.body else {
return None;
};
Some(mj_core::transcript::materialized_chunks_text(chunks))
})
.filter(|text| !text.trim().is_empty())
}
}
pub(super) struct HostState {
pub(super) control: SessionManagerControl,
pub(super) config: ReviewConfigSource,
pub(super) environment: Arc<dyn ReviewEnvironment>,
pub(super) shared: Arc<HostShared>,
pub(super) events: mpsc::UnboundedSender<HostEvent>,
pub(super) persistence: Option<mpsc::UnboundedSender<PersistenceRequest>>,
pub(super) persistence_task: Option<tokio::task::JoinHandle<()>>,
pub(super) reviews: BTreeMap<String, ReviewSlot>,
/// Sessions whose review is being prepared. Preparation is asynchronous,
/// so without this an automatic trigger and a manual `/review` racing each
/// other would both create a review and the second would overwrite the
/// first.
pub(super) preparing: BTreeSet<String>,
/// Reviews whose durable active marker is being written. They are not
/// visible and start no agents until that write succeeds.
pub(super) pending_open: BTreeMap<String, PendingOpen>,
/// Reviews whose durable active marker is being cleared. Their resolved
/// view and prompt hold remain until the ordered write completes.
pub(super) closing: BTreeSet<String>,
/// Primary handoff requests wait here until their durable pending record
/// has been written. This prevents an accepted relay command from racing
/// a failed SQLite write.
pub(super) awaiting_forward_persistence: BTreeMap<String, Vec<ReviewRequest>>,
/// Distinguishes reviews. Every asynchronous step carries the epoch of the
/// review that asked for it, so a late result cannot land on its
/// successor.
pub(super) next_epoch: u64,
/// The last view seen per session: its execution state, for the
/// Running→Idle edge, and its materialized transcript, for the seed.
pub(super) sessions: BTreeMap<String, SessionWatch>,
/// Sessions already told that no reviewer is configured. One notice per
/// session, not one per turn.
pub(super) missing_reviewer_reported: BTreeSet<String>,
/// Sessions whose durable handoff survived a restart and still needs the
/// primary relay's idempotent acknowledgement reconciled.
pub(super) recovery_candidates: BTreeSet<String>,
pub(super) recovery_in_flight: BTreeSet<String>,
}
pub(super) struct SessionWatch {
pub(super) execution: MaterializedExecutionState,
/// Whether the view had a prompt of ours in flight.
pub(super) prompt_driven: bool,
pub(super) materialized: Option<Box<MaterializedSession>>,
}
pub(super) async fn host_loop(
mut state: HostState,
mut events: mpsc::UnboundedReceiver<HostEvent>,
) {
while let Some(event) = events.recv().await {
if state.handle(event).await {
break;
}
}
}
pub(super) async fn persistence_loop(
environment: Arc<dyn ReviewEnvironment>,
events: mpsc::UnboundedSender<HostEvent>,
mut requests: mpsc::UnboundedReceiver<PersistenceRequest>,
) {
while let Some(request) = requests.recv().await {
match request {
PersistenceRequest::SweepInterrupted => {
let environment = environment.clone();
match tokio::task::spawn_blocking(move || environment.clear_interrupted()).await {
Ok(Ok(interrupted)) if !interrupted.is_empty() => {
let _ = events.send(HostEvent::Interrupted { interrupted });
}
Ok(Ok(_)) => {}
Ok(Err(error)) => {
tracing::warn!(%error, "could not clear interrupted reviews");
}
Err(error) => {
tracing::warn!(%error, "the interrupted-review sweep did not run");
}
}
}
PersistenceRequest::Save {
session_id,
state,
completion,
} => {
let environment = environment.clone();
let owner = session_id.clone();
let result =
tokio::task::spawn_blocking(move || environment.save_state(&owner, &state))
.await
.map_err(|error| format!("review state persistence task stopped: {error}"))
.and_then(|result| result);
if let Some(completion) = completion {
let _ = events.send(HostEvent::StateSaved {
session_id,
completion,
result,
});
} else if let Err(error) = result {
tracing::warn!(
session_id = %session_id,
%error,
"could not record how far this session has been reviewed"
);
}
}
PersistenceRequest::ClearActive { reply } => {
let environment = environment.clone();
let result = tokio::task::spawn_blocking(move || {
environment.clear_interrupted().map(|_| ())
})
.await
.map_err(|error| format!("review shutdown persistence task stopped: {error}"))
.and_then(|result| result);
let _ = reply.send(result);
}
}
}
}