pub struct Workspace {Show 123 fields
config_dir: PathBuf,
pub(crate) config: RwLock<Arc<LoadedConfig>>,
settings: Sender<Option<Arc<SettingsDocument>>>,
settings_version: OnceLock<String>,
settings_write: Mutex<()>,
reload_generation: AtomicU64,
reload_gate: Mutex<()>,
pub(crate) systemone: RwLock<Option<Arc<SystemOneClient>>>,
pub(crate) vault: RwLock<Option<Arc<VaultSession>>>,
pub(crate) voice: OnceLock<Arc<VoiceService>>,
pub(crate) voice_preview_error: Mutex<Option<String>>,
catalog: Arc<Mutex<HashMap<ProjectKey, Vec<SDKSessionInfo>>>>,
pub(crate) app_support: Option<PathBuf>,
pub(crate) pool: Mutex<HashMap<SessionSlot, PooledAgent>>,
pub(crate) test_spawn_handle: Mutex<Option<AgentHandle>>,
pub(crate) test_spawn_server: Mutex<Option<McpServer>>,
pub(crate) accounts: Arc<AccountPool>,
pub(crate) dictate: Arc<DictateState>,
pub(crate) dictate_runtime: Mutex<DictateRuntime>,
pub(crate) browser_host: OnceLock<Arc<dyn BrowserHost>>,
live_seats_lock: Mutex<()>,
live_seats_generation: AtomicU64,
live_seats_gate: Mutex<Option<(Sender<()>, Receiver<()>)>>,
pub(crate) attached_clients: AtomicUsize,
pub(crate) document_handoffs: Mutex<HashMap<Uuid, ParkedDocument>>,
pub(crate) dictate_catalogue: Mutex<CatalogueState>,
pub(crate) dictate_install: Mutex<InstallState>,
pub(crate) plugin_cli: Mutex<PluginCli>,
pub(crate) plugin_run: Mutex<Option<PluginUpdateRun>>,
pub(crate) plugin_action_running: AtomicBool,
pub(crate) dictate_activate: Mutex<ActivateState>,
pub(crate) dictate_bench: Mutex<BenchState>,
pub(crate) dictate_bench_cancel: AtomicBool,
pub(crate) speaking_bench: Mutex<SpeakingBenchState>,
pub(crate) speaking_bench_cancel: AtomicBool,
pub(crate) bench_claim: AtomicBool,
pub(crate) read_aloud_error: Mutex<Option<String>>,
pub(crate) test_catalogue_source: Mutex<Option<CatalogueSource>>,
pub(crate) test_cleanup_source: Mutex<Option<CleanupSource>>,
pub(crate) test_catalogue_dir: Mutex<Option<PathBuf>>,
pub(crate) test_read_aloud_dir: Mutex<Option<PathBuf>>,
pub(crate) update_tx: UpdateFanout,
pub(crate) command_senders: Mutex<HashMap<SessionSlot, UnboundedSender<Command>>>,
session_teardowns: Mutex<HashMap<SessionSlot, Sender<bool>>>,
shutting_down: AtomicBool,
live_workers: Mutex<HashMap<ProjectKey, Vec<WorkerEntry>>>,
pub(crate) idle_sweeps: Mutex<HashMap<SessionSlot, SweepClock>>,
worktree_claims: Arc<Mutex<HashMap<PathBuf, WorktreeClaim>>>,
despawn_cleanups: Mutex<HashSet<(ProjectKey, String)>>,
spawn_failures: Mutex<HashMap<SessionSlot, String>>,
deaths: Mutex<HashMap<SessionSlot, SeatDeath>>,
heal_progress: Mutex<HashMap<SessionSlot, HealProgress>>,
healing: OnceLock<Arc<Healing>>,
pub(crate) held_work_seats: HeldSeats,
pub(crate) domain_handles: Mutex<HashMap<SessionSlot, Arc<Mutex<DomainSession>>>>,
pub(crate) review_origin: Mutex<HashMap<(String, String), SessionSlot>>,
pub(crate) review_activity: Mutex<HashMap<SessionSlot, Vec<ReviewActivity>>>,
pub(crate) supervisor: Arc<Supervisor>,
dictate_preflight_started: AtomicBool,
pub(crate) voice_prewarm_started: AtomicBool,
pub(crate) voice_prewarm_dispatched: AtomicBool,
pub(crate) gateway: Arc<Gateway>,
gateway_ready: AtomicBool,
gateway_port: u16,
gateway_url: Mutex<Option<String>>,
gateway_bind_error: Mutex<Option<String>>,
kick_dispatcher_tx: UnboundedSender<KickRequest>,
kick_dispatcher_rx_slot: Mutex<Option<UnboundedReceiver<KickRequest>>>,
_single_instance_lock: Option<File>,
pub(crate) crons: Mutex<Vec<CronEntry>>,
pub(crate) crons_load_failed: AtomicBool,
pub(crate) tasks: Mutex<Vec<Task>>,
pub(crate) parked_by_slot: Mutex<HashMap<SessionSlot, ParkedForSlot>>,
pub(crate) pending_handoffs: Mutex<HashMap<SessionSlot, PendingBrief>>,
pub(crate) swapping: Mutex<HashMap<SessionSlot, u64>>,
pub(crate) swap_epochs: AtomicU64,
pub(crate) held_prompts: Mutex<HashMap<SessionSlot, Vec<HeldPrompt>>>,
pub(crate) gotify_subs: Mutex<Vec<GotifySubscription>>,
pub(crate) db: Arc<Mutex<Option<Db>>>,
catalog_loaded: Arc<AtomicBool>,
pricing_fetcher: PricingFetcher,
catalog_scan_started: AtomicBool,
cli_version: Arc<Mutex<Option<CliVersionInfo>>>,
last_fatal_error: Mutex<Option<AppError>>,
service_status: Arc<Mutex<Option<ServiceIssue>>>,
service_status_probe_started: AtomicBool,
pub(crate) gotify_connected: Mutex<bool>,
pub(crate) gotify_app_index: Mutex<HashMap<String, u64>>,
pub(crate) gotify_subsystem: Mutex<Option<GotifySubsystem>>,
pub(crate) slack: Arc<SlackWorkspaces>,
pub(crate) slack_subs: Mutex<Vec<SlackSubscription>>,
pub(crate) slack_subsystem: Mutex<BTreeMap<String, Sender<()>>>,
pub(crate) slack_connected: Mutex<BTreeMap<String, bool>>,
pub(crate) slack_inflight: Mutex<HashMap<(String, String), Vec<SlackPostStamp>>>,
pub(crate) slack_user_ids: Mutex<BTreeMap<String, String>>,
pub(crate) slack_user_names: Mutex<BTreeMap<(String, String), String>>,
pub(crate) slack_author_failures: Mutex<HashSet<(String, String)>>,
pub(crate) slack_draft_user_failures: Mutex<HashSet<(String, String)>>,
pub(crate) slack_thread_failures: Mutex<HashSet<(String, String, String)>>,
pub(crate) slack_thread_full_reads: Mutex<HashMap<(String, String, String), Instant>>,
pub(crate) slack_thread_first_walks: Mutex<HashSet<(String, String, String)>>,
pub(crate) slack_thread_edit_watermarks: Mutex<HashMap<(String, String, String), String>>,
pub(crate) slack_ledger_failures: Mutex<HashSet<(String, String)>>,
pub(crate) slack_drafts: Mutex<HashMap<Uuid, (SessionSlot, SlackDraft, Sender<SlackDraftAnswer>)>>,
pub(crate) browser_handoffs: Mutex<HashMap<Uuid, (SessionSlot, HandOff, Sender<HandOffEnding>)>>,
pub(crate) terminals: Mutex<HashMap<SessionSlot, Arc<TerminalEntry>>>,
pub(crate) terminal_handoffs: Mutex<HashMap<Uuid, (SessionSlot, TerminalHandOff, Sender<HandOffEnding>)>>,
pub(crate) next_terminal_id: AtomicU16,
pub(crate) settings_writes: Mutex<HashMap<Uuid, (SessionSlot, SettingsWriteAsk, Option<Sender<bool>>)>>,
pub(crate) parked_vault_asks: Mutex<HashMap<Uuid, (SessionSlot, VaultDraft, Option<Sender<VaultAskAnswer>>)>>,
pending_seq: Mutex<HashMap<SessionSlot, u64>>,
pub(crate) slack_recently_delivered: Mutex<HashMap<(String, Option<String>, String, String, Option<String>), Instant>>,
pub(crate) slack_load_failed: AtomicBool,
pub(crate) slack_user_id_retries: Mutex<BTreeMap<String, SystemTime>>,
pub(crate) slack_verification_started: AtomicBool,
respawn_in_flight: Mutex<HashSet<ProjectKey>>,
unwakeable_crons: Mutex<HashSet<CronId>>,
cooling_crons: Mutex<HashMap<CronId, SystemTime>>,
command_intercept: Mutex<Option<Vec<Command>>>,
test_extra_projects: Mutex<Vec<LoadedProject>>,
test_user_preferences: Mutex<Option<Value>>,
test_claude_pid: Mutex<HashMap<SessionSlot, u32>>,
pub(crate) test_scan_gate: Mutex<Option<Arc<Semaphore>>>,
}Expand description
spawned forge_agent::Agent handles, one per active session.
Construct via Workspace::new; consume via
Workspace::get_agent_handle; drain on exit via
Workspace::shutdown.
Fields§
§config_dir: PathBufRetained so error messages can reference the forge.toml
path the workspace was constructed from, and so the per-account
config-dir binding can re-resolve from here.
config: RwLock<Arc<LoadedConfig>>pub(crate) so the impl block in crate::gotify can read the
[gotify] section. Swapped whole by Workspace::reload_config,
which is why readers take an owned snapshot rather than a reference.
settings: Sender<Option<Arc<SettingsDocument>>>The effective-config document, rebuilt on every applied reload and
shared by every socket connection. None until the server names the
build that carries it.
settings_version: OnceLock<String>The forge version the document carries, set once by the server at boot. A workspace with no server (tests) leaves it empty.
settings_write: Mutex<()>One settings save at a time: the file is read, edited, validated and replaced under this lock, so two saves cannot interleave.
reload_generation: AtomicU64Odd while a reload is swapping state, even when settled. A spawn reads it before resolving and re-checks after, so a resolution that straddled the swap retries rather than pairing an old project pin with the new pool.
reload_gate: Mutex<()>Held across a reload’s odd-to-even swap, so a spawn that sees an odd generation waits on this rather than spinning on the counter.
systemone: RwLock<Option<Arc<SystemOneClient>>>The [systemone] decision client, built at boot when the section
is present and enabled; the session MCP servers are handed a
facade over it. None keeps the systemone tools out of every
session. Rebuilt by Workspace::reload_config.
vault: RwLock<Option<Arc<VaultSession>>>The [vault] credential session, built at boot when the section is
present; the family’s tools are handed a facade over it. None keeps
the vault tools out of every session. Rebuilt by
Workspace::reload_config only when the section itself changed, so
an unrelated settings save never locks a live vault.
voice: OnceLock<Arc<VoiceService>>The per-seat voice service, built on first use: its sink and its note
dispatch hold a Weak<Workspace>, which does not exist while the
value is still being constructed.
voice_preview_error: Mutex<Option<String>>The last voice preview’s render failure, for the settings card to draw. Cleared when the next preview is asked for.
catalog: Arc<Mutex<HashMap<ProjectKey, Vec<SDKSessionInfo>>>>Catalog of sessions per project. Populated by the background
catalog scan once Workspace::start_catalog_scan runs;
mutated in-place by Workspace::record_connected_session
each time a freshly spawned session reaches Connected, so the
Projects pane’s drilldown stays current without forcing a full
disk re-scan. Held under a Mutex because multiple in-process
tasks (the pane render, the connect-flow event handler) reach
for it across await points; Arc so the scan task can swap
its contents without holding an Arc<Workspace> cycle.
app_support: Option<PathBuf>forge’s machine-local app-support base, resolved once at
construction: the single-instance lock, the durable store and the
dictation catalogue cache all sit under it. None means boot
resolution failed (already warned there); hard rule #14 forbids a
cwd fallback.
pool: Mutex<HashMap<SessionSlot, PooledAgent>>Live Agents keyed by the session slot. parking_lot::Mutex so the
public methods can take &self. pub(crate) so sibling
modules (spawn::handle_deliver_worker_prompt_to_lead,
mcp::workers::facade::ProdWorkerFacade) can probe pool
membership for lead-delivery gating without an extra method
wrapper.
test_spawn_handle: Mutex<Option<AgentHandle>>A stand-in for the handle a cold spawn would create. The settings a
spawn launches with are otherwise unobservable: a pooled slot never
reaches the launch, and a cold one ends in a real subprocess. None
in production and in every test that does not install one, so a
spawn runs always.
test_spawn_server: Mutex<Option<McpServer>>The forge MCP server the last spawn built, kept because a stand-in
replaces Agent::spawn, so the server it would have been handed is
otherwise dropped unobserved -
and a test that has to drive a tool through the spawn’s OWN
composition (which facade it was built with) needs it.
accounts: Arc<AccountPool>The account state map, owned by the gateway and reached through its pool. It carries account health state updated on every spawn and refreshed by the in-memory usage poller, and it is what the gateway’s selection walk reads to choose an account.
dictate: Arc<DictateState>Dictation preflight: the per-model progress the launchpad
renders, the flag Escape sets, and the loaded engine held for
the run. Populated by start_dictate_preflight; inert when
[dictate] enabled is false.
dictate_runtime: Mutex<DictateRuntime>Live dictation: the takes being fed frames and the submitted
takes still awaiting a transcript. Driven by
Command::DictateStream / Command::DictateStop.
browser_host: OnceLock<Arc<dyn BrowserHost>>The server’s browser fleet, installed once at boot by forge-server.
None is a workspace with no browser host (tests, a view): the
browser tools then name their refusal.
live_seats_lock: Mutex<()>Captures do not overlap: the generation ranks whole captures, so the mint and the reads it labels happen under this one lock.
live_seats_generation: AtomicU64The generation each Self::live_seats capture takes, so the fleet
can drop a snapshot older than the one it last applied.
live_seats_gate: Mutex<Option<(Sender<()>, Receiver<()>)>>A test’s hold between Self::live_seats’s mint and its reads, so a
capture can be opened across a seat change. Test-only.
attached_clients: AtomicUsizeHow many client connections are attached, noted by the transport per connection. A document hand-off reads it before claiming an open: only a client that draws a document viewer is counted.
document_handoffs: Mutex<HashMap<Uuid, ParkedDocument>>The parked document hand-offs, under their ids: each holds the awaiting tool’s sender until the reader’s answer releases it.
dictate_catalogue: Mutex<CatalogueState>The model catalogue: the last fetched feed and what the last check
did. Loaded from its cache at boot, refreshed by
start_dictate_catalogue and Command::DictateCatalogueCheck, and
read by the models page through dictate_models.
dictate_install: Mutex<InstallState>Where the last model install got to. Set by
Command::DictateInstall, read by the models page through
dictate_install.
plugin_cli: Mutex<PluginCli>How a plugin action reaches claude plus the disk scan the actions
and the Extensions read share. Real in production; a test swaps it
so a whole action runs without shelling out or touching ~/.claude.
plugin_run: Mutex<Option<PluginUpdateRun>>The last plugin update run or check, in flight or finished: what a view attaching mid-run reads, and the guard that keeps one run at a time (a second press is refused while this is unfinished).
plugin_action_running: AtomicBoolOne plugin CLI action at a time; cleared when its task ends, panic included, so a crashed task never wedges the surface.
dictate_activate: Mutex<ActivateState>Where the last model activation got to. Set by
Command::DictateActivate, read by the models page through
dictate_activate.
dictate_bench: Mutex<BenchState>Where the last bench got to, and the flag its Stop sets. Read by
the models page through dictate_bench and bench_results.
dictate_bench_cancel: AtomicBool§speaking_bench: Mutex<SpeakingBenchState>Where the speaking loop’s bench got to, and the flag its Stop sets.
Read by the models page through speaking_bench and its results.
speaking_bench_cancel: AtomicBool§bench_claim: AtomicBoolOne bench at a time across both directions: a single claim taken before either state is written, so two presses cannot both pass the check and load two engines.
read_aloud_error: Mutex<Option<String>>The last read-aloud write’s failure, when there was one: a stop whose write fails has no dispatch left to answer, so the page reads it here instead. Cleared when the next recording starts.
test_catalogue_source: Mutex<Option<CatalogueSource>>A test’s own catalogue source, so a check can run against a loopback server instead of GitHub. Not present in production builds.
test_cleanup_source: Mutex<Option<CleanupSource>>A test’s own cleanup source, on the same terms.
test_catalogue_dir: Mutex<Option<PathBuf>>A test’s own catalogue directory, so a check never writes to the real app-support dir. Not present in production builds.
test_read_aloud_dir: Mutex<Option<PathBuf>>A test’s own read-aloud set directory, so an arming never writes to the real app-support dir. Not present in production builds.
update_tx: UpdateFanoutFan-out SessionUpdate sender: every producer inside the workspace
holds a clone, and it hands each update to every subscriber.
pub(crate) so the impl block in crate::crons can reach it.
command_senders: Mutex<HashMap<SessionSlot, UnboundedSender<Command>>>Per-session Command sender map. Populated when
Self::get_agent_handle spawns the first SessionTask for a
key; cleared on Self::release_session_with_cascade and Self::shutdown.
session_teardowns: Mutex<HashMap<SessionSlot, Sender<bool>>>Per-session teardown-completion signals, minted beside the command channel. The task marks its slot done as it drops - after its exit path has reaped the subprocess - and every waiter subscribes rather than taking the one receiver: the despawn holds its worktree removal until then (#1767), and a shutdown holds its exit until every slot answers. An entry stays after its task fires and is replaced when the slot respawns, so the map holds one small sender per slot this process has ever spawned - the price of a late reader still finding the answer.
shutting_down: AtomicBoolSet before a shutdown tears anything down. From then on no session may start - the boot wave, a cron tick and the socket all race the drain - and the mint refuses under the same lock the drain takes, which is what keeps a late spawn out of the awaited set.
live_workers: Mutex<HashMap<ProjectKey, Vec<WorkerEntry>>>Per-project list of live worker sessions. In-memory only -
wiped on forge restart by design (workers are ephemeral at the
forge UI level; their JSONLs persist on disk). Mutated via
insert_live_worker / remove_latest_worker / drain_live_workers.
idle_sweeps: Mutex<HashMap<SessionSlot, SweepClock>>The per-lead clock of the injected board sweep, keyed by the
lead’s slot. In memory only, so a restart re-arms every lead:
the sweep is a moment, not a schedule the store should own.
pub(crate) so the impl block in crate::idle_sweep can
reach it.
worktree_claims: Arc<Mutex<HashMap<PathBuf, WorktreeClaim>>>One claim per composed worker-worktree path, held while a spawn’s ensure/insert or a despawn’s removal is in flight. The live registry is per project; this is the repository-wide one, so two projects composing the same path cannot both act on it.
despawn_cleanups: Mutex<HashSet<(ProjectKey, String)>>The (project, label) pairs whose despawn cleanup is still running.
The label’s worktree is being torn down for that whole window, so a
spawn taking the label now would enter a directory the removal is
about to delete. Held from the cleanup’s handoff until it finishes,
and released by its own Drop so a panicking cleanup cannot leave a
label blocked forever. In-memory only, like the rest of the worker
registry.
spawn_failures: Mutex<HashMap<SessionSlot, String>>Why the last spawn or connection for a slot failed, keyed by slot.
Survives the failed attempt itself, which is what a DomainSession
cannot: a connection failure releases the session, so by the time a
view reads the slot there is nothing left to say the attempt ever
happened. Written before that release and cleared when a session
for the slot comes up again, so it reports an unrecovered failure
rather than a history.
deaths: Mutex<HashMap<SessionSlot, SeatDeath>>The occupant that died on a slot, keyed by slot, held until a
reader has been shown the seat again or a new turn commits there.
Self::spawn_failures says an attempt failed; this says the seat
HAD something running and it ended under its own process, which is
what lets the recovery be announced as a resume or a restart under
a new id rather than reading as a plain connect - and what keeps
the failure and its reason on the row until someone has looked.
heal_progress: Mutex<HashMap<SessionSlot, HealProgress>>Where each dead seat’s heal has got to while it is restarting, keyed by slot. The healing supervisor writes it between attempts; every row that draws the seat reads it, and a lead has no worker entry for the state to live on instead.
healing: OnceLock<Arc<Healing>>The auto-heal supervisor, built on the first death that reaches it.
It holds a Weak<Workspace>, which does not exist while the value
is still being constructed.
held_work_seats: HeldSeatsThe seats a view is showing, each with the working-tree scan loop it runs. Empty is the quiet case: a seat nobody holds is not scanned.
domain_handles: Mutex<HashMap<SessionSlot, Arc<Mutex<DomainSession>>>>Shared DomainSession handles, one per active SessionTask.
pub(crate) so crate-internal spawn and delivery paths can
reach a session’s DomainSession directly.
review_origin: Mutex<HashMap<(String, String), SessionSlot>>The session that submitted the reviews on a (project, branch) -
the target for a worker’s review-activity notice. Set by
Self::submit_review; latest submit wins (the reviewer is one
human whose session may rekey across a resume). pub(crate) so
the impl block in crate::review can reach it.
review_activity: Mutex<HashMap<SessionSlot, Vec<ReviewActivity>>>Review actions a caller took during its current turn, keyed by the
caller’s SessionSlot. Appended by Self::review_reply /
Self::review_resolve and drained into one notice per review at
the caller’s turn end (Self::drain_review_activity).
pub(crate) so the impl block in crate::review can reach it.
supervisor: Arc<Supervisor>The process’s subsystem supervisor: every long-lived background task registers here, so a death is named and a restart is bounded.
dictate_preflight_started: AtomicBoolSet the first time Self::start_dictate_preflight runs, so a
second call cannot spawn a second prewarm over the same models. Set
before the [dictate] enabled gate: the flag reads “the boot wave
asked for the prewarm”, which is the one call a test can watch
without a 3 GB fetch.
voice_prewarm_started: AtomicBoolRecords that the boot wave asked for the prewarm, set before the
[tts] enabled gate: the flag reads “the wave asked”, which is the
one call a test can watch without the weights. It is not a dispatch
guard; the guard only stops a second load of the same engine.
voice_prewarm_dispatched: AtomicBoolSet once the prewarm is dispatched, after the enabled gate, so a second call cannot spawn a second load of the same engine.
gateway: Arc<Gateway>The gateway’s listener + session bindings. Children are stamped with a base URL that names this listener, and every spawn registers here.
gateway_ready: AtomicBooltrue once the listener’s port is bound. The boot gate stays
shut until it is, because every session’s base URL names it.
gateway_port: u16The port the listener binds: [gateway] port or the default.
gateway_url: Mutex<Option<String>>The listener’s bound URL, stored when the port is taken. The stamp reads THIS, not the config value, so the base URL a child is stamped with is structurally the one that answers.
gateway_bind_error: Mutex<Option<String>>The bind failure, if the listener could not start. Surfaced by
the launchpad’s gate label; None while loading or bound.
kick_dispatcher_tx: UnboundedSender<KickRequest>Sender half of the worker-kick channel (#259). Cloned via
Self::enqueue_kick by maybe_kick_worker_on_connected
(and any future kick site). The matching receiver lives in
kick_dispatcher_rx_slot until Self::start_kick_dispatcher
takes it out and spawns the drainer task.
kick_dispatcher_rx_slot: Mutex<Option<UnboundedReceiver<KickRequest>>>Single-take slot holding the matching receiver.
Self::start_kick_dispatcher pops it on first call and
hands it to the drainer task; subsequent calls find None
and no-op (mirrors start_usage_poller’s guard against
duplicate spawns).
_single_instance_lock: Option<File>Single-instance lock file held open for the process lifetime.
Workspace::new takes an exclusive flock on a machine-local
lockfile keyed by the config dir (see crate::single_instance)
so a second forge on the same config dir refuses to start; the
flock releases when this File drops (Workspace teardown / process
exit / crash). Held purely for that side effect - never read.
None in testing_stub and on the degraded acquire path.
crons: Mutex<Vec<CronEntry>>Durable forge crons (mcp__forge__cron). In-memory working set,
loaded at boot from the machine-local redb store (crate::store::cron)
and persisted back after every mutation - create/delete, the
scheduler’s fire-advance, and boot catch-up - through the one
Workspace::with_crons_mut path. The single-instance guard keeps
this the only process holding that store, so this mutex alone
serialises writes.
pub(crate) so the impl block in crate::crons can reach it.
crons_load_failed: AtomicBoolSet at boot when the durable crons could not be loaded: the store
could not open, or its read failed. The in-memory set is empty while
rows may still be on disk, so it is not the registry. cron__list
refuses on it rather than answering an empty array a caller would read
as “you have no crons”.
It gates cron__list only: under the same failed load delete_cron
still answers NoSuchCron from the empty set, and create_cron still
promises durability while persist_cron returns quietly. Both predate
this cut and the charter’s list-then-delete flow is gated by the list
refusal; the siblings are #2943.
tasks: Mutex<Vec<Task>>The live task list (mcp__forge__tasks). In-memory working set,
loaded from the machine-local store at boot and persisted back
after every mutation through the one Workspace::with_tasks_mut
path. pub(crate) so the impl block in crate::tasks can reach
it.
parked_by_slot: Mutex<HashMap<SessionSlot, ParkedForSlot>>Payloads addressed to a slot that had no live session when they
arrived - a peer prompt, a fired cron, a Gotify notification, a
Slack message - keyed by (org, project, label) (None = lead),
the triple the sessions table uses. The slot’s session drains
its own bucket on first Connected. pub(crate) so the impl
block in crate::parked can reach it.
pending_handoffs: Mutex<HashMap<SessionSlot, PendingBrief>>The brief a handoff wrote, waiting for the successor it was written
for to connect. Taken by the session task on that connect, which is
what makes the brief the new occupant’s first turn. pub(crate) so
the impl block in crate::handoff can reach it.
swapping: Mutex<HashMap<SessionSlot, u64>>Seats whose occupant is being swapped by a handoff: from the moment the brief is built until the successor connects, a prompt aimed at one is held rather than run by the session leaving.
swap_epochs: AtomicU64Mints a swap’s token, so a build that lost the seat cannot spawn.
held_prompts: Mutex<HashMap<SessionSlot, Vec<HeldPrompt>>>Prompts held for a swapping seat, delivered to its successor after the brief, in arrival order.
gotify_subs: Mutex<Vec<GotifySubscription>>Active Gotify subscriptions (mcp__forge__gotify). The set the
stream matches each inbound message against. Durable ones (lead /
team-worker) are also persisted to db and reloaded here
at boot; ephemeral ad-hoc-worker ones live only in memory.
pub(crate) so the impl block in crate::gotify can reach it.
db: Arc<Mutex<Option<Db>>>Machine-local redb store. Backs durable crons (crate::store::cron),
Gotify and Slack subscriptions (crate::store::gotify,
crate::store::slack) and persisted sessions
(crate::store::sessions). None when the DB couldn’t
open (degrade to in-memory-only, no persistence) or in
testing_stub. pub(crate) so the impl block in
crate::review can reach it.
catalog_loaded: Arc<AtomicBool>Whether the boot catalog scan has populated catalog. The scan
runs in the background off the boot path; the Projects pane and
the session lookups read the catalog it fills.
pricing_fetcher: PricingFetcherHow Workspace::refresh_pricing fetches the litellm table. The
real fetch in production; a test constructor scripts it so the
refill is drivable without the network.
catalog_scan_started: AtomicBoolIdempotence guard for Workspace::start_catalog_scan.
cli_version: Arc<Mutex<Option<CliVersionInfo>>>The installed and npm-published claude CLI versions, merged from
every probe so far. One answer for every viewer, so it lives here
rather than in the view that happened to probe first; None until
the boot probe lands.
last_fatal_error: Mutex<Option<AppError>>The last fatal error, held so a view that was not subscribed when it fired can still learn of it: the update carries no state of its own and nothing else in the core records it.
service_status: Arc<Mutex<Option<ServiceIssue>>>The statuspage’s last answer, held so a view that attached after the
probe landed can read it rather than having missed the one update
that carried it. None until the probe answers.
service_status_probe_started: AtomicBoolIdempotence guard for the service-status probe.
gotify_connected: Mutex<bool>Whether the Gotify stream is currently connected. Set by the
subsystem pump on Connected / Disconnected, and cleared by the
stop path and the pump’s exit guard; read by the Inspector’s status
line.
pub(crate) so the impl block in crate::gotify can reach it.
gotify_app_index: Mutex<HashMap<String, u64>>Gotify application name -> numeric appid map, fetched from the
server’s /application list on subsystem start and refreshed on
each reconnect. Resolves an application name filter to the appid
inbound messages carry, and the reverse lookup for the envelope.
pub(crate) so the impl block in crate::gotify can reach it.
gotify_subsystem: Mutex<Option<GotifySubsystem>>Shutdown handle for the running Gotify subsystem. Some while the
pump runs; dropping/sending stops it, and the pump’s own exit guard
clears it when the task ends. None when idle (no subscriptions) or
unconfigured. Guards against double-starting.
pub(crate) so the impl block in crate::gotify can reach it.
slack: Arc<SlackWorkspaces>One Slack client per [[slack]] entry, resolved at boot. Empty
keeps the Slack connector dormant. pub(crate) so the impl
blocks in crate::slack and crate::mcp::slack can reach it.
slack_subs: Mutex<Vec<SlackSubscription>>Active Slack subscriptions (mcp__forge__slack). The set each
workspace’s pump sweeps. Durable ones are also persisted to db
and reloaded here at boot. pub(crate) so the impl block in
crate::slack can reach it.
slack_subsystem: Mutex<BTreeMap<String, Sender<()>>>Shutdown handles for the running Slack pumps, one per workspace
label. Slack has a pump per [[slack]] entry where Gotify has a
single server and a single handle.
slack_connected: Mutex<BTreeMap<String, bool>>Per-workspace pump liveness for the Inspector’s SLACK section. Keyed by label for the same reason.
slack_inflight: Mutex<HashMap<(String, String), Vec<SlackPostStamp>>>Conversations forge has a post in flight into, keyed
(workspace, the resolved conversation), each entry a stamp per
live call: one post call with its ledger write, or one upload’s
shares read. The sweep skips a conversation while any stamp is
within budget, because the message can reach history before its ts
is ledgered; a stamp past the request timeout is expired, never
obeyed, so a stalled call cannot silence the conversation.
slack_user_ids: Mutex<BTreeMap<String, String>>The authenticated user’s id per workspace, resolved once by the
boot auth.test and needed to recognise <@U...> mentions.
slack_user_names: Mutex<BTreeMap<(String, String), String>>Display names resolved per (workspace, user id), so a message from
a known author costs no users.info call.
(workspace, user id) pairs whose failed lookup has been reported, so
a persistent failure says so once rather than on every tick.
slack_draft_user_failures: Mutex<HashSet<(String, String)>>(workspace, user id) pairs whose failed DRAFT tag lookup has been
reported. Kept apart from the author set so a draft’s miss does not
spend the once-per-author line the sweep relies on.
slack_thread_failures: Mutex<HashSet<(String, String, String)>>(workspace, conversation, parent ts) whose failed thread read has
been reported, so a persistent failure says so once rather than on
every park.
slack_thread_full_reads: Mutex<HashMap<(String, String, String), Instant>>When each followed thread was last read whole, keyed
(workspace, conversation, parent ts). In memory: a restart costs one
extra whole read per thread, which is cheaper than a schema.
slack_thread_first_walks: Mutex<HashSet<(String, String, String)>>Followed threads whose next walk is their first since the row was created, keyed the same way.
slack_thread_edit_watermarks: Mutex<HashMap<(String, String, String), String>>Cache of each thread row’s persisted edit stamp, keyed the same way: an edit is measured against this, never against the reply cursor. A miss reads the row, because the stamp has to outlive the process.
slack_ledger_failures: Mutex<HashSet<(String, String)>>Conversations whose own-post ledger could not be read this run,
keyed (workspace, conversation). The read is retried on every
message; this only decides whether the failure is worth a line a
reader sees, once rather than per message.
slack_drafts: Mutex<HashMap<Uuid, (SessionSlot, SlackDraft, Sender<SlackDraftAnswer>)>>Composed Slack messages held for the user’s decision, keyed by draft id and carrying the session that asked. The owner is stored beside it so an answer is only ever applied by the session it was addressed to.
browser_handoffs: Mutex<HashMap<Uuid, (SessionSlot, HandOff, Sender<HandOffEnding>)>>Browser hand-offs parked for the person at a client, keyed by their id
and carrying the session that asked. The owner is stored beside each so
an answer is only ever applied by the session it was addressed to; the
blocked browser_hand_off handler awaits the sender, with no timeout,
for as long as the person takes.
terminals: Mutex<HashMap<SessionSlot, Arc<TerminalEntry>>>The per-seat terminals, created lazily on first use and living as long
as the seat: /new, /resume and a handoff leave the PTY, its shell
and its scrollback untouched; a release ends the seat and its
terminal.
terminal_handoffs: Mutex<HashMap<Uuid, (SessionSlot, TerminalHandOff, Sender<HandOffEnding>)>>Terminal hand-offs parked for the person at a client, keyed by their
id and carrying the session that asked. The blocked terminal__hand_off
handler awaits the sender, with no timeout, for as long as the person
takes.
next_terminal_id: AtomicU16The id the next terminal takes. One per seat today, but the wire carries an id so a second terminal per seat is additive rather than a protocol change.
settings_writes: Mutex<HashMap<Uuid, (SessionSlot, SettingsWriteAsk, Option<Sender<bool>>)>>Configuration writes held for the reader’s approval, keyed by the
write’s id and carrying the session that asked. Its own registry for
the same reason the draft’s is: a held write exists on a seat with no
domain, and nothing reaches forge.toml until the blocked handler’s
oneshot is answered.
parked_vault_asks: Mutex<HashMap<Uuid, (SessionSlot, VaultDraft, Option<Sender<VaultAskAnswer>>)>>Vault calls held for the reader’s approval, keyed by the call’s id and carrying the session that asked. Its own registry for the same reason the settings write’s is: a held call exists on a seat with no domain, and no operation runs in the vault until the blocked handler’s oneshot is answered.
pending_seq: Mutex<HashMap<SessionSlot, u64>>How many asks have ever arrived for a seat. The ask’s identity on the wire: one ask replacing another reads as a different ask, and answering one of several leaves the number where it was.
slack_recently_delivered: Mutex<HashMap<(String, Option<String>, String, String, Option<String>), Instant>>Slack messages handed to a session recently, keyed by
(project, owner, conversation, ts). A sweep re-runs a batch
whenever a 429 lands mid-sweep, a watermark write fails, or the
process dies between the two - this is what makes that re-run
idempotent rather than a re-delivery.
slack_load_failed: AtomicBoolSet at boot when the durable Slack subscriptions could not be read. The Inspector SLACK section reads it: without it the empty subscription set would hide the failure.
slack_user_id_retries: Mutex<BTreeMap<String, SystemTime>>The last time each workspace’s user-id retry ran, so a failing
auth.test is retried at most once a minute rather than per sweep.
slack_verification_started: AtomicBoolSet the first time Workspace::start_slack_verification runs.
Subsequent calls early-return to avoid spawning duplicate probes.
respawn_in_flight: Mutex<HashSet<ProjectKey>>Per-project in-flight guard for the lead Connected
hook’s catalog scan. Inserted synchronously when
respawn_workers_for_lead starts; removed when
the async scan completes and dispatches its SpawnWorker
commands. A concurrent second Connected (e.g. a fast /new
reconnect) checking this set sees the entry and skips its own
respawn, preventing duplicate worker sets while the scan
is in flight. The existing live_workers.is_empty() gate
covers the post-dispatch case.
unwakeable_crons: Mutex<HashSet<CronId>>The cron ids the LAST fire_due_crons pass found unwakeable, so
the first warning about one is a WARN and the repeats are not.
Not a cache: nothing but that warning’s level reads it, and it
holds only what the most recent pass saw, replaced wholesale at the
end of each pass. That is what stops an entry outliving its
condition - a cron that fires, advances or is deleted is simply
absent from the next pass’s set, so one that goes unwakeable again
is new again and warns at WARN again. A set that never cleared
would silence a cron that is firing.
cooling_crons: Mutex<HashMap<CronId, SystemTime>>The cron ids the last fire_due_crons pass found cooling, with the
time each last warned. Replaced wholesale each pass, like
unwakeable_crons, so an episode that clears drops its marker and a
later one warns again.
command_intercept: Mutex<Option<Vec<Command>>>Test-only intercept buffer for app-level Commands. When
Some, dispatch captures the command into the buffer
instead of routing it to the spawn::* handler - used by
respawn tests to assert what would have been
dispatched without spinning up real subprocesses. Always
None in production (no enable hook outside test cfg).
test_extra_projects: Mutex<Vec<LoadedProject>>Test-only project overlay. Entries appended via
seed_test_project are searched first in
find_project_view_by_name so tests can drive the
Connected-hook respawn trigger without writing a
real forge.toml. Empty in production.
test_user_preferences: Mutex<Option<Value>>Test-only stand-in for the CLI’s per-user preferences document, so
a view fixture reads an ignore preference without the machine’s
real $HOME/.claude.json. None means no fixture seeded one, and
the read goes to the file.
test_claude_pid: Mutex<HashMap<SessionSlot, u32>>Test-only stand-in for a seat’s live claude pid, so a fixture seat’s
held loop walks a real process tree with no CLI behind it. A seat with
no entry reads the pid off its own handle, as production does.
test_scan_gate: Mutex<Option<Arc<Semaphore>>>Test-only gate every stale read waits on before it scans, so a fixture
can hold a read in flight and see what a hold answers first. None
means reads run as they do in production.
Implementations§
Source§impl Workspace
impl Workspace
Sourcepub fn dictate_bench(&self) -> BenchState
pub fn dictate_bench(&self) -> BenchState
The bench’s state, for the page’s read.
Sourcefn read_aloud_dir(&self) -> Option<PathBuf>
fn read_aloud_dir(&self) -> Option<PathBuf>
The directory the read-aloud set lives under, beside forge’s other
machine-local stores; None when no app-support dir resolves. A
test points it at its own dir rather than the real one.
Sourcepub fn read_aloud_state(&self) -> ReadAloudState
pub fn read_aloud_state(&self) -> ReadAloudState
The read-aloud set, as the page draws it: the recordings this machine has, whether one is being recorded right now, the passage they are read from, and the last write’s failure when there was one.
Sourcepub(crate) fn delete_read_aloud(&self, id: &str) -> Result<(), DispatchError>
pub(crate) fn delete_read_aloud(&self, id: &str) -> Result<(), DispatchError>
Drop one recording from the read-aloud set, by the id the page read.
The id is a directory name this server wrote, and it is checked against that shape before anything is removed: a page-supplied path must never name a directory outside the set.
Sourcepub(crate) fn start_read_aloud(
&self,
initiator: Option<u64>,
) -> Result<(), DispatchError>
pub(crate) fn start_read_aloud( &self, initiator: Option<u64>, ) -> Result<(), DispatchError>
Begin recording the read-aloud set: the page’s own microphone feeds the frames, and no engine and no transcript are involved - the passage’s words are known, so recording transcribes nothing.
One capture at a time, like every other: refused while a take is live, and while a recording is already running - the refusal names whatever holds it.
Sourcepub fn read_aloud_push(&self, samples: &[f32], initiator: Option<u64>) -> bool
pub fn read_aloud_push(&self, samples: &[f32], initiator: Option<u64>) -> bool
Feed a client’s frames into the running recording, when that connection is the one that started it.
The audio is capped at the same span a take holds, so a recording nobody stops cannot grow without bound; the frames past the cap are dropped rather than silently stored as a shorter reading.
Sourcepub(crate) fn finish_read_aloud(
self: &Arc<Self>,
keep: bool,
initiator: Option<u64>,
) -> Result<(), DispatchError>
pub(crate) fn finish_read_aloud( self: &Arc<Self>, keep: bool, initiator: Option<u64>, ) -> Result<(), DispatchError>
Stop the recording: keep writes it as the set, and a stop that does
not keeps nothing. The write runs off the runtime thread, and its
failure lands in the read rather than in this answer - the dispatch
has already gone by the time the bytes hit the disk.
Sourcepub fn bench_results(&self) -> Vec<BenchResult>
pub fn bench_results(&self) -> Vec<BenchResult>
Every saved result, newest first, capped at what the page draws.
Sourcepub(crate) fn start_bench(
self: &Arc<Self>,
target: BenchTarget,
tier: Tier,
) -> Result<(), DispatchError>
pub(crate) fn start_bench( self: &Arc<Self>, target: BenchTarget, tier: Tier, ) -> Result<(), DispatchError>
Run one model over one tier’s corpus on this machine’s own material.
Refused while a take is live (the microphone is the machine’s, and a
bench would hold a second engine through it) and while another bench
runs - one at a time, like a download. The outcome rides
SessionUpdate::DictateModelsChanged,
the way every other dictate action’s does.
Sourcepub(crate) fn delete_bench_result(
&self,
target: &BenchTarget,
tier: Tier,
corpus: &str,
) -> Result<(), DispatchError>
pub(crate) fn delete_bench_result( &self, target: &BenchTarget, tier: Tier, corpus: &str, ) -> Result<(), DispatchError>
Drop one saved result: the row the page asked to delete, by its key.
Sourcepub(crate) fn stop_bench(&self) -> Result<(), DispatchError>
pub(crate) fn stop_bench(&self) -> Result<(), DispatchError>
Stop the run in flight. What it measured so far is discarded rather than saved: a partial corpus is not a result.
Sourcepub(crate) fn bench_running(&self) -> bool
pub(crate) fn bench_running(&self) -> bool
Whether a bench holds the machine, which is what a swap waits on. Both benches count: each loads its own engines, and a swap under either would pull a model out from under a run.
Sourcefn claim_bench(&self) -> Result<(), DispatchError>
fn claim_bench(&self) -> Result<(), DispatchError>
Take the one bench slot, or answer that another run holds it. The claim is a single atomic, so two presses cannot both pass the check and load two engines.
fn release_bench(&self)
Sourcepub fn speaking_bench(&self) -> SpeakingBenchState
pub fn speaking_bench(&self) -> SpeakingBenchState
The speaking bench’s state, for the page’s read.
Sourcepub fn speaking_bench_results(&self) -> Vec<SpeakingBenchResult>
pub fn speaking_bench_results(&self) -> Vec<SpeakingBenchResult>
Every saved speaking result, newest first, capped like the words bench’s list.
Sourcepub(crate) fn start_speaking_bench(
self: &Arc<Self>,
) -> Result<(), DispatchError>
pub(crate) fn start_speaking_bench( self: &Arc<Self>, ) -> Result<(), DispatchError>
Score the speaking loop on the public corpus: synthesize each transcript, hear it back through the settled recognizer, and record the round trip. Refused while a take is live or another bench runs - one at a time, like the words bench.
The run gets a thread of its own with a current-thread runtime: it awaits the engine’s chunks and blocks on recognition, so it must not hold a worker from the server’s runtime for minutes.
Sourcepub(crate) fn stop_speaking_bench(&self) -> Result<(), DispatchError>
pub(crate) fn stop_speaking_bench(&self) -> Result<(), DispatchError>
Stop the speaking run in flight. What it measured so far is discarded rather than saved: a partial corpus is not a result.
Sourceasync fn run_speaking_bench(self: Arc<Self>)
async fn run_speaking_bench(self: Arc<Self>)
Gather the public corpus, load both engines, and run the loop.
Sourcefn settle_speaking_bench(&self, outcome: Result<SpeakingBenchResult, String>)
fn settle_speaking_bench(&self, outcome: Result<SpeakingBenchResult, String>)
A speaking run that ended: a result is saved, a stop is idle, and any other ending is a failure with its own reason.
fn save_speaking_result(&self, result: &SpeakingBenchResult)
Sourceasync fn run_bench(self: Arc<Self>, target: BenchTarget, tier: Tier)
async fn run_bench(self: Arc<Self>, target: BenchTarget, tier: Tier)
Gather the corpus, build the target’s config, and run it.
Sourcefn settle_bench_success(&self, result: &BenchResult)
fn settle_bench_success(&self, result: &BenchResult)
A run that produced a result: save it, release the one bench slot, and go idle. The release is here rather than only on the failure paths, or one successful run would hold the slot for the process’s life.
Sourcefn settle_bench(&self, target: &BenchTarget, reason: String)
fn settle_bench(&self, target: &BenchTarget, reason: String)
A run that ended without a result: idle when it was stopped on request - a partial corpus is not a result, and nothing is saved - and failed with its own reason otherwise.
fn fail_bench(&self, target: BenchTarget, reason: String)
fn save_bench_result(&self, result: &BenchResult)
Source§impl Workspace
impl Workspace
Sourcepub fn biometric_enrolments(&self) -> Result<Vec<BiometricEnrolment>, String>
pub fn biometric_enrolments(&self) -> Result<Vec<BiometricEnrolment>, String>
Every device’s enrolment, read from the machine-local store.
A store that cannot be read is an error rather than an empty list: “no device is enrolled” and “the enrolments could not be read” send a reader looking in different places.
Sourcepub(crate) fn enrol_biometric(
&self,
id: Uuid,
device: &str,
purpose: &str,
blob: &str,
) -> Result<(), String>
pub(crate) fn enrol_biometric( &self, id: Uuid, device: &str, purpose: &str, blob: &str, ) -> Result<(), String>
Register one purpose’s wrapped blob under id, creating the device’s
enrolment when the id is new and replacing that purpose’s bytes when it
is not. The person’s press is this command’s only caller; a
rotation rewrite goes through Self::rewrite_biometric, which cannot
create.
Sourcepub(crate) fn rewrite_biometric(
&self,
id: Uuid,
purpose: &str,
blob: &str,
) -> Result<bool, String>
pub(crate) fn rewrite_biometric( &self, id: Uuid, purpose: &str, blob: &str, ) -> Result<bool, String>
Replace an existing purpose’s blob, refusing to create.
The person’s press is what creates a row; this is the rotation
rewrite, which may only update one. A device the person revoked has no
row, so its rewrite answers false and the client locks rather than
re-registering itself.
Sourcepub(crate) fn release_biometric(
&self,
id: Uuid,
purpose: &str,
) -> Result<Option<BiometricBlob>, String>
pub(crate) fn release_biometric( &self, id: Uuid, purpose: &str, ) -> Result<Option<BiometricBlob>, String>
The blob enrolled for purpose under id, stamped as used.
None is “this device has nothing enrolled for that purpose”, which
is the answer a client falls back to the password on. A store failure
is an error, because a client that cannot tell the two apart would
silently skip the password path.
Sourcepub(crate) fn revoke_biometric(
&self,
id: Uuid,
purpose: Option<&str>,
) -> Result<(), String>
pub(crate) fn revoke_biometric( &self, id: Uuid, purpose: Option<&str>, ) -> Result<(), String>
Drop one purpose’s blob, or None for the device’s whole enrolment.
Revoking the last purpose drops the enrolment with it: a device holding
nothing is a row with no meaning.
Sourcepub(crate) fn lock_vault(&self) -> bool
pub(crate) fn lock_vault(&self) -> bool
Lock the shared vault now, on the reader’s own press rather than the idle clock. The process holds one vault session, so any seat’s lock is every seat’s; parked cards are re-stamped so a dock that already drew one asks for the password again.
false when no vault is configured, which is the only state a view
would not have drawn the action in.
Sourcefn announce_biometric_enrolments(&self)
fn announce_biometric_enrolments(&self)
Tell every view the enrolment set moved, as the whole set the core holds. A write that cannot be read back announces nothing and says so; the next read still answers the truth.
Source§impl Workspace
impl Workspace
Sourcepub fn board_rows(
&self,
project: &str,
now: SystemTime,
stale_secs: u64,
) -> Vec<BoardRow>
pub fn board_rows( &self, project: &str, now: SystemTime, stale_secs: u64, ) -> Vec<BoardRow>
Every live row of project, with its derived facts.
Sourcepub fn fleet_rows(&self) -> Vec<FleetRow>
pub fn fleet_rows(&self) -> Vec<FleetRow>
One row per project, for the fleet page.
Sourcepub fn unaccounted(&self, project: &str) -> Vec<String>
pub fn unaccounted(&self, project: &str) -> Vec<String>
Live workers of project holding no open row.
Source§impl Workspace
impl Workspace
Sourcepub(crate) async fn register_browser_hand_off(
&self,
caller: &SessionSlot,
reason: &str,
target: &str,
) -> Result<(Uuid, Receiver<HandOffEnding>), String>
pub(crate) async fn register_browser_hand_off( &self, caller: &SessionSlot, reason: &str, target: &str, ) -> Result<(Uuid, Receiver<HandOffEnding>), String>
Hold a browser hand-off for the person at a client, and hand back its id plus the receiver the caller awaits.
No timeout rides the wait. The ask dock’s own timelines say a held prompt sits for hours, so nothing here ends it but an answer or the caller’s own drop. The one case answered up front is a stream nobody can answer on: holding a session on a prompt no view can draw would park it forever, which is the failure this path exists to refuse.
Sourceasync fn browser_hand_off_started(
&self,
caller: &SessionSlot,
target: &str,
) -> Result<u16, String>
async fn browser_hand_off_started( &self, caller: &SessionSlot, target: &str, ) -> Result<u16, String>
Take the fleet’s hold for a hand-off, awaited so its refusal is the tool call’s. Answers the tab number the fleet resolved the page id to, or its own sentence for a page that is not open or not this seat’s.
Sourcepub(crate) fn tell_browser_hand_off_finished(
&self,
caller: &SessionSlot,
tab: u16,
)
pub(crate) fn tell_browser_hand_off_finished( &self, caller: &SessionSlot, tab: u16, )
Tell the fleet a hand-off left its tab: the service acts on a tab that is still held, and a resolution for anything else touches nothing.
Sourcepub(crate) fn browser_handoff_waiting(
&self,
id: Uuid,
caller: &SessionSlot,
) -> bool
pub(crate) fn browser_handoff_waiting( &self, id: Uuid, caller: &SessionSlot, ) -> bool
Whether a hand-off with this id is still registered to caller,
without removing it - the read the dispatch guard makes before an
answer.
Sourcepub(crate) fn resolve_browser_hand_off(
&self,
id: Uuid,
caller: &SessionSlot,
answer: HandOffAnswer,
) -> bool
pub(crate) fn resolve_browser_hand_off( &self, id: Uuid, caller: &SessionSlot, answer: HandOffAnswer, ) -> bool
Remove a parked hand-off, answer its waiter, and tell every view it is
gone. false when the id is not this caller’s to resolve.
Source§impl Workspace
impl Workspace
Sourcepub fn dictate_models(&self) -> DictateModelsSnapshot
pub fn dictate_models(&self) -> DictateModelsSnapshot
Everything the models page draws.
Sourcepub fn start_dictate_catalogue(self: &Arc<Self>)
pub fn start_dictate_catalogue(self: &Arc<Self>)
Load the cached feed, then fetch a fresh one when it is due. One
task per forge run, started beside the dictation preflight; a
no-op unless [dictate] enabled is set.
The cache read is synchronous - one small file on the boot path -
and the fetch never is: it is what the state’s CatalogueCheck
reports on.
Sourcepub(crate) fn check_dictate_catalogue(
self: &Arc<Self>,
) -> Result<(), DispatchError>
pub(crate) fn check_dictate_catalogue( self: &Arc<Self>, ) -> Result<(), DispatchError>
The models page’s Check now: a fresh fetch, refused when there is nothing to check against or one is already running.
Sourcefn spawn_catalogue_refresh(self: &Arc<Self>) -> bool
fn spawn_catalogue_refresh(self: &Arc<Self>) -> bool
Fetch the feed off the runtime, land it, and push the re-read
view. Answers false when a check is already in flight, deciding
that under the same lock take that moves the state to
CatalogueCheck::Checking: two near-simultaneous dispatches
cannot both fetch.
Sourcepub(crate) async fn fetch_catalogue_once(&self) -> Result<Catalogue, Error>
pub(crate) async fn fetch_catalogue_once(&self) -> Result<Catalogue, Error>
Fetch the feed once and land it: the freshness line, the rows and the cache. Answers the catalogue, or the error the fetch failed with; the last-known feed stands either way.
Shared by the background check and the active-model resolution, which needs the feed’s entry for a config key’s variant.
Sourcepub(crate) fn catalogue_source(&self) -> CatalogueSource
pub(crate) fn catalogue_source(&self) -> CatalogueSource
Where the feed is fetched from. Test builds can point this at a loopback server; production always takes the real one.
Sourcepub(crate) fn cleanup_source(&self) -> CleanupSource
pub(crate) fn cleanup_source(&self) -> CleanupSource
Where the cleanup feed is fetched from, on the same terms - and a test serving the speech feed serves this one from the same loopback, so a check in a test reads one server rather than reaching the Hub.
Sourcefn catalogue_dir(&self) -> Option<PathBuf>
fn catalogue_dir(&self) -> Option<PathBuf>
Where the fetched feed is cached: the workspace’s own app-support base (re-resolving the machine dir here would let a test’s fetch write into the real tree), or a test’s own override.
Source§impl Workspace
impl Workspace
Sourcefn chases_for_project(&self, project: &str) -> Vec<TaskChase>
fn chases_for_project(&self, project: &str) -> Vec<TaskChase>
Every chase already fired for project.
Sourcefn history_for_project(&self, project: &str) -> Vec<TaskTransition>
fn history_for_project(&self, project: &str) -> Vec<TaskTransition>
project’s transitions, for the rung that reads when a wait
resolved.
Sourcefn record_chase(&self, chase: &TaskChase)
fn record_chase(&self, chase: &TaskChase)
Record one fired chase, durably, so the rung never repeats.
Sourcefn forget_drift(&self, project: &str, label: &str)
fn forget_drift(&self, project: &str, label: &str)
Forget one worker’s naming: its slate cleared, so the record goes with the state and the next drift is named at once.
Sourcepub fn chase_sweep(&self, deliver: &dyn Deliver, now: SystemTime) -> usize
pub fn chase_sweep(&self, deliver: &dyn Deliver, now: SystemTime) -> usize
One pass of the chase over every project: derive the crossings, walk the ladder for each, deliver, and record what fired. Returns how many messages reached a session.
Sourcepub fn start_chase_sweep(self: &Arc<Self>)
pub fn start_chase_sweep(self: &Arc<Self>)
Spawn the chase sweep: a background pass every
CHASE_INTERVAL_SECS that delivers the crossings. The registry
makes a second call a no-op; started once at boot. Mirrors
Workspace::start_cron_scheduler.
Source§impl Workspace
impl Workspace
Sourcepub(crate) fn with_crons_mut<R>(
&self,
f: impl FnOnce(&mut Vec<CronEntry>) -> R,
) -> R
pub(crate) fn with_crons_mut<R>( &self, f: impl FnOnce(&mut Vec<CronEntry>) -> R, ) -> R
Lock the durable cron list and apply f. Every cron-list mutation
routes through here to take the lock, and the mutation site persists
what it changed from inside its own closure - a row at a time, never
the whole in-memory set. A boot read that failed leaves that set
EMPTY while the db handle stays live, so a whole-set write would
erase every durable row the read never loaded.
Sourcefn persist_cron(&self, cron: &CronEntry)
fn persist_cron(&self, cron: &CronEntry)
Persist one cron row, replacing any prior record for its id. Called from inside the mutation’s lock.
Sourcefn delete_stored_cron(&self, id: &CronId)
fn delete_stored_cron(&self, id: &CronId)
Delete one cron row by id. Called from inside the mutation’s lock.
Sourcepub(crate) fn push_cron(&self, entry: CronEntry)
pub(crate) fn push_cron(&self, entry: CronEntry)
Append a cron and persist it. Backs cron__create.
Sourcepub(crate) fn remove_cron(&self, project_name: &str, id: &CronId) -> bool
pub(crate) fn remove_cron(&self, project_name: &str, id: &CronId) -> bool
Remove the cron id in project_name regardless of who owns it,
persist, and report whether an entry was removed. Backs the
fire-router’s dead-slot removal; the owner-scoped cron__delete
uses Self::remove_cron_owned_by.
Sourcepub(crate) fn remove_cron_owned_by(
&self,
project_name: &str,
id: &CronId,
label: Option<&str>,
) -> Option<CronEntry>
pub(crate) fn remove_cron_owned_by( &self, project_name: &str, id: &CronId, label: Option<&str>, ) -> Option<CronEntry>
Remove the cron id in project_name only when the slot that
owns it matches label (None = a lead cron, Some(label) =
that worker’s), persist, and return the entry removed (None when
nothing matched). Backs cron__delete so a caller deletes only its
own crons.
Sourcepub(crate) fn delete_crons_for_worker(
&self,
project_key: &ProjectKey,
label: &str,
)
pub(crate) fn delete_crons_for_worker( &self, project_key: &ProjectKey, label: &str, )
Remove worker label’s crons in project_key from the in-memory
set and the store, scoped to (project name, team_role == Some(label)).
Backs spawn::teardown_worker so a despawned dynamic worker’s crons
go with its durable row. The key resolves to a name via the same view
lookup remove_gotify_subscriptions_for_worker uses.
Sourcefn announce_cron_schedules_changed(&self, project_name: &str)
fn announce_cron_schedules_changed(&self, project_name: &str)
Tell every view the project’s schedule set moved, as the set the write just left, on the project’s lead seat. A name no project carries has no seat to route on and announces nothing.
Whether this run’s boot could not load the durable crons.
cron__list refuses on it.
Sourcepub fn crons_for_project(&self, project_name: &str) -> Vec<CronEntry>
pub fn crons_for_project(&self, project_name: &str) -> Vec<CronEntry>
The crons registered for project_name, whatever their owner. The
two scoping consumers narrow it - cron__list to the caller’s own
crons, the Inspector SCHEDULES snapshot to the active tab’s - while
the refused-delete determination reads it whole, because telling
“not in this project” from “not yours” needs the other owners’ rows.
Sourcepub(crate) fn all_crons_snapshot(&self) -> Vec<CronEntry>
pub(crate) fn all_crons_snapshot(&self) -> Vec<CronEntry>
A snapshot of every cron across all projects. Backs the scheduler’s per-tick due-check.
Sourcepub(crate) fn remember_cron_name(
&self,
slot: &SessionSlot,
uuid: &str,
cron_id: &str,
description: Option<&str>,
)
pub(crate) fn remember_cron_name( &self, slot: &SessionSlot, uuid: &str, cron_id: &str, description: Option<&str>, )
Remember the schedule a fired prompt came from, so its row can name the schedule after a reload the delivery frame does not survive.
Best-effort: a store that will not write costs a reloaded row its title, which is exactly what it had before the pairing existed.
Sourcepub fn cron_names(
&self,
slot: &SessionSlot,
uuids: &[&str],
) -> BTreeMap<String, CronName>
pub fn cron_names( &self, slot: &SessionSlot, uuids: &[&str], ) -> BTreeMap<String, CronName>
The schedules the given fired prompts came from, keyed by the prompt’s own id: the read half of the pairing the delivery frame carries.
Sourcepub(crate) fn advance_or_remove_cron(&self, id: &CronId, fired_at: SystemTime)
pub(crate) fn advance_or_remove_cron(&self, id: &CronId, fired_at: SystemTime)
Advance a fired cron and persist: a recurring cron records
last_fire and moves next_fire to the next future slot (removed
if it somehow has none); a run-once is removed. A direct state
mutation - the fire’s prompt delivery goes through the Command bus
separately.
Sourcepub fn fire_due_crons(self: &Arc<Self>, now: SystemTime)
pub fn fire_due_crons(self: &Arc<Self>, now: SystemTime)
Fire every cron due at now: deliver each prompt into its project
session (spawning it if asleep, unless the entry carries
wake: false for a job that must not wake a sleeping
owner, in which case a sleeping owner leaves the fire due and
unadvanced) and advance/remove the entry.
Delivery routes through the Command bus (a session action); the
advance is a direct state write - kept separate per the cron
state-vs-delivery split. now is injected so tests are
deterministic. Also the boot catch-up: calling this once at
startup fires every cron that came due while forge was down,
advancing each past its missed slots (catch-up-once).
Sourcepub fn start_cron_scheduler(self: &Arc<Self>)
pub fn start_cron_scheduler(self: &Arc<Self>)
Spawn the cron scheduler: a background task that wakes every
CRON_TICK_INTERVAL (~60s), fires every due cron, and exits when
the workspace drops. The registry makes a second call a no-op.
Mirrors Workspace::start_usage_poller; started once at boot.
Source§impl Workspace
impl Workspace
Sourcepub fn seed_test_cron(&self, entry: CronEntry)
pub fn seed_test_cron(&self, entry: CronEntry)
Register a durable cron directly, bypassing the MCP create path.
Cross-crate test access to the otherwise pub(crate) cron store
so a view can exercise the Inspector’s refresh_forge_crons
resolution against a seeded cron.
The store write without the announcement: a fixture seeds state, it does not perform the write a view is owed a frame for.
Source§impl Workspace
impl Workspace
Sourcepub(crate) fn register_document_hand_off(
&self,
seat: &SessionSlot,
path: &str,
reviewable: bool,
) -> Option<(Uuid, Receiver<DocumentHandOffEnding>)>
pub(crate) fn register_document_hand_off( &self, seat: &SessionSlot, path: &str, reviewable: bool, ) -> Option<(Uuid, Receiver<DocumentHandOffEnding>)>
Hold a document hand-off for the reader, and hand back its id plus the receiver the caller awaits.
No client, no park. A forge with no attached client returns
None rather than a receiver: holding a session on an ask no view
can draw would park it for ever, which is the failure this refuses.
The raise goes out as an ANSWERING prompt, the same way a browser
hand-off’s does - the ask needs a client that can answer it.
Sourcepub(crate) fn document_handoff_waiting(
&self,
id: Uuid,
seat: &SessionSlot,
) -> bool
pub(crate) fn document_handoff_waiting( &self, id: Uuid, seat: &SessionSlot, ) -> bool
Whether a hand-off with this id is still registered to seat,
without removing it - the read the dispatch guard makes before an
answer.
Sourcepub(crate) fn resolve_document_hand_off(
&self,
id: Uuid,
seat: &SessionSlot,
ending: DocumentHandOffEnding,
) -> bool
pub(crate) fn resolve_document_hand_off( &self, id: Uuid, seat: &SessionSlot, ending: DocumentHandOffEnding, ) -> bool
Remove a parked hand-off, answer its waiter, and tell every view it
is gone. false when the id is not this seat’s to resolve.
Sourcepub async fn hand_off_document(
&self,
seat: &SessionSlot,
path: &str,
reviewable: bool,
) -> Result<(String, DocumentHandOffEnding), String>
pub async fn hand_off_document( &self, seat: &SessionSlot, path: &str, reviewable: bool, ) -> Result<(String, DocumentHandOffEnding), String>
Hand one document to the reader: read it out of seat’s own tree,
ask the clients drawing that seat to open it, and park until the
reader answers.
The answer is a decision, not the open. The reader accepts,
reads and comments; the viewer’s approve or feedback releases the
call with the decision, note and filed review, NotNow releases it
with their decline, and the waiter’s own drop with
DocumentHandOffEnding::Abandoned. A refused read raises nothing
and parks on nothing.
Source§impl Workspace
impl Workspace
Sourcepub fn gotify_config(&self) -> Option<GotifyConfig>
pub fn gotify_config(&self) -> Option<GotifyConfig>
The [gotify] server connection from forge.toml, or None
when the section is absent. None keeps the Gotify subsystem
dormant and makes gotify__subscribe error.
Sourcepub(crate) fn add_gotify_subscription(
&self,
sub: GotifySubscription,
durable: bool,
)
pub(crate) fn add_gotify_subscription( &self, sub: GotifySubscription, durable: bool, )
Register a Gotify subscription in the active set. Durable ones (lead / team-worker) also persist to the redb store; ephemeral ad-hoc-worker ones stay in memory only and drop on restart.
Sourcepub(crate) fn remove_gotify_subscription_owned_by(
&self,
project: &str,
id: Uuid,
owner: Option<&str>,
) -> Option<GotifySubscription>
pub(crate) fn remove_gotify_subscription_owned_by( &self, project: &str, id: Uuid, owner: Option<&str>, ) -> Option<GotifySubscription>
Remove the subscription id in project only when its owner
matches owner (None = a lead subscription, Some(label) =
that worker’s), from both the active set and (when present) the
redb store. Returns the row as it stood (None when nothing
matched). Backs the owner-scoped gotify__unsubscribe so a caller
removes only what it subscribed, mirroring
Self::remove_cron_owned_by. Worker teardown uses
Self::remove_gotify_subscriptions_for_worker instead and is
deliberately not owner-gated.
Sourcepub(crate) fn remove_gotify_subscriptions_for_worker(
&self,
project_key: &ProjectKey,
label: &str,
)
pub(crate) fn remove_gotify_subscriptions_for_worker( &self, project_key: &ProjectKey, label: &str, )
Remove the worker label’s Gotify subscriptions in project_key
from both the active set and the redb store, scoped to
(project name, team_role == Some(label)). Backs
spawn::teardown_worker so a despawned durable dynamic worker can’t
orphan a persisted sub. Subscriptions are project-scoped by NAME, so
the key is resolved to a name via the same view lookup
resolve_identity uses when subscribing.
Sourcepub fn gotify_subscriptions_for_project(
&self,
project: &str,
) -> Vec<GotifySubscription>
pub fn gotify_subscriptions_for_project( &self, project: &str, ) -> Vec<GotifySubscription>
The active subscriptions for project, whatever their owner. The
scoping consumers narrow it - gotify__list to the caller’s own
subscriptions, the Inspector GOTIFY snapshot to the active tab’s
stamped project name - while the refused-unsubscribe determination
reads it whole, because telling “not in this project” from “not
yours” needs the other owners’ rows.
Sourcepub(crate) fn announce_connector_subscriptions_changed(
&self,
project_name: &str,
)
pub(crate) fn announce_connector_subscriptions_changed( &self, project_name: &str, )
Tell every view the project’s connector subscriptions moved, as the two sets the core holds, on the project’s lead seat. One update carries both connectors: they are one section of the board’s project row, and a reader that patches it needs the pair. A name no project carries has no seat to route on and announces nothing.
Lives here rather than on either connector: the section is the pair, so both seams announce through the one site.
Sourcepub fn gotify_connected(&self) -> bool
pub fn gotify_connected(&self) -> bool
Whether the Gotify stream is currently connected. Backs the Inspector GOTIFY status line.
Sourcepub fn start_gotify_subsystem(self: &Arc<Self>)
pub fn start_gotify_subsystem(self: &Arc<Self>)
Start the Gotify subsystem when it’s configured, has at least one
active subscription, and isn’t already running. Idempotent. Spawns
the forge-connectors pump, which runs the reconnecting stream task
and translates its events into the gotify_* state below and
GotifyHost::deliver calls. Called at boot and after a subscribe
grows the active set.
The pump’s exit guard clears the handle when the task ends, so a pump that dies no longer wedges the subsystem: the next call here starts a new one.
Sourcefn spawn_gotify_subsystem_task<F>(
self: &Arc<Self>,
token: Arc<()>,
pump: F,
) -> JoinHandle<()> ⓘ
fn spawn_gotify_subsystem_task<F>( self: &Arc<Self>, token: Arc<()>, pump: F, ) -> JoinHandle<()> ⓘ
Spawn the Gotify pump behind the guard that clears this run’s handle and liveness however the task ends: a return, a cancellation, or an unwind. Returns the task handle so a test can await the end.
Sourcepub fn stop_gotify_subsystem_if_idle(&self)
pub fn stop_gotify_subsystem_if_idle(&self)
Stop the Gotify subsystem once no subscriptions remain: signal the stream task to exit and mark disconnected. No-op while any subscription is active or the subsystem isn’t running.
Sourcepub(crate) fn stop_gotify_subsystem(&self)
pub(crate) fn stop_gotify_subsystem(&self)
Signal the running pump to stop and mark the connector disconnected, whatever subscriptions remain. The workspace’s shutdown calls this so a clean stop reaches the pump’s own exit rather than leaving the handle armed for the task’s drop.
Source§impl Workspace
impl Workspace
Sourcepub fn seed_test_gotify_subscription(&self, sub: GotifySubscription)
pub fn seed_test_gotify_subscription(&self, sub: GotifySubscription)
Register a Gotify subscription directly, bypassing the MCP
subscribe path. Cross-crate test access so a view can exercise
the Inspector’s refresh_gotify resolution, mirroring
Self::seed_test_cron.
The active-set write without the announcement: a fixture seeds state, it does not perform the write a view is owed a frame for.
Source§impl Workspace
impl Workspace
Sourcepub(crate) fn set_pending_brief(&self, slot: &SessionSlot, brief: PendingBrief)
pub(crate) fn set_pending_brief(&self, slot: &SessionSlot, brief: PendingBrief)
Hold the brief a handoff has written until its successor connects.
Sourcepub(crate) fn take_pending_brief(
&self,
slot: &SessionSlot,
) -> Option<PendingBrief>
pub(crate) fn take_pending_brief( &self, slot: &SessionSlot, ) -> Option<PendingBrief>
Take the brief a swap left for slot, if one is waiting.
Sourcepub(crate) fn clear_pending_brief(&self, slot: &SessionSlot)
pub(crate) fn clear_pending_brief(&self, slot: &SessionSlot)
Forget a brief whose swap never reached its successor.
Sourcepub(crate) fn begin_swap(&self, slot: &SessionSlot) -> u64
pub(crate) fn begin_swap(&self, slot: &SessionSlot) -> u64
Mark the seat as swapping its occupant: from the moment the brief is built until the successor connects, a prompt aimed at it is held rather than run by the session on its way out.
Sourcepub(crate) fn swap_in_progress(&self, slot: &SessionSlot) -> bool
pub(crate) fn swap_in_progress(&self, slot: &SessionSlot) -> bool
Whether a swap is in flight on this seat.
Sourcepub fn handoff_in_flight(&self, slot: &SessionSlot) -> bool
pub fn handoff_in_flight(&self, slot: &SessionSlot) -> bool
Whether a handoff owns this seat’s composer right now: its judgment turn is running, or the swap it opened is still building or spawning.
The domain’s own flag is cleared at the swap, before the successor connects - so a page attaching in that window would otherwise read a live composer over a seat that has no session at all.
Sourcefn end_machine_turn(&self, slot: &SessionSlot)
fn end_machine_turn(&self, slot: &SessionSlot)
End the machine turn a handoff raised, wherever its build stops.
Every path out of the build that leaves no successor needs this. The frame at the handoff’s begin is the live page’s only source, and a successor’s record read is what normally clears it - so a build that stops without one would leave the seat’s composer down for good.
Sourcepub(crate) fn swap_is_current(&self, slot: &SessionSlot, token: u64) -> bool
pub(crate) fn swap_is_current(&self, slot: &SessionSlot, token: u64) -> bool
Whether token still owns the seat’s swap.
A build that lost the seat must not spawn. A replacement during
ground_truth closes the gate, and a later handoff may open a new
one; only the token says whether the NewSession this build is about
to dispatch is still its own.
Sourcepub(crate) fn dispatch_peer_prompt_under(
self: &Arc<Self>,
caller: &SessionSlot,
key: &SessionSlot,
text: String,
uuid: String,
) -> Result<(), DispatchError>
pub(crate) fn dispatch_peer_prompt_under( self: &Arc<Self>, caller: &SessionSlot, key: &SessionSlot, text: String, uuid: String, ) -> Result<(), DispatchError>
Dispatch a peer message’s prompt, holding it for the successor when a swap is in flight and keeping its sender so a handoff that fails can answer it.
Sourcepub(crate) fn hold_prompt(&self, slot: &SessionSlot, prompt: HeldPrompt) -> bool
pub(crate) fn hold_prompt(&self, slot: &SessionSlot, prompt: HeldPrompt) -> bool
Hold a prompt for the successor. true when a swap took it.
Sourcepub(crate) fn end_swap(&self, slot: &SessionSlot) -> Vec<HeldPrompt>
pub(crate) fn end_swap(&self, slot: &SessionSlot) -> Vec<HeldPrompt>
End the swap and take everything held for it, in arrival order.
Sourcepub(crate) fn record_handoff(
&self,
record: &HandoffRecord,
predecessor_reads: &[String],
)
pub(crate) fn record_handoff( &self, record: &HandoffRecord, predecessor_reads: &[String], )
Write one handoff to the store, which is what at_percent is tuned
from later. A store that cannot write is logged, never a reason to
hold up the swap: the session is already handing over.
Sourcepub(crate) fn pending_brief_for(
&self,
slot: &SessionSlot,
) -> Option<PendingBrief>
pub(crate) fn pending_brief_for( &self, slot: &SessionSlot, ) -> Option<PendingBrief>
The brief waiting for slot’s successor, when one is.
Sourcepub(crate) fn handoff_for_successor(
&self,
slot: &SessionSlot,
occupant: &str,
) -> Option<(HandoffRecord, Vec<String>)>
pub(crate) fn handoff_for_successor( &self, slot: &SessionSlot, occupant: &str, ) -> Option<(HandoffRecord, Vec<String>)>
The handoff occupant arrived by, if it arrived by one.
Sourcepub(crate) fn measure_successor(
self: &Arc<Self>,
slot: SessionSlot,
occupant: String,
cwd: String,
predecessor_reads: Vec<String>,
)
pub(crate) fn measure_successor( self: &Arc<Self>, slot: SessionSlot, occupant: String, cwd: String, predecessor_reads: Vec<String>, )
Measure the successor’s first turns off its own transcript and write them onto the record. Runs detached: the read is a whole transcript and the seat has work to do.
Sourcepub(crate) fn start_handoff(self: &Arc<Self>, request: SwapRequest)
pub(crate) fn start_handoff(self: &Arc<Self>, request: SwapRequest)
Run a handoff: build the ground truth, announce the swap, and start the
new session on the same order /new uses.
The build reads the transcript and probes git, so it runs off the actor that decided it. The swap signal goes out before the spawn, so the seat knows the join before the successor’s transcript starts.
async fn run_handoff(self: Arc<Self>, request: SwapRequest)
Source§impl Workspace
impl Workspace
Sourcepub fn idle_sweep(&self, deliver: &dyn Deliver, now: SystemTime) -> usize
pub fn idle_sweep(&self, deliver: &dyn Deliver, now: SystemTime) -> usize
One pass over every project: deliver the injected sweep to each lead that is idle, or overdue for one. Returns how many prompts reached a session.
Sourcepub fn start_idle_sweep(self: &Arc<Self>)
pub fn start_idle_sweep(self: &Arc<Self>)
Spawn the idle sweep: a background pass every
IDLE_SWEEP_INTERVAL_SECS that injects the board sweep into a
lead with nothing in flight. The registry makes a second call a
no-op; started once at boot. Mirrors
Workspace::start_chase_sweep.
Source§impl Workspace
impl Workspace
Sourcepub fn dictate_install(&self) -> InstallState
pub fn dictate_install(&self) -> InstallState
The install’s state, for the page’s read.
Sourcepub fn installed_models(&self) -> Vec<InstalledModel>
pub fn installed_models(&self) -> Vec<InstalledModel>
Every model downloaded from the feed on this machine, oldest first.
Sourcepub(crate) fn uninstall_model(&self, file: &str) -> Result<(), DispatchError>
pub(crate) fn uninstall_model(&self, file: &str) -> Result<(), DispatchError>
Remove one model this machine downloaded: the file and the record.
The file name has to be one this machine recorded, because it
crosses from a page and joins onto the models directory: a name no
record carries is refused rather than removed, so ../../ and
anything else a caller invents names nothing.
Refused while a role RUNS the file - the engine holds it loaded - and named by that role, because the swap that frees it is the caller’s next step rather than this one’s. The record’s own runtime pick goes with it: a pick left naming a file that is gone refuses the next boot, and the page that removed the file is the one that can clear it.
Sourcepub(crate) fn start_install(
self: &Arc<Self>,
variant: String,
) -> Result<(), DispatchError>
pub(crate) fn start_install( self: &Arc<Self>, variant: String, ) -> Result<(), DispatchError>
Install one feed variant: fetch its doc, take the quant the row draws, and download that file into the models directory.
The outcome rides SessionUpdate::DictateModelsChanged, the same
way a catalogue check’s does.
Sourcefn fail_install(&self, file: String, reason: String)
fn fail_install(&self, file: String, reason: String)
Take the install no further, naming the step that stopped.
fn note_download(&self, file: &str, got: u64, total: u64)
Sourcefn set_install_file(&self, file: &str, total: u64)
fn set_install_file(&self, file: &str, total: u64)
Name the file while the doc is still being read, so the page’s bar has something to say from the first tick.
Sourcefn record_installed(&self, model: &InstalledModel) -> bool
fn record_installed(&self, model: &InstalledModel) -> bool
Record a downloaded model, answering whether the store took it.
false is a store that could not write, which is the caller’s to act
on: a file nothing recorded is a file the page can neither show nor
remove, so the install path takes the bytes back rather than leaving
one behind.
Sourcepub(crate) fn push_models(&self)
pub(crate) fn push_models(&self)
The page’s re-read, pushed the way a landed check pushes one.
Sourcefn variant_for_install(
&self,
variant: &str,
) -> Option<(CatalogueSource, CatalogueEntry)>
fn variant_for_install( &self, variant: &str, ) -> Option<(CatalogueSource, CatalogueEntry)>
The feed source and the entry a variant names, when both are known.
Sourceasync fn run_install(self: Arc<Self>, variant: String)
async fn run_install(self: Arc<Self>, variant: String)
Fetch, check and record one variant. Every failure lands as an
InstallState::Failed whose reason is its own, so the page says
which step stopped rather than that something did.
Sourceasync fn run_speaking_install(self: Arc<Self>, speaking: SpeakingVariant)
async fn run_speaking_install(self: Arc<Self>, speaking: SpeakingVariant)
Install one speaking variant: read the Hub’s current revision, take the variant’s ONNX file and every named voice at that revision, and record the set only once every digest matched.
Sourcefn record_speaking_install(&self, install: &SpeakingInstall) -> bool
fn record_speaking_install(&self, install: &SpeakingInstall) -> bool
Record one speaking install, answering whether the store took it.
Source§impl Workspace
impl Workspace
Sourcepub fn active_models(&self) -> Vec<(DictateRole, ActiveModel)>
pub fn active_models(&self) -> Vec<(DictateRole, ActiveModel)>
The models the roles run, as the preflight resolved them: the compiled pins until that resolution lands, then whatever the config key, the runtime pick or the pin answered, per role.
Sourcepub(crate) async fn resolve_active(
&self,
settings: &DictateSettings,
) -> Result<Vec<(DictateRole, ActiveModel)>, String>
pub(crate) async fn resolve_active( &self, settings: &DictateSettings, ) -> Result<Vec<(DictateRole, ActiveModel)>, String>
Resolve every role’s model, fresh: the config key when set, else the runtime pick, else the compiled pin.
The key’s variant resolves through the installed record first, a local read; a variant that is not on disk reaches the feed’s doc table, which is the download source and the only network call here. A key that resolves through neither is an error naming the key, which the preflight renders as the reason forge stops.
Sourcepub(crate) fn active_choice(&self, role: DictateRole) -> Option<ActiveChoice>
pub(crate) fn active_choice(&self, role: DictateRole) -> Option<ActiveChoice>
The runtime pick recorded for one role, when the store answers one.
A failed read reads as no pick - the role falls to the pin - and it says so, because a corrupt row silently reverting the pick would look like a pick that never happened.
Sourceasync fn resolve_variant(
&self,
key: &str,
variant: &str,
) -> Result<ModelSpec, String>
async fn resolve_variant( &self, key: &str, variant: &str, ) -> Result<ModelSpec, String>
The model a config key names: the installed record, else the feed’s doc table, else nothing.
Sourceasync fn catalogue_entry_for(&self, variant: &str) -> Option<CatalogueEntry>
async fn catalogue_entry_for(&self, variant: &str) -> Option<CatalogueEntry>
The feed’s entry for one variant: the loaded feed, or one catalogue fetch when the loaded feed does not carry it.
The fetch is what keeps a config key working on a fresh machine, whose local store and cache both predate the variant.
Sourcepub(crate) fn record_resolved_installs(
&self,
resolved: &[(DictateRole, ActiveModel)],
)
pub(crate) fn record_resolved_installs( &self, resolved: &[(DictateRole, ActiveModel)], )
Record a config-resolved model the preflight has just fetched, so the page’s installed set and the next boot’s resolution both see it.
Only the config leg reaches here: a runtime pick was installed before it could be picked, and the compiled pins are not recorded, because nothing here names their variant without the feed.
Source§impl Workspace
impl Workspace
Sourcepub fn dictate_activate(&self) -> ActivateState
pub fn dictate_activate(&self) -> ActivateState
The activation’s state, for the page’s read.
Sourcepub(crate) fn start_activate(
self: &Arc<Self>,
role: DictateRole,
file: String,
) -> Result<(), DispatchError>
pub(crate) fn start_activate( self: &Arc<Self>, role: DictateRole, file: String, ) -> Result<(), DispatchError>
Make one installed model the role’s active model on the running forge: build the new engine while the old still serves, swap, then drop the old.
A role forge.toml pins is refused by name - the page can still
download and bench it, but the runtime cannot move it - and a live
take refuses the swap rather than being dropped under.
Sourcepub(crate) fn start_deactivate(
self: &Arc<Self>,
role: DictateRole,
) -> Result<(), DispatchError>
pub(crate) fn start_deactivate( self: &Arc<Self>, role: DictateRole, ) -> Result<(), DispatchError>
Return one role to its [dictate] key or compiled default: clear
the runtime pick and swap the engine back, in activation’s own
build-then-swap order.
Sourcefn pinning_key(&self, role: DictateRole) -> Option<String>
fn pinning_key(&self, role: DictateRole) -> Option<String>
The config key that pins one role, when one does.
Sourcefn pin_for(&self, role: DictateRole) -> Option<ModelSpec>
fn pin_for(&self, role: DictateRole) -> Option<ModelSpec>
The compiled default one role runs, when it has one: the cleanup
role has none while normalizer is off.
Sourceasync fn run_activate(self: Arc<Self>, role: DictateRole, file: String)
async fn run_activate(self: Arc<Self>, role: DictateRole, file: String)
Load one installed model and swap the role onto it.
Sourceasync fn activate_speaking(
self: &Arc<Self>,
variant: &str,
) -> Result<String, String>
async fn activate_speaking( self: &Arc<Self>, variant: &str, ) -> Result<String, String>
Swap one speaking variant in, building the engine before the swap so a failed load leaves the previous one serving.
Sourceasync fn run_deactivate(self: Arc<Self>, role: DictateRole, pin: ModelSpec)
async fn run_deactivate(self: Arc<Self>, role: DictateRole, pin: ModelSpec)
Swap the role back to its compiled default and drop the pick.
Sourceasync fn swap_role_to(
&self,
role: DictateRole,
spec: ModelSpec,
from: ActiveFrom,
) -> Result<String, String>
async fn swap_role_to( &self, role: DictateRole, spec: ModelSpec, from: ActiveFrom, ) -> Result<String, String>
Build the engine the role’s new model needs while the old one still
serves, swap the Arc, then drop the old - never drop first, which
would leave the role with no engine at all if the new load fails.
Answers the RFC 3339 stamp the pick is recorded with, or the reason the load failed; a failure changes nothing.
fn fail_activate(&self, role: DictateRole, file: String, reason: String)
Sourcefn record_pick(&self, role: DictateRole, variant: &str, at: &str)
fn record_pick(&self, role: DictateRole, variant: &str, at: &str)
Record one role’s runtime pick, so the next boot answers it.
Sourcefn clear_pick(&self, role: DictateRole)
fn clear_pick(&self, role: DictateRole)
Drop one role’s runtime pick, so the role answers its config key or its compiled pin again.
Source§impl Workspace
impl Workspace
Sourcepub(crate) fn park_peer_prompt(
&self,
slot: &SessionSlot,
sender: &SessionSlot,
wrapped: WrappedPrompt,
)
pub(crate) fn park_peer_prompt( &self, slot: &SessionSlot, sender: &SessionSlot, wrapped: WrappedPrompt, )
Park a peer prompt for slot, drained by the session that next
connects as that slot.
Sourcepub(crate) fn peer_prompt_is_parked(
&self,
slot: &SessionSlot,
id: &MessageId,
) -> bool
pub(crate) fn peer_prompt_is_parked( &self, slot: &SessionSlot, id: &MessageId, ) -> bool
Whether slot’s bucket still holds the peer prompt with id. The
wake path parks before it dispatches a spawn, so an entry the spawn’s
own failure expired means the delivery was not accepted.
Sourcepub(crate) fn park_cron(
&self,
slot: &SessionSlot,
cron: &CronEntry,
missed: bool,
)
pub(crate) fn park_cron( &self, slot: &SessionSlot, cron: &CronEntry, missed: bool, )
Park a fired cron prompt for slot, missed-marked when it came
due while the owner was asleep. The entry’s identity rides along so
the drained echo can name the schedule it came from.
Sourcepub(crate) fn park_gotify(
&self,
slot: &SessionSlot,
notification: GotifyNotification,
)
pub(crate) fn park_gotify( &self, slot: &SessionSlot, notification: GotifyNotification, )
Park a Gotify notification for slot.
Sourcepub(crate) fn park_slack(&self, slot: &SessionSlot, messages: Vec<SlackMessage>)
pub(crate) fn park_slack(&self, slot: &SessionSlot, messages: Vec<SlackMessage>)
Park a Slack batch for slot.
Sourcepub(crate) fn hold_peer_prompt_unless_turn_ended(
&self,
slot: &SessionSlot,
sender: &SessionSlot,
wrapped: WrappedPrompt,
turn_running: impl FnOnce() -> bool,
) -> Option<WrappedPrompt>
pub(crate) fn hold_peer_prompt_unless_turn_ended( &self, slot: &SessionSlot, sender: &SessionSlot, wrapped: WrappedPrompt, turn_running: impl FnOnce() -> bool, ) -> Option<WrappedPrompt>
Hold a peer prompt for slot until the turn it arrived under ends,
when the seat is idle again and the prompt can start a turn of its
own - unless the turn has already ended, in which case the prompt
comes back for delivery now.
turn_running is read while the bucket’s own lock is held, and the
turn-end drain ends the turn’s markers before it takes the bucket:
the take’s lock is what orders the two, so a delivery is either
enqueued before a drain (and delivered by it) or reads the ended turn
after that drain’s take (and is delivered by its caller) - never left
behind a drain that already passed.
Sourcepub(crate) fn take_turn_held_for_slot(
&self,
slot: &SessionSlot,
) -> Vec<ParkedPeer>
pub(crate) fn take_turn_held_for_slot( &self, slot: &SessionSlot, ) -> Vec<ParkedPeer>
Take (and clear) the prompts held for slot’s running turn, in
the order they arrived.
Sourcepub(crate) fn take_parked_for_slot(&self, slot: &SessionSlot) -> ParkedForSlot
pub(crate) fn take_parked_for_slot(&self, slot: &SessionSlot) -> ParkedForSlot
Take (and clear) everything parked for the connecting session’s slot. The caller passes the triple it was spawned with, rather than resolving one from its cwd: a cwd under no configured project would resolve to nothing and leave the payloads parked forever, silently.
A prompt held for a turn end rides the peer list: the slot that connected is a different, idle session, and dropping the hold with the bucket would lose it silently.
Sourcepub(crate) fn expire_parked_for_slot(
self: &Arc<Self>,
slot: &SessionSlot,
reason: PeerFailureReason,
)
pub(crate) fn expire_parked_for_slot( self: &Arc<Self>, slot: &SessionSlot, reason: PeerFailureReason, )
Drop everything parked for slot, acknowledging each peer message
back to its sender so a message that never landed is not left
looking delivered. The parked message is the only payload with a
sender, so the Gotify and Slack drops have no one to tell and are
logged instead.
Source§impl Workspace
impl Workspace
Sourcepub fn plugin_update_run(&self) -> Option<PluginUpdateRun>
pub fn plugin_update_run(&self) -> Option<PluginUpdateRun>
The last plugin update run or check, in flight or finished, for the
Extensions read. None until one has happened this process.
Sourcepub async fn plugins_inventory(&self) -> Option<PluginsInventorySnapshot>
pub async fn plugins_inventory(&self) -> Option<PluginsInventorySnapshot>
The disk-only plugin inventory, scanned on the call: registry,
marketplace manifests and the plugin cache, no claude spawn.
None when no plugins root resolves.
Sourcepub fn set_plugin_scan_for_test(
&self,
scan: impl Fn() -> Option<PluginsInventorySnapshot> + Send + Sync + 'static,
)
pub fn set_plugin_scan_for_test( &self, scan: impl Fn() -> Option<PluginsInventorySnapshot> + Send + Sync + 'static, )
Replace the disk scan, so a cross-crate test can answer the Extensions read with a fixture instead of this machine’s plugins root. Test-only. Takes a closure rather than the seam, so the crate’s own types stay private.
fn plugin_cli(&self) -> PluginCli
Sourcefn begin_plugin_action(&self) -> Result<(), DispatchError>
fn begin_plugin_action(&self) -> Result<(), DispatchError>
Take the single-action gate, or refuse naming the one in flight.
Sourcefn plugin_home() -> Result<String, DispatchError>
fn plugin_home() -> Result<String, DispatchError>
The home directory plugin calls with no project context run in. Unavailable means the action fails rather than substituting another directory (hard rule 14).
Sourcefn declared_project_path(&self, name: &str) -> Result<String, DispatchError>
fn declared_project_path(&self, name: &str) -> Result<String, DispatchError>
The declared project’s path, by its forge.toml name.
Sourcepub(crate) fn start_plugin_install(
self: &Arc<Self>,
plugin_id: String,
scope: String,
project: Option<&str>,
) -> Result<(), DispatchError>
pub(crate) fn start_plugin_install( self: &Arc<Self>, plugin_id: String, scope: String, project: Option<&str>, ) -> Result<(), DispatchError>
Install one plugin at a scope.
Sourcepub(crate) fn start_plugin_update(
self: &Arc<Self>,
plugin_id: String,
scope: String,
) -> Result<(), DispatchError>
pub(crate) fn start_plugin_update( self: &Arc<Self>, plugin_id: String, scope: String, ) -> Result<(), DispatchError>
Update one installed plugin, in the scope and project the registry records for it.
The registry lookup is a disk scan, so it runs inside the spawned
task: the dispatch is fire-and-forget and its outcome rides the
Plugins frames, a refusal included - a plugin the registry does
not hold in that scope reports as a failed action naming it.
Sourcefn start_plugin_cli_action(
self: &Arc<Self>,
cwd: String,
args: Vec<String>,
message: String,
dispatch_id: Option<u64>,
) -> Result<(), DispatchError>
fn start_plugin_cli_action( self: &Arc<Self>, cwd: String, args: Vec<String>, message: String, dispatch_id: Option<u64>, ) -> Result<(), DispatchError>
One generic CLI action: gate, spawn, report. The outcome rides
SessionUpdate::PluginsCliActionSucceeded / Failed.
Sourcepub(crate) fn start_plugin_update_all(
self: &Arc<Self>,
) -> Result<(), DispatchError>
pub(crate) fn start_plugin_update_all( self: &Arc<Self>, ) -> Result<(), DispatchError>
Update exactly the stale installed plugins, one CLI call per entry, continuing past failures. The set is computed from a disk scan taken inside the run’s own task, so the dispatch never walks the tree on the runtime and the run updates what the marketplace reports now.
Nothing stale is not a refusal: the run lands finished with no rows, exactly as a check with nothing to report does, and the page draws its ordinary state.
Sourcepub(crate) fn start_plugin_check(self: &Arc<Self>) -> Result<(), DispatchError>
pub(crate) fn start_plugin_check(self: &Arc<Self>) -> Result<(), DispatchError>
Refresh the inventory through the CLI and report which installed plugins have a newer marketplace version, applying nothing.
Sourcepub(crate) fn start_marketplace_add(
self: &Arc<Self>,
source: String,
dispatch_id: Option<u64>,
) -> Result<(), DispatchError>
pub(crate) fn start_marketplace_add( self: &Arc<Self>, source: String, dispatch_id: Option<u64>, ) -> Result<(), DispatchError>
Add a marketplace by owner/repo or URL, at user scope.
Sourcepub(crate) fn start_marketplace_repair(
self: &Arc<Self>,
name: String,
) -> Result<(), DispatchError>
pub(crate) fn start_marketplace_repair( self: &Arc<Self>, name: String, ) -> Result<(), DispatchError>
Repair a drifted marketplace with the CLI’s remove-and-re-add pair. The re-add source is the repo for a git marketplace and the clone location for a directory one. The registry lookup is a disk scan, so it runs inside the task.
Source§impl Workspace
impl Workspace
Sourceconst MIN_POPULATED_REFS: usize = 3
const MIN_POPULATED_REFS: usize = 3
Branch refs a repo must hold before a majority-dead sweep of it is trusted.
Measured against real clones, counting branch refs rather than
names: a --depth 1 or --single-branch clone holds two, its
local head and the one remote-tracking ref it fetched, whatever
those branches are called. A full clone of a four-branch repo
holds five. Three sits in that gap.
It has to be refs and not names. The name set over-includes on
purpose, one phantom per slash, so release/2.0/rc1 adds 2.0/rc1
and rc1 beside itself and pushes a two-ref clone to three names -
over the bound, guard silent. One slash is not enough to show it:
release/2.0 gives two names against two refs, which is why a
test built on it passes whichever quantity the guard reads.
Three is not a tuning knob, it is the only position where the
guard exists. A solo repo - one main, no remote - knows ONE
name, which is BELOW the shallow clone’s two. So any value that
spares the solo repo also spares the shallow clone, and lowering
to two sweeps the shallow clone anyway. There is no threshold that
separates them; a solo repo with most of its review branches
merged is refused, and that is the cost of the guard rather than
something to calibrate away.
Sourcepub fn load_review_threads(
&self,
project: &str,
branch: &str,
) -> Result<Vec<ReviewThread>, String>
pub fn load_review_threads( &self, project: &str, branch: &str, ) -> Result<Vec<ReviewThread>, String>
Persisted review threads for (project, branch), empty when the
DB isn’t open or the read fails. project is the forge.toml
project NAME (worktree-agnostic). Query-style, so this is a direct
method - review-thread persistence is local redb IO, not an
agent-driving action that needs the Command bus.
Load the persisted review threads for (project, branch). Ok
with an empty vec when the store isn’t open or the branch has no
row; Err with a display string when an existing row fails to
decode / read, so the overlay can surface the failure instead of
showing a silently-empty review pane.
Sourcepub fn upsert_review_thread(
&self,
project: &str,
branch: &str,
thread: ReviewThread,
) -> bool
pub fn upsert_review_thread( &self, project: &str, branch: &str, thread: ReviewThread, ) -> bool
Insert or replace one review thread by id in (project, branch).
Returns whether the write was confirmed - false when the store
isn’t open or the write failed, so the caller can leave the
comment in the at-risk (not-yet-durable) bucket.
Sourcepub fn delete_review_thread(
&self,
project: &str,
branch: &str,
id: &str,
) -> bool
pub fn delete_review_thread( &self, project: &str, branch: &str, id: &str, ) -> bool
Remove one review thread by id from (project, branch). Returns
whether a thread was removed - false when the store isn’t open,
the write failed, or no thread carried the id.
Sourcepub fn set_review_thread_status(
&self,
project: &str,
branch: &str,
id: &str,
status: ReviewStatus,
)
pub fn set_review_thread_status( &self, project: &str, branch: &str, id: &str, status: ReviewStatus, )
Set the status of one review thread by id, bumping its updated_at.
Sourcepub fn delete_branch_review_state(&self, project: &str, branch: &str)
pub fn delete_branch_review_state(&self, project: &str, branch: &str)
Delete all of (project, branch)’s review state - threads and
reviews together, in one transaction. Called on worktree teardown
once the branch itself is gone, and by the boot sweep for a branch
deleted since, so orphaned rows don’t linger and a later branch
reusing the name inherits nothing.
Sourcepub fn sweep_dead_review_branches(&self) -> usize
pub fn sweep_dead_review_branches(&self) -> usize
Drop the review threads and reviews of every branch that no longer exists in the repo they were filed against, and report how many branches were cleared.
Every branch is judged by exact membership in one listing per
project - see forge_agent::env::worktree::repo_branch_names for
why a per-branch ref pattern cannot answer this. A project whose
root does not answer as a work-tree root is skipped whole, as is
one absent from forge.toml: there is nothing to check against.
Sourcepub fn start_review_branch_sweep(self: &Arc<Self>)
pub fn start_review_branch_sweep(self: &Arc<Self>)
Run Self::sweep_dead_review_branches off the startup path. Two
git calls per project, both blocking, so it goes on the blocking
pool rather than holding boot up.
Sourcepub fn load_reviews(
&self,
project: &str,
branch: &str,
) -> Result<Vec<ReviewSet>, String>
pub fn load_reviews( &self, project: &str, branch: &str, ) -> Result<Vec<ReviewSet>, String>
Load the submitted reviews for (project, branch), oldest first.
Ok with an empty vec when the store isn’t open or the branch has
no row; Err with a display string when an existing row fails to
decode, so the overlay can surface the failure.
Sourcepub fn review_replies_waiting(
&self,
project: &str,
branch: &str,
) -> Option<(usize, SystemTime)>
pub fn review_replies_waiting( &self, project: &str, branch: &str, ) -> Option<(usize, SystemTime)>
Worker answers on (project, branch) still owed a reviewer turn,
paired with when the oldest of them landed. None when nothing
waits or the row can’t be read.
The tally is the same awaits_reviewer count that
drain_review_activity puts on a notice and that the /diff
hydrate parks, over the same rows - so recomputing here
reproduces what those two would have written rather than
inventing a second number. since comes off the waiting
threads’ own updated_at, which for a thread awaiting the
reviewer is when the agent replied, so a recompute dates the
wait from the answer rather than from the recompute.
Sourcepub fn review_branches(&self, project: &str) -> Result<Vec<String>, String>
pub fn review_branches(&self, project: &str) -> Result<Vec<String>, String>
The branches under project that hold submitted reviews. Ok
with an empty vec when the store isn’t open; Err on a read
failure. review__list asks this only after its own branch came
back empty, so a review filed against another branch can’t read
as “no reviews”.
Sourcepub fn submit_review(
&self,
project: &str,
branch: &str,
summary: Option<String>,
thread_ids: &[String],
origin: SessionSlot,
) -> Option<ReviewSet>
pub fn submit_review( &self, project: &str, branch: &str, summary: Option<String>, thread_ids: &[String], origin: SessionSlot, ) -> Option<ReviewSet>
Seal a new review for (project, branch), filing the listed
still-unfiled threads into it and appending it with the optional
summary. Records origin as the notice target for the branch (the
reviewer’s session, so a later worker review-turn pings it). Returns
the minted forge_primitives::ReviewSet (None when the store
isn’t open or the write failed) so the caller can surface its number.
Sourcepub fn review_list(
&self,
project: &str,
branch: &str,
) -> Result<Vec<ReviewSummary>, String>
pub fn review_list( &self, project: &str, branch: &str, ) -> Result<Vec<ReviewSummary>, String>
The review__list view for (project, branch): submitted reviews
with per-review comment tallies, newest first. Ok (possibly empty)
when the store is closed or the branch has no reviews; Err when a
stored row fails to decode, so the MCP surfaces the failure instead
of a silent empty list.
Sourcepub fn review_get(
&self,
project: &str,
branch: &str,
review_id: &str,
) -> Result<Option<ReviewDetail>, String>
pub fn review_get( &self, project: &str, branch: &str, review_id: &str, ) -> Result<Option<ReviewDetail>, String>
The review__get view for one review on (project, branch): its
overview plus the comments filed under it, each carrying the
anchored code. Ok(None) when no such review; Err on a decode
failure.
Sourcepub fn review_reply(
&self,
caller: &SessionSlot,
project: &str,
branch: &str,
comment_id: &str,
author_label: &str,
text: &str,
at: &str,
) -> Result<CommentAnchorView, String>
pub fn review_reply( &self, caller: &SessionSlot, project: &str, branch: &str, comment_id: &str, author_label: &str, text: &str, at: &str, ) -> Result<CommentAnchorView, String>
Append a worker reply to comment_id on (project, branch) and
return the comment’s anchor with the status after the append (Open
-> Addressed; Resolved / Outdated unchanged). Records the reply in
caller’s turn activity buffer so the reviewer is notified at turn
end. Err when the store is closed, the write fails, or no comment
with that id exists in this scope, so a stale or cross-branch id is
rejected rather than silently ignored.
Sourcepub fn review_resolve(
&self,
caller: &SessionSlot,
project: &str,
branch: &str,
comment_id: &str,
) -> Result<CommentAnchorView, String>
pub fn review_resolve( &self, caller: &SessionSlot, project: &str, branch: &str, comment_id: &str, ) -> Result<CommentAnchorView, String>
Mark comment_id on (project, branch) Resolved and return the
comment’s anchor with its new status. Records the resolve in
caller’s turn activity buffer. Err when the store is closed,
the write fails, or no comment with that id exists in this scope.
Sourcefn note_review_activity(
&self,
caller: &SessionSlot,
project: &str,
branch: &str,
comment_id: &str,
replied: bool,
)
fn note_review_activity( &self, caller: &SessionSlot, project: &str, branch: &str, comment_id: &str, replied: bool, )
Append one review action to caller’s turn buffer, resolving the
review the action answers - the latest round the comment has a turn
in. An unfiled comment is skipped: there’s no review to notify about.
Sourcepub(crate) fn drain_review_activity(
&self,
caller: &SessionSlot,
) -> Vec<SessionUpdate>
pub(crate) fn drain_review_activity( &self, caller: &SessionSlot, ) -> Vec<SessionUpdate>
Drain caller’s accumulated review activity into one
SessionUpdate::ReviewActivityNotice per touched review, routed to
the review’s submit origin. A review with no recorded origin is
dropped (the reviewer still sees the state on /diff reopen) rather
than mis-routed to the caller. Called at the caller’s turn end so a
multi-comment review turn produces a single batched tally instead of
one line per reply. Empty when the caller took no review actions this
turn.
Source§impl Workspace
impl Workspace
Sourcepub(crate) fn register_settings_write(
&self,
caller: &SessionSlot,
ask: SettingsWriteAsk,
) -> (Uuid, Receiver<bool>, bool)
pub(crate) fn register_settings_write( &self, caller: &SessionSlot, ask: SettingsWriteAsk, ) -> (Uuid, Receiver<bool>, bool)
Hold a composed write and hand back its id plus the receiver the
caller awaits. Addressed to caller, so the prompt surfaces in the
session that will read the reply rather than in whichever session
happens to be focused.
Sourcepub(crate) fn settings_write_waiting(
&self,
id: Uuid,
caller: &SessionSlot,
) -> bool
pub(crate) fn settings_write_waiting( &self, id: Uuid, caller: &SessionSlot, ) -> bool
Whether a write with this id is still registered to caller, without
removing it - the read the dispatch guard makes before an answer.
Sourcepub(crate) fn resolve_settings_write(
&self,
id: Uuid,
caller: &SessionSlot,
approved: bool,
) -> bool
pub(crate) fn resolve_settings_write( &self, id: Uuid, caller: &SessionSlot, approved: bool, ) -> bool
Answer a held write with the reader’s decision, returning whether one was waiting for THIS caller. True exactly once, for the entry that was there; a write held for another session, or one already answered and still applying, is refused rather than answered.
The entry stays until the write is settled (Self::settle_settings_write):
the answer unblocks the caller, which applies the edit and then
reports the outcome, so the resolved update says what happened to the
write rather than merely that a button was pressed.
Sourcepub(crate) fn settle_settings_write(
&self,
id: Uuid,
caller: &SessionSlot,
ending: SettingsWriteEnding,
) -> bool
pub(crate) fn settle_settings_write( &self, id: Uuid, caller: &SessionSlot, ending: SettingsWriteEnding, ) -> bool
Settle an answered or abandoned write: remove the entry and emit the ending every view reads. Idempotent, because the caller’s drop guard and the answer path both reach it and only the first has an entry.
Sourcepub(crate) fn abandon_settings_writes(&self, slot: &SessionSlot)
pub(crate) fn abandon_settings_writes(&self, slot: &SessionSlot)
End every write slot still holds as abandoned.
A parked write outlives its own future. The blocked MCP handler’s future is dropped when the session’s task goes, but a detached control task is not aborted by the client’s disconnect, so the registry sweep is what tells every other view the dock is over. Worker labels are reused, and an entry left behind would draw the previous occupant’s write on the next session’s dock.
Sourcepub(crate) fn settings_write_asks(&self, slot: &SessionSlot) -> Vec<PendingAsk>
pub(crate) fn settings_write_asks(&self, slot: &SessionSlot) -> Vec<PendingAsk>
Every held write this seat owns, in the registry’s own order - a
HashMap keeps no arrival order to offer, the same exception two
Slack drafts already have. A seat with one write sees the same frame
and read; only two at once can differ between them.
Source§impl Workspace
impl Workspace
Sourcepub(crate) fn add_slack_subscription(
&self,
sub: SlackSubscription,
durable: bool,
)
pub(crate) fn add_slack_subscription( &self, sub: SlackSubscription, durable: bool, )
Register a Slack subscription in the active set. Durable ones also persist to the redb store; ephemeral ad-hoc-worker ones stay in memory only and drop on restart.
Sourcepub(crate) fn slack_delivery_seen(
&self,
project: &str,
team_role: Option<&str>,
message: &SlackMessage,
) -> bool
pub(crate) fn slack_delivery_seen( &self, project: &str, team_role: Option<&str>, message: &SlackMessage, ) -> bool
Whether this exact message has already been handed to its destination. The owner is part of the key: a lead and a worker in one project are two destinations, and one key would starve the second as “already delivered”. The edit ts is part of it too: a correction is a new delivery, while a sweep re-run is not.
Sourcepub(crate) fn slack_delivery_commit(
&self,
project: &str,
team_role: Option<&str>,
messages: &[SlackMessage],
)
pub(crate) fn slack_delivery_commit( &self, project: &str, team_role: Option<&str>, messages: &[SlackMessage], )
Record a hand-off so a sweep re-run drops it. Committed only after a successful hand-off: a failed dispatch leaves the keys unrecorded, so the next sweep re-delivers instead of losing the messages behind a cursor that moved on.
Sourcepub(crate) fn register_slack_draft(
&self,
caller: &SessionSlot,
draft: SlackDraft,
) -> (Uuid, Receiver<SlackDraftAnswer>)
pub(crate) fn register_slack_draft( &self, caller: &SessionSlot, draft: SlackDraft, ) -> (Uuid, Receiver<SlackDraftAnswer>)
Hold a composed draft and hand back its id plus the receiver the
caller awaits. The draft is addressed to caller, so its prompt
surfaces in the session that will read the reply rather than in
whichever session happens to be focused.
Sourcepub(crate) fn slack_draft_waiting(&self, id: Uuid, caller: &SessionSlot) -> bool
pub(crate) fn slack_draft_waiting(&self, id: Uuid, caller: &SessionSlot) -> bool
Whether a draft with this id is still registered to caller, without
removing it - the read the dispatch guard makes before an answer.
Sourcepub(crate) fn remove_slack_subscriptions_for_worker(
&self,
project_key: &ProjectKey,
label: &str,
)
pub(crate) fn remove_slack_subscriptions_for_worker( &self, project_key: &ProjectKey, label: &str, )
Remove every Slack subscription the worker label owns in
project_key, from the active set and the store. Worker teardown
calls this so a despawned worker cannot strand records - and so a
delivery for it does not fall through to the lead.
Sourcepub(crate) fn resolve_slack_draft(
&self,
id: Uuid,
caller: &SessionSlot,
resolution: DraftResolution,
) -> bool
pub(crate) fn resolve_slack_draft( &self, id: Uuid, caller: &SessionSlot, resolution: DraftResolution, ) -> bool
Answer a held draft, returning whether one was waiting for THIS caller. That return is also how a caller asks whether a draft is still outstanding: it is true exactly once, for the entry that was there. A draft held for another session is refused rather than answered, and a caller that has gone away is not an error.
The removal is the one point every removal a view could have parked
on funnels through - an answer, or a dead waiter - so the stand-down
is emitted here. The one other removal,
register_slack_draft’s no-answerable-subscriber path, drops a draft
the update may already have reached an observer with: a subscriber
that could ANSWER is what makes a draft answerable at all, and an
observer draws no dock to stand down.
Sourcepub(crate) fn set_slack_watermark(
&self,
workspace: &str,
conversation: &str,
ts: &str,
)
pub(crate) fn set_slack_watermark( &self, workspace: &str, conversation: &str, ts: &str, )
Record the last ts delivered for a conversation in a workspace,
through the store so it survives a restart.
Sourcepub(crate) fn record_slack_own_post(
&self,
workspace: &str,
conversation: &str,
ts: &str,
)
pub(crate) fn record_slack_own_post( &self, workspace: &str, conversation: &str, ts: &str, )
Record a message forge posted, so the sweep refuses it by identity rather than by author. forge posts as the user, so this is the only thing that tells its own words from the ones Ved typed.
Sourcepub(crate) fn slack_own_post(
&self,
workspace: &str,
conversation: &str,
ts: &str,
) -> bool
pub(crate) fn slack_own_post( &self, workspace: &str, conversation: &str, ts: &str, ) -> bool
Whether forge posted this message itself. A ledger that cannot be
read answers false: a self-post delivered once costs what a lost
ledger costs, while refusing would drop what the user wrote. The
failure is worth a line once, not once per message, because a
persistent fault would otherwise echo every post back to the agent.
Sourcepub(crate) fn begin_slack_post(
self: &Arc<Self>,
workspace: &str,
conversation: &str,
) -> SlackPostInFlight
pub(crate) fn begin_slack_post( self: &Arc<Self>, workspace: &str, conversation: &str, ) -> SlackPostInFlight
Mark one call’s post in flight until the guard drops, so a sweep that runs mid-post skips the conversation instead of delivering a message whose ts is not ledgered yet. A guard covers ONE call and its ledger write: an operation of several calls takes one per call, or a slow but healthy Slack would retire the mark mid-operation.
Sourcepub(crate) fn slack_post_in_flight(
&self,
workspace: &str,
conversation: &str,
) -> Option<Duration>
pub(crate) fn slack_post_in_flight( &self, workspace: &str, conversation: &str, ) -> Option<Duration>
How long the oldest live post call in conversation has been in
flight, or None. A stamp past the request timeout is expired
rather than obeyed, so a stalled call cannot silence the
conversation. A stamp crossing half the budget says so once, on the
transition; both are forge’s own problems, so both are warnings.
Sourcepub(crate) fn name_slack_conversation(
&self,
workspace: &str,
conversation: &str,
name: &str,
)
pub(crate) fn name_slack_conversation( &self, workspace: &str, conversation: &str, name: &str, )
Fill in the display name on this workspace’s conversation subscriptions that have none. A durable record is written through the store; an ephemeral one is named in memory only. Only unnamed records are touched: a conversation renamed since it was subscribed keeps the stored name.
Sourcepub(crate) fn slack_has_cursor(
&self,
workspace: &str,
conversation: &str,
) -> bool
pub(crate) fn slack_has_cursor( &self, workspace: &str, conversation: &str, ) -> bool
Whether the conversation (or the __mentions__ stream) has a sweep
cursor yet. The cursor belongs to the conversation and is shared by
every owner watching it, so a second subscriber seeds only when
this is false.
Sourcepub(crate) fn follow_slack_thread(
&self,
workspace: &str,
conversation: &str,
parent_ts: &str,
owner: SlackThreadOwner,
since: &str,
)
pub(crate) fn follow_slack_thread( &self, workspace: &str, conversation: &str, parent_ts: &str, owner: SlackThreadOwner, since: &str, )
Track a thread because a delivered message anchors it, owned by the
matching subscription. A new row’s cursor starts at since - the
delivered message’s own ts, not the parent’s: the parent of a
mention can be weeks old, and a cursor seeded from it would
idle-drop the thread before its first reply walk. A row that
exists keeps its cursor and gains the owner if it was not there.
Sourcefn forget_thread_clocks(
&self,
workspace: &str,
conversation: &str,
parent_ts: &str,
)
fn forget_thread_clocks( &self, workspace: &str, conversation: &str, parent_ts: &str, )
Drop a removed thread’s in-memory clocks, so a thread re-followed under the same parent starts clean.
Sourcepub(crate) fn mark_slack_thread_participating(
&self,
workspace: &str,
conversation: &str,
parent_ts: &str,
)
pub(crate) fn mark_slack_thread_participating( &self, workspace: &str, conversation: &str, parent_ts: &str, )
Record that the user is in a thread, so every later walk reads past the cursor rather than the whole thread.
Sourcefn slack_thread_idle_days(&self, workspace: &str) -> u64
fn slack_thread_idle_days(&self, workspace: &str) -> u64
The idle window this workspace drops a thread after, from its
[[slack]] entry: the followed set is per workspace, so its window
is too. A workspace whose entry is gone keeps the default.
Sourcepub(crate) fn slack_stale_after(&self, workspace: &str) -> Duration
pub(crate) fn slack_stale_after(&self, workspace: &str) -> Duration
The age a delivered block reaches before its header says so, from its
[[slack]] entry. A workspace whose entry is gone keeps the default.
Sourcepub(crate) fn slack_followed_threads(
&self,
workspace: &str,
conversation: &str,
) -> Vec<SlackFollowedThread>
pub(crate) fn slack_followed_threads( &self, workspace: &str, conversation: &str, ) -> Vec<SlackFollowedThread>
The threads tracked for a conversation, pruned while listed: an owner with no remaining subscription in the workspace is dropped from the record, a row whose last owner goes is deleted, and a row idle past the workspace’s window is dropped with a debug log.
Sourcepub(crate) fn slack_thread_watermark(
&self,
workspace: &str,
conversation: &str,
parent_ts: &str,
) -> Result<Option<String>>
pub(crate) fn slack_thread_watermark( &self, workspace: &str, conversation: &str, parent_ts: &str, ) -> Result<Option<String>>
A thread’s reply cursor, or None when the thread has none yet.
Sourcepub(crate) fn set_slack_thread_watermark(
&self,
workspace: &str,
conversation: &str,
parent_ts: &str,
ts: &str,
)
pub(crate) fn set_slack_thread_watermark( &self, workspace: &str, conversation: &str, parent_ts: &str, ts: &str, )
Advance a thread’s reply cursor, through the store so it survives a restart.
Sourcepub(crate) fn slack_thread_edit_watermark(
&self,
workspace: &str,
conversation: &str,
parent_ts: &str,
) -> Option<String>
pub(crate) fn slack_thread_edit_watermark( &self, workspace: &str, conversation: &str, parent_ts: &str, ) -> Option<String>
A thread’s newest judged edit stamp. The map is a cache; a miss reads the row, so a restart judges edits against what the last process delivered instead of re-offering the thread’s whole edit history.
Sourcepub(crate) fn set_slack_thread_edit_watermark(
&self,
workspace: &str,
conversation: &str,
parent_ts: &str,
ts: &str,
)
pub(crate) fn set_slack_thread_edit_watermark( &self, workspace: &str, conversation: &str, parent_ts: &str, ts: &str, )
Advance a thread’s judged edit stamp, through the store so a restart does not re-offer the correction it clears. The mark only moves forward: a caller that read a stale value must not roll a newer one back.
Sourcefn cache_slack_thread_edit_watermark(
&self,
key: &(String, String, String),
ts: &str,
)
fn cache_slack_thread_edit_watermark( &self, key: &(String, String, String), ts: &str, )
Cache a thread’s edit stamp, moving it forward only.
Sourcepub(crate) fn auto_subscribe_slack_conversation(
&self,
workspace: &str,
conversation: &str,
since: &str,
name: Option<&str>,
) -> bool
pub(crate) fn auto_subscribe_slack_conversation( &self, workspace: &str, conversation: &str, since: &str, name: Option<&str>, ) -> bool
Watch conversation because a mention arrived in it, owned by
whoever subscribes to mentions in that workspace - not the lead,
and not whichever session happens to be active. All mode is
deliberate: pulled into a conversation by a mention, the agent
should see what is said next rather than only the next mention.
The cursor starts at since (the mention that pulled us in), so
the channel’s history is not swept. Returns whether a record was
added. A label that is empty or the conversation id itself (the
search path’s fallback) stores as no name.
Sourcepub fn slack_connected_workspaces(&self) -> BTreeMap<String, bool>
pub fn slack_connected_workspaces(&self) -> BTreeMap<String, bool>
Per-workspace pump liveness, for the Inspector’s SLACK section. One entry per workspace a pump has reported on.
Sourcepub(crate) fn watch_slack_conversation(
&self,
workspace: &str,
project: &str,
team_role: Option<&str>,
conversation: &str,
mode: SlackWatchMode,
durable: bool,
) -> SlackSubscription
pub(crate) fn watch_slack_conversation( &self, workspace: &str, project: &str, team_role: Option<&str>, conversation: &str, mode: SlackWatchMode, durable: bool, ) -> SlackSubscription
Watch a conversation for this owner, handing back the record that already exists when there is one - a second record renders as a second Inspector row for one channel. A watch whose mode differs updates that record, since the caller asked for something it did not have and there is no other route to change a mode. The check and the write share one guard, so two subscribes racing cannot both create.
Sourcepub fn slack_subscriptions_for_project(
&self,
project: &str,
) -> Vec<SlackSubscription>
pub fn slack_subscriptions_for_project( &self, project: &str, ) -> Vec<SlackSubscription>
Every Slack subscription owned in project, whichever session
owns it. Backs the pump, which needs the whole set.
Sourcepub(crate) fn remove_slack_subscription_owned_by(
&self,
project: &str,
id: Uuid,
owner: Option<&str>,
) -> Option<SlackSubscription>
pub(crate) fn remove_slack_subscription_owned_by( &self, project: &str, id: Uuid, owner: Option<&str>, ) -> Option<SlackSubscription>
Remove the subscription id in project only when its owner
matches owner (None = a lead subscription, Some(label) =
that worker’s), from both the active set and the redb store.
Returns the row as it stood (None when nothing matched). Backs
the owner-scoped slack__unsubscribe so a caller removes only
what it subscribed.
Sourcepub(crate) fn clear_slack_cursors_after_removal(
&self,
removed: &SlackSubscription,
)
pub(crate) fn clear_slack_cursors_after_removal( &self, removed: &SlackSubscription, )
Clear the sweep cursors the removed subscription was the last cover for. “Has cursor” must mean a live pending window: a cursor left behind by the last unsubscribe replays the whole gap to a later subscriber as fresh deliveries, and the mention cursor has no baseline arm at all, so its clearing is what makes the re-subscribe seed fresh.
Sourcefn prune_slack_threads(&self, workspace: &str)
fn prune_slack_threads(&self, workspace: &str)
Prune every followed thread in workspace against the same
predicate the sweep’s listing uses: an owner survives only while
it holds ANY remaining subscription in the workspace. Runs when a
subscription is removed, so a thread whose conversation is no
longer swept does not freeze until some sweep happens to list it -
Self::slack_followed_threads re-prunes as a backstop. The
surviving set decides because an owner key removed with one
subscription may still hold others: a Mentions target anchors
threads in every conversation its mentions touched.
Sourcepub fn start_slack_verification(self: &Arc<Self>)
pub fn start_slack_verification(self: &Arc<Self>)
Prove each workspace’s token once, logging the team it belongs to
or the failure. Idempotent, and a no-op with no [[slack]] entry.
Sourcepub fn slack_subscription_load_failed(&self) -> bool
pub fn slack_subscription_load_failed(&self) -> bool
Whether boot could not read the durable Slack subscriptions.
Sourcepub(crate) fn retry_slack_user_id(self: &Arc<Self>, label: &str)
pub(crate) fn retry_slack_user_id(self: &Arc<Self>, label: &str)
Re-prove one workspace’s token in the background, so a boot-time auth failure heals itself instead of leaving the mention stream down until restart. Rate-limited to one attempt a minute per workspace; the caller is the host’s user-id miss.
Sourcepub fn start_slack_subsystem(self: &Arc<Self>)
pub fn start_slack_subsystem(self: &Arc<Self>)
Start one pump per configured workspace that has at least one
subscription. Idempotent per workspace, and a no-op with no
[[slack]] entry.
Sourcepub fn stop_slack_subsystem_if_idle(&self)
pub fn stop_slack_subsystem_if_idle(&self)
Stop the Slack pump for any workspace that no longer has a subscription, and mark it disconnected. Pumps are per workspace, so one workspace losing its subscriptions must not keep another’s pump alive - nor leave its own running with nothing to watch.
Source§impl Workspace
impl Workspace
Sourcepub(crate) fn with_tasks_mut<R>(&self, f: impl FnOnce(&mut Vec<Task>) -> R) -> R
pub(crate) fn with_tasks_mut<R>(&self, f: impl FnOnce(&mut Vec<Task>) -> R) -> R
Lock the task list and apply f. Every mutation routes through here
to take the lock, and the mutation site persists what it changed from
inside its own closure - a row at a time, never the whole in-memory
set. A boot read that failed leaves that set EMPTY while the db
handle stays live, so a whole-set write would erase every durable
row the read never loaded.
Sourcefn persist_task(&self, task: &Task)
fn persist_task(&self, task: &Task)
Persist one task row, replacing any prior record for its id. Called from inside the mutation’s lock.
Sourcefn delete_stored_task(&self, project_name: &str, id: &TaskId)
fn delete_stored_task(&self, project_name: &str, id: &TaskId)
Delete one task row by project and id. Called from inside the mutation’s lock.
Sourcepub(crate) fn resolve_task_id(
&self,
project_name: &str,
stated: &TaskId,
) -> Result<TaskId, IdError>
pub(crate) fn resolve_task_id( &self, project_name: &str, stated: &TaskId, ) -> Result<TaskId, IdError>
Resolve a caller-stated id to the row it names: an exact id, or a unique prefix of one. Backs the delete path, which resolves before it removes so an id naming nothing still removes nothing.
Sourcepub(crate) fn task_project_outside(
&self,
project_name: &str,
stated: &TaskId,
) -> Option<String>
pub(crate) fn task_project_outside( &self, project_name: &str, stated: &TaskId, ) -> Option<String>
The project holding a row whose exact id is stated, when that row
is outside project_name. Backs the refusal that keeps an id
another project owns apart from one no project has; a stated prefix
resolves only inside the caller’s own project, so only a full id is
classified here.
Sourcepub(crate) fn push_task(&self, task: Task)
pub(crate) fn push_task(&self, task: Task)
Append a task and persist, recording its creation in the history.
Backs tasks__create.
Sourcepub(crate) fn update_task(
&self,
project_name: &str,
id: &TaskId,
by: By,
f: impl FnOnce(&mut Task),
) -> Result<Option<Task>, MoveError>
pub(crate) fn update_task( &self, project_name: &str, id: &TaskId, by: By, f: impl FnOnce(&mut Task), ) -> Result<Option<Task>, MoveError>
Apply f to the task id in project_name, persist, record the
transition, and return the record as the write left it (None
when no such task is there). Open to every session in the project.
Rules ride on top of f, so no caller can forget them: a
transition into completed on a row minted verify=user lands in
waiting (the verify gate, never inherited from the epic); a
patch cannot take a user-bound row’s look gate away - clearing its
verify is refused by name; a completion of a row already waiting on
the user’s look is refused by name; a cancel of a row waiting on
the user is the withdraw door - it records the reason the caller
states in detail, and a cancel without one is refused (#2340);
a move off waiting drops the wait the row was holding, so a
completion of any other waiting row applies and clears - except on
a verify=user row, where a seat’s completion is refused by name
while the row waits: the gate would trade its wait for a look park
and answer with the status unchanged (#2414). The user’s own move
off waiting to completed keeps that path and parks on the look;
and completing a ROOT row closes it - the whole tree moves to the
archive, refused while any child is still open or while any row in
the tree is parked on the user (#2251).
Sourcefn move_task(
&self,
project_name: &str,
id: &TaskId,
by: By,
gate: bool,
f: impl FnOnce(&mut Task) -> Result<(), MoveError>,
) -> Result<Option<Task>, MoveError>
fn move_task( &self, project_name: &str, id: &TaskId, by: By, gate: bool, f: impl FnOnce(&mut Task) -> Result<(), MoveError>, ) -> Result<Option<Task>, MoveError>
The one commit path every task move runs through. gate is the
verify gate: it fires on a transition into completed, and the
user’s own approval is the one move that passes it (approving IS
the verification). f may refuse before anything moves.
Sourcepub(crate) fn user_move_task(
&self,
project_name: &str,
id: &TaskId,
to: TaskStatus,
) -> Result<Option<Task>, MoveError>
pub(crate) fn user_move_task( &self, project_name: &str, id: &TaskId, to: TaskStatus, ) -> Result<Option<Task>, MoveError>
The user moves a row’s state directly - the drag across the board.
Any wait the row was holding drops with the move, and the verify
gate holds for the user’s own drag too: a drag into completed on a
verify=user row lands in waiting for a look, not done.
Sourcepub(crate) fn attest_task(
&self,
project_name: &str,
id: &TaskId,
slot: &SessionSlot,
evidence: &str,
) -> Result<Option<Task>, MoveError>
pub(crate) fn attest_task( &self, project_name: &str, id: &TaskId, slot: &SessionSlot, evidence: &str, ) -> Result<Option<Task>, MoveError>
The lead completes a row waiting on the user’s look, recording the approval the user gave. Only a lead attests, only a look wait is the look’s to clear (a decision is the user’s to answer), and the quoted evidence is required. The row completes as the user’s own approve would, wearing the attestation the board draws.
Sourcepub(crate) fn reserve_task(
&self,
project_name: &str,
id: &TaskId,
reserved: bool,
) -> Result<Option<Task>, MoveError>
pub(crate) fn reserve_task( &self, project_name: &str, id: &TaskId, reserved: bool, ) -> Result<Option<Task>, MoveError>
The user reserves a row’s pick (or releases it): the sweep leaves a reserved row for them rather than dispatching it.
Sourcepub(crate) fn reopen_task(
&self,
project_name: &str,
id: &TaskId,
) -> Result<Option<Task>, MoveError>
pub(crate) fn reopen_task( &self, project_name: &str, id: &TaskId, ) -> Result<Option<Task>, MoveError>
The user strikes a lead attestation: the look gate is re-minted and the row parks on the user again, recorded in the history.
Sourcepub(crate) fn approve_task(
&self,
project_name: &str,
id: &TaskId,
) -> Result<Option<Task>, MoveError>
pub(crate) fn approve_task( &self, project_name: &str, id: &TaskId, ) -> Result<Option<Task>, MoveError>
The user’s approval of a row waiting on their look: the wait clears, the verify gate is satisfied, and the row completes - a root closing its tree like any completion.
Sourcepub(crate) fn send_back_task(
&self,
project_name: &str,
id: &TaskId,
words: &str,
) -> Result<Option<Task>, MoveError>
pub(crate) fn send_back_task( &self, project_name: &str, id: &TaskId, words: &str, ) -> Result<Option<Task>, MoveError>
The user sends a verified row back: it returns to its owner in progress, the words land in the detail, and the attempt ticks.
Sourcepub(crate) fn answer_task(
&self,
project_name: &str,
id: &TaskId,
words: &str,
) -> Result<Option<Task>, MoveError>
pub(crate) fn answer_task( &self, project_name: &str, id: &TaskId, words: &str, ) -> Result<Option<Task>, MoveError>
The user answers a row waiting on a question: the row resumes in progress with the words in its detail.
Sourcepub(crate) fn rank_task(
&self,
project_name: &str,
id: &TaskId,
to: RankMove,
) -> Result<Option<(Task, bool)>, MoveError>
pub(crate) fn rank_task( &self, project_name: &str, id: &TaskId, to: RankMove, ) -> Result<Option<(Task, bool)>, MoveError>
Move a row in its queue: Top above everything, Up and Down
one place, and Before where a drag landed - directly above the row
it was dropped on, or the end of the queue when it names none. The
whole queue is renumbered in one write, so the order a reader sees is
the order the next read returns. The bool says whether the row’s
place changed: a pick-up-and-put-back lands the same position.
Sourcepub(crate) fn assign_task(
&self,
project_name: &str,
id: &TaskId,
owner: Option<SessionSlot>,
) -> Result<Option<(Task, Option<SessionSlot>)>, MoveError>
pub(crate) fn assign_task( &self, project_name: &str, id: &TaskId, owner: Option<SessionSlot>, ) -> Result<Option<(Task, Option<SessionSlot>)>, MoveError>
Give a row an owner, or take it back. The returned record carries the owner it had before, read under the same lock as the write so a concurrent claim cannot slip between the two.
Sourcepub(crate) fn claim_task(
&self,
project_name: &str,
slot: &SessionSlot,
by_id: Option<&TaskId>,
in_epic: Option<&TaskId>,
) -> Result<Task, ClaimError>
pub(crate) fn claim_task( &self, project_name: &str, slot: &SessionSlot, by_id: Option<&TaskId>, in_epic: Option<&TaskId>, ) -> Result<Task, ClaimError>
Claim the top of a queue: the row by_id, or the first ready row
of in_epic by rank. Owner and in_progress land in one write, the
attempt ticks, and the claim is refused - by name - for a row a
live seat holds, a row that is waiting, or a caller already
working another row.
Sourcepub(crate) fn wait_task(
&self,
project_name: &str,
id: &TaskId,
slot: &SessionSlot,
kind: WaitingKind,
detail: Option<String>,
on: Option<&TaskId>,
) -> Result<Task, MoveError>
pub(crate) fn wait_task( &self, project_name: &str, id: &TaskId, slot: &SessionSlot, kind: WaitingKind, detail: Option<String>, on: Option<&TaskId>, ) -> Result<Task, MoveError>
Put a row the caller owns into waiting, stating what it waits on.
Sourcepub(crate) fn resolve_wait_on(
&self,
project_name: &str,
blocker: &TaskId,
) -> Vec<Task>
pub(crate) fn resolve_wait_on( &self, project_name: &str, blocker: &TaskId, ) -> Vec<Task>
Clear every wait on blocker - any terminal end of it counts, and
so does its deletion - and hand the released rows back so the
caller can message their owners.
Sourcepub(crate) fn seat_is_live(&self, slot: &SessionSlot) -> bool
pub(crate) fn seat_is_live(&self, slot: &SessionSlot) -> bool
Whether a seat has something behind it right now: a registered domain or agent, or a live worker registry row.
Sourcefn record_transition(
&self,
project_name: &str,
id: &TaskId,
from: Option<TaskStatus>,
to: TaskStatus,
by: By,
)
fn record_transition( &self, project_name: &str, id: &TaskId, from: Option<TaskStatus>, to: TaskStatus, by: By, )
Append one status transition to the durable history.
Sourcepub(crate) fn remove_task_tree(
&self,
project_name: &str,
id: &TaskId,
) -> Result<Vec<Task>, MoveError>
pub(crate) fn remove_task_tree( &self, project_name: &str, id: &TaskId, ) -> Result<Vec<Task>, MoveError>
Remove the task id in project_name together with every
descendant, persist, and return the records removed (empty when
nothing was). Backs tasks__delete. The cascade happens here
rather than at the call site so a parent can never be deleted out
from under children that would then have nobody to close them.
A tree holding a row waiting on the user - a verification park or a decision wait - is refused by name (#2251): removal would settle that row by deletion, an ask the update path refuses to unbind, complete, or drop without a reason. The check walks the whole cascade, so deleting the epic above the row is no way around it.
Sourcepub(crate) fn announce_tasks_changed(&self, project_name: &str)
pub(crate) fn announce_tasks_changed(&self, project_name: &str)
Tell every view the project’s task set moved, as the set the write just left. It routes on the project’s lead seat, which is the seat a project-scoped section belongs to; a name no project carries has no seat to route on and announces nothing.
Sourcepub fn tasks_for_project(&self, project_name: &str) -> Vec<Task>
pub fn tasks_for_project(&self, project_name: &str) -> Vec<Task>
The tasks registered for project_name. Backs tasks__list and
the Inspector TASKS snapshot.
Source§impl Workspace
impl Workspace
Sourcepub fn seed_test_task(&self, task: Task)
pub fn seed_test_task(&self, task: Task)
Register a task directly, bypassing the MCP create path.
Cross-crate test access to the otherwise pub(crate) task store
so a view can exercise the Inspector’s refresh_tasks
resolution against a seeded task.
The store write without the announcement: a fixture seeds state, it does not perform the write a view is owed a frame for.
Source§impl Workspace
impl Workspace
Sourcepub(crate) fn terminal_for(
self: &Arc<Self>,
slot: &SessionSlot,
) -> Result<Arc<TerminalEntry>, String>
pub(crate) fn terminal_for( self: &Arc<Self>, slot: &SessionSlot, ) -> Result<Arc<TerminalEntry>, String>
The seat’s terminal, spawned on first use.
One per seat, and the slot is the key. A /new or /resume
swapping the occupant never reaches here, which is what makes the PTY
outlive it.
Sourcepub(crate) fn spawn_terminal_at(
self: &Arc<Self>,
slot: &SessionSlot,
cwd: Option<PathBuf>,
) -> Result<Arc<TerminalEntry>, String>
pub(crate) fn spawn_terminal_at( self: &Arc<Self>, slot: &SessionSlot, cwd: Option<PathBuf>, ) -> Result<Arc<TerminalEntry>, String>
The seat’s terminal, spawned in cwd the first time; an existing
terminal is returned as it is.
A shell that exited without a close is replaced, so the seat’s next open starts a fresh shell rather than handing back a dead PTY.
Sourcepub fn open_terminal(
self: &Arc<Self>,
slot: &SessionSlot,
) -> Result<Arc<TerminalEntry>, String>
pub fn open_terminal( self: &Arc<Self>, slot: &SessionSlot, ) -> Result<Arc<TerminalEntry>, String>
The seat’s terminal, spawned on first use. The person’s door: a view can open one on a seat no agent has touched.
The door follows the seat read. A declared seat answers a read before anything starts in it (its directory resolves from the declaration), so its door opens and the terminal it starts appears in the record. A seat with no working tree - a worker never spawned, or a slot naming no project - has its read refused, and the door refuses with the same sentence rather than starting a shell nobody can draw.
Sourcepub(crate) fn spawn_terminal_for_run(
&self,
slot: &SessionSlot,
cwd: Option<PathBuf>,
) -> Result<Arc<TerminalEntry>, String>
pub(crate) fn spawn_terminal_for_run( &self, slot: &SessionSlot, cwd: Option<PathBuf>, ) -> Result<Arc<TerminalEntry>, String>
A fresh terminal for terminal__run, not registered: the command’s
own terminal, closed when the call returns.
Sourcepub fn terminal_if_any(&self, slot: &SessionSlot) -> Option<Arc<TerminalEntry>>
pub fn terminal_if_any(&self, slot: &SessionSlot) -> Option<Arc<TerminalEntry>>
The seat’s terminal, when it has one.
Sourcepub fn terminal_by_id(
&self,
slot: &SessionSlot,
id: u16,
) -> Result<Arc<TerminalEntry>, String>
pub fn terminal_by_id( &self, slot: &SessionSlot, id: u16, ) -> Result<Arc<TerminalEntry>, String>
The seat’s terminal, refused unless id names it.
Sourcepub fn terminal_held(&self, slot: &SessionSlot) -> bool
pub fn terminal_held(&self, slot: &SessionSlot) -> bool
Whether a terminal hand-off is parked on the seat.
Sourcepub fn terminal_controlled(&self, slot: &SessionSlot) -> bool
pub fn terminal_controlled(&self, slot: &SessionSlot) -> bool
Whether the person holds the seat’s terminal through a client’s take-control, rather than through an agent’s hand-off.
Sourcepub fn take_terminal_control(
&self,
slot: &SessionSlot,
id: u16,
viewer: u64,
) -> Result<(), String>
pub fn take_terminal_control( &self, slot: &SessionSlot, id: u16, viewer: u64, ) -> Result<(), String>
The person’s own take: always explicit, and always landing.
A take is allowed while a hand-off parks, because a park opens the surface rather than handing out the keyboard: the person reads it, takes control, and types. The id is the terminal the caller is viewing: a replaced shell has a new id, and a take that landed on it would hand a role no push could reach.
Sourcepub fn close_terminal(&self, slot: &SessionSlot)
pub fn close_terminal(&self, slot: &SessionSlot)
Close the seat’s terminal, if it has one.
Sourcepub(crate) fn sweep_idle_terminals(&self)
pub(crate) fn sweep_idle_terminals(&self)
Close every terminal an idle clock may close: untouched by any agent call, unattached, and unheld by a hand-off.
Sourcepub(crate) fn announce_terminal(
&self,
slot: &SessionSlot,
terminal: Option<TerminalSummary>,
)
pub(crate) fn announce_terminal( &self, slot: &SessionSlot, terminal: Option<TerminalSummary>, )
Tell the views a terminal opened, changed or closed.
Sourcepub(crate) fn register_terminal_hand_off(
&self,
caller: &SessionSlot,
terminal: u16,
reason: &str,
) -> Result<(Uuid, Receiver<HandOffEnding>), String>
pub(crate) fn register_terminal_hand_off( &self, caller: &SessionSlot, terminal: u16, reason: &str, ) -> Result<(Uuid, Receiver<HandOffEnding>), String>
Hold a terminal hand-off for the person at a client, and hand back its id plus the receiver the caller awaits.
No timeout rides the wait. The ask sits until an answer or the caller’s own drop. The one case answered up front is a stream nobody can answer on, which fails closed rather than parking the session on a prompt no view can draw.
Sourcepub(crate) fn terminal_handoff_waiting(
&self,
id: Uuid,
caller: &SessionSlot,
) -> bool
pub(crate) fn terminal_handoff_waiting( &self, id: Uuid, caller: &SessionSlot, ) -> bool
Whether a hand-off with this id is still registered to caller, without
removing it.
Sourcepub(crate) fn resolve_terminal_hand_off(
&self,
id: Uuid,
caller: &SessionSlot,
answer: TerminalHandOffAnswer,
) -> bool
pub(crate) fn resolve_terminal_hand_off( &self, id: Uuid, caller: &SessionSlot, answer: TerminalHandOffAnswer, ) -> bool
Remove a parked hand-off, answer its waiter, and tell every view it is
gone. false when the id is not this caller’s to resolve.
Sourcepub(crate) fn abandon_terminal_hand_offs(&self, slot: &SessionSlot)
pub(crate) fn abandon_terminal_hand_offs(&self, slot: &SessionSlot)
A seat ended - or /new//resume replaced the occupant that was
waiting - with a hand-off still parked: resolve it as abandoned so no
dock outlives its asker.
Sourcefn spawn_terminal(
&self,
slot: &SessionSlot,
cwd: Option<PathBuf>,
) -> Result<Arc<TerminalEntry>, String>
fn spawn_terminal( &self, slot: &SessionSlot, cwd: Option<PathBuf>, ) -> Result<Arc<TerminalEntry>, String>
Spawn a terminal for slot, with its cwd and the environment its
claude child gets.
Sourcefn watch_terminal_exit(
self: &Arc<Self>,
slot: &SessionSlot,
entry: &Arc<TerminalEntry>,
)
fn watch_terminal_exit( self: &Arc<Self>, slot: &SessionSlot, entry: &Arc<TerminalEntry>, )
Tell the strip when the shell exits, so “the shell exited” is drawn without waiting for a record read.
Sourcefn watch_terminal_state(
self: &Arc<Self>,
slot: &SessionSlot,
entry: &Arc<TerminalEntry>,
)
fn watch_terminal_state( self: &Arc<Self>, slot: &SessionSlot, entry: &Arc<TerminalEntry>, )
A state change is a push, not a poll. The strip’s words - a command running, a password wanted - come from the record, and a record that only changed on open, close and exit would say “idle” through a whole command.
Sourcefn ensure_terminal_clock(self: &Arc<Self>)
fn ensure_terminal_clock(self: &Arc<Self>)
Start the idle clock, once. The registry makes a second call a no-op.
Source§impl Workspace
impl Workspace
Sourcepub fn speaking_installs(&self) -> Vec<SpeakingInstall>
pub fn speaking_installs(&self) -> Vec<SpeakingInstall>
Every speaking install this machine has recorded.
Sourcepub fn speaking_install(&self, variant: &str) -> Option<SpeakingInstall>
pub fn speaking_install(&self, variant: &str) -> Option<SpeakingInstall>
One installed variant, when this machine has it.
Sourcepub fn speaking_variant(&self) -> (SpeakingVariant, SpeakingFrom)
pub fn speaking_variant(&self) -> (SpeakingVariant, SpeakingFrom)
The variant the role runs: the configured name, else the runtime pick, else the compiled pin.
Sourcepub fn speaking_files(&self) -> Option<ModelFiles>
pub fn speaking_files(&self) -> Option<ModelFiles>
The files the active variant loads, when this machine has them.
Sourcepub fn files_for_install(&self, install: &SpeakingInstall) -> Option<ModelFiles>
pub fn files_for_install(&self, install: &SpeakingInstall) -> Option<ModelFiles>
The files one installed variant is made of.
Source§impl Workspace
impl Workspace
Sourcepub(crate) fn vault_enrolment_material(&self) -> Option<VaultReleased>
pub(crate) fn vault_enrolment_material(&self) -> Option<VaultReleased>
The material a client wraps to enrol this device, from the shared
session - or None when it is locked or no [vault] is configured.
Sourcepub fn vault_state(&self) -> Option<VaultState>
pub fn vault_state(&self) -> Option<VaultState>
The shared vault session as a view draws it - None when no [vault]
is configured.
Source§impl Workspace
impl Workspace
Sourcepub(crate) fn register_vault_ask(
&self,
caller: &SessionSlot,
draft: VaultDraft,
needs_unlock: bool,
) -> (Uuid, Receiver<VaultAskAnswer>, bool)
pub(crate) fn register_vault_ask( &self, caller: &SessionSlot, draft: VaultDraft, needs_unlock: bool, ) -> (Uuid, Receiver<VaultAskAnswer>, bool)
Hold a vault call and hand back its id plus the receiver the caller
awaits. Addressed to caller, so the card surfaces in the session
that will read the reply rather than in whichever session is focused.
needs_unlock is the live session state at park time, used only for
the announcement; every later read restamps it.
Sourcepub(crate) fn vault_ask_waiting(&self, id: Uuid, caller: &SessionSlot) -> bool
pub(crate) fn vault_ask_waiting(&self, id: Uuid, caller: &SessionSlot) -> bool
Whether a vault call with this id is still registered to caller,
without removing it - the read the dispatch guard makes first.
Sourcepub(crate) fn resolve_vault_ask(
&self,
id: Uuid,
caller: &SessionSlot,
answer: VaultAskAnswer,
) -> bool
pub(crate) fn resolve_vault_ask( &self, id: Uuid, caller: &SessionSlot, answer: VaultAskAnswer, ) -> bool
Answer a held vault call, returning whether one was waiting for THIS caller. True exactly once, for the entry that was there; a call held for another session is refused rather than answered.
Sourcepub(crate) fn settle_vault_ask(
&self,
id: Uuid,
caller: &SessionSlot,
ending: VaultAskEnding,
) -> bool
pub(crate) fn settle_vault_ask( &self, id: Uuid, caller: &SessionSlot, ending: VaultAskEnding, ) -> bool
Settle an answered or abandoned call: remove the entry and emit the ending every view reads. Idempotent, because the caller’s drop guard and the answer path both reach it and only the first has an entry.
Sourcepub(crate) fn abandon_vault_asks(&self, slot: &SessionSlot)
pub(crate) fn abandon_vault_asks(&self, slot: &SessionSlot)
End every vault call slot still holds as abandoned.
A parked call outlives its own future. The blocked MCP handler’s future is dropped when the session’s task goes, but a detached control task is not aborted by the client’s disconnect, so the registry sweep is what tells every other view the dock is over.
Sourcepub(crate) fn rebase_vault_asks(&self, needs_unlock: bool)
pub(crate) fn rebase_vault_asks(&self, needs_unlock: bool)
Re-stamp every call still parked after the shared session’s lock state
changed: each is now an ordinary approve card when needs_unlock is
false, and asks for the password again when it is true. A fold that
already holds that id merges the restamped frame in place instead of
ignoring it. The answered call is already settled and gone from the
registry.
Sourcepub(crate) fn vault_asks(
&self,
slot: &SessionSlot,
needs_unlock: bool,
) -> Vec<PendingAsk>
pub(crate) fn vault_asks( &self, slot: &SessionSlot, needs_unlock: bool, ) -> Vec<PendingAsk>
Every held vault call this seat owns, stamped with the live session’s
lock state. A HashMap keeps no arrival order to offer, the same
exception the settings writes already have.
Source§impl Workspace
impl Workspace
Sourcepub(crate) fn voice_service(self: &Arc<Self>) -> Arc<VoiceService> ⓘ
pub(crate) fn voice_service(self: &Arc<Self>) -> Arc<VoiceService> ⓘ
The voice service, built on first use - the boot prewarm is the first
caller when [tts] is on. Its sink and note dispatch hold a
Weak<Workspace>, which is why this waits for an Arc<Self> rather
than being built with the rest of the value.
Sourcefn load_speaking_engine(&self) -> Result<Arc<dyn Engine>, String>
fn load_speaking_engine(&self) -> Result<Arc<dyn Engine>, String>
Load the speaking engine for the active variant.
The configured voice is checked against the installed set before the model loads, so a bad id is refused by name at warm rather than silently replaced by the engine’s own single-voice fallback.
Sourcepub fn start_voice_prewarm(self: &Arc<Self>)
pub fn start_voice_prewarm(self: &Arc<Self>)
Load the speaking engine beside boot, so the first say pays no
model load. One task per forge run, spawned rather than awaited; a
failed load leaves the slot empty, which the first say fills.
The slot is only ever filled, never emptied: a loaded engine is never
taken away, and a say that arrives while this load is still running
waits on the same lock rather than answering SayError::NotReady.
Sourcepub(crate) fn voice_enabled(&self) -> bool
pub(crate) fn voice_enabled(&self) -> bool
Whether speech is on, as the running system answers it: the built service’s own flag when it exists, else the config that would build one. A save marked restart cannot move one side without the other.
Sourcepub fn voice_listeners(&self, seat: &SessionSlot) -> usize
pub fn voice_listeners(&self, seat: &SessionSlot) -> usize
The clients with voice on for seat. Zero before the service has
been built, which is the truth: nobody could have toggled it yet.
Sourcepub fn voice_occupant_changed(&self, seat: &SessionSlot)
pub fn voice_occupant_changed(&self, seat: &SessionSlot)
The seat’s occupant changed under a listening client: tell the new session it is heard. A no-op before the service exists, because nobody could have toggled voice on.
Sourcepub fn voice_release(&self, client: u64) -> Vec<SessionSlot>
pub fn voice_release(&self, client: u64) -> Vec<SessionSlot>
A connection has left: take it off every seat it was listening to and tell each seat’s subscribers the count it left behind.
Sourcepub fn install_voice_engine_for_test(self: &Arc<Self>, engine: Arc<dyn Engine>)
pub fn install_voice_engine_for_test(self: &Arc<Self>, engine: Arc<dyn Engine>)
Put an already-built engine behind the service, so a test can drive a render without loading a model.
Sourcepub async fn voice_say_for_test(
self: &Arc<Self>,
seat: &SessionSlot,
text: String,
) -> Result<(), SayError>
pub async fn voice_say_for_test( self: &Arc<Self>, seat: &SessionSlot, text: String, ) -> Result<(), SayError>
Speak one utterance the way voice__say does, so a cross-crate test
can drive a render without an agent calling the tool.
fn build_voice_service( self: &Arc<Self>, loader: Arc<dyn Fn() -> Result<Arc<dyn Engine>, String> + Send + Sync>, ) -> VoiceService
Source§impl Workspace
impl Workspace
Sourcepub fn hold_seat(self: &Arc<Self>, slot: &SessionSlot)
pub fn hold_seat(self: &Arc<Self>, slot: &SessionSlot)
Show slot, which is what starts its working tree being watched.
A hold is what “somebody is looking” means, and it is what keeps an idle seat from being scanned at all.
The hold does not wait for its reads. The hold starts the seat’s watch and answers; the record’s own read follows it straight away (the subscribe path encodes from the stores), and the watch’s first pass starts from what that read left. Those reads ran before the hold answered until #2506, so a warm open waited on every one of them.
The caller owes the read’s mark. Self::note_record_read is what
releases the watch’s first pass; a hold without it has a watch that
waits and scans nothing.
A held seat waits for its session. A seat with no session has no store for a read, so the watch’s own loop goes quiet on it and wakes when the session appears - the same rule that covers a session ending under a running loop.
What the old refusal cost was the viewer that never comes back. A
subscribe to a sessionless seat was refused its hold, and most viewers
recovered by accident: a session starting is a REPLACES frame, the
reread around it unsubscribe/subscribes, and the next subscribe took
the hold - the work_watch_quiet / session_gone line in the live log
is one of those later holds. A viewer that nothing drove to
re-subscribe had no watch for as long as it showed the seat, and its
pushed rows stood still; the hold is unconditional now, so the FIRST
subscribe is the one that counts (#1706).
Sourcepub fn note_record_read(&self, slot: &SessionSlot)
pub fn note_record_read(&self, slot: &SessionSlot)
Mark the seat’s record read: the snapshot has taken the stores, so the watch’s first pass may start from them. The subscribe path calls this once its record is built; a seat nobody encodes has no watch reading.
Sourcepub(crate) fn note_repository_activity(&self, slot: &SessionSlot)
pub(crate) fn note_repository_activity(&self, slot: &SessionSlot)
The session reported a repository mutation. Re-read the tree on the
frame rather than at the next staleness window: a commit or a stage
touches only .git, which the file watch filters out, so nothing else
would notice for up to SNAPSHOT_STALENESS.
Sourcepub(crate) fn note_code_change_published(&self, slot: &SessionSlot)
pub(crate) fn note_code_change_published(&self, slot: &SessionSlot)
The session reported a published change. The held PR block was looked up before the PR existed and its cache window would serve that answer, so the next pass looks again.
Sourcefn take_pr_stale(&self, slot: &SessionSlot) -> bool
fn take_pr_stale(&self, slot: &SessionSlot) -> bool
Whether a nudge asked for a PR lookup, clearing the mark.
Sourceasync fn pass_test_scan_gate(&self)
async fn pass_test_scan_gate(&self)
Wait at the fixture’s gate when one is seeded. Test-only: production reads run as they always do.
Sourcepub fn release_seat(&self, slot: &SessionSlot)
pub fn release_seat(&self, slot: &SessionSlot)
Stop showing slot. The last hold stops its loop and its watch.
Sourcepub fn work_snapshot(&self, slot: &SessionSlot) -> Option<WorkSnapshot>
pub fn work_snapshot(&self, slot: &SessionSlot) -> Option<WorkSnapshot>
The seat’s scan, when one has been taken.
Sourcepub(crate) async fn walk_processes_if_stale(
&self,
slot: &SessionSlot,
pid: Option<u32>,
) -> bool
pub(crate) async fn walk_processes_if_stale( &self, slot: &SessionSlot, pid: Option<u32>, ) -> bool
Walk slot’s process tree when the snapshot held is missing or older
than SCAN_STALENESS, and store what the walk found.
The held seat’s own loop is the walker - the socket’s reads used to walk the seat a client read. This is that role moved to the loop, so a seat nobody holds is not walked at all and the walk costs what a held seat’s tree costs rather than a whole record per read.
The session’s live backgrounded local_bash commands ride along, from
the registry the core holds: a backgrounded bash is setsid-detached
and sits outside claude’s tree, so without them the walk misses the
very processes the feed leads with.
Sourcepub(crate) async fn walk_files_if_stale(
&self,
slot: &SessionSlot,
dirty: bool,
) -> bool
pub(crate) async fn walk_files_if_stale( &self, slot: &SessionSlot, dirty: bool, ) -> bool
Walk slot’s tree into a file index when the one held is missing, the
tree moved, or the answer is past INDEX_STALENESS, and store what
the walk found.
The held seat’s own loop is the walker, the role the process walk took: the socket’s reads used to walk their seat, and this walks only the seats somebody is showing. The walk honours the user’s own gitignore preference, read per walk so a flip reaches the next one.
The record’s read takes the same gate, so a snapshot’s first walk and the loop’s first pass are one walk rather than two of the same tree at once.
Sourceasync fn walk_files_locked(&self, slot: &SessionSlot, dirty: bool) -> bool
async fn walk_files_locked(&self, slot: &SessionSlot, dirty: bool) -> bool
Self::walk_files_if_stale with the seat’s gate already held.
Sourcepub async fn walk_files_for_record(&self, slot: &SessionSlot) -> Arc<FileIndex> ⓘ
pub async fn walk_files_for_record(&self, slot: &SessionSlot) -> Arc<FileIndex> ⓘ
The record’s walk of slot’s tree: the store when there is one, else
the walk the loop would take, under the same gate - so the snapshot’s
fallback and the first pass never walk at once. The pass walks again
only when the tree moved.
Sourceasync fn walk_files_now(&self, slot: &SessionSlot) -> Option<Arc<FileIndex>>
async fn walk_files_now(&self, slot: &SessionSlot) -> Option<Arc<FileIndex>>
Walk slot’s tree now and store it, answering what it found. A seat
with no session has nowhere to keep it, and the walk is answered
anyway - a record still draws the tree it stands in.
Sourcepub fn store_file_index(&self, slot: &SessionSlot, index: Arc<FileIndex>)
pub fn store_file_index(&self, slot: &SessionSlot, index: Arc<FileIndex>)
Store a walked file index for slot, as the seat’s loop does.
Sourcepub fn file_index(&self, slot: &SessionSlot) -> Option<HeldFileIndex>
pub fn file_index(&self, slot: &SessionSlot) -> Option<HeldFileIndex>
The seat’s walked file index, when one has been taken.
Sourcepub fn hold_work_gate(&self, slot: &SessionSlot) -> Option<Arc<Mutex<()>>>
pub fn hold_work_gate(&self, slot: &SessionSlot) -> Option<Arc<Mutex<()>>>
The seat’s work gate, for a test that has to hold it across a read.
None when the seat is not held.
Sourcepub fn store_work_snapshot(&self, slot: &SessionSlot, snapshot: WorkSnapshot)
pub fn store_work_snapshot(&self, slot: &SessionSlot, snapshot: WorkSnapshot)
Store a scan for slot, as the seat’s loop and a fixture both do.
Sourcepub async fn scan_work_if_stale(
&self,
slot: &SessionSlot,
dirty: bool,
pr: PrRefresh,
) -> Result<WorkSnapshot, String>
pub async fn scan_work_if_stale( &self, slot: &SessionSlot, dirty: bool, pr: PrRefresh, ) -> Result<WorkSnapshot, String>
Answer the seat’s scan, reading the tree first when the rule says to.
The rule is checked under the seat’s own gate, so the record’s read, the loop’s read and a fixture’s read cannot both decide a stale tree needs two scans: the second one finds what the first stored.
§Errors
A seat with no session behind it has no directory to read, and forge answers that rather than scanning the process’s own directory - the binary test every read here owes.
Sourcepub(crate) async fn scan_work_now(
&self,
slot: &SessionSlot,
pr_force: bool,
) -> Result<WorkSnapshot, String>
pub(crate) async fn scan_work_now( &self, slot: &SessionSlot, pr_force: bool, ) -> Result<WorkSnapshot, String>
The seat’s scan, taken because the session reported a repository
mutation rather than because its stored read aged out. pr_force
re-runs the PR lookup even inside the cache window the previous
attempt left behind.
Sourceasync fn scan_work_locked(
&self,
slot: &SessionSlot,
dirty: bool,
pr: PrRefresh,
force_pr: bool,
) -> Result<WorkSnapshot, String>
async fn scan_work_locked( &self, slot: &SessionSlot, dirty: bool, pr: PrRefresh, force_pr: bool, ) -> Result<WorkSnapshot, String>
Self::scan_work_if_stale with the seat’s gate already held.
Sourcepub async fn scan_work_at(
&self,
slot: &SessionSlot,
cwd: &Path,
pr: PrRefresh,
) -> WorkSnapshot
pub async fn scan_work_at( &self, slot: &SessionSlot, cwd: &Path, pr: PrRefresh, ) -> WorkSnapshot
Read cwd as slot’s working tree, store what it answered, and
answer it.
One body for both entry points - the record’s fallback and the loop - because a second scan path is a second answer about one tree.
Sourcepub async fn scan_work_at_without_base_fetch(
&self,
slot: &SessionSlot,
cwd: &Path,
) -> WorkSnapshot
pub async fn scan_work_at_without_base_fetch( &self, slot: &SessionSlot, cwd: &Path, ) -> WorkSnapshot
A sessionless seat’s fetching scan with no background kick: the read that draws the diff kicks the base refresh itself a moment later, so its kick is the only one and nothing of forge’s is queued ahead of it on the shared per-repository gate.
Sourcepub async fn scan_work_at_pinned_local(
&self,
slot: &SessionSlot,
cwd: &Path,
base: Option<&str>,
measured: Option<BaseMeasurement>,
) -> WorkSnapshot
pub async fn scan_work_at_pinned_local( &self, slot: &SessionSlot, cwd: &Path, base: Option<&str>, measured: Option<BaseMeasurement>, ) -> WorkSnapshot
A local scan pinned to base, taken without the seat’s gate: the
drawing read’s fallback when the gate is held past its bound. It writes
the store like any scan, and the caller has already refreshed the base.
Sourceasync fn scan_work_at_with_base(
&self,
slot: &SessionSlot,
cwd: &Path,
pr: PrRefresh,
base: Option<&str>,
measured: Option<BaseMeasurement>,
quiet: bool,
force_pr: bool,
) -> WorkSnapshot
async fn scan_work_at_with_base( &self, slot: &SessionSlot, cwd: &Path, pr: PrRefresh, base: Option<&str>, measured: Option<BaseMeasurement>, quiet: bool, force_pr: bool, ) -> WorkSnapshot
Self::scan_work_at pinned to one base revision: the branch-ahead
layer is computed against base (a sha a drawing read resolved)
rather than the ref’s name.
measured is what the checkout’s last completed base refresh measured,
with the instant it did and the words a failure left; None keeps the
state the store last carried, so a scan that did not refresh does not
erase it. A measurement older than the base the store carries is
ignored: a read that found the base unchecked has already superseded
it. quiet suppresses the background kick on a Fetch scan.
Sourcepub async fn scan_work_pinned(
&self,
slot: &SessionSlot,
cwd: &Path,
base: Option<&str>,
measured: Option<BaseMeasurement>,
) -> Result<WorkSnapshot, String>
pub async fn scan_work_pinned( &self, slot: &SessionSlot, cwd: &Path, base: Option<&str>, measured: Option<BaseMeasurement>, ) -> Result<WorkSnapshot, String>
A scan a drawing read must take, pinned to one base revision: it awaits the seat’s gate rather than serving the store a concurrent scan is replacing, so the count and chain it answers were read after the base moved. Local only - the caller has already refreshed the base.
Sourcefn project_env(&self, project: &str) -> HashMap<String, String>
fn project_env(&self, project: &str) -> HashMap<String, String>
project’s own declared environment, env_file included, which
is the identity a gh spawn on its behalf carries: the scan’s PR
row must read as the project, not as the server’s ambient login.
A name no project has declares none, so gh falls back to its
own login.
Source§impl Workspace
impl Workspace
Sourcepub fn testing_stub_handle() -> (AgentHandle, UnboundedReceiver<AgentCommand>)
pub fn testing_stub_handle() -> (AgentHandle, UnboundedReceiver<AgentCommand>)
Construct a stub AgentHandle plus the matching
Receiver<forge_primitives::AgentCommand> that drains every command
dispatched to it. Tests use this to wire a stub connection
without spinning up a real subprocess; the bridge underneath is
forge_agent::Agent::testing_stub - same shape as before, now
reachable from a view via forge_workspace::Workspace::*
so a view crate needs no direct forge-agent dep.
The returned Receiver carries forge_primitives::AgentCommand
because that’s what the bridge’s dispatcher accepts; this is
distinct from crate::protocol::Command (the workspace’s
outer envelope) that wraps these primitives under a
SessionSlot.
Sourcepub fn record_spawn_failure_for_test(&self, slot: &SessionSlot, message: &str)
pub fn record_spawn_failure_for_test(&self, slot: &SessionSlot, message: &str)
Record a slot’s failed spawn, as the connection-failure path does before it releases the session. Test-only.
Sourcepub fn record_failed_turn_for_test(&self, slot: &SessionSlot, at: SystemTime)
pub fn record_failed_turn_for_test(&self, slot: &SessionSlot, at: SystemTime)
Stamp a slot’s failed-turn record the way an errored Result
does, so a cross-crate test can read the row the surface builds.
Test-only.
Sourcepub fn record_death_for_test(
&self,
slot: &SessionSlot,
occupant: &str,
message: &str,
)
pub fn record_death_for_test( &self, slot: &SessionSlot, occupant: &str, message: &str, )
Record a slot’s death alone - the state a recovering connect leaves:
the failure cleared, the death riding with the reason it left. Add
Self::record_spawn_failure_for_test for the crashed-and-still-
down shape, where no connect has cleared anything. Test-only.
Sourcepub fn emit_for_test(&self, update: SessionUpdate)
pub fn emit_for_test(&self, update: SessionUpdate)
Push one update onto the fan-out, so a test can watch a view react to the core without driving a session. Test-only.
Sourcepub fn park_slack_draft_for_test(&self, key: &SessionSlot, draft: SlackDraft)
pub fn park_slack_draft_for_test(&self, key: &SessionSlot, draft: SlackDraft)
Park a Slack draft on key as a blocked Slack tool call would. The
receiver is dropped on purpose: the tool call is the test’s to
simulate, and the note’s own path is covered where a handler awaits
it. Test-only.
Sourcepub fn emit_for_test_reported(&self, update: SessionUpdate) -> bool
pub fn emit_for_test_reported(&self, update: SessionUpdate) -> bool
Self::emit_for_test, reporting whether a subscriber was there to
take it.
A dead subscriber is dropped from the registry on the next send, so
the answer is false once nothing is listening - which is how a test
tells a subscription still attached from one that went with its
socket.
Sourcepub fn test_subscriber_count(&self) -> usize
pub fn test_subscriber_count(&self) -> usize
How many subscribers are attached right now. Test-only: a test that watches a subscription arrive and leave reads this rather than asking whether an emit landed, which a permanently attached listener answers for everything.
Sourcepub fn test_answering_count(&self) -> usize
pub fn test_answering_count(&self) -> usize
How many subscribers could answer a prompt right now. Test-only: the role decides whether the core parks a turn on a reply, and a test that watches a client declare it has nothing else to read.
Sourcepub fn test_usage_poller_started(&self) -> bool
pub fn test_usage_poller_started(&self) -> bool
Whether the usage poller is registered and running. Test-only: the poller’s first tick is a minute out and its pass reaches the network, so a cross-crate boot test reads the registry rather than watching it poll.
Sourcepub fn test_dictate_preflight_started(&self) -> bool
pub fn test_dictate_preflight_started(&self) -> bool
Whether the dictate prewarm’s start has run. Test-only: the start reaches the network for models this fixture has none of, so a cross-crate boot test reads the guard rather than watching it fetch.
Sourcepub fn test_voice_prewarm_started(&self) -> bool
pub fn test_voice_prewarm_started(&self) -> bool
Whether the voice prewarm’s start has run. Test-only: the start loads multi-gigabyte weights this fixture has none of, so a cross-crate boot test reads the guard rather than watching it load.
Sourcepub fn seed_test_cli_version(
&self,
installed: Option<&str>,
latest: Option<&str>,
)
pub fn seed_test_cli_version( &self, installed: Option<&str>, latest: Option<&str>, )
Hold a claude version snapshot, so a cross-crate test can render the
version line without a real claude --version and npm probe.
Test-only.
Sourcepub fn seed_test_fatal_error(&self, error: AppError)
pub fn seed_test_fatal_error(&self, error: AppError)
Hold the last fatal, as the boot spawn’s failure records it, so a cross-crate test can read the home’s encoding without a dying spawn. Test-only.
Sourcepub fn install_testing_stub(
&self,
key: &SessionSlot,
) -> UnboundedReceiver<AgentCommand>
pub fn install_testing_stub( &self, key: &SessionSlot, ) -> UnboundedReceiver<AgentCommand>
Register a fresh testing-stub agent against key’s
DomainSession. Returns the matching
forge_primitives::AgentCommand receiver so tests can assert on
the commands the workspace routes through it (the same shape
as testing_stub_handle(), but with the handle installed in
one step so a view’s test code doesn’t have to touch
AgentHandle directly).
Auto-creates a DomainSession for key when none is
registered yet; otherwise overwrites the existing
DomainSession.conn slot.
Sourcepub fn seed_queued_prompt(
&self,
key: &SessionSlot,
uuid: &str,
source: PromptSource,
text: &str,
)
pub fn seed_queued_prompt( &self, key: &SessionSlot, uuid: &str, source: PromptSource, text: &str, )
Put a prompt in a seat’s queue exactly as a dispatch leaves it: the row a client attaching mid-queue reads, with no dispatch to drive.
Sourcepub fn testing_stub() -> (Arc<Self>, UnboundedReceiver<SeatUpdate>)
pub fn testing_stub() -> (Arc<Self>, UnboundedReceiver<SeatUpdate>)
Construct an empty Workspace for use in unit tests. Skips
the on-disk forge.toml load + catalog scan that
Workspace::new performs; the returned workspace carries an
empty project list and an empty pool. Tests register a domain
session via Self::register_domain_session before
exercising any code path that needs one.
Returns the workspace alongside the SeatUpdate receiver
this constructor subscribed. The workspace’s subscribe() slot
is None, so a later subscribe() mints a stream of its own
rather than handing back this one.
Sourcepub fn testing_stub_with_config_dir(
config_dir: PathBuf,
) -> (Arc<Self>, UnboundedReceiver<SeatUpdate>)
pub fn testing_stub_with_config_dir( config_dir: PathBuf, ) -> (Arc<Self>, UnboundedReceiver<SeatUpdate>)
Like testing_stub but with a caller-supplied config_dir so
tests that probe disk-side APIs (catalog scan, JSONL reads)
can point the stub at a tempdir of their own.
Sourcepub(crate) fn testing_stub_with_slack(
config_dir: PathBuf,
config: LoadedConfig,
slack: Arc<SlackWorkspaces>,
systemone: Option<Arc<SystemOneClient>>,
) -> (Arc<Self>, UnboundedReceiver<SeatUpdate>)
pub(crate) fn testing_stub_with_slack( config_dir: PathBuf, config: LoadedConfig, slack: Arc<SlackWorkspaces>, systemone: Option<Arc<SystemOneClient>>, ) -> (Arc<Self>, UnboundedReceiver<SeatUpdate>)
Self::testing_stub_with_config with the Slack clients supplied
by the caller, so a test can drive a real facade against a double
rather than a live workspace.
Sourcepub fn seed_test_user_preferences(&self, preferences: Value)
pub fn seed_test_user_preferences(&self, preferences: Value)
Hold the CLI’s per-user preferences document, so a cross-crate test
reads an ignore preference without the machine’s own
$HOME/.claude.json. Test-only.
Sourcepub fn seed_test_claude_pid(&self, key: &SessionSlot, pid: u32)
pub fn seed_test_claude_pid(&self, key: &SessionSlot, pid: u32)
Give a fixture seat a live claude pid, so its held loop walks a real
process tree with no CLI behind it. Test-only.
Sourcepub fn seed_test_scan_gate(&self, gate: Arc<Semaphore>)
pub fn seed_test_scan_gate(&self, gate: Arc<Semaphore>)
Hold every stale read at gate until the fixture adds permits, so a
test can say what a hold answers while its scans are still in flight.
Test-only.
Sourcepub fn note_tree_moved_for_test(&self, key: &SessionSlot)
pub fn note_tree_moved_for_test(&self, key: &SessionSlot)
Report the seat’s tree moved, as the file watch’s own mark does, so a cross-crate test drives the watch’s pass instead of waiting on the OS daemon’s delivery, which has no budget to derive a wait from. Test-only.
Sourcepub fn watch_pass_done_for_test(&self, key: &SessionSlot) -> Option<Arc<Notify>>
pub fn watch_pass_done_for_test(&self, key: &SessionSlot) -> Option<Arc<Notify>>
The mark a held seat’s watch raises at the end of every pass, so a test
waits for a pass rather than for a clock. None for a seat held by
nobody. Test-only.
Sourcepub fn pause_next_live_seats_capture(&self) -> (Receiver<()>, Sender<()>)
pub fn pause_next_live_seats_capture(&self) -> (Receiver<()>, Sender<()>)
Hold the next Self::live_seats capture between its mint and its
reads: the returned receiver fires once it is held, and a message on
the returned sender releases it. Test-only.
Sourcepub(crate) fn pass_live_seats_gate(&self)
pub(crate) fn pass_live_seats_gate(&self)
Hold this capture where a test armed Self::pause_next_live_seats_capture.
Test-only: a production capture never meets a gate.
Source§impl Workspace
Test-mode command interception. Feature-gated and pub for the same
reason the seeds below are: a view’s tests reach for these to
assert what boot WOULD have spawned, without starting a subprocess.
impl Workspace
Test-mode command interception. Feature-gated and pub for the same
reason the seeds below are: a view’s tests reach for these to
assert what boot WOULD have spawned, without starting a subprocess.
Sourcepub fn enable_test_dispatch_intercept(&self)
pub fn enable_test_dispatch_intercept(&self)
Enable test-mode app-level command interception. After this
call, every Command routed through the app-level branch of
dispatch is buffered in lieu of running the spawn handler;
drain via drain_test_dispatch_buffer. No-op if already
enabled. Test-only - tests use this to assert what would
have been dispatched without spinning up real subprocesses.
Sourcepub fn test_rotation_announcer_started(&self) -> bool
pub fn test_rotation_announcer_started(&self) -> bool
Whether the rotation announcer is registered and running, so a test pins that the boot call actually reaches the one watcher.
Sourcepub fn drain_test_dispatch_buffer(&self) -> Vec<Command>
pub fn drain_test_dispatch_buffer(&self) -> Vec<Command>
Drain every app-level Command captured since the last call.
Returns empty when no intercept was enabled or no commands
were dispatched. Test-only.
Source§impl Workspace
impl Workspace
Sourcepub fn seed_seat_state_for_test(
&self,
org: &str,
project: &str,
label: &str,
state: SeatState,
) -> Result<()>
pub fn seed_seat_state_for_test( &self, org: &str, project: &str, label: &str, state: SeatState, ) -> Result<()>
Plant a session row in the seat state a boot test derives from, so the wave can be driven without running a real spawn first. Test-only.
Sourcepub fn park_terminal_hand_off(
&self,
slot: &SessionSlot,
terminal: u16,
) -> Result<String, String>
pub fn park_terminal_hand_off( &self, slot: &SessionSlot, terminal: u16, ) -> Result<String, String>
Park a terminal hand-off on slot as a blocked terminal__hand_off
call would, so a transport test can type through the hand-off’s door.
Test-only.
Sourcepub fn answer_terminal_hand_off(&self, id: &str, slot: &SessionSlot)
pub fn answer_terminal_hand_off(&self, id: &str, slot: &SessionSlot)
Answer a parked terminal hand-off with Done, as the dock’s button would. Test-only.
Sourcepub fn seed_test_project(&self, name: &str, path: &str)
pub fn seed_test_project(&self, name: &str, path: &str)
Append a synthetic project to the test overlay searched first
by find_project_view_by_name. Used by respawn tests
to drive the Connected-hook worker-spawn trigger without
writing a real forge.toml. Test-only.
Sourcepub fn remove_test_project(&self, name: &str)
pub fn remove_test_project(&self, name: &str)
Drop a project seeded by Self::seed_test_project, so a test can
exercise the paths that react to a project leaving the config.
Sourcepub fn seed_test_project_with_slack_workspace(
&self,
name: &str,
path: &str,
workspace: &str,
)
pub fn seed_test_project_with_slack_workspace( &self, name: &str, path: &str, workspace: &str, )
Workspace::seed_test_project with a Slack binding, so a facade
test can drive the slot-to-workspace resolution. Test-only.
Sourcepub fn seed_test_project_with_max_workers(
&self,
name: &str,
path: &str,
max_workers: usize,
)
pub fn seed_test_project_with_max_workers( &self, name: &str, path: &str, max_workers: usize, )
Workspace::seed_test_project with the project’s
max_workers override, so facade tests can
exercise the override arm of the capacity read. Test-only.
fn seed_test_project_inner( &self, name: &str, path: &str, max_workers: Option<usize>, slack_workspace: Option<&str>, )
Sourcepub fn seed_test_gateway_bind_error(&self, error: Option<String>)
pub fn seed_test_gateway_bind_error(&self, error: Option<String>)
Set the gateway bind error, so a cross-crate test can render the failed state of the preflight gateway row. Test-only.
Sourcepub fn install_test_spawn_handle(&self, handle: AgentHandle)
pub fn install_test_spawn_handle(&self, handle: AgentHandle)
Set the gateway listener’s ready flag, so a cross-crate test can render each of the preflight gateway row’s states. Test-only. Stand in a stub handle for the next cold spawn, so a test can read the launch settings the spawn hands its child. Consumed by that spawn: a test that asserts on one installs one.
Sourcepub(crate) fn take_test_spawn_handle(&self) -> Option<AgentHandle>
pub(crate) fn take_test_spawn_handle(&self) -> Option<AgentHandle>
The installed stand-in, taken so one spawn cannot use it twice.
Sourcepub fn test_spawn_server(&self) -> Option<McpServer>
pub fn test_spawn_server(&self) -> Option<McpServer>
The forge MCP server the last spawn composed, so a test can drive a
tool through the spawn’s OWN wiring rather than a copy of it.
pub fn seed_test_gateway_ready(&self, ready: bool)
Sourcepub fn seed_test_gotify_connected(&self, connected: bool)
pub fn seed_test_gotify_connected(&self, connected: bool)
Set the Gotify stream’s connection flag, so a cross-crate test can give the two connectors’ liveness flags different values. Two false-valued bools read as agreement, which lets a read wired to the wrong connector pass unnoticed. Test-only.
Sourcepub fn seed_test_ready_account(&self, account: &str)
pub fn seed_test_ready_account(&self, account: &str)
Mark account Ready, so a cross-crate test can render chip-bearing
rows without driving the real account loader. Test-only.
Sourcepub fn seed_test_account_state(&self, account: &str, state: LoadingState)
pub fn seed_test_account_state(&self, account: &str, state: LoadingState)
Drive account to state directly, so a cross-crate test can
render a mid-flight or bailed preflight screen without the real
loader. Test-only.
Sourcepub fn seed_test_account_failure(&self, account: &str, status: UsageFetchStatus)
pub fn seed_test_account_failure(&self, account: &str, status: UsageFetchStatus)
Record a probe failure on account, so a cross-crate test can
render bailed-row copy keyed on why the account bailed. Test-only.
Sourcepub fn seed_test_dictate_snapshot(&self, snapshot: DictateSnapshot)
pub fn seed_test_dictate_snapshot(&self, snapshot: DictateSnapshot)
Replace the dictation preflight snapshot, so a cross-crate test can render any of its states without fetching 3 GB. Test-only.
Sourcepub fn install_test_dictate_engine(
&self,
models_dir: &Path,
) -> Result<(), Error>
pub fn install_test_dictate_engine( &self, models_dir: &Path, ) -> Result<(), Error>
Arm dictation with an engine over models_dir: its weights are
absent, so a take registers, meters and resolves without a model.
The caller holds the directory for as long as the engine is
wanted. Test-only.
The read-aloud set moves here too, because its own resolution falls back to the machine’s app-support dir whatever config dir a fixture gave the workspace: a test that kept a recording would write into the real one and the next run would read it back.
Sourcepub fn mark_test_session_live(&self, key: &SessionSlot)
pub fn mark_test_session_live(&self, key: &SessionSlot)
Mark key live the way a started session is - a command sender
with nothing behind it - so a liveness gate reads true without a
CLI. Test-only.
Sourcepub fn testing_stub_with_dictate_enabled() -> (Arc<Self>, UnboundedReceiver<SeatUpdate>)
pub fn testing_stub_with_dictate_enabled() -> (Arc<Self>, UnboundedReceiver<SeatUpdate>)
A testing_stub whose [dictate] enabled is true, so a
cross-crate test exercises the key handler’s enabled path
without a model download. Test-only.
Sourcepub fn seed_test_bound_session(&self, key: &SessionSlot, account: &str)
pub fn seed_test_bound_session(&self, key: &SessionSlot, account: &str)
Pool key as a live session and bind account to it, so a
cross-crate test can render the account panel against a real
binding without spawning a CLI. Test-only.
Sourcepub fn seed_test_running_session_id(&self, key: &SessionSlot, session_id: &str)
pub fn seed_test_running_session_id(&self, key: &SessionSlot, session_id: &str)
Pool key under session_id, so a cross-crate test can read the
transcript that id names. Test-only.
Sourcepub fn seed_test_stored_session_id(&self, key: &SessionSlot, session_id: &str)
pub fn seed_test_stored_session_id(&self, key: &SessionSlot, session_id: &str)
Record session_id as the store’s row for key with nothing live
behind it: the seat a wake resumes, whose transcript is the last
session it ran. Test-only.
pub fn seed_test_usage(&self, account: &str, snapshot: UsageSnapshot)
Sourcepub fn seed_test_advertised_catalogues(
&self,
slot: &SessionSlot,
commands: Vec<AvailableCommand>,
agents: Vec<AvailableAgent>,
)
pub fn seed_test_advertised_catalogues( &self, slot: &SessionSlot, commands: Vec<AvailableCommand>, agents: Vec<AvailableAgent>, )
Advertise commands and agents for slot, registering its
domain if it has none, so a cross-crate test can read what the
CLI’s init frame would have left behind without driving the wire.
Test-only.
Sourcepub fn seed_test_pending_interaction(
&self,
slot: &SessionSlot,
kind: PendingInteractionKind,
)
pub fn seed_test_pending_interaction( &self, slot: &SessionSlot, kind: PendingInteractionKind, )
Park one ask of kind on slot, so a test can read the state a held
turn produces without driving the wire. A prompt’s domain is
registered when it has none; a registry ask parks in its own registry
and needs no domain. Test-only.
Sourcepub fn clear_test_pending(&self, slot: &SessionSlot)
pub fn clear_test_pending(&self, slot: &SessionSlot)
Let go of everything parked on slot, which is half of what
answering does: the other half is the update that says so. Test-only.
Sourcepub fn seed_test_awaiting_login(&self, slot: &SessionSlot)
pub fn seed_test_awaiting_login(&self, slot: &SessionSlot)
Hold slot waiting to be let in, the state a session that cannot
authenticate reads as. Test-only.
Sourcepub fn seed_test_prompt_queue(
&self,
slot: &SessionSlot,
kind: PendingInteractionKind,
count: usize,
)
pub fn seed_test_prompt_queue( &self, slot: &SessionSlot, kind: PendingInteractionKind, count: usize, )
Park count unanswered asks of kind on slot under distinct ids,
the way a session holding a queue of them reads. Test-only.
Sourcefn seed_test_asks(
&self,
slot: &SessionSlot,
kind: PendingInteractionKind,
count: usize,
)
fn seed_test_asks( &self, slot: &SessionSlot, kind: PendingInteractionKind, count: usize, )
Park count asks of kind in the place that kind really lives - the
session’s pending set for a prompt, its own registry for everything
else - so the fixture is the state, not a stand-in. A registry ask is
inserted directly: the hand-offs and the draft carry a sender whose
receiver nobody kept, and the settings and vault asks, whose sender is
optional, carry None. Each parked ask then takes its number, the way
every production registration does, so a seeded ask reads the identity
the app would give it. It is the parking that makes the seat need a
person, and no test answers a seeded ask through its own oneshot.
Sourcepub fn seed_test_worker_row(&self, project_key: &ProjectKey, label: &str)
pub fn seed_test_worker_row(&self, project_key: &ProjectKey, label: &str)
Persist a dynamic-worker row directly, bypassing agents__spawn.
Cross-crate test access to the otherwise pub(crate) store write
so a view can render launchpad worker rows against a seeded row.
Sourcepub fn seed_test_worker_row_at(
&self,
project_key: &ProjectKey,
label: &str,
cwd: &str,
is_git_repo: bool,
)
pub fn seed_test_worker_row_at( &self, project_key: &ProjectKey, label: &str, cwd: &str, is_git_repo: bool, )
Self::seed_test_worker_row with the worker pointed at its own
tree, the way a spawn with cwd records it.
Sourcepub fn seed_test_git_worker_row(&self, project_key: &ProjectKey, label: &str)
pub fn seed_test_git_worker_row(&self, project_key: &ProjectKey, label: &str)
Self::seed_test_worker_row for a worker that was spawned in a
git repo, so its tree is the worktree under the project rather than
the project root. The flag is what crate::Workspace::cwd_for_session
composes the directory from, so a fixture that seeds the row without
it hands the test the project’s own tree.
Sourcepub fn attach_store_for_test(&self, db: Db)
pub fn attach_store_for_test(&self, db: Db)
Attach a store to the stub. Every other testing constructor leaves
db as None so nothing touches the real machine store; this one
opens the caller’s own redb, for the tests that read persisted rows
(the worker-seat set, the launchpad’s labels). Test-only.
Sourcepub fn seed_test_session_charter(
&self,
slot: &SessionSlot,
charter: &str,
interactive: bool,
)
pub fn seed_test_session_charter( &self, slot: &SessionSlot, charter: &str, interactive: bool, )
Write a session row for slot carrying charter, bypassing a
spawn. A test that drives /new needs the store to already hold
the worker’s mission, which is what the re-delivery reads; the
interactive flag is what its respawn reads to decide whether the
worker may put a question to its user.
Source§impl Workspace
impl Workspace
Sourceconst RESUME_CANDIDATES: usize = 50
const RESUME_CANDIDATES: usize = 50
The newest sessions a /resume picker offers: a reader picks from the
top, and the record is re-encoded on every seat open and reread, so the
answer cannot grow with the project’s history.
Sourcepub fn new(config_dir: PathBuf) -> Result<Self, WorkspaceError>
pub fn new(config_dir: PathBuf) -> Result<Self, WorkspaceError>
Builds a Workspace, kicks off the background catalog scan and the
claude version probe, and loads <config_dir>/forge.toml. Errors if
forge.toml is missing or malformed (e.g. no [[orgs]] entries, no
[[orgs.projects]] entries, unknown account references). No
Agents are spawned on success. The session catalog starts empty
and fills when the scan lands - see Workspace::start_catalog_scan.
Sourcepub fn new_for_test(config_dir: PathBuf) -> Result<Self, WorkspaceError>
pub fn new_for_test(config_dir: PathBuf) -> Result<Self, WorkspaceError>
Like Workspace::new but puts forge’s whole app-support base -
the redb store and the single-instance lock - under a tempdir
inside config_dir rather than the real machine
app_support_dir, so tests never touch the user’s durable store
or contend for their live lock. The catalog scan does NOT
auto-start; tests opt in via Workspace::start_catalog_scan
so the catalog stays empty until a fixture asks for it. Neither
boot probe starts either: a test workspace reaches no network.
Sourcepub fn new_for_test_with_pricing_fetcher(
config_dir: PathBuf,
fetcher: PricingFetcher,
) -> Result<Self, WorkspaceError>
pub fn new_for_test_with_pricing_fetcher( config_dir: PathBuf, fetcher: PricingFetcher, ) -> Result<Self, WorkspaceError>
Workspace::new_for_test with the pricing refill driven by
fetcher rather than by the inert default, so the refill’s
fetch-to-rescan path is exercisable without the network.
Sourcepub fn new_for_test_with_cli_version_prober(
config_dir: PathBuf,
prober: Box<dyn Fn() -> Pin<Box<dyn Future<Output = CliVersionInfo> + Send>> + Send + Sync>,
) -> Result<Self, WorkspaceError>
pub fn new_for_test_with_cli_version_prober( config_dir: PathBuf, prober: Box<dyn Fn() -> Pin<Box<dyn Future<Output = CliVersionInfo> + Send>> + Send + Sync>, ) -> Result<Self, WorkspaceError>
Workspace::new_for_test with prober driving the claude version
probe in place of the real one, so a test drives the constructor’s
own launch without spawning claude --version or reaching npm.
fn new_for_test_impl( config_dir: PathBuf, cli_version_prober: Option<Box<dyn Fn() -> Pin<Box<dyn Future<Output = CliVersionInfo> + Send>> + Send + Sync>>, pricing_fetcher: Option<PricingFetcher>, ) -> Result<Self, WorkspaceError>
Sourcepub fn start_catalog_scan(&self)
pub fn start_catalog_scan(&self)
Run the catalog scan in the background and signal readiness
when it lands. [Workspace::new] calls this during construction; tests built on new_for_test` opt in explicitly so
the catalog stays empty until a fixture asks for it. Idempotent -
a second call is a no-op.
Sourcepub fn catalog_ready(&self) -> bool
pub fn catalog_ready(&self) -> bool
Whether the background catalog scan has populated the catalog. False from construction until the scan lands; always true when constructed without a tokio runtime (nothing would run it).
Self::resumable_sessions reads it: a lead’s list is the catalog’s
group once the scan has landed, and the directory’s own read until
then, so the two sources are told apart rather than an unscanned
catalog being read as a project with no sessions. Tests also await it
to sequence a fixture against the scan.
Sourcepub fn supervisor(&self) -> &Arc<Supervisor> ⓘ
pub fn supervisor(&self) -> &Arc<Supervisor> ⓘ
The process’s subsystem supervisor. Every long-lived background task registers here, so a death is named and a restart is bounded.
Sourcefn start_cli_version_probe(
&self,
prober: Box<dyn Fn() -> Pin<Box<dyn Future<Output = CliVersionInfo> + Send>> + Send + Sync>,
)
fn start_cli_version_probe( &self, prober: Box<dyn Fn() -> Pin<Box<dyn Future<Output = CliVersionInfo> + Send>> + Send + Sync>, )
Kick off the claude version probe on the tokio runtime: one fetch at
boot, then a re-probe every CLI_VERSION_REFRESH_INTERVAL. The
constructor is the only production caller, and the prober it takes
is what lets a test drive this same launch. Idempotent - a second
call is a no-op.
Sourcepub fn cli_version(&self) -> Option<CliVersionInfo>
pub fn cli_version(&self) -> Option<CliVersionInfo>
The claude CLI versions the core holds: what is installed on this
machine and what npm publishes, both None until the boot probe
resolves them. The same answer for every viewer, so a view reads it
here rather than probing for itself. A change arrives as
SessionUpdate::CliVersionChanged.
Sourcefn new_impl(
config_dir: PathBuf,
app_support: Option<PathBuf>,
catalog_scan: bool,
cli_version_prober: Option<Box<dyn Fn() -> Pin<Box<dyn Future<Output = CliVersionInfo> + Send>> + Send + Sync>>,
service_status_prober: Option<Box<dyn Fn() -> Pin<Box<dyn Future<Output = Option<ServiceIssue>> + Send>> + Send + Sync>>,
pricing_fetcher: Option<PricingFetcher>,
) -> Result<Self, WorkspaceError>
fn new_impl( config_dir: PathBuf, app_support: Option<PathBuf>, catalog_scan: bool, cli_version_prober: Option<Box<dyn Fn() -> Pin<Box<dyn Future<Output = CliVersionInfo> + Send>> + Send + Sync>>, service_status_prober: Option<Box<dyn Fn() -> Pin<Box<dyn Future<Output = Option<ServiceIssue>> + Send>> + Send + Sync>>, pricing_fetcher: Option<PricingFetcher>, ) -> Result<Self, WorkspaceError>
Shared constructor body. app_support supplies the app-support
base dir; None resolves the real machine app_support_dir and
degrades to no lock and no durable store on failure (hard rule
#14: no cwd fallback).
cli_version_prober and service_status_prober are None for a
workspace built by a test. Both probes fetch over the network at boot
and announce the answer on the core’s own stream, so a test workspace
that ran them would both depend on a remote service’s health and hand
every test a slot-less update it never emitted.
Sourcepub fn server_config(&self) -> ServerConfig
pub fn server_config(&self) -> ServerConfig
Effective [server] settings: whether the socket’s listener starts
and where it binds. Read by the binary entry point, which serves the
socket - the workspace never does.
Sourcepub(crate) fn config_arc(&self) -> Arc<LoadedConfig> ⓘ
pub(crate) fn config_arc(&self) -> Arc<LoadedConfig> ⓘ
The live config as one owned snapshot. A reload swaps the whole value, so every reader takes the snapshot it will use rather than a reference that a reload could pull out from under it.
Sourcefn resolution_held(generation: u64, settled: u64) -> bool
fn resolution_held(generation: u64, settled: u64) -> bool
Whether a spawn’s resolution ran entirely inside one settled config: the generation is odd while a reload swaps state, so the start must be even and equal to the end.
Sourcepub fn reload_config(self: &Arc<Self>) -> Result<ConfigReload, WorkspaceError>
pub fn reload_config(self: &Arc<Self>) -> Result<ConfigReload, WorkspaceError>
Re-read <config_dir>/forge.toml and apply what can change without
a restart. A reload is a whole-config replacement: the parsed value
goes through the same loader a boot uses, and only a valid file
replaces the live config. An invalid file returns its error and
leaves the previous config untouched, so a half-written save never
loses a good config.
What a reader sees after this returns equals what a boot on the same file would load. Running sessions keep what they spawned with; only their next use reads the new values.
§Errors
The file could not be read, parsed or validated, or the systemone client could not be built. Nothing was applied.
Sourcepub fn client_config(&self) -> ClientConfig
pub fn client_config(&self) -> ClientConfig
Effective [client] settings: which of the sets forge ships a
client draws with. Carried by the greeting, so a client never reads
forge.toml itself.
Sourcepub fn set_settings_version(&self, version: &str)
pub fn set_settings_version(&self, version: &str)
Name the build the settings frame carries, and publish the first document. The server calls this once at boot, before the socket serves; a second call keeps the first name.
Sourcepub fn subscribe_settings(&self) -> Receiver<Option<Arc<SettingsDocument>>>
pub fn subscribe_settings(&self) -> Receiver<Option<Arc<SettingsDocument>>>
The effective-config document as it stood at the last load. Every connection takes its own receiver; a reload that changed a drawn value sends a new one to all of them.
Sourcepub fn save_settings(
&self,
fingerprint: &str,
edits: &[SettingsEditRequest],
) -> Result<(), SettingsSaveError>
pub fn save_settings( &self, fingerprint: &str, edits: &[SettingsEditRequest], ) -> Result<(), SettingsSaveError>
Apply a batched settings save to forge.toml.
The file is read under the write lock, the page’s fingerprint is compared with it, the edits are applied to its text, and the candidate is validated with the boot loader before an atomic replace. A save that fails validation writes nothing; the reload and its push are the watcher’s, so every client learns the outcome from one frame.
§Errors
The file moved since the page loaded it, an edit or the candidate is not valid, a guard refused it, or the write failed.
Sourcepub(crate) fn validate_settings(
&self,
fingerprint: &str,
edits: &[SettingsEditRequest],
) -> Result<(), SettingsSaveError>
pub(crate) fn validate_settings( &self, fingerprint: &str, edits: &[SettingsEditRequest], ) -> Result<(), SettingsSaveError>
Validate a save’s candidate without writing it.
The same checks the save runs, over the whole batch, so a caller can
refuse a write the file would never accept before asking the reader
about it. Not the authority: Self::save_settings repeats every
check under its write lock.
Sourcefn settings_candidate(
&self,
path: &Path,
fingerprint: &str,
edits: &[SettingsEditRequest],
) -> Result<String, SettingsSaveError>
fn settings_candidate( &self, path: &Path, fingerprint: &str, edits: &[SettingsEditRequest], ) -> Result<String, SettingsSaveError>
Build the save’s candidate text and run it through every check short of the write.
Sourcefn refuse_departures(
&self,
before: &LoadedConfig,
after_projects: &HashSet<(String, String)>,
after_accounts: &HashSet<String>,
) -> Result<(), SettingsSaveError>
fn refuse_departures( &self, before: &LoadedConfig, after_projects: &HashSet<(String, String)>, after_accounts: &HashSet<String>, ) -> Result<(), SettingsSaveError>
Refuse a save that drops an entry something live depends on.
Sourcefn live_sessions_of(&self, org: &str, project: &str) -> Vec<String>
fn live_sessions_of(&self, org: &str, project: &str) -> Vec<String>
The sessions a project currently has live, by their slots’ display names. A removal refuses while any exists: the reload leaves a live session running but invisible.
Sourcepub(crate) fn settings_snapshot(&self) -> Arc<SettingsDocument> ⓘ
pub(crate) fn settings_snapshot(&self) -> Arc<SettingsDocument> ⓘ
The effective-config document as the socket holds it, built from the live config where the server has not published one yet (a test workspace, or the first moments before the greeting). The settings MCP tools read this one document, so a key they report and the value the page draws cannot disagree.
Sourcefn publish_settings(&self)
fn publish_settings(&self)
Build the document from the live config and publish it when it differs from what the socket already holds.
Sourcepub fn browser_settings(&self) -> BrowserSettings
pub fn browser_settings(&self) -> BrowserSettings
The server browser’s own knobs: [browser]’s encoding rate and
quality, and its idle release’s two bounds. Absent section keeps the
defaults the struct carries.
Sourcepub fn browser_profile_config(
&self,
) -> (HashMap<String, String>, HashMap<(String, String), String>)
pub fn browser_profile_config( &self, ) -> (HashMap<String, String>, HashMap<(String, String), String>)
The profile names forge.toml resolved: each org’s, and each
project’s override keyed by (org, project). Project names are
unique inside an org, so the pair is the key the fleet resolves a
seat against.
Sourcepub fn document_max_bytes(&self) -> u64
pub fn document_max_bytes(&self) -> u64
The ceiling on one handed document, in bytes: [documents] max_size_mib over the crate’s own default. Read by the document
read, which refuses a document over it.
Sourcepub fn note_client_attached(&self)
pub fn note_client_attached(&self)
Note a client connection attached, and give it back when it goes. The transport’s per-connection guard calls these.
Sourcepub fn note_client_detached(&self)
pub fn note_client_detached(&self)
The counterpart of Self::note_client_attached.
Sourcepub(crate) fn client_attached(&self) -> bool
pub(crate) fn client_attached(&self) -> bool
Whether any client connection is attached. Only a client that draws a document viewer counts: a hand-off’s ask needs one that does.
Sourcepub fn install_browser_host(&self, host: Arc<dyn BrowserHost>)
pub fn install_browser_host(&self, host: Arc<dyn BrowserHost>)
Install the server’s browser fleet. Boot-time and once: the fleet outlives every session, and a second install would be two browsers answering one tool surface.
Sourcepub fn browser_host(&self) -> Option<Arc<dyn BrowserHost>>
pub fn browser_host(&self) -> Option<Arc<dyn BrowserHost>>
The browser fleet, when this workspace has one.
Sourcepub fn dictate_bind(&self) -> DictateBind
pub fn dictate_bind(&self) -> DictateBind
The push-to-talk key from forge.toml [dictate] bind. The value
rides the seat record a client last read, so a reload reaches the
key handler on the next seat read rather than the next event.
Sourcepub fn dictate_mode(&self) -> DictateMode
pub fn dictate_mode(&self) -> DictateMode
How press/release maps onto starting and stopping a take, from
forge.toml [dictate] mode. It rides the seat record like bind,
so a reload reaches the key handler on the next seat read.
Sourcepub fn dictate_enabled(&self) -> bool
pub fn dictate_enabled(&self) -> bool
Whether dictation is on at all. The key handler reads this so a
press with [dictate] disabled is dead rather than a refusal:
the box doc’s S0 is “nothing at all”, and with the section
absent every Cmd chord would otherwise pop an error.
Sourcepub fn plugin_settings(&self) -> PluginSettings
pub fn plugin_settings(&self) -> PluginSettings
The [plugins] auto-update policy from forge.toml. Read by the
plugins pane for the boot auto-update gate and the pane’s
auto-update state display.
Sourcepub fn record_plugin_updates(&self, records: &[PluginUpdateRecord])
pub fn record_plugin_updates(&self, records: &[PluginUpdateRecord])
Remember plugin updates applied by a pane run or by boot auto-update: the visible record of what moved, from where, and the ref a rollback restores. One transaction for the whole batch. A no-op with a warn when the store is closed.
Sourcepub fn plugin_update_records(&self) -> Vec<PluginUpdateRecord>
pub fn plugin_update_records(&self) -> Vec<PluginUpdateRecord>
Every remembered plugin update, latest write per installed entry. Empty when the store is closed or unreadable.
Sourcepub fn clear_plugin_update_record(&self, plugin_id: &str, scope: &str)
pub fn clear_plugin_update_record(&self, plugin_id: &str, scope: &str)
Forget the record for one installed entry after its rollback ran: the previous version it named is now current.
Sourcepub fn boot_wave_targets(&self, only: Option<&str>) -> Vec<BootWaveTarget>
pub fn boot_wave_targets(&self, only: Option<&str>) -> Vec<BootWaveTarget>
The projects a boot opens, in forge.toml order.
With no project named, that is every configured project whose lead
row is open (idle or in_turn) - the seats the user last had
open. A closed lead is left shut, and a project whose lead never
spawned has no row to open. A seat whose turn was cut off carries
the restart note; a parked one is resumed with nothing.
only names one project explicitly, which opens whether its row is
open or closed: naming it is the user’s own act, and closing is not
forgetting. A name no project declares is returned as itself so the
spawn handler reports it like any other unknown project.
A project renamed in forge.toml starts with no row, because the
row is keyed by its org and name: the rename is the same act that
loses the stored session id, and there is nothing stable underneath
to key by instead.
Sourcepub fn list_projects(&self) -> Vec<ProjectView>
pub fn list_projects(&self) -> Vec<ProjectView>
Every project listed in forge.toml, each carrying its catalog
sessions sorted by last-activity descending - sessions[0] is
the lead. Empty sessions means the project has nothing on
disk yet; the project still surfaces in the returned Vec.
Sourcepub(crate) fn handoff_policy_for(&self, slot: &SessionSlot) -> HandoffPolicy
pub(crate) fn handoff_policy_for(&self, slot: &SessionSlot) -> HandoffPolicy
The handoff trigger this seat runs under: the project’s table over the top-level one, with the defaults under both.
Sourcepub fn validate_project_name(&self, name: &str) -> Result<(), WorkspaceError>
pub fn validate_project_name(&self, name: &str) -> Result<(), WorkspaceError>
Validate that name matches a project in forge.toml. Used
by callers (e.g. the server’s boot) to fail fast on an unknown
CLI positional arg before setup, rather than surface the
same error later via Self::get_agent_handle.
Sourcefn apply_lead_delegation(
settings: &mut SessionLaunchSettings,
kind: SessionKind,
)
fn apply_lead_delegation( settings: &mut SessionLaunchSettings, kind: SessionKind, )
Stamp LEAD_DELEGATION_PREAMBLE onto a Lead session’s launch
settings. No-op for a worker.
Sourcefn slot_for_spawn(role: &SpawnRole, project: &LoadedProject) -> SessionSlot
fn slot_for_spawn(role: &SpawnRole, project: &LoadedProject) -> SessionSlot
The slot a spawn fills, from the role its caller stated. The label
is used as given: a worker must never be resolved through the
registry, which answers with the lead’s slot once its entry is
gone, and nothing recovers either answer from the key’s shape - a
key that looks like a worker is not evidence, and a project named
worker_foo once classified as a worker for exactly that reason.
The slot a spawn’s role states under project. The one source
for both a session’s tool surface and its address, so the two
cannot disagree.
Sourcepub fn get_agent_handle(
self: &Arc<Self>,
target: SessionTarget,
settings: SessionLaunchSettings,
role: &SpawnRole,
) -> Result<Arc<AgentHandle>>
pub fn get_agent_handle( self: &Arc<Self>, target: SessionTarget, settings: SessionLaunchSettings, role: &SpawnRole, ) -> Result<Arc<AgentHandle>>
Hands out the Arc<AgentHandle> for the requested session,
spawning the underlying Agent lazily if it isn’t already pooled.
Idempotent - repeated calls for the same target return the same
handle (no second subprocess). settings only apply to the
spawn; subsequent calls reuse the existing Agent and ignore the
parameter.
Each fresh spawn runs the gateway’s account selection and
stamps the chosen account’s env onto the spawned claude
subprocess, so it talks to the gateway listener rather than
upstream. Selection state lives in the in-memory usage cache;
nothing about account choice is persisted across forge
launches.
Workspace does not track which handle the caller is “using” -
that’s the caller’s concern.
role is what the caller states this session to be. There is no
keyless form: a role forge cannot state is one it would have to
guess from the live-worker registry, which answers with the lead’s
slot for a worker whose entry is gone.
Sourcepub(crate) fn get_agent_handle_at_key(
self: &Arc<Self>,
target: SessionTarget,
settings: SessionLaunchSettings,
resolved_key: Option<SessionSlot>,
role: &SpawnRole,
reopen: bool,
) -> Result<Arc<AgentHandle>>
pub(crate) fn get_agent_handle_at_key( self: &Arc<Self>, target: SessionTarget, settings: SessionLaunchSettings, resolved_key: Option<SessionSlot>, role: &SpawnRole, reopen: bool, ) -> Result<Arc<AgentHandle>>
Like Self::get_agent_handle but takes the key the caller
already resolved, so the pool entry lands under the same key the
caller announced. None for callers that have not resolved one.
role is what the caller states this spawn to be. It is the one
source for both the tool surface and the slot’s label, so a worker
cannot be handed a lead’s tool surface or a lead’s address.
reopen is the opening door’s word: a user’s click, the boot wave
and a cron wake open the seat they name, while the heal’s recovery
of a death must leave a seat the reader closed shut.
Sourcepub(crate) fn account_pool(&self) -> &AccountPool
pub(crate) fn account_pool(&self) -> &AccountPool
Crate-internal accessor for the boot-time loading task to
drive LoadingState transitions on the workspace’s account
map. Not exposed beyond the crate - the field stays private
so only intentional callers reach in.
Sourcepub fn gateway_bind_error(&self) -> Option<String>
pub fn gateway_bind_error(&self) -> Option<String>
The gateway’s bind failure, if the listener could not start. The launchpad surfaces this instead of “loading accounts” so a dead bind does not read as an endless wait.
Sourcepub fn gateway_ready(&self) -> bool
pub fn gateway_ready(&self) -> bool
Whether the gateway listener has bound its port. The preflight screen’s gateway row renders this.
Sourcepub fn gateway_port(&self) -> u16
pub fn gateway_port(&self) -> u16
The port the gateway’s listener binds.
Sourcefn gateway_listener_url(&self) -> String
fn gateway_listener_url(&self) -> String
The base URL children are stamped with: the listener’s BOUND address, not the config-derived one - the config names the port that was asked for, the listener names the port that was bound, and the stamp must carry the one that answers.
Sourcepub fn all_accounts_loaded(&self) -> bool
pub fn all_accounts_loaded(&self) -> bool
true when every [[accounts]] entry has reached a terminal
LoadingState AND the gateway’s listener is bound. The boot wave
and the cron spawn path gate on this - spawning while an account
is still resolving means the walk runs over a partial map, and
spawning before the listener is bound stamps a base URL nothing
answers - and a view reads it for its accounts card. Public so a
view can read the pool’s settledness without reaching into the
crate-private account map.
Sourcepub fn project_would_bind(&self, project_key: &ProjectKey) -> bool
pub fn project_would_bind(&self, project_key: &ProjectKey) -> bool
Whether a spawn in project_key would find an account: the
gateway’s walk over the project’s declared model, which is what a
spawn runs and what a project row’s refusal reads. false for a
project without a model, or one whose org serves nothing it
declares, which is the refusal a row draws.
Sourcepub(crate) fn project_walk_cooling(
&self,
project_key: &ProjectKey,
) -> Option<ProjectCooling>
pub(crate) fn project_walk_cooling( &self, project_key: &ProjectKey, ) -> Option<ProjectCooling>
The cooling that would refuse a spawn in project_key, or None
when a spawn would find an account.
Matches the walk’s budget failure alone, rather than taking any
error, because that failure is the one carrying a reset to wait
for - and only a refusal that clears on its own is owed a
deferral. The walk’s other failure is the config-level one, and
forge.toml refuses a project whose model no account in its org
serves (ProjectModelUndeclared), so it is not reachable from a
loaded config today. Keep the match narrow anyway: an empty walk
with nothing to wait for must fail as before rather than retry
forever, and this is the line that says so.
Sourcepub fn account_loading_snapshot(&self) -> Vec<AccountLoadingRow>
pub fn account_loading_snapshot(&self) -> Vec<AccountLoadingRow>
Snapshot of (AccountKey display name, LoadingState) pairs in
declaration order, read by the accounts surface. The order
matches forge.toml’s [[accounts]] declarations so the rows
sit next to the user’s mental model of which-account-is-which.
Sourcepub fn account_pool_view(&self) -> AccountPoolView
pub fn account_pool_view(&self) -> AccountPoolView
The account pool as a view draws it: the rows the home snapshot
carries, and the payload SessionUpdate::AccountsChanged pushes.
One builder for both paths, so a client folding the frame and a client reading the snapshot draw the same pool.
Sourcepub fn account_auth_for(&self, display_name: &str) -> Option<AccountAuth>
pub fn account_auth_for(&self, display_name: &str) -> Option<AccountAuth>
How the account called display_name authenticates - the input
the auth-repair hints branch on. None when the name isn’t a
configured account.
Sourcepub(crate) fn degraded_account_name(&self, account: &str) -> Option<String>
pub(crate) fn degraded_account_name(&self, account: &str) -> Option<String>
The account name back, when that account is saturated or bailed:
the walk takes one of those only when nothing else in the pin
declares the project’s model, so a spawn landing there is worth
saying out loud. None when it has room, and for an unknown name.
Sourcepub fn config_path(&self) -> PathBuf
pub fn config_path(&self) -> PathBuf
The forge.toml this workspace loaded. Preflight names it as
one of the two ways past an account that will not authenticate;
an edit applies to new work as the file changes. The other is
repairing the account’s own credentials, which a poller picks up
in place without one.
Sourcepub fn config_dir(&self) -> &Path
pub fn config_dir(&self) -> &Path
The config dir this workspace owns: forge.toml’s parent dir, and
the dir every session’s child runs under, so a session’s transcript
lives in its projects/ tree.
Sourcepub fn dictate_snapshot(&self) -> DictateSnapshot
pub fn dictate_snapshot(&self) -> DictateSnapshot
Per-model dictation progress for the preflight screen. Empty
models means [dictate] enabled is false and preflight has no
Dictation section to draw.
Sourcepub fn dictate_models_dir(&self) -> Option<PathBuf>
pub fn dictate_models_dir(&self) -> Option<PathBuf>
Where the dictation models land. None when the platform has no
usable cache directory and none was configured.
Sourcepub fn dictate_close(&self, key: &SessionSlot, initiator: u64) -> bool
pub fn dictate_close(&self, key: &SessionSlot, initiator: u64) -> bool
The connection that was streaming a take for key has gone: the
take is DROPPED, not submitted - its reader is gone, so nothing it
produced would land anywhere, and stopping it now spends nothing
further on it.
Only a take by THIS connection is closed: an entry with no sink is not a connection’s to close, and a take that ended with another one started since is not this connection’s to close. The seat is free the moment this returns, so a client that reconnects and starts again is not refused by the take it left behind. Answers whether there was one to close.
Sourcepub fn dictate_axes(&self) -> DictateAxes
pub fn dictate_axes(&self) -> DictateAxes
The dictate axes in force: forge.toml over the crate’s own
defaults. What a capturing client starts on and resets to.
Sourcepub fn dictate_push(
&self,
key: &SessionSlot,
samples: &[f32],
initiator: Option<u64>,
) -> bool
pub fn dictate_push( &self, key: &SessionSlot, samples: &[f32], initiator: Option<u64>, ) -> bool
Push one frame of client-captured audio into the seat’s live take, answering whether the samples were kept.
The take must be initiator’s own. A frame carries no seat, and
a seat’s live take can be another connection’s - decision 3’s own
scenario, where a client whose start was refused still has its
microphone open for the frames already on the wire - so a push from
any other connection is dropped rather than landing in someone
else’s dictation. false also means the seat has no live take
(never started, refused, or already resolved) or the take has
stopped (the cap, or the speaker let go). A caller drops the frame
rather than holding it, because the take’s own answer is what a
reader sees either way.
Sourcepub fn cancel_dictate_preflight(&self)
pub fn cancel_dictate_preflight(&self)
Stop the in-flight model fetch. Whatever reached the disk stays
there as a .part, so the next run resumes; the snapshot then
carries crate::DictateFailure::Cancelled and forge quits,
because there is no dictation-less runtime to fall back to.
Sourcepub fn start_dictate_preflight(self: &Arc<Self>)
pub fn start_dictate_preflight(self: &Arc<Self>)
Fetch, verify and load the dictation models. One task per forge run, started beside the account loaders; a no-op when dictation is switched off.
This is the prewarm the run keeps resident: the engine it parks in the state serves every take, so a press pays no model load, and the task it runs on is spawned rather than awaited, so none of the fetch, the verify or the load is on the boot path. The one event it writes says which of the two ended it.
The active models are resolved first - the config key, the runtime pick, or the compiled pin, per role - and the resolution’s failure is the same stop a failed fetch is, naming the config key that could not be answered.
Sourcepub fn session_chip_for(
&self,
project_key: &ProjectKey,
) -> Option<SessionChipInfo>
pub fn session_chip_for( &self, project_key: &ProjectKey, ) -> Option<SessionChipInfo>
Resolve project_key to the per-session chip the Projects pane
renders next to each row: the account a spawn in it would land
on, and that account’s current visual-state category. Returns
None when the project is unknown, declares no model, or its
org serves nothing it declares.
The walk is per org and model, so every row in a project shows the same account - which is what a spawn does.
State derivation:
LoadingState::Bailedsplits by the recorded failure class, the same split the account rows render: auth failures (Unauthorized/Expired) ->SessionChipState::Bailed(red with a⚠prefix); transient classes and an unrecorded bail ->Degraded(warning yellow, the pollers heal it).Ready+ any usage window at the cap (the sameis_saturatedsignal the walk demotes an account for) ->AtCap(yellow; the session still spawns but will throttle).- Every account serving the model inside a rotation cooldown ->
RateLimited, naming the account whose cooldown lifts first: the one the first spawn after the thaw lands on. The walk still refuses a spawn; only this read looks past the cooldown. - Otherwise ->
Normal(DIM; default chip).
Sourcefn select_account_for_project(
&self,
project: &LoadedProject,
model: &str,
) -> Result<AccountKey, Error>
fn select_account_for_project( &self, project: &LoadedProject, model: &str, ) -> Result<AccountKey, Error>
The account the gateway walks for a project’s declared model, with the walk’s failures turned into the vocabulary a spawn reports.
Sourcepub fn start_account_loading_tasks(self: &Arc<Self>)
pub fn start_account_loading_tasks(self: &Arc<Self>)
Spawn one boot-time loading task per [[accounts]] entry in
forge.toml. Each task runs the per-account loading state
machine (in the crate-private account_loader module) until
the account reaches a terminal LoadingState (Ready or
Bailed). Called once at startup, replacing the older
“single-probe-then-poll” boot model with the explicit loading
gate that the launchpad now consults via
AccountStateMap::all_loaded().
Sourcepub(crate) fn enqueue_kick(&self, request: KickRequest)
pub(crate) fn enqueue_kick(&self, request: KickRequest)
Enqueue a worker kick on the dispatcher channel (#259). The
drainer task fires one Command::Prompt per
KICK_DISPATCH_INTERVAL, so simultaneous boot-time kicks
across a team of N workers spread out as N × INTERVAL instead
of all hitting Anthropic’s per-IP burst limit in the same tick.
Send errors (channel closed - workspace has shut down) are
logged at error because they signal a kick was dropped after
maybe_kick_worker_on_connected already decided to send. The
worker stays idle in that case; this is rare in practice
(workspace shutdown drains all sessions before the kick path
could fire), but the log line preserves diagnosability.
Sourcepub fn start_kick_dispatcher(self: &Arc<Self>)
pub fn start_kick_dispatcher(self: &Arc<Self>)
Spawn the worker-kick drainer task (#259). Takes the receiver
out of kick_dispatcher_rx_slot and starts a tokio task that
loops on recv(), calls
Self::dispatch_forged_prompt for each request, then sleeps
KICK_DISPATCH_INTERVAL before the next pull.
Call once at construction, AFTER Workspace::new returns and
the result is Arc-wrapped (mirrors start_account_loading_tasks
/ start_usage_poller shape). Subsequent calls find the slot
empty and no-op, so a caller’s programming error can’t
duplicate the drainer.
The drainer holds an Arc::downgrade(self) so the task exits
cleanly when the workspace is dropped: each upgrade() returns
None and the loop breaks. No explicit shutdown signal needed.
Sourcepub fn start_gateway_listener(self: &Arc<Self>)
pub fn start_gateway_listener(self: &Arc<Self>)
Bind the gateway’s inference listener and open the boot gate once the port is ours. Called once at boot, from the server’s startup. On success the launchpad’s gate opens; on failure the gate STAYS SHUT and every spawn attempt are refused at the spawn entries with the reason attached - the refusal is the protection, not the log line.
Sourcepub fn sole_instance(&self) -> bool
pub fn sole_instance(&self) -> bool
Whether this is the only live forge on the machine: no other config dir’s lock is held.
A boot may change shared app-support state only when this is true. The test beside the store tests pins the peer case.
The app-support tree is machine-wide, so a boot that changes something shared in it - the browser host’s per-version prune - has to establish this first, or it deletes a peer’s state.
Sourcepub fn start_rotation_announcer(self: &Arc<Self>)
pub fn start_rotation_announcer(self: &Arc<Self>)
Announce the roster on every rotation change: a cooldown a live 429 starts (a write the gateway publishes) and the clock-driven thaw after it (which no write announces, so this task sleeps to it).
A project row’s would_bind is derived from the selection walk,
so both moments change what the home draws with nothing else to hang
an update on. Started once at boot; the registry makes a second call
a no-op, since a second watcher would double every frame.
Sourcepub fn start_usage_poller(self: &Arc<Self>)
pub fn start_usage_poller(self: &Arc<Self>)
Spawn the background account-usage poller on the interval
[usage] poll_seconds resolves to (60 s by default). Fetches
OAuth usage for every [[accounts]] entry via the per-
account config-dir’s credentials file (no Agent spawn
required), writes each result into AccountStateMap.by_key.
The session page draws its 5h/7d utilization from that cache,
and the gateway’s account selection reads it too.
Called once at boot, AFTER start_account_loading_tasks
(which subsumed the old spawn_initial_account_probe in #246).
The supervisor’s registry is the idempotency: a second call while
the poller is running is a no-op, so a caller’s programming error
can’t multiply the poll rate.
Sourcepub async fn refresh_account_usage_once(self: &Arc<Self>)
pub async fn refresh_account_usage_once(self: &Arc<Self>)
One pass of the usage poller: fetch OAuth usage for every
configured account in parallel, write each result back to
AccountStateMap. Per-account fetch errors are logged at
warn so persistent auth failures (revoked token, expired
refresh, missing credentials file) surface in default log
output. Snapshot-mapping errors stay at debug because they
indicate a response-shape drift rather than something the
user needs to act on. Public so tests can drive a
deterministic refresh without waiting for the poll tick.
Sourcepub(crate) fn record_usage_success(
&self,
key: &AccountKey,
snapshot: UsageSnapshot,
)
pub(crate) fn record_usage_success( &self, key: &AccountKey, snapshot: UsageSnapshot, )
Write one successful poll result. A healed account is immediately eligible for new spawns: the walk reads the state map directly, so there is nothing to recompute. Existing sessions keep the account they bound to.
Sourcepub fn usage_for(&self, display_name: &str) -> Option<UsageSnapshot>
pub fn usage_for(&self, display_name: &str) -> Option<UsageSnapshot>
The newest usage reading the pool holds for an account, by display
name: a rate-limited response’s windows when one landed after the
last successful probe, else that probe’s snapshot. None while the
account has reported nothing. A view renders the 5h / 7d bars from
this reading.
Sourcepub fn bound_account_for(&self, key: &SessionSlot) -> Option<AccountKey>
pub fn bound_account_for(&self, key: &SessionSlot) -> Option<AccountKey>
The account the gateway is serving key’s session with right
now, which a re-selection may have moved off the spawn-time
pick. The lookup goes through the pooled registration: bindings
are keyed by the segment the child’s base URL was stamped with,
which is the id the occupant runs under.
Sourcepub(crate) fn stamp_respawn_overrides(
&self,
slot: &SessionSlot,
session_id: &str,
launch_settings: &mut SessionLaunchSettings,
)
pub(crate) fn stamp_respawn_overrides( &self, slot: &SessionSlot, session_id: &str, launch_settings: &mut SessionLaunchSettings, )
Stamp a respawn’s gateway registration for a child that runs
under session_id: the four gateway-owned keys ride
launch_settings as overrides, the binding moves to that
segment, and the one it replaces is dropped. The pool entry
records the same id, so bound_account_for reads the binding the
child actually answers to. The gateway half is a no-op when the
slot is not pooled or carries no registration: the launch then
keeps the account env the original spawn laid down.
The tools forge has replaced are denied unconditionally, because a
respawn’s settings are built by a view and carry no spawn-time
flags - so /new and /resume are exactly where those denials
would otherwise come back. A worker’s own denials come back with
them: the kind decides the worktree pins, and the row’s own
interactive flag decides the question.
Sourcepub fn usage_error_for(&self, display_name: &str) -> Option<UsageFetchStatus>
pub fn usage_error_for(&self, display_name: &str) -> Option<UsageFetchStatus>
Read the last poll-attempt failure for an account, if any.
None when the most recent poll succeeded (or no attempt
has been made yet). The class is what tells an empty bar apart
from an upstream failure (the HTTP 429 case is especially common
when multiple forge instances poll the same Anthropic account).
Sourcepub(crate) fn resolve_slot(
&self,
target: &SessionTarget,
) -> Result<SessionSlot, WorkspaceError>
pub(crate) fn resolve_slot( &self, target: &SessionTarget, ) -> Result<SessionSlot, WorkspaceError>
Resolves a SessionTarget to the slot it names. A
project-rooted target resolves to that project’s lead slot; a
specific session names its own. For Named with no matching
project, returns WorkspaceError::ProjectNotFound.
Sourcefn project_for_target(&self, target: &SessionTarget) -> Option<LoadedProject>
fn project_for_target(&self, target: &SessionTarget) -> Option<LoadedProject>
The project a spawn under target belongs to. A project-rooted
target names it directly; a specific session slot names it by
org and name.
Sourcepub(crate) fn project_for_slot(
&self,
slot: &SessionSlot,
) -> Option<LoadedProject>
pub(crate) fn project_for_slot( &self, slot: &SessionSlot, ) -> Option<LoadedProject>
The project slot names, if forge.toml still declares it.
Sourcepub(crate) fn slack_workspace_for_slot(
&self,
slot: &SessionSlot,
) -> Option<String>
pub(crate) fn slack_workspace_for_slot( &self, slot: &SessionSlot, ) -> Option<String>
The Slack workspace bound to slot’s project: the project’s own
binding, else its org’s. None when the slot names no configured
project or nothing binds one - both leave the Slack tools refusing
with the key to add.
Sourcefn find_project_by_name(
&self,
name: &str,
) -> Result<LoadedProject, WorkspaceError>
fn find_project_by_name( &self, name: &str, ) -> Result<LoadedProject, WorkspaceError>
Look up a project by name from forge.toml. Returns
WorkspaceError::ProjectNotFound when no project carries
that name.
Sourcepub(crate) fn stored_charter_for(&self, slot: &SessionSlot) -> Option<String>
pub(crate) fn stored_charter_for(&self, slot: &SessionSlot) -> Option<String>
The charter the store holds for slot, if any. A worker’s mission
lives in its conversation, so a /new that emptied it has to
re-deliver this or the worker comes back with no idea what it is
for. Only a worker has one, and a lead’s absence is the ordinary
answer here.
Sourcepub(crate) fn stored_interactive_for(&self, slot: &SessionSlot) -> Option<bool>
pub(crate) fn stored_interactive_for(&self, slot: &SessionSlot) -> Option<bool>
Whether the store’s row for slot says its session may ask its user
a question. None when the store holds no row: a lead has none, and
a worker without one was never spawned.
Sourcefn stored_resume_id(
&self,
slot: &SessionSlot,
force_new: bool,
) -> Result<Option<String>, WorkspaceError>
fn stored_resume_id( &self, slot: &SessionSlot, force_new: bool, ) -> Result<Option<String>, WorkspaceError>
The id the store holds for slot, unless --new (force_new)
forces a fresh session. Ok(Some(id)) => resume that id;
Ok(None) => start fresh under a minted one. force_new
overrides a stored id - that is what makes the boot wave’s leads
come up fresh under --new, while every non-boot spawn leaves
force_new false and resumes.
The store is the only source. A row with no id is a session that has never run, so the caller mints rather than re-deriving one from the transcripts. A store that cannot be read is an error rather than a mint: the session it names would be forked, and the new id written over the row that could not be read.
Sourcefn record_spawn_id(
&self,
slot: &SessionSlot,
id: Option<String>,
reopen: bool,
) -> String
fn record_spawn_id( &self, slot: &SessionSlot, id: Option<String>, reopen: bool, ) -> String
Record id as the session for slot, minting a fresh one when
the caller has none to hand in. A spawn leaves the seat open with
no turn in flight; the boot wave read its pre-spawn state already,
and the turn its note starts flips it back.
Sourcepub(crate) fn record_seat_state(&self, slot: &SessionSlot, state: SeatState)
pub(crate) fn record_seat_state(&self, slot: &SessionSlot, state: SeatState)
Move slot’s row to state. A write that cannot land is warned and
not retried: the row is what the next boot derives from, and a
missing write reads as the state it held before. A closed row is
left closed; only the spawn path reopens it.
Sourcefn write_seat_state(&self, slot: &SessionSlot, state: SeatState, reopen: bool)
fn write_seat_state(&self, slot: &SessionSlot, state: SeatState, reopen: bool)
The one write behind Self::record_seat_state and the spawn’s
reopen. reopen is the opening door’s word, not a turn’s: a spawn
the user or the boot wave asked for may open a closed seat, while a
settle or a swap landing late must not.
Sourcefn restore_seat_state(
&self,
slot: &SessionSlot,
state: Option<SeatState>,
expected: SeatState,
)
fn restore_seat_state( &self, slot: &SessionSlot, state: Option<SeatState>, expected: SeatState, )
Put slot’s row back to the state it held before a refused spawn.
Not a transition: the value is written verbatim, None included.
Sourcepub(crate) fn stored_seat_state(&self, slot: &SessionSlot) -> Option<SeatState>
pub(crate) fn stored_seat_state(&self, slot: &SessionSlot) -> Option<SeatState>
The state slot’s row holds, or None when the row is absent, was
written before the field existed, or could not be read.
Sourcefn forget_spawn_id(&self, slot: &SessionSlot, previous: Option<&str>)
fn forget_spawn_id(&self, slot: &SessionSlot, previous: Option<&str>)
Undo a spawn’s recorded id after a bind refuses: the store keeps the id it held before, or none at all, so a boot never resumes onto a transcript that never existed.
Sourcepub(crate) fn note_running_session_id(
&self,
slot: &SessionSlot,
session_id: &str,
)
pub(crate) fn note_running_session_id( &self, slot: &SessionSlot, session_id: &str, )
Record the id the CLI adopted for a live session, so the store
stops disagreeing with what is running. An in-session /resume, a
/clear, a login or a logout can move a session’s id without
forge choosing it, and a boot resolves a session from this row.
slot is the one the task is registered under, so the row is
named rather than derived: a session’s role is what its spawn
stated and nothing re-derives it from the live-worker registry.
Sourcefn note_worker_session_id(&self, slot: &SessionSlot, session_id: &str)
fn note_worker_session_id(&self, slot: &SessionSlot, session_id: &str)
Mirror the id a live worker adopted onto its registry entry, so
the status echo and agents__list name the occupant that is
running rather than the one it was spawned under.
Sourcepub fn running_session_id_for(&self, slot: &SessionSlot) -> Option<String>
pub fn running_session_id_for(&self, slot: &SessionSlot) -> Option<String>
The id the session at slot runs under, when this process holds
it live. None for a slot with no pooled session. This is the
slot’s occupant rather than a snapshot of when a caller last
heard about it, so a read that has to name the id now - a
transcript path, a CLI argument - asks here.
Sourcepub fn transcript_session_id_for(&self, slot: &SessionSlot) -> Option<String>
pub fn transcript_session_id_for(&self, slot: &SessionSlot) -> Option<String>
The id a transcript read for slot is looked up under: the live
occupant’s when this process holds one, and the store’s own row -
the session a wake resumes - otherwise.
A sleeping seat still has a session. The store records the id under
the slot before the child starts and the CLI writes its transcript
under it, so a sleeping seat’s history is readable without a spawn; a
read that named only pooled sessions drew one empty. None for a
slot with no session at all: nothing live, and no stored id.
Sourcepub(crate) fn session_is_pooled(&self, slot: &SessionSlot) -> bool
pub(crate) fn session_is_pooled(&self, slot: &SessionSlot) -> bool
Whether an agent is currently pooled for slot.
Sourcefn pooled_handle_has_task(&self, slot: &SessionSlot) -> bool
fn pooled_handle_has_task(&self, slot: &SessionSlot) -> bool
Whether the slot’s pooled handle still has a session task consuming its commands. The teardown signal is the task’s own record of itself: unanswered while it runs, answered as it drops. A pooled handle with no such task is one no spawn may hand back (#2683).
Sourcepub fn session_last_activity(&self, slot: &SessionSlot) -> Option<SystemTime>
pub fn session_last_activity(&self, slot: &SessionSlot) -> Option<SystemTime>
When the transcript of the session at slot was last written,
read from the catalog row naming the id it currently runs under.
None for a slot with no live session, or one whose transcript
is not on disk yet.
Sourcepub async fn resumable_sessions(
&self,
slot: &SessionSlot,
) -> Vec<SDKSessionInfo>
pub async fn resumable_sessions( &self, slot: &SessionSlot, ) -> Vec<SDKSessionInfo>
The sessions the seat at slot can jump back to with /resume,
newest first, and never the one it is already on.
The set is what claude --resume can find from the directory the
seat’s own resume runs in (Self::cwd_for_session, which is what
--resume is handed): a lead’s project root, a worker’s worktree, or
the project root for a worker with none. A lead gets its project’s own
sessions and never its workers’ - Workers::Hidden, which is what
keeps a lead’s turns out of a transcript tagged for a worker - while a
worker gets only the transcripts carrying its own label.
A lead is answered from the boot catalog’s group for its project,
which the boot scan already read from disk and holds in memory, with
each row kept only if its transcript is still there: the catalog also
mirrors every Connected, including a session that never wrote a turn,
and --resume cannot find that one. A worker’s runs are not in the
catalog, so its own directory is scanned - its worktree for a git
worker, the project root otherwise. A seat whose project forge has not
loaded has no directory to resume from, and answers empty.
Sourcefn catalog_sessions_for(&self, cwd: &str) -> Option<Vec<SDKSessionInfo>>
fn catalog_sessions_for(&self, cwd: &str) -> Option<Vec<SDKSessionInfo>>
The boot catalog’s group for the project at cwd, or None while the
boot scan has not landed: a group missing because nothing has scanned
is not a project with no sessions.
Sourcefn transcript_dir_for(&self, cwd: &str) -> PathBuf
fn transcript_dir_for(&self, cwd: &str) -> PathBuf
The directory claude writes cwd’s transcripts in, under this
workspace’s config dir.
Sourcefn session_scan_root(&self) -> PathBuf
fn session_scan_root(&self) -> PathBuf
Where this workspace’s projects/ tree resolves, with the boot scan’s
own fallbacks.
Sourceasync fn scan_sessions(
&self,
cwd: &str,
workers: Workers,
) -> Vec<SDKSessionInfo>
async fn scan_sessions( &self, cwd: &str, workers: Workers, ) -> Vec<SDKSessionInfo>
One directory scanned for the sessions a seat may resume - a worker’s own runs, and a lead’s before the boot catalog has landed - through the store’s tag cache like the boot scan: an unchanged transcript is not re-read end to end for a list a reader opens for a moment.
The cap bounds the reads as well as the answer: the scan reads the newest transcripts first and stops once the rows it serves are in hand, filling from the next batch when one comes back short - and a transcript a worker’s cached tag rules out is not read at all (#2567), so a non-git worker sharing the project root no longer pays the project’s whole scan. A lead takes the catalog above for a list that costs no read at all.
Sourcepub(crate) fn fresh_session_id_for(&self, slot: &SessionSlot) -> String
pub(crate) fn fresh_session_id_for(&self, slot: &SessionSlot) -> String
A fresh id for a session starting now, recorded against slot’s
row.
Sourcefn stored_session_id(
&self,
org: &str,
project: &str,
label: &str,
) -> Result<Option<String>, Error>
fn stored_session_id( &self, org: &str, project: &str, label: &str, ) -> Result<Option<String>, Error>
The session id the store holds for (org, project, label).
Ok(None) means no id: the row is absent, or it carries none.
That is a session which has never run, and a caller may mint for
it. Err means the row could not be read, which is a different
answer and must never be treated as absence: a caller that minted
there would fork the session and write the new id over the row it
failed to read.
Sourcepub(crate) fn record_session_id(
&self,
org: &str,
project: &str,
label: &str,
id: &str,
)
pub(crate) fn record_session_id( &self, org: &str, project: &str, label: &str, id: &str, )
Record id as the session for (org, project, label), keeping the
fields a worker’s row already carries. A write that cannot land is
warned and not retried: the session still runs under id, and the
next boot reads whatever the row held.
Sourcepub(crate) fn find_project_view_by_name(
&self,
name: &str,
) -> Option<LoadedProject>
pub(crate) fn find_project_view_by_name( &self, name: &str, ) -> Option<LoadedProject>
Locate a ProjectView-like (LoadedProject) by name from
forge.toml. Returns None when no project carries that name.
Used by the spawn handlers to resolve the project’s path / cwd
before emitting SessionUpdate::Spawning.
Sourcepub(crate) fn lead_slot_for_project(
&self,
project_name: &str,
) -> Option<SessionSlot>
pub(crate) fn lead_slot_for_project( &self, project_name: &str, ) -> Option<SessionSlot>
The seat a project-scoped section update routes on: the project’s
lead, which exists for every declared project. None for a name no
project carries, the shape a project that left forge.toml leaves.
Sourcepub(crate) fn update_tx(&self) -> &UpdateFanout
pub(crate) fn update_tx(&self) -> &UpdateFanout
Internal accessor for the SessionUpdate fan-out sender. Used
by spawn.rs to emit Spawning / ConnectionFailed /
FatalError from the App-level handlers.
Sourcepub(crate) fn announce_accounts_changed(&self)
pub(crate) fn announce_accounts_changed(&self)
Tell every view the account pool moved.
Called from the writes that move it - a boot probe settling, the usage
poller landing or failing, and the listener binding - because none of
them belongs to a seat. The frame carries the pool’s drawn state, built
by Self::account_pool_view, so a subscriber applies a usage-only
move without a home re-read; a move in what a project row binds on
still asks for the snapshot that refreshes it.
Sourcefn session_cwd_for(&self, slot: &SessionSlot) -> Option<String>
fn session_cwd_for(&self, slot: &SessionSlot) -> Option<String>
The cwd a project lead’s slot runs in: its project’s path.
claude --resume indexes by the project key derived from the
subprocess’s working directory, so every explicit-resume code
path must spawn the subprocess in the session’s original cwd or
the resume hits “No conversation found with session ID …” even
when the .jsonl exists.
Read from the slot rather than from the catalog: a catalog row is
keyed by the id the CLI adopted, which /new replaces, so the
lookup answered nothing for the session that had just started and
resume was handed an empty working directory.
A WORKER answers nothing here, deliberately. Its cwd is its
worktree, which only the live-worker registry can compose; the
project root is a plausible-looking wrong answer that would shadow
the registry arm in Self::cwd_for_session and send a git
worker’s resume to the wrong directory.
Sourcepub fn record_connected_session(
&self,
cwd: &str,
session_id: &str,
summary: Option<String>,
)
pub fn record_connected_session( &self, cwd: &str, session_id: &str, summary: Option<String>, )
Insert (or update) a session entry under the project that
owns cwd. Called after every Connected migration so newly
spawned sessions surface in the
Projects-pane drilldown immediately, without forcing a full
disk re-scan. The entry is placed at the head of the
project’s session list to match the most-recent-first ordering
the scan produces.
Sourcepub fn subscribe_mirror(&self) -> UnboundedReceiver<SeatUpdate>
pub fn subscribe_mirror(&self) -> UnboundedReceiver<SeatUpdate>
Subscribe without answering the workspace’s prompts: a logger, a mirror, anything that reads the stream but renders no prompt.
Every caller gets its own stream, so a second view attaches beside the first rather than being refused, and the workspace drops the matching subscription once its receiver goes. A stream carries what is emitted after this call; a subscriber reads what it missed from the subject’s snapshot instead.
The distinction from Self::subscribe_answerer is load-bearing
rather than descriptive. A permission, question or Slack-draft
request is parked on a reply, and the paths that raise one resolve it
Cancelled instead when no subscriber can answer, so that a request
nobody will reply to fails the turn rather than hanging it. A
subscriber that declares itself an observer is not counted as an
answer.
A browser hand-off fails closed differently: nothing parks a decision for it to cancel, so its registration hands the blocked call a dead receiver instead and the tool answers the reason - no attached client can show the hand-off - rather than holding a session on a prompt no view can draw.
Every frame arrives as a SeatUpdate, carrying the number its
seat minted for it.
Sourcepub fn subscribe_answerer(&self) -> UnboundedReceiver<SeatUpdate>
pub fn subscribe_answerer(&self) -> UnboundedReceiver<SeatUpdate>
Subscribe as an answerer: the same stream, for a client that renders prompts and can answer them.
The answering half of Self::subscribe_mirror. The role decides one
thing: whether the core may park a prompt on this subscriber’s reply.
Sourcepub(crate) fn update_sender(&self) -> UpdateFanout
pub(crate) fn update_sender(&self) -> UpdateFanout
Clone the workspace’s SessionUpdate sender. Internal to this
crate: a view’s own async work belongs on a channel of its own,
so that the subscription methods above are the whole of what the core
owes a frontend.
Sourcepub fn seat_next_seq(&self, key: &SessionSlot) -> u64
pub fn seat_next_seq(&self, key: &SessionSlot) -> u64
The number key’s next frame will be minted under: what a client
reading the seat expects next, beside the occupant that names the
epoch it belongs to.
Sourcepub fn seat_frames_between(
&self,
key: &SessionSlot,
from: u64,
to: u64,
) -> Vec<SeatUpdate>
pub fn seat_frames_between( &self, key: &SessionSlot, from: u64, to: u64, ) -> Vec<SeatUpdate>
The frames key’s stream emitted under the numbers in from..=to,
oldest first: the answer to a client’s range ask while the seat’s own
window holds them.
Sourcepub fn seat_oldest_kept_seq(&self, key: &SessionSlot) -> Option<u64>
pub fn seat_oldest_kept_seq(&self, key: &SessionSlot) -> Option<u64>
The oldest number key’s window still holds, None for a seat that
has emitted nothing: what tells a range answered from memory from one
that has to be read off the transcript.
Sourcepub fn seat_unringed_between(
&self,
key: &SessionSlot,
from: u64,
to: u64,
) -> u64
pub fn seat_unringed_between( &self, key: &SessionSlot, from: u64, to: u64, ) -> u64
How many of the numbers in from..=to on key’s stream no window
holds: the frames that close an epoch are numbered but kept out of the
ring, so a reader measuring whether a copy covers a stretch leaves them
out of what it must account for.
Sourcepub fn seat_epoch_opened_with_rows(&self, key: &SessionSlot) -> bool
pub fn seat_epoch_opened_with_rows(&self, key: &SessionSlot) -> bool
Whether key’s latest epoch-opening seal carried rows, which no ring
holds: what tells a copy filled from the window that it cannot reach
the rows the epoch began with.
Sourcepub fn domain_session_for(
&self,
key: &SessionSlot,
) -> Option<Arc<Mutex<DomainSession>>>
pub fn domain_session_for( &self, key: &SessionSlot, ) -> Option<Arc<Mutex<DomainSession>>>
Borrow the workspace-side DomainSession for key.
Returns the Arc-cloned mutex protecting the domain bucket.
Callers .lock() to read or mutate. None when no
SessionTask is registered for key (e.g., the session was
closed or hasn’t been spawned yet).
Callers should hold the lock for the shortest scope possible;
concurrent reducers and the per-session SessionTask share
this mutex.
Sourcepub fn available_commands_for(
&self,
slot: &SessionSlot,
) -> Vec<AvailableCommand>
pub fn available_commands_for( &self, slot: &SessionSlot, ) -> Vec<AvailableCommand>
The slash commands the CLI last advertised for the session at
slot. Empty for a slot whose session has not connected yet, or
whose CLI advertised none.
Sourcepub fn available_agents_for(&self, slot: &SessionSlot) -> Vec<AvailableAgent>
pub fn available_agents_for(&self, slot: &SessionSlot) -> Vec<AvailableAgent>
The subagents the CLI last advertised for the session at slot.
Empty in the same two cases as Self::available_commands_for.
Sourcepub fn subagent_cards_for(&self, slot: &SessionSlot) -> Vec<SubagentCard>
pub fn subagent_cards_for(&self, slot: &SessionSlot) -> Vec<SubagentCard>
The sub-agent instances the session at slot has folded, as the list
the session task pushes when it moves.
A record reads this rather than folding the conversation itself. The task folds a card per frame and announces the list moving, so a second fold over the held conversation would be a second answer to a question one fold already answers - and one that disagrees with the frames as soon as the conversation a record can see is shorter than the one the session has run.
Sourcepub fn has_agent_for(&self, key: &SessionSlot) -> bool
pub fn has_agent_for(&self, key: &SessionSlot) -> bool
Whether the session at key currently has a live agent
handle stamped onto its DomainSession. Encapsulates the
presence check so callers don’t need to peek at
DomainSession.conn directly - the field layout is a
workspace internal.
Sourcefn apply_dictate_override(
self: &Arc<Self>,
key: &SessionSlot,
update: DictateOverrideUpdate,
) -> Result<(), DispatchError>
fn apply_dictate_override( self: &Arc<Self>, key: &SessionSlot, update: DictateOverrideUpdate, ) -> Result<(), DispatchError>
Apply a /dictate override edit to the session’s DomainSession
and echo the full set back. An unknown session is refused the same
way the per-session commands are: there is nothing to edit.
§Errors
Returns DispatchError::UnknownSession when no DomainSession
is registered for the key.
Sourcepub fn dispatch_workspace_prompt(
self: &Arc<Self>,
key: &SessionSlot,
text: String,
) -> Result<(), DispatchError>
pub fn dispatch_workspace_prompt( self: &Arc<Self>, key: &SessionSlot, text: String, ) -> Result<(), DispatchError>
Dispatch a workspace-originated plain prompt (cron fire, peer, gotify or slack delivery, kick, notices).
A delivery drawn in a view needs an update here, paired with an arm
in forge-server’s delivery_turn. This path emits no frame of its
own - the forge for one is delivery_turn, downstream, and it ends in
_ => return None. So a prompt sent from here whose typed update has
no arm there draws nothing in any view, silently: a prompt with no
envelope of its own takes Self::dispatch_forged_prompt instead,
which forges the frame here.
Sourcepub fn dispatch_workspace_prompt_from(
self: &Arc<Self>,
key: &SessionSlot,
text: String,
source: PromptSource,
) -> Result<(), DispatchError>
pub fn dispatch_workspace_prompt_from( self: &Arc<Self>, key: &SessionSlot, text: String, source: PromptSource, ) -> Result<(), DispatchError>
Self::dispatch_workspace_prompt, with the row’s source label named
by the caller: a delivery knows what it is (a cron fire, a Gotify
notification, a peer comm) and the queued row has to say so.
Sourcepub fn dispatch_workspace_prompt_under(
self: &Arc<Self>,
key: &SessionSlot,
text: String,
source: PromptSource,
uuid: String,
) -> Result<(), DispatchError>
pub fn dispatch_workspace_prompt_under( self: &Arc<Self>, key: &SessionSlot, text: String, source: PromptSource, uuid: String, ) -> Result<(), DispatchError>
Self::dispatch_workspace_prompt_from with the id supplied by the
caller, for a site that already minted one: a delivery that draws its
own envelope row passes the same id to both, so the row a view holds
while the prompt queues and the prompt’s lifecycle frames are one thing
by id.
Sourcepub(crate) fn fire_auto_continue(self: &Arc<Self>, key: &SessionSlot)
pub(crate) fn fire_auto_continue(self: &Arc<Self>, key: &SessionSlot)
Fire the continuation for a seat whose turn just failed: one prompt of forge’s own, the moment the failure lands (#2146).
Every non-cancelled failed turn is this one’s to answer, whatever its class - a transient server error included, which nobody’s retry ladder throws back at the CLI once the turn has died. The decision is taken under the lock; the dispatch is not.
Sourcepub fn dispatch_forged_prompt(
self: &Arc<Self>,
key: &SessionSlot,
text: String,
) -> Result<(), DispatchError>
pub fn dispatch_forged_prompt( self: &Arc<Self>, key: &SessionSlot, text: String, ) -> Result<(), DispatchError>
Dispatch a workspace-originated prompt whose words no view has drawn:
a worker kick, an auto-continue. Self::dispatch_workspace_prompt
exactly, plus the frame - nothing else carries these words, where a
delivery’s own envelope update is what draws a delivery, and routing
one of those through here would draw its words twice.
Sourcepub(crate) fn dispatch_forged_prompt_under(
self: &Arc<Self>,
key: &SessionSlot,
text: String,
uuid: String,
) -> Result<(), DispatchError>
pub(crate) fn dispatch_forged_prompt_under( self: &Arc<Self>, key: &SessionSlot, text: String, uuid: String, ) -> Result<(), DispatchError>
Self::dispatch_forged_prompt with the id supplied by the caller,
for a forged prompt an association has to outlive the dispatch: the
auto-continue records the id it sent under, so the prompt can be
matched where it runs. The frame and the prompt carry one id either
way - the row a view draws and the turn the model receives are one
thing by id.
Sourcepub(crate) fn dispatch_handoff_brief(
self: &Arc<Self>,
key: &SessionSlot,
text: String,
uuid: String,
) -> Result<(), DispatchError>
pub(crate) fn dispatch_handoff_brief( self: &Arc<Self>, key: &SessionSlot, text: String, uuid: String, ) -> Result<(), DispatchError>
The brief a handoff’s successor runs as its first turn.
Its own row, forged like the machinery’s other prompts. The successor’s transcript opens with this prompt and the client draws it as the handoff procedure; the CLI never echoes a stdin prompt, so this frame is the page’s only carrier of it.
Sourcepub(crate) fn dispatch_handoff_prompt(
self: &Arc<Self>,
key: &SessionSlot,
text: String,
uuid: String,
) -> Result<(), DispatchError>
pub(crate) fn dispatch_handoff_prompt( self: &Arc<Self>, key: &SessionSlot, text: String, uuid: String, ) -> Result<(), DispatchError>
The handoff’s own judgment prompt.
The one prompt a swap’s gate lets through. The gate opens when the handoff begins, and this is the prompt the handoff is built on: held like any other, it would wait for a successor that is waiting for it.
fn dispatch_forged_prompt_held( self: &Arc<Self>, key: &SessionSlot, text: String, uuid: String, holdable: bool, ) -> Result<(), DispatchError>
Sourcefn tell_row_owner(self: &Arc<Self>, task: &Task, text: String)
fn tell_row_owner(self: &Arc<Self>, task: &Task, text: String)
Tell a row’s live owner what the user just did to it, one line.
Forged, not plain. The board’s edits carry no envelope update of their own, so a prompt routed the plain way queues invisibly in a view - the forged frame is what draws the line the moment it lands. Silent for an ownerless row and a sleeping seat: the board is where that edit shows, and a prompt is a real turn.
Sourcefn lane_words(to: TaskStatus) -> &'static str
fn lane_words(to: TaskStatus) -> &'static str
What a board notice calls the lane a move landed in: the board’s own
names, so Pending reads backlog as its lane does.
Sourcepub fn dispatch(self: &Arc<Self>, cmd: Command) -> Result<(), DispatchError>
pub fn dispatch(self: &Arc<Self>, cmd: Command) -> Result<(), DispatchError>
Route a Command. Per-session commands (cmd.key() == Some(key)) fan out to the matching SessionTask. App-level
commands (cmd.key() == None - SpawnProject) route to the
workspace’s own handler.
Test fallback: when no SessionTask is registered for key
but a DomainSession carries a stub AgentHandle (e.g., the
Workspace::testing_stub path), the command runs
synchronously against that handle. This keeps #[test]-flavor
unit tests (no tokio runtime) able to observe the
forge_primitives::AgentCommand emitted on the stub’s channel
without spinning up an async actor.
§Errors
Returns DispatchError::UnknownSession when no SessionTask
is registered for the requested key (e.g., the session was just
closed), with one exception: a ResumeSession starts the seat on the
id it names, which is the recovery a failed start leaves. Returns
DispatchError::SessionClosed when the task’s command receiver has
been dropped.
Sourcepub fn dispatch_from_view(
self: &Arc<Self>,
cmd: Command,
) -> Result<(), DispatchError>
pub fn dispatch_from_view( self: &Arc<Self>, cmd: Command, ) -> Result<(), DispatchError>
Dispatch one command a view sent over the socket.
The same dispatch as Self::dispatch: the frame a prompt draws is
the session task’s, emitted when the CLI starts it, so no entry needs
to say which door the words came through.
§Errors
As Self::dispatch.
Sourcefn dispatch_answered(
self: &Arc<Self>,
cmd: Command,
) -> Result<(), DispatchError>
fn dispatch_answered( self: &Arc<Self>, cmd: Command, ) -> Result<(), DispatchError>
Route a command, answering the prompts forge handles itself.
A prompt draws its turn in the session task, never here (#2816): the queue carries its words from dispatch and the page takes the row when the CLI starts it. Only the answer a forge command, a misuse or an unrunnable slash name owes is emitted from this path, because the task never sees those prompts.
NOT from dispatch_workspace_prompt, which is the delivery path: a
delivery already draws as an envelope turn of its own.
Sourcefn run_forge_prompt(
self: &Arc<Self>,
key: &SessionSlot,
prompt: &ForgePrompt,
) -> (Result<(), DispatchError>, Option<(NoticeSeverity, String)>)
fn run_forge_prompt( self: &Arc<Self>, key: &SessionSlot, prompt: &ForgePrompt, ) -> (Result<(), DispatchError>, Option<(NoticeSeverity, String)>)
Run one of forge’s own commands against key’s seat.
The launch settings are built here rather than taken from the caller: a view that supplied its own would spawn a session with what its own snapshot happened to hold, and a client has no snapshot to supply.
The answer is returned rather than emitted, so the dispatcher can land it after the command has run.
Sourcefn show_mode(
&self,
key: &SessionSlot,
) -> (Result<(), DispatchError>, Option<(NoticeSeverity, String)>)
fn show_mode( &self, key: &SessionSlot, ) -> (Result<(), DispatchError>, Option<(NoticeSeverity, String)>)
A bare /mode answers what the session is on, not what the
command’s usage is (#1626). The hook’s observation outranks the
launch’s own setting - the precedence the inspector draws - because
the CLI can change mode without re-emitting status. A session that
has reported neither is answered with the usage line it answered
before.
Sourcefn show_effort(
&self,
key: &SessionSlot,
) -> (Result<(), DispatchError>, Option<(NoticeSeverity, String)>)
fn show_effort( &self, key: &SessionSlot, ) -> (Result<(), DispatchError>, Option<(NoticeSeverity, String)>)
A bare /effort answers the level in force, on show_mode’s
terms: the observed level, else the one the launch asked for (#1626).
Sourcepub(crate) fn replace_browser_seat(&self, seat: &SessionSlot)
pub(crate) fn replace_browser_seat(&self, seat: &SessionSlot)
Tell the fleet a /new or /resume kept the slot and swapped the
occupant, so the previous occupant’s tabs close. Best-effort, like
the spawn’s own warm: a workspace with no fleet has no tabs to replace.
Sourcefn release_browser_seat(&self, seat: &SessionSlot)
fn release_browser_seat(&self, seat: &SessionSlot)
A session ended, told to the fleet as a fact rather than left to the next sweep: a sweep rebuilds its set from configuration, so a closed lead is still in it. The tombstone is synchronous, so it lands before the release work; the tab close is the fleet’s own async work.
Sourcefn spawn_inputs(&self, key: &SessionSlot) -> (String, SessionLaunchSettings)
fn spawn_inputs(&self, key: &SessionSlot) -> (String, SessionLaunchSettings)
The cwd and settings a re-spawn on key carries.
Sourcefn resume_without_task(
self: &Arc<Self>,
key: &SessionSlot,
session_id: &str,
launch_settings: SessionLaunchSettings,
) -> Result<(), DispatchError>
fn resume_without_task( self: &Arc<Self>, key: &SessionSlot, session_id: &str, launch_settings: SessionLaunchSettings, ) -> Result<(), DispatchError>
Start a seat on an id when no task is running for it: the /resume <old-id> recovery a failed handoff names.
A lead goes through the project wake, so a failed start surfaces the
same ConnectionFailed and the same fallback a wake has. The id named
is recorded first because that path resumes the slot’s stored id; it
names a transcript that already exists, so a failed start leaves a real
id rather than an unrun one. A worker goes through the spawn its boot
re-spawn uses, with the row’s own charter, tree and family selection.
Sourcefn fill_launch_settings(
&self,
launch_settings: &mut SessionLaunchSettings,
cwd: Option<&str>,
)
fn fill_launch_settings( &self, launch_settings: &mut SessionLaunchSettings, cwd: Option<&str>, )
Fill in the launch settings a caller did not build.
A caller with its own snapshot of the documents builds them and hands them over; a client has no config to read, so a spawn it asks for arrives with none, and a spawn that ran on those alone would carry no language, no model and the CLI’s own defaults for permissions, effort and output style - a session launched differently from one started with the documents in hand.
Read from the workspace’s own config dir, which is what a session with no account-scoped dir of its own runs under. A caller bound to an account builds its own from that account, so the two can differ where an account names a dir of its own - and where they do, the caller that supplied settings keeps them.
Sourcefn set_effort(
self: &Arc<Self>,
key: &SessionSlot,
level: &str,
) -> (Result<(), DispatchError>, Option<(NoticeSeverity, String)>)
fn set_effort( self: &Arc<Self>, key: &SessionSlot, level: &str, ) -> (Result<(), DispatchError>, Option<(NoticeSeverity, String)>)
/effort <level>: write the level the next launch reads.
A settings write rather than a session command - the CLI carries no control request for effort - so it lands in the same document the launch builder reads and takes effect when the session next starts.
Sourcefn unrunnable_slash_name(&self, key: &SessionSlot, text: &str) -> Option<String>
fn unrunnable_slash_name(&self, key: &SessionSlot, text: &str) -> Option<String>
The line to answer a slash name this session cannot run, or None
when the text is the CLI’s to read.
What a session can run is what its CLI advertised, or one of the names
the CLI resolves without advertising (crate::prompt::FORWARDED).
A session that has advertised nothing refuses nothing: an empty
catalogue is not knowing, and refusing on ignorance would drop names
the CLI has.
Sourcepub(crate) fn notice(
&self,
key: &SessionSlot,
severity: NoticeSeverity,
text: &str,
)
pub(crate) fn notice( &self, key: &SessionSlot, severity: NoticeSeverity, text: &str, )
Emit one line the core has for a view about key.
Sourcepub(crate) fn launch_settings_for(
&self,
key: &SessionSlot,
cwd: &str,
) -> SessionLaunchSettings
pub(crate) fn launch_settings_for( &self, key: &SessionSlot, cwd: &str, ) -> SessionLaunchSettings
The settings a launch on key carries, read from the same documents
the CLI reads.
Sourcepub(crate) fn route(self: &Arc<Self>, cmd: Command) -> Result<(), DispatchError>
pub(crate) fn route(self: &Arc<Self>, cmd: Command) -> Result<(), DispatchError>
Route a command that carries no frame of its own.
Sourcefn board_edit_refused(&self, what: &str, why: &str)
fn board_edit_refused(&self, what: &str, why: &str)
Say on the service line why a board edit did not land: a refusal a reader cannot see is indistinguishable from a press that did nothing.
Sourcepub(crate) fn dispatch_worker_respawns(
self: &Arc<Self>,
lead: &SessionSlot,
project_key: &ProjectKey,
dynamic: &[SessionRecord],
force_new: bool,
)
pub(crate) fn dispatch_worker_respawns( self: &Arc<Self>, lead: &SessionSlot, project_key: &ProjectKey, dynamic: &[SessionRecord], force_new: bool, )
Re-spawn this project’s persisted workers on lead reconnect, resuming by label off the id the store holds for that label. Charter and kick come from the DB row.
Since this holds the worker set, it decides the kick directly:
a resume of a worker whose row says in_turn takes the row’s own
resume_kick and falls back to the forge restart note, telling it
to continue rather than restart; a resume of a parked worker gets
nothing, and a fresh re-spawn (never prompted, so no store row)
re-delivers the stored kick. maybe_kick_worker_on_connected then
just delivers whatever this put on the WorkerEntry.
Sourcepub(crate) fn respawn_workers_for_lead(
self: &Arc<Self>,
lead: &SessionSlot,
project_key: ProjectKey,
force_new: bool,
)
pub(crate) fn respawn_workers_for_lead( self: &Arc<Self>, lead: &SessionSlot, project_key: ProjectKey, force_new: bool, )
Lead Connected-hook entry point. Synchronously claims a
per-project in-flight guard, then dispatches one
Command::SpawnWorker per persisted row, resuming by the id the
store holds for that row’s label. The guard is released after
the dispatches go out so a fast double-Connected can’t slip a
second wave through.
No-op when the per-project guard is already claimed (another
wave is in flight). The first-pass live_workers.is_empty()
gate in session_task::maybe_respawn_workers_on_connected catches
the post-dispatch case; this guard covers the in-flight window.
Sourcefn try_claim_respawn(&self, project_key: &ProjectKey) -> bool
fn try_claim_respawn(&self, project_key: &ProjectKey) -> bool
Claim the per-project respawn in-flight guard. Returns true if the guard was acquired (entry was absent), false if another wave was already in flight.
Sourcepub(crate) fn cron_unwakeable_is_new(&self, id: &CronId) -> bool
pub(crate) fn cron_unwakeable_is_new(&self, id: &CronId) -> bool
Whether this fire pass is the FIRST to find id unwakeable,
recording it as seen if so. True is the pass that warns; false is
every later pass while the condition holds.
Sourcepub(crate) fn cron_unwakeable_commit(&self, ids: HashSet<CronId>)
pub(crate) fn cron_unwakeable_commit(&self, ids: HashSet<CronId>)
Close a fire pass: ids is what it found unwakeable, and it
replaces the previous pass’s set wholesale. That replacement is
the only clearing this state needs - an entry cannot survive a
pass that did not find its cron unwakeable, whether the cron fired,
advanced, was deleted, or simply was not due.
Sourcepub(crate) fn cron_cooling_warned_at(&self, id: &CronId) -> Option<SystemTime>
pub(crate) fn cron_cooling_warned_at(&self, id: &CronId) -> Option<SystemTime>
When the previous fire pass last warned about this cron’s cooling,
or None when that pass did not find it cooling.
Sourcepub(crate) fn cron_cooling_commit(&self, warned_at: HashMap<CronId, SystemTime>)
pub(crate) fn cron_cooling_commit(&self, warned_at: HashMap<CronId, SystemTime>)
Close a fire pass with the crons it still found cooling and the
time each last warned, replacing the previous pass’s map wholesale
for the same reason Self::cron_unwakeable_commit does.
Sourcepub(crate) fn resolve_worker_resume_session(
&self,
org: &str,
project: &str,
run_dir: &Path,
label: &str,
) -> Result<ResumeTarget, Error>
pub(crate) fn resolve_worker_resume_session( &self, org: &str, project: &str, run_dir: &Path, label: &str, ) -> Result<ResumeTarget, Error>
The session a worker label resumes onto.
The row the label is registered under is authoritative while it is
there: a worker that has one opens as it always has. The
transcripts are the recovery path for a row that is gone - a
despawn deleted it, or this install never had it - and there the
newest transcript in run_dir’s directory carrying the label’s
worker tag wins.
Ok(ResumeTarget::None) is a label with nothing in either, and
Err is a store, directory or transcript that is there but could
not be read. Only the first is a fallback: a caller that started a
fresh session on the second would be answering a question the
lookup never answered.
Sourcepub(crate) fn stamp_adopted_worker_transcript(
&self,
project_key: &ProjectKey,
label: &str,
session_id: &str,
run_dir: &Path,
) -> Result<()>
pub(crate) fn stamp_adopted_worker_transcript( &self, project_key: &ProjectKey, label: &str, session_id: &str, run_dir: &Path, ) -> Result<()>
Stamp an adopted pre-field transcript with the adopting project, so a second project can never adopt it.
Sourcefn projects_composing(
&self,
run_dir: &Path,
label: &str,
) -> Vec<(String, String)>
fn projects_composing( &self, run_dir: &Path, label: &str, ) -> Vec<(String, String)>
The declared projects whose transcript lookup for label opens
run_dir: the directory their worker runs in, or the sanitised
key that directory is stored under.
Sourcefn release_respawn(&self, project_key: &ProjectKey)
fn release_respawn(&self, project_key: &ProjectKey)
Release the per-project respawn in-flight guard. Paired with
try_claim_respawn; called once the dispatches have gone out, on
each path that can issue them.
Sourcepub fn set_session_id_in_domain(
&self,
key: &SessionSlot,
value: Option<SessionId>,
)
pub fn set_session_id_in_domain( &self, key: &SessionSlot, value: Option<SessionId>, )
Set the session_id field on the workspace’s DomainSession
for key. No-op when no domain handle is registered for
key. Stamps the claude-issued UUID once the first
Connected event fires - the workspace consults this when
routing AgentHandle calls that take a session_id.
Sourcepub async fn shutdown(&self) -> Vec<SessionSlot>
pub async fn shutdown(&self) -> Vec<SessionSlot>
Graceful shutdown of every pooled Agent. Drains the pool, then
drops each Arc<AgentHandle>.
The subprocess kill is asynchronous through each SessionTask:
dropping the command senders closes every task’s command channel,
each task’s run loop exits, and its exit path awaits
AgentHandle::disconnect, which takes the bridge’s client slot
and runs the SDK’s graceful shutdown (signal reader task, drain,
close the child). Client has no Drop of its own, so without
that disconnect the child would survive the pool drain.
A well-behaved caller releases its handle reference before calling
shutdown, so Workspace is the sole owner of every pool entry.
Callers that hold cloned handles across shutdown keep the
AgentHandle’s task alive until they release them.
The return waits for those exit paths, because the runtime a
caller is about to drop does not poll them: each child sits in its
own process group and only its own close signals that group, so a
shutdown that returned early would leave the tree running. No
session may start once it begins - the gate goes up before the first
drain and the mint reads it under the same lock - and the drain is
repeated until nothing is registered. The wait is bounded by the
same TEARDOWN_WAIT the despawn’s removal holds to.
Returns the slots that had not answered; empty is a clean stop. The caller turns a non-empty answer into a non-zero exit, so a supervisor can tell a clean stop from one that left a tree.
Sourcepub(crate) async fn shutdown_within(&self, wait: Duration) -> Vec<SessionSlot>
pub(crate) async fn shutdown_within(&self, wait: Duration) -> Vec<SessionSlot>
Self::shutdown with the budget injected, so a case can drive the
expiry without sitting out the production wait. Returns the slots
that had not answered by the deadline; empty is a clean stop.
Sourcepub fn release_session_with_cascade(self: &Arc<Self>, session_key: &SessionSlot)
pub fn release_session_with_cascade(self: &Arc<Self>, session_key: &SessionSlot)
Release a single session’s pool entry: drops the workspace’s
Arc<AgentHandle> for that key so the underlying claude
subprocess exits once the consumer (a view’s bucket) also
drops its reference.
Cascade-aware lead release. Use this when closing a project’s
lead session from a view: the lead-row × click, the launchpad’s
per-row close on a failed lead bucket, etc.
When session_key is a project’s lead - it appears in the
project’s catalog AND is NOT in live_workers[project] - every
live worker under that project is released first via the
non-cascading release_session primitive. Workers’ JSONLs
persist on disk; only the in-memory live state + the running
claude subprocesses are torn down.
The “in-catalog AND not-in-live_workers” rule is the
discriminator (rather than sessions.first()): the catalog
also indexes worker sessions once their Connected fires, so
sessions[0] is not a reliable lead marker after a worker
reaches Running. live_workers is the authoritative
“this session is a child agent” registry.
Sourcepub(crate) fn announce_release(self: &Arc<Self>, key: &SessionSlot)
pub(crate) fn announce_release(self: &Arc<Self>, key: &SessionSlot)
Announce that a seat’s release has begun.
One place for the close doors to say it, so a viewer draws the seat as going to sleep from the gesture instead of keeping the state it last read. Each door calls it where the close is certain and the teardown has not started: a lead before its cascade, a worker before its own kill - where a despawn’s removal frame only follows the worktree cleanup, minutes later (#1930).
The seat’s failure record and any standing death go with the announcement, before the frame that makes a view re-read: a close is the reader acting on the row, and a trace that outlived the seat kept it in the needs-you group with nothing left to act on.
Sourcepub(crate) fn release_session(self: &Arc<Self>, session_key: &SessionSlot)
pub(crate) fn release_session(self: &Arc<Self>, session_key: &SessionSlot)
Non-cascading single-session release - the primitive. Drops
the pool entry, command sender, and domain handle for
session_key. No side effects beyond that single session.
Use this for ANY session close where cascade semantics are undesirable or undefined:
handle_close_worker(per-row worker X click) - the close must affect only the worker being closed.- The lead-cascade loop inside
release_session_with_cascadeitself, when releasing each worker.
Use release_session_with_cascade instead when the caller is
the lead-row close gesture.
Sourcepub(crate) fn session_is_live(&self, key: &SessionSlot) -> bool
pub(crate) fn session_is_live(&self, key: &SessionSlot) -> bool
Whether a session task still exists for key, which is what
makes it live: its command channel is registered and routable.
Sourcepub fn is_shutting_down(&self) -> bool
pub fn is_shutting_down(&self) -> bool
Whether a shutdown has begun: no new session may start.
Sourcepub(crate) fn mint_session_teardown(
&self,
key: &SessionSlot,
) -> Option<Sender<bool>>
pub(crate) fn mint_session_teardown( &self, key: &SessionSlot, ) -> Option<Sender<bool>>
Mint the slot’s teardown signal, or None when a shutdown is
already underway and no session may start. The refusal is read under
the same lock a shutdown drains with, so a spawn can never register
behind the drain.
Sourcepub(crate) fn session_teardown(
&self,
key: &SessionSlot,
) -> Option<Receiver<bool>>
pub(crate) fn session_teardown( &self, key: &SessionSlot, ) -> Option<Receiver<bool>>
Subscribe to the slot’s teardown signal, if a session task is
registered behind it and has not answered yet. None for a slot
with no task - a stranded row - and for an entry a finished task
already answered: either way the caller has nothing to wait for,
and the despawn’s mid-spawn guard reads None exactly that way.
The entry itself stays in the map, unlike the one receiver it replaced: more than one waiter may need the same answer, and a shutdown’s drain reads every entry’s value rather than subscribing blind.
Sourcepub(crate) fn release_session_if_current(
&self,
session_key: &SessionSlot,
handle: &Arc<AgentHandle>,
)
pub(crate) fn release_session_if_current( &self, session_key: &SessionSlot, handle: &Arc<AgentHandle>, )
Supersession-safe release: drop session_key’s pool entry,
command sender, and domain handle ONLY when the pooled agent is
still handle (by Arc identity). A SessionTask whose session
was re-spawned under the same key is a
stale predecessor - when it exits and runs this cleanup, the
pool already holds the successor’s handle, so the guard no-ops
and the live re-spawned session is left intact. Gating all three
maps on the one identity check keeps a superseded task from
half-cleaning its successor.
Sourcepub fn live_seats(&self) -> Option<LiveSeats>
pub fn live_seats(&self) -> Option<LiveSeats>
Every seat that exists right now: each configured project’s lead, and every worker the store holds - running or not, because a durable worker’s seat exists until it is despawned.
The browser sweep reads this: a tab whose owning seat is not here is
an orphan and closes. No profile is deleted by that sweep - a
profile’s logins are the org’s and stay until the idle clock closes
the host - so the cost of a wrong answer is a closed tab, not a lost
login. Captures are serialised, the lock spanning the mint and the
reads (crate::browser::LiveSeats): a higher generation is a later
seat set, which is what lets the fleet drop a stale one - including for
a seat released without a sweep of its own, since only a capture that
read after the release can drop its tombstone and every snapshot still
listing the seat is older than that capture. It still holds two rules,
because a closed tab is a page an agent was working in:
- It errs toward INCLUSION: a seat wrongly present leaves one tab to the next sweep, while a seat wrongly absent would close a live session’s page.
- It fails closed:
Nonewhen the store cannot be read, so no caller can mistake an unread store for “no seats exist” and sweep everything. Nothing sends a set it could not read.
Sourcepub fn list_live_workers(&self, project_key: &ProjectKey) -> Vec<WorkerEntry>
pub fn list_live_workers(&self, project_key: &ProjectKey) -> Vec<WorkerEntry>
Snapshot the live workers for project_key. Returns an empty
Vec when no workers exist (rather than None) so a view’s tree-
child render can branch only on is_empty.
Sourcepub fn live_worker_states_by_project(
&self,
) -> HashMap<ProjectKey, Vec<LiveWorkerState>>
pub fn live_worker_states_by_project( &self, ) -> HashMap<ProjectKey, Vec<LiveWorkerState>>
Every live worker’s liveness, keyed by project.
The launchpad’s worker rows read this once per frame, which is
what the shape is for: one lock answers every project row, and
the projection leaves each worker’s charter in the registry
instead of copying it, which Self::list_live_workers does on
every call.
Sourcepub fn has_background_work(&self, slot: &SessionSlot) -> bool
pub fn has_background_work(&self, slot: &SessionSlot) -> bool
Whether slot’s session has live background work. A fact about the
session rather than about a viewer, so the core holds one answer for
every view.
Sourcepub fn session_failed_turn(&self, slot: &SessionSlot) -> Option<SystemTime>
pub fn session_failed_turn(&self, slot: &SessionSlot) -> Option<SystemTime>
When slot’s newest turn ended in failure, for the rail’s mark.
None for a slot whose newest turn succeeded, never ran, was
cancelled by the reader, or has since been moved past with a new
turn.
Sourcepub fn pending_interaction(
&self,
slot: &SessionSlot,
) -> Option<PendingInteractionKind>
pub fn pending_interaction( &self, slot: &SessionSlot, ) -> Option<PendingInteractionKind>
What the session at slot is waiting on a person for, or None
when it can advance on its own.
The one predicate every needs-you surface reads. A session activity, a roster row, a rail mark and a notification all ask this rather than each keeping a list of sources: the lists drifted, and a terminal hand-off drew in the dock while its seat read as running (#2954).
The order is fixed. The hand-offs lead because each parks in a registry of the workspace’s own rather than in the session’s pending set, so a seat with no domain can still be holding one; then the question outranks the permission prompt beside it, because both hold a turn and the question is the one a person has to read; then the remaining registries, the Slack draft first.
The row names the strongest ask; the dock draws its own queue.
Self::pending_asks fronts a draft because a card is what a person
reads first, while this answers one kind - so a seat holding a Slack
draft and a document hand-off names the hand-off on its row and shows
the draft at the front of the dock.
Sourcepub fn pending_interaction_depth(&self, slot: &SessionSlot) -> usize
pub fn pending_interaction_depth(&self, slot: &SessionSlot) -> usize
How many asks the session at slot is holding, zero when none.
The same ledger Self::pending_interaction reads, summed, so a
view showing one of them can say how many wait behind it.
Sourcefn pending_ask_ledger(
&self,
slot: &SessionSlot,
) -> [(PendingInteractionKind, usize); 8]
fn pending_ask_ledger( &self, slot: &SessionSlot, ) -> [(PendingInteractionKind, usize); 8]
Every ask source, in Self::pending_interaction’s precedence
order, with the number of asks each holds.
One list feeds the kind and the depth, so a source cannot be counted without being answered or answered without being counted. The guard test parks one ask of every kind and reads both back.
Lock order: each registry read takes and releases its own lock, and the domain guard is off before the registry reads after it, so no path here holds a registry lock and the domain lock together.
Sourcepub(crate) fn note_ask(&self, slot: &SessionSlot)
pub(crate) fn note_ask(&self, slot: &SessionSlot)
Note that an ask arrived for slot.
The number is the ask’s identity on the wire. A client announces a new ask by it, so a re-read of the roster is not news, an ask replacing another at the same depth is, and an answer that shrinks a batch is not.
Sourcepub fn pending_request_seq(&self, slot: &SessionSlot) -> u64
pub fn pending_request_seq(&self, slot: &SessionSlot) -> u64
How many asks have ever arrived for slot, zero when none has.
Sourcepub(crate) fn record_fatal_error(&self, error: AppError)
pub(crate) fn record_fatal_error(&self, error: AppError)
Record a fatal error, so a view that was not attached when it fired can still read it.
Sourcepub fn last_fatal_error(&self) -> Option<AppError>
pub fn last_fatal_error(&self) -> Option<AppError>
The last fatal error, or None when nothing has failed fatally.
A fatal error is App-level: it names the startup that could not happen rather than a seat, and it arrives once. Without this the only way to know is to have been subscribed when it went out.
Sourcepub fn service_status(&self) -> Option<ServiceIssue>
pub fn service_status(&self) -> Option<ServiceIssue>
The statuspage’s last answer, or None.
None covers both “every relevant component is operational” and
“the statuspage could not be reached”, which is the fetch’s own
contract rather than something invented here - and a view that draws
nothing for it is drawing the answer, not a gap.
The core probes rather than the view because the answer is the same for every viewer and because it must outlive any one of them: a client that attaches later would otherwise never see a service status at all.
Sourcefn start_service_status_probe(
&self,
prober: Box<dyn Fn() -> Pin<Box<dyn Future<Output = Option<ServiceIssue>> + Send>> + Send + Sync>,
)
fn start_service_status_probe( &self, prober: Box<dyn Fn() -> Pin<Box<dyn Future<Output = Option<ServiceIssue>> + Send>> + Send + Sync>, )
Kick off the statuspage probe. Idempotent, and off the boot path.
Sourcepub fn pending_asks(&self, slot: &SessionSlot) -> Vec<PendingAsk>
pub fn pending_asks(&self, slot: &SessionSlot) -> Vec<PendingAsk>
What the seat at slot is holding - a draft leads, then arrival
order - empty when it is holding nothing. Every hop keeps that
order, so a re-read and the live folds cannot disagree about the
front, with one exception: two drafts held at once - and two
configuration writes held at once - come back in the registry’s own
order, because the registry keeps none to offer.
The list rather than one ask, because a parallel batch parks several at once and a view that attached mid-batch has no other way to read the ones behind the front: the stream is a mirror with no backlog, and the request beside the answer’s oneshot is what is left. A view draws the front.
A draft leads because it is parked in its own registry rather than in the session’s pending set, so a seat with no domain can still hold one.
Sourcepub fn worker_activity(&self, entry: &WorkerEntry) -> SessionLifecycleState
pub fn worker_activity(&self, entry: &WorkerEntry) -> SessionLifecycleState
What entry’s session is doing right now. The two liveness states
that need no session of their own answer here; everything else is
Self::session_activity.
Call this with no worker lock held - it reaches for
domain_handles and then the DomainSession.
Sourcepub fn spawn_failure(&self, slot: &SessionSlot) -> Option<String>
pub fn spawn_failure(&self, slot: &SessionSlot) -> Option<String>
Why the last spawn or connection for slot failed, when it has not
come up since. None for a slot that is running, was never tried,
or recovered.
Sourcepub(crate) fn record_spawn_failure(&self, slot: &SessionSlot, message: &str)
pub(crate) fn record_spawn_failure(&self, slot: &SessionSlot, message: &str)
Record that slot’s spawn or connection failed, and why. Called
before the failure releases the session, because the release is
what takes the rest of the evidence with it.
Sourcepub(crate) fn clear_spawn_failure(&self, slot: &SessionSlot)
pub(crate) fn clear_spawn_failure(&self, slot: &SessionSlot)
Clear slot’s recorded failure: a session for it came up. Called
from the Connected arm, which is the path production takes - the
spawn entries hoist a domain straight into domain_handles rather
than registering one, so clearing on registration would never run.
Sourcepub(crate) fn record_death(
&self,
slot: &SessionSlot,
occupant: SessionId,
reason: &str,
)
pub(crate) fn record_death( &self, slot: &SessionSlot, occupant: SessionId, reason: &str, )
Record that the occupant running on slot died, with the reason it
left, keeping both until a reader has been shown the seat and the
recovery has announced itself. The lifecycle is NOT held down by
this: a recovering connect clears the failure as it always did, and
what rides on until shown is the death - died on the row, with the
reason and the mark the client draws from it.
Sourcepub(crate) fn unannounced_death(&self, slot: &SessionSlot) -> Option<SessionId>
pub(crate) fn unannounced_death(&self, slot: &SessionSlot) -> Option<SessionId>
The died occupant recorded for slot when the recovery has not
announced it yet, consuming that one announcement: the notice fires
on the first connect that answers the death and stays quiet after.
The spend does not consume it. A reader who opens the crashed seat’s page spends the row’s trace and then wakes the seat, so the connect that answers their own click is the first one to see the death - and it is owed the sentence like any other.
Sourcepub fn death_stands(&self, slot: &SessionSlot) -> bool
pub fn death_stands(&self, slot: &SessionSlot) -> bool
Whether a death stands unshown on slot: the row carries died with
its reason while one does, so a crash nobody watched is still
readable when someone looks. pub because the row a view reads
carries it.
Sourcepub fn death_reason(&self, slot: &SessionSlot) -> Option<String>
pub fn death_reason(&self, slot: &SessionSlot) -> Option<String>
The reason a standing death left, for the row to draw beside its
mark. None once a reader has been shown the seat.
Sourcepub fn set_heal_progress(
&self,
slot: &SessionSlot,
attempt: u32,
next_at: SystemTime,
)
pub fn set_heal_progress( &self, slot: &SessionSlot, attempt: u32, next_at: SystemTime, )
Record that slot is between heal attempts: which attempt is next
and when it fires. Written by the healing supervisor and read by
every row that draws the seat.
Sourcepub fn clear_heal_progress(&self, slot: &SessionSlot)
pub fn clear_heal_progress(&self, slot: &SessionSlot)
Forget slot’s heal progress: an occupant connected, or the seat
gave up and its row now draws the failure instead.
Sourcepub fn heal_progress(&self, slot: &SessionSlot) -> Option<HealProgress>
pub fn heal_progress(&self, slot: &SessionSlot) -> Option<HealProgress>
Where slot’s heal has got to, or None when the seat is not
restarting.
Sourcepub fn heal_attempts(&self) -> Option<u32>
pub fn heal_attempts(&self) -> Option<u32>
How many restarts a heal may make, from the current config: the row
says restarting (attempt 1 of 3) from this.
Sourcepub(crate) fn note_seat_death(
self: &Arc<Self>,
slot: &SessionSlot,
diagnostic: &str,
)
pub(crate) fn note_seat_death( self: &Arc<Self>, slot: &SessionSlot, diagnostic: &str, )
A seat that had been up died: hand the death to the supervisor. A resume’s new occupant that never came up is a death here too - the attempt failed rather than a first spawn being surfaced.
Sourcepub(crate) fn note_seat_connected(self: &Arc<Self>, slot: &SessionSlot)
pub(crate) fn note_seat_connected(self: &Arc<Self>, slot: &SessionSlot)
A session came up on slot, which cancels any scheduled heal.
Sourcepub(crate) fn mark_worker_restarting(
&self,
project_key: &ProjectKey,
slot: &SessionSlot,
)
pub(crate) fn mark_worker_restarting( &self, project_key: &ProjectKey, slot: &SessionSlot, )
slot’s worker is between heal attempts: its row draws
Restarting, and the label is free for the resume.
Sourcepub fn spend_death(&self, slot: &SessionSlot) -> bool
pub fn spend_death(&self, slot: &SessionSlot) -> bool
A reader has been shown the death on slot - the seat’s page was
opened: the row’s trace goes (whether or not the recovery has
announced itself yet), and the answer says whether there was one to
spend. pub because the seat being shown is the server’s to see,
and this is the spend the server makes on the view’s behalf.
Sourcepub fn session_activity(&self, slot: &SessionSlot) -> SessionLifecycleState
pub fn session_activity(&self, slot: &SessionSlot) -> SessionLifecycleState
What the session at slot is doing right now - the axis
WorkerLiveness does not answer, since it stops moving once the
worker connects. Reachable without a WorkerEntry, which a project
lead has none of.
A pending interaction outranks the turn it is blocking:
Attention is the state a lead has to act on, and reporting the
blocked worker as Running is what let the deadlock stay
invisible.
Call this with no worker lock held - it reaches for
domain_handles and then the DomainSession.
Sourcepub fn worker_status_snapshot(&self, entry: &WorkerEntry) -> WorkerStatus
pub fn worker_status_snapshot(&self, entry: &WorkerEntry) -> WorkerStatus
entry projected to the wire shape with activity derived.
This is the agents__list projection; WorkerEntry::to_status
is the event-path one that leaves activity unset.
Sourcepub fn project_name_for_path(&self, cwd: &str) -> Option<String>
pub fn project_name_for_path(&self, cwd: &str) -> Option<String>
The forge.toml project NAME that owns cwd, matched by the
project whose expanded path is an ancestor-or-equal of cwd
(component-aware, longest wins), so a worker in a
<project>/.claude/worktrees/<label> worktree resolves to its
parent project. cwd is ~-expanded before the lexical match so
a tilde form can’t miss the already-expanded ProjectView.path.
None when cwd is blank or under no configured project. The
Inspector stamps this NAME onto a tab’s UI bucket once (at
Connect), then scopes SCHEDULES / GOTIFY by name rather than
re-deriving the project every render tick from a stale cwd.
Sourcepub fn project_name_for_resolved_path(&self, cwd: &str) -> Option<String>
pub fn project_name_for_resolved_path(&self, cwd: &str) -> Option<String>
The forge.toml project NAME that owns the RESOLVED cwd: the
match Self::project_name_for_path makes, retried with both
sides canonicalized when the lexical one misses. A spawn’s tree is
canonical - the CLI derives its state directory from the canonical
path - while a declared path may reach the same directory through
an alias (/tmp for /private/tmp, a symlinked parent), and the
lexical rule alone would miss: a crossing-into-another-project
check that misses is the rule the check exists for.
Both sides resolve together or not at all, the way the sibling’s lexical match keeps them in one form - a per-side canonicalize would put an existing root on one side of the comparison and an uncanonicalized child on the other.
Sourcefn project_identity_for_key(
&self,
project_key: &ProjectKey,
) -> Option<(String, String)>
fn project_identity_for_key( &self, project_key: &ProjectKey, ) -> Option<(String, String)>
The (org, project name) a project key names, when forge.toml
still declares it. The sessions table is keyed by the pair
while every worker site holds the key, so a worker’s row cannot be
written or found without this translation.
Sourcepub(crate) fn record_worker_row(
&self,
project_key: &ProjectKey,
label: &str,
id: &str,
charter: &str,
kick: Option<&str>,
resume_kick: Option<&str>,
interactive: bool,
tree: WorkerTree,
mcp_families: Option<&[String]>,
) -> Result<()>
pub(crate) fn record_worker_row( &self, project_key: &ProjectKey, label: &str, id: &str, charter: &str, kick: Option<&str>, resume_kick: Option<&str>, interactive: bool, tree: WorkerTree, mcp_families: Option<&[String]>, ) -> Result<()>
Write label’s worker row: the id it runs under plus the
re-spawn arguments its spawn stated. The row is the only thing a
boot re-spawns from, so the charter, the kick, the interactive
flag and the gitness its worktree derives from land here rather
than staying in memory.
Err when the store is closed, the project is no longer
configured, or the write failed, so the caller can warn the lead
that this worker will not survive a restart.
Sourcepub(crate) fn recorded_worker_is_git_repo(
&self,
project_key: &ProjectKey,
label: &str,
) -> Result<Option<bool>>
pub(crate) fn recorded_worker_is_git_repo( &self, project_key: &ProjectKey, label: &str, ) -> Result<Option<bool>>
The git flag the worker’s row records, or Ok(None) when there is
no row to read or its row predates the field. The spawn reads it in
preference to probing the project path, so the cwd it builds is the
one the launchpad already checked.
A read failure is an Err, not an absent row: an absent row means
the caller may probe, while an unreadable one means the answer is
unknown and the caller should say so rather than quietly compose a
different directory. Distinct from Self::stored_worker_row,
which reports both the same way because its callers act on the row
itself rather than on a field of it.
Sourcepub(crate) fn recorded_worker_mcp_families(
&self,
project_key: &ProjectKey,
label: &str,
) -> Result<Option<Vec<String>>>
pub(crate) fn recorded_worker_mcp_families( &self, project_key: &ProjectKey, label: &str, ) -> Result<Option<Vec<String>>>
The MCP-family selection the worker’s row records, or Ok(None)
when there is no row or its row names none (which resolves to
every family). Same failure contract as
Self::recorded_worker_is_git_repo: an unreadable row is an
Err, not an absence.
Sourcepub(crate) fn delete_worker_row(
&self,
project_key: &ProjectKey,
label: &str,
) -> Result<bool>
pub(crate) fn delete_worker_row( &self, project_key: &ProjectKey, label: &str, ) -> Result<bool>
Delete a worker’s persisted row so it never re-spawns. The row is the only thing that brings one back, so a delete that cannot land is an error rather than a warn.
Ok reports whether a row was there to remove. Err means the row
could not be addressed or the delete failed - which is not the same
answer as an absent row, so spawn::handle_despawn_worker reports
the failure rather than NotFound when it gets one.
Sourcepub(crate) fn worker_rows_for_project(
&self,
project_key: &ProjectKey,
) -> Vec<SessionRecord>
pub(crate) fn worker_rows_for_project( &self, project_key: &ProjectKey, ) -> Vec<SessionRecord>
Every persisted worker row for project_key, the lead’s own row
excluded. Empty when the DB isn’t open or the read fails. Backs
the lead-reconnect re-spawn merge and the durable-identity read.
Sourcepub(crate) fn worker_rows_for_project_strict(
&self,
project_key: &ProjectKey,
) -> Result<Vec<SessionRecord>>
pub(crate) fn worker_rows_for_project_strict( &self, project_key: &ProjectKey, ) -> Result<Vec<SessionRecord>>
The project’s durable worker rows, with a store failure surfaced rather than swallowed: a caller deciding whether a worktree path is free refuses on an unknown.
Sourcepub fn worker_labels_by_project(&self) -> HashMap<ProjectKey, Vec<String>>
pub fn worker_labels_by_project(&self) -> HashMap<ProjectKey, Vec<String>>
Every persisted worker label the launchpad still offers, keyed by the project key it holds. A row the boot wave would not start is not among them, unless a live worker holds it as Failed: that row is the failure’s own record, and its reason has to be drawable.
The launchpad’s worker rows read this once per frame, which is
what the shape is for: one read transaction answers every project
row, over a projection that skips each row’s charter. Unlike
Self::list_live_workers it answers before the project has
launched, which is when the launchpad renders.
Empty on a read failure rather than surfacing it, deliberately
unlike the sibling stored_worker_row: the caller is a render
path, where a warn plus a bare row beats failing the frame.
Sourcepub(crate) fn stored_worker_row(
&self,
project_key: &ProjectKey,
label: &str,
) -> Result<Option<SessionRecord>>
pub(crate) fn stored_worker_row( &self, project_key: &ProjectKey, label: &str, ) -> Result<Option<SessionRecord>>
The row label holds in project_key. Distinct from
Self::worker_rows_for_project, which swallows a read failure as
empty: this surfaces the error (and treats a missing store as one)
so the cron fire router can tell “conclusively absent” from “could
not read” and leave the cron on a hiccup.
Sourcepub(crate) fn update_worker_row(
&self,
project_key: &ProjectKey,
label: &str,
charter: Option<String>,
kick: Option<String>,
resume_kick: Option<String>,
mcp_families: Option<Vec<String>>,
) -> Result<bool>
pub(crate) fn update_worker_row( &self, project_key: &ProjectKey, label: &str, charter: Option<String>, kick: Option<String>, resume_kick: Option<String>, mcp_families: Option<Vec<String>>, ) -> Result<bool>
Merge the supplied fields onto the worker row for (project_key, label), leaving a None field at its stored value. Returns
whether a row existed; this never creates one, because a row is
what makes a worker re-spawn on the next lead connect. Read and
write share one store lock so a concurrent despawn cannot land
between them and resurrect the row.
Sourcepub fn scan_usage(&self) -> UsageReport
pub fn scan_usage(&self) -> UsageReport
Scan the shared session-JSONL pool into a UsageReport for the
usage subject. Query-style (a direct method, not a Command):
reads the one real projects dir, refreshes the incremental
per-file cache, and rolls the deduped summaries up into the four
windows priced from the bundled table. Does blocking file IO, so
callers run it off the UI thread.
Sourcefn usage_summary_for(&self, path: &Path, tz: &Tz) -> Option<FileUsageSummary>
fn usage_summary_for(&self, path: &Path, tz: &Tz) -> Option<FileUsageSummary>
Cached summary for path when its mtime and size still match,
otherwise re-parse (bucketing by tz) and refresh the cache.
None when the file vanished between listing and parsing.
fn load_usage_summary(&self, path: &str) -> Option<FileUsageSummary>
fn store_usage_summary(&self, path: &str, summary: &FileUsageSummary)
Sourcepub async fn refresh_pricing(self: &Arc<Self>) -> bool
pub async fn refresh_pricing(self: &Arc<Self>) -> bool
Refresh the LiteLLM pricing cache when it is absent or older than a day, re-fetching immediately on a missed day. Fire-and-forget from a view: it fetches through the proxy-aware client and is a no-op on any network failure, so the last-good cache is kept. Returns whether a new price table was stored (so a caller can re-price without a redundant scan when nothing changed). The redb read and the ~1.6 MB write run on the blocking pool so the once-a-day fsync can’t stall a UI frame.
Sourcefn store_fresh_pricing(&self, json: String) -> bool
fn store_fresh_pricing(&self, json: String) -> bool
Store freshly-fetched pricing json unless it parses to an empty table - a garbage 200 must not wipe a good cache. Returns whether it stored.
Sourcefn load_pricing(&self) -> PricingTable
fn load_pricing(&self) -> PricingTable
The cached pricing, or an empty table before the first fetch lands (a scan answered in the meantime reports tokens with a blank cost).
Sourcefn load_cached_pricing(&self) -> Option<CachedPricing>
fn load_cached_pricing(&self) -> Option<CachedPricing>
Read the cached pricing snapshot, warning (not swallowing) on a redb or decode error so a corrupt cache is diagnosable.
Sourcefn pricing_is_fresh(&self) -> bool
fn pricing_is_fresh(&self) -> bool
Whether the cached pricing is younger than the daily refresh window; a missing or older cache is stale and re-fetched.
fn store_pricing(&self, entry: &CachedPricing)
Sourcepub(crate) fn canonical_model_for_session(
&self,
key: &SessionSlot,
) -> Option<String>
pub(crate) fn canonical_model_for_session( &self, key: &SessionSlot, ) -> Option<String>
The canonical model forge stamped for key’s session: the
project’s declared model. None when the session has no
project registration or the project declares no model.
Sourcepub(crate) fn declared_models_for_session(
&self,
key: &SessionSlot,
) -> Vec<AvailableModel>
pub(crate) fn declared_models_for_session( &self, key: &SessionSlot, ) -> Vec<AvailableModel>
The /model picker rows for a session: the declared models of
the session’s org’s accounts, in pin order, deduped.
Sourcepub fn install_db_for_test(&self, db: Db)
pub fn install_db_for_test(&self, db: Db)
Install a redb store into a test workspace so the durable-vs-
ephemeral persistence path is exercisable without Workspace::new.
Sourcepub fn all_live_worker_session_keys(&self) -> Vec<SessionSlot>
pub fn all_live_worker_session_keys(&self) -> Vec<SessionSlot>
Snapshot every live worker’s session_key across every project.
Used for worker-key routing that excludes worker buckets
from project-row click routing without depending on
list_projects() for enumeration.
Sourcepub fn insert_live_worker(&self, project_key: &ProjectKey, entry: WorkerEntry)
pub fn insert_live_worker(&self, project_key: &ProjectKey, entry: WorkerEntry)
Insert a worker entry into live_workers[project_key].
remove_latest_worker resolves the single live match. The
one-live-worker-per-label invariant is enforced by
Self::insert_live_worker_if_label_absent; this raw push is for
callers (tests, re-tag) that already own that guarantee.
Sourcepub fn insert_live_worker_if_label_absent(
&self,
project_key: &ProjectKey,
entry: WorkerEntry,
cap: Option<usize>,
) -> Result<(), LiveWorkerRefusal>
pub fn insert_live_worker_if_label_absent( &self, project_key: &ProjectKey, entry: WorkerEntry, cap: Option<usize>, ) -> Result<(), LiveWorkerRefusal>
Insert entry only if no live (non-Failed) worker already holds
its label in project_key, and - when cap is Some - only if
that project’s live worker count is under it. Holds
live_workers.lock() across the label-check, the cap-check AND
the push, so two genuinely-concurrent SpawnWorker dispatches (a
reconnect re-spawn racing a manual agents__spawn, say) can’t
both pass a check-then-insert window and fork two subprocesses
onto one worktree or overshoot the cap. The label check precedes
the cap check, so a duplicate-label spawn at the cap reports the
collision. cap: None is the boot re-spawn exemption. Returns
Ok(()) on insert. This is the sole enforcement point for the
at-most-one-live-worker-per-label invariant and the per-project
worker cap.
Sourcepub(crate) fn mark_despawn_cleanup_pending(
self: &Arc<Self>,
project_key: &ProjectKey,
label: &str,
) -> DespawnCleanupPending
pub(crate) fn mark_despawn_cleanup_pending( self: &Arc<Self>, project_key: &ProjectKey, label: &str, ) -> DespawnCleanupPending
Mark label’s despawn cleanup as in flight in project_key, and
hand back the guard that releases it.
A despawn takes this BEFORE its teardown, under the live registry’s
lock: from here until the cleanup ends the label must not read as
free to a spawn, because the worktree that cleanup will delete is
still on disk. Drop does the releasing, so a cleanup that returns
early or panics cannot leave the label unspawnable for the run.
Sourcepub(crate) fn despawn_cleanup_pending(
&self,
project_key: &ProjectKey,
label: &str,
) -> bool
pub(crate) fn despawn_cleanup_pending( &self, project_key: &ProjectKey, label: &str, ) -> bool
Whether label’s despawn cleanup is still running in project_key.
Sourcepub fn count_live_workers(&self, project_key: &ProjectKey) -> usize
pub fn count_live_workers(&self, project_key: &ProjectKey) -> usize
The project’s cap-relevant worker count: the same number
insert_live_worker_if_label_absent enforces against, read for
WorkerFacade::capacity.
Sourcepub(crate) fn clear_failed_worker(&self, project_key: &ProjectKey, label: &str)
pub(crate) fn clear_failed_worker(&self, project_key: &ProjectKey, label: &str)
Drop any failed tombstone for label before a restore appends its
own entry.
A failed spawn keeps its entry for a worker that has no worktree, so the failure stays on screen. That entry is not live, so the insert would append a second entry for the same slot, and the by-slot reads take the first match: the tombstone would move as the new session connects while the new entry stayed behind as a phantom. A restore supersedes the failure it replaces.
Sourcepub fn remove_latest_worker(
&self,
project_key: &ProjectKey,
label: &str,
) -> Option<WorkerEntry>
pub fn remove_latest_worker( &self, project_key: &ProjectKey, label: &str, ) -> Option<WorkerEntry>
Remove the latest-spawned worker matching label from
live_workers[project_key]. Returns the removed entry, or
None when no match exists.
Sourcepub fn remove_worker_by_session_key(
&self,
session_key: &SessionSlot,
) -> Option<(ProjectKey, WorkerEntry)>
pub fn remove_worker_by_session_key( &self, session_key: &SessionSlot, ) -> Option<(ProjectKey, WorkerEntry)>
Remove the worker whose session_key exactly matches across
any project’s live_workers. Used by the async-spawn-failure
path so concurrent same-label spawns don’t accidentally
roll back the wrong entry (remove_latest_worker would peek
the wrong one when two workers share a label). Returns the
matched (project_key, entry) pair, or None when no entry
matches.
Sourcepub fn drain_live_workers(&self, project_key: &ProjectKey) -> Vec<WorkerEntry>
pub fn drain_live_workers(&self, project_key: &ProjectKey) -> Vec<WorkerEntry>
Drain every worker entry for project_key and return them in
insertion order.
Sourcepub fn worker_lookup_for_session(
&self,
session_key: &SessionSlot,
) -> Option<(ProjectKey, String, bool, bool, Option<PathBuf>)>
pub fn worker_lookup_for_session( &self, session_key: &SessionSlot, ) -> Option<(ProjectKey, String, bool, bool, Option<PathBuf>)>
Locate (project_key, label, is_git_repo_at_spawn, needs_tag, cwd) for any worker matching session_key across every
project’s live_workers. Used by the Connected handler in
SessionTask::translate_event to decide whether a just-
connected session is a worker (and what tag to write).
is_git_repo_at_spawn lets the tag-write path route to the
worktree-derived JSONL via worker_tag_dir in
crate::mcp::workers::types, and needs_tag says whether that
write has ever landed - the rollback reads it to tell a worker
that never established itself from one that is simply re-tagging.
cwd is the tree the spawn resolved, which every composed
directory reads instead of the project’s path.
None when the session is a lead (or not a worker at all).
Sourcepub fn worker_tree_for_session(
&self,
session_key: &SessionSlot,
) -> Option<PathBuf>
pub fn worker_tree_for_session( &self, session_key: &SessionSlot, ) -> Option<PathBuf>
The tree a live worker runs in when that is NOT its project’s own
directory: the root its spawn resolved. None for a lead, for a
worker in its project’s directory, and for a worker this cannot
place - so a caller that gets one has a tree to name.
Sourcepub(crate) fn worktree_collision(
&self,
path: &Path,
own: &ProjectKey,
own_label: &str,
own_op: WorktreeOp,
) -> Option<String>
pub(crate) fn worktree_collision( &self, path: &Path, own: &ProjectKey, own_label: &str, own_op: WorktreeOp, ) -> Option<String>
What a worker’s composed worktree path collides with, when it does: a declared project’s own checkout, or the worktree of a worker under another project with the same label.
The base is repository-global (#2460), so two projects of one
repository - the main checkout and a linked worktree of it, or two
linked worktrees - compose the same <main>/.claude/worktrees/ <label> and the same worktree-<label> branch for one label. The
one-live-worker guard is per project, so without this the second
spawn would run in the first worker’s tree and a despawn would
remove it; a label naming a checkout parked under .claude/worktrees
would do the same to that project. A crashed worker’s row still
claims its path until the row is closed - refusing is the safe
direction - and a durable row claims it even with no in-memory
entry (after a restart, before that project’s lead connects).
Callers refuse rather than reuse or remove. A separate forge
process is not visible here.
Sourcepub(crate) fn claim_worktree(
&self,
path: &Path,
project_key: &ProjectKey,
label: &str,
op: WorktreeOp,
) -> Result<WorktreeClaimGuard, String>
pub(crate) fn claim_worktree( &self, path: &Path, project_key: &ProjectKey, label: &str, op: WorktreeOp, ) -> Result<WorktreeClaimGuard, String>
Claim the composed worktree path for this (project, label) while a spawn’s ensure/insert or a despawn’s removal acts on it.
Re-entrant for the same owner: a resume’s pre-ensure claim and the core handler’s claim are one spawn. Refused when another project or label holds it, which is the repository-wide half of the one-live rule - the live registry is per project, and two projects of one repository compose the same path.
Sourcefn claim_conflict(
&self,
composed: &Path,
own: &ProjectKey,
own_label: &str,
own_op: WorktreeOp,
) -> Option<String>
fn claim_conflict( &self, composed: &Path, own: &ProjectKey, own_label: &str, own_op: WorktreeOp, ) -> Option<String>
What a composed path is claimed by, when a seat other than
own/own_label on the SAME operation holds it. A different
operation on the same owner and label is a conflict: a despawn must
not enter a resume’s claim.
fn claim_message(&self, claim: &WorktreeClaim, path: &Path) -> String
Sourcepub(crate) fn worktree_held_elsewhere(
&self,
path: &Path,
own: &ProjectKey,
own_label: &str,
) -> Option<String>
pub(crate) fn worktree_held_elsewhere( &self, path: &Path, own: &ProjectKey, own_label: &str, ) -> Option<String>
What a composed path is held by, when it is held by anything other
than a spawn of own/own_label: an in-flight claim of another
owner or operation, or a live seat of ANY project (a same-project
winner of a spawn race included). Used by a rollback deciding
whether a tree it minted is still its own.
Sourcefn live_worker_root(
&self,
project_key: &ProjectKey,
cwd: Option<&Path>,
) -> Option<PathBuf>
fn live_worker_root( &self, project_key: &ProjectKey, cwd: Option<&Path>, ) -> Option<PathBuf>
The tree a live worker’s directory composes against: the one its
spawn resolved, or the project’s own forge.toml path when the
spawn stated none. None when the session is not a live worker,
or its project is no longer loaded.
Sourcepub fn cwd_for_session(&self, session_key: &SessionSlot) -> Option<String>
pub fn cwd_for_session(&self, session_key: &SessionSlot) -> Option<String>
The working directory forge holds for session_key. Two
sources, in order:
- The sessions catalog (
session_cwd_for). Leads only: the boot scan hides worker-tagged sessions and the Connected handler skips the catalog mirror for workers, so a worker never has a row to read. - The worker registry (
live_workersviaworker_lookup_for_session), composed byworker_tag_diragainst the tree the worker’s spawn resolved - its own root when it has one, the project’sforge.tomlpath otherwise - the worktree for a git worker, that root itself otherwise. This is the authoritative source for every worker.
None leaves the caller to decide what an unknown cwd means:
resume_cwd_for_slot hands claude an empty cwd, while the
review MCP reports SessionCwdUnknown to the caller.
Sourcepub fn document_roots_for_session(
&self,
session_key: &SessionSlot,
) -> Vec<PathBuf>
pub fn document_roots_for_session( &self, session_key: &SessionSlot, ) -> Vec<PathBuf>
The extra document roots the seat’s project declares: its
document_roots entries, ~ expanded at load. Empty for a project
that declares none, and for a slot whose project is not loaded.
Sourcepub(crate) fn resume_cwd_for_slot(&self, session_key: &SessionSlot) -> String
pub(crate) fn resume_cwd_for_slot(&self, session_key: &SessionSlot) -> String
The cwd to pass claude --resume for the session at
session_key: Self::cwd_for_session, or an empty string
when forge holds none (pass through and let the bridge surface
ConnectionFailed - the session can’t be resumed cleanly anyway).
claude --resume does NOT receive a --worktree flag (see
SessionLaunchSettings::extra_args in
forge-agent/src/client.rs - lead/resume paths leave
extra_args empty), so the subprocess cwd is the ONLY signal
claude uses to derive the JSONL location. Handing a git
worker just its project root makes claude look under the
project’s sanitised dir, miss the worker JSONL (which lives
under the worktree’s sanitised dir), and exit with “No
conversation found with session ID:” (#245 Layer B).
Sourcepub(crate) fn project_root_for_key(
&self,
target: &ProjectKey,
) -> Option<PathBuf>
pub(crate) fn project_root_for_key( &self, target: &ProjectKey, ) -> Option<PathBuf>
Look up a project root path by its ProjectKey. Searches
the test overlay first (under cfg(test) / feature = "testing"), then config.projects. Returns None when no
loaded project’s path canonicalises to the given key.
Used by Self::git_scan_cwd_for_session and
Self::resume_cwd_for_slot to derive the project root
from a worker’s project_key without depending on the worker’s
cwd_raw value (which carries the project root for fresh
spawns and the worktree path for resumed sessions - the two
cases would otherwise need different composition logic).
Sourcepub(crate) fn project_key_for_name(&self, name: &str) -> Option<ProjectKey>
pub(crate) fn project_key_for_name(&self, name: &str) -> Option<ProjectKey>
The project whose path canonicalises to target. Consults the
test overlay, like every other project lookup - a resolution
that saw only config.projects would return None for a seeded
project and make an absence assertion pass vacuously.
The registry key for a project, resolved from its forge.toml
name. None when the config no longer names it.
pub(crate) fn project_for_key( &self, target: &ProjectKey, ) -> Option<LoadedProject>
Sourcepub fn git_scan_cwd_for_session(
&self,
session_key: &SessionSlot,
cwd_raw: &Path,
) -> PathBuf
pub fn git_scan_cwd_for_session( &self, session_key: &SessionSlot, cwd_raw: &Path, ) -> PathBuf
Resolve the cwd a git-diff scan should run against for the
session at session_key. Workers spawned in a git tree run
inside claude’s --worktree <label> fork at
<base>/.claude/worktrees/<label>/ (base the canonical root of
the tree the worker’s spawn resolved - its own when it has one, the
project’s forge.toml path otherwise), and the worker’s cwd_raw
carries different values depending on lifecycle:
- fresh spawn ->
cwd_raw = <root>(the value claude sends inAgentEvent::Connected.cwdbefore it chdirs into the worktree), - resumed session ->
cwd_raw = <base>/.claude/worktrees/<label>(claude chdirs before writing the first catalog row, so the resume path reads the worktree path back ascwd).
Anchor the composition on the worker’s own tree (via
Self::worker_lookup_for_session + the internal
live_worker_root lookup) rather than cwd_raw so both
lifecycle states resolve to the same final path. For non-
worker sessions (project leads), non-git workers, or workers
whose root can’t be resolved, returns cwd_raw unchanged.
Sourcepub(crate) fn handle_async_worker_spawn_failure(
self: &Arc<Self>,
session_key: &SessionSlot,
message: &str,
kind: SpawnFailureKind,
) -> bool
pub(crate) fn handle_async_worker_spawn_failure( self: &Arc<Self>, session_key: &SessionSlot, message: &str, kind: SpawnFailureKind, ) -> bool
Handle an async worker-spawn failure. Two paths, branching on the classifier outcome (#245 Layer C):
- Worktree-creation failure (the worker never actually
started because git worktree setup failed): roll back the
WorkerEntry(the worker doesn’t exist; the user-visible signal is a typed notice routed to the lead’s chat) and emitWorkerStatusChanged { Removed }. Mirrors the sync rollback inhandle_spawn_worker. - Any other failure (resume against missing JSONL,
generic dispatch error, claude subprocess exit, etc.):
keep the
WorkerEntryand transition it toWorkerLiveness::Failedwith the first line of the message as the diagnostic. The Projects pane renders the worker as a red✕with a DIM sub-row carrying the diagnostic, so a stuck-Spawning-forever case becomes visible instead of silently disappearing.
The classifier uses is_git_repo_at_spawn + substring match
against the bridge-wrapped message; see
classify_worker_spawn_failure for the routing rules and
the bridge-prefix contract.
Returns true when the caller IS a worker (so it knows the
failure was consumed here - the existing ConnectionFailed
emission still fires for the view side). false for lead
sessions or any other non-worker, in which case the caller’s
existing behaviour proceeds unchanged.
Sourcepub(crate) fn fail_worker_without_worktree(
self: &Arc<Self>,
slot: &SessionSlot,
) -> bool
pub(crate) fn fail_worker_without_worktree( self: &Arc<Self>, slot: &SessionSlot, ) -> bool
Mark a git worker Failed and release its session when the worktree its connect is about to run in is gone or unusable. Answers whether it did, so the first-connect branch can stop before it kicks the worker, announces it live or hands it parked prompts (#1593).
The path composes from the row’s own root, never the connect cwd: a resumed worker connects with its worktree as cwd, and composing from that doubled the path in the reason.
Sourcepub(crate) fn apply_worker_tag_or_rollback(
self: &Arc<Self>,
session_key: &SessionSlot,
session_id: &str,
cwd: &str,
minted_row: bool,
)
pub(crate) fn apply_worker_tag_or_rollback( self: &Arc<Self>, session_key: &SessionSlot, session_id: &str, cwd: &str, minted_row: bool, )
Tag a worker session’s JSONL with forge:worker:<label> and
transition its WorkerEntry from Spawning to Running.
Called from SessionTask::translate_event on the worker’s first
Connected.
cwd is the tree the worker runs in (the directory
discriminator tag_session uses to find the right .jsonl when
CONFIG_DIR hosts multiple project directories).
The tag-write races against claude CLI’s first JSONL write. For
workers spawned with an initial_prompt, the file lands within
~100 ms-2 s of Connected, so a 30 x 100 ms retry loop reliably
catches it. For idle-spawned workers (no prompt) claude doesn’t
create the JSONL at all until the first user turn arrives later,
so the retry will exhaust with Io(NotFound). That’s NOT a
rollback condition - we keep the worker live (it’s fully
functional from live_workers), transition to Running, mark
the entry needs_tag = true, and emit StatusChanged. The
opportunistic retry on first DeliverWorkerPrompt
(see spawn::handle_deliver_worker_prompt) catches it once
claude is processing the turn.
Other errors (permission denied, disk full, invalid UUID) still
indicate the worker can’t be properly tracked on disk, so we
roll back: remove from live_workers, release the session, emit
a Removed status event.
The tag-write runs in a detached tokio task to keep
translate_event synchronous.
Idempotent - calling twice for the same session_key is a no-op
if the entry is already Running.
Sourcepub(crate) fn apply_worker_tag_or_rollback_with_config_dir(
self: &Arc<Self>,
session_key: &SessionSlot,
session_id: &str,
project_key: &ProjectKey,
label: &str,
cwd: &str,
is_git_repo_at_spawn: bool,
needs_tag: bool,
minted_row: bool,
config_dir: &Path,
)
pub(crate) fn apply_worker_tag_or_rollback_with_config_dir( self: &Arc<Self>, session_key: &SessionSlot, session_id: &str, project_key: &ProjectKey, label: &str, cwd: &str, is_git_repo_at_spawn: bool, needs_tag: bool, minted_row: bool, config_dir: &Path, )
Testable inner of Self::apply_worker_tag_or_rollback that
takes the resolved config_dir directly. The production caller
resolves via config_dir_for; unit tests pass a tempdir to
exercise the retry / rollback / deferred branches without
having to register a full AgentHandle.
cwd is the tree this worker runs in - the root its spawn
resolved, which is the project’s forge.toml path for a worker
that stated none. For git-repo workers
(is_git_repo_at_spawn = true) the tag-write is routed to
<base>/.claude/worktrees/<label> via
crate::mcp::workers::types::worker_tag_dir, base the CLI’s
main working tree, so the lookup matches where claude’s
--worktree <label> actually wrote the JSONL.
needs_tag and minted_row together decide whether a rollback
takes the worker’s durable row with it; see the arm below.
Sourcepub(crate) fn apply_worker_tag_or_rollback_with_budget(
self: &Arc<Self>,
session_key: &SessionSlot,
session_id: &str,
project_key: &ProjectKey,
label: &str,
effective_cwd: &Path,
facts: WorkerTagFacts,
config_dir: &Path,
budget: WorkerTagRetryBudget,
)
pub(crate) fn apply_worker_tag_or_rollback_with_budget( self: &Arc<Self>, session_key: &SessionSlot, session_id: &str, project_key: &ProjectKey, label: &str, effective_cwd: &Path, facts: WorkerTagFacts, config_dir: &Path, budget: WorkerTagRetryBudget, )
Self::apply_worker_tag_or_rollback_with_config_dir with the
worktree-aware tag directory and the retry budget both injected,
so a test can shrink the detached task’s wall time and derive its
own wait from the same numbers the task runs on.
Sourcepub(crate) fn retry_worker_tag_opportunistic(
self: &Arc<Self>,
project_key: &ProjectKey,
session_key: &SessionSlot,
session_id: &str,
label: &str,
cwd: &str,
is_git_repo_at_spawn: bool,
)
pub(crate) fn retry_worker_tag_opportunistic( self: &Arc<Self>, project_key: &ProjectKey, session_key: &SessionSlot, session_id: &str, label: &str, cwd: &str, is_git_repo_at_spawn: bool, )
Opportunistically retry the JSONL tag-write for a worker whose
needs_tag flag is set. Called by
spawn::handle_deliver_worker_prompt when a DeliverWorkerPrompt
arrives for a worker that was spawned idle (no initial_prompt)
and therefore had no JSONL at Connected. By the time the first
turn fires, claude is writing the JSONL, so the tag-write should
succeed within a turn or two.
On success: clear needs_tag and emit
WorkerStatusChanged { StatusChanged }.
On failure (any kind): log warn, leave needs_tag = true so the
next turn retries again. Never rolls back - the worker stays
functional regardless of the tag’s on-disk state.
Sourcepub(crate) fn retry_worker_tag_opportunistic_with_config_dir(
self: &Arc<Self>,
project_key: &ProjectKey,
session_key: &SessionSlot,
session_id: &str,
label: &str,
cwd: &str,
is_git_repo_at_spawn: bool,
config_dir: &Path,
)
pub(crate) fn retry_worker_tag_opportunistic_with_config_dir( self: &Arc<Self>, project_key: &ProjectKey, session_key: &SessionSlot, session_id: &str, label: &str, cwd: &str, is_git_repo_at_spawn: bool, config_dir: &Path, )
Testable inner of Self::retry_worker_tag_opportunistic that
takes the resolved config_dir directly. Same shape as the
_with_config_dir variant of apply_worker_tag_or_rollback,
including the worktree-aware cwd routing for git-repo workers.
Sourcepub fn refresh_context_usage(
&self,
key: &SessionSlot,
) -> Result<(), DispatchError>
pub fn refresh_context_usage( &self, key: &SessionSlot, ) -> Result<(), DispatchError>
Request a fresh context-usage snapshot for key. The bridge
replies via SessionUpdate::ContextUsageSnapshot.
§Errors
Returns DispatchError::UnknownSession when no agent is
registered for key (e.g., the session was just closed) or
when the bridge hasn’t stamped a session_id yet.
Sourcepub fn refresh_mcp_snapshot(
&self,
key: &SessionSlot,
) -> Result<(), DispatchError>
pub fn refresh_mcp_snapshot( &self, key: &SessionSlot, ) -> Result<(), DispatchError>
Request a fresh MCP server snapshot for key. The bridge
replies via SessionUpdate::McpSnapshot.
§Errors
Sourcepub fn store_process_snapshot(
&self,
key: &SessionSlot,
snapshot: Option<ProcessSnapshot>,
)
pub fn store_process_snapshot( &self, key: &SessionSlot, snapshot: Option<ProcessSnapshot>, )
Record the OS walk of key’s process tree for views to read.
The walk itself belongs to whoever owns the tick that drives it,
because a walk is a sysinfo refresh that costs tens of
milliseconds and no read should pay for one. The answer belongs
here, so a second view reads the tree rather than walking the OS
again. None clears it: the walk described a subprocess tree
that is gone.
No-op for a slot with no session: minting a domain for whoever asked would leave the workspace routing to a session nobody runs.
Sourcepub fn process_snapshot(&self, key: &SessionSlot) -> Option<ProcessSnapshot>
pub fn process_snapshot(&self, key: &SessionSlot) -> Option<ProcessSnapshot>
The last OS walk of key’s process tree, or None when nothing
has walked it or the tree it described is gone.
Sourcepub fn project_memory_path(
&self,
key: &SessionSlot,
cwd: &Path,
) -> Option<PathBuf>
pub fn project_memory_path( &self, key: &SessionSlot, cwd: &Path, ) -> Option<PathBuf>
Resolve the auto-memory path the bridge would consult for
cwd, scoped to key’s configured account. Returns None
when no agent is registered for key.
Sourcepub fn settings_documents(
&self,
key: &SessionSlot,
cwd: Option<&Path>,
) -> Option<SettingsDocuments>
pub fn settings_documents( &self, key: &SessionSlot, cwd: Option<&Path>, ) -> Option<SettingsDocuments>
Snapshot the bridge’s settings documents for key at cwd.
Returns None when no agent is registered for key.
Sourcepub fn user_preferences(&self) -> Option<Value>
pub fn user_preferences(&self) -> Option<Value>
The CLI’s per-user preferences document, from $HOME/.claude.json.
Read on each call rather than held, because the user can change a preference while forge runs.
Sourcepub fn config_dir_for(&self, key: &SessionSlot) -> Option<PathBuf>
pub fn config_dir_for(&self, key: &SessionSlot) -> Option<PathBuf>
Resolve the agent’s configured config_dir for key. Returns
None when no agent is registered for key.
Sourcepub fn claude_pid(&self, key: &SessionSlot) -> Option<u32>
pub fn claude_pid(&self, key: &SessionSlot) -> Option<u32>
OS PID of the claude subprocess bound to key. Returns
None when the session has no live client (pre-spawn /
post-disconnect). The PID is stable
for the lifetime of the subprocess, so consumers (e.g. the
Inspector pane’s PROCESSES OS walk) can cache snapshots
keyed off this value.
Sourcefn agent_handle_for(&self, key: &SessionSlot) -> Option<Arc<AgentHandle>>
fn agent_handle_for(&self, key: &SessionSlot) -> Option<Arc<AgentHandle>>
Borrow the Arc<AgentHandle> registered against key.
Workspace-internal helper - surfaces a sometimes-None to keep
the early-init / disconnected branches explicit.
Sourcefn handle_and_session_id(
&self,
key: &SessionSlot,
) -> Result<(Arc<AgentHandle>, String), DispatchError>
fn handle_and_session_id( &self, key: &SessionSlot, ) -> Result<(Arc<AgentHandle>, String), DispatchError>
Resolve (handle, session_id_string) for key. Both must be
available; missing either surfaces as UnknownSession for
uniform error handling.
Source§impl Workspace
impl Workspace
Sourcepub fn register_domain_session(
&self,
key: SessionSlot,
handle: Option<Arc<AgentHandle>>,
) -> Arc<Mutex<DomainSession>> ⓘ
pub fn register_domain_session( &self, key: SessionSlot, handle: Option<Arc<AgentHandle>>, ) -> Arc<Mutex<DomainSession>> ⓘ
Register a fresh DomainSession for key under this workspace.
handle is None for pre-spawn / pre-Connect domains (filled
in later when the spawn handler runs); Some for test fixtures
that wire a stub handle up front.
DomainSession carries workspace-internal routing metadata
(conn / session_id / pending_interactions). A view’s per-
session operational state lives on the view side; the workspace
does not read or write those fields.
Returns the inserted Arc<Mutex<DomainSession>> so callers
can seed fields on the same handle they just registered.
Sourcepub(crate) fn notice_undelivered_message(
self: &Arc<Self>,
sender: &SessionSlot,
target: &SessionSlot,
reason: PeerFailureReason,
)
pub(crate) fn notice_undelivered_message( self: &Arc<Self>, sender: &SessionSlot, target: &SessionSlot, reason: PeerFailureReason, )
Tell a sender its message never landed.
The one delivery-ack path, and it is NOT outstanding state coming back: nothing is tracked between the send and this notice. A message parked for a sleeping project is dropped when the spawn it waited on fails, and the parked entry itself carries the sender, so the sender is told rather than left believing the words arrived.
Auto Trait Implementations§
impl !Freeze for Workspace
impl !RefUnwindSafe for Workspace
impl !UnwindSafe for Workspace
impl Send for Workspace
impl Sync for Workspace
impl Unpin for Workspace
impl UnsafeUnpin for Workspace
Blanket Implementations§
§impl<T> AnySync for T
impl<T> AnySync for T
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
impl<S, T> Duplex<S> for Twhere
T: FromSample<S> + ToSample<S>,
§impl<S> FromSample<S> for S
impl<S> FromSample<S> for S
fn from_sample_(s: S) -> S
§impl<T> Instrument for T
impl<T> Instrument for T
§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read more