Skip to main content

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}