webbee 0.3.22__tar.gz → 0.3.23__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {webbee-0.3.22 → webbee-0.3.23}/CHANGELOG.md +10 -0
- {webbee-0.3.22 → webbee-0.3.23}/PKG-INFO +1 -1
- {webbee-0.3.22 → webbee-0.3.23}/pyproject.toml +1 -1
- webbee-0.3.23/src/webbee/__init__.py +1 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/repl.py +71 -1
- webbee-0.3.23/src/webbee/session.py +501 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/steer.py +38 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/thread.py +8 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/tui.py +61 -24
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_repl.py +56 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_session.py +125 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_steer.py +110 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_tui.py +90 -1
- webbee-0.3.22/src/webbee/__init__.py +0 -1
- webbee-0.3.22/src/webbee/session.py +0 -453
- {webbee-0.3.22 → webbee-0.3.23}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/.gitignore +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/LICENSE +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/README.md +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/install.sh +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/account.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/active_sessions.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/boot.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/cli.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/commands.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/config.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/consent.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/details.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/events.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/frames.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/home.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/http.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/mode_store.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/queue_panel.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/reflow.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/remote.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/render.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/repo.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/selection.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/sessions.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/sizing.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/slots.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/stream.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/tabs.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/todo_panel.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/tokens.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/tools.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/update.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/src/webbee/worktrees.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/__init__.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/conftest.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_account.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_active_sessions.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_cli.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_clipboard.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_commands.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_config.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_details.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_events.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_home.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_intel_query.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_intel_service.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_intel_store.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_marathon.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_mode_store.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_packaging.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_reflow.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_render.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_repo.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_sessions.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_sizing.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_slots.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_stream.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_tabs.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_thread.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_todo_panel.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_tokens.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_tools.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_update.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_version.py +0 -0
- {webbee-0.3.22 → webbee-0.3.23}/tests/test_worktrees.py +0 -0
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.23
|
|
4
|
+
|
|
5
|
+
If a task on your session is waiting for your terminal — sent from the panel
|
|
6
|
+
or Telegram while the terminal was idle — the terminal now picks it up
|
|
7
|
+
within seconds instead of the task stalling.
|
|
8
|
+
|
|
9
|
+
- An idle terminal now notices when a running task on its own session is
|
|
10
|
+
waiting for a tool approval or result, and reconnects to it automatically
|
|
11
|
+
— no more silent stalls that eventually get marked "unresponsive".
|
|
12
|
+
|
|
3
13
|
## 0.3.22
|
|
4
14
|
|
|
5
15
|
Open the same project in two tabs and they truly run in parallel — each tab
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.23"
|
|
@@ -977,6 +977,72 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
977
977
|
slot.sink.foreign_turn(surface, "user", text)
|
|
978
978
|
await _run_turn_on(slot, text, surface=surface, steer_iid=steer_iid)
|
|
979
979
|
|
|
980
|
+
async def _attach_turn_on(slot: SessionSlot, attach_info: dict) -> None:
|
|
981
|
+
"""poll_idle_steer's `attach_turn` seam (attach-on-poll, URGENT
|
|
982
|
+
live-user-pain fix): a kernel-side marathon turn woken by a panel/
|
|
983
|
+
Telegram message dispatched a local tool_request/confirm_request
|
|
984
|
+
while THIS terminal sat idle-but-online -- nothing attached the
|
|
985
|
+
stream, so the dispatch just burned tool_wait (5-65min) before the
|
|
986
|
+
kernel parked it "unresponsive". `attach_info` is the gateway's
|
|
987
|
+
{task_id, last_id, kind} (GET /pending-steer's additive `attach`
|
|
988
|
+
field); `slot.agent.attach()` opens the SSE with NO start POST at
|
|
989
|
+
all -- doing so alone fires client_connected server-side, which is
|
|
990
|
+
what makes the kernel re-dispatch the pending request. `marathon=not
|
|
991
|
+
once` (the SAME flag `_spawn_slot_poller` already threads into
|
|
992
|
+
poll_idle_steer/derive_session_id for THIS slot's poller) so a
|
|
993
|
+
--once slot's attach derives its "coding-"-prefixed id, never the
|
|
994
|
+
"marathon-" one -- a mismatched prefix would derive a DIFFERENT,
|
|
995
|
+
wrong session id than the one whose `attach` field this actually is.
|
|
996
|
+
|
|
997
|
+
Runs through the SAME slot-explicit start seam `_home_input` uses
|
|
998
|
+
(`ui_hooks["start_attach_in"]`, tui's own `_start_attach_in`) so
|
|
999
|
+
`slot.turn["task"]` is genuinely set under a dock -- Esc/Ctrl-C can
|
|
1000
|
+
cancel it locally exactly like a typed turn, and begin_turn/
|
|
1001
|
+
end_turn/busy-gate/queue-drain semantics are identical to any other
|
|
1002
|
+
turn (mirrors `_run_turn_on`'s own shape). The fallback loop (no
|
|
1003
|
+
dock, no ui_hooks entry) just awaits the coroutine directly -- no
|
|
1004
|
+
Esc/Ctrl-C key handling exists there to satisfy. Either way this
|
|
1005
|
+
BLOCKS for the whole turn: poll_idle_steer's `attach_turn` contract
|
|
1006
|
+
requires pausing polling for its duration, same discipline as
|
|
1007
|
+
`submit`/`_steer_submit_on` above."""
|
|
1008
|
+
async def _drive() -> None:
|
|
1009
|
+
_sink = slot.sink
|
|
1010
|
+
_sink.begin_turn()
|
|
1011
|
+
_note = getattr(_sink, "note", None)
|
|
1012
|
+
if _note is not None:
|
|
1013
|
+
_note("⚡ attaching — a running task on this session needs this terminal")
|
|
1014
|
+
task_id = str(attach_info.get("task_id") or "")
|
|
1015
|
+
start_id = str(attach_info.get("last_id") or "0-0")
|
|
1016
|
+
try:
|
|
1017
|
+
text = await slot.agent.attach(_sink, task_id=task_id, start_id=start_id,
|
|
1018
|
+
marathon=not once)
|
|
1019
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
1020
|
+
_sink.abort()
|
|
1021
|
+
_sink.note("Interrupted.")
|
|
1022
|
+
_sink.end_turn("") # clear busy (poller starvation guard)
|
|
1023
|
+
return
|
|
1024
|
+
except Exception as e: # network/auth/etc — never crash the poller
|
|
1025
|
+
_mark = getattr(_sink, "mark_turn_failed", None)
|
|
1026
|
+
if _mark is not None:
|
|
1027
|
+
_mark()
|
|
1028
|
+
if type(e).__name__ in ("StreamAuthError", "NotLoggedInError"):
|
|
1029
|
+
_sink.note("Session expired or access revoked — run /login to sign in again.")
|
|
1030
|
+
else:
|
|
1031
|
+
_sink.note(f"Error: {type(e).__name__}: {e}")
|
|
1032
|
+
_sink.end_turn("")
|
|
1033
|
+
if slot.pending:
|
|
1034
|
+
_sink.note(f"⏸ queue held: {len(slot.pending)} queued message(s) wait "
|
|
1035
|
+
"— ↑ pulls the next into the input, /queue clear drops them")
|
|
1036
|
+
return
|
|
1037
|
+
_sink.end_turn(text)
|
|
1038
|
+
|
|
1039
|
+
coro = _drive()
|
|
1040
|
+
start_attach_in = ui_hooks.get("start_attach_in")
|
|
1041
|
+
if start_attach_in is not None:
|
|
1042
|
+
await start_attach_in(slot, coro)
|
|
1043
|
+
else:
|
|
1044
|
+
await coro
|
|
1045
|
+
|
|
980
1046
|
async def _stop_active_turn() -> None:
|
|
981
1047
|
"""tui.run_session's `stop_turn` leg (Esc/Ctrl-C) -- resolves the
|
|
982
1048
|
ACTIVE slot's agent AT CALL TIME (W4a Task 3: the injected callable
|
|
@@ -1006,7 +1072,10 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
1006
1072
|
shared `first_session_slot`: `is_busy`/`mode_getter` read its OWN
|
|
1007
1073
|
sink/turn/mode, `live_session_id` its OWN agent, `submit` renders the
|
|
1008
1074
|
remote line into its OWN pane and runs the turn through
|
|
1009
|
-
`_run_turn_on(slot, ...)
|
|
1075
|
+
`_run_turn_on(slot, ...)`, and `attach_turn` (attach-on-poll) runs
|
|
1076
|
+
`_attach_turn_on(slot, ...)` against this SAME slot when the
|
|
1077
|
+
gateway's drain reports an unanswered request already dispatched on
|
|
1078
|
+
this session. Staggered (item 3): each poller's FIRST
|
|
1010
1079
|
tick carries a small incrementing offset (0, 1, 2, 0, 1, 2... seconds
|
|
1011
1080
|
-- `poll_idle_steer`'s own `initial_delay`) so several tabs opened
|
|
1012
1081
|
back-to-back don't all hit the gateway in the same instant. Appended
|
|
@@ -1025,6 +1094,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
1025
1094
|
slot, text, surface, steer_iid),
|
|
1026
1095
|
on_mode=lambda mode, surface: _on_mode(slot, mode, surface),
|
|
1027
1096
|
mode_getter=lambda: slot.mode,
|
|
1097
|
+
attach_turn=lambda attach_info: _attach_turn_on(slot, attach_info),
|
|
1028
1098
|
client=shared_client, slot_id=slot.slot_id, initial_delay=offset))
|
|
1029
1099
|
slot.bg_tasks.append(task)
|
|
1030
1100
|
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from collections import OrderedDict, deque
|
|
4
|
+
|
|
5
|
+
from webbee.coding_context import build_coding_context, detect_verify_cmd
|
|
6
|
+
from webbee.consent import _retire, race_consent
|
|
7
|
+
from webbee.frames import (
|
|
8
|
+
_MARATHON_FACT_TYPES,
|
|
9
|
+
_first_time,
|
|
10
|
+
_is_foreign_frame,
|
|
11
|
+
_origin_tag,
|
|
12
|
+
_progress_text,
|
|
13
|
+
_summary,
|
|
14
|
+
handle_action_frame,
|
|
15
|
+
handle_step_finished,
|
|
16
|
+
handle_step_started,
|
|
17
|
+
handle_tool_request,
|
|
18
|
+
marathon_note,
|
|
19
|
+
render_foreign_frame,
|
|
20
|
+
render_todo_frame,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# W1 Task 10 (own-task watchdog): a test seam for the deduped-twin-hang clock
|
|
25
|
+
# below -- monkeypatched as `webbee.session._monotonic` so a test can drive
|
|
26
|
+
# elapsed-time without a real sleep.
|
|
27
|
+
_monotonic = time.monotonic
|
|
28
|
+
|
|
29
|
+
# Deduped-twin hang (W1 front-3b): a turn's task_id can be ring-dropped
|
|
30
|
+
# kernel-side as an at-least-once twin, so the shared stream carries ONLY
|
|
31
|
+
# frames tagged with OTHER task_ids while this turn's own task_id never
|
|
32
|
+
# appears. Left alone the dock spins "working" forever. Past this many
|
|
33
|
+
# seconds of foreign-only traffic with zero own frames, end the turn honestly.
|
|
34
|
+
_FOREIGN_ONLY_DEADLINE_S = 90.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_SEEN_KEEP = 64 # kernel re-dispatch only ever targets the CURRENT pending
|
|
38
|
+
# req_id -- a small recency window is enough; unbounded full
|
|
39
|
+
# results (file bodies, bash output) were the many-hour-
|
|
40
|
+
# marathon RAM leak (W1 front-1).
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _remember(seen: OrderedDict, rid, out) -> None:
|
|
44
|
+
"""LRU-bounded store for `seen` -- the run() dedup cache. A (re-)store
|
|
45
|
+
always counts as the most-recent touch; the least-recently-touched entry
|
|
46
|
+
is evicted once the store grows past _SEEN_KEEP."""
|
|
47
|
+
seen[rid] = out
|
|
48
|
+
seen.move_to_end(rid)
|
|
49
|
+
while len(seen) > _SEEN_KEEP:
|
|
50
|
+
seen.popitem(last=False)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_transient_status(status: int) -> bool:
|
|
54
|
+
return status >= 500 or status in (408, 429)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _transient_exceptions():
|
|
58
|
+
# Lazy import (module-level `import httpx` would move httpx's cost to CLI
|
|
59
|
+
# boot -- session.py is imported by repl.py at module top, and the rest of
|
|
60
|
+
# this module already keeps httpx as a run()-local, boot-time-optimization
|
|
61
|
+
# import). `except <call expression>` is valid Python -- the call just
|
|
62
|
+
# has to happen on every except test, which is cheap (import is memoized).
|
|
63
|
+
import httpx
|
|
64
|
+
return (httpx.HTTPError, OSError)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def _transient_retry(send, *, attempts: int = 5, base: float = 1.0, cap: float = 8.0):
|
|
68
|
+
"""Bounded transient-retry for gateway WRITE calls (turn-start POST): a
|
|
69
|
+
502 during a deploy must not kill the turn. Verdict/normal statuses return
|
|
70
|
+
immediately; TRANSPORT errors (httpx.HTTPError/OSError) and 5xx/408/429
|
|
71
|
+
retry with capped backoff -- anything else (a bug in the caller) is not a
|
|
72
|
+
transient condition and must surface immediately, not be retried 5x.
|
|
73
|
+
The LAST failure is returned/raised for the caller's raise_for_status."""
|
|
74
|
+
backoff = base
|
|
75
|
+
last_exc = None
|
|
76
|
+
last_resp = None
|
|
77
|
+
n = max(1, attempts)
|
|
78
|
+
for i in range(n):
|
|
79
|
+
try:
|
|
80
|
+
resp = await send()
|
|
81
|
+
if not _is_transient_status(resp.status_code):
|
|
82
|
+
return resp
|
|
83
|
+
last_resp, last_exc = resp, None
|
|
84
|
+
except _transient_exceptions() as e:
|
|
85
|
+
last_exc, last_resp = e, None
|
|
86
|
+
if i < n - 1: # never sleep after the FINAL attempt
|
|
87
|
+
await asyncio.sleep(backoff)
|
|
88
|
+
backoff = min(backoff * 2, cap)
|
|
89
|
+
if last_exc is not None:
|
|
90
|
+
raise last_exc
|
|
91
|
+
return last_resp
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AgentSession:
|
|
95
|
+
"""Client-side driver for one coding turn against the Imperal cloud.
|
|
96
|
+
The brain runs server-side; this is the hands — it streams kernel-
|
|
97
|
+
pre-approved tool_request frames over SSE, runs each tool locally, relays
|
|
98
|
+
confirm_request replies RAW for the brain to interpret, drives the sink
|
|
99
|
+
for live UI, and posts results back until a final frame arrives.
|
|
100
|
+
|
|
101
|
+
P1: one POST per turn (server reloads the shared webbee-terminal thread,
|
|
102
|
+
so context carries across turns). Persistent signal-based sessions are P3."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, cfg, token_provider, workspace_root: str, mode: str = "default", intel=None,
|
|
105
|
+
shadow=None, slot_id: str = "") -> None:
|
|
106
|
+
self.cfg = cfg
|
|
107
|
+
self.token_provider = token_provider
|
|
108
|
+
self.workspace_root = workspace_root
|
|
109
|
+
self.mode = mode
|
|
110
|
+
self.session_id: str = ""
|
|
111
|
+
self.steps: deque = deque(maxlen=200)
|
|
112
|
+
self._task_id: str = ""
|
|
113
|
+
self._intel = intel # IntelService, or None (base install / boot failure)
|
|
114
|
+
self._shadow = shadow # ShadowGit, or None (git unavailable / boot failure)
|
|
115
|
+
# W4b T5: "" (tab-1 / the fallback loop's only slot) keeps every turn's
|
|
116
|
+
# POST body byte-identical to before this feature existed -- a later
|
|
117
|
+
# tab's own short slot_id rides the body as `slot`, and the gateway
|
|
118
|
+
# mints an `-s{slot}`-suffixed session id from it.
|
|
119
|
+
self.slot_id = slot_id
|
|
120
|
+
|
|
121
|
+
async def _headers(self) -> dict:
|
|
122
|
+
token = await self.token_provider()
|
|
123
|
+
return {"Authorization": f"Bearer {token}"}
|
|
124
|
+
|
|
125
|
+
async def run(self, task: str, sink, *, marathon: bool = False, goal: str = "",
|
|
126
|
+
surface: str = "", steer_iid: str = "") -> str:
|
|
127
|
+
import httpx
|
|
128
|
+
from uuid import uuid4
|
|
129
|
+
|
|
130
|
+
from webbee.tools import LocalToolExecutor
|
|
131
|
+
from imperal_mcp.client import ImperalClient
|
|
132
|
+
|
|
133
|
+
# FIX1 (W1 final review): every turn-start POST needs a stable dedup
|
|
134
|
+
# key. A typed turn mints its own (steer_iid arrives empty); a steer
|
|
135
|
+
# pickup keeps the queue entry's OWN id. Either way, _transient_retry
|
|
136
|
+
# below re-sends this SAME closure -- so a retry after an ambiguous
|
|
137
|
+
# failure (timeout / edge-504 whose first attempt may have already
|
|
138
|
+
# landed) carries the IDENTICAL key, and the kernel's steer-iid ring
|
|
139
|
+
# drops the twin instead of executing the instruction twice.
|
|
140
|
+
steer_iid = steer_iid or uuid4().hex
|
|
141
|
+
|
|
142
|
+
# Offload to a worker thread — build_coding_context runs sync
|
|
143
|
+
# subprocess.run(git status, timeout=10) + os.walk; inline on the dock's
|
|
144
|
+
# asyncio loop it froze the whole UI at every turn start.
|
|
145
|
+
coding_context = await asyncio.to_thread(build_coding_context, self.workspace_root, self._intel)
|
|
146
|
+
if marathon:
|
|
147
|
+
# verify_cmd is CLIENT-detected here and carried in coding_context —
|
|
148
|
+
# the trusted proof-of-done the kernel runs (never brain-authored).
|
|
149
|
+
root = coding_context.get("repo_root") or coding_context.get("cwd") or self.workspace_root
|
|
150
|
+
verify_cmd = await asyncio.to_thread(detect_verify_cmd, root)
|
|
151
|
+
coding_context = {**coding_context, "verify_cmd": verify_cmd}
|
|
152
|
+
imperal_id = await ImperalClient(self.cfg, self.token_provider).whoami()
|
|
153
|
+
executor = LocalToolExecutor(self.workspace_root, indexer=self._intel,
|
|
154
|
+
shadow=self._shadow)
|
|
155
|
+
|
|
156
|
+
body = {"user_id": imperal_id, "task": task, "coding_context": coding_context}
|
|
157
|
+
if surface:
|
|
158
|
+
# Liveness v2 §B: an idle-steer pickup carries the queued item's
|
|
159
|
+
# origin surface so the kernel adopts it start-path (provenance +
|
|
160
|
+
# [surface] tags). Additive-only -- a typed turn's body is
|
|
161
|
+
# byte-identical to before.
|
|
162
|
+
body["surface"] = surface
|
|
163
|
+
# steer-iid-dedup: ALWAYS present now (FIX1 above) -- W1 turn-start
|
|
164
|
+
# retries need the ring dedup, so a typed turn's minted id rides the
|
|
165
|
+
# body exactly like a pickup's own id. No longer additive-only for
|
|
166
|
+
# this key (surface/marathon/goal above still are).
|
|
167
|
+
body["steer_iid"] = steer_iid
|
|
168
|
+
if self.slot_id:
|
|
169
|
+
# W4b T5: slot-suffixed session ids -- the gateway mints
|
|
170
|
+
# {prefix}-{imperal_id}-r{repo_key}-s{slot} when a slot rides the
|
|
171
|
+
# body. Additive-only: "" (tab-1 / fallback's only slot) keeps
|
|
172
|
+
# today's body byte-identical, no "slot" key at all.
|
|
173
|
+
body["slot"] = self.slot_id
|
|
174
|
+
if marathon:
|
|
175
|
+
body["marathon"] = True
|
|
176
|
+
body["goal"] = goal
|
|
177
|
+
|
|
178
|
+
headers = await self._headers()
|
|
179
|
+
async with httpx.AsyncClient(base_url=self.cfg.api_url, timeout=60) as client:
|
|
180
|
+
resp = await _transient_retry(lambda: client.post(
|
|
181
|
+
"/v1/agent/sessions", json=body, headers=headers))
|
|
182
|
+
resp.raise_for_status()
|
|
183
|
+
_sess = resp.json()
|
|
184
|
+
session_id = _sess["session_id"]
|
|
185
|
+
start_id = _sess.get("last_id", "0-0")
|
|
186
|
+
task_id = _sess.get("task_id", "")
|
|
187
|
+
return await self._serve_stream(sink, client, session_id, start_id, task_id,
|
|
188
|
+
executor, marathon=marathon)
|
|
189
|
+
|
|
190
|
+
async def attach(self, sink, *, task_id: str, start_id: str, marathon: bool = True) -> str:
|
|
191
|
+
"""Attach-on-poll (idle terminal picks up a kernel-dispatched task):
|
|
192
|
+
NO start POST. A marathon turn woken by a panel/Telegram message
|
|
193
|
+
while this terminal sat idle can dispatch a local tool_request/
|
|
194
|
+
confirm_request that nothing attaches -- the gateway's
|
|
195
|
+
`GET .../pending-steer` response flags this via its additive
|
|
196
|
+
`attach` field (set only when the drain found no items AND the
|
|
197
|
+
session's stream tail holds an unanswered request) and hands the
|
|
198
|
+
client `task_id`/`last_id` to resume from. This session id is
|
|
199
|
+
DERIVED the same way `webbee.steer.derive_session_id` derives it
|
|
200
|
+
for polling in the first place (this AgentSession already knows its
|
|
201
|
+
own workspace + slot_id, so the derivation is reused verbatim, not
|
|
202
|
+
reinvented) -- simply opening the SSE stream against it fires
|
|
203
|
+
`client_connected` server-side, which makes the kernel re-dispatch
|
|
204
|
+
the pending request onto the stream. From there this runs through
|
|
205
|
+
the EXACT SAME frame-loop `run()` uses (`_serve_stream`): tool
|
|
206
|
+
execution, consent racing, dedup, and the final/marathon_paused
|
|
207
|
+
exits all behave identically to a normal turn -- including
|
|
208
|
+
begin_turn/end_turn semantics, busy gates, and Esc/stop (both
|
|
209
|
+
driven by the caller around this call, same as any other turn)."""
|
|
210
|
+
import httpx
|
|
211
|
+
|
|
212
|
+
from webbee.steer import derive_session_id
|
|
213
|
+
from webbee.tools import LocalToolExecutor
|
|
214
|
+
|
|
215
|
+
session_id = await derive_session_id(
|
|
216
|
+
self.cfg, self.token_provider, self.workspace_root,
|
|
217
|
+
marathon=marathon, slot_id=self.slot_id)
|
|
218
|
+
executor = LocalToolExecutor(self.workspace_root, indexer=self._intel,
|
|
219
|
+
shadow=self._shadow)
|
|
220
|
+
async with httpx.AsyncClient(base_url=self.cfg.api_url, timeout=60) as client:
|
|
221
|
+
return await self._serve_stream(sink, client, session_id, start_id, task_id,
|
|
222
|
+
executor, marathon=marathon)
|
|
223
|
+
|
|
224
|
+
async def _serve_stream(self, sink, client, session_id: str, start_id: str,
|
|
225
|
+
task_id: str, executor, *, marathon: bool = False) -> str:
|
|
226
|
+
"""The frame-loop phase of ONE coding turn -- factored out of `run()`
|
|
227
|
+
(behavior-preserving split) so `attach()` (no start POST at all --
|
|
228
|
+
see there) can drive the identical loop against a session id it
|
|
229
|
+
derived itself instead of one a fresh POST just minted. Sets
|
|
230
|
+
`self.session_id`/`self._task_id` itself (rather than trusting the
|
|
231
|
+
caller already did) so every frame-loop reference to `self._task_id`
|
|
232
|
+
below (the C7 foreign-frame filter, the consent race) sees the
|
|
233
|
+
RIGHT turn's id regardless of which caller reached this point."""
|
|
234
|
+
self.session_id = session_id
|
|
235
|
+
self._task_id = task_id
|
|
236
|
+
self.steps = deque(maxlen=200)
|
|
237
|
+
|
|
238
|
+
seen: OrderedDict = OrderedDict() # req_id -> already-posted result
|
|
239
|
+
# (at-least-once dedup); LRU-64 via
|
|
240
|
+
# _remember -- see _SEEN_KEEP.
|
|
241
|
+
# Slice-5 T9: id-sets shared across BOTH vocabularies for EXT tools
|
|
242
|
+
# (the kernel reuses the SAME tc["id"] as step_id there), so a step
|
|
243
|
+
# announced by one vocabulary is never re-announced by its
|
|
244
|
+
# dual-emitted twin. step_labels carries a v2 step_started's label
|
|
245
|
+
# forward to its later step_finished (which carries no app_id/tool
|
|
246
|
+
# of its own — facts-only). local_ids tracks LOCAL-tool v2 step_ids,
|
|
247
|
+
# which use a disjoint id space from the tool_request round trip
|
|
248
|
+
# (see webbee.frames' module docstring) and are a pure no-op.
|
|
249
|
+
started: set = set()
|
|
250
|
+
finished: set = set()
|
|
251
|
+
step_labels: dict = {}
|
|
252
|
+
local_ids: set = set()
|
|
253
|
+
from webbee.stream import stream_frames
|
|
254
|
+
_fr = getattr(self.token_provider, "force_refresh", None)
|
|
255
|
+
_rc = getattr(sink, "reconnecting", None)
|
|
256
|
+
stream = stream_frames(client, session_id, self._headers, start_id=start_id,
|
|
257
|
+
force_refresh=_fr, on_retry=_rc)
|
|
258
|
+
# Liveness A: explicit __anext__ pulls (not `async for`) so a
|
|
259
|
+
# pending local consent can RACE the stream. Between iterations at
|
|
260
|
+
# most ONE of carry_task/carry_frame is set — a consent race hands
|
|
261
|
+
# ownership of its pulled-ahead pull back to this loop: a pending
|
|
262
|
+
# task when consent won, an already-pulled frame when the stream
|
|
263
|
+
# won. Everything else is byte-identical to the old async-for.
|
|
264
|
+
carry_task = None # a still-pending __anext__ task (consent won)
|
|
265
|
+
carry_frame = None # an already-pulled frame (the stream won)
|
|
266
|
+
_t0 = _monotonic() # W1 Task 10: own-task watchdog start
|
|
267
|
+
_own_frames = False # -> True the instant ANY non-foreign frame lands
|
|
268
|
+
try:
|
|
269
|
+
while True:
|
|
270
|
+
if carry_frame is not None:
|
|
271
|
+
frame, carry_frame = carry_frame, None
|
|
272
|
+
else:
|
|
273
|
+
if carry_task is not None:
|
|
274
|
+
pull, carry_task = carry_task, None
|
|
275
|
+
else:
|
|
276
|
+
pull = asyncio.ensure_future(stream.__anext__())
|
|
277
|
+
try:
|
|
278
|
+
frame = await pull
|
|
279
|
+
except StopAsyncIteration:
|
|
280
|
+
break
|
|
281
|
+
ftype = frame.get("type")
|
|
282
|
+
|
|
283
|
+
# A frame from a DIFFERENT turn on the shared persistent stream
|
|
284
|
+
# (task_id absent on legacy kernels -> treated as own). C7 safety:
|
|
285
|
+
# foreign actionable frames are NEVER executed/consented and NEVER
|
|
286
|
+
# end this turn -- but instead of vanishing they (and any origin-
|
|
287
|
+
# stamped cross-surface display frame, e.g. a Telegram-steered
|
|
288
|
+
# turn's progress) now render ONE tagged, display-only line.
|
|
289
|
+
if _is_foreign_frame(frame, self._task_id):
|
|
290
|
+
render_foreign_frame(frame, sink)
|
|
291
|
+
if (not _own_frames and self._task_id
|
|
292
|
+
and _monotonic() - _t0 > _FOREIGN_ONLY_DEADLINE_S):
|
|
293
|
+
# Deduped-twin hang (W1 front-3b): the kernel ring
|
|
294
|
+
# dropped this turn's task as a twin -- its task_id
|
|
295
|
+
# will never appear on the stream. Foreign traffic
|
|
296
|
+
# flowing + zero own frames is the signature; end
|
|
297
|
+
# honestly instead of spinning "working" forever.
|
|
298
|
+
_note = getattr(sink, "note", None)
|
|
299
|
+
if _note is not None:
|
|
300
|
+
_note("⚠ this message produced no work of its own "
|
|
301
|
+
"(likely a duplicate the kernel dropped) — done waiting")
|
|
302
|
+
return ""
|
|
303
|
+
continue
|
|
304
|
+
_own_frames = True
|
|
305
|
+
|
|
306
|
+
# Live steer topology: a Telegram/panel-steered turn keeps THIS
|
|
307
|
+
# client's task_id (the terminal stays the sole executor) with
|
|
308
|
+
# `origin` stamped -- tag the text renders below; everything
|
|
309
|
+
# else (execution, dedup, consent, accounting) is unchanged.
|
|
310
|
+
_tag = _origin_tag(frame)
|
|
311
|
+
|
|
312
|
+
if ftype == "tool_request":
|
|
313
|
+
rid = frame.get("req_id")
|
|
314
|
+
sid = str(rid or "")
|
|
315
|
+
if rid in seen:
|
|
316
|
+
seen.move_to_end(rid)
|
|
317
|
+
out = seen[rid]
|
|
318
|
+
else:
|
|
319
|
+
# UI rendering is guarded so it can never block the
|
|
320
|
+
# result POST below (an unposted result hangs the kernel
|
|
321
|
+
# dispatch and freezes the dock).
|
|
322
|
+
if _first_time(sid, started):
|
|
323
|
+
try:
|
|
324
|
+
sink.tool_start(_tag + frame.get("tool", ""), frame.get("args", {}))
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|
|
327
|
+
out = await asyncio.to_thread(handle_tool_request, frame, executor)
|
|
328
|
+
res = out["result"]
|
|
329
|
+
if _first_time(sid, finished):
|
|
330
|
+
try:
|
|
331
|
+
sink.tool_result(_tag + frame.get("tool", ""), bool(res.get("ok")), _summary(res))
|
|
332
|
+
self.steps.append({"step_id": sid,
|
|
333
|
+
"label": frame.get("tool", ""),
|
|
334
|
+
"ok": bool(res.get("ok"))})
|
|
335
|
+
except Exception:
|
|
336
|
+
pass
|
|
337
|
+
_remember(seen, rid, out)
|
|
338
|
+
await self._post_result(client, session_id, out)
|
|
339
|
+
|
|
340
|
+
elif ftype == "confirm_request":
|
|
341
|
+
rid = frame.get("req_id")
|
|
342
|
+
if rid in seen:
|
|
343
|
+
seen.move_to_end(rid)
|
|
344
|
+
await self._post_result(client, session_id, seen[rid])
|
|
345
|
+
else:
|
|
346
|
+
if self.mode == "plan":
|
|
347
|
+
sink.plan_blocked(frame.get("tool", ""))
|
|
348
|
+
# Liveness A: the local prompt races the stream so a
|
|
349
|
+
# consent answered from ANOTHER surface (Telegram
|
|
350
|
+
# relay) unfreezes this terminal instead of leaving
|
|
351
|
+
# the dock stuck on `approve? y/n`.
|
|
352
|
+
race = await race_consent(frame, sink, stream,
|
|
353
|
+
mode=self.mode, task_id=self._task_id)
|
|
354
|
+
carry_task, carry_frame = race.carry_task, race.carry_frame
|
|
355
|
+
if race.stream_ended:
|
|
356
|
+
break
|
|
357
|
+
if race.out is not None:
|
|
358
|
+
_remember(seen, rid, race.out)
|
|
359
|
+
await self._post_result(client, session_id, race.out)
|
|
360
|
+
|
|
361
|
+
elif ftype == "panel_release_required":
|
|
362
|
+
sink.panel_release(frame.get("panel_url", ""), frame.get("summary", ""))
|
|
363
|
+
|
|
364
|
+
elif ftype == "action": # R2 — ext-tool call (server-side) surfaced in the feed
|
|
365
|
+
handle_action_frame(frame, sink, started, finished, self.steps)
|
|
366
|
+
|
|
367
|
+
elif ftype == "step_started": # v2 (Slice-5 T8 dual-emit)
|
|
368
|
+
handle_step_started(frame, sink, started, step_labels, local_ids)
|
|
369
|
+
|
|
370
|
+
elif ftype == "step_finished": # v2 (Slice-5 T8 dual-emit)
|
|
371
|
+
handle_step_finished(frame, sink, finished, step_labels, self.steps, local_ids)
|
|
372
|
+
|
|
373
|
+
elif ftype == "thinking": # system-driven reasoning -> the 💭 block
|
|
374
|
+
_text = _progress_text(frame)
|
|
375
|
+
(getattr(sink, "thinking", None) or sink.progress)(_tag + _text if _text else "")
|
|
376
|
+
|
|
377
|
+
elif ftype == "progress": # P2 — dual-reads llm_text (v2) / text (legacy)
|
|
378
|
+
_text = _progress_text(frame)
|
|
379
|
+
sink.progress(_tag + _text if _text else "")
|
|
380
|
+
|
|
381
|
+
elif ftype == "usage": # P2 — cumulative tokens + credits (Slice C; raw $ stays server-side)
|
|
382
|
+
sink.usage(
|
|
383
|
+
int(frame.get("tokens", 0) or 0),
|
|
384
|
+
int(frame.get("credits", 0) or 0),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
elif ftype in ("task_queued", "task_dequeued"):
|
|
388
|
+
# Full-queue-layer K1: a follow-up queued into the RUNNING
|
|
389
|
+
# kernel session shows in the live queue panel the instant
|
|
390
|
+
# it queues (tagged by origin) and leaves the panel when
|
|
391
|
+
# the kernel drains it. These frames carry NO task_id
|
|
392
|
+
# (they belong to the session, not a turn), so the C7
|
|
393
|
+
# filter never eats them; getattr-guarded like todos/
|
|
394
|
+
# queued_run — a minimal sink drops them, a render error
|
|
395
|
+
# never breaks the loop. Terminal-origin rows render TOO
|
|
396
|
+
# (mid-turn inject, 0.3.15): an injected line never sits
|
|
397
|
+
# in the LOCAL panel — the kernel's echo is its only row,
|
|
398
|
+
# and task_dequeued clears it when the turn absorbs it.
|
|
399
|
+
_origin = str(frame.get("origin", "") or "")
|
|
400
|
+
_hook = getattr(sink, "remote_queued" if ftype == "task_queued"
|
|
401
|
+
else "remote_dequeued", None)
|
|
402
|
+
if _hook is not None:
|
|
403
|
+
_iid = str(frame.get("steer_iid", "") or "")
|
|
404
|
+
try:
|
|
405
|
+
if ftype == "task_queued":
|
|
406
|
+
_hook(_origin, str(frame.get("text", "") or ""), _iid)
|
|
407
|
+
else:
|
|
408
|
+
_hook(_origin, _iid)
|
|
409
|
+
except Exception:
|
|
410
|
+
pass
|
|
411
|
+
|
|
412
|
+
elif ftype == "marathon_complete": # U4 — the whole GOAL is done: terminal
|
|
413
|
+
return frame.get("text", "")
|
|
414
|
+
|
|
415
|
+
elif ftype in _MARATHON_FACT_TYPES: # U4 — marathon plan/milestone/pause/todo
|
|
416
|
+
# Facts-only. A `todo` fact carries the FULL list -> the
|
|
417
|
+
# dedicated full-checklist render (falls back to the old
|
|
418
|
+
# one-line note on a minimal sink). The other facts render
|
|
419
|
+
# ONE human-readable line. Guarded: a minimal sink (no
|
|
420
|
+
# `note`) simply drops the fact rather than crashing the
|
|
421
|
+
# turn (the stream reader already tolerates unknown frame
|
|
422
|
+
# types by ignoring them).
|
|
423
|
+
if ftype == "todo":
|
|
424
|
+
render_todo_frame(frame, sink)
|
|
425
|
+
else:
|
|
426
|
+
_note = getattr(sink, "note", None)
|
|
427
|
+
if _note is not None:
|
|
428
|
+
_note(marathon_note(frame))
|
|
429
|
+
if ftype == "marathon_paused":
|
|
430
|
+
# Parked (out-of-credits / consent / runaway) -> end the
|
|
431
|
+
# turn so the dock leaves "working"; the note shows why, and
|
|
432
|
+
# the run resumes on the user's next reply. The kernel's task
|
|
433
|
+
# queue stays alive server-side while parked -- tell the sink
|
|
434
|
+
# via marathon_parked() so end_turn keeps the queue-panel's
|
|
435
|
+
# remote rows (tagged parked) instead of wiping them (W1
|
|
436
|
+
# front-3b: an empty panel over a non-empty kernel queue lies).
|
|
437
|
+
# getattr-guarded like every other sink hook here: a minimal
|
|
438
|
+
# sink drops it, a crashing hook never breaks the turn.
|
|
439
|
+
_parked = getattr(sink, "marathon_parked", None)
|
|
440
|
+
if _parked is not None:
|
|
441
|
+
try:
|
|
442
|
+
_parked(str(frame.get("reason", "") or ""))
|
|
443
|
+
except Exception:
|
|
444
|
+
pass
|
|
445
|
+
return ""
|
|
446
|
+
|
|
447
|
+
elif ftype == "final":
|
|
448
|
+
# In a MARATHON a `final` is a PER-MILESTONE result, NOT the end
|
|
449
|
+
# of the run -> keep streaming (the goal ends on marathon_complete
|
|
450
|
+
# / marathon_paused, or a user-stop `final` with stopped=true). A
|
|
451
|
+
# non-marathon coding turn's `final` is terminal (unchanged).
|
|
452
|
+
if marathon and not frame.get("stopped"):
|
|
453
|
+
continue
|
|
454
|
+
_text = frame.get("text", "")
|
|
455
|
+
return _tag + _text if _text else ""
|
|
456
|
+
finally:
|
|
457
|
+
# Never leak a pulled-ahead __anext__ past the loop (e.g. an
|
|
458
|
+
# exit while a consent race's carry is still pending).
|
|
459
|
+
if carry_task is not None:
|
|
460
|
+
await _retire(carry_task)
|
|
461
|
+
|
|
462
|
+
return ""
|
|
463
|
+
|
|
464
|
+
_result_delays = (0.5, 2.0, 5.0) # class attr — tests override per instance
|
|
465
|
+
|
|
466
|
+
async def _post_result(self, client, session_id: str, out: dict) -> None:
|
|
467
|
+
# Never raises into the stream loop. W1: bounded transient retries —
|
|
468
|
+
# the kernel dedups by req_id, so a duplicate post is safe, and this
|
|
469
|
+
# cuts outage recovery from the kernel's tool-wait expiry (up to
|
|
470
|
+
# 65min for bash) to seconds. Final fallback unchanged: give up
|
|
471
|
+
# silently, kernel re-dispatches the same req_id.
|
|
472
|
+
for delay in (0.0,) + tuple(self._result_delays):
|
|
473
|
+
if delay:
|
|
474
|
+
await asyncio.sleep(delay)
|
|
475
|
+
try:
|
|
476
|
+
headers = await self._headers()
|
|
477
|
+
r = await client.post(f"/v1/agent/sessions/{session_id}/result",
|
|
478
|
+
json=out, headers=headers)
|
|
479
|
+
if r.status_code < 500:
|
|
480
|
+
return
|
|
481
|
+
except Exception:
|
|
482
|
+
continue
|
|
483
|
+
|
|
484
|
+
async def stop(self) -> None:
|
|
485
|
+
"""P5g: server-side stop for Esc/Ctrl-C. Posts a cancel for the
|
|
486
|
+
in-flight turn so the kernel tears down server-side (not just the
|
|
487
|
+
local asyncio task). Fail-soft — the local task.cancel() the dock
|
|
488
|
+
already does is what actually tears the UI down, so a network error
|
|
489
|
+
here must never raise into the key-binding handler."""
|
|
490
|
+
if not self.session_id:
|
|
491
|
+
return
|
|
492
|
+
try:
|
|
493
|
+
import httpx
|
|
494
|
+
|
|
495
|
+
headers = await self._headers()
|
|
496
|
+
async with httpx.AsyncClient(base_url=self.cfg.api_url, timeout=10) as client:
|
|
497
|
+
await client.post(
|
|
498
|
+
f"/v1/agent/sessions/{self.session_id}/cancel", headers=headers,
|
|
499
|
+
)
|
|
500
|
+
except Exception:
|
|
501
|
+
pass
|