cli-agent-runner 0.2.9__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 (278) hide show
  1. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/.vulture-whitelist.py +3 -0
  2. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/CHANGELOG.md +30 -0
  3. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/PKG-INFO +1 -1
  4. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/_emit.py +7 -1
  5. cli_agent_runner-0.2.11/agent_runner/_throttle.py +372 -0
  6. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/_version.py +2 -2
  7. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/agent_runtime.py +17 -13
  8. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/api.py +23 -8
  9. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/api_types.py +5 -0
  10. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/builtin_plugins/claude_rate_limit.py +6 -4
  11. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/builtin_plugins/codewhale.py +6 -3
  12. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/builtin_plugins/gemini.py +5 -3
  13. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/builtin_plugins/kimi.py +5 -3
  14. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/builtin_plugins/pi.py +3 -2
  15. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/cli/common.py +1 -1
  16. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/cli/events_cmd.py +3 -3
  17. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/cli/peek_cmd.py +2 -2
  18. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/cli/round_cmd.py +7 -2
  19. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/cli/serve_cmd.py +250 -54
  20. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/cli/service_cmd.py +3 -4
  21. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/cli/upgrade_cmd.py +10 -10
  22. cli_agent_runner-0.2.11/agent_runner/clock.py +79 -0
  23. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/config.py +9 -0
  24. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/defenses.py +3 -2
  25. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/events.py +5 -3
  26. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/http_progress.py +1 -0
  27. cli_agent_runner-0.2.11/agent_runner/lifecycle.py +101 -0
  28. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/metrics.py +2 -2
  29. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/monitor.py +6 -4
  30. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/phase_select.py +30 -12
  31. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/remote_relay.py +3 -3
  32. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/runner.py +16 -64
  33. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/schedule.py +5 -4
  34. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/service_unit.py +9 -1
  35. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/vcs_state.py +4 -3
  36. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/docs/configuration.md +10 -3
  37. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/docs/migrations/0.2.md +49 -0
  38. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/docs/plugins.md +2 -2
  39. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/docs/runbook.md +33 -7
  40. cli_agent_runner-0.2.11/tests/_clock.py +48 -0
  41. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/integration/test_fresh_eyes_signal.py +3 -0
  42. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/integration/test_substrate_fingerprint.py +4 -0
  43. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/integration/test_transient_error_backoff.py +3 -0
  44. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_architecture.py +16 -2
  45. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_atomic_write_enforced.py +3 -0
  46. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_doc_test_pointers_exist.py +14 -1
  47. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_module_boundaries.py +48 -37
  48. cli_agent_runner-0.2.11/tests/invariants/test_no_raw_time.py +136 -0
  49. cli_agent_runner-0.2.11/tests/invariants/test_no_vacuous_invariants.py +41 -0
  50. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_peek_schema_version.py +7 -2
  51. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_phase_select_stateless.py +11 -8
  52. cli_agent_runner-0.2.11/tests/invariants/test_stash_uses_sha_not_index.py +54 -0
  53. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_agent_runtime_grace.py +33 -0
  54. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_api_observation.py +55 -2
  55. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_claude_error_detector.py +6 -6
  56. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_events.py +3 -5
  57. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_events_cmd_tail.py +2 -2
  58. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_exit_cause.py +10 -2
  59. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_gemini_plugin.py +2 -2
  60. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_kimi_plugin.py +1 -1
  61. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_lifecycle.py +23 -0
  62. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_metrics.py +2 -7
  63. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_monitor_detectors.py +19 -0
  64. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_phase_select.py +47 -0
  65. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_pi_plugin.py +1 -1
  66. cli_agent_runner-0.2.11/tests/unit/test_round_cmd.py +52 -0
  67. cli_agent_runner-0.2.11/tests/unit/test_runner_throttle.py +618 -0
  68. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_serve_config_broken.py +3 -3
  69. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_serve_crash_loop.py +7 -2
  70. cli_agent_runner-0.2.11/tests/unit/test_serve_phase_schedule.py +553 -0
  71. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_service_unit.py +11 -1
  72. cli_agent_runner-0.2.9/agent_runner/_throttle.py +0 -142
  73. cli_agent_runner-0.2.9/agent_runner/lifecycle.py +0 -67
  74. cli_agent_runner-0.2.9/tests/invariants/test_stash_uses_sha_not_index.py +0 -21
  75. cli_agent_runner-0.2.9/tests/unit/test_runner_throttle.py +0 -339
  76. cli_agent_runner-0.2.9/tests/unit/test_serve_phase_schedule.py +0 -193
  77. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/.codecov.yml +0 -0
  78. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  79. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  80. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  81. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  82. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/.github/workflows/ci.yml +0 -0
  83. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/.github/workflows/release.yml +0 -0
  84. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/.gitignore +0 -0
  85. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/BACKLOG.md +0 -0
  86. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/CODE_OF_CONDUCT.md +0 -0
  87. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/CONTRIBUTING.md +0 -0
  88. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/LICENSE +0 -0
  89. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/README.md +0 -0
  90. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/README.zh.md +0 -0
  91. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/SECURITY.md +0 -0
  92. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/__init__.py +0 -0
  93. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/_docgen.py +0 -0
  94. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/_redact.py +0 -0
  95. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/_registry.py +0 -0
  96. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/_substrate.py +0 -0
  97. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/builtin_plugins/__init__.py +0 -0
  98. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/builtin_plugins/_constants.py +0 -0
  99. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/builtin_plugins/default_dirty_handler.py +0 -0
  100. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/cli/__init__.py +0 -0
  101. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/cli/__main__.py +0 -0
  102. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/cli/init_cmd.py +0 -0
  103. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/cli/install_cmd.py +0 -0
  104. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/cli/migrate_cmd.py +0 -0
  105. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/cli/monitor_cmd.py +0 -0
  106. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/context_store.py +0 -0
  107. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/hooks.py +0 -0
  108. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/migrations.py +0 -0
  109. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/presets/__init__.py +0 -0
  110. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/presets/aider.toml +0 -0
  111. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/presets/claude.toml +0 -0
  112. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/presets/codewhale.toml +0 -0
  113. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/presets/gemini.toml +0 -0
  114. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/presets/kimi.toml +0 -0
  115. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/presets/pi.toml +0 -0
  116. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/prompt_loader.py +0 -0
  117. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/round_log.py +0 -0
  118. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/round_view.py +0 -0
  119. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/scaffold.py +0 -0
  120. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/agent_runner/startup_check.py +0 -0
  121. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/build.sh +0 -0
  122. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/docs/README.md +0 -0
  123. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/docs/architecture.md +0 -0
  124. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/docs/commands.md +0 -0
  125. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/docs/events.md +0 -0
  126. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/docs/long-running-agents.md +0 -0
  127. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/docs/quickstart.md +0 -0
  128. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/docs/recipes/aider.md +0 -0
  129. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/docs/recipes/codewhale.md +0 -0
  130. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/docs/recipes/kimi.md +0 -0
  131. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/docs/recipes/pi.md +0 -0
  132. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/docs/thesis.md +0 -0
  133. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/pyproject.toml +0 -0
  134. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/__init__.py +0 -0
  135. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/_test_helpers.py +0 -0
  136. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/conftest.py +0 -0
  137. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/contract/__init__.py +0 -0
  138. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/contract/test_public_api_surface.py +0 -0
  139. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/e2e/__init__.py +0 -0
  140. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/e2e/conftest.py +0 -0
  141. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/e2e/test_e2e_graceful_stop.py +0 -0
  142. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/e2e/test_e2e_install_systemd.py +0 -0
  143. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/e2e/test_e2e_monitor_remote.py +0 -0
  144. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/e2e/test_e2e_round_lifecycle.py +0 -0
  145. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/fixtures/cli-real-output/claude-2.1.143-assistant-tool-use.jsonl +0 -0
  146. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/fixtures/cli-real-output/claude-2.1.143-result-event.jsonl +0 -0
  147. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/fixtures/cli-real-output/gemini-0.42.0-result-event.jsonl +0 -0
  148. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/generate_vulture_whitelist.py +0 -0
  149. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/integration/__init__.py +0 -0
  150. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/integration/test_bounded_run.py +0 -0
  151. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/integration/test_context_enricher_namespacing.py +0 -0
  152. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/integration/test_dirty_handler_seam.py +0 -0
  153. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/integration/test_grace_kill_emission.py +0 -0
  154. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/integration/test_install_dry_run.py +0 -0
  155. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/integration/test_monitor_seeded.py +0 -0
  156. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/integration/test_plugin_detector_loaded.py +0 -0
  157. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/integration/test_plugin_owned_paths.py +0 -0
  158. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/integration/test_plugin_real_flow.py +0 -0
  159. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/integration/test_prompt_delivery_stdin.py +0 -0
  160. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/integration/test_run_one_round_with_fake_agent.py +0 -0
  161. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/integration/test_scaffold_presets.py +0 -0
  162. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/integration/test_serve_loop.py +0 -0
  163. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/__init__.py +0 -0
  164. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/_docs.py +0 -0
  165. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/_event_scan.py +0 -0
  166. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_authored_facts_declared.py +0 -0
  167. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_auto_stop_policy_ssot.py +0 -0
  168. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_catalogs.py +0 -0
  169. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_classification_ssot.py +0 -0
  170. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_config_error_consistency.py +0 -0
  171. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_config_value_set_ssot.py +0 -0
  172. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_doc_builtin_plugins.py +0 -0
  173. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_doc_claims_match_ssot.py +0 -0
  174. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_doc_cli_claims.py +0 -0
  175. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_doc_operator_surface.py +0 -0
  176. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_doc_transient_error_claims.py +0 -0
  177. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_doc_usage_event_fields.py +0 -0
  178. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_docs_generated.py +0 -0
  179. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_docs_index_complete.py +0 -0
  180. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_entry_points_resolve.py +0 -0
  181. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_event_kind_registry.py +0 -0
  182. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_event_kinds_ssot.py +0 -0
  183. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_events_doc_contract.py +0 -0
  184. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_gen_blocks_name_their_source.py +0 -0
  185. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_hook_contract_docs.py +0 -0
  186. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_layer_2_loop_size.py +0 -0
  187. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_module_sizes.py +0 -0
  188. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_no_ai_signatures.py +0 -0
  189. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_no_pytest_skip_on_parse_fail.py +0 -0
  190. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_plugins_config_stable.py +0 -0
  191. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_readme_zh_is_pointer.py +0 -0
  192. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_repo_constants_patched_in_tests.py +0 -0
  193. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_round_result_stable.py +0 -0
  194. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_set_diff_for_auto_tool_classification.py +0 -0
  195. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_upstream_schema_canary.py +0 -0
  196. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/invariants/test_vulture_whitelist_generated.py +0 -0
  197. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/literate/__init__.py +0 -0
  198. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/literate/parser.py +0 -0
  199. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/literate/test_parser.py +0 -0
  200. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/literate/test_quickstart.py +0 -0
  201. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/__init__.py +0 -0
  202. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_agent_runtime.py +0 -0
  203. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_agent_runtime_progress.py +0 -0
  204. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_agent_runtime_signal_name.py +0 -0
  205. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_api_assemble_prompt.py +0 -0
  206. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_api_events_stream.py +0 -0
  207. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_api_install.py +0 -0
  208. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_api_read_round_num.py +0 -0
  209. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_api_resolve_phase.py +0 -0
  210. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_api_service.py +0 -0
  211. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_api_types.py +0 -0
  212. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_auto_stop_gating.py +0 -0
  213. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_catalogs.py +0 -0
  214. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_cli.py +0 -0
  215. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_cli_common.py +0 -0
  216. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_cli_init_install.py +0 -0
  217. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_cli_monitor_http.py +0 -0
  218. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_cli_service_peek_monitor.py +0 -0
  219. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_cli_upgrade.py +0 -0
  220. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_codewhale_plugin.py +0 -0
  221. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_config.py +0 -0
  222. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_config_fresh_eyes.py +0 -0
  223. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_config_max_rounds.py +0 -0
  224. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_config_prompt_delivery.py +0 -0
  225. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_config_schedule.py +0 -0
  226. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_config_stop_file.py +0 -0
  227. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_config_substrate_fingerprint_paths.py +0 -0
  228. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_config_transient_error_action.py +0 -0
  229. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_context_store.py +0 -0
  230. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_default_dirty_handler.py +0 -0
  231. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_defenses.py +0 -0
  232. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_detector_protocol.py +0 -0
  233. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_dirty_handlers.py +0 -0
  234. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_docgen.py +0 -0
  235. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_emit_config_migrated.py +0 -0
  236. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_emit_schedule.py +0 -0
  237. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_emit_schedule_phase_skipped.py +0 -0
  238. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_events_cmd.py +0 -0
  239. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_example_dirty_handler.py +0 -0
  240. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_example_plugin.py +0 -0
  241. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_fresh_eyes_trigger.py +0 -0
  242. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_hook_failure_isolation.py +0 -0
  243. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_hooks.py +0 -0
  244. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_http_progress.py +0 -0
  245. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_init_entry_points.py +0 -0
  246. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_migrate_cmd.py +0 -0
  247. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_migrations.py +0 -0
  248. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_monitor_assembly.py +0 -0
  249. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_monitor_detect_anomaly_repetitive.py +0 -0
  250. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_monitor_detect_rate_limit.py +0 -0
  251. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_monitor_detect_supervisor_stale.py +0 -0
  252. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_monitor_remote.py +0 -0
  253. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_monitor_schedule_pause.py +0 -0
  254. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_network_blip_signal_guard.py +0 -0
  255. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_peek_argparse.py +0 -0
  256. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_peek_schedule.py +0 -0
  257. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_peek_select.py +0 -0
  258. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_phase_agent_launch.py +0 -0
  259. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_phase_profiles.py +0 -0
  260. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_plugin_constants.py +0 -0
  261. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_presets.py +0 -0
  262. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_prompt_loader.py +0 -0
  263. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_redact.py +0 -0
  264. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_round_log_helpers.py +0 -0
  265. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_round_view.py +0 -0
  266. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_runner.py +0 -0
  267. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_scaffold.py +0 -0
  268. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_schedule.py +0 -0
  269. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_serve_cmd_bounded.py +0 -0
  270. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_serve_round_log.py +0 -0
  271. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_serve_schedule_gate.py +0 -0
  272. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_serve_sentinel.py +0 -0
  273. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_serve_startup_hooks.py +0 -0
  274. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_startup_check.py +0 -0
  275. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_substrate.py +0 -0
  276. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_throttle_plugin_classification.py +0 -0
  277. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_upgrade_migrate.py +0 -0
  278. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.11}/tests/unit/test_vcs_state.py +0 -0
