Expand description
App-level spawn command handlers. Called from
crate::Workspace::dispatch when an App-level
Command::SpawnProject arrives.
Each handler resolves the id the session will run under, emits
SessionUpdate::Spawning under it, and kicks off the agent spawn.
The matching Connected arrives later under the same key.
Re-exports§
use std::sync::Arc;🔒use std::time::Duration;🔒use std::time::SystemTime;🔒use forge_agent::client::SessionLaunchSettings;🔒use crate::mcp::gotify::types::GotifyNotification;🔒use crate::mcp::peers::types::WrappedPrompt;🔒use crate::protocol::Command;🔒use crate::protocol::PromptSource;🔒use crate::protocol::SessionUpdate;🔒use crate::protocol::WorkerSpawnReply;🔒use crate::protocol::WorkerStatusAction;🔒use crate::protocol::WorktreeDisposition;🔒use crate::target::ProjectKey;🔒use crate::workspace::DespawnCleanupPending;🔒use crate::workspace::LiveWorkerRefusal;🔒use crate::workspace::Workspace;🔒use crate::SessionSlot;🔒use crate::SessionTarget;🔒use std::fmt::Write as _;🔒use forge_primitives::slack::SlackMessage;🔒
Structs§
- Despawn
Cleanup 🔒 - The despawn’s filesystem half, handed to the blocking pool.
- Worker
Spawn 🔒Args - The per-worker half of a spawn request, bundled so a caller cannot
transpose
kickwithresume_kick- both areOption<String>and the mistake is silent.
Enums§
- Cron
Fire 🔒Outcome - Outcome of a cron fire’s delivery, so
fire_due_cronscan decide the entry’s fate rather than treating every hand-off as a success. - Cron
Owner 🔒Check - Outcome of checking whether a cron’s owner still exists to be woken.
Constants§
- DEFAULT_
LEAD_ 🔒CHARTER - Charter every lead session is launched with.
- REPLACED_
CLI_ 🔒TOOLS - The
claudeCLI’s tools forge has replaced with its own surfaces: the task quartet (mcp__forge__tasks__*over forge’s own store), the cron trio (forge’s durable crons),SendMessage/ListAgents(theagents__*group),Workflow(forge’s workers) andRemoteTrigger(cloud routines, which forge has no equivalent for and nothing here reaches for). - SECONDS_
PER_ 🔒DAY - TEARDOWN_
WAIT 🔒 - How long the despawn holds its worktree removal for the session task’s teardown signal before giving up and removing anyway: the SDK’s close-wait budget (the child’s reap) plus a margin for the task to observe its closed channel, disconnect and drop. An expiry is logged - it is the one shape where the removal goes ahead unprotected.
Functions§
- answer_
delivery 🔒 - Answer a delivery’s
respondchannel, if the caller asked for one:Ok(())once the prompt was accepted - handed to the target’s session queue, held for its turn end, or parked for its connect - andErr(reason)when nothing could take it. The sender’s ack is built from this answer alone, so it never claims a delivery that did not happen. - apply_
blocked_ 🔒tools - Deny every session - lead, resumed or worker - the tools forge has
replaced, merging into a
--disallowedToolsvalue already there, so the flag is emitted once with every name in it rather than twice. A worker’s spawn is the case that carries one: its own denials already hold that entry. Called from the one spawn path every session goes through and from the respawn stamp, so no launch can be assembled without it. - apply_
lead_ 🔒charter - Stamp
DEFAULT_LEAD_CHARTERonto the launch settings so every lead session carries one. No-op when a charter is already set - worker spawns supply their own and we never overwrite it. - blocked_
tools_ 🔒for - The
--disallowedToolsvalue for a session, comma-separated - the form the CLI’s variadic<tools...>parser accepts. - buffer_
prompt_ 🔒until_ connected - Handle a
Command::DeliverWorkerPrompt: route a wrapped peer-style envelope to the worker matchingtarget_labelin the caller’s project. Latest-spawned-wins on duplicate labels. The typed PeerEnvelopeAppended echo follows the same pattern ashandle_deliver_peer_prompt- workers reuse the peer envelope verbatim so a view’s chat render is identical between the two paths. - build_
worker_ 🔒extra_ args - Build the list of
(flag, value)extra CLI args specific to a worker spawn. When the project is a git repo, append("worktree", Some(label))so the spawnedclaudesubprocess creates a worktree at<base>/.claude/worktrees/<label>/-basethe repo’s canonical root, the main checkout for a project that is itself a linked worktree - and runs the session inside it. In all cases, append the worker’sblocked_tools_forset as its--disallowedToolsentry: workers are pinned to their spawn-time location (whether a worktree or the project cwd) and must not be able to call claude’s built-in worktree-hop tools to escape. Comma-separated value form is empirically accepted by the CLI’s variadic<tools...>parser. - charter_
with_ 🔒withheld_ families - The worker’s prompt charter: the stored text plus one line per withheld MCP family. The row keeps the RAW charter - these lines are composed per spawn from the current selection, so they can neither duplicate across re-spawns nor outlive a revision.
- cron_
slot_ 🔒exists - Whether a cron’s slot still has a session that can be woken. A lead’s
does whenever its project does; a worker’s does while its label has a
row AND that row can still start, since the boot wave is what
re-spawns it. A read failure yields
CronOwnerCheck::Unknownso the fire router leaves the cron rather than deleting a live slot’s cron on a transient hiccup. - deliver_
cron_ 🔒prompt - Deliver a due cron’s prompt into its OWNER’s session as a plain user
turn AND echo it as a cron block.
team_roleNoneroutes to the project lead,Some(label)to that worker. A live owner gets aCommand::Prompt; an asleep-but-existing owner is woken by dispatchingCommand::SpawnProject(which resumes the lead and, through the lead’s respawn, the worker), the prompt buffered by(project, team_role)and drained on the owner’s connect. An owner that no longer exists (a gone project, or a worker label with no persisted row) yieldsTargetGone. - deliver_
gotify_ 🔒message - Deliver a matched Gotify
notificationintoprojectas a plain user turn AND echo it into the target’s chat as a notification block. Whenteam_rolenames a running team worker, deliver straight to it; otherwise deliver to the project lead - echo + dispatch aCommand::Promptif it’s running, else park it for the target’s slot and dispatchCommand::SpawnProject(SessionTaskdrains + echoes on Connected viadeliver_parked_gotify). A team-worker subscription with NO live entry falls through to lead delivery (spawning the project brings the team up); a live-but-not-yet-connected worker parks for its own label instead. A project no longer in forge.toml is logged and skipped. - deliver_
slack_ 🔒message - Deliver one conversation’s matched Slack messages to its subscriber’s
session: dispatch them now when that session is running, buffer them on
the session’s own domain when it is still spawning. Mirrors
deliver_gotify_message, including its rule that a worker-owned subscription falls through to the lead only when the worker is gone entirely: the despawn race (teardown removed its subscriptions first), or aFailedentry, which is kept by design and is not live - a bucket parked for one would never drain. The fall-through commits under the worker’s own dedupe key, so the batch counts as delivered and stays with the lead: a durable worker that later respawns does not re-receive it. - delivery_
hold_ 🔒signal - The turn signal a delivery is held on: only what a
Resultclears. - dismiss_
failed_ 🔒worker - Close a failed worker, or one whose failure is all that is left of it: take the tombstone entry when one survives, clear the slot’s failure record, and announce the release so views re-read the row without it.
- drain_
turn_ 🔒held_ for_ slot - Deliver everything held for
slotwhile a turn was in flight, in the order it arrived. Called where the turn’s Result lands, so each held prompt starts a turn of its own instead of folding into the one that was running. A prompt the seat cannot take now - the session died between the hold and the turn end - is told to its sender rather than dropped, which is the whole point of holding it. - emit_
worker_ 🔒removed - Announce a torn-down worker to the views, carrying what became of its worktree.
- handle_
close_ 🔒worker - Handle a
Command::CloseWorker(a view’s per-row X-button): tear the worker down viateardown_worker. Does NOT touch the git worktree - that’s theagents__despawnpath’s job; the X-button’s behavior is intentionally unchanged. - handle_
deliver_ 🔒peer_ prompt - Handle a
Command::DeliverPeerPrompt. Resolves the target project to a running SessionTask (deliver immediately) or a sleeping one (buffer + auto-spawn), then dispatches the wrapped prompt as a regularCommand::Prompt. - handle_
deliver_ 🔒worker_ prompt - handle_
deliver_ 🔒worker_ prompt_ to_ lead - Handle a
Command::DeliverWorkerPromptToLead: route a wrapped peer-style prompt from a worker back to its lead, addressed by the lead’sSessionSlot(resolved at Tool dispatch from the worker’sspawned_by_session_id). Same wire shape asDeliverWorkerPrompt- PeerEnvelopeAppended echo + Command::Prompt dispatch - so a view renders the lead’s message identically to a sibling-worker delivery. - handle_
despawn_ 🔒worker - Handle a
Command::DespawnWorker(theagents__despawnMCP tool): the lead’s clean-close gesture. Unlikehandle_close_workerit also cleans up the worker’s git worktree. - handle_
despawn_ 🔒worker_ within handle_despawn_workerwith the teardown wait injected, so a case can drive the expiry without sitting out the production budget.- handle_
heal_ 🔒project_ spawn - The heal’s resume: the same spawn as
handle_spawn_project, but it cannot open a seat the reader closed. A heal recovers a death; it is never an opening door. - handle_
spawn_ 🔒project - Resolve the id the project’s lead will run under, emit
SessionUpdate::Spawningunder it, then spawn the agent. TheConnectedevent from the resultingSessionTasklands under the same key, so the announced bucket is the one the child connects under. A spawn that fails names the reason, which the heal’s own supervisor counts as a failed attempt; a command dispatch drops it. - handle_
spawn_ 🔒worker - Handle a
Command::SpawnWorker: insert aSpawningworker entry inlive_workers[project_key], dispatch a spawn for the id the worker will run under (or the id being resumed) with the charter threaded ontoSessionLaunchSettings, then reply onreturn_towith that id and the tag value. The Connected handler insession_task::translate_eventwrites the actual JSONL tag row and transitions the entry from Spawning to Running (or rolls back on tag-write failure). - in_
thread_ 🔒order - The bundle’s members in reading order: oldest first, with a thread’s replies directly under the parent they answer.
- known_
project_ 🔒refusal - The refusal for a spawn whose tree resolves to a project
forge.tomldeclares (D3). One source: the facade refuses with it before it ensures anything, and the handler refuses with it as the shared core both dispatch sources pass through. - live_
cron_ 🔒slot - The live session that fills the cron’s slot in
view: for a lead cron the running lead (the first open session that is not a live worker); for a worker cron the live worker with that label.Nonewhen no session holds the slot. - member_
line 🔒 - One bundle member. The
<author>:clause is whatforge_server::envelope::detect_inboundkeys on, so a member always carries one. A resolved name is preferred; the raw id is only a fallback the block drops, since an id is never a name to print. - missed_
cron_ 🔒text - Text delivered for a cron fire: the raw prompt, prefixed with a plain marker when the fire is overdue so the owner can tell a catch-up from an on-time fire.
- push_
cron_ 🔒prompt_ into_ chat - Emit a typed
CronPromptAppendedso the target session’s chat buffer shows a cron block for the fired prompt. Mirrorspush_gotify_notification_into_chat- the target’sclaudesubprocess still receives the raw prompt via a separateCommand::Prompt; this only drives the visible chat echo. - push_
gotify_ 🔒notification_ into_ chat - Emit a typed
GotifyNotificationAppendedso the target session’s chat buffer shows the inbound notification block. Mirrorspush_peer_user_turn_into_chat- the target’sclaudesubprocess still receives the prose via a separateCommand::Promptdispatch; this only drives the visible chat echo. - push_
peer_ 🔒user_ turn_ into_ chat - Emit a typed
PeerEnvelopeAppendedso the target session’s chat buffer shows the inbound peer user-turn. A view’s reducer builds the chat-side echo directly from theWrappedPrompt’s typed fields - workspace no longer forges an SDKMessage::Userframe (audit I11). - push_
slack_ 🔒message_ into_ chat - Emit a typed
SlackMessageAppendedso the target session’s chat buffer shows the inbound Slack block. Mirrorspush_gotify_notification_into_chat- the target’sclaudesubprocess still receives the prose via a separateCommand::Promptdispatch; this only drives the visible chat echo. - reap_
branch_ 🔒and_ reviews - Reap the worker’s branch and, once that is gone, the review state keyed to it. Returns the warning a kept branch produces, which the caller’s result carries.
- reap_
worker_ 🔒branch - Reap the
worktree-<label>branch claude creates for a worker’s worktree, which outlives the worktree itself. Returns a warning when the branch was left in place,Nonewhen it was deleted or never existed. - send_
dispatch_ 🔒turn_ error - A failed prompt dispatch to a running target unwinds the echo’s already-opened turn: without this its bar counts forever and the bucket spinner never drops (mirrors the typed-submit compensation). Per-site warns stay at the call sites.
- slack_
bundle_ 🔒to_ prose - The user-turn prose for one conversation’s delivered Slack messages. It
carries the ids a reply needs - conversation, each member’s own ts, and
its thread - because the only way an agent can answer in place is to feed
those back to
slack__postorslack__edit.forge_server::envelope::detect_inboundparses this shape into a Slack envelope and a view renders that as a block, so the bracketed header, its optional age clause, its member count and the one-line-per-member body are a contract with the parser. - spawn_
project_ 🔒inner - spawn_
prompt_ 🔒charter - The charter a spawned worker’s prompt carries: the stored text plus the withheld-family lines for the EFFECTIVE set - the same config gate the tool build applies, so the prompt and the tools cannot disagree about a family.
- stale_
age_ 🔒clause - The
, 8d oldclause a header carries when its newest message is at or past the workspace’s stale age. Zero turns the mark off; a future or unparseable ts carries none. - stamp_
permission_ 🔒mode - Stamp the project’s
permission_modeinto the launch settings’permissions.defaultMode, where the existingapplied_permission_modearm inforge_sdk_workerpicks it up; overrides a launcher-supplied default. - sweep_
browser_ 🔒seats - Tell the browser fleet which seats remain: the seat is down, so its tabs go with it, except any a parked hand-off holds. The sweep runs off the teardown path, which is reaping a child and can take minutes; a fleet that is not installed is a workspace with no browsers to sweep. The snapshot carries its capture generation, so the fleet drops a sweep that arrives after a newer capture.
- team_
worker_ 🔒key - The team worker labelled
labelinproject, if a LIVE entry exists - regardless of whether it has finished connecting. The caller checks connectedness to decide dispatch-vs-buffer. - teardown_
worker 🔒 - Shared worker teardown used by both
handle_close_worker(a view’s X-button) andhandle_despawn_worker(theagents__despawnMCP tool): remove the latest-spawned worker matchinglabelfromlive_workers[project_key]and release its session (terminates the claude subprocess on drop). Returns the removedWorkerEntry, orNonewhen no live worker matched. JSONL on disk is NOT deleted - teardown only removes the in-memory live state. - try_
emit 🔒 - Emit a
SessionUpdateand log at debug when no subscriber took it (the view has not attached yet, or has gone). The send is logically best-effort - no caller can act on the failure - but visibility in the log distinguishes “no view took it” from “the emit never happened” during diagnosis. - warm_
browser_ 🔒seat - Warm the seat’s browser profile: create the entry and start its host, with no tab. Best-effort, off the spawn’s own path: a profile whose host cannot start is logged by the fleet rather than failing the spawn.
- warn_
teardown_ 🔒wait_ expired - The removal went ahead without the ordering: the session’s teardown did not answer within the wait. Warned rather than silent - it is the one shape where the despawn’s protection is absent.
- worker_
limit_ 🔒reached_ message - The caller-facing refusal for an at-cap worker spawn. One source so the classifier pin in the facade tests tracks the real text.
- worktree_
dirty_ 🔒verdict - The despawn’s dirty verdict, taken off the runtime’s worker:
git statuswalks the tracked tree, and the verdict gates the teardown, so the caller cannot await aspawn_blockinghandle.block_in_placehands the worker’s tasks to another thread for the call; a current-thread runtime has no other thread, and stays inline. ALocalSetis not detected - tokio still reportsMultiThreadthere, whereblock_in_placepanics - and the workspace runs none.