soothe-cli 0.7.0__tar.gz → 0.7.1__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.
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/PKG-INFO +1 -1
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/execution/daemon.py +1 -1
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/execution/daemon_errors.py +18 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/state/session_stats.py +60 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/transport/session.py +30 -1
- soothe_cli-0.7.1/src/soothe_cli/runtime/turn/pipeline.py +309 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_app.py +8 -1
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_execution.py +37 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_messages_mixin.py +20 -12
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_model.py +23 -30
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_startup.py +51 -26
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_ui.py +26 -4
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/app.tcss +6 -6
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/model_config.py +70 -14
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/textual_adapter.py +275 -30
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/tool_display.py +54 -1
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/context_viewer.py +1 -1
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/loading.py +30 -8
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/cognition_step.py +5 -4
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +1 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/user.py +20 -12
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/welcome.py +1 -15
- soothe_cli-0.7.0/src/soothe_cli/runtime/turn/pipeline.py +0 -166
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/.gitignore +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/README.md +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/pyproject.toml +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/__init__.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/__init__.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/commands/__init__.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/execution/__init__.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/execution/headless.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/execution/launcher.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/cli/main.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/config/__init__.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/config/cli_config.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/config/loader.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/config/logging_setup.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/__init__.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/headless/processor.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/presentation/engine.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/presentation/explore_task_display.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/state/step_router.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/state/transcript.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/turn/prepare.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/wire/display_text.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/wire/message_text.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/runtime/wire/messages.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/__init__.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/_cli_context.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/_env_vars.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/_version.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/__init__.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_commands.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_history.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/app/_module_init.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/binding.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/command_registry.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/commands/__init__.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/commands/command_router.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/config.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/file_change_notify.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/file_change_renderers.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/hooks.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/input.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/media_utils.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/path_utils.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/preview_limits.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/project_utils.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/sessions.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/skills/__init__.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/skills/invocation.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/skills/load.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/theme.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/tips.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/unicode_security.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/update_check.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/__init__.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/_links.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/autopilot_screen.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/diff.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/editor.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/history.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/message_store.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/assistant.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/status.py +0 -0
- {soothe_cli-0.7.0 → soothe_cli-0.7.1}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
|
@@ -280,7 +280,7 @@ async def _run_headless_session_once(
|
|
|
280
280
|
query_started = True
|
|
281
281
|
elif (state == "idle" and query_started) or state == "stopped":
|
|
282
282
|
loop_clock = asyncio.get_event_loop()
|
|
283
|
-
drain_deadline = loop_clock.time() +
|
|
283
|
+
drain_deadline = loop_clock.time() + 30.0
|
|
284
284
|
while loop_clock.time() < drain_deadline:
|
|
285
285
|
try:
|
|
286
286
|
nxt = await asyncio.wait_for(client.read_event(), timeout=0.25)
|
|
@@ -5,11 +5,19 @@ from __future__ import annotations
|
|
|
5
5
|
# Partial match for pool_runner RuntimeError when an OS worker exits mid-turn.
|
|
6
6
|
DAEMON_WORKER_SUBPROCESS_LOST = "Worker subprocess exited unexpectedly during query execution"
|
|
7
7
|
|
|
8
|
+
# Partial match for thread_runner RuntimeError when a worker thread dies mid-turn.
|
|
9
|
+
DAEMON_WORKER_THREAD_LOST = "Worker thread exited unexpectedly during query execution"
|
|
10
|
+
|
|
8
11
|
_FRIENDLY_WORKER_SUBPROCESS_LOST = (
|
|
9
12
|
"The daemon execution worker stopped unexpectedly (for example after the pool "
|
|
10
13
|
"recycled an idle subprocess). Send your message again."
|
|
11
14
|
)
|
|
12
15
|
|
|
16
|
+
_FRIENDLY_WORKER_THREAD_LOST = (
|
|
17
|
+
"The daemon execution worker stopped unexpectedly during your request. "
|
|
18
|
+
"Send your message again, or use /resume to recover the loop."
|
|
19
|
+
)
|
|
20
|
+
|
|
13
21
|
_FRIENDLY_DAEMON_CONNECTION_LOST = (
|
|
14
22
|
"Daemon connection lost (the daemon may have restarted). "
|
|
15
23
|
"Send your message again to reconnect and continue this loop."
|
|
@@ -45,9 +53,13 @@ def friendly_daemon_execution_error(exc: BaseException | str) -> str:
|
|
|
45
53
|
return _FRIENDLY_DAEMON_CONNECTION_LOST
|
|
46
54
|
if isinstance(exc, RuntimeError) and DAEMON_WORKER_SUBPROCESS_LOST in str(exc):
|
|
47
55
|
return _FRIENDLY_WORKER_SUBPROCESS_LOST
|
|
56
|
+
if isinstance(exc, RuntimeError) and DAEMON_WORKER_THREAD_LOST in str(exc):
|
|
57
|
+
return _FRIENDLY_WORKER_THREAD_LOST
|
|
48
58
|
text = str(exc)
|
|
49
59
|
if DAEMON_WORKER_SUBPROCESS_LOST in text:
|
|
50
60
|
return _FRIENDLY_WORKER_SUBPROCESS_LOST
|
|
61
|
+
if DAEMON_WORKER_THREAD_LOST in text:
|
|
62
|
+
return _FRIENDLY_WORKER_THREAD_LOST
|
|
51
63
|
return text if isinstance(exc, str) else str(exc)
|
|
52
64
|
|
|
53
65
|
|
|
@@ -55,3 +67,9 @@ def is_daemon_worker_subprocess_lost(exc: BaseException | str) -> bool:
|
|
|
55
67
|
"""Return whether an error indicates a pool worker process exited mid-query."""
|
|
56
68
|
text = str(exc)
|
|
57
69
|
return DAEMON_WORKER_SUBPROCESS_LOST in text
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_daemon_worker_thread_lost(exc: BaseException | str) -> bool:
|
|
73
|
+
"""Return whether an error indicates a pool worker thread exited mid-query."""
|
|
74
|
+
text = str(exc)
|
|
75
|
+
return DAEMON_WORKER_THREAD_LOST in text
|
|
@@ -8,6 +8,7 @@ config, no widget imports) so that `app.py` can import `SessionStats` and
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import time
|
|
11
12
|
from dataclasses import dataclass, field
|
|
12
13
|
|
|
13
14
|
SpinnerStatus = str | None
|
|
@@ -106,6 +107,52 @@ class SessionStats:
|
|
|
106
107
|
self.event_stats.merge(other.event_stats)
|
|
107
108
|
|
|
108
109
|
|
|
110
|
+
@dataclass
|
|
111
|
+
class TurnLatencyStats:
|
|
112
|
+
"""IG-534 Phase 3: end-to-end turn latency observability.
|
|
113
|
+
|
|
114
|
+
Attributes:
|
|
115
|
+
turn_start_monotonic: Monotonic timestamp when the turn began.
|
|
116
|
+
time_to_first_chunk_ms: Milliseconds from turn start to first applied chunk.
|
|
117
|
+
synthesis_visible_ms: Milliseconds from turn start to first goal_completion apply.
|
|
118
|
+
goal_completion_applied: Whether a goal_completion chunk was applied.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
turn_start_monotonic: float = 0.0
|
|
122
|
+
time_to_first_chunk_ms: float | None = None
|
|
123
|
+
synthesis_visible_ms: float | None = None
|
|
124
|
+
goal_completion_applied: bool = False
|
|
125
|
+
|
|
126
|
+
def record_first_chunk(self) -> None:
|
|
127
|
+
"""Record time-to-first-chunk once per turn."""
|
|
128
|
+
if self.time_to_first_chunk_ms is not None or self.turn_start_monotonic <= 0:
|
|
129
|
+
return
|
|
130
|
+
self.time_to_first_chunk_ms = (time.monotonic() - self.turn_start_monotonic) * 1000.0
|
|
131
|
+
|
|
132
|
+
def record_goal_completion(self) -> None:
|
|
133
|
+
"""Record synthesis-visible latency once per turn."""
|
|
134
|
+
if self.goal_completion_applied or self.turn_start_monotonic <= 0:
|
|
135
|
+
return
|
|
136
|
+
self.goal_completion_applied = True
|
|
137
|
+
self.synthesis_visible_ms = (time.monotonic() - self.turn_start_monotonic) * 1000.0
|
|
138
|
+
|
|
139
|
+
def merge(self, other: TurnLatencyStats) -> None:
|
|
140
|
+
"""Merge another stats object (keeps earliest first-chunk / synthesis times)."""
|
|
141
|
+
if other.time_to_first_chunk_ms is not None:
|
|
142
|
+
if (
|
|
143
|
+
self.time_to_first_chunk_ms is None
|
|
144
|
+
or other.time_to_first_chunk_ms < self.time_to_first_chunk_ms
|
|
145
|
+
):
|
|
146
|
+
self.time_to_first_chunk_ms = other.time_to_first_chunk_ms
|
|
147
|
+
if other.synthesis_visible_ms is not None:
|
|
148
|
+
if (
|
|
149
|
+
self.synthesis_visible_ms is None
|
|
150
|
+
or other.synthesis_visible_ms < self.synthesis_visible_ms
|
|
151
|
+
):
|
|
152
|
+
self.synthesis_visible_ms = other.synthesis_visible_ms
|
|
153
|
+
self.goal_completion_applied = self.goal_completion_applied or other.goal_completion_applied
|
|
154
|
+
|
|
155
|
+
|
|
109
156
|
@dataclass
|
|
110
157
|
class TurnEventStats:
|
|
111
158
|
"""Event counts accumulated over a single daemon turn.
|
|
@@ -139,6 +186,8 @@ class TurnEventStats:
|
|
|
139
186
|
text_chunks: int = 0
|
|
140
187
|
heartbeats_dropped: int = 0
|
|
141
188
|
post_idle_drained: int = 0
|
|
189
|
+
inbound_dropped: int = 0
|
|
190
|
+
latency: TurnLatencyStats | None = None
|
|
142
191
|
|
|
143
192
|
def record(
|
|
144
193
|
self,
|
|
@@ -187,6 +236,11 @@ class TurnEventStats:
|
|
|
187
236
|
self.heartbeats_dropped += other.heartbeats_dropped
|
|
188
237
|
self.post_idle_drained += other.post_idle_drained
|
|
189
238
|
self.filtered_early += other.filtered_early
|
|
239
|
+
self.inbound_dropped += other.inbound_dropped
|
|
240
|
+
if other.latency is not None:
|
|
241
|
+
if self.latency is None:
|
|
242
|
+
self.latency = TurnLatencyStats()
|
|
243
|
+
self.latency.merge(other.latency)
|
|
190
244
|
|
|
191
245
|
def summary_line(self) -> str:
|
|
192
246
|
"""Return a one-line summary suitable for structured logging.
|
|
@@ -220,6 +274,12 @@ class TurnEventStats:
|
|
|
220
274
|
detail_parts.append(f"{self.heartbeats_dropped} hb-drop")
|
|
221
275
|
if self.post_idle_drained:
|
|
222
276
|
detail_parts.append(f"{self.post_idle_drained} post-idle")
|
|
277
|
+
if self.inbound_dropped:
|
|
278
|
+
detail_parts.append(f"{self.inbound_dropped} inbound-drop")
|
|
279
|
+
if self.latency is not None and self.latency.time_to_first_chunk_ms is not None:
|
|
280
|
+
detail_parts.append(f"ttfc={self.latency.time_to_first_chunk_ms:.0f}ms")
|
|
281
|
+
if self.latency is not None and self.latency.synthesis_visible_ms is not None:
|
|
282
|
+
detail_parts.append(f"synth={self.latency.synthesis_visible_ms:.0f}ms")
|
|
223
283
|
if detail_parts:
|
|
224
284
|
parts.append("; ".join(detail_parts))
|
|
225
285
|
|
|
@@ -26,7 +26,7 @@ logger = logging.getLogger(__name__)
|
|
|
26
26
|
|
|
27
27
|
# Match headless daemon client: brief read window after ``idle`` so stream events
|
|
28
28
|
# that arrive slightly after status are not dropped (``cli/execution/daemon.py``).
|
|
29
|
-
_POST_IDLE_DRAIN_DEADLINE_S =
|
|
29
|
+
_POST_IDLE_DRAIN_DEADLINE_S = 30.0
|
|
30
30
|
|
|
31
31
|
# Align with ``bootstrap_loop_session`` daemon-ready wait (RFC-450 §8.2).
|
|
32
32
|
_RPC_HANDSHAKE_TIMEOUT_S = 20.0
|
|
@@ -91,6 +91,7 @@ class TuiDaemonSession:
|
|
|
91
91
|
self.turn_event_stats = TurnEventStats()
|
|
92
92
|
self.last_turn_end_state: str | None = None
|
|
93
93
|
self.last_turn_cancellation_seen: bool = False
|
|
94
|
+
self.last_turn_error_message: str | None = None
|
|
94
95
|
|
|
95
96
|
@property
|
|
96
97
|
def loop_id(self) -> str | None:
|
|
@@ -248,6 +249,14 @@ class TuiDaemonSession:
|
|
|
248
249
|
"""Ask the daemon to cancel the in-flight query (same wire path as ``/cancel``)."""
|
|
249
250
|
await self._client.notify("slash_command", {"cmd": "/cancel"})
|
|
250
251
|
|
|
252
|
+
async def cancel_active_turn(self) -> None:
|
|
253
|
+
"""Cancel the in-flight query on the active loop (IG-533 ordering contract).
|
|
254
|
+
|
|
255
|
+
Call before switching ``loop_id`` (e.g. ``/clear``) so synthesis on the
|
|
256
|
+
prior loop is torn down server-side instead of filtered client-side.
|
|
257
|
+
"""
|
|
258
|
+
await self.cancel_remote_query()
|
|
259
|
+
|
|
251
260
|
async def _drain_stream_events_after_idle(
|
|
252
261
|
self,
|
|
253
262
|
*,
|
|
@@ -316,6 +325,8 @@ class TuiDaemonSession:
|
|
|
316
325
|
self.turn_event_stats = TurnEventStats()
|
|
317
326
|
self.last_turn_end_state = None
|
|
318
327
|
self.last_turn_cancellation_seen = False
|
|
328
|
+
self.last_turn_error_message = None
|
|
329
|
+
inbound_dropped_baseline = getattr(self._client, "inbound_dropped", 0)
|
|
319
330
|
query_started = False
|
|
320
331
|
expected_loop_id = self._loop_id
|
|
321
332
|
self._streaming = True
|
|
@@ -431,8 +442,15 @@ class TuiDaemonSession:
|
|
|
431
442
|
# Graph auto-resumes LangGraph interrupts server-side; keep consuming events.
|
|
432
443
|
if mode == "updates" and isinstance(data, dict) and "__interrupt__" in data:
|
|
433
444
|
continue
|
|
445
|
+
except Exception as exc:
|
|
446
|
+
self.last_turn_error_message = str(exc)
|
|
447
|
+
raise
|
|
434
448
|
finally:
|
|
435
449
|
self._streaming = False
|
|
450
|
+
self.turn_event_stats.inbound_dropped = max(
|
|
451
|
+
0,
|
|
452
|
+
getattr(self._client, "inbound_dropped", 0) - inbound_dropped_baseline,
|
|
453
|
+
)
|
|
436
454
|
|
|
437
455
|
async def list_skills(self) -> list[dict[str, Any]]:
|
|
438
456
|
"""Return skill rows from the daemon catalog (no filesystem paths)."""
|
|
@@ -651,5 +669,16 @@ class TuiDaemonSession:
|
|
|
651
669
|
return []
|
|
652
670
|
return [m for m in raw if isinstance(m, dict)]
|
|
653
671
|
|
|
672
|
+
async def fetch_goal_completion_text(self, loop_id: str) -> str | None:
|
|
673
|
+
"""Return the latest persisted ``goal_completion`` body for a loop, if any."""
|
|
674
|
+
rows = await self.fetch_conversation_log(loop_id, limit=200, include_events=False)
|
|
675
|
+
for row in reversed(rows):
|
|
676
|
+
if row.get("phase") != "goal_completion":
|
|
677
|
+
continue
|
|
678
|
+
text = row.get("text") or row.get("content") or ""
|
|
679
|
+
if isinstance(text, str) and text.strip():
|
|
680
|
+
return text.strip()
|
|
681
|
+
return None
|
|
682
|
+
|
|
654
683
|
|
|
655
684
|
DaemonSession = TuiDaemonSession
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Decouple daemon stream ingestion from chunk processing and UI application.
|
|
2
|
+
|
|
3
|
+
Three stages run concurrently during a TUI turn:
|
|
4
|
+
|
|
5
|
+
1. **Reader** (async, main event loop): pulls ``(namespace, mode, data)`` tuples from the
|
|
6
|
+
daemon and enqueues them.
|
|
7
|
+
2. **Processor** (dedicated thread): runs CPU-heavy parsing / routing without blocking
|
|
8
|
+
Textual rendering.
|
|
9
|
+
3. **Applier** (async, main event loop): consumes prepared plans and performs widget
|
|
10
|
+
updates.
|
|
11
|
+
|
|
12
|
+
The websocket client stays on the main asyncio loop; only synchronous work moves off it.
|
|
13
|
+
High-priority chunks (tool wire, loop step events) are applied before low-priority text.
|
|
14
|
+
|
|
15
|
+
IG-535 Optimization 2: Added batched apply mode to reduce DOM mutations per chunk.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import logging
|
|
22
|
+
import queue
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import AsyncIterator, Callable
|
|
26
|
+
from typing import Any, Generic, TypeVar
|
|
27
|
+
|
|
28
|
+
from soothe_sdk.ux.loop_stream import assistant_output_phase
|
|
29
|
+
|
|
30
|
+
from soothe_cli.runtime.state.session_stats import TurnLatencyStats
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
T = TypeVar("T")
|
|
35
|
+
_SENTINEL = object()
|
|
36
|
+
|
|
37
|
+
# Lower number = higher priority (matches asyncio.PriorityQueue ordering).
|
|
38
|
+
PRIORITY_HIGH = 0
|
|
39
|
+
PRIORITY_NORMAL = 1
|
|
40
|
+
PRIORITY_LOW = 2
|
|
41
|
+
|
|
42
|
+
# IG-535: Batching defaults for TUI apply path optimization
|
|
43
|
+
_DEFAULT_BATCH_SIZE = 10
|
|
44
|
+
_DEFAULT_BATCH_DELAY_MS = 50
|
|
45
|
+
|
|
46
|
+
# IG-535: Queue sizes tuned for 32 concurrent loops with dense streaming
|
|
47
|
+
# Inbound: daemon→TUI chunks; Outbound: processed→UI apply plans
|
|
48
|
+
_DEFAULT_INBOUND_MAXSIZE = 2048 # IG-535: Increased from 1024 for multi-loop throughput
|
|
49
|
+
_DEFAULT_OUTBOUND_MAXSIZE = 1024 # IG-535: Increased from 512 for batched apply backlog
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TurnApplyBatcher(Generic[T]):
|
|
53
|
+
"""IG-535 Optimization 2: Accumulate prepared chunks for batched apply.
|
|
54
|
+
|
|
55
|
+
Reduces DOM mutations per chunk by batching up to N chunks before applying.
|
|
56
|
+
HIGH priority items trigger immediate flush for responsiveness.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
max_batch_size: Maximum chunks per batch (default 10).
|
|
60
|
+
max_batch_delay_ms: Maximum delay before forced flush (default 50ms).
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
max_batch_size: int = _DEFAULT_BATCH_SIZE,
|
|
67
|
+
max_batch_delay_ms: int = _DEFAULT_BATCH_DELAY_MS,
|
|
68
|
+
) -> None:
|
|
69
|
+
self._max_batch_size = max_batch_size
|
|
70
|
+
self._max_batch_delay_s = max_batch_delay_ms / 1000.0
|
|
71
|
+
self._pending: list[T] = []
|
|
72
|
+
self._high_priority_count: int = 0
|
|
73
|
+
self._last_flush_monotonic: float = time.monotonic()
|
|
74
|
+
|
|
75
|
+
def add(self, prepared: T) -> bool:
|
|
76
|
+
"""Add chunk to batch. Returns True if batch should flush now.
|
|
77
|
+
|
|
78
|
+
Flush triggers:
|
|
79
|
+
1. Batch size threshold reached
|
|
80
|
+
2. HIGH priority item present (immediate for responsiveness)
|
|
81
|
+
3. Time threshold elapsed
|
|
82
|
+
"""
|
|
83
|
+
self._pending.append(prepared)
|
|
84
|
+
priority = getattr(prepared, "priority", PRIORITY_LOW)
|
|
85
|
+
if priority == PRIORITY_HIGH:
|
|
86
|
+
self._high_priority_count += 1
|
|
87
|
+
|
|
88
|
+
now = time.monotonic()
|
|
89
|
+
if len(self._pending) >= self._max_batch_size:
|
|
90
|
+
return True
|
|
91
|
+
if self._high_priority_count > 0:
|
|
92
|
+
return True
|
|
93
|
+
if now - self._last_flush_monotonic >= self._max_batch_delay_s:
|
|
94
|
+
return True
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
def flush(self) -> list[T]:
|
|
98
|
+
"""Return accumulated batch and reset state."""
|
|
99
|
+
batch = list(self._pending)
|
|
100
|
+
self._pending.clear()
|
|
101
|
+
self._high_priority_count = 0
|
|
102
|
+
self._last_flush_monotonic = time.monotonic()
|
|
103
|
+
return batch
|
|
104
|
+
|
|
105
|
+
def has_pending(self) -> bool:
|
|
106
|
+
"""Return True if there are pending items."""
|
|
107
|
+
return bool(self._pending)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def pending_count(self) -> int:
|
|
111
|
+
"""Return number of pending items."""
|
|
112
|
+
return len(self._pending)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TurnEventPipeline(Generic[T]):
|
|
116
|
+
"""Bridge daemon chunk ingestion, background processing, and UI application."""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
loop: asyncio.AbstractEventLoop,
|
|
121
|
+
*,
|
|
122
|
+
inbound_maxsize: int = _DEFAULT_INBOUND_MAXSIZE,
|
|
123
|
+
outbound_maxsize: int = _DEFAULT_OUTBOUND_MAXSIZE,
|
|
124
|
+
) -> None:
|
|
125
|
+
self._loop = loop
|
|
126
|
+
self._inbound: queue.Queue[Any] = queue.Queue(maxsize=inbound_maxsize)
|
|
127
|
+
# Thread-safe outbound bridge: processor puts without blocking the event loop.
|
|
128
|
+
self._outbound: queue.PriorityQueue[tuple[int, int, Any]] = queue.PriorityQueue(
|
|
129
|
+
maxsize=outbound_maxsize
|
|
130
|
+
)
|
|
131
|
+
self._outbound_seq = 0
|
|
132
|
+
self._stop = threading.Event()
|
|
133
|
+
self._thread: threading.Thread | None = None
|
|
134
|
+
self._processor_error: BaseException | None = None
|
|
135
|
+
|
|
136
|
+
async def feed_chunks(self, chunk_source: AsyncIterator[Any]) -> None:
|
|
137
|
+
"""Read all chunks from *chunk_source* into the inbound queue."""
|
|
138
|
+
try:
|
|
139
|
+
async for chunk in chunk_source:
|
|
140
|
+
if self._stop.is_set():
|
|
141
|
+
break
|
|
142
|
+
await asyncio.to_thread(self._inbound.put, chunk)
|
|
143
|
+
finally:
|
|
144
|
+
await asyncio.to_thread(self._inbound.put, _SENTINEL)
|
|
145
|
+
|
|
146
|
+
def start_processor(
|
|
147
|
+
self,
|
|
148
|
+
process_fn: Callable[[Any], T],
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Start the background processor thread.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
process_fn: Synchronous function mapping one raw chunk to a prepared plan.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def _worker() -> None:
|
|
157
|
+
while not self._stop.is_set():
|
|
158
|
+
try:
|
|
159
|
+
item = self._inbound.get(timeout=0.25)
|
|
160
|
+
except queue.Empty:
|
|
161
|
+
continue
|
|
162
|
+
if item is _SENTINEL:
|
|
163
|
+
self._put_outbound(PRIORITY_LOW, _SENTINEL)
|
|
164
|
+
break
|
|
165
|
+
try:
|
|
166
|
+
prepared = process_fn(item)
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
logger.exception("Turn chunk processor failed")
|
|
169
|
+
self._processor_error = exc
|
|
170
|
+
self._put_outbound(PRIORITY_LOW, _SENTINEL)
|
|
171
|
+
break
|
|
172
|
+
if prepared is None:
|
|
173
|
+
continue
|
|
174
|
+
priority = getattr(prepared, "priority", PRIORITY_LOW)
|
|
175
|
+
self._put_outbound(int(priority), prepared)
|
|
176
|
+
|
|
177
|
+
self._thread = threading.Thread(
|
|
178
|
+
target=_worker,
|
|
179
|
+
name="soothe-tui-turn-processor",
|
|
180
|
+
daemon=True,
|
|
181
|
+
)
|
|
182
|
+
self._thread.start()
|
|
183
|
+
|
|
184
|
+
def _put_outbound(self, priority: int, item: Any) -> None:
|
|
185
|
+
"""Enqueue a prepared chunk from the processor thread (thread-safe, non-blocking)."""
|
|
186
|
+
seq = self._outbound_seq
|
|
187
|
+
self._outbound_seq += 1
|
|
188
|
+
try:
|
|
189
|
+
self._outbound.put_nowait((priority, seq, item))
|
|
190
|
+
except queue.Full:
|
|
191
|
+
logger.warning("Turn outbound queue full; dropping prepared chunk")
|
|
192
|
+
|
|
193
|
+
async def iter_prepared(self) -> AsyncIterator[T]:
|
|
194
|
+
"""Yield prepared chunk plans until the stream ends."""
|
|
195
|
+
while True:
|
|
196
|
+
_priority, _seq, item = await asyncio.to_thread(self._outbound.get)
|
|
197
|
+
if item is _SENTINEL:
|
|
198
|
+
if self._processor_error is not None:
|
|
199
|
+
raise self._processor_error
|
|
200
|
+
break
|
|
201
|
+
yield item
|
|
202
|
+
|
|
203
|
+
def shutdown(self) -> None:
|
|
204
|
+
"""Signal the processor thread to stop (best-effort)."""
|
|
205
|
+
self._stop.set()
|
|
206
|
+
if self._thread is not None and self._thread.is_alive():
|
|
207
|
+
self._thread.join(timeout=2.0)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _record_apply_latency(prepared: Any, latency: TurnLatencyStats | None) -> None:
|
|
211
|
+
"""IG-534 Phase 3: record first-chunk and synthesis-visible timings."""
|
|
212
|
+
if latency is None or getattr(prepared, "skip", False):
|
|
213
|
+
return
|
|
214
|
+
latency.record_first_chunk()
|
|
215
|
+
if getattr(prepared, "mode", None) == "messages":
|
|
216
|
+
message = getattr(prepared, "normalized_message", None)
|
|
217
|
+
if message is not None and assistant_output_phase(message) == "goal_completion":
|
|
218
|
+
latency.record_goal_completion()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def run_turn_pipeline(
|
|
222
|
+
chunk_source: AsyncIterator[Any],
|
|
223
|
+
process_fn: Callable[[Any], T],
|
|
224
|
+
apply_fn: Callable[[T], Any],
|
|
225
|
+
*,
|
|
226
|
+
batch_size: int = _DEFAULT_BATCH_SIZE,
|
|
227
|
+
batch_delay_ms: int = _DEFAULT_BATCH_DELAY_MS,
|
|
228
|
+
batching_enabled: bool = True,
|
|
229
|
+
latency_stats: TurnLatencyStats | None = None,
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Run reader, processor thread, and applier coroutine to completion.
|
|
232
|
+
|
|
233
|
+
IG-535: Added batched apply mode to reduce DOM mutations per chunk.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
chunk_source: Async iterator of daemon stream chunks.
|
|
237
|
+
process_fn: Sync chunk processor (runs in background thread).
|
|
238
|
+
apply_fn: Async callable applied on the main loop for each prepared plan.
|
|
239
|
+
batch_size: Maximum chunks per batch when batching enabled (default 10).
|
|
240
|
+
batch_delay_ms: Maximum delay before forced flush (default 50ms).
|
|
241
|
+
batching_enabled: Enable batching for performance (default True; disable for debug).
|
|
242
|
+
latency_stats: Optional per-turn latency recorder (IG-534 Phase 3).
|
|
243
|
+
"""
|
|
244
|
+
loop = asyncio.get_running_loop()
|
|
245
|
+
if latency_stats is not None and latency_stats.turn_start_monotonic <= 0:
|
|
246
|
+
latency_stats.turn_start_monotonic = time.monotonic()
|
|
247
|
+
pipeline: TurnEventPipeline[T] = TurnEventPipeline(loop)
|
|
248
|
+
pipeline.start_processor(process_fn)
|
|
249
|
+
|
|
250
|
+
async def _apply_with_latency(prepared: T) -> None:
|
|
251
|
+
_record_apply_latency(prepared, latency_stats)
|
|
252
|
+
await apply_fn(prepared)
|
|
253
|
+
|
|
254
|
+
if batching_enabled:
|
|
255
|
+
batcher: TurnApplyBatcher[T] = TurnApplyBatcher(
|
|
256
|
+
max_batch_size=batch_size,
|
|
257
|
+
max_batch_delay_ms=batch_delay_ms,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
async def _apply_batched() -> None:
|
|
261
|
+
"""Apply chunks in batches with single yield per batch."""
|
|
262
|
+
async for prepared in pipeline.iter_prepared():
|
|
263
|
+
if batcher.add(prepared):
|
|
264
|
+
batch = batcher.flush()
|
|
265
|
+
# Apply all items in batch
|
|
266
|
+
for p in batch:
|
|
267
|
+
await _apply_with_latency(p)
|
|
268
|
+
# Single yield to event loop per batch (IG-535)
|
|
269
|
+
await asyncio.sleep(0)
|
|
270
|
+
|
|
271
|
+
# Final flush for remaining items
|
|
272
|
+
if batcher.has_pending():
|
|
273
|
+
for p in batcher.flush():
|
|
274
|
+
await _apply_with_latency(p)
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
await asyncio.gather(
|
|
278
|
+
pipeline.feed_chunks(chunk_source),
|
|
279
|
+
_apply_batched(),
|
|
280
|
+
)
|
|
281
|
+
finally:
|
|
282
|
+
pipeline.shutdown()
|
|
283
|
+
else:
|
|
284
|
+
# Original non-batched path (for debugging or high-frequency streams)
|
|
285
|
+
async def _apply_all() -> None:
|
|
286
|
+
async for prepared in pipeline.iter_prepared():
|
|
287
|
+
await _apply_with_latency(prepared)
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
await asyncio.gather(
|
|
291
|
+
pipeline.feed_chunks(chunk_source),
|
|
292
|
+
_apply_all(),
|
|
293
|
+
)
|
|
294
|
+
finally:
|
|
295
|
+
pipeline.shutdown()
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
__all__ = [
|
|
299
|
+
"PRIORITY_HIGH",
|
|
300
|
+
"PRIORITY_LOW",
|
|
301
|
+
"PRIORITY_NORMAL",
|
|
302
|
+
"TurnApplyBatcher",
|
|
303
|
+
"TurnEventPipeline",
|
|
304
|
+
"run_turn_pipeline",
|
|
305
|
+
"_DEFAULT_BATCH_SIZE",
|
|
306
|
+
"_DEFAULT_BATCH_DELAY_MS",
|
|
307
|
+
"_DEFAULT_INBOUND_MAXSIZE",
|
|
308
|
+
"_DEFAULT_OUTBOUND_MAXSIZE",
|
|
309
|
+
]
|
|
@@ -192,6 +192,14 @@ class SootheApp(
|
|
|
192
192
|
self._daemon_skills_wire: list[dict[str, Any]] = []
|
|
193
193
|
"""Cached ``skills_list_response`` rows when the TUI uses ``TuiDaemonSession``."""
|
|
194
194
|
|
|
195
|
+
self._preloaded_model_data: (
|
|
196
|
+
tuple[list[tuple[str, str]], str | None, dict[str, dict[str, Any]]] | None
|
|
197
|
+
) = None
|
|
198
|
+
"""Cached ``/model`` selector data from startup ``models_list`` prewarm."""
|
|
199
|
+
|
|
200
|
+
self._wire_credential_map: dict[str, bool | None] | None = None
|
|
201
|
+
"""Provider credential flags paired with ``_preloaded_model_data``."""
|
|
202
|
+
|
|
195
203
|
self._connecting = True
|
|
196
204
|
|
|
197
205
|
self._sandbox_type: str | None = None
|
|
@@ -360,7 +368,6 @@ class SootheApp(
|
|
|
360
368
|
loop_id=self._lc_loop_id,
|
|
361
369
|
mcp_tool_count=self._mcp_tool_count,
|
|
362
370
|
workspace_path=self._cwd,
|
|
363
|
-
connecting=self._connecting,
|
|
364
371
|
id="welcome-banner",
|
|
365
372
|
)
|
|
366
373
|
yield Container(id="messages")
|
|
@@ -565,6 +565,13 @@ class _ExecutionMixin:
|
|
|
565
565
|
AppMessage("Not connected to the daemon; cannot start a new loop.")
|
|
566
566
|
)
|
|
567
567
|
else:
|
|
568
|
+
if self._agent_running:
|
|
569
|
+
await self._interrupt_daemon_agent_turn()
|
|
570
|
+
worker = self._agent_worker
|
|
571
|
+
if worker is not None:
|
|
572
|
+
with suppress(Exception):
|
|
573
|
+
await asyncio.wait_for(worker.wait(), timeout=5.0)
|
|
574
|
+
await self._cleanup_agent_task()
|
|
568
575
|
status_event = await self._daemon_session.new_loop()
|
|
569
576
|
new_loop_id = (
|
|
570
577
|
str(status_event.get("loop_id", "")) or self._session_state.reset_loop()
|
|
@@ -937,6 +944,7 @@ class _ExecutionMixin:
|
|
|
937
944
|
if self._ui_adapter:
|
|
938
945
|
self._ui_adapter.finalize_pending_tools_with_error(f"{error_title}: {display_err}")
|
|
939
946
|
self._ui_adapter.finalize_pending_steps_with_error(f"{error_title}: {display_err}")
|
|
947
|
+
await self._try_recover_goal_completion_from_ledger()
|
|
940
948
|
try:
|
|
941
949
|
await self._mount_message(ErrorMessage(f"{error_title}. {display_err}"))
|
|
942
950
|
except Exception:
|
|
@@ -990,6 +998,35 @@ class _ExecutionMixin:
|
|
|
990
998
|
if not busy and self._pending_messages:
|
|
991
999
|
await self._process_next_from_queue()
|
|
992
1000
|
|
|
1001
|
+
async def _try_recover_goal_completion_from_ledger(self) -> None:
|
|
1002
|
+
"""Mount persisted goal_completion when the stream aborted before the report card."""
|
|
1003
|
+
adapter = self._ui_adapter
|
|
1004
|
+
session = self._daemon_session
|
|
1005
|
+
if adapter is None or session is None or self._session_state is None:
|
|
1006
|
+
return
|
|
1007
|
+
if getattr(adapter, "_goal_completion_mounted_this_turn", False):
|
|
1008
|
+
return
|
|
1009
|
+
loop_id = self._session_state.loop_id
|
|
1010
|
+
if not loop_id:
|
|
1011
|
+
return
|
|
1012
|
+
try:
|
|
1013
|
+
from soothe_cli.tui.widgets.messages.assistant import AssistantMessage
|
|
1014
|
+
|
|
1015
|
+
text = await session.fetch_goal_completion_text(loop_id)
|
|
1016
|
+
if not text:
|
|
1017
|
+
return
|
|
1018
|
+
msg = AssistantMessage(text, id=f"asst-recovered-{loop_id[:8]}")
|
|
1019
|
+
await self._mount_message(msg)
|
|
1020
|
+
await msg.write_initial_content()
|
|
1021
|
+
adapter._goal_completion_mounted_this_turn = True
|
|
1022
|
+
logger.info(
|
|
1023
|
+
"Recovered goal_completion from ledger for loop %s (%d chars)",
|
|
1024
|
+
loop_id[:8],
|
|
1025
|
+
len(text),
|
|
1026
|
+
)
|
|
1027
|
+
except Exception:
|
|
1028
|
+
logger.debug("goal_completion ledger recovery failed", exc_info=True)
|
|
1029
|
+
|
|
993
1030
|
async def _cleanup_agent_task(self) -> None:
|
|
994
1031
|
"""Clean up after agent task completes or is cancelled."""
|
|
995
1032
|
self._agent_running = False
|