@@ -57,6 +57,8 @@ throttled_until_epoch
57
57
  limit_type
58
58
  agent
59
59
  since_round
60
+ phase
61
+ throttled_agents
60
62
 
61
63
  # agent_runner.api_types.RoundResult
62
64
  round_num
@@ -106,6 +108,7 @@ reset_at_epoch
106
108
  classification
107
109
  agent
108
110
  since_round
111
+ phase
109
112
 
110
113
  # agent_runner.config.AgentConfig
111
114
  command
@@ -5,6 +5,36 @@ 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
+
26
+ ## [0.2.10] - 2026-08-29
27
+
28
+ ### Added
29
+ - Throttle-aware skip: under `[phases] phase_policy = "skip"`, a phase whose provider is currently rate-limited/erroring is stepped over in the rotation — a healthy sibling runs instead of the whole loop sleeping on the throttle. No new config; the trigger is the existing `skip` policy.
30
+ - `transient_error_detected` now carries the failing `phase`; `peek --json` `rate_limit` and the HTTP progress state surface which phase is throttled.
31
+ - `transient_error_recovered` is emitted when a skip-around throttle clears (events-derived, restart-safe, no double-emit with the back-off path).
32
+
33
+ ### Notes
34
+ - Only `skip` routes around a throttle; `wait`, `--ignore-schedule`, and non-`[phases]` configs keep the 0.2.9 global back-off. When every phase is throttled or window-closed, the loop waits until the earlier of a window opening or the throttle resetting.
35
+
36
+ See `docs/migrations/0.2.md`.
37
+
8
38
  ## [0.2.9] - 2026-08-28
