cli-agent-runner 0.2.10__tar.gz → 0.2.11__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.
Files changed (276) hide show
  1. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/.vulture-whitelist.py +1 -0
  2. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/CHANGELOG.md +18 -0
  3. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/PKG-INFO +1 -1
  4. cli_agent_runner-0.2.11/agent_runner/_throttle.py +372 -0
  5. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/_version.py +2 -2
  6. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/agent_runtime.py +9 -7
  7. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/api.py +16 -1
  8. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/api_types.py +3 -0
  9. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/cli/common.py +1 -1
  10. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/cli/round_cmd.py +7 -2
  11. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/cli/serve_cmd.py +162 -58
  12. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/config.py +9 -0
  13. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/defenses.py +3 -2
  14. cli_agent_runner-0.2.11/agent_runner/lifecycle.py +101 -0
  15. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/monitor.py +3 -1
  16. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/runner.py +14 -64
  17. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/service_unit.py +9 -1
  18. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/docs/configuration.md +8 -5
  19. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/docs/migrations/0.2.md +29 -0
  20. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/docs/plugins.md +2 -2
  21. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/docs/runbook.md +30 -18
  22. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/_clock.py +5 -0
  23. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/integration/test_fresh_eyes_signal.py +3 -0
  24. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/integration/test_substrate_fingerprint.py +4 -0
  25. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/integration/test_transient_error_backoff.py +3 -0
  26. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_architecture.py +14 -2
  27. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_atomic_write_enforced.py +3 -0
  28. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_doc_test_pointers_exist.py +14 -1
  29. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_module_boundaries.py +48 -37
  30. cli_agent_runner-0.2.11/tests/invariants/test_no_vacuous_invariants.py +41 -0
  31. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_peek_schema_version.py +7 -2
  32. cli_agent_runner-0.2.11/tests/invariants/test_stash_uses_sha_not_index.py +54 -0
  33. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_agent_runtime_grace.py +33 -0
  34. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_api_observation.py +54 -1
  35. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_events.py +1 -1
  36. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_exit_cause.py +10 -2
  37. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_lifecycle.py +23 -0
  38. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_monitor_detectors.py +19 -0
  39. cli_agent_runner-0.2.11/tests/unit/test_round_cmd.py +52 -0
  40. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_runner_throttle.py +206 -15
  41. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_serve_config_broken.py +3 -3
  42. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_serve_crash_loop.py +7 -2
  43. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_serve_phase_schedule.py +125 -26
  44. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_service_unit.py +11 -1
  45. cli_agent_runner-0.2.10/agent_runner/_throttle.py +0 -220
  46. cli_agent_runner-0.2.10/agent_runner/lifecycle.py +0 -67
  47. cli_agent_runner-0.2.10/tests/invariants/test_stash_uses_sha_not_index.py +0 -21
  48. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/.codecov.yml +0 -0
  49. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  50. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  51. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  52. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  53. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/.github/workflows/ci.yml +0 -0
  54. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/.github/workflows/release.yml +0 -0
  55. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/.gitignore +0 -0
  56. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/BACKLOG.md +0 -0
  57. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/CODE_OF_CONDUCT.md +0 -0
  58. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/CONTRIBUTING.md +0 -0
  59. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/LICENSE +0 -0
  60. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/README.md +0 -0
  61. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/README.zh.md +0 -0
  62. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/SECURITY.md +0 -0
  63. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/__init__.py +0 -0
  64. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/_docgen.py +0 -0
  65. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/_emit.py +0 -0
  66. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/_redact.py +0 -0
  67. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/_registry.py +0 -0
  68. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/_substrate.py +0 -0
  69. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/builtin_plugins/__init__.py +0 -0
  70. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/builtin_plugins/_constants.py +0 -0
  71. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/builtin_plugins/claude_rate_limit.py +0 -0
  72. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/builtin_plugins/codewhale.py +0 -0
  73. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/builtin_plugins/default_dirty_handler.py +0 -0
  74. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/builtin_plugins/gemini.py +0 -0
  75. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/builtin_plugins/kimi.py +0 -0
  76. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/builtin_plugins/pi.py +0 -0
  77. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/cli/__init__.py +0 -0
  78. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/cli/__main__.py +0 -0
  79. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/cli/events_cmd.py +0 -0
  80. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/cli/init_cmd.py +0 -0
  81. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/cli/install_cmd.py +0 -0
  82. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/cli/migrate_cmd.py +0 -0
  83. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/cli/monitor_cmd.py +0 -0
  84. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/cli/peek_cmd.py +0 -0
  85. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/cli/service_cmd.py +0 -0
  86. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/cli/upgrade_cmd.py +0 -0
  87. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/clock.py +0 -0
  88. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/context_store.py +0 -0
  89. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/events.py +0 -0
  90. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/hooks.py +0 -0
  91. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/http_progress.py +0 -0
  92. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/metrics.py +0 -0
  93. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/migrations.py +0 -0
  94. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/phase_select.py +0 -0
  95. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/presets/__init__.py +0 -0
  96. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/presets/aider.toml +0 -0
  97. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/presets/claude.toml +0 -0
  98. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/presets/codewhale.toml +0 -0
  99. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/presets/gemini.toml +0 -0
  100. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/presets/kimi.toml +0 -0
  101. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/presets/pi.toml +0 -0
  102. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/prompt_loader.py +0 -0
  103. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/remote_relay.py +0 -0
  104. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/round_log.py +0 -0
  105. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/round_view.py +0 -0
  106. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/scaffold.py +0 -0
  107. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/schedule.py +0 -0
  108. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/startup_check.py +0 -0
  109. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/agent_runner/vcs_state.py +0 -0
  110. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/build.sh +0 -0
  111. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/docs/README.md +0 -0
  112. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/docs/architecture.md +0 -0
  113. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/docs/commands.md +0 -0
  114. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/docs/events.md +0 -0
  115. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/docs/long-running-agents.md +0 -0
  116. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/docs/quickstart.md +0 -0
  117. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/docs/recipes/aider.md +0 -0
  118. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/docs/recipes/codewhale.md +0 -0
  119. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/docs/recipes/kimi.md +0 -0
  120. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/docs/recipes/pi.md +0 -0
  121. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/docs/thesis.md +0 -0
  122. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/pyproject.toml +0 -0
  123. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/__init__.py +0 -0
  124. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/_test_helpers.py +0 -0
  125. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/conftest.py +0 -0
  126. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/contract/__init__.py +0 -0
  127. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/contract/test_public_api_surface.py +0 -0
  128. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/e2e/__init__.py +0 -0
  129. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/e2e/conftest.py +0 -0
  130. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/e2e/test_e2e_graceful_stop.py +0 -0
  131. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/e2e/test_e2e_install_systemd.py +0 -0
  132. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/e2e/test_e2e_monitor_remote.py +0 -0
  133. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/e2e/test_e2e_round_lifecycle.py +0 -0
  134. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/fixtures/cli-real-output/claude-2.1.143-assistant-tool-use.jsonl +0 -0
  135. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/fixtures/cli-real-output/claude-2.1.143-result-event.jsonl +0 -0
  136. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/fixtures/cli-real-output/gemini-0.42.0-result-event.jsonl +0 -0
  137. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/generate_vulture_whitelist.py +0 -0
  138. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/integration/__init__.py +0 -0
  139. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/integration/test_bounded_run.py +0 -0
  140. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/integration/test_context_enricher_namespacing.py +0 -0
  141. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/integration/test_dirty_handler_seam.py +0 -0
  142. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/integration/test_grace_kill_emission.py +0 -0
  143. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/integration/test_install_dry_run.py +0 -0
  144. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/integration/test_monitor_seeded.py +0 -0
  145. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/integration/test_plugin_detector_loaded.py +0 -0
  146. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/integration/test_plugin_owned_paths.py +0 -0
  147. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/integration/test_plugin_real_flow.py +0 -0
  148. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/integration/test_prompt_delivery_stdin.py +0 -0
  149. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/integration/test_run_one_round_with_fake_agent.py +0 -0
  150. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/integration/test_scaffold_presets.py +0 -0
  151. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/integration/test_serve_loop.py +0 -0
  152. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/__init__.py +0 -0
  153. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/_docs.py +0 -0
  154. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/_event_scan.py +0 -0
  155. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_authored_facts_declared.py +0 -0
  156. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_auto_stop_policy_ssot.py +0 -0
  157. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_catalogs.py +0 -0
  158. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_classification_ssot.py +0 -0
  159. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_config_error_consistency.py +0 -0
  160. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_config_value_set_ssot.py +0 -0
  161. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_doc_builtin_plugins.py +0 -0
  162. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_doc_claims_match_ssot.py +0 -0
  163. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_doc_cli_claims.py +0 -0
  164. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_doc_operator_surface.py +0 -0
  165. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_doc_transient_error_claims.py +0 -0
  166. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_doc_usage_event_fields.py +0 -0
  167. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_docs_generated.py +0 -0
  168. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_docs_index_complete.py +0 -0
  169. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_entry_points_resolve.py +0 -0
  170. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_event_kind_registry.py +0 -0
  171. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_event_kinds_ssot.py +0 -0
  172. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_events_doc_contract.py +0 -0
  173. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_gen_blocks_name_their_source.py +0 -0
  174. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_hook_contract_docs.py +0 -0
  175. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_layer_2_loop_size.py +0 -0
  176. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_module_sizes.py +0 -0
  177. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_no_ai_signatures.py +0 -0
  178. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_no_pytest_skip_on_parse_fail.py +0 -0
  179. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_no_raw_time.py +0 -0
  180. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_phase_select_stateless.py +0 -0
  181. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_plugins_config_stable.py +0 -0
  182. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_readme_zh_is_pointer.py +0 -0
  183. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_repo_constants_patched_in_tests.py +0 -0
  184. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_round_result_stable.py +0 -0
  185. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_set_diff_for_auto_tool_classification.py +0 -0
  186. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_upstream_schema_canary.py +0 -0
  187. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/invariants/test_vulture_whitelist_generated.py +0 -0
  188. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/literate/__init__.py +0 -0
  189. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/literate/parser.py +0 -0
  190. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/literate/test_parser.py +0 -0
  191. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/literate/test_quickstart.py +0 -0
  192. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/__init__.py +0 -0
  193. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_agent_runtime.py +0 -0
  194. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_agent_runtime_progress.py +0 -0
  195. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_agent_runtime_signal_name.py +0 -0
  196. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_api_assemble_prompt.py +0 -0
  197. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_api_events_stream.py +0 -0
  198. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_api_install.py +0 -0
  199. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_api_read_round_num.py +0 -0
  200. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_api_resolve_phase.py +0 -0
  201. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_api_service.py +0 -0
  202. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_api_types.py +0 -0
  203. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_auto_stop_gating.py +0 -0
  204. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_catalogs.py +0 -0
  205. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_claude_error_detector.py +0 -0
  206. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_cli.py +0 -0
  207. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_cli_common.py +0 -0
  208. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_cli_init_install.py +0 -0
  209. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_cli_monitor_http.py +0 -0
  210. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_cli_service_peek_monitor.py +0 -0
  211. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_cli_upgrade.py +0 -0
  212. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_codewhale_plugin.py +0 -0
  213. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_config.py +0 -0
  214. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_config_fresh_eyes.py +0 -0
  215. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_config_max_rounds.py +0 -0
  216. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_config_prompt_delivery.py +0 -0
  217. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_config_schedule.py +0 -0
  218. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_config_stop_file.py +0 -0
  219. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_config_substrate_fingerprint_paths.py +0 -0
  220. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_config_transient_error_action.py +0 -0
  221. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_context_store.py +0 -0
  222. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_default_dirty_handler.py +0 -0
  223. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_defenses.py +0 -0
  224. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_detector_protocol.py +0 -0
  225. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_dirty_handlers.py +0 -0
  226. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_docgen.py +0 -0
  227. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_emit_config_migrated.py +0 -0
  228. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_emit_schedule.py +0 -0
  229. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_emit_schedule_phase_skipped.py +0 -0
  230. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_events_cmd.py +0 -0
  231. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_events_cmd_tail.py +0 -0
  232. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_example_dirty_handler.py +0 -0
  233. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_example_plugin.py +0 -0
  234. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_fresh_eyes_trigger.py +0 -0
  235. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_gemini_plugin.py +0 -0
  236. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_hook_failure_isolation.py +0 -0
  237. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_hooks.py +0 -0
  238. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_http_progress.py +0 -0
  239. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_init_entry_points.py +0 -0
  240. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_kimi_plugin.py +0 -0
  241. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_metrics.py +0 -0
  242. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_migrate_cmd.py +0 -0
  243. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_migrations.py +0 -0
  244. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_monitor_assembly.py +0 -0
  245. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_monitor_detect_anomaly_repetitive.py +0 -0
  246. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_monitor_detect_rate_limit.py +0 -0
  247. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_monitor_detect_supervisor_stale.py +0 -0
  248. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_monitor_remote.py +0 -0
  249. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_monitor_schedule_pause.py +0 -0
  250. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_network_blip_signal_guard.py +0 -0
  251. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_peek_argparse.py +0 -0
  252. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_peek_schedule.py +0 -0
  253. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_peek_select.py +0 -0
  254. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_phase_agent_launch.py +0 -0
  255. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_phase_profiles.py +0 -0
  256. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_phase_select.py +0 -0
  257. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_pi_plugin.py +0 -0
  258. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_plugin_constants.py +0 -0
  259. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_presets.py +0 -0
  260. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_prompt_loader.py +0 -0
  261. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_redact.py +0 -0
  262. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_round_log_helpers.py +0 -0
  263. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_round_view.py +0 -0
  264. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_runner.py +0 -0
  265. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_scaffold.py +0 -0
  266. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_schedule.py +0 -0
  267. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_serve_cmd_bounded.py +0 -0
  268. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_serve_round_log.py +0 -0
  269. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_serve_schedule_gate.py +0 -0
  270. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_serve_sentinel.py +0 -0
  271. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_serve_startup_hooks.py +0 -0
  272. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_startup_check.py +0 -0
  273. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_substrate.py +0 -0
  274. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_throttle_plugin_classification.py +0 -0
  275. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_upgrade_migrate.py +0 -0
  276. {cli_agent_runner-0.2.10 → cli_agent_runner-0.2.11}/tests/unit/test_vcs_state.py +0 -0
