forge_workspace/domain_session.rs
1//! Workspace-side per-session state.
2//!
3//! Holds what workspace itself needs: routing metadata (`AgentHandle`
4//! slot, claude-issued `session_id`), the pending-interaction mailbox,
5//! and the facts every view reads through the view surface. The rest of
6//! the operational state a view renders (lifecycle, cwd, account info)
7//! lives on the view's own session record.
8//!
9//! Two classes are held here rather than folded per view. The turn
10//! signal [`DomainSession::runtime_state`] is the one the workspace needs
11//! authoritatively for its worker-liveness and prompt-interception
12//! guards, and [`DomainSession::background_work`],
13//! [`DomainSession::available_commands`] and
14//! [`DomainSession::available_agents`] are held so a view arriving late
15//! reads them from the core.
16
17use std::collections::{HashMap, HashSet};
18use std::sync::Arc;
19
20use forge_agent::AgentHandle;
21use forge_primitives::{
22 AvailableAgent, AvailableCommand, AvailableModel, CurrentModel, EffortLevel, McpServerStatus,
23 MonitorRecord, PermissionMode, RuntimeSessionState, SessionId,
24};
25
26use crate::SessionSlot;
27use crate::protocol::PendingInteractionSlot;
28
29/// The MCP servers a session's bridge last reported, and the failure
30/// standing beside them when the read did not complete.
31#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
32pub struct McpServers {
33 pub servers: Vec<McpServerStatus>,
34 /// Why the read failed, when it did. A failed read carries an empty
35 /// server list, so a reader that ignored this would report a session
36 /// with no servers connected.
37 pub error: Option<String>,
38}
39
40/// How full the session's context window is, from the bridge's last
41/// answer. Both halves are `Option` because the upstream probe reports
42/// them independently.
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44pub struct ContextUsage {
45 pub percent: Option<u8>,
46 pub max_tokens: Option<u64>,
47}
48
49/// A continuation the failure left behind: the words the model will
50/// receive, taken by the fire as soon as the failed turn is folded.
51#[derive(Debug, Clone)]
52pub struct PendingAutoContinue {
53 pub reason: String,
54}
55
56/// The handoff this session is running, if any.
57///
58/// The request is parked rather than acted on while a turn runs: a handoff
59/// never interrupts one. The judgment turn is forge's own prompt, so the
60/// reply it captures is the last assistant text before that turn ends - and
61/// only that turn's end finishes the handoff.
62#[derive(Debug, Clone, Default, PartialEq, Eq)]
63pub struct HandoffState {
64 /// A request that arrived while a turn was running.
65 pub parked: bool,
66 /// The note the running turn's exit call carried, which becomes the
67 /// brief's judgment half. `None` falls back to the turn's last text.
68 pub supplied: Option<String>,
69 /// Why this handoff runs, which the row and the record both state.
70 pub reason: Option<forge_primitives::HandoffReason>,
71 /// A fill reading was asked for at the last turn boundary, and the
72 /// trigger is waiting to judge it.
73 pub probing: bool,
74 /// The reading that fired this handoff, carried to the row.
75 pub fill: Option<crate::handoff::FillReading>,
76 /// The token of the swap gate this handoff opened, so a build that lost
77 /// the seat cannot spawn.
78 pub swap_token: Option<u64>,
79 /// The judgment turn forge sent is running.
80 pub awaiting: bool,
81 /// The id the judgment prompt was sent under, so the swap waits for
82 /// THAT prompt's turn rather than for whatever turn ends next.
83 pub judgment_uuid: Option<String>,
84 /// The CLI's own lifecycle frame said the judgment prompt started. A
85 /// turn that ends without it is not the judgment turn.
86 pub judgment_started: bool,
87 /// The last assistant text seen while that turn ran.
88 pub judgment: Option<String>,
89}
90
91impl HandoffState {
92 /// Whether a handoff owns this seat's next turn boundary.
93 pub fn in_progress(&self) -> bool {
94 self.parked || self.awaiting
95 }
96}
97
98/// Workspace's owned per-session state. One `DomainSession` per
99/// active `SessionTask`. Single writer (the `SessionTask`); accessed
100/// via `Arc<parking_lot::Mutex<DomainSession>>` so the `Workspace`
101/// can route commands without locking the whole pool.
102/// The lifecycle states that settle a queued row.
103///
104/// Written out rather than derived, and it has to agree with the client's own
105/// set (`client/src/session/apply.ts`): the core drops what the client drops.
106/// A state neither names leaves the row standing on both sides, which is the
107/// fail-safe direction - a pile that keeps a delivered prompt is visibly odd,
108/// and one that empties on a word nobody reads is silently wrong.
109const SETTLED_STATES: [&str; 5] = ["started", "completed", "cancelled", "discarded", "refused"];
110
111/// The interactions a seat is parked on, oldest first.
112///
113/// **Ordered because the read answers it, and the dock draws the front.** Two
114/// AskUserQuestion calls in one assistant message run in parallel with
115/// different tool ids, so a seat can hold several at once, and the rule is
116/// first-parked-first-drawn. One Vec rather than a map and an
117/// order beside it: a seat holds a handful at most, and there is then no
118/// second structure to keep in step.
119#[derive(Default)]
120pub struct PendingInteractions {
121 held: Vec<(String, PendingInteractionSlot)>,
122}
123
124impl PendingInteractions {
125 /// Park `slot` under `tool_id`. A repeat of an id replaces in place, so a
126 /// frame folded twice does not park the same interaction twice.
127 pub fn insert(&mut self, tool_id: String, slot: PendingInteractionSlot) {
128 if let Some(waiting) = self.held.iter_mut().find(|(id, _)| *id == tool_id) {
129 waiting.1 = slot;
130 return;
131 }
132 self.held.push((tool_id, slot));
133 }
134
135 pub fn get(&self, tool_id: &str) -> Option<&PendingInteractionSlot> {
136 self.held.iter().find(|(id, _)| id == tool_id).map(|(_, slot)| slot)
137 }
138
139 pub fn remove(&mut self, tool_id: &str) -> Option<PendingInteractionSlot> {
140 let at = self.held.iter().position(|(id, _)| id == tool_id)?;
141 Some(self.held.remove(at).1)
142 }
143
144 pub fn contains_key(&self, tool_id: &str) -> bool {
145 self.held.iter().any(|(id, _)| id == tool_id)
146 }
147
148 pub fn len(&self) -> usize {
149 self.held.len()
150 }
151
152 pub fn is_empty(&self) -> bool {
153 self.held.is_empty()
154 }
155
156 /// Oldest first.
157 pub fn values(&self) -> impl Iterator<Item = &PendingInteractionSlot> {
158 self.held.iter().map(|(_, slot)| slot)
159 }
160
161 /// Oldest first, together with the ids that key them.
162 pub fn drain(&mut self) -> impl Iterator<Item = (String, PendingInteractionSlot)> {
163 std::mem::take(&mut self.held).into_iter()
164 }
165}
166
167/// What one frame says about whether a turn is open, when it says anything.
168///
169/// Derived from the message in [`liveness_of`], and folded in by
170/// [`DomainSession::note_liveness`].
171#[derive(Clone, Copy, PartialEq, Eq, Debug)]
172pub enum TurnLiveness {
173 /// The model produced output, so a turn is running.
174 Working,
175 /// The turn's own `Result`, or the transport's `Error`: it is over.
176 Ended,
177}
178
179/// What one message says about a turn's liveness, or `None` where it says
180/// nothing.
181pub fn liveness_of(msg: &forge_primitives::Message) -> Option<TurnLiveness> {
182 match msg {
183 forge_primitives::Message::Assistant { .. }
184 | forge_primitives::Message::ThinkingTokens { .. } => Some(TurnLiveness::Working),
185 forge_primitives::Message::Result { .. } | forge_primitives::Message::Error { .. } => {
186 Some(TurnLiveness::Ended)
187 }
188 _ => None,
189 }
190}
191
192pub struct DomainSession {
193 pub key: SessionSlot,
194 /// Claude-issued session UUID. `None` until the first `Connected`
195 /// event from this session's bridge. Workspace consults this when
196 /// dispatching `AgentHandle` calls that route by session id.
197 pub session_id: Option<SessionId>,
198 /// Agent connection handle bound to this session at spawn time.
199 /// `None` for pre-spawn / pre-Connect domains (the cron and gotify
200 /// delivery paths register a placeholder handle-less domain before
201 /// the spawn, which fills the handle in later).
202 pub conn: Option<Arc<AgentHandle>>,
203 /// Pending permission/question/elicitation oneshots indexed by the
204 /// wire `tool_id` / `elicitation_id`, oldest first. `SessionTask` pops
205 /// on `Respond*` commands; bridge inserts on every `*Request` event.
206 pub pending_interactions: PendingInteractions,
207 /// `--new` boot-wave flag, stamped at spawn time from
208 /// `SessionLaunchSettings.force_new`. For a project lead it makes
209 /// the Connected-time respawn skip the store lookup
210 /// (`resume_existing = None` for every worker), so they come
211 /// up fresh alongside their fresh lead. `false` for every non-boot
212 /// spawn.
213 pub spawned_force_new: bool,
214 /// A boot spawn's lead restart note, waiting for the Connected hook
215 /// to deliver it as the lead's first turn. `None` for every other
216 /// spawn, and cleared by the hook so a reconnect repeats nothing.
217 pub pending_lead_resume_note: Option<String>,
218 /// Whether the spawn that opened this session wrote the worker's
219 /// durable row rather than adopting one that was already there.
220 /// Stamped at spawn time from the spawn's role, and read by the
221 /// tag-write rollback to decide whether the row goes with the spawn
222 /// it is discarding. `false` for every lead and every non-worker.
223 pub spawn_wrote_row: bool,
224 /// Latest runtime liveness mirrored from the session's
225 /// `session_state_changed` wire messages. Operational turn state
226 /// otherwise lives on the view's own session record; this one signal is
227 /// duplicated here so the workspace's worker-liveness and
228 /// prompt-interception guards can tell an in-flight turn from an
229 /// idle one (a view's own gate alone can race a just-delivered
230 /// peer / cron / gotify / slack prompt). `None` until the first
231 /// state message.
232 pub runtime_state: Option<RuntimeSessionState>,
233 /// Turn committed at `Command::Prompt` routing, ahead of the
234 /// wire-lagged `runtime_state`; the guards OR it in.
235 ///
236 /// **Derived from the commit count, not a bare flag.** A Result ends ONE
237 /// turn, and a prompt committed before that Result landed belongs to the
238 /// next one - so clearing a flag at the Result would erase the next
239 /// turn's stamp and make a running seat read idle. `commit_turn` and
240 /// `serve_turn` are the only writers.
241 pub turn_pending: bool,
242 /// Prompts committed as turns on this occupant.
243 pub turn_commits: u64,
244 /// Results (or transport errors) that closed one of them.
245 pub turns_served: u64,
246 /// Frame-derived liveness: the model's own output proves a turn is
247 /// running, and a `Result`/`Error` ends it.
248 ///
249 /// The two mirrors above miss a turn's opening. `turn_pending` covers
250 /// only a routed `Command::Prompt`, and one `Result` clears it; and
251 /// `runtime_state` no longer arrives ("may never arrive", above). So a
252 /// turn whose opening is thinking and prose reads NOT in flight on the
253 /// wire until its first tool call, and a client drawing from the header
254 /// shows no running row for that stretch - measured at 41 seconds on a
255 /// resumed worker (2026-10-09), where the terminal showed it running and
256 /// the client did not.
257 pub turn_open: bool,
258 /// Whether the reader asked to stop the in-flight turn. Stamped
259 /// where `Command::Cancel` routes and spent when that turn's
260 /// `Result` lands: a cancelled turn ends with the same failed
261 /// `Result` a genuine error does, so `failed_turn_at` below cannot
262 /// tell the two apart without it.
263 pub pending_cancel: bool,
264 /// When the slot's newest turn ended in failure, for the rail's
265 /// mark. Cleared when a new turn is committed, dropped with the
266 /// occupant, and never set for a turn the reader cancelled.
267 pub failed_turn_at: Option<std::time::SystemTime>,
268 /// The continuation the seat's newest failed turn left: what the model
269 /// will be told, taken by the fire as soon as that failure is folded.
270 /// Cleared by the fire, a new turn, a success, or the occupant changing.
271 pub auto_continue: Option<PendingAutoContinue>,
272 /// The continuation forge has sent for that failure, until the prompt
273 /// runs: its id, and the occupant the failure belonged to. A `/new` or
274 /// `/resume` the queue drains first leaves a different occupant under
275 /// it, and the words describe a history the replacement does not carry.
276 /// Not cleared by `Connected` - the replacement is the case it exists
277 /// for.
278 pub auto_continue_queued: Option<(String, Option<forge_primitives::SessionId>)>,
279 /// Whether a continuation has already gone out for the seat's current
280 /// failure episode. Held until a turn completes or a view shows the
281 /// seat, so a failure that persists is continued once rather than in a
282 /// prompt loop.
283 pub auto_continue_spent: bool,
284 /// Whether the CLI last reported live background work here - the
285 /// `background_tasks_changed` snapshot, which carries the whole set
286 /// each change, so an empty one clears. Held on the session rather
287 /// than folded per view: two views always agree about it, and a
288 /// connection failure clears it because the CLI sends no terminal
289 /// snapshot for a session that died.
290 pub background_work: bool,
291 /// The CLI's background-task registry as it last reported it, each entry
292 /// carrying the command its own card named where both have been seen.
293 ///
294 /// The bool above says that something is running; this is what a view
295 /// draws a row from, and it is held for the same reason - the set arrives
296 /// whole on each `background_tasks_changed`, and a view that attached
297 /// afterwards would otherwise see the flag and not one row.
298 pub background_tasks: Vec<crate::BackgroundTask>,
299 /// The CLI's long-running calls it announced outside the registry, by
300 /// task id: a foreground call that outlives a beat reaches `task_started`
301 /// and its own `task_notification`, and never `background_tasks_changed`.
302 ///
303 /// Held apart from the registry because that snapshot replaces itself
304 /// whole and would wipe them; merged where a view reads
305 /// ([`Self::task_rows`]), so the row says what the seat is running
306 /// whether the model backgrounded the call or not. Each keeps the CLI's
307 /// own `is_backgrounded`, so a consumer can tell a foreground hold from a
308 /// registry row; the turn's end settles the foreground ones even when no
309 /// ending frame came, and a backgrounded hold lasts only until the next
310 /// snapshot - that set is the background world, complete on every change.
311 pub(crate) live_tasks: Vec<crate::BackgroundTask>,
312 /// Prompts in the CLI's queue for this occupant: what a view draws as
313 /// waiting, and what the pile read answers with.
314 ///
315 /// Appended where the prompt is sent - the only place that knows its
316 /// source and words - and advanced by the CLI's own lifecycle frames,
317 /// which are the only writer of a prompt's state. Belongs to the
318 /// occupant, so it is dropped with the background registry.
319 pub prompt_queue: Vec<crate::protocol::QueuedPrompt>,
320 /// Whether this occupant's CLI advertises `msg_lifecycle_v1`, from its
321 /// init frame. `None` until one has been read.
322 ///
323 /// The pile is drawn FOR it: without the frames nothing would ever settle
324 /// a row, so a session that does not advertise them records no rows at all
325 /// and falls back to drawing the prompt the way it was drawn before the
326 /// pile existed. Unknown reads as present - the first prompt can precede
327 /// the first init - and the pinned CLI always advertises it.
328 pub lifecycle_frames: Option<bool>,
329 /// The command a tool call's card carried, by tool-use id.
330 ///
331 /// Held rather than resolved on the spot because the card arrives BEFORE
332 /// the `task_started` that links it to a task, so the command has to be
333 /// kept until a link names it. Cleared with the registry: it belongs to
334 /// the occupant that made the calls.
335 background_commands: HashMap<String, String>,
336 /// The ids whose cards arrived since the last registry change.
337 ///
338 /// What keeps the map above bounded: a command is kept while a live task
339 /// links to it, or for one registry change after its card arrived - which
340 /// is exactly the window in which the `task_started` naming it can land.
341 staged_commands: HashSet<String>,
342 /// The tool call that began a task, by task id, from `task_started`.
343 task_tool_use: HashMap<String, String>,
344 /// Whether this session is waiting on `/login` before it can run.
345 ///
346 /// Set from the signals the CLI actually sends: an assistant message
347 /// whose error is `AuthenticationFailed`, or a retry frame naming the
348 /// same class. A `TurnError` envelope whose text reads like an auth
349 /// failure sets it too, as a fallback - that envelope's producers are
350 /// forge's own failed write and cancel rather than CLI output. Cleared
351 /// by a `Connected` and by a turn that finished, since either proves
352 /// the credential works. A fact about the session, so the lifecycle
353 /// derivation reads it rather than each view inventing the state from
354 /// the event it saw.
355 pub awaiting_login: bool,
356 /// The `/dictate` overlay's per-session normalizer-axis overrides.
357 /// Set by `Command::SetDictateOverride` / `::ResetDictateOverrides`
358 /// and consumed when a capture finishes into
359 /// `NormalizeOptions`. Session-scoped and volatile: dies with the
360 /// session, never persisted.
361 pub dictate_overrides: crate::dictate::DictateOverrides,
362 /// The slash commands the CLI last advertised for this session, from
363 /// the `system/init` frame's `slash_commands` and from
364 /// `commands_changed`. Held here so a view that arrived after the
365 /// turn started reads the list through the view surface, rather than
366 /// waiting a whole turn for the next frame.
367 pub available_commands: Vec<AvailableCommand>,
368 /// The subagents the CLI last advertised, from the same init frame's
369 /// `agents`, on the same terms as [`Self::available_commands`].
370 pub available_agents: Vec<AvailableAgent>,
371 /// Whether this turn's `system/init` has already been read for the
372 /// agent catalogue. The first init of a turn carries it and a
373 /// re-fire inside the same turn repeats it, so the read arms once per
374 /// turn - the rule this fold applies.
375 pub agents_emitted_this_turn: bool,
376 /// Hook-observed permission mode: higher fidelity than the
377 /// `system/status` mode, because
378 /// the CLI can change mode without re-emitting status.
379 pub observed_permission_mode: Option<PermissionMode>,
380 /// Hook-observed effort level, on the same terms as
381 /// [`Self::observed_permission_mode`].
382 pub observed_effort: Option<EffortLevel>,
383 /// The effort level forge stamped into this session's launch
384 /// settings, read back so a session whose hook has not fired yet
385 /// still answers with the level it is running at rather than none.
386 /// `Max` when the launch asked for nothing, which is forge's default.
387 pub configured_effort: EffortLevel,
388 /// The permission mode forge stamped into the same launch settings,
389 /// on the same terms: the answer until a hook reports one. `None`
390 /// when the launch pinned none.
391 pub configured_permission_mode: Option<PermissionMode>,
392 /// The MCP servers this session last saw, from its own bridge: MCP
393 /// is configured per session, so this is not an account-wide fact.
394 pub mcp_servers: Option<McpServers>,
395 /// How full the context window was at the last poll.
396 pub context_usage: Option<ContextUsage>,
397 /// The handoff this occupant is running, if any. Belongs to the
398 /// occupant: a swap that completes resets it, and a request that was
399 /// parked for a seat whose occupant changed describes a session that
400 /// is gone.
401 pub handoff: HandoffState,
402 /// The model the session resolved to, from its connect and from
403 /// every later init frame that names a different one.
404 pub current_model: Option<CurrentModel>,
405 /// The model catalogue the connect resolved against, kept so a frame
406 /// naming another model can be resolved the same way the connect's own
407 /// name was rather than falling back to the raw id.
408 pub available_models: Vec<AvailableModel>,
409 /// The monitors this session has running, and the ones that settled
410 /// while it did, folded from the wire: the `Monitor` tool call, the
411 /// task id the CLI assigns it, and the transition that settles it.
412 /// The set drains once every entry in it is terminal, so a session
413 /// that ran a monitor and stopped drawing one holds none.
414 pub monitors: Vec<MonitorRecord>,
415 /// The last OS-level walk of the session's process tree. The scan
416 /// runs on a view's tick rather than here, so this is the most
417 /// recent one whoever asked last produced.
418 pub process_snapshot: Option<forge_agent::env::processes::ProcessSnapshot>,
419 /// The last scan of the session's working tree, and when it was taken.
420 /// Written by the seat's watch loop while a view is showing it, so a
421 /// read answers a value rather than paying for a `git` subprocess.
422 pub work_snapshot: Option<crate::work::WorkSnapshot>,
423 /// The session's sub-agent instances, folded a frame at a time as they
424 /// arrive. Seeded from the history a connect carries, then driven by
425 /// each frame.
426 pub card_tracker: crate::subagent_cards::CardTracker,
427 /// The instance list the last announcement carried, which a push is
428 /// gated on: an announcement is a frame about a change, so the frame
429 /// that moved nothing announces nothing.
430 pub cards_snapshot: Vec<forge_primitives::runtime::SubagentCard>,
431 /// The seat's walked file index, and when it was taken. Written by the
432 /// seat's own loop while a view is showing it, so the composer's `@`
433 /// list reads a value rather than paying for a walk of the tree.
434 pub file_index: Option<crate::work::HeldFileIndex>,
435}
436
437impl DomainSession {
438 /// Whether a permission prompt is still waiting on `tool_id`.
439 ///
440 /// The session task asks the same question as it pops the slot, and this
441 /// asks it where a refusal can still reach a caller: by the time the task
442 /// sees the answer, the click has already happened.
443 pub fn awaits_permission(&self, tool_id: &str) -> bool {
444 matches!(
445 self.pending_interactions.get(tool_id),
446 Some(PendingInteractionSlot::Permission { .. }),
447 )
448 }
449
450 /// The same for a question. A prompt answered already, or one that asked
451 /// something else, is a dock that is gone.
452 pub fn awaits_question(&self, tool_id: &str) -> bool {
453 matches!(
454 self.pending_interactions.get(tool_id),
455 Some(PendingInteractionSlot::Question { .. }),
456 )
457 }
458
459 /// Replace the registry with the CLI's last snapshot, which carries the
460 /// whole set, and fill in whatever commands are already resolvable.
461 ///
462 /// **A backgrounded hold is redundant by definition**, because this
463 /// snapshot is the whole background set; keeping one past a later snapshot
464 /// that drops its task is exactly how a `TaskStop`'s row - which sends no
465 /// ending and no registry change - resurfaces. So every backgrounded hold
466 /// is dropped here. A foreground hold is dropped only when the snapshot
467 /// names its task or call: then the registry owns the call.
468 ///
469 /// Also the place the held commands are pruned after a snapshot: a task
470 /// that has left both sets takes its link with it, and the staging window
471 /// for a card whose `task_started` has not landed closes.
472 pub(crate) fn replace_background_tasks(&mut self, tasks: Vec<crate::BackgroundTask>) {
473 self.background_tasks = tasks;
474 self.link_background_calls();
475 let registered_ids: HashSet<&str> =
476 self.background_tasks.iter().map(|task| task.task_id.as_str()).collect();
477 let registered_calls: HashSet<&str> =
478 self.background_tasks.iter().filter_map(|task| task.tool_use_id.as_deref()).collect();
479 self.live_tasks.retain(|task| {
480 !task.is_backgrounded
481 && !registered_ids.contains(task.task_id.as_str())
482 && task.tool_use_id.as_deref().is_none_or(|call| !registered_calls.contains(call))
483 });
484 // Prune with the staging intact, then close the window: a card that
485 // arrived since the last change keeps its command for this one, which
486 // is exactly where its `task_started` can land.
487 self.prune_task_maps();
488 self.staged_commands.clear();
489 }
490
491 /// Record a task the CLI announced on `task_started`, which is the only
492 /// signal a long foreground call leaves: the registry names backgrounded
493 /// calls alone, and a foreground one is announced here and ended by its
494 /// own `task_notification` or the turn it ran in.
495 ///
496 /// Idempotent by task id AND by call: the CLI can announce one call under
497 /// two task ids, and a second row for it would double the seat's count. A
498 /// backgrounded task its registry already names is not held at all: the
499 /// snapshot is the whole background set, and the mirror would be the row
500 /// that survives the snapshot dropping it.
501 pub(crate) fn note_live_task(&mut self, task: crate::BackgroundTask) {
502 let names_it = |held: &crate::BackgroundTask| {
503 held.task_id == task.task_id
504 || (task.tool_use_id.is_some() && held.tool_use_id == task.tool_use_id)
505 };
506 let known = self.live_tasks.iter().any(names_it)
507 || (task.is_backgrounded && self.background_tasks.iter().any(names_it));
508 if known {
509 return;
510 }
511 self.live_tasks.push(task);
512 self.link_background_calls();
513 }
514
515 /// Withdraw a task its own ending frame settled.
516 ///
517 /// Matched by the task id **or the call** the frame names: one call can be
518 /// announced under two task ids, and the ending may name either.
519 pub(crate) fn settle_live_task(&mut self, task_id: &str, tool_use_id: Option<&str>) {
520 let settled = |task: &crate::BackgroundTask| {
521 task.task_id == task_id
522 || tool_use_id.is_some_and(|call| task.tool_use_id.as_deref() == Some(call))
523 };
524 let calls: Vec<String> = self
525 .live_tasks
526 .iter()
527 .filter(|task| settled(task))
528 .filter_map(|task| task.tool_use_id.clone())
529 .collect();
530 self.live_tasks.retain(|task| !settled(task));
531 // The settled call's command is read by nothing now. Other cards stay
532 // staged: their `task_started` may still be on its way.
533 for call in calls {
534 self.staged_commands.remove(&call);
535 }
536 self.prune_task_maps();
537 }
538
539 /// Withdraw what the end of a turn settles: its foreground holds.
540 ///
541 /// **A foreground call cannot outlive the turn that ran it.** Its own
542 /// `task_notification` normally ends it, but the `TaskStop` tool path
543 /// sends no ending frame, and a cancelled turn can bury the one it would
544 /// have sent, so the turn's terminal frame settles the rest. A background
545 /// hold stays: a backgrounded call outlives its turn, and the CLI's
546 /// registry is its own source.
547 ///
548 /// The turn boundary also closes the staging window: every card the turn
549 /// drew has been linked by its `task_started` or never named a task.
550 pub(crate) fn settle_foreground_holds(&mut self) {
551 self.live_tasks.retain(|task| task.is_backgrounded);
552 self.staged_commands.clear();
553 self.prune_task_maps();
554 }
555
556 /// The seat's live tasks as a view reads them: the CLI's background
557 /// registry first, then the announced foreground calls still running.
558 ///
559 /// Deduped by task id and by call, so a hold the registry names never
560 /// draws beside it before the next snapshot prunes it.
561 pub fn task_rows(&self) -> Vec<crate::BackgroundTask> {
562 let registered_ids: HashSet<&str> =
563 self.background_tasks.iter().map(|task| task.task_id.as_str()).collect();
564 let registered_calls: HashSet<&str> =
565 self.background_tasks.iter().filter_map(|task| task.tool_use_id.as_deref()).collect();
566 let mut rows = self.background_tasks.clone();
567 rows.extend(
568 self.live_tasks
569 .iter()
570 .filter(|task| {
571 !registered_ids.contains(task.task_id.as_str())
572 && task
573 .tool_use_id
574 .as_deref()
575 .is_none_or(|call| !registered_calls.contains(call))
576 })
577 .cloned(),
578 );
579 rows
580 }
581
582 /// Drop the link of every task that is no longer live, and the command of
583 /// every call no live task names.
584 ///
585 /// A card whose command arrived since the last prune stays while it is
586 /// staged, because its `task_started` may still be on its way.
587 fn prune_task_maps(&mut self) {
588 let running: HashSet<&str> = self
589 .background_tasks
590 .iter()
591 .chain(self.live_tasks.iter())
592 .map(|task| task.task_id.as_str())
593 .collect();
594 self.task_tool_use.retain(|task_id, _| running.contains(task_id.as_str()));
595 let linked: HashSet<&str> = self.task_tool_use.values().map(String::as_str).collect();
596 self.background_commands
597 .retain(|id, _| linked.contains(id.as_str()) || self.staged_commands.contains(id));
598 }
599
600 /// Seed the announced holds a fixture cannot drive through a session
601 /// actor: the view surface's read test has no wire to produce them.
602 #[cfg(any(test, feature = "testing"))]
603 pub fn seed_live_tasks(&mut self, tasks: Vec<crate::BackgroundTask>) {
604 self.live_tasks = tasks;
605 self.link_background_calls();
606 }
607
608 /// Record the command a tool call's card carried.
609 pub(crate) fn hold_background_command(&mut self, tool_use_id: String, command: String) {
610 self.staged_commands.insert(tool_use_id.clone());
611 self.background_commands.insert(tool_use_id, command);
612 self.link_background_calls();
613 }
614
615 /// Record which tool call began a task, which is the link that names the
616 /// command, and fill in that task's command if the card is already held.
617 pub(crate) fn hold_task_tool_use(&mut self, task_id: String, tool_use_id: String) {
618 self.task_tool_use.insert(task_id, tool_use_id);
619 self.link_background_calls();
620 }
621
622 /// Let go of the registry and everything that resolves a command in it: a
623 /// new occupant's calls are its own, and the CLI re-sends the set only
624 /// when it changes.
625 ///
626 /// The queued prompts are handed back rather than dropped: they died with
627 /// the process that held them, so the caller owns the row each one still
628 /// deserves.
629 pub(crate) fn drop_background_tasks(&mut self) -> Vec<crate::protocol::QueuedPrompt> {
630 self.background_work = false;
631 self.background_tasks.clear();
632 self.live_tasks.clear();
633 self.background_commands.clear();
634 self.staged_commands.clear();
635 self.task_tool_use.clear();
636 // The queue died with the CLI process the prompts were written to, and
637 // so did the promise its init frame made: the flag is re-read from the
638 // new occupant's own init, which the CLI re-fires every turn. Left
639 // latched, a `/resume` onto an advertising CLI would keep the pile
640 // silently off, or latch true beside rows nothing can settle.
641 self.lifecycle_frames = None;
642 std::mem::take(&mut self.prompt_queue)
643 }
644
645 /// Record a prompt as waiting, at the dispatch site.
646 pub(crate) fn record_queued_prompt(
647 &mut self,
648 uuid: &str,
649 source: crate::protocol::PromptSource,
650 text: &str,
651 attachments: Vec<forge_primitives::ImageAttachment>,
652 ) {
653 self.prompt_queue.push(crate::protocol::QueuedPrompt {
654 uuid: uuid.to_owned(),
655 source,
656 text: text.to_owned(),
657 attachments,
658 });
659 }
660
661 /// The entry a lifecycle frame names, while it is still waiting.
662 ///
663 /// **Read before the frame advances the queue**, because `started` is a
664 /// settled state: the entry leaves the pile as the row it draws arrives.
665 pub(crate) fn queued_prompt(&self, uuid: &str) -> Option<&crate::protocol::QueuedPrompt> {
666 self.prompt_queue.iter().find(|prompt| prompt.uuid == uuid)
667 }
668
669 /// Take every entry still waiting, leaving the pile empty.
670 ///
671 /// **The capability arrives after the first prompt.** `msg_lifecycle_v1`
672 /// is read from the CLI's init frame, and its first user message lands
673 /// before that - so a prompt recorded while the flag was `None` can never
674 /// be settled by a lifecycle frame. The task takes the entries and draws
675 /// them itself, the only carrier they will get.
676 pub(crate) fn take_queued_prompts(&mut self) -> Vec<crate::protocol::QueuedPrompt> {
677 std::mem::take(&mut self.prompt_queue)
678 }
679
680 /// Advance a prompt's state from one of the CLI's lifecycle frames.
681 ///
682 /// Returns whether the id was known. A state the CLI has SETTLED drops the
683 /// row, so the pile holds only prompts still waiting; a state this build
684 /// cannot name leaves it standing, because a word the CLI adds later must
685 /// not empty a pile nobody can see being wrong. An unknown id is not an
686 /// error - a prompt dispatched before this build, or a frame for a prompt
687 /// another cohort minted, simply is not in the pile.
688 pub(crate) fn advance_queued_prompt(&mut self, uuid: &str, state: &str) -> bool {
689 let Some(at) = self.prompt_queue.iter().position(|p| p.uuid == uuid) else {
690 return false;
691 };
692 if SETTLED_STATES.contains(&state) {
693 self.prompt_queue.remove(at);
694 }
695 true
696 }
697
698 /// Drop an entry outright - a cancel that the CLI confirmed.
699 pub(crate) fn drop_queued_prompt(&mut self, uuid: &str) {
700 self.prompt_queue.retain(|p| p.uuid != uuid);
701 }
702
703 /// Fill in the call that began each entry, and the command its card
704 /// carried, wherever both halves are known. Called after either half
705 /// lands, so the order the CLI sends them in does not matter.
706 fn link_background_calls(&mut self) {
707 for task in self.background_tasks.iter_mut().chain(self.live_tasks.iter_mut()) {
708 if task.tool_use_id.is_none() {
709 task.tool_use_id = self.task_tool_use.get(&task.task_id).cloned();
710 }
711 if task.command.is_some() {
712 continue;
713 }
714 task.command = self
715 .task_tool_use
716 .get(&task.task_id)
717 .and_then(|tool_use| self.background_commands.get(tool_use))
718 .cloned();
719 }
720 }
721
722 /// Construct a fresh `DomainSession` bound to `key` with the
723 /// given `conn`. Pre-spawn / pre-Connect callers pass `None` to
724 /// register a placeholder domain whose handle slot fills in once
725 /// the spawn handler runs.
726 pub fn new(key: SessionSlot, conn: Option<Arc<AgentHandle>>) -> Self {
727 Self {
728 key,
729 session_id: None,
730 conn,
731 pending_interactions: PendingInteractions::default(),
732 prompt_queue: Vec::new(),
733 lifecycle_frames: None,
734 spawned_force_new: false,
735 pending_lead_resume_note: None,
736 spawn_wrote_row: false,
737 runtime_state: None,
738 turn_pending: false,
739 turn_commits: 0,
740 turns_served: 0,
741 turn_open: false,
742 pending_cancel: false,
743 failed_turn_at: None,
744 auto_continue: None,
745 auto_continue_queued: None,
746 auto_continue_spent: false,
747 background_work: false,
748 background_tasks: Vec::new(),
749 live_tasks: Vec::new(),
750 background_commands: HashMap::new(),
751 staged_commands: HashSet::new(),
752 task_tool_use: HashMap::new(),
753 awaiting_login: false,
754 dictate_overrides: crate::dictate::DictateOverrides::default(),
755 available_commands: Vec::new(),
756 available_agents: Vec::new(),
757 agents_emitted_this_turn: false,
758 observed_permission_mode: None,
759 observed_effort: None,
760 configured_effort: EffortLevel::Max,
761 configured_permission_mode: None,
762 mcp_servers: None,
763 context_usage: None,
764 handoff: HandoffState::default(),
765 current_model: None,
766 available_models: Vec::new(),
767 monitors: Vec::new(),
768 process_snapshot: None,
769 work_snapshot: None,
770 card_tracker: crate::subagent_cards::CardTracker::default(),
771 cards_snapshot: Vec::new(),
772 file_index: None,
773 }
774 }
775
776 /// Whether a turn is in flight. `turn_pending` is the primary
777 /// signal: it is stamped synchronously when a `Command::Prompt` is
778 /// routed, so it covers the window before any wire echo lands.
779 /// `runtime_state` is OR'd in rather than trusted on its own -
780 /// `session_state_changed` appears in no wire-conformance baseline
781 /// and in none of the captured session JSONL, so it may never
782 /// arrive.
783 ///
784 /// Shared by the prompt-interception guards and the worker
785 /// activity derivation so the two cannot drift apart.
786 pub fn turn_in_flight(&self) -> bool {
787 self.turn_pending
788 || self.turn_open
789 || matches!(
790 self.runtime_state,
791 Some(RuntimeSessionState::Running | RuntimeSessionState::RequiresAction)
792 )
793 }
794
795 /// A prompt was committed as a turn.
796 ///
797 /// **A prompt queued into a turn already running joins that turn.** The
798 /// CLI runs it inside the same turn and emits one Result for both, so
799 /// counting it as another turn would leave the outstanding count
800 /// permanently ahead and close the handoff's idle gate for the life of
801 /// the seat. A prompt sent while the task still reads a turn in flight
802 /// but the CLI has started a new one is held busy by `prompt_queue`
803 /// until its own Result serves the boundary.
804 pub fn commit_turn(&mut self) -> bool {
805 if self.turn_in_flight() {
806 return false;
807 }
808 self.turn_commits = self.turn_commits.saturating_add(1);
809 self.turn_pending = true;
810 true
811 }
812
813 /// Undo a commit whose prompt never reached its task: the send failed, so
814 /// no Result will ever serve it. Without this the seat reads a turn in
815 /// flight for the life of the session, and the header's spinner with it.
816 pub fn uncommit_turn(&mut self) {
817 self.turn_commits = self.turn_commits.saturating_sub(1);
818 self.turn_pending = self.turn_commits > self.turns_served;
819 }
820
821 /// One Result (or transport error) closed the oldest outstanding turn.
822 ///
823 /// **One, not all of them.** A prompt committed before this Result
824 /// landed belongs to the next turn, so the flag is derived from what is
825 /// still outstanding rather than cleared outright.
826 pub fn serve_turn(&mut self) {
827 self.turns_served = self.turns_served.saturating_add(1);
828 self.turn_pending = self.turn_commits > self.turns_served;
829 }
830
831 /// Whether a committed turn is still waiting for its Result.
832 pub fn turns_outstanding(&self) -> bool {
833 self.turn_commits > self.turns_served
834 }
835
836 /// Drop the turn accounting with the occupant it described.
837 pub fn reset_turns(&mut self) {
838 self.turn_commits = 0;
839 self.turns_served = 0;
840 self.turn_pending = false;
841 }
842
843 /// Whether a handoff may start here: nothing running, nothing waiting in
844 /// the CLI's queue, and nothing parked on an answer.
845 ///
846 /// **All three, not just the turn.** A prompt already queued is a turn
847 /// the handoff's own prompt would be folded into, and a parked
848 /// interaction is a turn that has not ended; the document names both as
849 /// the "otherwise idle" condition.
850 pub fn is_idle_for_handoff(&self) -> bool {
851 !self.turn_in_flight()
852 && self.prompt_queue.is_empty()
853 && self.pending_interactions.is_empty()
854 }
855
856 /// Fold one frame's own claim about the turn into the liveness mirror.
857 ///
858 /// The model's output is the proof a turn is running and its Result is the
859 /// proof it ended - which is what a view derives from the same
860 /// frames. Nothing else is read: a `system` report, a task fact or a hook
861 /// line says nothing about whether a turn is open, so it leaves the mirror
862 /// where it stands.
863 pub fn note_liveness(&mut self, said: TurnLiveness) {
864 self.turn_open = said == TurnLiveness::Working;
865 }
866}
867
868/// The effort level a spawn asked for, read back from the launch
869/// settings forge stamps for the CLI.
870///
871/// The CLI reads `effortLevel` out of its own settings file, so a spawn
872/// that pins nothing runs at whatever that file says; forge's default
873/// for an unset one is `Max`, the same default the config reader
874/// applies.
875pub(crate) fn configured_effort_from_settings(
876 settings: &crate::SessionLaunchSettings,
877) -> EffortLevel {
878 settings
879 .settings
880 .as_ref()
881 .and_then(|document| document.get(EFFORT_LEVEL_KEY))
882 .and_then(serde_json::Value::as_str)
883 .and_then(EffortLevel::from_stored)
884 .unwrap_or(EffortLevel::Max)
885}
886
887/// The mode a spawn asked for, read back from the same launch settings
888/// the effort comes from.
889///
890/// `None` when the launch pinned nothing and when it pinned a mode forge
891/// cannot name: unlike effort there is no default to fall back on here,
892/// because the CLI's own default is not a forge setting.
893pub(crate) fn configured_permission_mode_from_settings(
894 settings: &crate::SessionLaunchSettings,
895) -> Option<PermissionMode> {
896 settings
897 .settings
898 .as_ref()
899 .and_then(|document| document.get(crate::SessionLaunchSettings::PERMISSIONS_KEY))
900 .and_then(|permissions| {
901 permissions.get(crate::SessionLaunchSettings::PERMISSIONS_DEFAULT_MODE_KEY)
902 })
903 .and_then(serde_json::Value::as_str)
904 .and_then(PermissionMode::from_wire)
905}
906
907/// The CLI settings key a session's effort is stamped under. One const,
908/// so the writer and this reader cannot split the seam.
909pub(crate) const EFFORT_LEVEL_KEY: &str = "effortLevel";
910
911impl std::fmt::Debug for DomainSession {
912 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
913 f.debug_struct("DomainSession")
914 .field("key", &self.key)
915 .field("session_id", &self.session_id)
916 .field("pending_interactions_count", &self.pending_interactions.len())
917 .finish_non_exhaustive()
918 }
919}
920
921#[cfg(test)]
922mod tests {
923 use super::*;
924
925 /// The level the spawn asked the CLI to run at, so a session whose
926 /// hook has not fired yet still names an effort rather than none.
927 #[test]
928 fn a_launch_that_pins_an_effort_names_that_level() {
929 let settings = crate::SessionLaunchSettings {
930 settings: Some(serde_json::json!({ "effortLevel": "high" })),
931 ..Default::default()
932 };
933
934 assert_eq!(
935 configured_effort_from_settings(&settings),
936 EffortLevel::High,
937 "the level the launch stamped is the level the session runs at",
938 );
939 }
940
941 /// A launch that pins nothing leaves the CLI on its own settings
942 /// file, and forge's default for an unset level is `Max`.
943 #[test]
944 fn a_launch_that_pins_no_effort_reads_forges_default() {
945 let settings = crate::SessionLaunchSettings::default();
946
947 assert_eq!(
948 configured_effort_from_settings(&settings),
949 EffortLevel::Max,
950 "an unset effort is forge's default rather than nothing",
951 );
952 }
953
954 /// A level forge cannot name is a level it cannot report, and the
955 /// default is a better answer than a guess.
956 #[test]
957 fn an_unreadable_launch_effort_reads_forges_default() {
958 let settings = crate::SessionLaunchSettings {
959 settings: Some(serde_json::json!({ "effortLevel": "turbo" })),
960 ..Default::default()
961 };
962
963 assert_eq!(
964 configured_effort_from_settings(&settings),
965 EffortLevel::Max,
966 "a level forge cannot read falls back rather than failing",
967 );
968 }
969
970 /// The mode a spawn asked for, read back from the same launch
971 /// settings. Forge stamps its effective default here, so a session
972 /// whose hook has not fired yet still names the mode it was launched
973 /// in rather than nothing.
974 #[test]
975 fn a_launch_that_pins_a_mode_names_that_mode() {
976 let settings = crate::SessionLaunchSettings {
977 settings: Some(serde_json::json!({ "permissions": { "defaultMode": "plan" } })),
978 ..Default::default()
979 };
980
981 assert_eq!(
982 configured_permission_mode_from_settings(&settings),
983 Some(PermissionMode::Plan),
984 "the mode the launch stamped is the mode the session runs in",
985 );
986 }
987
988 /// A launch that pinned nothing says so rather than naming a mode
989 /// forge did not choose.
990 #[test]
991 fn a_launch_that_pins_no_mode_names_none() {
992 let settings = crate::SessionLaunchSettings::default();
993
994 assert_eq!(
995 configured_permission_mode_from_settings(&settings),
996 None,
997 "no pin is no answer, rather than a mode forged from nothing",
998 );
999 }
1000
1001 /// **A prompt the CLI queues inside a running turn shares that turn's
1002 /// one Result.** Counting it as another turn leaves the outstanding
1003 /// count permanently ahead, which closes the handoff's idle gate for the
1004 /// life of the seat.
1005 #[test]
1006 fn a_prompt_queued_into_a_running_turn_joins_its_single_result() {
1007 let mut domain = DomainSession::new(SessionSlot::lead("Org", "proj"), None);
1008 domain.commit_turn();
1009 domain.commit_turn();
1010
1011 assert_eq!(domain.turn_commits, 1, "the queued prompt joins the running turn");
1012 domain.serve_turn();
1013 assert!(!domain.turns_outstanding(), "the one Result serves both prompts");
1014 assert!(!domain.turn_pending, "and the seat reads idle at the boundary");
1015 }
1016
1017 /// A mode forge cannot name is a mode it cannot report.
1018 #[test]
1019 fn an_unreadable_launch_mode_names_none() {
1020 let settings = crate::SessionLaunchSettings {
1021 settings: Some(serde_json::json!({ "permissions": { "defaultMode": "yolo" } })),
1022 ..Default::default()
1023 };
1024
1025 assert_eq!(
1026 configured_permission_mode_from_settings(&settings),
1027 None,
1028 "a mode forge cannot read is not guessed at",
1029 );
1030 }
1031}