[Bug]:
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
End-to-end approval identity. The
approval_id(or a runtime-generatedrequest_id, withapproval_idas 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.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. Theapproval.resolvedbroadcast must always carry{approval_id, state, reason?}so the frontend can update the specific popup instead of closing whatever is on screen.Idempotent, honest responses. Every decision endpoint must:
- return the actual outcome, not "received":
200 {state: resolved}vs410 Gone {state: expired, reason: timeout}vs409 {state: superseded}; - make late clicks harmless (no-op with the real state, UI refreshes from that state);
- Write Gate:
success:trueonly after the write/patch has actually applied; on apply failure return the failure and keep the record pending (or mark itfailed, never silently stuck).
- return the actual outcome, not "received":
Runtime capability negotiation, not version guessing. On bridge startup, probe what the hermes-agent runtime emits (
request_idsupport, timeout source, etc.) and adapt or surface a visible compatibility warning in the UI. Today theresolved = 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.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:falsewithstate: 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)
- Studio v0.7.23 + hermes-agent v0.20.0.
- Start a session with two parallel
delegate_taskchildren that each trigger a dangerous command → two approval popups, same session key. - Click Allow on the first popup.
- Observe: the other child's command executes (FIFO), or nothing executes for 300s (no
request_idon 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
Environment
Docker
Node Version
No response
Additional Context
No response
Source: EKKOLearnAI/hermes-studio