@team-agent/installer 0.5.61 → 0.5.63

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 (250) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +5 -2
  4. package/crates/team-agent/src/cli/attach_app_server_leader.rs +1 -0
  5. package/crates/team-agent/src/cli/diagnose.rs +1 -0
  6. package/crates/team-agent/src/cli/emit.rs +41 -7
  7. package/crates/team-agent/src/cli/helpers.rs +1 -0
  8. package/crates/team-agent/src/cli/leader.rs +6 -1
  9. package/crates/team-agent/src/cli/leaders.rs +2 -0
  10. package/crates/team-agent/src/cli/mod.rs +33 -3
  11. package/crates/team-agent/src/cli/named_address.rs +8 -1
  12. package/crates/team-agent/src/cli/profile.rs +2 -1
  13. package/crates/team-agent/src/cli/send/coordinator.rs +1 -0
  14. package/crates/team-agent/src/cli/send/mailbox.rs +3 -0
  15. package/crates/team-agent/src/cli/send/persist.rs +1 -0
  16. package/crates/team-agent/src/cli/send/presentation.rs +1 -0
  17. package/crates/team-agent/src/cli/send/resolve.rs +206 -1
  18. package/crates/team-agent/src/cli/send.rs +2 -1
  19. package/crates/team-agent/src/cli/spec.rs +1 -0
  20. package/crates/team-agent/src/cli/status.rs +2 -1
  21. package/crates/team-agent/src/cli/status_port/compact.rs +1 -0
  22. package/crates/team-agent/src/cli/status_port/format.rs +1 -0
  23. package/crates/team-agent/src/cli/status_port/inbox.rs +1 -0
  24. package/crates/team-agent/src/cli/tests/verb_profile.rs +38 -0
  25. package/crates/team-agent/src/cli/types.rs +3 -2
  26. package/crates/team-agent/src/communication_mode/mod.rs +1 -0
  27. package/crates/team-agent/src/compiler.rs +1 -0
  28. package/crates/team-agent/src/conpty/backend.rs +1 -0
  29. package/crates/team-agent/src/conpty/mod.rs +1 -0
  30. package/crates/team-agent/src/coordinator/backoff.rs +5 -8
  31. package/crates/team-agent/src/coordinator/conpty_shim.rs +3 -2
  32. package/crates/team-agent/src/coordinator/health.rs +1 -0
  33. package/crates/team-agent/src/coordinator/mod.rs +2 -2
  34. package/crates/team-agent/src/coordinator/orphan.rs +4 -116
  35. package/crates/team-agent/src/coordinator/runtime_detectors.rs +1 -0
  36. package/crates/team-agent/src/coordinator/runtime_observation.rs +1 -0
  37. package/crates/team-agent/src/coordinator/steps/abnormal.rs +15 -866
  38. package/crates/team-agent/src/coordinator/steps/delivery.rs +1 -0
  39. package/crates/team-agent/src/coordinator/steps/health_sync.rs +1 -0
  40. package/crates/team-agent/src/coordinator/steps/mod.rs +2 -0
  41. package/crates/team-agent/src/coordinator/steps/persist.rs +1 -0
  42. package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +1 -0
  43. package/crates/team-agent/src/coordinator/steps/session_gate.rs +1 -0
  44. package/crates/team-agent/src/coordinator/tests/abnormal.rs +0 -174
  45. package/crates/team-agent/src/coordinator/tests/api_error_recovery.rs +139 -375
  46. package/crates/team-agent/src/coordinator/tests/basics.rs +0 -184
  47. package/crates/team-agent/src/coordinator/tests/mod.rs +2 -17
  48. package/crates/team-agent/src/coordinator/tests/tick_core.rs +0 -49
  49. package/crates/team-agent/src/coordinator/tick.rs +23 -40
  50. package/crates/team-agent/src/coordinator/types.rs +3 -176
  51. package/crates/team-agent/src/db/agent_health_capture.rs +1 -0
  52. package/crates/team-agent/src/db/message_store.rs +2 -1
  53. package/crates/team-agent/src/db/migration.rs +5 -4
  54. package/crates/team-agent/src/db/mod.rs +1 -0
  55. package/crates/team-agent/src/db/schema.rs +1 -0
  56. package/crates/team-agent/src/diagnose/comms.rs +2 -1
  57. package/crates/team-agent/src/diagnose/mod.rs +1 -0
  58. package/crates/team-agent/src/diagnose/orphans.rs +1 -0
  59. package/crates/team-agent/src/layout/manager.rs +3 -7
  60. package/crates/team-agent/src/layout/mod.rs +2 -2
  61. package/crates/team-agent/src/layout/overlay.rs +6 -1
  62. package/crates/team-agent/src/layout/placement.rs +1 -0
  63. package/crates/team-agent/src/layout/recovery.rs +3 -0
  64. package/crates/team-agent/src/layout/runtime_sessions.rs +8 -3
  65. package/crates/team-agent/src/layout/sessions.rs +8 -1
  66. package/crates/team-agent/src/layout/tmux_endpoint.rs +3 -0
  67. package/crates/team-agent/src/layout/worker_env.rs +3 -0
  68. package/crates/team-agent/src/layout/worker_window_helpers.rs +2 -0
  69. package/crates/team-agent/src/leader/helpers.rs +1 -0
  70. package/crates/team-agent/src/leader/incident.rs +1 -0
  71. package/crates/team-agent/src/leader/lease.rs +27 -22
  72. package/crates/team-agent/src/leader/mod.rs +2 -3
  73. package/crates/team-agent/src/leader/owner_bind.rs +13 -3
  74. package/crates/team-agent/src/leader/provider_attribution.rs +1 -0
  75. package/crates/team-agent/src/leader/rediscover.rs +4 -17
  76. package/crates/team-agent/src/leader/registry.rs +3 -15
  77. package/crates/team-agent/src/leader/start.rs +373 -125
  78. package/crates/team-agent/src/leader/takeover.rs +10 -232
  79. package/crates/team-agent/src/leader/tests/claim_leader_mailbox_flush_contract.rs +544 -0
  80. package/crates/team-agent/src/leader/tests/historical_inventory_upgrade_gate_red.rs +624 -0
  81. package/crates/team-agent/src/leader/tests/identity.rs +7 -2
  82. package/crates/team-agent/src/leader/tests/idle.rs +0 -193
  83. package/crates/team-agent/src/leader/tests/lease_api.rs +0 -45
  84. package/crates/team-agent/src/leader/tests/mod.rs +4 -41
  85. package/crates/team-agent/src/leader/tests/rehomed_env.rs +83 -0
  86. package/crates/team-agent/src/leader/tests/team_in_team_state_scope_red.rs +1273 -0
  87. package/crates/team-agent/src/leader/tests/terminal_and_replay_vs_rerender_invariants_red.rs +417 -0
  88. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +22 -103
  89. package/crates/team-agent/src/leader/types.rs +1 -28
  90. package/crates/team-agent/src/lib.rs +3 -0
  91. package/crates/team-agent/src/lifecycle/display.rs +7 -1
  92. package/crates/team-agent/src/lifecycle/helpers.rs +3 -1
  93. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +2 -2
  94. package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +1 -0
  95. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +1 -0
  96. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +1 -0
  97. package/crates/team-agent/src/lifecycle/launch/plan.rs +2 -2
  98. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +4 -26
  99. package/crates/team-agent/src/lifecycle/launch/readiness.rs +0 -27
  100. package/crates/team-agent/src/lifecycle/launch/spawn.rs +1 -27
  101. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +4 -32
  102. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +1 -0
  103. package/crates/team-agent/src/lifecycle/launch.rs +11 -12
  104. package/crates/team-agent/src/lifecycle/mod.rs +2 -1
  105. package/crates/team-agent/src/lifecycle/restart/agent.rs +3 -66
  106. package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +2 -2
  107. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -5
  108. package/crates/team-agent/src/lifecycle/restart/remove.rs +1 -1
  109. package/crates/team-agent/src/lifecycle/restart/selection.rs +3 -3
  110. package/crates/team-agent/src/lifecycle/restart.rs +13 -15
  111. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +1007 -0
  112. package/crates/team-agent/src/lifecycle/tests/b0_legacy_snapshot_nonauthority_contract.rs +265 -0
  113. package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +459 -0
  114. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +849 -0
  115. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +323 -0
  116. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +423 -0
  117. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +737 -0
  118. package/crates/team-agent/src/lifecycle/tests/codex_mcp_approval_inheritance_red.rs +1187 -0
  119. package/crates/team-agent/src/lifecycle/tests/codex_weak_window_attribution_red.rs +683 -0
  120. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +394 -0
  121. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +1856 -0
  122. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +917 -0
  123. package/crates/team-agent/src/lifecycle/tests/display_adaptive_red.rs +481 -0
  124. package/crates/team-agent/src/lifecycle/tests/f032_startup_prompt_best_effort_red.rs +301 -0
  125. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +516 -0
  126. package/crates/team-agent/src/lifecycle/tests/host_cotenant_death_p0_contract.rs +986 -0
  127. package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +651 -0
  128. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +2 -0
  129. package/crates/team-agent/src/lifecycle/tests/quick_start_worker_readiness_red.rs +332 -0
  130. package/crates/team-agent/src/lifecycle/tests/realmachine_clusters_1_6_red.rs +846 -0
  131. package/crates/team-agent/src/lifecycle/tests/realmachine_residual_g1_g4_red.rs +648 -0
  132. package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +910 -0
  133. package/crates/team-agent/src/lifecycle/tests/restart_liveness_red.rs +630 -0
  134. package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +1083 -0
  135. package/crates/team-agent/src/lifecycle/tests/restart_session_capture_red.rs +1677 -0
  136. package/crates/team-agent/src/lifecycle/tests/resume_recover_red.rs +410 -0
  137. package/crates/team-agent/src/lifecycle/tests/stale_team_saveconflict_contract.rs +428 -0
  138. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +1032 -0
  139. package/crates/team-agent/src/lifecycle/tests/status_credential_redaction_contract.rs +869 -0
  140. package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +1249 -0
  141. package/crates/team-agent/src/lifecycle/tests/swallow_batch4_semantics_red.rs +342 -0
  142. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +542 -0
  143. package/crates/team-agent/src/lifecycle/tests/team_in_team_sibling_quick_start_red.rs +1103 -0
  144. package/crates/team-agent/src/lifecycle/tests/team_in_team_state_scope_red.rs +1276 -0
  145. package/crates/team-agent/src/lifecycle/tests/team_key_retirement_roster_red.rs +145 -0
  146. package/crates/team-agent/src/lifecycle/tests/team_key_retirement_txn_red.rs +603 -0
  147. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +837 -0
  148. package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +928 -0
  149. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +951 -0
  150. package/crates/team-agent/src/lifecycle/tests/worker_dangerous_bypass_argv_red.rs +327 -0
  151. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +760 -0
  152. package/crates/team-agent/src/lifecycle/tests.rs +59 -0
  153. package/crates/team-agent/src/lifecycle/types.rs +0 -9
  154. package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
  155. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -0
  156. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +1 -0
  157. package/crates/team-agent/src/mcp_server/mod.rs +1 -0
  158. package/crates/team-agent/src/mcp_server/normalize.rs +4 -3
  159. package/crates/team-agent/src/mcp_server/tools.rs +1 -0
  160. package/crates/team-agent/src/mcp_server/types.rs +1 -18
  161. package/crates/team-agent/src/mcp_server/wire.rs +5 -5
  162. package/crates/team-agent/src/messaging/activity.rs +1 -0
  163. package/crates/team-agent/src/messaging/address.rs +1 -0
  164. package/crates/team-agent/src/messaging/delivery.rs +4 -3
  165. package/crates/team-agent/src/messaging/helpers.rs +2 -1
  166. package/crates/team-agent/src/messaging/leader_channel.rs +1 -0
  167. package/crates/team-agent/src/messaging/leader_receiver.rs +2 -1
  168. package/crates/team-agent/src/messaging/mod.rs +10 -14
  169. package/crates/team-agent/src/messaging/peers.rs +2 -0
  170. package/crates/team-agent/src/messaging/persist.rs +3 -1
  171. package/crates/team-agent/src/messaging/presentation.rs +2 -1
  172. package/crates/team-agent/src/messaging/results.rs +1 -0
  173. package/crates/team-agent/src/messaging/scheduler.rs +1 -0
  174. package/crates/team-agent/src/messaging/selftest.rs +1 -1
  175. package/crates/team-agent/src/messaging/send.rs +41 -8
  176. package/crates/team-agent/src/messaging/tests/mod.rs +5 -0
  177. package/crates/team-agent/src/messaging/types.rs +2 -2
  178. package/crates/team-agent/src/messaging/watchers.rs +19 -13
  179. package/crates/team-agent/src/model/enums.rs +1 -0
  180. package/crates/team-agent/src/model/errors.rs +1 -0
  181. package/crates/team-agent/src/model/ids.rs +1 -0
  182. package/crates/team-agent/src/model/mod.rs +1 -0
  183. package/crates/team-agent/src/model/name_similarity.rs +1 -0
  184. package/crates/team-agent/src/model/pane_authority_refusal.rs +1 -0
  185. package/crates/team-agent/src/model/paths.rs +1 -0
  186. package/crates/team-agent/src/model/permissions.rs +3 -2
  187. package/crates/team-agent/src/model/routing.rs +1 -0
  188. package/crates/team-agent/src/model/spec.rs +1 -0
  189. package/crates/team-agent/src/model/task_graph.rs +1 -0
  190. package/crates/team-agent/src/model/yaml/tests.rs +1 -0
  191. package/crates/team-agent/src/model/yaml.rs +1 -0
  192. package/crates/team-agent/src/packaging/install.rs +9 -147
  193. package/crates/team-agent/src/packaging/migrate.rs +2 -0
  194. package/crates/team-agent/src/packaging/mod.rs +1 -0
  195. package/crates/team-agent/src/packaging/repair.rs +2 -0
  196. package/crates/team-agent/src/packaging/tests.rs +29 -218
  197. package/crates/team-agent/src/packaging/types.rs +8 -15
  198. package/crates/team-agent/src/platform/argv.rs +4 -0
  199. package/crates/team-agent/src/platform/errors.rs +10 -1
  200. package/crates/team-agent/src/platform/file_lock.rs +4 -157
  201. package/crates/team-agent/src/platform/mod.rs +0 -61
  202. package/crates/team-agent/src/platform/process.rs +1 -0
  203. package/crates/team-agent/src/provider/adapter.rs +1 -0
  204. package/crates/team-agent/src/provider/adapters/claude.rs +1 -0
  205. package/crates/team-agent/src/provider/adapters/claude_fork.rs +1 -0
  206. package/crates/team-agent/src/provider/adapters/codex.rs +1 -0
  207. package/crates/team-agent/src/provider/adapters/copilot.rs +1 -0
  208. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +1 -0
  209. package/crates/team-agent/src/provider/adapters/fake.rs +1 -0
  210. package/crates/team-agent/src/provider/adapters/mod.rs +1 -0
  211. package/crates/team-agent/src/provider/approvals/mod.rs +1 -0
  212. package/crates/team-agent/src/provider/approvals/parsing.rs +2 -5
  213. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +6 -5
  214. package/crates/team-agent/src/provider/classify.rs +1 -0
  215. package/crates/team-agent/src/provider/faults.rs +1 -26
  216. package/crates/team-agent/src/provider/helpers.rs +1 -0
  217. package/crates/team-agent/src/provider/mod.rs +1 -1
  218. package/crates/team-agent/src/provider/session/capture.rs +1 -4
  219. package/crates/team-agent/src/provider/session/context_fork/claude.rs +1 -0
  220. package/crates/team-agent/src/provider/session/context_fork/codex.rs +1 -0
  221. package/crates/team-agent/src/provider/session/context_fork/outcome.rs +1 -0
  222. package/crates/team-agent/src/provider/session/context_fork.rs +1 -0
  223. package/crates/team-agent/src/provider/session/mod.rs +2 -4
  224. package/crates/team-agent/src/provider/session/resume.rs +2 -209
  225. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -0
  226. package/crates/team-agent/src/provider/session_scan/codex.rs +1 -0
  227. package/crates/team-agent/src/provider/session_scan/common.rs +1 -0
  228. package/crates/team-agent/src/provider/session_scan/copilot.rs +1 -0
  229. package/crates/team-agent/src/provider/session_scan.rs +1 -42
  230. package/crates/team-agent/src/provider/startup_prompt.rs +1 -0
  231. package/crates/team-agent/src/provider/types.rs +1 -0
  232. package/crates/team-agent/src/provider/wire.rs +1 -0
  233. package/crates/team-agent/src/state/identity.rs +16 -5
  234. package/crates/team-agent/src/state/identity_keys.rs +1 -0
  235. package/crates/team-agent/src/state/mod.rs +2 -0
  236. package/crates/team-agent/src/state/owner_gate.rs +5 -0
  237. package/crates/team-agent/src/state/ownership.rs +10 -4
  238. package/crates/team-agent/src/state/paths.rs +12 -0
  239. package/crates/team-agent/src/state/persist.rs +9 -3
  240. package/crates/team-agent/src/state/projection.rs +15 -2
  241. package/crates/team-agent/src/state/repository/intent.rs +1 -0
  242. package/crates/team-agent/src/state/repository/tests.rs +1 -0
  243. package/crates/team-agent/src/state/repository.rs +7 -13
  244. package/crates/team-agent/src/state/selector.rs +1 -0
  245. package/crates/team-agent/src/tmux_backend/tests.rs +39 -0
  246. package/crates/team-agent/src/tmux_backend.rs +63 -0
  247. package/crates/team-agent/src/transport.rs +15 -0
  248. package/package.json +4 -4
  249. package/crates/team-agent/src/leader/inject.rs +0 -33
  250. package/crates/team-agent/src/provider/command.rs +0 -80
