Skip to content

[FEATURE]: HITL routing consults presence #786

Description

@justintime4tea

Summary

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")]
pub enum DecisionRouteConfig {
    Conversational { timeout_secs: u64 },                      // as today
    Webhook { url: WebhookUrl, timeout_secs: u64, /* ... */ }, // as today, plus #271's delivery
    Adaptive {
        conversational: ConversationalRoute,   // the Conversational fields
        webhook: 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.
pub struct HitlRuntime {
    pub patterns: Arc<[GlobPattern]>,
    pub route: Arc<DecisionRoute>,          // for Adaptive, holds both resolved routes
    pub park_enabled: bool,
}
impl HitlApprovalWrapper {
    fn route_for(&self, run: &RunContext) -> ApprovalRoute {
        match (&*self.runtime.route, run.present()) { /* ... */ }
    }
}

// aura_events::run — the added lifecycle variant
pub enum LifecycleEvent {
    // ... existing variants ...
    ApprovalRouted {
        decision_id: String,
        route: ApprovalRoute,
        because: RouteCause,
    },
}

#[serde(rename_all = "snake_case")]
pub enum ApprovalRoute { Conversational, Webhook, Poll, Parked }   // Poll: the runtime polls on the run's behalf

// aura::hitl — how a decision arrives, replacing hitl::registry's oneshot
impl HitlApprovalWrapper {
    /// 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.
    async fn await_decision(&self, decision_id: &DecisionId, run: &RunContext) -> Result<ApprovalDecision, GateError>;
}

#[serde(rename_all = "snake_case")]
pub enum RouteCause { 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.

Searched Issues

  • No similar issues found

Code of Conduct

  • I agree to follow this project's Code of Conduct

Activity

  1. justintime4tea commented on Oct 7, 2026

    @justintime4tea
    CollaboratorAuthor

    Once #760 closes, this issue should take into considering #760's final impl (its in draft) and make necessary adjustments (if any).

  2. justintime4tea commented on Oct 9, 2026

    @justintime4tea
    CollaboratorAuthor

    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:

    1. 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.
    2. 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 #760 hitl::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.
    3. 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions