refactor(dispatch): SessionDriver seam + protocol v4 — close the session-parity gaps structurally - #1967
Merged
Merged
Conversation
…river resolver Three call sites decided "is this session a dispatch observer projection" with three different signal sets: SessionModule checked target+jobId+store, FlowChatStore checked target+store, and PersistenceModule checked only the bound target. During the startup window where DispatchJobObserver has not bound config yet, the weaker predicates could persist a projection to disk or treat it as a local backend session. resolveSessionDriverId is now the single three-signal source of truth (non-local target OR configured job id OR observer-store membership), and all three predicates delegate to it. First step of the SessionDriver seam.
…tions Introduces the SessionDriver interface (create/delete/archive/rename/ ensureReady/cancel) with a static registry, and moves the flavor-specific bodies out of SessionModule/MessageModule verbatim: - LocalSessionDriver: agentAPI-backed creation, backend delete/archive/ rename, state-machine cancellation. - DispatchSessionDriver: observer-projection creation (session id + job id minting, observer registration), tombstoned dismissal for delete/archive, local-only rename, dispatchApi cancel. Shared orchestration (workspace/title resolution, creation dedupe, cascade computation, transient handling) stays in SessionModule and now calls the driver at the divergence point. Model-resolution helpers and turn cancellation choreography move to leaf utils so drivers depend downward only; getModelMaxTokens/markCurrentTurnItemsAsCancelled re-export from their old homes.
…startTurn
sendMessage becomes shared choreography only: busy-gate planning, queueing,
mode switch, conflict retries, and the error path. The three dispatch
submission paths (submit / append-steer / continue with idempotent retry
ids) move verbatim into DispatchSessionDriver; the local dialog-turn path
(optimistic turn, state machine, worktree materialization, model sync, ACP
fork, backend-retry fallback) moves into LocalSessionDriver.
Load-bearing invariants preserved and now stated in the interface:
- planSubmission tests steer-eligibility before queueing, so a steerable
dispatch message can never be parked in a queue that only drains for
state-machine sessions (new regression test).
- ensureReady may return void synchronously; a projection adds its
optimistic turn with no microtask boundary after the user's send action
(pinned by the existing projects-immediately test).
- The dispatch_pending_<jobId> turn id, the submit-snapshot cursor CAS
arguments, and the per-session retry-id lifecycle are byte-identical.
- Steered/continued messages still skip the shared post-submission
bookkeeping ('detached' outcome).
Idempotent retry ids generalize into session-drivers/idempotency.ts;
syncSessionModelSelection moves to utils/modelSync (re-exported).
Optimistic-turn adoption in EventHandlerModule now keys purely off turn
metadata (generic adoption key) instead of dispatch config checks — any
driver that marks a projected turn gets in-place adoption when the
executor's DialogTurnStarted arrives. The metadata key stays byte-identical
for cached-transcript compatibility; features/dispatch keeps thin wrappers.
usePermissionRequests drops its dispatchJobId parameter: the driver now
supplies the request source ('live' runtime subscription vs an
external-store pair over the polled mailbox) and both respond paths.
Dispatch answers resolve their job id from bound config with an
observer-store fallback, covering the pre-binding startup window.
useComposerCapabilities replaces the scattered usesDispatchTransport / dispatchSubmissionInFlight branches with one derivation: slash-command availability, usage-report and thread-goal visibility, transfer-in-flight composer busy state, submission-options lock, session-scoped approval, baseline-locked worktree chip, and target-fed model selection are now semantic flags read at each site. Capability resolution uses the driver resolver, so an unbound projection no longer briefly offers local-only affordances during the startup race.
…-drivers - SessionModule's three remaining transport checks (activity touch, fork refusal, ensure-backend guard) resolve through the driver registry. - DispatchJobObserver installation routes through a session-drivers/dispatch shim so FlowChatManager no longer imports the feature module. - eslint no-restricted-imports fence: flow_chat/** may not import @/features/dispatch/* outside session-drivers, with four documented exceptions (type-only config fields, FlowChatStore's snapshot mutations, and the two components rendering the dispatch target-picker feature chip — all queued for the config.dispatch collapse follow-up). Remaining dispatch references in flow_chat outside the fence exceptions: 0. Decision points inside the exceptions: ChatInput's target-picker chip memo and FlowChatStore's cursor-guarded mutations, both feature machinery rather than transport branches.
A dispatch session's model and approval policy were fixed at submit because every worker read job.request. Follow-up turns can now carry overrides: - DispatchContinueRequest gains optional model/approvalPolicy; a retried turnId bound to different options is refused (same idempotency contract as the prompt). - The worker peeks the queued turn before runtime bootstrap (the approval policy is baked into initialize_core_services), persists the effective options back onto job.json under the job lock — list/status and any replacement worker observe what the turn actually ran with — and emits approvalPolicySelected/modelSelected audit events on change. Restored sessions get the turn's model via update_session_model. - Submit idempotency is unaffected: the stored intent fingerprint is compared, never recomputed from the mutated request (pinned by test). - Controller mirrors the options into the outbound observer index so reconciliation cannot revert the UI to pre-override values. - Protocol version 3 -> 4 with a new required per_turn_options capability; stale SSH targets fail probe and go through the existing signed auto-install; device targets get the version-mismatch error.
…sions The composer's model and approval controls were locked permanently after the first submit — coherent when a job was one exchange, wrong now that a dispatch session is a conversation. With protocol v4: - capabilities lock the options only while a turn is in flight (transfer/queued/running); they reopen on terminal states. - The dispatch driver's follow-up turn carries the session's current dispatchModel/dispatchApprovalPolicy as per-turn overrides, and the retry-idempotency key includes them so changed options mint a fresh turn id instead of colliding with the target's content binding.
…continue subcommand /compact and /usage now work for dispatch sessions end to end: - Follow-up turns carry a kind (prompt | compact). A compact turn runs manual context compaction bound to the worker's own turn id, so its DialogTurn/ContextCompression events flow through the existing event loop, project into the transcript, and settle the job like any turn. New compatibility facade start_manual_compaction exposes the coordinator's caller-identified compaction without awaiting completion. - New read-only 'query' verb answers persisted-state questions (usage report) from PersistenceManager in a short-lived process — no runtime bootstrap, no workspace runtime ownership, safe next to a running worker. Wired through SSH, device RPC, desktop dispatch_query command, and all peer-deny/workspace-policy/contract lists. - Fixes a latent bug: the CLI had no 'dispatch continue' subcommand, so SSH targets and desktop-as-target hosts could not receive follow-up turns at all (only the CLI-daemon in-process path worked). 'continue' and 'query' are now registered clap verbs. - Composer capabilities gain a per-command ops set: dispatch sessions execute /compact and /usage (driver-routed via FlowChatManager facades), the usage toolbar entry is visible again, and the remaining local-only commands stay hidden with the pickers. - New session_query capability required alongside per_turn_options.
Images now reach dispatch sessions. Submit and continue carry inline data-URL attachments which the worker converts to runtime image attachments on the dialog turn; the optimistic projection turn renders the same previews as a local send, and the target's DialogTurnStarted metadata replays them after reconnect. Transport budgets are explicit rather than silently truncated: SSH stages the request file over SFTP (16 MiB total, 8 MiB per image, stdin cap raised to 24 MiB), while the account-device envelope enforces a 192 KiB inline budget with a clear use-an-SSH-target error. Steering has no attachment channel, so images sent while a turn runs are refused with a wait-for-this-turn message instead of the old blanket unsupported error. New inline_attachments capability required at preflight.
The worker's event filter becomes a JobEventScope: SubagentSessionLinked events whose parent is the job session (or an already-admitted child, recursively) register the child and are published, all further events of admitted children flow into the log, and child permission requests route through the same durable mailbox. Parent turn discipline is unchanged and foreign links are still refused (pinned by test). On the controller, the observer stops suppressing the link event and the driver resolver gains a fourth signal: a session whose parent chain leads to a dispatch projection is itself dispatch-driven — never persisted locally, never treated as a local backend session — with the session lookup injected by FlowChatManager to keep the resolver a leaf module. Child transcripts now render under dispatch projections exactly like local subagent runs.
…ly executable commands Self-audit of the refactor found four surfaces that would have grown into exactly the maintenance burden this work exists to prevent: - The protocol version lived in 4 copies (CLI u32, controller u64, SSH transport u64, Web UI) and the capability list in 3 authoritative copies plus a weaker partial inside validate_dispatch_protocol whose submission path silently lacked the v4 entries. All Rust copies now derive from one services-core dispatch_contract module (version, capability names, attachment shape, structural limits, shared validator); the submission gate is now the full surface narrowed to the selected approval policy, strictly stronger than before. - The controller duplicated the target's semantic whitelists (query kind, turn kind/prompt rules). Those are the target's contract; a stale controller copy would reject valid requests after a target upgrade. The controller now enforces only transport-owned checks (sizes, budgets, idempotency-id format) plus the pre-baseline approval-policy fail-fast. - The Web UI's contract test now reads the shared Rust contract file and pins version + capability names cross-language, and dialog fixtures spread BASE_DISPATCH_CAPABILITIES instead of hand-copied lists — a capability bump is one Rust edit, one frontend edit, and CI fails on any drift. The SSH protocol tests (feature-gated, previously not exercised in this branch's runs) now derive their fixtures from the same single list. - The slash-command picker now offers exactly the ops the session can execute. Previously (predating this refactor) a dispatch user could pick /goal from the list and have it sent to the remote agent as literal prompt text.
bobleer
force-pushed
the
refactor/dispatch-session-driver
branch
from
August 2, 2026 07:12
dc0840d to
8f9cf92
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Dispatch sessions lagged normal sessions in five audited ways: model and approval policy were locked after the first submit, images were rejected, local slash commands were disabled wholesale, and subagent sessions never projected. The root cause was structural, not five missing features: the controller-side session layer had 73 dispatch decision points scattered across 8 files with no abstraction seam, so every new chat feature had to be adapted in each place or dispatch silently degraded.
The target-side durable core (job store, detached worker, byte-cursor event log, mailboxes) was verified sound — the worker already runs the unmodified product runtime — so this PR keeps it and fixes the two layers around it.
What
1. Frontend
SessionDriverseam (src/web-ui/src/flow_chat/session-drivers/)planSubmission/startTurn/steer), permissions, and ops route throughLocalSessionDriver/DispatchSessionDriver. Shared choreography (optimistic turns, queues, conflict retries, error path) stays in flow-chat-manager.ComposerCapabilitiesobject (per-command ops set, transfer-busy state, options lock, session-scoped approval, …) instead ofusesDispatchTransportbranches.no-restricted-importsfence forbidsflow_chat/**from importing@/features/dispatch/*outside the drivers (four documented exceptions). Dispatch branch count in flow_chat outside the fence: 0.2. Protocol v4 (hard break from v3; stale SSH targets go through the existing signed auto-install, device targets get a clear version-mismatch error)
model/approvalPolicy; the worker peeks them before runtime bootstrap, persists the effective values ontojob.json, and emits audit events. The composer unlocks both between turns. Submit idempotency is fingerprint-stable (pinned by test).compactruns manual context compaction as a worker-attributed turn — its events settle the job through the unchanged event loop./compactworks end to end.queryverb: read-only persisted-state answers (usage report) from a short-lived process — no runtime bootstrap, no workspace-ownership contention with a running worker./usageworks end to end.SubagentSessionLinked, and child projections inherit observer-only ownership through the resolver's parent chain.3. Wire contract single-sourced (
services-core/src/dispatch_contract.rs)Bugs found and fixed along the way
dispatch continuesubcommand, so SSH targets and desktop-as-target hosts could not receive follow-up turns at all (only the CLI-daemon in-process path worked).continueandqueryare now registered clap verbs./goalin a dispatch session sent it to the remote agent as literal prompt text. The picker now filters by the session's ops set.validate_dispatch_protocol's submission path silently lacked the v4 capability requirements (weaker than the probe path); both now derive from the single required list.Compatibility
Protocol v3 → v4 is a deliberate hard break (no dual-version support). SSH targets self-heal via the signed prebuilt auto-install; paired devices must update both sides.
Testing
bitfun-cli(70+),bitfun-core(49),bitfun-services-integrations --features remote-ssh-concrete(124),bitfun-services-core, plus new tests for per-turn option carry-forward/idempotency, turn-kind dispatching, child-session event scoping, and contract derivation.pnpm lintclean (fence active).Deliberately deferred (tracked, not missing)
/goalfor dispatch (one more turn-kind + query on the existing rails),/btw//init//review//reload-skills(locally-scoped semantics; now capability-hidden with reasons instead of silently broken), device staged-chunk attachments beyond 192 KiB, theSessionConfigdispatch-field collapse intoconfig.dispatch, ACP as a third driver, and the architecture-doc rewrite.