cli-agent-runner 0.1.41__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/CHANGELOG.md +26 -1
  2. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/PKG-INFO +6 -6
  3. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/README.md +5 -5
  4. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/README.zh.md +6 -6
  5. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/__init__.py +4 -0
  6. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/_emit.py +23 -0
  7. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/_version.py +2 -2
  8. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/api.py +62 -1
  9. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/api_types.py +13 -0
  10. cli_agent_runner-0.2.0/agent_runner/builtin_plugins/default_dirty_handler.py +83 -0
  11. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/cli/serve_cmd.py +26 -5
  12. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/defenses.py +12 -2
  13. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/events.py +3 -0
  14. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/hooks.py +67 -0
  15. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/monitor.py +0 -25
  16. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/runner.py +9 -43
  17. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/vcs_state.py +57 -11
  18. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/architecture.md +33 -7
  19. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/commands.md +1 -1
  20. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/configuration.md +6 -2
  21. cli_agent_runner-0.2.0/docs/migrations/0.1.42.md +58 -0
  22. cli_agent_runner-0.2.0/docs/migrations/0.2.0.md +166 -0
  23. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/plugins.md +122 -0
  24. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/runbook.md +21 -0
  25. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/thesis.md +12 -7
  26. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/pyproject.toml +3 -0
  27. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/_test_helpers.py +26 -8
  28. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/integration/test_bounded_run.py +10 -2
  29. cli_agent_runner-0.2.0/tests/integration/test_dirty_handler_seam.py +74 -0
  30. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/integration/test_fresh_eyes_signal.py +2 -0
  31. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/integration/test_substrate_fingerprint.py +5 -1
  32. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_architecture.py +4 -1
  33. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_api_types.py +27 -0
  34. cli_agent_runner-0.2.0/tests/unit/test_catalogs.py +10 -0
  35. cli_agent_runner-0.2.0/tests/unit/test_default_dirty_handler.py +77 -0
  36. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_defenses.py +2 -2
  37. cli_agent_runner-0.2.0/tests/unit/test_dirty_handlers.py +49 -0
  38. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_init_entry_points.py +3 -2
  39. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_monitor_detectors.py +1 -18
  40. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_runner.py +2 -2
  41. cli_agent_runner-0.2.0/tests/unit/test_serve_config_broken.py +33 -0
  42. cli_agent_runner-0.2.0/tests/unit/test_serve_crash_loop.py +128 -0
  43. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_vcs_state.py +92 -0
  44. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/.codecov.yml +0 -0
  45. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  46. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  47. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  48. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  49. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/.github/workflows/ci.yml +0 -0
  50. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/.github/workflows/release.yml +0 -0
  51. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/.gitignore +0 -0
  52. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/.vulture-whitelist.py +0 -0
  53. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/CODE_OF_CONDUCT.md +0 -0
  54. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/CONTRIBUTING.md +0 -0
  55. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/LICENSE +0 -0
  56. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/SECURITY.md +0 -0
  57. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/_docgen.py +0 -0
  58. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/_redact.py +0 -0
  59. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/_registry.py +0 -0
  60. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/_substrate.py +0 -0
  61. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/_throttle.py +0 -0
  62. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/agent_runtime.py +0 -0
  63. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/builtin_plugins/__init__.py +0 -0
  64. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/builtin_plugins/_constants.py +0 -0
  65. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/builtin_plugins/claude_rate_limit.py +0 -0
  66. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/builtin_plugins/codewhale.py +0 -0
  67. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/builtin_plugins/gemini.py +0 -0
  68. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/cli/__init__.py +0 -0
  69. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/cli/__main__.py +0 -0
  70. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/cli/common.py +0 -0
  71. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/cli/events_cmd.py +0 -0
  72. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/cli/init_cmd.py +0 -0
  73. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/cli/install_cmd.py +0 -0
  74. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/cli/monitor_cmd.py +0 -0
  75. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/cli/peek_cmd.py +0 -0
  76. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/cli/round_cmd.py +0 -0
  77. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/cli/service_cmd.py +0 -0
  78. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/cli/upgrade_cmd.py +0 -0
  79. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/config.py +0 -0
  80. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/context_store.py +0 -0
  81. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/detector_helpers.py +0 -0
  82. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/http_progress.py +0 -0
  83. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/lifecycle.py +0 -0
  84. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/metrics.py +0 -0
  85. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/presets/__init__.py +0 -0
  86. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/presets/aider.toml +0 -0
  87. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/presets/claude.toml +0 -0
  88. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/presets/codewhale.toml +0 -0
  89. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/presets/gemini.toml +0 -0
  90. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/prompt_loader.py +0 -0
  91. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/round_log.py +0 -0
  92. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/round_view.py +0 -0
  93. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/scaffold.py +0 -0
  94. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/service_unit.py +0 -0
  95. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/agent_runner/startup_check.py +0 -0
  96. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/build.sh +0 -0
  97. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/deploy/example-agent-runner.toml +0 -0
  98. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/deploy/launchd.plist.tmpl +0 -0
  99. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/deploy/run-loop.sh +0 -0
  100. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/deploy/systemd.service.tmpl +0 -0
  101. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/README.md +0 -0
  102. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/events.md +0 -0
  103. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/long-running-agents.md +0 -0
  104. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/marketing/README.md +0 -0
  105. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/marketing/promo-cn.html +0 -0
  106. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.16.md +0 -0
  107. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.17.md +0 -0
  108. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.19.md +0 -0
  109. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.20.md +0 -0
  110. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.21.md +0 -0
  111. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.22.md +0 -0
  112. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.23.md +0 -0
  113. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.24.md +0 -0
  114. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.25.md +0 -0
  115. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.26.md +0 -0
  116. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.27.md +0 -0
  117. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.28.md +0 -0
  118. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.29.md +0 -0
  119. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.30.md +0 -0
  120. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.31.md +0 -0
  121. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.32.md +0 -0
  122. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.33.md +0 -0
  123. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.34.md +0 -0
  124. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.35.md +0 -0
  125. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.36.md +0 -0
  126. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.37.md +0 -0
  127. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.38.md +0 -0
  128. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.39.md +0 -0
  129. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/migrations/0.1.40.md +0 -0
  130. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/quickstart.md +0 -0
  131. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/recipes/aider.md +0 -0
  132. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/docs/recipes/codewhale.md +0 -0
  133. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/__init__.py +0 -0
  134. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/conftest.py +0 -0
  135. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/contract/__init__.py +0 -0
  136. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/contract/test_public_api_surface.py +0 -0
  137. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/e2e/__init__.py +0 -0
  138. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/e2e/conftest.py +0 -0
  139. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/e2e/test_e2e_graceful_stop.py +0 -0
  140. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/e2e/test_e2e_install_systemd.py +0 -0
  141. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/e2e/test_e2e_monitor_remote.py +0 -0
  142. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/e2e/test_e2e_round_lifecycle.py +0 -0
  143. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/fixtures/cli-real-output/claude-2.1.143-assistant-tool-use.jsonl +0 -0
  144. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/fixtures/cli-real-output/claude-2.1.143-result-event.jsonl +0 -0
  145. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/fixtures/cli-real-output/gemini-0.42.0-result-event.jsonl +0 -0
  146. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/integration/__init__.py +0 -0
  147. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/integration/test_context_enricher_namespacing.py +0 -0
  148. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/integration/test_grace_kill_emission.py +0 -0
  149. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/integration/test_install_dry_run.py +0 -0
  150. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/integration/test_monitor_seeded.py +0 -0
  151. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/integration/test_plugin_detector_loaded.py +0 -0
  152. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/integration/test_plugin_owned_paths.py +0 -0
  153. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/integration/test_plugin_real_flow.py +0 -0
  154. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/integration/test_run_one_round_with_fake_agent.py +0 -0
  155. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/integration/test_scaffold_presets.py +0 -0
  156. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/integration/test_serve_loop.py +0 -0
  157. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/integration/test_transient_error_backoff.py +0 -0
  158. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/__init__.py +0 -0
  159. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_atomic_write_enforced.py +0 -0
  160. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_catalogs.py +0 -0
  161. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_classification_ssot.py +0 -0
  162. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_doc_claims_match_ssot.py +0 -0
  163. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_docs_generated.py +0 -0
  164. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_entry_points_resolve.py +0 -0
  165. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_event_kind_registry.py +0 -0
  166. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_event_kinds_ssot.py +0 -0
  167. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_events_doc_contract.py +0 -0
  168. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_layer_2_loop_size.py +0 -0
  169. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_module_boundaries.py +0 -0
  170. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_module_sizes.py +0 -0
  171. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_no_ai_signatures.py +0 -0
  172. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_no_pytest_skip_on_parse_fail.py +0 -0
  173. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_peek_schema_version.py +0 -0
  174. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_repo_constants_patched_in_tests.py +0 -0
  175. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_round_result_stable.py +0 -0
  176. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_stash_uses_sha_not_index.py +0 -0
  177. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/invariants/test_upstream_schema_canary.py +0 -0
  178. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/literate/__init__.py +0 -0
  179. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/literate/parser.py +0 -0
  180. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/literate/test_parser.py +0 -0
  181. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/literate/test_quickstart.py +0 -0
  182. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/__init__.py +0 -0
  183. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_agent_runtime.py +0 -0
  184. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_agent_runtime_grace.py +0 -0
  185. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_agent_runtime_progress.py +0 -0
  186. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_api_assemble_prompt.py +0 -0
  187. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_api_events_stream.py +0 -0
  188. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_api_install.py +0 -0
  189. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_api_observation.py +0 -0
  190. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_api_read_round_num.py +0 -0
  191. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_api_resolve_phase.py +0 -0
  192. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_api_service.py +0 -0
  193. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_auto_stop_gating.py +0 -0
  194. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_claude_error_detector.py +0 -0
  195. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_cli.py +0 -0
  196. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_cli_common.py +0 -0
  197. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_cli_init_install.py +0 -0
  198. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_cli_monitor_http.py +0 -0
  199. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_cli_service_peek_monitor.py +0 -0
  200. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_cli_upgrade.py +0 -0
  201. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_codewhale_plugin.py +0 -0
  202. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_config.py +0 -0
  203. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_config_fresh_eyes.py +0 -0
  204. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_config_max_rounds.py +0 -0
  205. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_config_stop_file.py +0 -0
  206. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_config_substrate_fingerprint_paths.py +0 -0
  207. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_config_transient_error_action.py +0 -0
  208. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_context_store.py +0 -0
  209. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_detector_helpers.py +0 -0
  210. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_detector_protocol.py +0 -0
  211. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_docgen.py +0 -0
  212. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_events.py +0 -0
  213. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_events_cmd.py +0 -0
  214. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_fresh_eyes_trigger.py +0 -0
  215. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_gemini_plugin.py +0 -0
  216. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_hook_failure_isolation.py +0 -0
  217. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_hooks.py +0 -0
  218. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_http_progress.py +0 -0
  219. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_lifecycle.py +0 -0
  220. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_metrics.py +0 -0
  221. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_monitor_assembly.py +0 -0
  222. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_monitor_detect_anomaly_repetitive.py +0 -0
  223. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_monitor_detect_rate_limit.py +0 -0
  224. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_monitor_detect_supervisor_stale.py +0 -0
  225. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_monitor_remote.py +0 -0
  226. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_peek_argparse.py +0 -0
  227. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_peek_select.py +0 -0
  228. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_presets.py +0 -0
  229. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_prompt_loader.py +0 -0
  230. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_redact.py +0 -0
  231. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_round_log_helpers.py +0 -0
  232. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_round_view.py +0 -0
  233. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_runner_throttle.py +0 -0
  234. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_scaffold.py +0 -0
  235. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_serve_cmd_bounded.py +0 -0
  236. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_serve_round_log.py +0 -0
  237. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_serve_sentinel.py +0 -0
  238. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_serve_startup_hooks.py +0 -0
  239. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_service_unit.py +0 -0
  240. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_startup_check.py +0 -0
  241. {cli_agent_runner-0.1.41 → cli_agent_runner-0.2.0}/tests/unit/test_substrate.py +0 -0
