You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Which approval route a gated call takes is fixed in config. Conversational assumes an approver is at the client, parks the call in-process, and the pending approval lives exactly as long as the SSE connection — a dropped stream denies it. Webhook assumes no one is there and goes out of band. The assumption is made once, at TOML load, about every run the deployment will ever serve. The run knows, at every moment, whether anyone is present — #781 mirrors it onto the RunContext the gate already holds (HitlApprovalWrapper::bind_run, PR #738; a PreparedAgent field after PR #719).
Let the route read it.
Goals
An adaptive mode alongside the two: [hitl.route] mode = "adaptive" configures both routes and picks per gated call. A run whose RunContext reports presence takes the conversational route; one without takes the webhook route — sync, or poll where delivery = "poll" (HITL Park/Reify #271) — or parks under [hitl.park] where enabled.
The decision reaches the gate through the store and the bus, not a oneshot. POST /v1/approvals/{decision_id} writes the decision to ApprovalStore and publishes on EventBus topic approval:{decision_id}; the conversational arm, which subscribed to that topic when it routed the call, wakes and reads the store. The oneshot registry in hitl::registry goes. After this nothing in the web server holds a channel end into an agent, the hand-off below is a store read rather than the transfer of a live channel, and an approval can be resolved by an instance other than the one waiting on it.
Polling for an unattended approval is the runtime's job, not the route's. ApprovalRouted { route: Poll } on the session stream records that the approval needs polling; the runtime holding the session — under [FEATURE]: Atomic Fence for VFS Claims #581, its claim holder — runs the poller as a task keyed by session, the same shape as [FEATURE]: Liveness policy when a run's last claim detaches #784's liveness timer, and re-attaches it when a parked run is resumed. The poll pool inside the HITL route code retires.
Presence lost mid-approval hands off rather than fails closed — under Continue or Park ([FEATURE]: Liveness policy when a run's last claim detaches #784). A conversational approval whose run loses its last present observer is re-routed to the webhook or parked, and the pending approval outlives the connection that raised it; the approval_pending the client saw stays valid, and POST /v1/approvals/{decision_id} still resolves it. Under Cancel, the run ends with its last claimant and the approval is cancelled with it ([FEATURE]: HITL webhook route has no cancellation contract, stranding abandoned approvals #613's fix), so there is nothing to hand off. The hand-off is a gate change this issue owns: the conversational arm, awaiting its oneshot, has to notice presence going and switch arms without losing the decision id.
Presence gained mid-approval surfaces the pending approval to the newcomer, from the run's record ([FEATURE]: A runtime that owns sessions and their runs #780) — not from journal replay, which is bounded and may no longer hold it. That is what makes "zoom in and approve" work.
The two existing modes behave exactly as they do now. Adaptive is opt-in.
Route decisions are lifecycle events, so a run's stream records which route each approval took and why. LifecycleEvent gains a variant for it.
Data structures
Proposed:
// aura-config — the third mode#[serde(tag = "mode", rename_all = "snake_case")]pubenumDecisionRouteConfig{Conversational{timeout_secs:u64},// as todayWebhook{url:WebhookUrl,timeout_secs:u64,/* ... */},// as today, plus #271's deliveryAdaptive{conversational:ConversationalRoute,// the Conversational fieldswebhook:WebhookRoute,// the Webhook fields},}// aura::hitl — the gate reads presence from the run it already holds.// No trait, no back-reference to the runtime: #781 keeps `RunContext::present`// current on every attach and detach.pubstructHitlRuntime{pubpatterns:Arc<[GlobPattern]>,pubroute:Arc<DecisionRoute>,// for Adaptive, holds both resolved routespubpark_enabled:bool,}implHitlApprovalWrapper{fnroute_for(&self,run:&RunContext) -> ApprovalRoute{match(&*self.runtime.route, run.present()){/* ... */}}}// aura_events::run — the added lifecycle variantpubenumLifecycleEvent{// ... existing variants ...ApprovalRouted{decision_id:String,route:ApprovalRoute,because:RouteCause,},}#[serde(rename_all = "snake_case")]pubenumApprovalRoute{Conversational,Webhook,Poll,Parked}// Poll: the runtime polls on the run's behalf// aura::hitl — how a decision arrives, replacing hitl::registry's oneshotimplHitlApprovalWrapper{/// Subscribes to `approval:{decision_id}` on the session store's bus before/// the call is surfaced, then awaits either the bus or the run's cancel/// token, and reads the decision from ApprovalStore. No channel is handed/// to the web server; the ingress writes and publishes.asyncfnawait_decision(&self,decision_id:&DecisionId,run:&RunContext) -> Result<ApprovalDecision,GateError>;}#[serde(rename_all = "snake_case")]pubenumRouteCause{Presence,NoPresence,PresenceLost,PresenceGained}
Additional Context
The #271 stack (PR #683, with PR #760 on top) owns the unattended half this routes to — poll delivery and park activation — and its config surface is where adaptive is added, which is why this waits for it. Its poll pool is instance-local and does not survive more than one instance; recording the need to poll in the session stream and keying the poller by session is what makes it the claim holder's job and lets a resumed run pick its poller back up.
Presence is self-declared: an observer that attaches with presence: true is believed. A dashboard that claims it routes approvals to something that cannot answer, and the conversational timeout denies them. That is the same trust mode = "conversational" places in a client today; adaptive mode does not widen it, it only makes it per-run.
Following up on the note above: what to compare once #760 lands. As of #760's head 3ff1eb17, three parts of this issue overlap it:
Config. This issue adds mode = "adaptive" to [hitl.route]. On Park and resume for human-in-the-loop approvals #760 that table is DecisionRouteConfig, an enum tagged by mode with two variants: conversational, and webhook, which carries delivery = "sync" | "poll", poll_url, poll_interval_secs, poll_request_timeout_secs, receiver_wait_timeout_secs, and the header mappings. An adaptive variant needs both variants' settings, so decide how it composes them (duplicate the fields, or nest the two routes) against Park and resume for human-in-the-loop approvals #760's final shape.
Decision delivery. This issue says the decision reaches the gate through ApprovalStore and EventBus, not a oneshot. On Park and resume for human-in-the-loop approvals #760hitl::registry already stores the decision and publishes it on a per-approval bus topic, but still wakes the suspended tool call through a oneshot. Against Park and resume for human-in-the-loop approvals #760 the change here is removing that oneshot wake, not the whole move; rewrite the goal and the aura::hitl data structures to match.
Polling. This issue makes polling a runtime task keyed by session, run by the claim holder under [FEATURE]: Atomic Fence for VFS Claims #581. Park and resume for human-in-the-loop approvals #760 adds hitl/poller.rs, a reconciler that lists undecided approvals from the shared ApprovalStore and keeps its own instance_id's rows. Restate the change as moving that reconciler from instance-keyed to session-keyed, and check what it does with rows owned by an instance that has died.
Summary
Which approval route a gated call takes is fixed in config.
Conversationalassumes an approver is at the client, parks the call in-process, and the pending approval lives exactly as long as the SSE connection — a dropped stream denies it.Webhookassumes no one is there and goes out of band. The assumption is made once, at TOML load, about every run the deployment will ever serve. The run knows, at every moment, whether anyone is present — #781 mirrors it onto theRunContextthe gate already holds (HitlApprovalWrapper::bind_run, PR #738; aPreparedAgentfield after PR #719).Let the route read it.
Goals
[hitl.route] mode = "adaptive"configures both routes and picks per gated call. A run whoseRunContextreports presence takes the conversational route; one without takes the webhook route — sync, or poll wheredelivery = "poll"(HITL Park/Reify #271) — or parks under[hitl.park]where enabled.POST /v1/approvals/{decision_id}writes the decision toApprovalStoreand publishes onEventBustopicapproval:{decision_id}; the conversational arm, which subscribed to that topic when it routed the call, wakes and reads the store. The oneshot registry inhitl::registrygoes. After this nothing in the web server holds a channel end into an agent, the hand-off below is a store read rather than the transfer of a live channel, and an approval can be resolved by an instance other than the one waiting on it.ApprovalRouted { route: Poll }on the session stream records that the approval needs polling; the runtime holding the session — under [FEATURE]: Atomic Fence for VFS Claims #581, its claim holder — runs the poller as a task keyed by session, the same shape as [FEATURE]: Liveness policy when a run's last claim detaches #784's liveness timer, and re-attaches it when a parked run is resumed. The poll pool inside the HITL route code retires.ContinueorPark([FEATURE]: Liveness policy when a run's last claim detaches #784). A conversational approval whose run loses its last present observer is re-routed to the webhook or parked, and the pending approval outlives the connection that raised it; theapproval_pendingthe client saw stays valid, andPOST /v1/approvals/{decision_id}still resolves it. UnderCancel, the run ends with its last claimant and the approval is cancelled with it ([FEATURE]: HITL webhook route has no cancellation contract, stranding abandoned approvals #613's fix), so there is nothing to hand off. The hand-off is a gate change this issue owns: the conversational arm, awaiting its oneshot, has to notice presence going and switch arms without losing the decision id.LifecycleEventgains a variant for it.Data structures
Proposed:
Additional Context
The #271 stack (PR #683, with PR #760 on top) owns the unattended half this routes to — poll delivery and park activation — and its config surface is where
adaptiveis added, which is why this waits for it. Its poll pool is instance-local and does not survive more than one instance; recording the need to poll in the session stream and keying the poller by session is what makes it the claim holder's job and lets a resumed run pick its poller back up.Presence is self-declared: an observer that attaches with
presence: trueis believed. A dashboard that claims it routes approvals to something that cannot answer, and the conversational timeout denies them. That is the same trustmode = "conversational"places in a client today; adaptive mode does not widen it, it only makes it per-run.Searched Issues
Code of Conduct