aru-code 0.52.0__tar.gz → 0.53.0__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.
- {aru_code-0.52.0/aru_code.egg-info → aru_code-0.53.0}/PKG-INFO +1 -1
- aru_code-0.53.0/aru/__init__.py +1 -0
- aru_code-0.53.0/aru/_debug/__init__.py +6 -0
- aru_code-0.53.0/aru/_debug/analyze_trace.py +328 -0
- aru_code-0.53.0/aru/_debug/loop_tracer.py +329 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/streaming.py +9 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/app.py +139 -2
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/chat.py +51 -16
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/prompt_area.py +7 -3
- {aru_code-0.52.0 → aru_code-0.53.0/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.52.0 → aru_code-0.53.0}/aru_code.egg-info/SOURCES.txt +3 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/pyproject.toml +1 -1
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_chat_scrollable.py +31 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_copy.py +91 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_input_behaviour.py +41 -3
- aru_code-0.52.0/aru/__init__.py +0 -1
- {aru_code-0.52.0 → aru_code-0.53.0}/LICENSE +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/README.md +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/agent_factory.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/agents/base.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/agents/catalog.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/agents/planner.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/cache_patch.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/checkpoints.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/cli.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/commands.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/completers.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/config.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/context.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/display.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/doom_loop.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/events.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/format/__init__.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/format/manager.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/format/runner.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/history_blocks.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/lsp/__init__.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/lsp/client.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/lsp/manager.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/lsp/protocol.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/memory/__init__.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/memory/extractor.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/memory/loader.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/memory/store.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/permissions.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/plugin_cache.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/plugins/__init__.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/plugins/hooks.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/plugins/manager.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/providers.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/runner.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/runtime.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/select.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/session.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/sinks.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tool_policy.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/_diff.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/_shared.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/apply_patch.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/apply_patch_prompt.txt +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/codebase.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/delegate.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/delegate_prompt.txt +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/file_ops.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/lsp.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/memory_tool.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/plan_mode.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/registry.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/search.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/shell.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/skill.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/web.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/worktree.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/__init__.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/log_bridge.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/notifications.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/sanitize.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/screens/__init__.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/screens/choice.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/screens/confirm.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/screens/keymap.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/screens/search.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/screens/session_picker.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/screens/text_input.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/sinks.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/slash_bridge.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/themes.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/ui.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/__init__.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/completer.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/context_pane.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/file_link.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/header.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/inline_choice.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/loaded_pane.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/prompt_queue.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/status.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/subagent_panel.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/tasklist_panel.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/thinking.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/tools.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru/ui.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/setup.cfg +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_agents_md_coverage.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_apply_patch.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_async_tool_permission.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cache_patch_metrics.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cache_patch_stop_reason.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_catalog.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_checkpoints.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cli.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_codebase.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_config.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_context.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_context_pane.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cwd_awareness.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_delegate.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_doom_loop.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_events_backward_compat.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_events_schema.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_fork_ctx_concurrency.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_format.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_guardrails_scenarios.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_invoke_skill.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_invoked_skills.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_loaded_pane_path.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_lsp.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_lsp_rename.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_main.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_markdown_to_text.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_mcp_health.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_memory.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_memory_tool.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_microcompact.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_permissions.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_plan_mode_refactor.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_plugin_cache.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_plugin_errors.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_plugin_hooks_v2.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_plugins.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_providers.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_ranker.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_reasoning.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_runner_interrupt.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_runner_recovery.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_runtime.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_select.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_session_free_cost.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_skill_disallowed_tools.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_status_breakdown.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_status_cost.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_streaming_sink.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_subagent_tool_events.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tasklist.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_thread_tool_timeout.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tool_policy.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_truncation_marker.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_app_boot.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_bindings.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_bus_flow.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_chat.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_chat_adversarial.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_completer.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_completer_dynamic.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_error_display.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_file_link.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_layer12_recovery.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_layer13_recovery.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_mention_expand.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_modals.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_mode_cycle.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_native_selection.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_permission_flow.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_plan_task_render.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_prompt_queue.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_shell_bang.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_sidebar_toggle.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_slash_bridge.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_slash_model.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_snapshot_smoke.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_subagent_panel.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_theme.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_thinking_and_boot.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_widgets_visual.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_ui_adapter.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_worktree.py +0 -0
- {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_worktree_session_restore.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.53.0"
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Analyze ``~/.aru/loop-trace.log`` and answer the decision tree from
|
|
2
|
+
``docs/aru/2026-04-30-ctrlc-streaming-plan.md`` Fase 3.
|
|
3
|
+
|
|
4
|
+
Usage::
|
|
5
|
+
|
|
6
|
+
python -m aru._debug.analyze_trace [path]
|
|
7
|
+
|
|
8
|
+
Default path: ``~/.aru/loop-trace.log``. Pass an explicit path to
|
|
9
|
+
analyse a different file (e.g. one shipped from another machine).
|
|
10
|
+
|
|
11
|
+
The analyser is intentionally simple — `awk`-style line parsing,
|
|
12
|
+
bucketed counters, and a fixed set of questions. Any pattern more
|
|
13
|
+
complex than what this script captures should be added as a new
|
|
14
|
+
section here, not a separate ad-hoc script.
|
|
15
|
+
|
|
16
|
+
Output sections:
|
|
17
|
+
|
|
18
|
+
STATISTICS — summary of every event kind that appeared, with
|
|
19
|
+
count and (where relevant) max duration.
|
|
20
|
+
HOTSPOTS — top-10 ``loop_blocked`` entries sorted by gap.
|
|
21
|
+
CTRL_C — for each Ctrl+C key press detected at
|
|
22
|
+
``driver.process_message``, the latency to
|
|
23
|
+
``app._post_message`` and ``action_ctrl_c``.
|
|
24
|
+
VERDICT — direct readout of the decision tree (P1/P2/P3 or
|
|
25
|
+
continue-investigating).
|
|
26
|
+
|
|
27
|
+
Designed to be idempotent and zero-side-effect — never writes back to
|
|
28
|
+
the log.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
from collections import defaultdict
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Event:
|
|
41
|
+
ts_ms: int
|
|
42
|
+
thread: str
|
|
43
|
+
name: str
|
|
44
|
+
detail: str
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def parse(cls, line: str) -> "Event | None":
|
|
48
|
+
if line.startswith("#") or not line.strip():
|
|
49
|
+
return None
|
|
50
|
+
parts = line.rstrip("\n").split(",", 3)
|
|
51
|
+
if len(parts) < 3:
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
ts = int(parts[0])
|
|
55
|
+
except ValueError:
|
|
56
|
+
return None
|
|
57
|
+
thread = parts[1]
|
|
58
|
+
name = parts[2]
|
|
59
|
+
detail = parts[3] if len(parts) > 3 else ""
|
|
60
|
+
return cls(ts_ms=ts, thread=thread, name=name, detail=detail)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_kv(detail: str) -> dict[str, str]:
|
|
64
|
+
out: dict[str, str] = {}
|
|
65
|
+
for tok in detail.split():
|
|
66
|
+
if "=" in tok:
|
|
67
|
+
k, _, v = tok.partition("=")
|
|
68
|
+
out[k] = v
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _detail_int(detail: str, key: str) -> int | None:
|
|
73
|
+
kv = _parse_kv(detail)
|
|
74
|
+
if key not in kv:
|
|
75
|
+
return None
|
|
76
|
+
try:
|
|
77
|
+
return int(float(kv[key]))
|
|
78
|
+
except ValueError:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Stats:
|
|
84
|
+
counts: dict[str, int] = field(default_factory=lambda: defaultdict(int))
|
|
85
|
+
max_gap_ms_loop: int = 0
|
|
86
|
+
loop_blocked_top: list[Event] = field(default_factory=list)
|
|
87
|
+
finalize_render_max_ms: int = 0
|
|
88
|
+
finalize_render_calls: int = 0
|
|
89
|
+
finalize_render_total_ms: int = 0
|
|
90
|
+
stream_bursts: list[Event] = field(default_factory=list)
|
|
91
|
+
ctrl_c_press: list[Event] = field(default_factory=list)
|
|
92
|
+
post_messages_ctrl_c: list[Event] = field(default_factory=list)
|
|
93
|
+
action_ctrl_c: list[Event] = field(default_factory=list)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_ctrl_c(detail: str) -> bool:
|
|
97
|
+
kv = _parse_kv(detail)
|
|
98
|
+
return kv.get("key") == "ctrl+c"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def collect(events: list[Event]) -> Stats:
|
|
102
|
+
s = Stats()
|
|
103
|
+
for e in events:
|
|
104
|
+
s.counts[e.name] += 1
|
|
105
|
+
if e.name == "loop_blocked":
|
|
106
|
+
gap = _detail_int(e.detail, "gap_ms") or 0
|
|
107
|
+
s.max_gap_ms_loop = max(s.max_gap_ms_loop, gap)
|
|
108
|
+
s.loop_blocked_top.append(e)
|
|
109
|
+
elif e.name == "loop_tick":
|
|
110
|
+
gap = _detail_int(e.detail, "gap_ms") or 0
|
|
111
|
+
s.max_gap_ms_loop = max(s.max_gap_ms_loop, gap)
|
|
112
|
+
elif e.name == "finalize_render":
|
|
113
|
+
dt = _detail_int(e.detail, "dt_ms") or 0
|
|
114
|
+
s.finalize_render_calls += 1
|
|
115
|
+
s.finalize_render_total_ms += dt
|
|
116
|
+
s.finalize_render_max_ms = max(s.finalize_render_max_ms, dt)
|
|
117
|
+
elif e.name == "stream.event_burst":
|
|
118
|
+
s.stream_bursts.append(e)
|
|
119
|
+
elif e.name == "driver.process_message":
|
|
120
|
+
if _is_ctrl_c(e.detail):
|
|
121
|
+
s.ctrl_c_press.append(e)
|
|
122
|
+
elif e.name == "app._post_message":
|
|
123
|
+
if _is_ctrl_c(e.detail):
|
|
124
|
+
s.post_messages_ctrl_c.append(e)
|
|
125
|
+
elif e.name == "action_ctrl_c":
|
|
126
|
+
s.action_ctrl_c.append(e)
|
|
127
|
+
|
|
128
|
+
s.loop_blocked_top.sort(
|
|
129
|
+
key=lambda e: _detail_int(e.detail, "gap_ms") or 0, reverse=True
|
|
130
|
+
)
|
|
131
|
+
s.loop_blocked_top = s.loop_blocked_top[:10]
|
|
132
|
+
return s
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _correlate_ctrl_c(s: Stats) -> list[dict]:
|
|
136
|
+
"""Match each ``driver.process_message ctrl+c`` to the next
|
|
137
|
+
``app._post_message ctrl+c`` and ``action_ctrl_c`` after it.
|
|
138
|
+
|
|
139
|
+
Returns one dict per press with latencies in ms.
|
|
140
|
+
"""
|
|
141
|
+
out: list[dict] = []
|
|
142
|
+
for press in s.ctrl_c_press:
|
|
143
|
+
next_post = next(
|
|
144
|
+
(p for p in s.post_messages_ctrl_c if p.ts_ms >= press.ts_ms),
|
|
145
|
+
None,
|
|
146
|
+
)
|
|
147
|
+
next_action = next(
|
|
148
|
+
(a for a in s.action_ctrl_c if a.ts_ms >= press.ts_ms),
|
|
149
|
+
None,
|
|
150
|
+
)
|
|
151
|
+
out.append(
|
|
152
|
+
{
|
|
153
|
+
"press_ms": press.ts_ms,
|
|
154
|
+
"thread_seen": press.thread,
|
|
155
|
+
"post_lag_ms": (
|
|
156
|
+
next_post.ts_ms - press.ts_ms if next_post else None
|
|
157
|
+
),
|
|
158
|
+
"action_lag_ms": (
|
|
159
|
+
next_action.ts_ms - press.ts_ms if next_action else None
|
|
160
|
+
),
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _verdict(s: Stats, presses: list[dict]) -> list[str]:
|
|
167
|
+
out: list[str] = []
|
|
168
|
+
if not presses:
|
|
169
|
+
out.append("No Ctrl+C key events recorded.")
|
|
170
|
+
out.append("-> Either the user did not press Ctrl+C during this trace,")
|
|
171
|
+
out.append(" or the Textual input thread (P1) is wedged before")
|
|
172
|
+
out.append(" ``Driver.process_message`` is reached. If the user did")
|
|
173
|
+
out.append(" press Ctrl+C: P1 — investigate EventMonitor / ConIn read.")
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
for i, p in enumerate(presses, 1):
|
|
177
|
+
out.append(f"Press #{i} at ts={p['press_ms']}ms (thread={p['thread_seen']}):")
|
|
178
|
+
post = p["post_lag_ms"]
|
|
179
|
+
action = p["action_lag_ms"]
|
|
180
|
+
|
|
181
|
+
if post is None:
|
|
182
|
+
out.append(
|
|
183
|
+
" P2/P3 — driver saw the key, but ``app._post_message`` "
|
|
184
|
+
"never fired."
|
|
185
|
+
)
|
|
186
|
+
out.append(
|
|
187
|
+
" -> loop saturated for the rest of the trace. Look at "
|
|
188
|
+
"loop_blocked HOTSPOTS at this timestamp."
|
|
189
|
+
)
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
out.append(f" press -> app._post_message: {post}ms")
|
|
193
|
+
if post > 500:
|
|
194
|
+
out.append(
|
|
195
|
+
" P2 — loop took >500ms to drain the posted callback. "
|
|
196
|
+
"Saturation is the dominant cause."
|
|
197
|
+
)
|
|
198
|
+
elif post > 50:
|
|
199
|
+
out.append(
|
|
200
|
+
" Borderline — pump latency >50ms; check loop_blocked "
|
|
201
|
+
"near this timestamp."
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
out.append(" pump latency healthy.")
|
|
205
|
+
|
|
206
|
+
if action is None:
|
|
207
|
+
out.append(
|
|
208
|
+
" P3 — pump received but action_ctrl_c never dispatched. "
|
|
209
|
+
"Check Screen.dispatch."
|
|
210
|
+
)
|
|
211
|
+
continue
|
|
212
|
+
out.append(f" press -> action_ctrl_c: {action}ms")
|
|
213
|
+
if action > 500:
|
|
214
|
+
out.append(
|
|
215
|
+
" P3 — pump dispatch is the bottleneck (likely behind a "
|
|
216
|
+
"queue of expensive events)."
|
|
217
|
+
)
|
|
218
|
+
elif action - (post or 0) > 100:
|
|
219
|
+
out.append(
|
|
220
|
+
" Pump->action handoff is slow; suspect heavy event "
|
|
221
|
+
"ahead of Key in the queue."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return out
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _suggest_fix(s: Stats) -> list[str]:
|
|
228
|
+
out: list[str] = []
|
|
229
|
+
if s.finalize_render_max_ms > 200:
|
|
230
|
+
out.append(
|
|
231
|
+
f"finalize_render max {s.finalize_render_max_ms}ms across "
|
|
232
|
+
f"{s.finalize_render_calls} calls "
|
|
233
|
+
f"(total {s.finalize_render_total_ms}ms)."
|
|
234
|
+
)
|
|
235
|
+
out.append(
|
|
236
|
+
"-> C3 candidate: move finalize_render off-thread "
|
|
237
|
+
"(asyncio.to_thread). One-file change in chat.py."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
fast_bursts = [
|
|
241
|
+
b
|
|
242
|
+
for b in s.stream_bursts
|
|
243
|
+
if (_detail_int(b.detail, "dt_ms") or 999) < 5
|
|
244
|
+
]
|
|
245
|
+
if fast_bursts:
|
|
246
|
+
out.append(
|
|
247
|
+
f"{len(fast_bursts)} stream bursts of 16 events in <5ms — "
|
|
248
|
+
f"hot-loop without yield."
|
|
249
|
+
)
|
|
250
|
+
out.append(
|
|
251
|
+
"-> C1 candidate: ``await asyncio.sleep(0)`` every N events "
|
|
252
|
+
"in streaming.py. One-line change."
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if not out:
|
|
256
|
+
out.append(
|
|
257
|
+
"No obvious culprit in C1/C3. New round of instrumentation "
|
|
258
|
+
"needed (Compositor render hooks, paint cost)."
|
|
259
|
+
)
|
|
260
|
+
return out
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def main(argv: list[str]) -> int:
|
|
264
|
+
# Windows cp1252 stdout chokes on em-dashes / arrows in our prose
|
|
265
|
+
# — switch to UTF-8 so the analyser can run anywhere without
|
|
266
|
+
# truncating the report.
|
|
267
|
+
try:
|
|
268
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
path = argv[1] if len(argv) > 1 else os.path.expanduser("~/.aru/loop-trace.log")
|
|
272
|
+
if not os.path.exists(path):
|
|
273
|
+
print(f"trace file not found: {path}", file=sys.stderr)
|
|
274
|
+
return 2
|
|
275
|
+
with open(path, encoding="utf-8") as fh:
|
|
276
|
+
events = []
|
|
277
|
+
for line in fh:
|
|
278
|
+
ev = Event.parse(line)
|
|
279
|
+
if ev is not None:
|
|
280
|
+
events.append(ev)
|
|
281
|
+
|
|
282
|
+
if not events:
|
|
283
|
+
print("trace file has no events", file=sys.stderr)
|
|
284
|
+
return 2
|
|
285
|
+
|
|
286
|
+
s = collect(events)
|
|
287
|
+
presses = _correlate_ctrl_c(s)
|
|
288
|
+
|
|
289
|
+
print("=" * 72)
|
|
290
|
+
print(f"Trace: {path}")
|
|
291
|
+
print(f"Events: {len(events)} ({events[0].ts_ms}ms -> {events[-1].ts_ms}ms)")
|
|
292
|
+
print("=" * 72)
|
|
293
|
+
|
|
294
|
+
print("\n--- STATISTICS ---")
|
|
295
|
+
for name in sorted(s.counts):
|
|
296
|
+
print(f" {name:<32} {s.counts[name]}")
|
|
297
|
+
print(f" max loop gap: {s.max_gap_ms_loop}ms")
|
|
298
|
+
print(
|
|
299
|
+
f" finalize_render: max={s.finalize_render_max_ms}ms "
|
|
300
|
+
f"total={s.finalize_render_total_ms}ms "
|
|
301
|
+
f"calls={s.finalize_render_calls}"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
print("\n--- HOTSPOTS (top 10 loop_blocked) ---")
|
|
305
|
+
for e in s.loop_blocked_top:
|
|
306
|
+
print(f" ts={e.ts_ms:>8}ms thread={e.thread:<20} {e.detail}")
|
|
307
|
+
|
|
308
|
+
print("\n--- CTRL_C ---")
|
|
309
|
+
for p in presses:
|
|
310
|
+
print(
|
|
311
|
+
f" ts={p['press_ms']:>8}ms "
|
|
312
|
+
f"post_lag={p['post_lag_ms']}ms "
|
|
313
|
+
f"action_lag={p['action_lag_ms']}ms"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
print("\n--- VERDICT ---")
|
|
317
|
+
for line in _verdict(s, presses):
|
|
318
|
+
print(f" {line}")
|
|
319
|
+
|
|
320
|
+
print("\n--- SUGGESTED FIX ---")
|
|
321
|
+
for line in _suggest_fix(s):
|
|
322
|
+
print(f" {line}")
|
|
323
|
+
|
|
324
|
+
return 0
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
sys.exit(main(sys.argv))
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Loop-saturation tracer for Ctrl+C-during-streaming investigation.
|
|
2
|
+
|
|
3
|
+
Activate with ``ARU_DEBUG_LOOP=1``. When off, every entry point is a
|
|
4
|
+
single ``if not _ENABLED: return`` so production cost is one branch.
|
|
5
|
+
|
|
6
|
+
Output: ``~/.aru/loop-trace.log`` — append-only CSV with one event per
|
|
7
|
+
line, columns ``timestamp_ms,thread,event,detail``. Suitable for
|
|
8
|
+
``awk`` / pandas / spreadsheet import without parsing.
|
|
9
|
+
|
|
10
|
+
Six instrumentation points (see ``docs/aru/2026-04-30-ctrlc-streaming-plan.md``
|
|
11
|
+
Fase 1):
|
|
12
|
+
|
|
13
|
+
A. ``loop_tick`` / ``loop_blocked`` — heartbeat scheduled on the
|
|
14
|
+
main asyncio loop at 20 Hz. Gap > 200 ms yields a ``loop_blocked``
|
|
15
|
+
entry — direct evidence the loop was unable to drain a callback
|
|
16
|
+
for that long, regardless of why.
|
|
17
|
+
|
|
18
|
+
B. ``driver.process_message`` — every message the Textual input
|
|
19
|
+
thread parses, before it's posted to the App pump. Confirms the
|
|
20
|
+
input thread is alive and saw the keystroke. Logged from the
|
|
21
|
+
``textual-input`` thread.
|
|
22
|
+
|
|
23
|
+
C. ``app._post_message`` — every message the App pump dequeues.
|
|
24
|
+
Confirms the ``run_coroutine_threadsafe`` callback that B
|
|
25
|
+
scheduled actually drained on the loop. Logged from the loop
|
|
26
|
+
thread.
|
|
27
|
+
|
|
28
|
+
D. ``action_ctrl_c`` — entry of the App's Ctrl+C handler. Confirms
|
|
29
|
+
binding dispatch reached our action.
|
|
30
|
+
|
|
31
|
+
E. ``stream.event_burst`` — sampled every N events inside the
|
|
32
|
+
``async for event in agent.arun(...)`` loop. Detects rajadas of
|
|
33
|
+
events arriving without a yield between them.
|
|
34
|
+
|
|
35
|
+
F. ``finalize_render`` — duration of the synchronous full-buffer
|
|
36
|
+
markdown re-parse on the loop thread.
|
|
37
|
+
|
|
38
|
+
Also exposed: ``trace(event, detail)`` so ad-hoc probes can be added
|
|
39
|
+
during diagnosis without recompiling.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import os
|
|
45
|
+
import threading
|
|
46
|
+
import time
|
|
47
|
+
from typing import Any
|
|
48
|
+
|
|
49
|
+
# ── Activation gate ──────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
_ENABLED: bool = bool(os.environ.get("ARU_DEBUG_LOOP"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_enabled() -> bool:
|
|
55
|
+
return _ENABLED
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── File handle (lazy, line-buffered) ────────────────────────────────
|
|
59
|
+
|
|
60
|
+
_TRACE_PATH: str = os.path.expanduser("~/.aru/loop-trace.log")
|
|
61
|
+
_LOCK = threading.Lock()
|
|
62
|
+
_FILE: Any = None
|
|
63
|
+
_T0: float = time.monotonic()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _now_ms() -> int:
|
|
67
|
+
return int((time.monotonic() - _T0) * 1000)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def trace(event: str, detail: str = "") -> None:
|
|
71
|
+
"""Append one event to the trace log. No-op when disabled.
|
|
72
|
+
|
|
73
|
+
Format: ``ts_ms,thread,event,detail\\n``. Detail is allowed to
|
|
74
|
+
contain commas — analysis tools should split on the first three
|
|
75
|
+
commas only.
|
|
76
|
+
"""
|
|
77
|
+
if not _ENABLED:
|
|
78
|
+
return
|
|
79
|
+
try:
|
|
80
|
+
ts_ms = _now_ms()
|
|
81
|
+
thread = threading.current_thread().name
|
|
82
|
+
line = f"{ts_ms},{thread},{event},{detail}\n"
|
|
83
|
+
with _LOCK:
|
|
84
|
+
global _FILE
|
|
85
|
+
if _FILE is None:
|
|
86
|
+
os.makedirs(os.path.dirname(_TRACE_PATH), exist_ok=True)
|
|
87
|
+
# Line-buffered so a hard kill still leaves a usable
|
|
88
|
+
# trace on disk. ``buffering=1`` is line-buffered for
|
|
89
|
+
# text mode in Python.
|
|
90
|
+
_FILE = open(_TRACE_PATH, "a", encoding="utf-8", buffering=1)
|
|
91
|
+
_FILE.write(
|
|
92
|
+
f"# === session start === pid={os.getpid()} "
|
|
93
|
+
f"t0_monotonic={_T0:.3f}\n"
|
|
94
|
+
)
|
|
95
|
+
_FILE.write(line)
|
|
96
|
+
except Exception:
|
|
97
|
+
# Never let the tracer crash the app.
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── (B/C) Textual monkey-patches ─────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
_patches_installed = False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def install_textual_patches() -> None:
|
|
107
|
+
"""Patch ``Driver.process_message`` and ``App._post_message``.
|
|
108
|
+
|
|
109
|
+
Idempotent — only patches once per process. Patches the *class*,
|
|
110
|
+
so it covers all driver/app instances created later. Safe to call
|
|
111
|
+
multiple times.
|
|
112
|
+
|
|
113
|
+
The patched functions log to the tracer then delegate to the
|
|
114
|
+
original. They never raise — a buggy tracer must not break the
|
|
115
|
+
app.
|
|
116
|
+
"""
|
|
117
|
+
global _patches_installed
|
|
118
|
+
if not _ENABLED or _patches_installed:
|
|
119
|
+
return
|
|
120
|
+
try:
|
|
121
|
+
from textual.driver import Driver
|
|
122
|
+
from textual.app import App
|
|
123
|
+
except Exception:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
_orig_pm = Driver.process_message
|
|
128
|
+
|
|
129
|
+
def _patched_pm(self, message):
|
|
130
|
+
try:
|
|
131
|
+
trace(
|
|
132
|
+
"driver.process_message",
|
|
133
|
+
f"type={type(message).__name__} "
|
|
134
|
+
f"key={getattr(message, 'key', '-')}",
|
|
135
|
+
)
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
return _orig_pm(self, message)
|
|
139
|
+
|
|
140
|
+
Driver.process_message = _patched_pm
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
_orig_post = App._post_message
|
|
146
|
+
|
|
147
|
+
async def _patched_post(self, message):
|
|
148
|
+
try:
|
|
149
|
+
trace(
|
|
150
|
+
"app._post_message",
|
|
151
|
+
f"type={type(message).__name__} "
|
|
152
|
+
f"key={getattr(message, 'key', '-')}",
|
|
153
|
+
)
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
return await _orig_post(self, message)
|
|
157
|
+
|
|
158
|
+
App._post_message = _patched_post
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
# WriterThread.stop instrumentation — investigates the Ctrl+Q
|
|
163
|
+
# "summary appears but terminal does not release" symptom. ``stop()``
|
|
164
|
+
# internally does ``put(None) + join()``; if the queue has thousands
|
|
165
|
+
# of pending writes, ``join()`` blocks until ConPTY drains them all,
|
|
166
|
+
# which is exactly the wedge shape the user reports.
|
|
167
|
+
try:
|
|
168
|
+
from textual.drivers._writer_thread import WriterThread
|
|
169
|
+
_orig_stop = WriterThread.stop
|
|
170
|
+
|
|
171
|
+
def _patched_stop(self):
|
|
172
|
+
qsize = self._queue.qsize() if hasattr(self._queue, "qsize") else -1
|
|
173
|
+
trace("writer_thread.stop", f"begin qsize={qsize}")
|
|
174
|
+
try:
|
|
175
|
+
return _orig_stop(self)
|
|
176
|
+
finally:
|
|
177
|
+
trace("writer_thread.stop", "end")
|
|
178
|
+
|
|
179
|
+
WriterThread.stop = _patched_stop
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
# Sniff every WriterThread.write call for escapes that change the
|
|
184
|
+
# terminal's mode (alt-screen, mouse, bracketed paste, etc.). The
|
|
185
|
+
# "TUI invadida pelo terminal" symptom is consistent with one of
|
|
186
|
+
# these escapes leaking from a non-Textual source while mouse
|
|
187
|
+
# tracking remains enabled. Logging the *issuer* lets us pin which
|
|
188
|
+
# site sent ``\x1b[?1049l`` (leave alt-screen) at runtime.
|
|
189
|
+
#
|
|
190
|
+
# The sniff is pattern-based — only a handful of escapes are
|
|
191
|
+
# logged, so noise is bounded even at high write rates.
|
|
192
|
+
try:
|
|
193
|
+
import re as _re
|
|
194
|
+
_MODE_RE = _re.compile(
|
|
195
|
+
r"\x1b\[\?(1049|1000|1003|1006|1015|1004|2004|25)([hl])"
|
|
196
|
+
)
|
|
197
|
+
_orig_write = WriterThread.write
|
|
198
|
+
|
|
199
|
+
def _patched_write(self, text):
|
|
200
|
+
try:
|
|
201
|
+
if isinstance(text, str):
|
|
202
|
+
for m in _MODE_RE.finditer(text):
|
|
203
|
+
trace(
|
|
204
|
+
"term_mode_escape",
|
|
205
|
+
f"mode={m.group(1)} action={m.group(2)} "
|
|
206
|
+
f"sample={text[max(0, m.start()-8):m.end()+8]!r}",
|
|
207
|
+
)
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
return _orig_write(self, text)
|
|
211
|
+
|
|
212
|
+
WriterThread.write = _patched_write
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
_patches_installed = True
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ── (A) Heartbeat ────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
_heartbeat_state: dict[str, Any] = {"last": 0.0, "running": False}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def start_heartbeat(loop) -> None:
|
|
225
|
+
"""Begin a 20 Hz heartbeat on *loop*.
|
|
226
|
+
|
|
227
|
+
Each tick measures the wall-clock gap since the previous tick. A
|
|
228
|
+
healthy loop ticks every ~50 ms (the call_later interval); a
|
|
229
|
+
saturated loop ticks late, and the gap measures exactly how long
|
|
230
|
+
the loop was unable to run a callback.
|
|
231
|
+
|
|
232
|
+
Gap > 200 ms emits ``loop_blocked``; otherwise ``loop_tick``. The
|
|
233
|
+
heartbeat keeps itself alive via recursive ``call_later`` and
|
|
234
|
+
stops when ``stop_heartbeat`` flips the running flag.
|
|
235
|
+
"""
|
|
236
|
+
if not _ENABLED:
|
|
237
|
+
return
|
|
238
|
+
_heartbeat_state["last"] = time.monotonic()
|
|
239
|
+
_heartbeat_state["running"] = True
|
|
240
|
+
|
|
241
|
+
def _tick() -> None:
|
|
242
|
+
if not _heartbeat_state["running"]:
|
|
243
|
+
return
|
|
244
|
+
now = time.monotonic()
|
|
245
|
+
gap_ms = (now - _heartbeat_state["last"]) * 1000
|
|
246
|
+
_heartbeat_state["last"] = now
|
|
247
|
+
if gap_ms > 200:
|
|
248
|
+
trace("loop_blocked", f"gap_ms={gap_ms:.0f}")
|
|
249
|
+
else:
|
|
250
|
+
trace("loop_tick", f"gap_ms={gap_ms:.0f}")
|
|
251
|
+
try:
|
|
252
|
+
loop.call_later(0.05, _tick)
|
|
253
|
+
except Exception:
|
|
254
|
+
_heartbeat_state["running"] = False
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
loop.call_later(0.05, _tick)
|
|
258
|
+
except Exception:
|
|
259
|
+
_heartbeat_state["running"] = False
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def stop_heartbeat() -> None:
|
|
263
|
+
_heartbeat_state["running"] = False
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ── (E) Stream hot-loop sampler ──────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
class StreamSampler:
|
|
269
|
+
"""Sampler used inside ``streaming.run_stream``'s ``async for``.
|
|
270
|
+
|
|
271
|
+
Counts events and emits ``stream.event_burst`` every ``every`` ticks
|
|
272
|
+
with the wall-clock duration since the previous emission. A burst
|
|
273
|
+
of 16 events in <5 ms means the loop processed 16 events back-to-back
|
|
274
|
+
with no IO yield — direct evidence of hot-loop saturation.
|
|
275
|
+
|
|
276
|
+
Used as a context-light counter (one int + one float per call); the
|
|
277
|
+
log emission is gated by ``every`` so the trace stays readable.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
__slots__ = ("_n", "_t0", "_every")
|
|
281
|
+
|
|
282
|
+
def __init__(self, every: int = 16) -> None:
|
|
283
|
+
self._n = 0
|
|
284
|
+
self._t0 = time.monotonic()
|
|
285
|
+
self._every = every
|
|
286
|
+
|
|
287
|
+
def tick(self, event_kind: str = "") -> None:
|
|
288
|
+
if not _ENABLED:
|
|
289
|
+
return
|
|
290
|
+
self._n += 1
|
|
291
|
+
if self._n % self._every == 0:
|
|
292
|
+
now = time.monotonic()
|
|
293
|
+
dt_ms = (now - self._t0) * 1000
|
|
294
|
+
trace(
|
|
295
|
+
"stream.event_burst",
|
|
296
|
+
f"n={self._n} dt_ms={dt_ms:.0f} kind={event_kind}",
|
|
297
|
+
)
|
|
298
|
+
self._t0 = now
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ── (F) finalize_render timer (helper) ───────────────────────────────
|
|
302
|
+
|
|
303
|
+
class TimedSection:
|
|
304
|
+
"""Context manager that logs the duration of a sync block.
|
|
305
|
+
|
|
306
|
+
Used in ``finalize_render`` and any other place we suspect of
|
|
307
|
+
blocking the loop synchronously. Emits ``<event> dt_ms=...`` on
|
|
308
|
+
exit even if the block raised, so the duration is recorded for
|
|
309
|
+
both success and failure paths.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
__slots__ = ("_event", "_detail", "_t0")
|
|
313
|
+
|
|
314
|
+
def __init__(self, event: str, detail: str = "") -> None:
|
|
315
|
+
self._event = event
|
|
316
|
+
self._detail = detail
|
|
317
|
+
self._t0 = 0.0
|
|
318
|
+
|
|
319
|
+
def __enter__(self) -> "TimedSection":
|
|
320
|
+
if _ENABLED:
|
|
321
|
+
self._t0 = time.monotonic()
|
|
322
|
+
return self
|
|
323
|
+
|
|
324
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
325
|
+
if not _ENABLED:
|
|
326
|
+
return
|
|
327
|
+
dt_ms = (time.monotonic() - self._t0) * 1000
|
|
328
|
+
suffix = f" exc={exc_type.__name__}" if exc_type is not None else ""
|
|
329
|
+
trace(self._event, f"{self._detail} dt_ms={dt_ms:.0f}{suffix}")
|
|
@@ -199,11 +199,20 @@ async def run_stream(
|
|
|
199
199
|
doom_loop_detector = DoomLoopDetector()
|
|
200
200
|
import time as _time
|
|
201
201
|
|
|
202
|
+
# Loop-saturation tracer hook (off unless ``ARU_DEBUG_LOOP=1``).
|
|
203
|
+
# Counts events as they're processed and emits ``stream.event_burst``
|
|
204
|
+
# periodically so we can detect rajadas with no IO yield in between
|
|
205
|
+
# — the suspected hot-loop pattern that strands Ctrl+C during
|
|
206
|
+
# streaming. See ``docs/aru/2026-04-30-ctrlc-streaming-plan.md``.
|
|
207
|
+
from aru._debug.loop_tracer import StreamSampler as _StreamSampler
|
|
208
|
+
_stream_sampler = _StreamSampler(every=16)
|
|
209
|
+
|
|
202
210
|
while True:
|
|
203
211
|
reset_last_stop_reason()
|
|
204
212
|
_stall_counter = 0
|
|
205
213
|
|
|
206
214
|
async for event in agent.arun(current_input, **arun_kwargs):
|
|
215
|
+
_stream_sampler.tick(type(event).__name__)
|
|
207
216
|
if isinstance(event, RunOutput):
|
|
208
217
|
run_output = event
|
|
209
218
|
state.run_output = event
|