@@ -5,6 +5,30 @@ 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.0] - 2026-07-02
9
+
10
+ ### Changed
11
+ - Dirty-tree handling is now pluggable. The clean-exit-dirty policy moved out of core into a `dirty_handler` hook; `stash` / `ignore` / `auto_commit` now ship as the bundled, default-on `default_dirty_handler` plugin. **No behavior or config change for existing users** — `[vcs] dirty_action` works exactly as before.
12
+
13
+ ### Added
14
+ - `dirty_handler` plugin seam (4th lifecycle hook) + `DirtyOutcome` on `RoundResult`; `stash_orphan` / `try_auto_commit` are now public `api` primitives. Consumers can fully own dirty-tree policy — see `docs/migrations/0.2.0.md`.
15
+ - `dirty_auto_committed` event (emitted when a handler auto-commits).
16
+
17
+ ## [0.1.42] - 2026-06-25
18
+
19
+ ### Added
20
+ - `crash_loop` defense — serve stops after 5 consecutive *unknown* short crashes (non-zero exit, <60s, no classified transient), escalating the restart delay and recording the failure reason. Ends the respawn-forever crash loop; recoverable-slow failures (rate-limit / quota / 5xx / timeout) still ride the transient-error backoff unchanged.
21
+ - `config_broken` defense — a permanent startup-battery failure now halts serve (distinct no-retry exit code `78`) instead of respawning a broken config every round.
22
+
23
+ ### Fixed
24
+ - `vcs.dirty_action` no longer sweeps the runner's own `log_dir` bookkeeping when `log_dir` is inside `work_dir`: `auto_commit` excludes it from the commit (no more phantom `git_head` advance on a zero-work round) and `stash` excludes it from `git stash push -u` (logs no longer vanish). `.evolving/` and agent work are unaffected.
25
+
26
+ ### Removed
27
+ - The inert `smoke_fail_rate` monitor alert (could never fire — superseded by the always-on `config_broken` stop). Monitor now ships 11 detectors.
28
+
29
+ ### Docs
30
+ - `thesis.md`: the stuck-loop defense is described honestly as a notify-level, opt-in-to-auto-stop monitor detector (`anomaly_repetitive_active`), not a default hard-stop; fixed the `stuck_loop_detected` naming drift.
31
+
8
32
  ## [0.1.41] - 2026-06-07
