[Bug]:

Author: StevenNotchCreated Sep 18, 2026Updated Sep 18, 2026
Labelsbug

Ekko Studio Version

v0.7.21,docker部署,遇到超时后始终关不掉那个审批页面,后面不知道怎么的消失了

Agent Runtime and Version (if applicable)

No response

Bug Description

[Bug] Approval lifecycle is structurally broken: approval_id is not propagated end-to-end, no state machine, no idempotency — 5 open symptoms of one root cause

中文摘要:审批链路的 approval_id 只在 Studio 前端生成,bridge 收到后只用来查 session,实际 resolve 走 hermes-agent 的 FIFO 队列,因此多弹窗并存时 Allow/Deny 会打到错误的命令上(#1992);agent 版本不发 request_id 时审批永远不 resolve、空等 300s(#2756);超时后迟到点击返回 ok:true 但命令不执行、弹窗却关闭(#2558);Write Gate 写失败仍返回 success:true(#2560);/approve 消息先于入队到达时静默丢失(#1933)。五个 open issue 是同一个根因:审批没有端到端的身份标识和统一状态机,各层超时/失败语义各自为政。本 issue 建议按「id 端到端透传 + 状态机 + 幂等 + 版本能力探测」做一次统一重构,而不是继续逐个修症状。

Summary

Every one of the approval bugs currently open in this repo is a different symptom of the same architectural gap: the Studio-generated approval_id never reaches the layer that actually resolves the approval, and no layer owns a single authoritative state for an approval's lifecycle.

Concretely, AgentPool.respond_approval() in bridge_pool.py receives an approval_id from the frontend but only uses it to look up the session, then delegates to hermes-agent's resolve_gateway_approval(session_key, choice), which resolves the oldest pending entry in that session's FIFO queue — not the approval the user clicked (#1992). Everything else follows: wrong-command resolution, silent no-ops on version skew, fake successes, and timeout desyncs.

This issue proposes one structural fix with a clear scope, and cites the five open issues it would close.

The five symptoms (all currently open)

Issue Symptom Root cause within this architecture
#1992 With ≥2 concurrent pending approvals (parallel delegate_task, execute_code, rapid dangerous commands), the user's Allow/Deny resolves the wrong command resolve is keyed by session FIFO, not by approval identity
#2756 With hermes-agent < v0.20.5 (no request_id emitted), WebUI clicks return ok:true but the agent thread never receives the choice; blocks until 300s timeout or an interrupt forces deny bridge requires a field the runtime may not send; failure is silent, no capability negotiation
#2558 After the command-side timeout fires, a late click still gets socket ack ok:true + resolved:false, the missing-resolved broadcast closes the popup, and the user experiences a false success; three independent timeout sources (agent queue, bridge hardcoded 300_000, user-configured approvals.timeout) race each other no single authoritative state machine; each layer times out on its own clock
#2560 Write Gate approve returns success:true even when the staged patch fails to apply; the record stays in the pending list while the UI shows success the "approve" API conflates decision received with write succeeded; no post-action verification
#1933 A /approve message arriving before queue registration is silently dropped; users retry 2–7 times the response path assumes the entry already exists; no pending-registration wait or re-queue

Proposed design

  1. End-to-end approval identity. The approval_id (or a runtime-generated request_id, with approval_id as the Studio-side alias) must be carried through: gateway notify → queue entry → popup → resolve call → result broadcast. The resolve call must match on that id, not on session FIFO. FIFO should only be a legacy fallback while emitting a warning, then removed.

  2. One state machine, one owner. An approval is one of: pending → resolved | expired | superseded. The runtime (hermes-agent queue) owns the transition; the bridge and UI only observe via events. The approval.resolved broadcast must always carry {approval_id, state, reason?} so the frontend can update the specific popup instead of closing whatever is on screen.

  3. Idempotent, honest responses. Every decision endpoint must:

    • return the actual outcome, not "received": 200 {state: resolved} vs 410 Gone {state: expired, reason: timeout} vs 409 {state: superseded};
    • make late clicks harmless (no-op with the real state, UI refreshes from that state);
    • Write Gate: success:true only after the write/patch has actually applied; on apply failure return the failure and keep the record pending (or mark it failed, never silently stuck).
  4. Runtime capability negotiation, not version guessing. On bridge startup, probe what the hermes-agent runtime emits (request_id support, timeout source, etc.) and adapt or surface a visible compatibility warning in the UI. Today the resolved = bool(gateway_request_id) and resolve_gateway_approval(...) pattern turns missing capability into a silent no-op (#2756). A documented compatibility matrix (Studio x.y ↔ hermes-agent z.w) in the README would also unblock users.

  5. Registration-before-decision. The queue entry must exist (or a registration promise be awaited) before any decision path can answer "no pending"; a decision arriving slightly early should wait briefly (bounded) or be re-queued with a visible "resending…" state rather than being dropped (#1933).

Acceptance criteria

  • Two approvals pending in the same session: clicking popup A resolves only A; B stays pending and its popup remains on screen.
  • With a runtime that does not emit request_id: either approvals resolve correctly via the negotiated fallback, or the UI shows a persistent compatibility banner — never a silent 300s hang.
  • A decision arriving after expiry returns 410/resolved:false with state: expired, and the UI updates that specific popup to "expired, command not executed" instead of silently closing it.
  • Write Gate approve on an unapplicable patch returns a non-success outcome and the record is visibly failed/pending, never a green success with a stuck row.
  • A fast /approve (within the registration race window) succeeds on the first attempt, or the user is told "approval was registered, retrying" and it succeeds automatically.
  • All five issues above can be closed or explicitly marked as covered.

Why now

Between v0.7.20 and v0.7.23 the only approval-related commit was #3018 (Hermes 0.21 session association) — a runtime-compat patch, not a routing fix. The five issues above have been accumulating since 0.6.x and each individual patch risks colliding with the next (e.g., a FIFO-ordering "fix" for #1992 makes #1933's race window worse). A single scoped refactor with the state machine above is the only path that lets the remaining per-issue patches be deleted instead of stacked.

Reproduction (minimal, for #1992/#2756 pair)

  1. Studio v0.7.23 + hermes-agent v0.20.0.
  2. Start a session with two parallel delegate_task children that each trigger a dangerous command → two approval popups, same session key.
  3. Click Allow on the first popup.
  4. Observe: the other child's command executes (FIFO), or nothing executes for 300s (no request_id on 0.20.0).

Happy to help test against a patched bridge on a Docker setup — this is the same configuration we run in production (multiple profiles, concurrent coding agents).

Steps to Reproduce

遇到超时后始终关不掉那个审批页面,后面不知道怎么的消失了

Expected Behavior

v0.7.21,docker部署,遇到超时后始终关不掉那个审批页面,后面不知道怎么的消失了

Actual Behavior

遇到超时后始终关不掉那个审批页面,后面不知道怎么的消失了

Logs / Error Messages

bash

Environment

Docker

Node Version

No response

Additional Context

No response

Source: EKKOLearnAI/hermes-studio