9
39
 
10
40
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cli-agent-runner
3
- Version: 0.2.9
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
@@ -188,8 +188,13 @@ def emit_transient_error_detected(
188
188
  reset_at_epoch: int,
189
189
  round_num: int,
190
190
  raw: str,
191
+ phase: str = "",
191
192
  ) -> None:
192
- """Emit detection of a transient agent error (rate limit / 5xx / timeout)."""
193
+ """Emit detection of a transient agent error (rate limit / 5xx / timeout).
194
+
195
+ ``phase`` is the rotation phase the failing round ran under ("" when the
196
+ config has no ``[phases]``). It lets a ``phase_policy = "skip"`` serve loop
197
+ step over *this* phase while a healthy sibling keeps running."""
193
198
  from agent_runner._redact import redact_secrets
194
199
  from agent_runner.events import TRANSIENT_ERROR_DETECTED, emit
195
200
 
@@ -202,6 +207,7 @@ def emit_transient_error_detected(
202
207
  reset_at_epoch=reset_at_epoch,
203
208
  round_num=round_num,
204
209
  raw=raw,
210
+ phase=phase,
205
211
  )
206
212
 
207
213
 
@@ -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.9'
22
- __version_tuple__ = version_tuple = (0, 2, 9)
21
+ __version__ = version = '0.2.11'
22
+ __version_tuple__ = version_tuple = (0, 2, 11)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -15,13 +15,14 @@ import shutil
15
15
  import signal