9
33
 
10
34
  ### Added
@@ -851,7 +875,8 @@ Initial public release on PyPI as `cli-agent-runner`.
851
875
  - Tag-triggered release publishing to PyPI via Trusted Publishing OIDC,
852
876
  gated by a manual approval on the `pypi` GitHub environment.
853
877
 
854
- [Unreleased]: https://github.com/wan9yu/cli-agent-runner/compare/v0.1.26...HEAD
878
+ [Unreleased]: https://github.com/wan9yu/cli-agent-runner/compare/v0.2.0...HEAD
879
+ [0.2.0]: https://github.com/wan9yu/cli-agent-runner/compare/v0.1.42...v0.2.0
855
880
  [0.1.26]: https://github.com/wan9yu/cli-agent-runner/compare/v0.1.25...v0.1.26
856
881
  [0.1.25]: https://github.com/wan9yu/cli-agent-runner/compare/v0.1.24...v0.1.25
857
882
  [0.1.24]: https://github.com/wan9yu/cli-agent-runner/compare/v0.1.23...v0.1.24
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-agent-runner
3
- Version: 0.1.41
3
+ Version: 0.2.0
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
@@ -49,7 +49,7 @@ full disks, runaway memory.
49
49
 
50
50
  ```
51
51
  ┌──────────────────────────────────────────┐
52
- │ Layer 3: The Witness (monitor) │ 12 detectors + auto-stop
52
+ │ Layer 3: The Witness (monitor) │ 11 detectors + auto-stop
53
53
  ├──────────────────────────────────────────┤
54
54
  │ Layer 2: The Loop (serve, ~120 LOC) │ signal-trapping restart loop
55
55
  ├──────────────────────────────────────────┤
@@ -86,14 +86,14 @@ Full walkthrough: [`docs/quickstart.md`](docs/quickstart.md).
86
86
  |---|---|
87
87
  | `init` / `install` / `uninstall` | `peek` — state snapshot |
88
88
  | `start` / `stop` / `kill` / `cancel` | `watch` — peek in a refresh loop |
89
- | `restart` / `status` | `monitor` — 12 detectors, alerts, auto-stop |
89
+ | `restart` / `status` | `monitor` — 11 detectors, alerts, auto-stop |
90
90
  | `round` / `serve` / `upgrade` | `events` — query / stream events.jsonl |
91
91
 
92
92
  Verb reference: [`docs/commands.md`](docs/commands.md).
93
93
 
94
94
  ## Defenses (built in)
95
95
 
96
- 11 named defenses, structured as data — see `agent-runner peek --select defenses`.
96
+ 12 named defenses, structured as data — see `agent-runner peek --select defenses`.
97
97
  Each carries the historical incident it codifies and the invariant test that
98
98
  guards it. Highlights:
99
99
 
@@ -106,10 +106,10 @@ guards it. Highlights:
106
106
 
107
107
  Full list and rationale: [`docs/architecture.md`](docs/architecture.md).
108
108
 
109
- ## Monitor: 12 detectors
109
+ ## Monitor: 11 detectors
110
110
 
111
111
  Notify only: `timeout_rate`, `hung`, `orphan_chain`, `disk_warning`,
112
- `mem_pressure`, `smoke_fail_rate`, `network_fail`, `rate_limit_active`,
112
+ `mem_pressure`, `network_fail`, `rate_limit_active`,
113
113
  `anomaly_repetitive_active`, `supervisor_stale`.
114
114
 
115
115
  **Auto-stop the service** (continuing is harmful):
@@ -12,7 +12,7 @@ full disks, runaway memory.
12
12
 
13
13
  ```