@@ -58,6 +58,7 @@ limit_type
58
58
  agent
59
59
  since_round
60
60
  phase
61
+ throttled_agents
61
62
 
62
63
  # agent_runner.api_types.RoundResult
63
64
  round_num
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.11] - 2026-08-29
9
+
10
+ ### Changed
11
+ - systemd `serve` unit now uses `Restart=on-failure` with `RestartPreventExitStatus=78 75`: a deliberate stop (sentinel / stop-file / max-rounds / SIGTERM) and a config give-up stay stopped, while a genuine crash still restarts. Re-render or hand-edit existing units.
12
+ - Throttle-aware skip is keyed on the **agent** (binary basename of `command[0]`), not the phase: every phase sharing a rate-limited agent is stepped over together, so a multi-provider rotation no longer hammers one throttled key while a healthy provider keeps running.
13
+
14
+ ### Fixed
15
+ - `round` exits non-zero on a genuine crash, so the crash-loop breaker (`crash_loop`, exit 75) sees crashes instead of a masked exit 0; a permanent config failure gives up with exit 78 (`config_broken`) instead of restart-looping.
16
+ - Round deadlines and sleeps are measured on the monotonic clock — an NTP step no longer mis-fires or defers a hard-wall timeout.
17
+ - Back-off and restart sleeps are chunked, so a SIGTERM lands within one chunk instead of after a multi-hour throttle window; an interrupted back-off leaves the throttle active (no false recovered).
18
+ - A grace-killed round still dispatches its dirty work and is no longer miscounted as a timeout.
19
+ - Only one `serve` loop runs per project (loop-lifetime lock); `serve.pid` records a start-time token so `stop`/`kill` never signals a recycled PID.
20
+
21
+ ### Notes
22
+ - `peek --json` schema `1.10`: `rate_limit.throttled_agents` lists all currently-throttled agents. Additive; no config change.
23
+
24
+ See `docs/migrations/0.2.md`.
25
+
8
26
  ## [0.2.10] - 2026-08-29