@@ -1,3 +1,4 @@
1
+ //!
1
2
  //! unit-11 (Stage 4) — coordinator tick `abnormal` step group.
2
3
  //!
3
4
  //! Abnormal-exit detection + classification step extracted from
@@ -15,6 +16,7 @@ use super::super::health::pid_is_running;
15
16
  use super::super::tick::TickError;
16
17
  use super::super::types::Pid;
17
18
 
19
+ ///
18
20
  /// #236 `worker.abnormal_exit` watcher.
19
21
  ///
20
22
  /// Notify only when the bounded transcript/rollout tail contains a latest explicit
@@ -176,34 +178,19 @@ pub(crate) fn detect_abnormal_exits(
176
178
  {
177
179
  continue;
178
180
  }
179
- // 0.5.36 (`.team/artifacts/supermarket-api-error-recovery-locate.md`
180
- // §7.1/§7.6/§7.5): classify, update backpressure, record recovery
181
- // intent (retryable only), then compose the policy-aware tail. This
182
- // is INTENT WRITE only — actual lifecycle work happens post-save
183
- // via `attempt_due_recoveries` (§7.3, R6 guard).
184
181
  let manual_command =
185
182
  recovery_manual_command(agent.agent_id.as_str(), workspace, team.as_str());
186
- let error_observation_key_string = error_observation_key.clone().unwrap_or_default();
187
- let (recovery_class, recovery_schedule, recovery_cohort_key, recovery_backpressure_until) =
188
- process_api_error_recovery_intent(
189
- state,
190
- event_log,
191
- &team,
192
- &agent,
193
- &fact,
194
- &manual_command,
195
- error_observation_key_string.as_str(),
196
- )?;
197
- let recovery_tail = recovery_notification_tail(
198
- recovery_class,
199
- recovery_schedule.as_ref(),
200
- recovery_backpressure_until,
201
- &recovery_cohort_key,
183
+ let manual_recovery_hint = format!(
184
+ "No automatic restart was performed.\nTo recover manually, run: {manual_command}"
185
+ );
186
+ let content = format_abnormal_exit_message(
187
+ &team,
188
+ &agent,
202
189
  &fact,
203
- &manual_command,
190
+ &liveness,
191
+ size,
192
+ &manual_recovery_hint,
204
193
  );
205
- let content =
206
- format_abnormal_exit_message(&team, &agent, &fact, &liveness, size, &recovery_tail);
207
194
  let outcome = crate::messaging::send_to_leader_receiver(
208
195
  workspace,
209
196
  state,
@@ -967,6 +954,7 @@ fn abnormal_last_error_observation_cohort(state: &Value, agent_id: &str) -> Opti
967
954
  /// P1: Python `_TAIL_BYTES` parity (idle_takeover_wiring.py:13) — RS must not read less.
968
955
  const ABNORMAL_TAIL_BYTES: u64 = 131_072;
969
956
 
957
+ ///
970
958
  /// P1: bounded tail read; a partial first line is harmless (the consumer only parses
971
959
  /// the latest complete JSONL record) and lossy UTF-8 keeps a mid-codepoint seek safe.
972
960
  pub(crate) fn read_tail_text(path: &Path, max_bytes: u64) -> std::io::Result<String> {
@@ -1297,7 +1285,7 @@ fn format_abnormal_exit_message(
1297
1285
  fact: &crate::provider::FaultFact,
1298
1286
  liveness: &ProcessCheck,
1299
1287
  size: u64,
1300
- recovery_tail: &str,
1288
+ manual_recovery_hint: &str,
1301
1289
  ) -> String {
1302
1290
  let turn_id = fact.turn_id.as_ref().map(|id| id.as_str()).unwrap_or("-");
1303
1291
  format!(
@@ -1311,7 +1299,7 @@ turn_id: {turn_id}\n\
1311
1299
  transcript: {path}\n\
1312
1300
  last_offset: {size}\n\
1313
1301
  pid_status: {pid_status}\n\n\
1314
- {recovery_tail}",
1302
+ {manual_recovery_hint}",
1315
1303
  node = agent.agent_id.as_str(),
1316
1304
  provider = provider_wire(agent.provider),
1317
1305
  signature = fact.signature.as_str(),
@@ -1320,106 +1308,6 @@ pid_status: {pid_status}\n\n\
1320
1308
  )
1321
1309
  }
1322
1310
 
1323
- // ─────────────────────────────────────────────────────────────────────────
1324
- // 0.5.36 (`.team/artifacts/supermarket-api-error-recovery-locate.md` §7-§8):
1325
- // api_error recovery classifier + policy + state I/O + notification tail.
1326
- // Recovery EXECUTION lives post-atomic_save in `attempt_due_recoveries`
1327
- // below; `detect_abnormal_exits` only RECORDS intent (§7.3, R6 guard).
1328
- // ─────────────────────────────────────────────────────────────────────────
1329
-
1330
- /// 0.5.36 §7.1 classifier. Retryable = transient provider outage; recovery
1331
- /// may be scheduled. NonRetryable = configuration / auth issue; guide only.
1332
- /// Unknown = neither confirmed retryable nor known-bad; guide only.
1333
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1334
- pub(crate) enum ApiErrorRecoveryClass {
1335
- Retryable,
1336
- NonRetryable,
1337
- Unknown,
1338
- }
1339
-
1340
- impl ApiErrorRecoveryClass {
1341
- fn as_str(self) -> &'static str {
1342
- match self {
1343
- Self::Retryable => "retryable",
1344
- Self::NonRetryable => "non_retryable",
1345
- Self::Unknown => "unknown",
1346
- }
1347
- }
1348
- }
1349
-
1350
- /// 0.5.36 §7.1: rules keyed on status/error text.
1351
- pub(crate) fn classify_api_error_recovery(
1352
- signature: &str,
1353
- api_error_status: Option<i64>,
1354
- error: Option<&str>,
1355
- ) -> ApiErrorRecoveryClass {
1356
- if signature != "api_error" {
1357
- return ApiErrorRecoveryClass::Unknown;
1358
- }
1359
- if let Some(status) = api_error_status {
1360
- return match status {
1361
- 408 | 429 => ApiErrorRecoveryClass::Retryable,
1362
- s if (500..600).contains(&s) => ApiErrorRecoveryClass::Retryable,
1363
- 400 | 401 | 403 | 404 => ApiErrorRecoveryClass::NonRetryable,
1364
- _ => classify_error_text(error),
1365
- };
1366
- }
1367
- classify_error_text(error)
1368
- }
1369
-
1370
- fn classify_error_text(error: Option<&str>) -> ApiErrorRecoveryClass {
1371
- match error {
1372
- Some(text) => {
1373
- let lower = text.to_ascii_lowercase();
1374
- if ["rate_limit", "overloaded", "timeout"]
1375
- .iter()
1376
- .any(|needle| lower.contains(needle))
1377
- {
1378
- ApiErrorRecoveryClass::Retryable
1379
- } else if [
1380
- "model_not_found",
1381
- "auth",
1382
- "invalid_request",
1383
- "not_found_error",
1384
- ]
1385
- .iter()
1386
- .any(|needle| lower.contains(needle))
1387
- {
1388
- ApiErrorRecoveryClass::NonRetryable
1389
- } else {
1390
- ApiErrorRecoveryClass::Unknown
1391
- }
1392
- }
1393
- None => ApiErrorRecoveryClass::Unknown,
1394
- }
1395
- }
1396
-
1397
- // 0.5.36 §7.2 defaults — code constants; a later config slice may expose
1398
- // them without a new CLI flag.
1399
- pub(crate) const RECOVERY_MAX_ATTEMPTS: u64 = 2;
1400
- pub(crate) const RECOVERY_BACKOFF_SECS: [i64; 2] = [30, 120];
1401
- pub(crate) const BACKPRESSURE_THRESHOLD: usize = 3;
1402
- pub(crate) const BACKPRESSURE_WINDOW_SECS: i64 = 120;
1403
- pub(crate) const BACKPRESSURE_COOLDOWN_SECS: i64 = 300;
1404
-
1405
- fn now_utc() -> chrono::DateTime<chrono::Utc> {
1406
- chrono::Utc::now()
1407
- }
1408
-
1409
- fn cohort_key_for(team: &str, provider: &str, fact: &crate::provider::FaultFact) -> String {
1410
- let status = fact
1411
- .api_error_status
1412
- .map(|s| s.to_string())
1413
- .unwrap_or_else(|| "-".to_string());
1414
- let error = fact.error.as_deref().unwrap_or("-");
1415
- format!(
1416
- "{team}:{provider}:{signature}:{status}:{error}",
1417
- signature = fact.signature.as_str()
1418
- )
1419
- }
1420
-
1421
- /// 0.5.36 §7.2 manual command line. shell-single-quoted workspace so paths
1422
- /// with spaces survive copy/paste (§7.5).
1423
1311
  fn recovery_manual_command(agent_id: &str, workspace: &Path, team: &str) -> String {
1424
1312
  let workspace_str = workspace.to_string_lossy();
1425
1313
  let shell_workspace = workspace_str.replace('\'', "'\\''");
@@ -1428,747 +1316,12 @@ fn recovery_manual_command(agent_id: &str, workspace: &Path, team: &str) -> Stri
1428
1316
  )
1429
1317
  }
1430
1318
 
1431
- fn recovery_intent_root<'a>(
1432
- state: &'a mut Value,
1433
- ) -> Option<&'a mut serde_json::Map<String, Value>> {
1434
- coordinator_child_object(state, "abnormal_api_error_recovery")
1435
- }
1436
-
1437
- fn recovery_intent_agents<'a>(
1438
- state: &'a mut Value,
1439
- ) -> Option<&'a mut serde_json::Map<String, Value>> {
1440
- let root = recovery_intent_root(state)?;
1441
- let agents = root
1442
- .entry("agents".to_string())
1443
- .or_insert_with(|| serde_json::json!({}));
1444
- if !agents.is_object() {
1445
- *agents = serde_json::json!({});
1446
- }
1447
- agents.as_object_mut()
1448
- }
1449
-
1450
- fn recovery_backpressure<'a>(
1451
- state: &'a mut Value,
1452
- ) -> Option<&'a mut serde_json::Map<String, Value>> {
1453
- let root = recovery_intent_root(state)?;
1454
- let bp = root
1455
- .entry("backpressure".to_string())
1456
- .or_insert_with(|| serde_json::json!({}));
1457
- if !bp.is_object() {
1458
- *bp = serde_json::json!({});
1459
- }
1460
- bp.as_object_mut()
1461
- }
1462
-
1463
- /// 0.5.36 §7.6: increment the cohort counter, prune stale events outside the
1464
- /// sliding window, and return the current active-cooldown state (if any) so
1465
- /// the caller can decide canary vs deferral. Only fresh retryable errors get
1466
- /// here — non-retryable/unknown never touch backpressure.
1467
- fn record_backpressure_event(
1468
- state: &mut Value,
1469
- team: &str,
1470
- provider: &str,
1471
- fact: &crate::provider::FaultFact,
1472
- agent_id: &str,
1473
- now: chrono::DateTime<chrono::Utc>,
1474
- ) -> BackpressureDecision {
1475
- let cohort = cohort_key_for(team, provider, fact);
1476
- let now_str = now.to_rfc3339();
1477
- let window = chrono::Duration::seconds(BACKPRESSURE_WINDOW_SECS);
1478
- let cooldown = chrono::Duration::seconds(BACKPRESSURE_COOLDOWN_SECS);
1479
- let Some(bp) = recovery_backpressure(state) else {
1480
- return BackpressureDecision {
1481
- cooldown_until: None,
1482
- just_activated: false,
1483
- cohort_key: cohort,
1484
- };
1485
- };
1486
- let entry = bp
1487
- .entry(cohort.clone())
1488
- .or_insert_with(|| serde_json::json!({}));
1489
- let obj = match entry.as_object_mut() {
1490
- Some(obj) => obj,
1491
- None => {
1492
- *entry = serde_json::json!({});
1493
- entry.as_object_mut().expect("just replaced with object")
1494
- }
1495
- };
1496
- let window_started = obj
1497
- .get("window_started_at")
1498
- .and_then(Value::as_str)
1499
- .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1500
- .map(|dt| dt.with_timezone(&chrono::Utc));
1501
- let reset_window = window_started
1502
- .map(|start| now.signed_duration_since(start) > window)
1503
- .unwrap_or(true);
1504
- if reset_window {
1505
- obj.insert("window_started_at".to_string(), serde_json::json!(now_str));
1506
- obj.insert("count".to_string(), serde_json::json!(0u64));
1507
- obj.insert(
1508
- "agents".to_string(),
1509
- serde_json::json!(Vec::<String>::new()),
1510
- );
1511
- }
1512
- obj.insert("last_seen_at".to_string(), serde_json::json!(now_str));
1513
- let count = obj
1514
- .get("count")
1515
- .and_then(Value::as_u64)
1516
- .unwrap_or(0)
1517
- .saturating_add(1);
1518
- obj.insert("count".to_string(), serde_json::json!(count));
1519
- let agents_arr = obj
1520
- .entry("agents".to_string())
1521
- .or_insert_with(|| serde_json::json!([]));
1522
- if let Some(list) = agents_arr.as_array_mut() {
1523
- if !list.iter().any(|v| v.as_str() == Some(agent_id)) {
1524
- list.push(serde_json::json!(agent_id));
1525
- }
1526
- }
1527
- let previously_active = obj
1528
- .get("cooldown_until")
1529
- .and_then(Value::as_str)
1530
- .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1531
- .map(|dt| dt.with_timezone(&chrono::Utc) > now)
1532
- .unwrap_or(false);
1533
- let mut just_activated = false;
1534
- let mut cooldown_until: Option<chrono::DateTime<chrono::Utc>> = None;
1535
- if previously_active {
1536
- cooldown_until = obj
1537
- .get("cooldown_until")
1538
- .and_then(Value::as_str)
1539
- .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1540
- .map(|dt| dt.with_timezone(&chrono::Utc));
1541
- } else if (count as usize) >= BACKPRESSURE_THRESHOLD {
1542
- let until = now + cooldown;
1543
- obj.insert(
1544
- "cooldown_until".to_string(),
1545
- serde_json::json!(until.to_rfc3339()),
1546
- );
1547
- obj.insert("status".to_string(), serde_json::json!("active"));
1548
- cooldown_until = Some(until);
1549
- just_activated = true;
1550
- }
1551
- BackpressureDecision {
1552
- cooldown_until,
1553
- just_activated,
1554
- cohort_key: cohort,
1555
- }
1556
- }
1557
-
1558
- struct BackpressureDecision {
1559
- cooldown_until: Option<chrono::DateTime<chrono::Utc>>,
1560
- just_activated: bool,
1561
- cohort_key: String,
1562
- }
1563
-
1564
- /// 0.5.36 §7.2: reserve or update the per-agent recovery intent. Returns the
1565
- /// scheduled `next_retry_at` for the notification/event.
1566
- fn schedule_recovery_intent(
1567
- state: &mut Value,
1568
- agent_id: &str,
1569
- cohort_key: &str,
1570
- error_key: &str,
1571
- manual_command: &str,
1572
- now: chrono::DateTime<chrono::Utc>,
1573
- cooldown_until: Option<chrono::DateTime<chrono::Utc>>,
1574
- ) -> Option<RecoveryIntentSchedule> {
1575
- let Some(agents) = recovery_intent_agents(state) else {
1576
- return None;
1577
- };
1578
- let existing = agents.get(agent_id).cloned();
1579
- let attempts = existing
1580
- .as_ref()
1581
- .and_then(|v| v.get("attempts").and_then(Value::as_u64))
1582
- .unwrap_or(0);
1583
- if attempts >= RECOVERY_MAX_ATTEMPTS {
1584
- return None;
1585
- }
1586
- let backoff = RECOVERY_BACKOFF_SECS
1587
- .get(attempts as usize)
1588
- .copied()
1589
- .unwrap_or(*RECOVERY_BACKOFF_SECS.last().unwrap_or(&120));
1590
- let mut next_retry = now + chrono::Duration::seconds(backoff);
1591
- let mut backpressured = false;
1592
- if let Some(until) = cooldown_until {
1593
- if until > next_retry {
1594
- next_retry = until;
1595
- backpressured = true;
1596
- }
1597
- }
1598
- let status = if backpressured {
1599
- "backpressured"
1600
- } else {
1601
- "scheduled"
1602
- };
1603
- let payload = serde_json::json!({
1604
- "error_key": error_key,
1605
- "cohort_key": cohort_key,
1606
- "status": status,
1607
- "attempts": attempts,
1608
- "max_attempts": RECOVERY_MAX_ATTEMPTS,
1609
- "next_retry_at": next_retry.to_rfc3339(),
1610
- "last_attempt_at": Value::Null,
1611
- "last_error": Value::Null,
1612
- "backoff_seconds": backoff,
1613
- "manual_command": manual_command,
1614
- });
1615
- agents.insert(agent_id.to_string(), payload);
1616
- Some(RecoveryIntentSchedule {
1617
- next_retry_at: next_retry.to_rfc3339(),
1618
- attempt: attempts,
1619
- backoff_seconds: backoff,
1620
- backpressured,
1621
- })
1622
- }
1623
-
1624
- struct RecoveryIntentSchedule {
1625
- next_retry_at: String,
1626
- attempt: u64,
1627
- backoff_seconds: i64,
1628
- backpressured: bool,
1629
- }
1630
-
1631
- /// 0.5.36 §7.6: returns true when any agent already holds a `scheduled`
1632
- /// recovery intent for the given cohort.
1633
- fn has_active_canary_in_cohort(state: &Value, cohort_key: &str) -> bool {
1634
- let Some(agents) = state
1635
- .pointer("/coordinator/abnormal_api_error_recovery/agents")
1636
- .and_then(Value::as_object)
1637
- else {
1638
- return false;
1639
- };
1640
- agents.values().any(|entry| {
1641
- entry.get("status").and_then(Value::as_str) == Some("scheduled")
1642
- && entry.get("cohort_key").and_then(Value::as_str) == Some(cohort_key)
1643
- })
1644
- }
1645
-
1646
- fn recovery_intent_status(state: &Value, agent_id: &str) -> Option<String> {
1647
- state
1648
- .pointer(&format!(
1649
- "/coordinator/abnormal_api_error_recovery/agents/{agent_id}/status"
1650
- ))
1651
- .and_then(Value::as_str)
1652
- .map(str::to_string)
1653
- }
1654
-
1655
- fn recovery_intent_field(state: &Value, agent_id: &str, field: &str) -> Option<Value> {
1656
- state
1657
- .pointer(&format!(
1658
- "/coordinator/abnormal_api_error_recovery/agents/{agent_id}/{field}"
1659
- ))
1660
- .cloned()
1661
- }
1662
-
1663
- /// 0.5.36 §7.5 policy-aware notification tail.
1664
- fn recovery_notification_tail(
1665
- class: ApiErrorRecoveryClass,
1666
- schedule: Option<&RecoveryIntentSchedule>,
1667
- backpressure_until: Option<chrono::DateTime<chrono::Utc>>,
1668
- cohort_key: &str,
1669
- fact: &crate::provider::FaultFact,
1670
- manual_command: &str,
1671
- ) -> String {
1672
- let manual_line = format!("manual_recovery: {manual_command}");
1673
- let backpressure_line = match backpressure_until {
1674
- Some(until) => {
1675
- let status = fact
1676
- .api_error_status
1677
- .map(|s| s.to_string())
1678
- .unwrap_or_else(|| "-".to_string());
1679
- format!(
1680
- "backpressure: active cohort={cohort_key} status={status} until={until}",
1681
- until = until.to_rfc3339()
1682
- )
1683
- }
1684
- None => "backpressure: inactive".to_string(),
1685
- };
1686
- let auto_line = match class {
1687
- ApiErrorRecoveryClass::Retryable => match schedule {
1688
- Some(sched) if sched.backpressured => format!(
1689
- "auto_recovery: delayed by provider backpressure until {}",
1690
- sched.next_retry_at
1691
- ),
1692
- Some(sched) => format!(
1693
- "auto_recovery: scheduled attempt {attempt}/{max} in {backoff}s (due {due})",
1694
- attempt = sched.attempt + 1,
1695
- max = RECOVERY_MAX_ATTEMPTS,
1696
- backoff = sched.backoff_seconds,
1697
- due = sched.next_retry_at,
1698
- ),
1699
- None => "auto_recovery: not scheduled (recovery_exhausted); use manual command below"
1700
- .to_string(),
1701
- },
1702
- ApiErrorRecoveryClass::NonRetryable => {
1703
- let error = fact.error.as_deref().unwrap_or("non_retryable");
1704
- format!("auto_recovery: not scheduled (non_retryable_api_error: {error})")
1705
- }
1706
- ApiErrorRecoveryClass::Unknown => {
1707
- "auto_recovery: not scheduled (unknown_api_error_class)".to_string()
1708
- }
1709
- };
1710
- format!("{auto_line}\n{manual_line}\n{backpressure_line}")
1711
- }
1712
-
1713
- /// 0.5.36 §7 orchestration for a single fresh abnormal notification. Runs
1714
- /// inside `detect_abnormal_exits`; only classifies + records intent +
1715
- /// emits recovery-family events. Returns the classifier verdict, the
1716
- /// scheduled intent (if any), the cohort key, and the active backpressure
1717
- /// window (if any) so the caller can build the notification tail.
1718
- ///
1719
- /// R6 guard: this function must not call any lifecycle helpers. Actual
1720
- /// lifecycle work runs post-atomic_save in `attempt_due_recoveries`.
1721
- fn process_api_error_recovery_intent(
1722
- state: &mut Value,
1723
- event_log: &EventLog,
1724
- team: &str,
1725
- agent: &AbnormalWatchAgent,
1726
- fact: &crate::provider::FaultFact,
1727
- manual_command: &str,
1728
- error_key: &str,
1729
- ) -> Result<
1730
- (
1731
- ApiErrorRecoveryClass,
1732
- Option<RecoveryIntentSchedule>,
1733
- String,
1734
- Option<chrono::DateTime<chrono::Utc>>,
1735
- ),
1736
- TickError,
1737
- > {
1738
- let class = classify_api_error_recovery(
1739
- fact.signature.as_str(),
1740
- fact.api_error_status,
1741
- fact.error.as_deref(),
1742
- );
1743
- let provider_str = provider_wire(agent.provider);
1744
- let cohort_key = cohort_key_for(team, provider_str, fact);
1745
- if !matches!(class, ApiErrorRecoveryClass::Retryable) {
1746
- return Ok((class, None, cohort_key, None));
1747
- }
1748
- let now = now_utc();
1749
- let cohort_hint = cohort_key_for(team, provider_str, fact);
1750
- let canary_active = has_active_canary_in_cohort(state, &cohort_hint);
1751
- let mut bp_decision = record_backpressure_event(
1752
- state,
1753
- team,
1754
- provider_str,
1755
- fact,
1756
- agent.agent_id.as_str(),
1757
- now,
1758
- );
1759
- // 0.5.36 §7.6: "at most one canary". If another agent in the same
1760
- // cohort already holds a scheduled intent, defer the new arrival to
1761
- // the cohort cooldown (or a synthetic short cooldown if none yet).
1762
- if canary_active {
1763
- let synthetic = bp_decision
1764
- .cooldown_until
1765
- .unwrap_or_else(|| now + chrono::Duration::seconds(BACKPRESSURE_COOLDOWN_SECS));
1766
- bp_decision.cooldown_until = Some(synthetic);
1767
- }
1768
- if bp_decision.just_activated {
1769
- let bp_agents = state
1770
- .pointer(&format!(
1771
- "/coordinator/abnormal_api_error_recovery/backpressure/{}/agents",
1772
- bp_decision.cohort_key
1773
- ))
1774
- .cloned()
1775
- .unwrap_or_else(|| Value::Array(Vec::new()));
1776
- let cooldown_until_str = bp_decision
1777
- .cooldown_until
1778
- .map(|dt| dt.to_rfc3339())
1779
- .unwrap_or_default();
1780
- event_log.write(
1781
- "worker.abnormal_exit.backpressure_started",
1782
- serde_json::json!({
1783
- "team_id": team,
1784
- "provider": provider_str,
1785
- "signature": fact.signature.as_str(),
1786
- "apiErrorStatus": fact.api_error_status,
1787
- "error": fact.error.as_deref(),
1788
- "cohort_key": bp_decision.cohort_key,
1789
- "threshold": BACKPRESSURE_THRESHOLD,
1790
- "window_seconds": BACKPRESSURE_WINDOW_SECS,
1791
- "cooldown_until": cooldown_until_str,
1792
- "agents": bp_agents,
1793
- }),
1794
- )?;
1795
- }
1796
- let schedule = schedule_recovery_intent(
1797
- state,
1798
- agent.agent_id.as_str(),
1799
- &bp_decision.cohort_key,
1800
- error_key,
1801
- manual_command,
1802
- now,
1803
- bp_decision.cooldown_until,
1804
- );
1805
- if let Some(sched) = schedule.as_ref() {
1806
- if sched.backpressured {
1807
- // Backpressured workers are not scheduled for the canary window;
1808
- // emit a distinct `backpressure_active` event so the leader
1809
- // notification tail can trace which cohort deferred which agent.
1810
- event_log.write(
1811
- "worker.abnormal_exit.backpressure_active",
1812
- serde_json::json!({
1813
- "team_id": team,
1814
- "agent_id": agent.agent_id.as_str(),
1815
- "cohort_key": bp_decision.cohort_key,
1816
- "cooldown_until": bp_decision
1817
- .cooldown_until
1818
- .map(|dt| dt.to_rfc3339())
1819
- .unwrap_or_default(),
1820
- "action": "deferred_recovery",
1821
- "manual_command": manual_command,
1822
- }),
1823
- )?;
1824
- } else {
1825
- event_log.write(
1826
- "worker.abnormal_exit.recovery_scheduled",
1827
- serde_json::json!({
1828
- "team_id": team,
1829
- "agent_id": agent.agent_id.as_str(),
1830
- "provider": provider_str,
1831
- "signature": fact.signature.as_str(),
1832
- "apiErrorStatus": fact.api_error_status,
1833
- "error": fact.error.as_deref(),
1834
- "attempt": sched.attempt,
1835
- "max_attempts": RECOVERY_MAX_ATTEMPTS,
1836
- "due_at": sched.next_retry_at,
1837
- "backoff_seconds": sched.backoff_seconds,
1838
- "error_key": error_key,
1839
- "cohort_key": bp_decision.cohort_key,
1840
- "backpressured": false,
1841
- "manual_command": manual_command,
1842
- }),
1843
- )?;
1844
- }
1845
- } else {
1846
- // schedule_recovery_intent returns None ONLY when max_attempts hit.
1847
- event_log.write(
1848
- "worker.abnormal_exit.recovery_exhausted",
1849
- serde_json::json!({
1850
- "team_id": team,
1851
- "agent_id": agent.agent_id.as_str(),
1852
- "attempts": RECOVERY_MAX_ATTEMPTS,
1853
- "last_error": fact.error.as_deref(),
1854
- "manual_command": manual_command,
1855
- }),
1856
- )?;
1857
- }
1858
- Ok((
1859
- class,
1860
- schedule,
1861
- bp_decision.cohort_key,
1862
- bp_decision.cooldown_until,
1863
- ))
1864
- }
1865
-
1866
- // ─────────────────────────────────────────────────────────────────────────
1867
- // 0.5.36 §7.3 recovery execution — runs post-atomic_save, reloads fresh
1868
- // state, consumes due intents, invokes the lifecycle `start_agent_at_paths`
1869
- // with force=true (stop-before-start semantics for live panes, so noop is
1870
- // impossible), and writes the outcome back to state via the lifecycle
1871
- // path (which owns its own save).
1872
- // ─────────────────────────────────────────────────────────────────────────
1873
-
1874
- /// 0.5.36 §7.3: process all due recovery intents. Called from tick.rs
1875
- /// AFTER atomic_save has flushed the detector-written intent. Reloads a
1876
- /// fresh state each call so the caller's stale in-memory state cannot
1877
- /// clobber lifecycle writes. Best-effort: recovery failure produces
1878
- /// events + updated intent, never a tick failure.
1879
- pub(crate) fn attempt_due_recoveries(
1880
- workspace: &Path,
1881
- event_log: &EventLog,
1882
- transport: &dyn crate::transport::Transport,
1883
- ) {
1884
- // 0.5.43 debt-sweep (§5 D-j): single fresh post-save load. Do NOT
1885
- // re-use the tick's pre-save Value — that would reopen the 0.5.36
1886
- // R6 lifecycle-save-clobbered-by-tick window (highest risk item in
1887
- // this slice per locate). We own our load here, mutate in memory
1888
- // (`clear_stale_terminal_next_retry_at` is now a pure
1889
- // `(&mut Value) -> bool` scrub), persist through the existing
1890
- // allowlisted root save when the scrub actually changed a row, and
1891
- // then collect due agents from the SAME post-scrub Value so
1892
- // terminal intents newly stripped of `next_retry_at` cannot double-
1893
- // fire below.
1894
- let Ok(mut state) = crate::state::persist::load_runtime_state(workspace) else {
1895
- return;
1896
- };
1897
- if clear_stale_terminal_next_retry_at(&mut state) {
1898
- let _ = crate::state::repository::StateRepository::new(workspace).save(
1899
- crate::state::repository::StateWriteIntent::CoordinatorApiErrorRecovery {
1900
- team_key: state.get("active_team_key").and_then(Value::as_str),
1901
- agent_id: None,
1902
- },
1903
- &state,
1904
- );
1905
- }
1906
- let due_agents = collect_due_recovery_agents(&state);
1907
- for agent_id in due_agents {
1908
- run_single_recovery(workspace, event_log, transport, &agent_id);
1909
- }
1910
- }
1911
-
1912
- fn collect_due_recovery_agents(state: &Value) -> Vec<String> {
1913
- let now = now_utc();
1914
- let Some(agents) = state
1915
- .pointer("/coordinator/abnormal_api_error_recovery/agents")
1916
- .and_then(Value::as_object)
1917
- else {
1918
- return Vec::new();
1919
- };
1920
- let mut due = Vec::new();
1921
- for (agent_id, intent) in agents {
1922
- let status = intent.get("status").and_then(Value::as_str).unwrap_or("");
1923
- // 0.5.37 R8: dispatch only for non-terminal states. `succeeded`,
1924
- // `blocked`, `exhausted`, `backpressured` are terminal / awaiting
1925
- // manual action — a lifecycle dispatch here would be double-fire
1926
- // or wasted work. `scheduled` / `running` remain dispatchable so
1927
- // an interrupted tick can resume.
1928
- if !matches!(status, "scheduled" | "running") {
1929
- continue;
1930
- }
1931
- let Some(next_retry_at) = intent
1932
- .get("next_retry_at")
1933
- .and_then(Value::as_str)
1934
- .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1935
- .map(|dt| dt.with_timezone(&chrono::Utc))
1936
- else {
1937
- continue;
1938
- };
1939
- if next_retry_at <= now {
1940
- due.push(agent_id.clone());
1941
- }
1942
- }
1943
- due
1944
- }
1945
-
1946
- /// 0.5.37 R8 + 0.5.43 debt-sweep §5 D-j: pure scrub helper. Terminal
1947
- /// recovery intents (`succeeded`/`blocked`/`exhausted`) must not carry
1948
- /// a stale `next_retry_at`. Returns true when at least one row was
1949
- /// stripped, so the caller can save through its own load/write cycle
1950
- /// (no double-load, no double-save). No I/O in this function — the
1951
- /// only responsibility is mutation on a caller-owned `Value`.
1952
- fn clear_stale_terminal_next_retry_at(state: &mut Value) -> bool {
1953
- let mut mutated = false;
1954
- if let Some(agents) = recovery_intent_agents(state) {
1955
- for (_agent_id, entry) in agents.iter_mut() {
1956
- let Some(obj) = entry.as_object_mut() else {
1957
- continue;
1958
- };
1959
- let status = obj
1960
- .get("status")
1961
- .and_then(Value::as_str)
1962
- .unwrap_or("")
1963
- .to_string();
1964
- if !matches!(status.as_str(), "succeeded" | "blocked" | "exhausted") {
1965
- continue;
1966
- }
1967
- let had_stale = obj.get("next_retry_at").filter(|v| !v.is_null()).is_some();
1968
- if had_stale {
1969
- obj.remove("next_retry_at");
1970
- mutated = true;
1971
- }
1972
- }
1973
- }
1974
- mutated
1975
- }
1976
-
1977
- fn run_single_recovery(
1978
- workspace: &Path,
1979
- event_log: &EventLog,
1980
- transport: &dyn crate::transport::Transport,
1981
- agent_id: &str,
1982
- ) {
1983
- // Read the current intent so we can bump attempts / write result fields
1984
- // through the shared writer (`write_recovery_intent_field`); the actual
1985
- // lifecycle helper below owns its own state save.
1986
- let Ok(state_before) = crate::state::persist::load_runtime_state(workspace) else {
1987
- return;
1988
- };
1989
- let team_key = crate::state::projection::team_state_key(&state_before);
1990
- let attempt = state_before
1991
- .pointer(&format!(
1992
- "/coordinator/abnormal_api_error_recovery/agents/{agent_id}/attempts"
1993
- ))
1994
- .and_then(Value::as_u64)
1995
- .unwrap_or(0);
1996
- let manual_command = state_before
1997
- .pointer(&format!(
1998
- "/coordinator/abnormal_api_error_recovery/agents/{agent_id}/manual_command"
1999
- ))
2000
- .and_then(Value::as_str)
2001
- .map(str::to_string)
2002
- .unwrap_or_else(|| recovery_manual_command(agent_id, workspace, team_key.as_str()));
2003
- let _ = event_log.write(
2004
- "worker.abnormal_exit.recovery_started",
2005
- serde_json::json!({
2006
- "team_id": team_key.as_str(),
2007
- "agent_id": agent_id,
2008
- "attempt": attempt,
2009
- "mode": "start_agent_force",
2010
- "allow_fresh": false,
2011
- }),
2012
- );
2013
- let agent_id_typed = crate::model::ids::AgentId::new(agent_id.to_string());
2014
- let outcome = crate::lifecycle::restart::start_agent_at_paths_for_recovery(
2015
- workspace,
2016
- &agent_id_typed,
2017
- Some(team_key.as_str()),
2018
- transport,
2019
- );
2020
- let now = now_utc();
2021
- match outcome {
2022
- Ok(recovery_outcome) => {
2023
- let _ = event_log.write(
2024
- "worker.abnormal_exit.recovery_succeeded",
2025
- serde_json::json!({
2026
- "team_id": team_key.as_str(),
2027
- "agent_id": agent_id,
2028
- "start_mode": recovery_outcome.start_mode,
2029
- "target": recovery_outcome.target,
2030
- "coordinator_started": recovery_outcome.coordinator_started,
2031
- }),
2032
- );
2033
- write_recovery_intent_result(
2034
- workspace,
2035
- agent_id,
2036
- RecoveryIntentUpdate {
2037
- status: "succeeded",
2038
- attempts: attempt + 1,
2039
- last_attempt_at: now.to_rfc3339(),
2040
- last_error: None,
2041
- blocked_reason: None,
2042
- },
2043
- );
2044
- }
2045
- Err(RecoveryError::NoopBlocked) => {
2046
- let _ = event_log.write(
2047
- "worker.abnormal_exit.recovery_blocked",
2048
- serde_json::json!({
2049
- "team_id": team_key.as_str(),
2050
- "agent_id": agent_id,
2051
- "reason": "noop_not_recovery",
2052
- "manual_command": manual_command,
2053
- }),
2054
- );
2055
- write_recovery_intent_result(
2056
- workspace,
2057
- agent_id,
2058
- RecoveryIntentUpdate {
2059
- status: "blocked",
2060
- attempts: attempt + 1,
2061
- last_attempt_at: now.to_rfc3339(),
2062
- last_error: None,
2063
- blocked_reason: Some("noop_not_recovery"),
2064
- },
2065
- );
2066
- }
2067
- Err(RecoveryError::Lifecycle(reason)) => {
2068
- let _ = event_log.write(
2069
- "worker.abnormal_exit.recovery_blocked",
2070
- serde_json::json!({
2071
- "team_id": team_key.as_str(),
2072
- "agent_id": agent_id,
2073
- "reason": "lifecycle_error",
2074
- "detail": reason,
2075
- "manual_command": manual_command,
2076
- }),
2077
- );
2078
- write_recovery_intent_result(
2079
- workspace,
2080
- agent_id,
2081
- RecoveryIntentUpdate {
2082
- status: "blocked",
2083
- attempts: attempt + 1,
2084
- last_attempt_at: now.to_rfc3339(),
2085
- last_error: Some(reason),
2086
- blocked_reason: Some("lifecycle_error"),
2087
- },
2088
- );
2089
- }
2090
- }
2091
- }
2092
-
2093
- pub(crate) enum RecoveryError {
2094
- NoopBlocked,
2095
- Lifecycle(String),
2096
- }
2097
-
2098
- /// 0.5.36 §7.3 typed outcome returned from
2099
- /// `lifecycle::restart::start_agent_at_paths_for_recovery` to the post-save
2100
- /// step. Small on purpose — just the fields the `recovery_succeeded` event
2101
- /// needs.
2102
- pub(crate) struct RecoveryLifecycleOutcome {
2103
- pub start_mode: String,
2104
- pub target: String,
2105
- pub coordinator_started: bool,
2106
- }
2107
-
2108
- struct RecoveryIntentUpdate {
2109
- status: &'static str,
2110
- attempts: u64,
2111
- last_attempt_at: String,
2112
- last_error: Option<String>,
2113
- blocked_reason: Option<&'static str>,
2114
- }
2115
-
2116
- fn write_recovery_intent_result(workspace: &Path, agent_id: &str, update: RecoveryIntentUpdate) {
2117
- let Ok(mut state) = crate::state::persist::load_runtime_state(workspace) else {
2118
- return;
2119
- };
2120
- if let Some(agents) = recovery_intent_agents(&mut state) {
2121
- let entry = agents
2122
- .entry(agent_id.to_string())
2123
- .or_insert_with(|| serde_json::json!({}));
2124
- if !entry.is_object() {
2125
- *entry = serde_json::json!({});
2126
- }
2127
- if let Some(obj) = entry.as_object_mut() {
2128
- obj.insert("status".to_string(), serde_json::json!(update.status));
2129
- obj.insert("attempts".to_string(), serde_json::json!(update.attempts));
2130
- obj.insert(
2131
- "last_attempt_at".to_string(),
2132
- serde_json::json!(update.last_attempt_at),
2133
- );
2134
- obj.insert(
2135
- "last_error".to_string(),
2136
- match update.last_error.as_ref() {
2137
- Some(text) => serde_json::json!(text),
2138
- None => Value::Null,
2139
- },
2140
- );
2141
- match update.blocked_reason {
2142
- Some(reason) => {
2143
- obj.insert("blocked_reason".to_string(), serde_json::json!(reason));
2144
- }
2145
- None => {
2146
- obj.remove("blocked_reason");
2147
- }
2148
- }
2149
- // 0.5.37 R8: transitioning into a terminal state clears the
2150
- // stale `next_retry_at`; a future retry earns a fresh due
2151
- // time when it re-enters `scheduled` through the detector.
2152
- if matches!(update.status, "succeeded" | "blocked" | "exhausted") {
2153
- obj.remove("next_retry_at");
2154
- }
2155
- }
2156
- }
2157
- let _ = crate::state::repository::StateRepository::new(workspace).save(
2158
- crate::state::repository::StateWriteIntent::CoordinatorApiErrorRecovery {
2159
- team_key: state.get("active_team_key").and_then(Value::as_str),
2160
- agent_id: Some(agent_id),
2161
- },
2162
- &state,
2163
- );
2164
- }
2165
-
2166
1319
  #[cfg(test)]
2167
1320
  #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
2168
1321
  mod tests {
2169
1322
  use super::*;
2170
1323
  use crate::coordinator::tick::Coordinator;
2171
- use crate::coordinator::types::{ErrorLists, ProviderRegistry, WorkspacePath};
1324
+ use crate::coordinator::types::{ProviderRegistry, WorkspacePath};
2172
1325
  use std::io::Write as _;
2173
1326
 
2174
1327
  struct NormalRegistry;
@@ -2180,10 +1333,6 @@ mod tests {
2180
1333
  ) -> Box<dyn crate::provider::ProviderAdapter> {
2181
1334
  crate::provider::get_adapter(provider)
2182
1335
  }
2183
-
2184
- fn error_lists(&self, _provider: crate::provider::Provider) -> ErrorLists {
2185
- ErrorLists::default()
2186
- }
2187
1336
  }
2188
1337
  #[test]
2189
1338
  fn abnormal_stale_error_baselines_then_fresh_alive_error_notifies() {