14
14
  ┌──────────────────────────────────────────┐
15
- │ Layer 3: The Witness (monitor) │ 12 detectors + auto-stop
15
+ │ Layer 3: The Witness (monitor) │ 11 detectors + auto-stop
16
16
  ├──────────────────────────────────────────┤
17
17
  │ Layer 2: The Loop (serve, ~120 LOC) │ signal-trapping restart loop
18
18
  ├──────────────────────────────────────────┤
@@ -49,14 +49,14 @@ Full walkthrough: [`docs/quickstart.md`](docs/quickstart.md).
49
49
  |---|---|
50
50
  | `init` / `install` / `uninstall` | `peek` — state snapshot |
51
51
  | `start` / `stop` / `kill` / `cancel` | `watch` — peek in a refresh loop |
52
- | `restart` / `status` | `monitor` — 12 detectors, alerts, auto-stop |
52
+ | `restart` / `status` | `monitor` — 11 detectors, alerts, auto-stop |
53
53
  | `round` / `serve` / `upgrade` | `events` — query / stream events.jsonl |
54
54
 
55
55
  Verb reference: [`docs/commands.md`](docs/commands.md).
56
56
 
57
57
  ## Defenses (built in)
58
58
 
59
- 11 named defenses, structured as data — see `agent-runner peek --select defenses`.
59
+ 12 named defenses, structured as data — see `agent-runner peek --select defenses`.
60
60
  Each carries the historical incident it codifies and the invariant test that
61
61
  guards it. Highlights:
62
62
 
@@ -69,10 +69,10 @@ guards it. Highlights:
69
69
 
70
70
  Full list and rationale: [`docs/architecture.md`](docs/architecture.md).
71
71
 
72
- ## Monitor: 12 detectors
72
+ ## Monitor: 11 detectors
73
73
 
74
74
  Notify only: `timeout_rate`, `hung`, `orphan_chain`, `disk_warning`,
75
- `mem_pressure`, `smoke_fail_rate`, `network_fail`, `rate_limit_active`,
75
+ `mem_pressure`, `network_fail`, `rate_limit_active`,
76
76
  `anomaly_repetitive_active`, `supervisor_stale`.
77
77
 
78
78
  **Auto-stop the service** (continuing is harmful):
@@ -6,7 +6,7 @@
6
6
 
7
7
  把任意 CLI agent(Claude Code、自研 agent、任何长跑命令)包装成可被
8
8
  systemd / launchd 拉起、能被远程观测的服务。**每轮跑完进程退出**,外层
9
- supervisor 重启 —— 这是核心模式。中间穿插 11 条防御,避开 production 上
9
+ supervisor 重启 —— 这是核心模式。中间穿插 12 条防御,避开 production 上
10
10
  最容易翻车的几条路:
11
11
 
12
12
  - 轮卡死、Tool 调用空转 → 硬墙 timeout
@@ -20,7 +20,7 @@ supervisor 重启 —— 这是核心模式。中间穿插 11 条防御,避开
20
20
 