9
27
 
10
28
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cli-agent-runner
3
- Version: 0.2.10
3
+ Version: 0.2.11
4
4
  Summary: Restart-on-exit supervisor for autonomous CLI agents
5
5
  Project-URL: Homepage, https://github.com/wan9yu/cli-agent-runner
6
6
  Project-URL: Documentation, https://github.com/wan9yu/cli-agent-runner#readme
@@ -0,0 +1,372 @@
1
+ """Throttle state helpers — read events.jsonl tail for transient error state,
2
+ and own the supervisor-side back-off sleep.
3
+
4
+ Internal module. Callers: cli/serve_cmd.py (throttle scan + back-off + restart
5
+ delay), api.py (peek). The back-off lives here, NOT in runner.py, to satisfy the
6
+ ouroboros defense: runner.py writes events.jsonl but must never read it back
7
+ (§3 module boundary invariant), and back-off is driven by the events-derived
8
+ throttle state this module scans.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import random
15
+ from datetime import UTC
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from agent_runner.api_types import TransientErrorState
20
+ from agent_runner.clock import SYSTEM_CLOCK, Clock
21
+ from agent_runner.events import (
22
+ TRANSIENT_ERROR_DETECTED,
23
+ TRANSIENT_ERROR_RECOVERED,
24
+ parse_iso_ms,
25
+ )
26
+
27
+ # _scan_events_for_transient sentinel: file held no transient event at all
28
+ # (distinct from "latest transient was a recovered" → None).
29
+ _NO_TRANSIENT = object()
30
+
31
+
32
+ def _iter_events(path: Path):
33
+ """Yield parsed event dicts from a JSONL file; skip blank / corrupt lines."""
34
+ with path.open() as f:
35
+ for line in f:
36
+ line = line.strip()
37
+ if not line:
38
+ continue
39
+ try:
40
+ yield json.loads(line)
41
+ except json.JSONDecodeError:
42
+ continue
43
+
44
+
45
+ def _scan_events_for_transient(path: Path):
46
+ """The file's LAST transient event: the detected dict, ``None`` (latest was a
47
+ recovered), or ``_NO_TRANSIENT`` (no transient event in the file).
48
+
49
+ Forward single-pass, O(1) memory — it keeps only the latest transient event,
50
+ never a 100-line tail. That matters for throttle-aware skip: the loop keeps
51
+ emitting rounds on healthy phases during a throttle, so the ``detected`` event
52
+ can be thousands of lines back; a bounded tail would scroll it out and make the
53
+ supervisor forget the throttle mid-window."""
54
+ latest: Any = _NO_TRANSIENT
55
+ for ev in _iter_events(path):
56
+ kind = ev.get("event")
57
+ if kind == TRANSIENT_ERROR_DETECTED:
58
+ latest = ev
59
+ elif kind == TRANSIENT_ERROR_RECOVERED:
60
+ latest = None
61
+ return latest
62
+
63
+
64
+ def _latest_unrecovered_detected(log_dir: Path) -> dict[str, Any] | None:
65
+ """The most recent ``transient_error_detected`` with no
66
+ ``transient_error_recovered`` after it, or None. Used by
67
+ :func:`_check_throttle_state` (still-throttled?) — the single-throttle "latest"
68
+ view kept for the non-skip (wait / back_off / stop) paths and peek.
69
+
70
+ Scans the newest monthly ``events-*.jsonl`` and, only if it holds no transient
71
+ event yet, the previous month's — so a throttle that spans a month boundary
72
+ (detected in the old file, cleared in the new) is not orphaned."""
73
+ candidates = sorted(log_dir.glob("events-*.jsonl"))
74
+ for path in reversed(candidates[-2:]):
75
+ result = _scan_events_for_transient(path)
76
+ if result is not _NO_TRANSIENT:
77
+ return result # a detected dict, or None (latest transient was recovered)
78
+ return None
79
+
80
+
81
+ def _latest_transient_per_agent(log_dir: Path) -> dict[str, Any]:
82
+ """Per agent label, the last transient event across the newest TWO monthly files:
83
+ the ``detected`` dict, or ``None`` if the latest was a ``recovered``.
84
+
85
+ Unlike :func:`_latest_unrecovered_detected` (which early-exits at the first file
86
+ holding any transient), this always merges BOTH files per agent — a throttle for
87
+ agent A in the old file must not be masked by agent B's event in the newer one.
88
+ Iterates old-then-new so the newest event per agent wins."""
89
+ latest: dict[str, Any] = {}
90
+ for path in sorted(log_dir.glob("events-*.jsonl"))[-2:]:
91
+ for ev in _iter_events(path):
92
+ kind = ev.get("event")
93
+ if kind == TRANSIENT_ERROR_DETECTED:
94
+ latest[str(ev.get("agent", "unknown"))] = ev
95
+ elif kind == TRANSIENT_ERROR_RECOVERED:
96
+ latest[str(ev.get("agent", "unknown"))] = None
97
+ return latest
98
+
99
+
100
+ def _active_throttles(
101
+ log_dir: Path, *, clock: Clock = SYSTEM_CLOCK
102
+ ) -> dict[str, TransientErrorState]:
103
+ """Currently-active throttles keyed by AGENT label (binary basename), one entry
104
+ per agent whose latest transient event is an unrecovered ``detected`` with its
105
+ reset still in the future.
106
+
107
+ Rate limits are per-provider (per API key / agent binary), not per-phase — so the
108
+ serve skip-policy routes around EVERY phase sharing a throttled agent while a
109
+ healthy agent's phase keeps running. Restart-safe (events-derived)."""
110
+ now = clock.epoch()
111
+ active: dict[str, TransientErrorState] = {}
112
+ for agent, detected in _latest_transient_per_agent(log_dir).items():
113
+ if detected is None:
114
+ continue
115
+ reset_at = int(detected.get("reset_at_epoch", 0))
116
+ if reset_at <= now:
117
+ continue
118
+ active[agent] = TransientErrorState(
119
+ reset_at_epoch=reset_at,
120
+ classification=str(detected.get("classification", "rate_limit_account")),
121
+ agent=agent,
122
+ since_round=int(detected.get("round_num", 0)),
123
+ phase=str(detected.get("phase", "")),
124
+ )
125
+ return active
126
+
127
+
128
+ def _check_throttle_state(
129
+ log_dir: Path, *, clock: Clock = SYSTEM_CLOCK
130
+ ) -> TransientErrorState | None:
131
+ """Scan events.jsonl tail for latest unmatched transient error.
132
+
133
+ Reads `transient_error_detected` / `transient_error_recovered` event names.
134
+ Returns TransientErrorState if currently throttled (reset still in future,
135
+ no matching recovered after). Restart-safe.
136
+
137
+ ``clock`` supplies the wall clock the reset is compared against — inject a
138
+ ``FakeClock`` in tests to pin an exact instant. Only the reset comparison
139
+ reads it; the event scan is pure.
140
+ """
141
+ now = clock.epoch()
142
+ latest_detected = _latest_unrecovered_detected(log_dir)
143
+ if latest_detected is None:
144
+ return None
145
+ reset_at = int(latest_detected.get("reset_at_epoch", 0))
146
+ if reset_at <= now:
147
+ return None # Reset already passed without recovery emit; treat as recovered
148
+
149
+ classification = str(latest_detected.get("classification", "rate_limit_account"))
150
+
151
+ return TransientErrorState(
152
+ reset_at_epoch=reset_at,
153
+ classification=classification,
154
+ agent=str(latest_detected.get("agent", "unknown")),
155
+ since_round=int(latest_detected.get("round_num", 0)),
156
+ phase=str(latest_detected.get("phase", "")),
157
+ )
158
+
159
+
160
+ def _throttled_for_s(ts: Any, *, clock: Clock = SYSTEM_CLOCK) -> int:
161
+ """Seconds since a detected event's ``ts`` (best-effort; 0 if unparseable).
162
+ ``clock`` supplies now; inject a ``FakeClock`` for exact-value tests."""
163
+ if not ts:
164
+ return 0
165
+ try:
166
+ detected = parse_iso_ms(str(ts)) # events.py owns the Z→+00:00 workaround
167
+ except (ValueError, TypeError):
168
+ return 0
169
+ if detected.tzinfo is None:
170
+ detected = detected.replace(tzinfo=UTC) # hand-injected naive ts → treat as UTC
171
+ return max(0, int(clock.epoch() - detected.timestamp()))
172
+
173
+
174
+ def pending_recovered(log_dir: Path, *, clock: Clock = SYSTEM_CLOCK) -> list[tuple[str, str, int]]:
175
+ """One ``(agent, classification, throttled_for_s)`` per agent whose throttle
176
+ cleared WITHOUT a recovered breadcrumb — its latest transient event is a
177
+ ``detected`` with ``reset_at <= now`` and NO ``transient_error_recovered`` after.
178
+
179
+ This is the throttle-aware-skip path: the loop rotated to a healthy phase instead
180
+ of calling ``_apply_back_off`` (which emits its own recovered), so a multi-hour
181
+ throttle would otherwise close with no breadcrumb. Events-derived → restart-safe
182
+ AND never double-emits: once the caller emits recovered, that event sits after the
183
+ detected one and the agent drops out. Per-agent, so an agent that cleared is
184
+ reported even while a sibling is still throttled (overlapping recovery); an empty
185
+ list means nothing cleared (all still throttled, or the back-off path already left
186
+ a recovered)."""
187
+ now = clock.epoch()
188
+ cleared: list[tuple[str, str, int]] = []
189
+ for agent, detected in _latest_transient_per_agent(log_dir).items():
190
+ if detected is None or int(detected.get("reset_at_epoch", 0)) > now:
191
+ continue # None = latest was a recovered; > now = still throttled
192
+ cleared.append(
193
+ (
194
+ agent,
195
+ str(detected.get("classification", "rate_limit_account")),
196
+ _throttled_for_s(detected.get("ts"), clock=clock),
197
+ )
198
+ )
199
+ return cleared
200
+
201
+
202
+ # Module-level supervisor state — bucket → consecutive-failure count.
203
+ # Cleared by reset_counters() or by serve restart.
204
+ _consecutive_failures: dict[str, int] = {}
205
+
206
+
207
+ def compute_adjusted_reset_at(
208
+ *,
209
+ classification: str,
210
+ original_reset_at_epoch: int,
211
+ agent: str,
212
+ log_dir: Path,
213
+ clock: Clock = SYSTEM_CLOCK,
214
+ ) -> tuple[int, int, bool]:
215
+ """Apply exp backoff for estimated-class transient errors.
216
+
217
+ Returns (applied_reset_at_epoch, consecutive_count, capped_by_absolute_max).
218
+
219
+ For server-authoritative classification (``rate_limit_account``): returns
220
+ the original reset epoch verbatim, never increments the counter, and
221
+ never emits an adjustment event. Anthropic's resetsAt is authoritative.
222
+
223
+ For any classification absent from ``_BACK_OFF_DEFAULTS`` — i.e. one a
224
+ third-party plugin defined itself, which ``api_types.py`` types
225
+ ``classification`` as ``str`` to permit — the same verbatim path applies:
226
+ core has no base duration for it, so the emitter's reset_at_epoch is the
227
+ only non-invented answer.
228
+
229
+ For estimated classifications (``rate_limit_model``, ``api_transient_5xx``,
230
+ ``api_timeout``): increments the counter for this bucket, computes
231
+ duration = base × 2^min(n, _EXP_CAP), caps at _ABSOLUTE_CAP_S, emits
232
+ ``transient_error_backoff_capped`` if multiplier > 1 or capped.
233
+ """
234
+ from agent_runner._emit import emit_transient_error_backoff_capped
235
+ from agent_runner.builtin_plugins._constants import (
236
+ _ABSOLUTE_CAP_S,
237
+ _BACK_OFF_DEFAULTS,
238
+ _EXP_CAP,
239
+ )
240
+
241
+ if classification == "rate_limit_account" or classification not in _BACK_OFF_DEFAULTS:
242
+ # Server-authoritative, or a plugin's own classification (api_types.py types
243
+ # `classification` as str precisely so plugins can add their own): the emitter
244
+ # supplied reset_at_epoch, so respect it verbatim and never touch the counter.
245
+ return (original_reset_at_epoch, 0, False)
246
+
247
+ # Estimated class: apply exp backoff.
248
+ base = _BACK_OFF_DEFAULTS[classification]
249
+ n = _consecutive_failures.get(classification, 0)
250
+ multiplier = 2 ** min(n, _EXP_CAP)
251
+ extended_duration = base * multiplier
252
+ capped_by_absolute_max = extended_duration > _ABSOLUTE_CAP_S
253
+ applied_duration = min(extended_duration, _ABSOLUTE_CAP_S)
254
+ applied_reset_at = int(clock.epoch()) + applied_duration
255
+
256
+ new_count = n + 1
257
+ _consecutive_failures[classification] = new_count
258
+
259
+ # Emit observability event when supervisor adjusted the wait.
260
+ if multiplier > 1 or capped_by_absolute_max:
261
+ emit_transient_error_backoff_capped(
262
+ log_dir,
263
+ classification=classification,
264
+ agent=agent,
265
+ requested_sleep_s=int(base),
266
+ applied_sleep_s=applied_duration,
267
+ original_reset_at_epoch=original_reset_at_epoch,
268
+ applied_reset_at_epoch=applied_reset_at,
269
+ consecutive_count=new_count,
270
+ capped_by_absolute_max=capped_by_absolute_max,
271
+ )
272
+
273
+ return (applied_reset_at, new_count, capped_by_absolute_max)
274
+
275
+
276
+ def reset_counters() -> None:
277
+ """Clear all bucket counters. Called by serve loop when no active throttle."""
278
+ _consecutive_failures.clear()
279
+
280
+
281
+ _BACK_OFF_CAP_S = 28800 # 8h — defensive cap; 1.6× the 5h-window
282
+ _BACK_OFF_JITTER_MIN_S = 5
283
+ _BACK_OFF_JITTER_MAX_S = 30
284
+
285
+
286
+ def _interruptible_sleep(
287
+ total_s: float, stop: dict[str, bool], *, clock: Clock = SYSTEM_CLOCK, chunk_s: int = 30
288
+ ) -> bool:
289
+ """Sleep ``total_s`` in ``<= chunk_s`` slices, re-checking ``stop`` at each
290
+ boundary; return True iff ``stop["requested"]`` cut it short. Shared by the serve
291
+ restart delay and :func:`_apply_back_off` so a SIGTERM lands within one chunk.
292
+
293
+ Counts down the *intended* nap per slice rather than measuring a wall/monotonic
294
+ deadline: NTP-step immune (no clock read for the deadline) AND does not busy-spin
295
+ when ``clock.sleep`` is a no-op (a test patching ``time.sleep`` runs it instantly
296
+ instead of looping until real time advances)."""
297
+ remaining = float(total_s)
298
+ while remaining > 0:
299
+ if stop["requested"]:
300
+ return True
301
+ nap = min(float(chunk_s), remaining)
302
+ clock.sleep(nap)
303
+ remaining -= nap
304
+ return False
305
+
306
+
307
+ def _apply_back_off(
308
+ log_dir: Path,
309
+ throttle: TransientErrorState,
310
+ *,
311
+ stop: dict[str, bool],
312
+ clock: Clock = SYSTEM_CLOCK,
313
+ chunk_s: int = 30,
314
+ ) -> bool:
315
+ """Sleep until adjusted reset_at + jitter, then emit recovered. Returns True iff
316
+ ``stop["requested"]`` was set before the sleep completed — an interrupted back-off,
317
+ where the caller must break WITHOUT treating the throttle as recovered: the reset
318
+ has not passed, so a recovered breadcrumb would poison restart-safe state.
319
+
320
+ The sleep is chunked and re-checks ``stop`` at each boundary (see
321
+ :func:`_interruptible_sleep`), so a SIGTERM lands within one chunk instead of after
322
+ a multi-hour window.
323
+
324
+ For estimated-class classifications (rate_limit_model / api_transient_5xx /
325
+ api_timeout), applies exp backoff on consecutive failures via
326
+ :func:`compute_adjusted_reset_at`. For server-authoritative rate_limit_account, the
327
+ original reset_at_epoch is used verbatim. The 8h cap is a last-line defense against a
328
+ malformed (far-future) reset epoch (e.g. a manual event with a far-future ts).
329
+ """
330
+ from agent_runner._emit import (
331
+ emit_transient_error_backoff_capped,
332
+ emit_transient_error_recovered,
333
+ )
334
+
335
+ adjusted_reset_at, _consecutive_count, _capped = compute_adjusted_reset_at(
336
+ classification=throttle.classification,
337
+ original_reset_at_epoch=throttle.reset_at_epoch,
338
+ agent=throttle.agent,
339
+ log_dir=log_dir,
340
+ clock=clock,
341
+ )
342
+
343
+ requested = (
344
+ adjusted_reset_at
345
+ - clock.epoch()
346
+ + random.uniform(_BACK_OFF_JITTER_MIN_S, _BACK_OFF_JITTER_MAX_S)
347
+ )
348
+ if requested > _BACK_OFF_CAP_S:
349
+ # Defensive: malformed reset epoch. The exp-backoff layer caps at 30min, so
350
+ # legitimate flow never reaches this branch.
351
+ emit_transient_error_backoff_capped(
352
+ log_dir,
353
+ classification=throttle.classification,
354
+ agent=throttle.agent,
355
+ requested_sleep_s=int(requested),
356
+ applied_sleep_s=_BACK_OFF_CAP_S,
357
+ )
358
+ sleep_s = float(_BACK_OFF_CAP_S)
359
+ else:
360
+ sleep_s = max(requested, 0.0)
361
+
362
+ sleep_start = clock.epoch()
363
+ if _interruptible_sleep(sleep_s, stop, clock=clock, chunk_s=chunk_s):
364
+ return True # SIGTERM during back-off — leave the throttle active, no breadcrumb
365
+
366
+ emit_transient_error_recovered(
367
+ log_dir,
368
+ classification=throttle.classification,
369
+ agent=throttle.agent,
370
+ throttled_for_s=int(clock.epoch() - sleep_start),
371
+ )
372
+ return False
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.10'
22
- __version_tuple__ = version_tuple = (0, 2, 10)
21
+ __version__ = version = '0.2.11'
22
+ __version_tuple__ = version_tuple = (0, 2, 11)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -75,8 +75,10 @@ def _kill_pgroup(proc: subprocess.Popen, clock: Clock = SYSTEM_CLOCK) -> None:
75
75
  os.killpg(pgid, signal.SIGTERM)