16
16
  import subprocess # noqa: TID251 — sanctioned subprocess caller
17
17
  import threading
18
- import time
19
18
  from collections.abc import Callable
20
19
  from dataclasses import dataclass, field
21
20
  from pathlib import Path
22
21
 
23
22
  import psutil
24
23
 
24
+ from agent_runner.clock import SYSTEM_CLOCK, Clock
25
+
25
26
  REAP_GRACE_S = 5
26
27
 
27
28
 
@@ -68,15 +69,17 @@ def _build_argv(command: list[str], prompt_arg_template: list[str], prompt: str)
68
69
  return list(command) + [a.replace("{prompt}", prompt) for a in prompt_arg_template]
69
70
 
70
71
 
71
- def _kill_pgroup(proc: subprocess.Popen) -> None:
72
+ def _kill_pgroup(proc: subprocess.Popen, clock: Clock = SYSTEM_CLOCK) -> None:
72
73
  pgid = proc.pid
73
74
  try:
74
75
  os.killpg(pgid, signal.SIGTERM)
75
76
  except OSError:
76
77
  pass
77
- deadline = time.time() + REAP_GRACE_S
78
- while time.time() < deadline and proc.poll() is None:
79
- time.sleep(0.1)
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:
82
+ clock.sleep(0.1)
80
83
  try:
81
84
  os.killpg(pgid, signal.SIGKILL)
82
85
  except OSError:
@@ -173,6 +176,7 @@ def run(
173
176
  progress_interval_s: int = 0,
174
177
  on_grace_extended: Callable[[list[dict], list[dict]], None] | None = None,
175
178
  grace_kill_ignore_patterns: list[re.Pattern[str]] | None = None,
179
+ clock: Clock = SYSTEM_CLOCK,
176
180
  ) -> RunResult:
177
181
  """Spawn the agent subprocess and wait for exit or timeout.
178
182
 
@@ -213,7 +217,7 @@ def run(
213
217
  env = {**os.environ, **env_extra, "PWD": str(work_dir)}
214
218
  log_path.parent.mkdir(parents=True, exist_ok=True)
215
219
  log_file = log_path.open("w", encoding="utf-8")
216
- start = time.time()
220
+ start = clock.monotonic() # all round-duration/deadline math is monotonic (NTP-safe)
217
221
  last_progress_at = start
218
222
  proc = subprocess.Popen(
219
223
  argv,
@@ -246,13 +250,13 @@ def run(
246
250
  try:
247
251
  while True:
248
252
  ret = proc.poll()
249
- now = time.time()
253
+ now = clock.monotonic() # duration / R1128 hard-wall / grace / interval — all monotonic
250
254
  if ret is not None:
251
255
  duration = now - start
252
256
  return RunResult(exit_code=ret, duration_s=duration, timed_out=False, pid=proc.pid)
253
257
  if now - start > timeout_s:
254
- _kill_pgroup(proc)
255
- duration = time.time() - start
258
+ _kill_pgroup(proc, clock)
259
+ duration = clock.monotonic() - start
256
260
  exit_code = proc.returncode if proc.returncode is not None else -1
257
261
  return RunResult(
258
262
  exit_code=exit_code, duration_s=duration, timed_out=True, pid=proc.pid
@@ -276,8 +280,8 @@ def run(
276
280
  on_grace_extended(live, ignored)
277
281
  grace_extended_emitted = True
278
282
  else:
279
- _kill_pgroup(proc)
280
- duration = time.time() - start
283
+ _kill_pgroup(proc, clock)
284
+ duration = clock.monotonic() - start
281
285
  exit_code = proc.returncode if proc.returncode is not None else -1
282
286
  return RunResult(
283
287
  exit_code=exit_code,
@@ -293,7 +297,7 @@ def run(
293
297
  try:
294
298
  st = log_path.stat()
295
299
  log_size_kb = st.st_size // 1024
296
- last_write_age_s = max(0, int(now - st.st_mtime))
300
+ last_write_age_s = max(0, int(clock.epoch() - st.st_mtime))
297
301
  except OSError:
298
302
  log_size_kb = 0
299
303
  last_write_age_s = 0
@@ -305,6 +309,6 @@ def run(
305
309
  }
306
310
  )
307
311
  last_progress_at = now
308
- time.sleep(0.2)
312
+ clock.sleep(0.2)
309
313
  finally:
310
314
  log_file.close()
@@ -15,7 +15,6 @@ import shutil
15
15
  import signal
16
16
  import subprocess # noqa: TID251 — api uses systemctl + ssh, both subprocess
17
17
  import sysconfig
18
- import time
19
18
  from collections.abc import Iterator, Sequence
20
19
  from pathlib import Path
21
20
  from typing import Any, Literal, TextIO
@@ -30,6 +29,7 @@ from agent_runner.api_types import (
30
29
  ServiceStatus,
31
30
  select_path,
32
31
  )
32
+ from agent_runner.clock import SYSTEM_CLOCK
33
33
  from agent_runner.config import Config, RuntimeConfig, load_config
34
34
  from agent_runner.lifecycle import (
35
35
  PIDFile,
@@ -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
@@ -323,13 +330,13 @@ def kill(project: str | Path) -> ServiceStatus:
323
330
  if pid is None:
324
331
  return status(project)
325
332
  send_signal_to_pid(pid, signal.SIGTERM)
326
- deadline = time.time() + 5
333
+ deadline = SYSTEM_CLOCK.epoch() + 5
327
334
  alive = True
328
- while time.time() < deadline:
335
+ while SYSTEM_CLOCK.epoch() < deadline:
329
336
  alive = pid_alive(pid)
330
337
  if not alive:
331
338
  break
332
- time.sleep(0.1)
339
+ SYSTEM_CLOCK.sleep(0.1)
333
340
  if alive:
334
341
  send_signal_to_pid(pid, signal.SIGKILL)
335
342
  return ServiceStatus(mode=ServiceMode.PID_FILE, active=alive, pid=pid)
@@ -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(
@@ -448,6 +462,8 @@ def peek(
448
462
  limit_type=throttle.classification,
449
463
  agent=throttle.agent,
450
464
  since_round=throttle.since_round,
465
+ phase=throttle.phase,
466
+ throttled_agents=tuple(sorted(active)),
451
467
  )
452
468
  raw_service = status(project if project is not None else work_dir)
453
469
  svc = dataclasses.replace(raw_service, rate_limit=rate_limit)
@@ -567,7 +583,7 @@ def _monitor_loop_iter(
567
583
  log_dir=cfg.runtime.log_dir,
568
584
  allowed_stop_names=cfg.monitor.auto_stop_on,
569
585
  )
570
- time.sleep(interval_s)
586
+ SYSTEM_CLOCK.sleep(interval_s)
571
587
 
572
588
 
573
589
  def _tail_events_jsonl(
@@ -586,7 +602,6 @@ def _tail_events_jsonl(
586
602
  appears, it is picked up from byte 0.
587
603
  """
588
604
  import json as _json
589
- import time as _time
590
605
 
591
606
  seen_positions: dict[Path, int] = {}
592
607
  if start_at_now:
@@ -620,7 +635,7 @@ def _tail_events_jsonl(
620
635
  any_new = True
621
636
  seen_positions[path] = f.tell()
622
637
  if not any_new:
623
- _time.sleep(poll_interval_s)
638
+ SYSTEM_CLOCK.sleep(poll_interval_s)
624
639
 
625
640
 
626
641
  def _primary_prompt_file(cfg: Config) -> Path | None: