fix(desktop): refuse window-owned pane requests at once when no window shows the session
preview.read / preview.act / terminal.read / window.read / tour are answered only by the Desktop window that shows the requesting session (#114981). When no attached window showed it, every window stayed silent and the agent waited out the full 30-45s bridge deadline, then got the generic "No preview tab is open, or the read timed out." A window not hosting the session now declines with JSON-RPC error 4404 instead of staying silent. The gateway counts a decline as that client's vote, not as the answer: the request stays open for the owner window and settles only once every attached answering client declined, with a distinct refusal ("No Hermes Desktop window is showing this chat ... Ask the user to open this chat in the Desktop app, then retry."). A bystander window therefore still cannot beat the owner (#113348). The backend advertises the counting via client.capabilities (declines_not_shown); the shared channel only sends a decline to a backend that did, so a new app against an older backend keeps today's silence. Fixes #119333
This commit is contained in:
@@ -20,17 +20,18 @@ const deps = {
|
||||
upsertToolCall: () => undefined
|
||||
} as ServerRequestContext['deps']
|
||||
|
||||
function deliver(method: string, params: Record<string, unknown>, activeSessionId: null | string) {
|
||||
function deliver(method: string, params: Record<string, unknown>, activeSessionId: null | string, replayed?: boolean) {
|
||||
const respond = vi.fn()
|
||||
const fail = vi.fn()
|
||||
const decline = vi.fn()
|
||||
|
||||
const handled = handleServerRequest(
|
||||
{ fail, id: 'srq-1', method, params, profile: 'default', respond },
|
||||
{ decline, fail, id: 'srq-1', method, params, profile: 'default', replayed, respond },
|
||||
deps,
|
||||
activeSessionId
|
||||
)
|
||||
|
||||
return { fail, handled, respond }
|
||||
return { decline, fail, handled, respond }
|
||||
}
|
||||
|
||||
describe('connection request routing', () => {
|
||||
@@ -91,31 +92,44 @@ describe('preview action request routing', () => {
|
||||
expect(previewSessionRoute({ replayed: true, sessionId: '', activeSessionId: null })).toBe('run')
|
||||
})
|
||||
|
||||
it('leaves a scoped action request unanswered in a window showing another session', () => {
|
||||
const { handled, respond, fail } = deliver(
|
||||
it('declines a scoped action request in a window showing another session instead of answering it', () => {
|
||||
// A decline leaves the request open for the owner window; the backend
|
||||
// settles only when every attached window declined (#119333, #113348).
|
||||
const { decline, handled, respond, fail } = deliver(
|
||||
'preview.act',
|
||||
{ action: 'elements', session_id: 'session-a' },
|
||||
'session-b'
|
||||
)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(decline).toHaveBeenCalledTimes(1)
|
||||
expect(respond).not.toHaveBeenCalled()
|
||||
expect(fail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves scoped pane reads unanswered in a window showing another session', async () => {
|
||||
it('declines scoped pane reads in a window showing another session', async () => {
|
||||
const reads = ['preview.read', 'terminal.read', 'window.read'].map(method =>
|
||||
deliver(method, { session_id: 'session-a' }, 'session-b')
|
||||
)
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
for (const { handled, respond } of reads) {
|
||||
for (const { decline, handled, respond } of reads) {
|
||||
expect(handled).toBe(true)
|
||||
expect(decline).toHaveBeenCalledTimes(1)
|
||||
expect(respond).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('declines a replayed request only after the retry still finds no host', async () => {
|
||||
const replay = deliver('preview.read', { session_id: 'session-a' }, null, true)
|
||||
|
||||
expect(replay.decline).not.toHaveBeenCalled()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(replay.decline).toHaveBeenCalledTimes(1)
|
||||
expect(replay.respond).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("answers pane reads for a session hosted in one of this window's tiles", async () => {
|
||||
// The tile session is not the active one, but this window hosts it: its
|
||||
// panes are here, so an 'ignore' would stall the tool until its deadline.
|
||||
@@ -128,9 +142,10 @@ describe('preview action request routing', () => {
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
for (const { handled, respond } of reads) {
|
||||
for (const { decline, handled, respond } of reads) {
|
||||
expect(handled).toBe(true)
|
||||
expect(respond).toHaveBeenCalledTimes(1)
|
||||
expect(decline).not.toHaveBeenCalled()
|
||||
}
|
||||
} finally {
|
||||
$sessionTiles.set([])
|
||||
@@ -149,11 +164,12 @@ describe('tour request routing', () => {
|
||||
$toursEnabled.set(true)
|
||||
})
|
||||
|
||||
it('leaves a scoped request unanswered in another session even when tours are disabled', () => {
|
||||
it('declines a scoped request in another session even when tours are disabled', () => {
|
||||
$toursEnabled.set(false)
|
||||
const { handled, respond } = deliver('tour', { action: 'discover', session_id: 'session-a' }, 'session-b')
|
||||
const { decline, handled, respond } = deliver('tour', { action: 'discover', session_id: 'session-a' }, 'session-b')
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(decline).toHaveBeenCalledTimes(1)
|
||||
expect(respond).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -78,6 +78,15 @@ type PreviewSessionRoute = 'ignore' | 'retry' | 'run'
|
||||
*/
|
||||
const WINDOW_OWNED_REQUESTS = new Set(['preview.act', 'preview.read', 'terminal.read', 'window.read', 'tour'])
|
||||
|
||||
/**
|
||||
* A window not hosting the session declines instead of staying silent. The
|
||||
* backend keeps the request open for the owner and only settles once every
|
||||
* attached window declined, so when no window shows the chat the agent is told
|
||||
* now rather than after its whole deadline (#119333). `decline` is a no-op
|
||||
* against a backend that would take the first error as the answer.
|
||||
*/
|
||||
const declineNotShown = (request: ScopedServerRequest) => request.decline?.('This window is not showing the session.')
|
||||
|
||||
/**
|
||||
* Whether a request's `session_id` names the same conversation as the pane's
|
||||
* active session. The two sides are not always the same identity class: the
|
||||
@@ -599,13 +608,15 @@ export function handleServerRequest(
|
||||
const route = previewSessionRoute({ activeSessionId, replayed: request.replayed, sessionId, storedIdForRuntimeId })
|
||||
|
||||
if (route === 'ignore') {
|
||||
declineNotShown(request)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (route === 'retry') {
|
||||
// Re-read the ref instead of capturing activeSessionId: session resume
|
||||
// publishes its binding synchronously between this replay and the next
|
||||
// turn. A second miss deliberately stays silent for another window.
|
||||
// turn. A second miss leaves the request to another window.
|
||||
setTimeout(() => {
|
||||
if (
|
||||
previewSessionRoute({
|
||||
@@ -616,6 +627,8 @@ export function handleServerRequest(
|
||||
}) === 'run'
|
||||
) {
|
||||
handler({ deps, request, sessionId, isActiveSession: true })
|
||||
} else {
|
||||
declineNotShown(request)
|
||||
}
|
||||
}, 0)
|
||||
|
||||
|
||||
@@ -1821,6 +1821,7 @@ export interface ClientCapabilitiesParams {
|
||||
}
|
||||
export interface ClientCapabilitiesResult {
|
||||
server_requests: string[]
|
||||
declines_not_shown?: boolean
|
||||
}
|
||||
/** ``word`` is the token under the cursor (``@`` prefix = context reference); ``cwd`` / ``session_id`` pick the directory the listing resolves against. */
|
||||
export interface CompletePathParams {
|
||||
|
||||
@@ -7849,6 +7849,11 @@
|
||||
},
|
||||
"title": "Server Requests",
|
||||
"type": "array"
|
||||
},
|
||||
"declines_not_shown": {
|
||||
"default": false,
|
||||
"title": "Declines Not Shown",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { JsonRpcGatewayError, JsonRpcRequestChannel, type JsonRpcTransport } from './json-rpc-channel.js'
|
||||
import { JSON_RPC_SESSION_NOT_SHOWN, JsonRpcGatewayError, JsonRpcRequestChannel, type JsonRpcTransport } from './json-rpc-channel.js'
|
||||
|
||||
const spyTransport = () => {
|
||||
const sent: string[] = []
|
||||
@@ -193,6 +193,38 @@ describe('JsonRpcRequestChannel', () => {
|
||||
expect(sent).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sends a not-shown decline only to a backend that advertised counting it', async () => {
|
||||
// An older backend settles a request on the first error frame, so a bystander window's decline
|
||||
// would beat the owner's answer (#113348): there `decline` must stay silent.
|
||||
const channel = new JsonRpcRequestChannel({ requestIdPrefix: 'c' })
|
||||
const { sent, transport, last } = spyTransport()
|
||||
|
||||
const ready = () =>
|
||||
channel.handleFrame(JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'gateway.ready', payload: {} } }))
|
||||
|
||||
const ask = (id: string) =>
|
||||
channel.handleFrame(JSON.stringify({ id, jsonrpc: '2.0', method: 'preview.read', params: { session_id: 's1' } }))
|
||||
|
||||
channel.attach(transport)
|
||||
channel.onRequest(req => void req.decline?.('not here'))
|
||||
|
||||
ready()
|
||||
channel.handleFrame(JSON.stringify({ id: last().id, jsonrpc: '2.0', result: { server_requests: ['preview.read'] } }))
|
||||
await Promise.resolve()
|
||||
ask('srq-old')
|
||||
expect(sent).toHaveLength(1)
|
||||
|
||||
ready()
|
||||
channel.handleFrame(JSON.stringify({ id: last().id, jsonrpc: '2.0', result: { declines_not_shown: true, server_requests: [] } }))
|
||||
await Promise.resolve()
|
||||
ask('srq-new')
|
||||
expect(JSON.parse(sent.at(-1)!)).toEqual({
|
||||
error: { code: JSON_RPC_SESSION_NOT_SHOWN, message: 'not here' },
|
||||
id: 'srq-new',
|
||||
jsonrpc: '2.0'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes a server request to the first accepting handler and answers -32601 when nobody accepts', () => {
|
||||
const unhandled: string[] = []
|
||||
const channel = new JsonRpcRequestChannel({ onUnhandledRequest: req => void unhandled.push(req.method) })
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ClientCapabilitiesResult } from './gateway-contract.generated.js'
|
||||
import type { GatewayEvent } from './gateway-events.js'
|
||||
|
||||
export type GatewayRequestId = number | string
|
||||
@@ -39,6 +40,13 @@ export interface ServerRequest<M extends string = string, P extends ServerReques
|
||||
respond: (result: Record<string, unknown>) => void
|
||||
/** Answer with a JSON-RPC error (the backend treats it as unanswered). */
|
||||
fail: (code: number, message: string) => void
|
||||
/**
|
||||
* "No window here shows this session" for a window-owned bridge. Sent only
|
||||
* to a backend that counts it as one client declining rather than as the
|
||||
* answer (`client.capabilities` → `declines_not_shown`); an older backend
|
||||
* settles on the first error, so there this stays silent for the owner.
|
||||
*/
|
||||
decline?: (message: string) => void
|
||||
/**
|
||||
* Renderer-side tag set by the owner when a request arrives through a
|
||||
* replay (`open_requests`) rather than live; handlers that already show the
|
||||
@@ -72,6 +80,9 @@ export const JSON_RPC_METHOD_NOT_FOUND = -32601
|
||||
/** JSON-RPC "internal error" — used when a server→client request handler throws. */
|
||||
export const JSON_RPC_INTERNAL_ERROR = -32603
|
||||
|
||||
/** A window-owned request's session is not shown by any window of this client (tui_gateway/server_requests.py::NOT_SHOWN_CODE). */
|
||||
export const JSON_RPC_SESSION_NOT_SHOWN = 4404
|
||||
|
||||
/** Map a raw `error` member of a response frame to the typed error every surface inspects. */
|
||||
export function jsonRpcErrorFromFrame(raw: unknown, fallbackMessage = 'Hermes RPC failed'): JsonRpcGatewayError {
|
||||
const err = (raw && typeof raw === 'object' ? raw : {}) as JsonRpcErrorPayload
|
||||
@@ -182,6 +193,8 @@ export class JsonRpcRequestChannel {
|
||||
private heartbeatSequence = 0
|
||||
private readonly outstandingPings = new Set<string>()
|
||||
private lastLivenessAt = 0
|
||||
/** The bound generation's backend counts `decline` as one client abstaining (see `ServerRequest.decline`). */
|
||||
private backendCountsDeclines = false
|
||||
private readonly requestHandlers: ServerRequestHandler[] = []
|
||||
private readonly options: Required<
|
||||
Omit<JsonRpcRequestChannelOptions, 'onEvent' | 'onHeartbeatFailure' | 'onRequestHandlerError' | 'onUnhandledRequest'>
|
||||
@@ -217,6 +230,7 @@ export class JsonRpcRequestChannel {
|
||||
this.stopHeartbeat()
|
||||
this.transport = transport
|
||||
this.lastLivenessAt = Date.now()
|
||||
this.backendCountsDeclines = false
|
||||
}
|
||||
|
||||
/** Drop the transport and fail every in-flight call with `error`. */
|
||||
@@ -358,7 +372,12 @@ export class JsonRpcRequestChannel {
|
||||
params,
|
||||
replayed,
|
||||
respond: result => send({ result }),
|
||||
fail: (code, message) => send({ error: { code, message } })
|
||||
fail: (code, message) => send({ error: { code, message } }),
|
||||
decline: message => {
|
||||
if (this.backendCountsDeclines) {
|
||||
send({ error: { code: JSON_RPC_SESSION_NOT_SHOWN, message } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const handler of this.requestHandlers) {
|
||||
@@ -487,7 +506,15 @@ export class JsonRpcRequestChannel {
|
||||
* that is ignored.
|
||||
*/
|
||||
private advertiseCapabilities(): void {
|
||||
this.request('client.capabilities', { server_requests: true }).catch(() => undefined)
|
||||
const transport = this.transport
|
||||
|
||||
this.request<Partial<ClientCapabilitiesResult> | null>('client.capabilities', { server_requests: true })
|
||||
.then(result => {
|
||||
if (this.transport === transport) {
|
||||
this.backendCountsDeclines = result?.declines_not_shown === true
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -403,6 +403,88 @@ def test_server_request_error_response_fails_fast(server):
|
||||
assert box["result"] is None
|
||||
|
||||
|
||||
def _two_window_session(server, sid):
|
||||
"""A session two advertised Desktop windows are attached to (a FanoutTransport of WS peers)."""
|
||||
import asyncio
|
||||
|
||||
from tui_gateway import server_requests
|
||||
from tui_gateway.transport import FanoutTransport
|
||||
|
||||
# The fanout awaits a WS peer's write_async on its loop; a peer without one is pruned on first write.
|
||||
loop = asyncio.new_event_loop()
|
||||
threading.Thread(target=loop.run_forever, daemon=True).start()
|
||||
windows = (_silent_ws(), _silent_ws())
|
||||
for window in windows:
|
||||
async def _write_async(obj, window=window):
|
||||
return window.write(obj)
|
||||
window._loop, window.write_async = loop, _write_async
|
||||
server_requests.advertise(window, True)
|
||||
_ws_session(server, sid, FanoutTransport(*windows))
|
||||
return windows
|
||||
|
||||
|
||||
def _not_shown(req):
|
||||
from tui_gateway import server_requests
|
||||
return {"jsonrpc": "2.0", "id": req.id, "error": {"code": server_requests.NOT_SHOWN_CODE, "message": "not here"}}
|
||||
|
||||
|
||||
def test_window_owned_request_refuses_at_once_when_no_window_shows_the_session(server):
|
||||
"""#119333: every attached window says it does not show the session → preview.read resolves with the
|
||||
distinct not-shown refusal now, instead of the agent waiting out the 45s bridge deadline."""
|
||||
from tui_gateway import server_requests
|
||||
|
||||
windows = _two_window_session(server, "s-none")
|
||||
box = {}
|
||||
thread = threading.Thread(target=lambda: box.setdefault("r", server._ask("preview.read", "s-none", {}, timeout=30)),
|
||||
daemon=True)
|
||||
thread.start()
|
||||
req = _wait_open(server_requests)
|
||||
t0 = time.monotonic()
|
||||
assert server.dispatch(_not_shown(req), windows[0]) is None
|
||||
# A replayed decline from the same window is still one window's vote.
|
||||
server.dispatch(_not_shown(req), windows[0])
|
||||
time.sleep(0.05)
|
||||
assert thread.is_alive()
|
||||
server.dispatch(_not_shown(req), windows[1])
|
||||
thread.join(timeout=2)
|
||||
assert not thread.is_alive() and time.monotonic() - t0 < 2
|
||||
assert json.loads(box["r"]) == {"success": False, "error": server_requests.NOT_SHOWN_MESSAGE}
|
||||
assert server_requests.open_requests("s-none") == []
|
||||
|
||||
|
||||
def test_window_owned_request_decline_does_not_beat_the_owner_window(server):
|
||||
"""#113348 stays fixed: one bystander window declining leaves the request open for the window that
|
||||
shows the session, and its answer is the result."""
|
||||
from tui_gateway import server_requests
|
||||
|
||||
bystander, owner = _two_window_session(server, "s-own")
|
||||
box = {}
|
||||
thread = threading.Thread(target=lambda: box.setdefault("r", server._ask("preview.read", "s-own", {}, timeout=5)),
|
||||
daemon=True)
|
||||
thread.start()
|
||||
req = _wait_open(server_requests)
|
||||
server.dispatch(_not_shown(req), bystander)
|
||||
# An unattributed decline (proxied / relayed frame) is not a window's vote either.
|
||||
assert server_requests.resolve_response(_not_shown(req)) is True
|
||||
time.sleep(0.05)
|
||||
assert thread.is_alive() and [r["id"] for r in server_requests.open_requests("s-own")] == [req.id]
|
||||
server.dispatch({"jsonrpc": "2.0", "id": req.id, "result": {"value": '{"text": "page"}'}}, owner)
|
||||
thread.join(timeout=2)
|
||||
assert box["r"] == '{"text": "page"}'
|
||||
|
||||
|
||||
def test_client_capabilities_advertises_counting_not_shown_declines(server):
|
||||
from tui_gateway.transport import bind_transport, reset_transport
|
||||
|
||||
peer = _silent_ws()
|
||||
token = bind_transport(peer)
|
||||
try:
|
||||
response = server.handle_request({"id": 1, "method": "client.capabilities", "params": {"server_requests": True}})
|
||||
finally:
|
||||
reset_transport(token)
|
||||
assert response["result"]["declines_not_shown"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["secret", "sudo", "terminal.read", "tour"])
|
||||
def test_server_request_timeout_emits_one_request_cancel(capture, method):
|
||||
from tui_gateway import server_requests
|
||||
|
||||
@@ -36,6 +36,11 @@ class ClientCapabilitiesParams(Params):
|
||||
class ClientCapabilitiesResult(Result):
|
||||
#: Server→client request methods this backend may send.
|
||||
server_requests: list[str]
|
||||
#: This backend counts a ``4404`` "no window here shows this session" error from a window-owned
|
||||
#: bridge (preview/terminal/window read, preview act, tour) as that client declining, not as the
|
||||
#: answer: the request settles only once every attached client declined (server_requests.py).
|
||||
#: An older backend settles on the first error, so a client sends the decline only when this is true.
|
||||
declines_not_shown: bool = False
|
||||
|
||||
|
||||
method("client.capabilities", params=ClientCapabilitiesParams, result=ClientCapabilitiesResult,
|
||||
|
||||
@@ -447,7 +447,7 @@ def _(rid, params: dict) -> dict:
|
||||
from tui_gateway import server_requests
|
||||
from tui_gateway.contracts import registry as contracts
|
||||
server_requests.advertise(_caller_transport(), bool(params.get("server_requests")))
|
||||
return _ok(rid, {"server_requests": sorted(contracts.SERVER_REQUESTS)})
|
||||
return _ok(rid, {"server_requests": sorted(contracts.SERVER_REQUESTS), "declines_not_shown": True})
|
||||
|
||||
|
||||
@method("ping")
|
||||
|
||||
@@ -53,7 +53,7 @@ def dispatch(req: dict, transport: Optional[Transport] = None) -> dict | None:
|
||||
from tui_gateway import server_requests
|
||||
if server_requests.is_response_frame(req):
|
||||
# The renderer answering one of OUR requests (clarify, approval, …): no response frame goes back.
|
||||
if not server_requests.resolve_response(req) and not _relay_compute_host_response(req):
|
||||
if not server_requests.resolve_response(req, t) and not _relay_compute_host_response(req):
|
||||
logger.debug("dropping response for unknown server request id=%r", req.get("id"))
|
||||
return None
|
||||
normalized = _normalize_request(req)
|
||||
|
||||
@@ -684,7 +684,8 @@ def _emit(event: str, sid: str, payload: dict | None = None) -> bool:
|
||||
from tui_gateway import server_requests as _server_requests # noqa: E402
|
||||
|
||||
_server_requests.bind_sinks(lambda frame: write_json(frame), lambda event, sid, payload: _emit(event, sid, payload),
|
||||
lambda sid: _session_client_answers_requests(sid))
|
||||
lambda sid: _session_client_answers_requests(sid),
|
||||
lambda sid: _session_answering_clients(sid))
|
||||
|
||||
|
||||
# Live WS peer transports (maintained by tui_gateway.ws): the only route for session-less background
|
||||
|
||||
@@ -21,6 +21,12 @@ Batch clarify keeps per-question locks (``clarify.lock`` → :func:`lock_answer`
|
||||
editable until every question is locked, locked answers survive a timeout, and the last lock
|
||||
resolves the request with the full answer set.
|
||||
|
||||
Window-owned bridges (``preview.read`` / ``preview.act`` / ``terminal.read`` / ``window.read`` /
|
||||
``tour``) are answered only by the window showing the session; every other attached window declines
|
||||
with :data:`NOT_SHOWN_CODE`. A decline does not settle the request — the owner may still answer — until
|
||||
every answering client attached to the session has declined; then the request resolves with
|
||||
:data:`NOT_SHOWN_MESSAGE` at once instead of the agent waiting out the deadline (#119333).
|
||||
|
||||
Capability: a client says once per connection that it answers server→client requests
|
||||
(``client.capabilities {server_requests: true}`` → :func:`advertise`). A WebSocket client that never
|
||||
did is a build older than this half of the protocol — it drops the frame silently and the agent
|
||||
@@ -30,6 +36,7 @@ return the same ``None`` an error response produces without writing the frame (#
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
@@ -41,7 +48,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class ServerRequest:
|
||||
__slots__ = ("id", "sid", "method", "params", "event", "result", "answered", "created_at",
|
||||
"qids", "locked", "on_result")
|
||||
"qids", "locked", "on_result", "declined")
|
||||
|
||||
def __init__(self, sid: str, method: str, params: dict, *, qids: list[str] | None = None,
|
||||
on_result: Callable[[dict | None], None] | None = None) -> None:
|
||||
@@ -57,6 +64,8 @@ class ServerRequest:
|
||||
self.qids = list(qids) if qids else None
|
||||
self.locked: dict[str, str] = {}
|
||||
self.on_result = on_result
|
||||
# Client transports that answered NOT_SHOWN_CODE (no window there shows this session).
|
||||
self.declined: set = set()
|
||||
|
||||
def frame(self) -> dict:
|
||||
return {"jsonrpc": "2.0", "id": self.id, "method": self.method,
|
||||
@@ -82,6 +91,15 @@ _emit: Callable[[str, str, dict], Any] = lambda event, sid, payload: None # noq
|
||||
# ``answerable(sid)``: False only when every client attached to the session is a build that never
|
||||
# advertised handling server→client requests (session_transports.py::_session_client_answers_requests).
|
||||
_answerable: Callable[[str], bool] = lambda sid: True # noqa: E731
|
||||
# ``clients(sid)``: the attached client transports that answer server→client requests — the set whose
|
||||
# unanimous NOT_SHOWN_CODE decline settles a window-owned request (session_transports.py).
|
||||
_clients: Callable[[str], list] = lambda sid: [] # noqa: E731
|
||||
|
||||
# Error code a client answers when none of its windows shows the request's session, and the refusal the
|
||||
# tool reports once every attached client said so. Mirrored in apps/desktop server-requests.ts.
|
||||
NOT_SHOWN_CODE = 4404
|
||||
NOT_SHOWN_MESSAGE = ("No Hermes Desktop window is showing this chat, so its preview, terminal and tour are out "
|
||||
"of reach. Ask the user to open this chat in the Desktop app, then retry.")
|
||||
|
||||
# Client transports that sent ``client.capabilities {server_requests: true}`` (identity set: StdioTransport
|
||||
# has __slots__ and cannot be weak-referenced; ws.py forgets a peer on disconnect).
|
||||
@@ -89,9 +107,11 @@ _answering_clients: set = set()
|
||||
|
||||
|
||||
def bind_sinks(write_json: Callable[[dict], Any], emit: Callable[[str, str, dict], Any],
|
||||
answerable: Callable[[str], bool]) -> None:
|
||||
global _write, _emit, _answerable
|
||||
answerable: Callable[[str], bool], clients: Callable[[str], list] | None = None) -> None:
|
||||
global _write, _emit, _answerable, _clients
|
||||
_write, _emit, _answerable = write_json, emit, answerable
|
||||
if clients is not None:
|
||||
_clients = clients
|
||||
|
||||
|
||||
def advertise(transport: Any, server_requests: bool) -> None:
|
||||
@@ -198,12 +218,47 @@ def send_async(method: str, sid: str, params: dict, on_result: Callable[[dict |
|
||||
return settle
|
||||
|
||||
|
||||
def resolve_response(frame: dict) -> bool:
|
||||
def _is_not_shown(frame: dict) -> bool:
|
||||
error = frame.get("error")
|
||||
return isinstance(error, dict) and error.get("code") == NOT_SHOWN_CODE
|
||||
|
||||
|
||||
def _decline(rid: str, transport: Any) -> bool:
|
||||
"""One client's "no window here shows this session". Settles only once every answering client
|
||||
attached to the session declined: a bystander window must not beat the owner (#113348), and with
|
||||
no owner at all the agent gets the refusal now rather than at the deadline (#119333). A decline
|
||||
from an unknown transport (relayed, proxied) is recorded nowhere and the wait goes on."""
|
||||
with _lock:
|
||||
req = _open.get(rid)
|
||||
if req is None:
|
||||
logger.debug("server request %s: decline dropped, request no longer open", rid)
|
||||
return False
|
||||
clients = set(_clients(req.sid)) if transport is not None else set()
|
||||
with _lock:
|
||||
if _open.get(rid) is not req:
|
||||
return True # settled meanwhile
|
||||
if transport is not None:
|
||||
req.declined.add(transport)
|
||||
if not clients or not clients <= req.declined:
|
||||
return True
|
||||
_open.pop(rid, None)
|
||||
req.result = {"value": json.dumps({"success": False, "error": NOT_SHOWN_MESSAGE})}
|
||||
req.answered = True
|
||||
if req.on_result is not None:
|
||||
req.on_result(req.result)
|
||||
req.event.set()
|
||||
return True
|
||||
|
||||
|
||||
def resolve_response(frame: dict, transport: Any = None) -> bool:
|
||||
"""Route one client response frame to its open request. False when nothing is waiting for that id
|
||||
(already timed out / cancelled, or owned by another process — see the compute-host bridge)."""
|
||||
(already timed out / cancelled, or owned by another process — see the compute-host bridge).
|
||||
*transport* is the client connection the frame arrived on (counts a NOT_SHOWN_CODE decline)."""
|
||||
rid = frame.get("id")
|
||||
if not isinstance(rid, str):
|
||||
return False
|
||||
if _is_not_shown(frame):
|
||||
return _decline(rid, transport)
|
||||
with _lock:
|
||||
req = _open.get(rid)
|
||||
if req is None:
|
||||
|
||||
@@ -47,6 +47,15 @@ def _session_client_answers_requests(sid: str) -> bool:
|
||||
return not clients or any(server_requests.answers_requests(peer) for peer in clients)
|
||||
|
||||
|
||||
def _session_answering_clients(sid: str) -> list:
|
||||
"""The live WebSocket clients attached to *sid* that advertised answering server→client requests:
|
||||
the windows whose unanimous "not shown here" settles a window-owned request (server_requests.py)."""
|
||||
from tui_gateway import server_requests
|
||||
from tui_gateway.ws import WSTransport
|
||||
return [peer for peer in _session_live_transports(_sessions.get(sid))
|
||||
if isinstance(peer, WSTransport) and server_requests.answers_requests(peer)]
|
||||
|
||||
|
||||
def _warn_foreign_login(session: dict, transport) -> None:
|
||||
"""Ownership is not enforced; a second login sharing a session is only logged, and the agent keeps the
|
||||
creator's user id."""
|
||||
|
||||
Reference in New Issue
Block a user