76
76
  except OSError:
77
77
  pass
78
- deadline = clock.epoch() + REAP_GRACE_S
79
- while clock.epoch() < deadline and proc.poll() is None:
78
+ deadline = (
79
+ clock.monotonic() + REAP_GRACE_S
80
+ ) # monotonic: an NTP step must not stretch/skip the reap
81
+ while clock.monotonic() < deadline and proc.poll() is None:
80
82
  clock.sleep(0.1)
81
83
  try:
82
84
  os.killpg(pgid, signal.SIGKILL)
@@ -215,7 +217,7 @@ def run(
215
217
  env = {**os.environ, **env_extra, "PWD": str(work_dir)}
216
218
  log_path.parent.mkdir(parents=True, exist_ok=True)
217
219
  log_file = log_path.open("w", encoding="utf-8")
218
- start = clock.epoch()
220
+ start = clock.monotonic() # all round-duration/deadline math is monotonic (NTP-safe)
219
221
  last_progress_at = start
220
222
  proc = subprocess.Popen(
221
223
  argv,
@@ -248,13 +250,13 @@ def run(
248
250
  try:
249
251
  while True:
250
252
  ret = proc.poll()
251
- now = clock.epoch()
253
+ now = clock.monotonic() # duration / R1128 hard-wall / grace / interval — all monotonic
252
254
  if ret is not None:
253
255
  duration = now - start
254
256
  return RunResult(exit_code=ret, duration_s=duration, timed_out=False, pid=proc.pid)
255
257
  if now - start > timeout_s:
256
258
  _kill_pgroup(proc, clock)
257
- duration = clock.epoch() - start
259
+ duration = clock.monotonic() - start
258
260
  exit_code = proc.returncode if proc.returncode is not None else -1
259
261
  return RunResult(
260
262
  exit_code=exit_code, duration_s=duration, timed_out=True, pid=proc.pid
@@ -279,7 +281,7 @@ def run(
279
281
  grace_extended_emitted = True
280
282
  else:
281
283
  _kill_pgroup(proc, clock)
282
- duration = clock.epoch() - start
284
+ duration = clock.monotonic() - start
283
285
  exit_code = proc.returncode if proc.returncode is not None else -1
284
286
  return RunResult(
285
287
  exit_code=exit_code,
@@ -295,7 +297,7 @@ def run(
295
297
  try:
296
298
  st = log_path.stat()
297
299
  log_size_kb = st.st_size // 1024
298
- last_write_age_s = max(0, int(now - st.st_mtime))
300
+ last_write_age_s = max(0, int(clock.epoch() - st.st_mtime))
299
301
  except OSError:
300
302
  log_size_kb = 0
301
303
  last_write_age_s = 0
@@ -52,6 +52,13 @@ from agent_runner.service_unit import (
52
52
  # facade without coupling to runner (runner imports api, not the reverse).
53
53
  PERMANENT_CONFIG_EXIT = 78
54
54
 
55
+ # Exit code serve returns when the crash-loop breaker trips (a give-up stop, like
56
+ # config_broken). Distinct from 78 so `systemctl status` / SuccessExitStatus can
57
+ # tell the two "needs intervention" stops apart. 75 = EX_TEMPFAIL (sysexits).
58
+ # The systemd unit lists both in RestartPreventExitStatus so a deliberate stop
59
+ # stays stopped (and visibly failed) while an unexpected supervisor crash restarts.
60
+ CRASH_LOOP_EXIT = 75
61
+
55
62
  # Crash-loop circuit breaker (b12). The serve loop escalates the restart delay
56
63
  # on consecutive UNKNOWN short crashes (non-zero exit, short duration, no
57
64
  # classified transient) and STOPS after CRASH_LOOP_THRESHOLD of them — the Run 6
@@ -438,9 +445,16 @@ def peek(
438
445
  )
439
446
  recent_blips = _recent_events_of_kind(parsed_events, AGENT_NETWORK_BLIP, _RECENT_BLIPS_LIMIT)
440
447
 
441
- from agent_runner._throttle import _check_throttle_state
448
+ from agent_runner._throttle import _active_throttles, _check_throttle_state
442
449
 
443
450
  throttle = _check_throttle_state(log_dir)
451
+ active = _active_throttles(log_dir)
452
+ if throttle is None and active:
453
+ # The global-latest scalar view can be None while a sibling agent is still
454
+ # throttled (the newest transient event is another agent's recovered). Fall
455
+ # back to the active throttle that clears LAST so the scalar fields stay
456
+ # coherent and the plural throttled_agents view is not dropped to null.
457
+ throttle = max(active.values(), key=lambda s: s.reset_at_epoch)
444
458
  rate_limit: RateLimitState | None = None
445
459
  if throttle is not None:
446
460
  rate_limit = RateLimitState(
@@ -449,6 +463,7 @@ def peek(
449
463
  agent=throttle.agent,
450
464
  since_round=throttle.since_round,
451
465
  phase=throttle.phase,
466
+ throttled_agents=tuple(sorted(active)),
452
467
  )
453
468
  raw_service = status(project if project is not None else work_dir)
454
469
  svc = dataclasses.replace(raw_service, rate_limit=rate_limit)
@@ -49,6 +49,9 @@ class RateLimitState:
49
49
  agent: str
50
50
  since_round: int
51
51
  phase: str = "" # which rotation phase is throttled ("" = no [phases])
52
+ # All agents (binary basenames) currently throttled — plural view for multi-provider
53
+ # rotations. The scalar fields above stay the "latest" throttle for back-compat.
54
+ throttled_agents: tuple[str, ...] = ()
52
55
 
53
56
 
54
57
  @dataclass(frozen=True)
@@ -16,7 +16,7 @@ from agent_runner.hooks import plugin_context_enrichers, post_round_hooks, pre_r
16
16
  from agent_runner.monitor import plugin_detectors
17
17
  from agent_runner.vcs_state import plugin_owned_paths
18
18
 
19
- PEEK_SCHEMA_VERSION = "1.9"
19
+ PEEK_SCHEMA_VERSION = "1.10"
20
20
 
21
21
 
22
22
  def cfg_from_args(args) -> Config:
@@ -21,5 +21,10 @@ def add_parser(sub, parent) -> None:
21
21
 
22
22
  def cmd(args) -> int:
23
23
  cfg = cfg_from_args(args)
24
- run_one_round(cfg, phase_override=args.phase)
25
- return 0
24
+ result = run_one_round(cfg, phase_override=args.phase)
25
+ # Surface a real agent crash as a non-zero exit so serve's crash-loop breaker
26
+ # (which keys on this subprocess's returncode) can count it. A grace-kill (agent
27
+ # produced a result then lingered) or a timeout (long, not a short crash) is NOT
28
+ # a crash → 0. Never returns the serve-reserved 78 / 75.
29
+ crashed = result.exit_code != 0 and not result.killed_for_grace and not result.timed_out
30
+ return 1 if crashed else 0