21
21
  ```
22
22
  ┌──────────────────────────────────────────┐
23
- │ Layer 3:Witness(monitor) │ 12 个检测器 + 自动停服
23
+ │ Layer 3:Witness(monitor) │ 11 个检测器 + 自动停服
24
24
  ├──────────────────────────────────────────┤
25
25
  │ Layer 2:Loop(serve,~120 LOC 薄壳) │ 捕获信号,循环拉起 round
26
26
  ├──────────────────────────────────────────┤
@@ -63,7 +63,7 @@ agent-runner monitor # 实时异常检测,OAuth/磁盘 critical
63
63
  |---|---|
64
64
  | `init` / `install` / `uninstall` | `peek` —— 项目状态快照 |
65
65
  | `start` / `stop` / `kill` / `cancel` | `watch` —— peek 在刷新循环里 |
66
- | `restart` / `status` | `monitor` —— 12 个检测器 + 告警 + 自动停服 |
66
+ | `restart` / `status` | `monitor` —— 11 个检测器 + 告警 + 自动停服 |
67
67
  | `round` / `serve` / `upgrade` | `events` —— 查询 / 流式订阅 events.jsonl |
68
68
 
69
69
  **停服三动词**有清晰的语义分层:
@@ -73,7 +73,7 @@ agent-runner monitor # 实时异常检测,OAuth/磁盘 critical
73
73
 
74
74
  动词参考:[`docs/commands.md`](docs/commands.md)。
75
75
 
76
- ## 内置防御(11 条)
76
+ ## 内置防御(12 条)
77
77
 
78
78
  防御以数据形式定义在 `agent_runner/defenses.py`,可通过
79
79
  `agent-runner peek --select defenses` 直接拿到。每条防御自带:
@@ -95,11 +95,11 @@ agent-runner monitor # 实时异常检测,OAuth/磁盘 critical
95
95
 
96
96
  完整列表 + 历史出处:[`docs/architecture.md`](docs/architecture.md)。
97
97
 
98
- ## Monitor:12 个检测器
98
+ ## Monitor:11 个检测器
99
99
 
100
100
  **只告警**(warning 级,服务继续跑):
101
101
  `timeout_rate` / `hung` / `orphan_chain` / `disk_warning` /
102
- `mem_pressure` / `smoke_fail_rate` / `network_fail` / `rate_limit_active` /
102
+ `mem_pressure` / `network_fail` / `rate_limit_active` /
103
103
  `anomaly_repetitive_active` / `supervisor_stale`
104
104
 
105
105
  **自动停服**(critical 级,继续是 net negative):
@@ -14,6 +14,7 @@ _HOOK_GROUPS = (
14
14
  "agent_runner.context_enrichers",
15
15
  "agent_runner.post_round_hooks",
16
16
  "agent_runner.serve_startup_hooks",
17
+ "agent_runner.dirty_handler_hooks",
17
18
  )
18
19
 
19
20
  # Tracks the names passed to the most recent ``apply_plugin_disable`` call.
@@ -111,6 +112,9 @@ def apply_plugin_disable(names: list[str]) -> None:
111
112
  # Serve-startup hooks
112
113
  _prune_by_name(hooks._SERVE_STARTUP_HOOKS, desired, found)
113
114
 
115
+ # Dirty handlers
116
+ _prune_by_name(hooks._DIRTY_HANDLERS, desired, found)
117
+
114
118
  # Plugin event kinds
115
119
  for name in list(events._PLUGIN_KINDS):
116
120
  if name in desired:
@@ -45,6 +45,29 @@ def emit_max_rounds_reached(log_dir: Path, *, rounds_completed: int, max_rounds:
45
45
  emit(log_dir, MAX_ROUNDS_REACHED, rounds_completed=rounds_completed, max_rounds=max_rounds)
46
46
 
47
47
 
48
+ def emit_config_broken(log_dir: Path, *, reason: str) -> None:
49
+ """Emit config_broken (serve stopped on a permanent startup-battery failure)."""
50
+ from agent_runner.events import CONFIG_BROKEN, emit
51
+
52
+ emit(log_dir, CONFIG_BROKEN, reason=reason)
53
+
54
+
55
+ def emit_crash_loop(log_dir: Path, *, consecutive: int, exit_code: int, log_path: Path) -> None:
56
+ """Emit crash_loop (serve stopped after consecutive unknown short crashes).
57
+
58
+ Captures the failure reason — a redacted tail of the round log — so a
59
+ recurring unknown crash can later be classified into a transient bucket.
60
+ """
61
+ from agent_runner._redact import redact_secrets
62
+ from agent_runner.events import CRASH_LOOP, emit
63
+
64
+ try:
65
+ reason = redact_secrets(log_path.read_text(errors="replace")[-2000:])
66
+ except OSError:
67
+ reason = ""
68
+ emit(log_dir, CRASH_LOOP, consecutive=consecutive, exit_code=exit_code, reason=reason)
69
+
70
+
48
71
  def emit_stop_file_detected(
49
72
  log_dir: Path, *, stop_file: Path, content: str, rounds_completed: int
50
73
  ) -> None:
@@ -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.1.41'
22
- __version_tuple__ = version_tuple = (0, 1, 41)
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -18,7 +18,7 @@ import sysconfig
18
18
  import time
19
19
  from collections.abc import Iterator
20
20
  from pathlib import Path
21
- from typing import Any
21
+ from typing import Any, Literal
22
22
 
23
23
  from agent_runner import events, lifecycle
24
24
  from agent_runner.api_types import (
@@ -45,6 +45,59 @@ from agent_runner.service_unit import (
45
45
  serve_unit_filename,
46
46
  )
47
47
 
48
+ # Exit code for a permanent (no-retry) startup-battery failure. A broken config
49
+ # does not self-heal between rounds, so serve STOPS rather than respawning it
50
+ # forever. 78 = EX_CONFIG (sysexits) — avoids argparse's 2 and the generic 1.
51
+ # Lives here (not runner.py) so serve_cmd can import it from the sanctioned api
52
+ # facade without coupling to runner (runner imports api, not the reverse).
53
+ PERMANENT_CONFIG_EXIT = 78
54
+
55
+ # Crash-loop circuit breaker (b12). The serve loop escalates the restart delay
56
+ # on consecutive UNKNOWN short crashes (non-zero exit, short duration, no
57
+ # classified transient) and STOPS after CRASH_LOOP_THRESHOLD of them — the Run 6
58
+ # ~100-empty-rounds scar. Recoverable-slow failures (rate limit / 5h quota / 5xx
59
+ # / timeout) are already handled by the transient-error throttle and never reach
60
+ # this path. A clean (exit 0), long, or classified-transient round resets the run.
61
+ CRASH_LOOP_THRESHOLD = 5
62
+ CRASH_LOOP_SHORT_EXIT_S = 60 # mirrors monitor.SHORT_EXIT_THRESHOLD_S
63
+ CRASH_LOOP_MAX_DELAY_S = 1800 # cap the escalating restart delay (30 min)
64
+
65
+
66
+ def post_round_decision(
67
+ *,
68
+ returncode: int,
69
+ duration_s: float,
70
+ throttle_active: bool,
71
+ consecutive: int,
72
+ restart_delay_s: int,
73
+ ) -> tuple[Literal["config_broken", "crash_loop", "continue"], int, int]:
74
+ """Restart policy after one round — keeps the serve loop a thin dispatcher.
75
+
76
+ Returns ``(action, delay_s, consecutive)`` where action is:
77
+ - ``"config_broken"`` — permanent startup failure (b18): stop.
78
+ - ``"crash_loop"`` — CRASH_LOOP_THRESHOLD consecutive unknown short crashes
79
+ (b12): stop. An unknown short crash is a non-zero, fast exit with no
80
+ classified transient (rate-limit/5xx/timeout are handled by the throttle).
81
+ - ``"continue"`` — sleep ``delay_s`` then run the next round.
82
+
83
+ A clean (exit 0), long, or transient round resets ``consecutive`` to 0; an
84
+ unknown short crash escalates the delay (restart × 2ⁿ, capped) until the stop.
85
+ """
86
+ if returncode == PERMANENT_CONFIG_EXIT:
87
+ return ("config_broken", 0, consecutive)
88
+ unknown_short_crash = (
89
+ returncode != 0 and duration_s < CRASH_LOOP_SHORT_EXIT_S and not throttle_active
90
+ )
91
+ if unknown_short_crash:
92
+ consecutive += 1
93
+ if consecutive >= CRASH_LOOP_THRESHOLD:
94
+ return ("crash_loop", 0, consecutive)
95
+ delay = min(restart_delay_s * 2**consecutive, CRASH_LOOP_MAX_DELAY_S)
96
+ return ("continue", delay, consecutive)
97
+ delay = restart_delay_s if returncode == 0 else restart_delay_s * 2
98
+ return ("continue", delay, 0)
99
+
100
+
48
101
  _PROJECT_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
49
102
 
50
103
  _LINGER_HINT = (
@@ -730,6 +783,8 @@ def check_self_terminated_sentinel(log_dir: Path) -> bool:
730
783
  from agent_runner._emit import ( # noqa: E402,F401 — intentional bottom re-export
731
784
  emit_agent_usage_recorded,
732
785
  emit_anomaly_repetitive_tool,
786
+ emit_config_broken,
787
+ emit_crash_loop,
733
788
  emit_fresh_eyes_round_triggered,
734
789
  emit_max_rounds_reached,
735
790
  emit_rate_limit_stop,
@@ -744,6 +799,12 @@ from agent_runner._emit import ( # noqa: E402,F401 — intentional bottom re-ex
744
799
  emit_transient_error_recovered,
745
800
  )
746
801
 
802
+ # Re-export git primitives so external callers can reach them via the api facade.
803
+ from agent_runner.vcs_state import ( # noqa: E402,F401 — public primitives
804
+ stash_orphan,
805
+ try_auto_commit,
806
+ )
807
+
747
808
 
748
809
  def narrate_events(log_dir: Path, *, poll_interval_s: float = 0.5) -> Iterator[str]:
749
810
  """Tail events-*.jsonl files in log_dir, yielding one formatted line per event.
@@ -28,6 +28,18 @@ class ServiceMode(StrEnum):
28
28
  NONE = "none"
29
29
 
30
30
 
31
+ @dataclass(frozen=True)
32
+ class DirtyOutcome:
33
+ """What a dirty tree resolved to, reported on RoundResult and via events.
34
+
35
+ ``ref`` is the stash SHA (kind="stashed") or commit SHA (kind="committed");
36
+ None for "ignored".
37
+ """
38
+
39
+ kind: Literal["stashed", "committed", "ignored"]
40
+ ref: str | None = None
41
+
42
+
31
43
  @dataclass(frozen=True)
32
44
  class RateLimitState:
33
45
  """Public: surfaced via peek --json when supervisor is currently throttled."""
@@ -158,6 +170,7 @@ class RoundResult:
158
170
  log_path: Path
159
171
  dirty_files: list[str]
160
172
  stashed: bool
173
+ dirty_outcome: DirtyOutcome | None = None
161
174
  killed_for_grace: bool = False
162
175
 
163
176
 
@@ -0,0 +1,83 @@
1
+ """Bundled default dirty-tree handler — reproduces the pre-0.2.0 built-in
2
+ behavior (stash / ignore / auto_commit per [vcs] dirty_action) as a plugin.
3
+ Ships enabled; disable via [plugins] disable = ["default_dirty_handler"]."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from agent_runner import api, context_store, events, vcs_state
8
+ from agent_runner.api_types import DirtyOutcome
9
+ from agent_runner.events import now_iso_ms # match the helper runner.py uses
10
+ from agent_runner.hooks import HookContext, register_dirty_handler
11
+ from agent_runner.vcs_state import AutoCommitError
12
+
13
+
14
+ class DefaultDirtyHandler:
15
+ name = "default_dirty_handler"
16
+ priority = 1000 # last-resort fallback
17
+
18
+ def handle_dirty(self, ctx: HookContext, dirty_files) -> DirtyOutcome | None:
19
+ action = ctx.vcs.dirty_action if ctx.vcs else "stash"
20
+ if action == "ignore":
21
+ return DirtyOutcome(kind="ignored")
22
+ if action == "auto_commit":
23
+ try:
24
+ sha = api.try_auto_commit(
25
+ ctx.work_dir, ctx.round_num, ctx.phase, log_dir=ctx.log_dir
26
+ )
27
+ except AutoCommitError as exc:
28
+ # Parity with runner.py: emit failure event, leave tree dirty
29
+ # (no stash fallback). Dirty tree carries into the next round.
30
+ events.emit(
31
+ ctx.log_dir,
32
+ events.DIRTY_COMMIT_FAILED,
33
+ round_num=ctx.round_num,
34
+ phase=ctx.phase,
35
+ reason=str(exc),
36
+ )
37
+ return DirtyOutcome(kind="ignored")
38
+ if not sha:
39
+ return DirtyOutcome(kind="ignored")
40
+ events.emit(
41
+ ctx.log_dir,
42
+ events.DIRTY_AUTO_COMMITTED,
43
+ round_num=ctx.round_num,
44
+ files=dirty_files[:20],
45
+ ref=sha,
46
+ )
47
+ return DirtyOutcome(kind="committed", ref=sha)
48
+ return self._stash(ctx, dirty_files)
49
+
50
+ def _stash(self, ctx: HookContext, dirty_files) -> DirtyOutcome:
51
+ # via vcs_state (not api): test_runner.py monkeypatches vcs_state.stash_orphan;
52
+ # the api re-export is a separate binding a patch wouldn't intercept.
53
+ ref = vcs_state.stash_orphan(
54
+ ctx.work_dir,
55
+ round_num=ctx.round_num,
56
+ phase=ctx.phase,
57
+ idempotency_s=ctx.vcs.stash_idempotency_s if ctx.vcs else 5,
58
+ log_dir=ctx.log_dir,
59
+ )
60
+ if ref is None:
61
+ return DirtyOutcome(kind="ignored")
62
+ context_store.write_orphan_state(
63
+ ctx.log_dir,
64
+ context_store.OrphanState(
65
+ round_num=ctx.round_num,
66
+ files=dirty_files,
67
+ stashed_ref=ref.sha,
68
+ stash_message=ref.message,
69
+ timestamp=now_iso_ms(),
70
+ phase=ctx.phase,
71
+ ),
72
+ )
73
+ events.emit(
74
+ ctx.log_dir,
75
+ events.ORPHAN_STASHED,
76
+ round_num=ctx.round_num,
77
+ ref=ref.sha,
78
+ reason="clean_exit_with_dirty_tree",
79
+ )
80
+ return DirtyOutcome(kind="stashed", ref=ref.sha)
81
+
82
+
83
+ register_dirty_handler(DefaultDirtyHandler())
@@ -23,12 +23,15 @@ from agent_runner._throttle import _check_throttle_state
23
23
  from agent_runner._throttle import reset_counters as _reset_counters
24
24
  from agent_runner.api import (
25
25
  check_self_terminated_sentinel,
26
+ emit_config_broken,
27
+ emit_crash_loop,
26
28
  emit_fresh_eyes_round_triggered,
27
29
  emit_max_rounds_reached,
28
30
  emit_rate_limit_stop,
29
31
  emit_round_substrate_after,
30
32
  emit_round_substrate_before,
31
33
  emit_stop_file_detected,
34
+ post_round_decision,
32
35
  )
33
36
  from agent_runner.cli.common import cfg_from_args
34
37
  from agent_runner.hooks import run_serve_startup_hooks
@@ -135,6 +138,7 @@ def cmd(args) -> int:
135
138
  stop_file = cfg.runtime.stop_file # cache: same pattern as effective_max_rounds
136
139
  work_dir = cfg.runtime.work_dir
137
140
  rounds_completed = 0
141
+ consecutive_crashes = 0 # b12: consecutive UNKNOWN short crashes (crash-loop breaker)
138
142
 
139
143
  try:
140
144
  pid_file.write(os.getpid())
@@ -197,6 +201,7 @@ def cmd(args) -> int:
197
201
  every_n=cfg.runtime.fresh_eyes_every_n,
198
202
  )
199
203
  round_log_path = log_dir / f"round-{round_num}.log"
204
+ round_started = time.monotonic()
200
205
  with round_log_path.open("w") as f:
201
206
  r = subprocess.run(
202
207
  [
@@ -211,6 +216,7 @@ def cmd(args) -> int:
211
216
  stdout=f,
212
217
  stderr=subprocess.STDOUT,
213
218
  )
219
+ round_duration_s = time.monotonic() - round_started
214
220
  atomic_relink(log_dir / ROUND_CURRENT_LINK, round_log_path)
215
221
  git_head_after = compute_git_head(work_dir)
216
222
  paths_hash_after = compute_paths_hash(work_dir, cfg.runtime.substrate_fingerprint_paths)
@@ -221,13 +227,28 @@ def cmd(args) -> int:
221
227
  paths_hash=paths_hash_after,
222
228
  )
223
229
  rounds_completed += 1
230
+ # Restart policy (config_broken / crash_loop / continue) lives in the
231
+ # tested api.post_round_decision helper so this loop stays thin.
232
+ action, delay, consecutive_crashes = post_round_decision(
233
+ returncode=r.returncode,
234
+ duration_s=round_duration_s,
235
+ throttle_active=_check_throttle_state(log_dir) is not None,
236
+ consecutive=consecutive_crashes,
237
+ restart_delay_s=cfg.runtime.restart_delay_s,
238
+ )
239
+ if action == "config_broken":
240
+ emit_config_broken(log_dir, reason="startup battery permanent failure")
241
+ break
242
+ if action == "crash_loop":
243
+ emit_crash_loop(
244
+ log_dir,
245
+ consecutive=consecutive_crashes,
246
+ exit_code=r.returncode,
247
+ log_path=round_log_path,
248
+ )
249
+ break
224
250
  if args.once or stop["requested"]:
225
251
  break
226
- delay = (
227
- cfg.runtime.restart_delay_s
228
- if r.returncode == 0
229
- else cfg.runtime.restart_delay_s * 2
230
- )
231
252
  time.sleep(delay)
232
253
  finally:
233
254
  pid_file.unlink()
@@ -83,8 +83,18 @@ def catalog(cfg: Config) -> list[Defense]:
83
83
  Defense(
84
84
  name="startup_smoke_check",
85
85
  value="6 checks (config / log_dir / agent_cli / git / prompt_file / prompt_smoke)",
86
- codifies="R721 + #446 — _common.md frontmatter caused 4h/123-round silent burn",
87
- guarded_by=None,
86
+ codifies=(
87
+ "R721 + #446 — _common.md frontmatter caused 4h/123-round silent burn; "
88
+ "now halts serve (config_broken) instead of respawning a broken config"
89
+ ),
90
+ guarded_by=Path("tests/unit/test_serve_config_broken.py"),
91
+ current_state="active",
92
+ ),
93
+ Defense(
94
+ name="crash_loop_breaker",
95
+ value="stop after 5 consecutive short crashes; exp-escalating delay",
96
+ codifies="Run 6 — crashing agent respawned ~100 empty rounds at a fixed 2x delay",
97
+ guarded_by=Path("tests/unit/test_serve_crash_loop.py"),
88
98
  current_state="active",
89
99
  ),
90
100
  Defense(
@@ -32,6 +32,9 @@ ANOMALY_REPETITIVE_TOOL = "anomaly_repetitive_tool"
32
32
  AGENT_NETWORK_BLIP = "agent_network_blip"
33
33
  AGENT_SPAWN = "agent_spawn"
34
34
  AGENT_USAGE_RECORDED = "agent_usage_recorded"
35
+ CONFIG_BROKEN = "config_broken"
36
+ CRASH_LOOP = "crash_loop"
37
+ DIRTY_AUTO_COMMITTED = "dirty_auto_committed"
35
38
  DIRTY_COMMIT_FAILED = "dirty_commit_failed"
36
39
  DIRTY_DETECTED = "dirty_detected"
37
40
  FRESH_EYES_ROUND_TRIGGERED = "fresh_eyes_round_triggered"
@@ -30,6 +30,7 @@ Public API:
30
30
  from __future__ import annotations
31
31
 
32
32
  import sys
33
+ import traceback as tb_mod
33
34
  from dataclasses import dataclass
34
35
  from pathlib import Path
35
36
  from typing import Any, Protocol, runtime_checkable
@@ -43,6 +44,14 @@ _TAIL_BYTES = 1024
43
44
  _TRUNC_MARKER = "\n... [truncated] ...\n"
44
45
 
45
46
 
47
+ @dataclass(frozen=True)
48
+ class VcsHookView:
49
+ """Minimal vcs config a dirty handler needs (keeps HookContext narrow)."""
50
+
51
+ dirty_action: str
52
+ stash_idempotency_s: int
53
+
54
+
46
55
  @dataclass(frozen=True)
47
56
  class HookContext:
48
57
  """Narrow per-round context passed to all hook callbacks.
@@ -86,6 +95,26 @@ class HookContext:
86
95
  """Count threshold for repetitive-tool anomaly (0 = disabled).
87
96
  Populated from ``[monitor] anomaly_repetitive_threshold`` (0.1.31+).
88
97
  """
98
+ vcs: VcsHookView | None = None
99
+ """Narrowed vcs config slice populated by the runner for all per-round hooks
100
+ (pre-round, enrichers, post-round, dirty-handler) (0.2.0+).
101
+ May be None only when a HookContext is constructed without it (e.g. tests).
102
+ """
103
+
104
+
105
+ @runtime_checkable
106
+ class DirtyHandler(Protocol):
107
+ """Resolves a clean-exit dirty tree. Runs ascending by ``priority``; the
108
+ first to return a DirtyOutcome wins. Return None to pass to the next."""
109
+
110
+ name: str
111
+ priority: int
112
+
113
+ def handle_dirty(
114
+ self,
115
+ ctx: HookContext,
116
+ dirty_files: list[str],
117
+ ) -> Any | None: ...
89
118
 
90
119
 
91
120
  @runtime_checkable
@@ -148,6 +177,7 @@ _PRE_ROUND_HOOKS: list[PreRoundHook] = []
148
177
  _CONTEXT_ENRICHERS: list[ContextEnricher] = []
149
178
  _POST_ROUND_HOOKS: list[PostRoundHook] = []
150
179
  _SERVE_STARTUP_HOOKS: list[ServeStartupHook] = []
180
+ _DIRTY_HANDLERS: list[DirtyHandler] = []
151
181
 
152
182
 
153
183
  def register_pre_round_hook(hook: PreRoundHook) -> None:
@@ -191,6 +221,43 @@ def plugin_context_enrichers() -> list[str]:
191
221
  return sorted(e.name for e in _CONTEXT_ENRICHERS)
192
222
 
193
223
 
224
+ def register_dirty_handler(handler: DirtyHandler) -> None:
225
+ ensure_unique(handler.name, _DIRTY_HANDLERS, "dirty_handler")
226
+ _DIRTY_HANDLERS.append(handler)
227
+
228
+
229
+ def dirty_handlers() -> list[DirtyHandler]:
230
+ return list(_DIRTY_HANDLERS)
231
+
232
+
233
+ def dispatch_dirty(
234
+ ctx: HookContext,
235
+ dirty_files: list[str],
236
+ log_dir: Path,
237
+ ) -> Any | None:
238
+ """Run registered DirtyHandlers ascending by priority; first non-None wins.
239
+
240
+ A handler that raises is isolated — emits ``hook_failed`` with
241
+ ``hook_kind="dirty_handler"`` and treated as pass (continue to next).
242
+ """
243
+ ordered = sorted(_DIRTY_HANDLERS, key=lambda h: getattr(h, "priority", 0))
244
+ for h in ordered:
245
+ try:
246
+ outcome = h.handle_dirty(ctx, dirty_files)
247
+ except Exception as exc: # noqa: BLE001 — isolate; fall through to next
248
+ events.emit(
249
+ log_dir,
250
+ events.HOOK_FAILED,
251
+ hook_name=h.name,
252
+ hook_kind="dirty_handler",
253
+ **_summarize_error(exc, tb=tb_mod.format_exc()),
254
+ )
255
+ continue
256
+ if outcome is not None:
257
+ return outcome
258
+ return None
259
+
260
+
194
261
  def run_serve_startup_hooks(cfg: Any, log_dir: Path) -> bool:
195
262
  """Run all serve_startup_hooks. Returns True on success, False on abort.
196
263