@team-agent/installer 0.3.39 → 0.4.0

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 (61) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +7 -0
  4. package/crates/team-agent/src/cli/adapters.rs +8 -3
  5. package/crates/team-agent/src/cli/diagnose.rs +12 -3
  6. package/crates/team-agent/src/cli/emit.rs +1 -0
  7. package/crates/team-agent/src/cli/mod.rs +250 -9
  8. package/crates/team-agent/src/cli/send.rs +11 -2
  9. package/crates/team-agent/src/cli/status_port.rs +39 -4
  10. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +189 -6
  11. package/crates/team-agent/src/cli/tests/status_send.rs +189 -0
  12. package/crates/team-agent/src/cli/tests/verb_settle.rs +2 -0
  13. package/crates/team-agent/src/cli/types.rs +5 -0
  14. package/crates/team-agent/src/coordinator/mod.rs +1 -0
  15. package/crates/team-agent/src/coordinator/steps/abnormal.rs +4 -0
  16. package/crates/team-agent/src/coordinator/steps/delivery.rs +4 -0
  17. package/crates/team-agent/src/coordinator/steps/health_sync.rs +4 -0
  18. package/crates/team-agent/src/coordinator/steps/mod.rs +83 -0
  19. package/crates/team-agent/src/coordinator/steps/persist.rs +4 -0
  20. package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +4 -0
  21. package/crates/team-agent/src/coordinator/steps/session_gate.rs +5 -0
  22. package/crates/team-agent/src/coordinator/tests/health_sync.rs +156 -0
  23. package/crates/team-agent/src/coordinator/tick.rs +126 -30
  24. package/crates/team-agent/src/{message_store.rs → db/message_store.rs} +6 -0
  25. package/crates/team-agent/src/db/mod.rs +1 -0
  26. package/crates/team-agent/src/layout/mod.rs +7 -0
  27. package/crates/team-agent/src/layout/runtime_sessions.rs +312 -0
  28. package/crates/team-agent/src/layout/tmux_endpoint.rs +162 -0
  29. package/crates/team-agent/src/leader/start.rs +27 -1
  30. package/crates/team-agent/src/leader/tests/identity.rs +50 -0
  31. package/crates/team-agent/src/lib.rs +6 -2
  32. package/crates/team-agent/src/lifecycle/launch/readiness.rs +32 -0
  33. package/crates/team-agent/src/lifecycle/launch/spawn.rs +32 -0
  34. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +51 -0
  35. package/crates/team-agent/src/lifecycle/launch.rs +34 -0
  36. package/crates/team-agent/src/lifecycle/restart/agent.rs +17 -0
  37. package/crates/team-agent/src/lifecycle/restart/common.rs +143 -23
  38. package/crates/team-agent/src/lifecycle/restart/preflight.rs +87 -0
  39. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +308 -0
  40. package/crates/team-agent/src/lifecycle/restart/selection.rs +87 -22
  41. package/crates/team-agent/src/lifecycle/restart.rs +6 -0
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +6 -1
  43. package/crates/team-agent/src/lifecycle/tests/restart.rs +187 -0
  44. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  45. package/crates/team-agent/src/lifecycle/types.rs +20 -1
  46. package/crates/team-agent/src/mcp_server/helpers.rs +72 -1
  47. package/crates/team-agent/src/mcp_server/tools.rs +29 -4
  48. package/crates/team-agent/src/provider/adapter.rs +31 -1
  49. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +34 -0
  50. package/crates/team-agent/src/provider/classify.rs +138 -0
  51. package/crates/team-agent/src/provider/command.rs +71 -0
  52. package/crates/team-agent/src/provider/mod.rs +3 -0
  53. package/crates/team-agent/src/{session_capture.rs → provider/session/capture.rs} +139 -6
  54. package/crates/team-agent/src/provider/session/mod.rs +13 -0
  55. package/crates/team-agent/src/provider/session/resume.rs +417 -0
  56. package/crates/team-agent/src/provider/session_scan.rs +63 -0
  57. package/crates/team-agent/src/provider/types.rs +5 -0
  58. package/crates/team-agent/src/state/persist.rs +238 -0
  59. package/crates/team-agent/src/tmux_backend.rs +25 -0
  60. package/npm/install.mjs +27 -1
  61. package/package.json +4 -4
@@ -0,0 +1,71 @@
1
+ //! unit-7 (Stage 3) — provider command-assembly boundary.
2
+ //!
3
+ //! The provider/adapter.rs monolith mixes three concerns:
4
+ //! 1. The `ProviderAdapter` TRAIT and facade dispatch (the public API).
5
+ //! 2. Per-provider COMMAND ASSEMBLY (argv plan, base/resume flags,
6
+ //! --session-id injection — adapter.rs:409-508 region).
7
+ //! 3. SESSION SCANNING (capture polling + transcript scanning —
8
+ //! adapter.rs:511-546, 969-1078).
9
+ //!
10
+ //! This module CREATES the dedicated home for #2 so future work can
11
+ //! migrate command-assembly fns here in small, behavior-neutral commits.
12
+ //! Today it only carries the documented intent and a small typed helper —
13
+ //! the bulk of the assembly fns are still in adapter.rs and reachable
14
+ //! through the existing pub(crate) surface.
15
+ //!
16
+ //! Why incremental: every command-assembly fn touches multiple internal
17
+ //! constants (`--session-id`, `--resume`, MCP-config-path conventions) and
18
+ //! per-provider quirks. Moving them blindly is high-risk. The boundary
19
+ //! exists so the next commit (or any contributor) has a documented target
20
+ //! for relocations.
21
+
22
+ use crate::provider::Provider;
23
+
24
+ /// Lightweight tag for command-plan branches in the existing adapter
25
+ /// (`Base` vs `Resume`). Distinct type so command-assembly code reads
26
+ /// clearly even when it lives next to adapter dispatch.
27
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
28
+ pub enum CommandPlanKind {
29
+ /// Initial spawn — `--session-id <uuid>` predetermined.
30
+ Base,
31
+ /// Resume an existing session — `--resume <sid>`, no fresh
32
+ /// `--session-id` injected.
33
+ Resume,
34
+ }
35
+
36
+ /// Returns true when the provider's command plan honors `--session-id`
37
+ /// for fresh spawns. Mirrors the existing adapter logic but lives next to
38
+ /// where future command-assembly fns will land.
39
+ pub fn honors_session_id_for_base_spawn(provider: Provider) -> bool {
40
+ matches!(
41
+ provider,
42
+ Provider::Codex
43
+ | Provider::Claude
44
+ | Provider::ClaudeCode
45
+ | Provider::Copilot
46
+ )
47
+ }
48
+
49
+ #[cfg(test)]
50
+ mod tests {
51
+ use super::*;
52
+
53
+ #[test]
54
+ fn fake_provider_does_not_honor_session_id() {
55
+ assert!(!honors_session_id_for_base_spawn(Provider::Fake));
56
+ }
57
+
58
+ #[test]
59
+ fn real_providers_honor_session_id() {
60
+ for p in [Provider::Codex, Provider::Claude, Provider::Copilot] {
61
+ assert!(honors_session_id_for_base_spawn(p));
62
+ }
63
+ }
64
+
65
+ #[test]
66
+ fn command_plan_kind_is_copy() {
67
+ let a = CommandPlanKind::Base;
68
+ let b = a;
69
+ assert_eq!(a, b);
70
+ }
71
+ }
@@ -34,7 +34,10 @@ pub use crate::model::enums::{AuthMode, Provider};
34
34
  pub mod adapter;
35
35
  pub mod approvals;
36
36
  pub mod classify;
37
+ pub mod command;
37
38
  pub mod faults;
39
+ pub mod session;
40
+ pub mod session_scan;
38
41
  pub mod startup_prompt;
39
42
  pub mod types;
40
43
  // helpers 全部原为模块私有(JSONL 解析 / 正则编译),非根可见 → 私有 mod,不参与 re-export。
@@ -409,14 +409,87 @@ where
409
409
  }
410
410
 
411
411
  fn agent_session_complete(agent: &Value) -> bool {
412
- agent
412
+ // RM-039-SESS-001 step 1 (architect verdict 2026-06-22): historically
413
+ // this returned true the moment `rollout_path` was non-empty — even when
414
+ // the file no longer existed on disk. That created a "stale-positive"
415
+ // capture tuple: the worker had a session_id + a stored rollout_path,
416
+ // but the provider had rotated/garbage-collected the actual transcript
417
+ // file. `pending_session_capture` skipped agents whose
418
+ // `agent_session_complete()` was true, so the runtime never recaptured
419
+ // the now-broken backing, and downstream consumers saw the stale tuple
420
+ // as authoritative.
421
+ //
422
+ // Fix: a non-empty `rollout_path` only counts as complete when the path
423
+ // actually exists. session_id remains a required non-empty check
424
+ // (architect directive: "Keep session_id non-empty as required; do not
425
+ // infer context from event log alone unless the referenced transcript
426
+ // path exists.").
427
+ //
428
+ // Blocker-2 Layer-1 (prerelease 0.4.0, architect bugs-prerelease-blockers.md §139):
429
+ // existence alone is not enough — a small Claude session-metadata JSON
430
+ // (~/.claude/sessions/<pid>.json, ~300-400 bytes, sessionId only, no
431
+ // assistant/user records) used to satisfy this and prevent recapture of
432
+ // the real .claude/projects/<cwd>/<sid>.jsonl transcript. For
433
+ // Claude/ClaudeCode rollout paths, additionally require a recognizable
434
+ // transcript lifecycle record (an assistant or user record). Other
435
+ // providers keep existence-only semantics (codex 不可改项).
436
+ let session_id_ok = agent
413
437
  .get("session_id")
414
438
  .and_then(Value::as_str)
415
- .is_some_and(|session| !session.is_empty())
416
- && agent
417
- .get("rollout_path")
418
- .and_then(Value::as_str)
419
- .is_some_and(|path| !path.is_empty())
439
+ .is_some_and(|session| !session.is_empty());
440
+ if !session_id_ok {
441
+ return false;
442
+ }
443
+ let rollout_path = match agent
444
+ .get("rollout_path")
445
+ .and_then(Value::as_str)
446
+ .filter(|path| !path.is_empty())
447
+ {
448
+ Some(path) => path,
449
+ None => return false,
450
+ };
451
+ let path = std::path::Path::new(rollout_path);
452
+ if !path.exists() {
453
+ return false;
454
+ }
455
+ let provider_wire = agent.get("provider").and_then(Value::as_str).unwrap_or("");
456
+ if matches!(provider_wire, "claude" | "claude-code" | "claude_code") {
457
+ return claude_rollout_has_lifecycle_records(path);
458
+ }
459
+ true
460
+ }
461
+
462
+ /// Blocker-2 Layer-1 (prerelease 0.4.0): a Claude rollout file qualifies as
463
+ /// activity backing only when it contains at least one recognizable transcript
464
+ /// lifecycle record (top-level `type:"assistant"` or `type:"user"`). The
465
+ /// ~/.claude/sessions/<pid>.json metadata file has neither, so this returns
466
+ /// false for it and the runtime recaptures via the .claude/projects/<cwd>/
467
+ /// scan in adapter.rs. Bounded read so a large transcript is not slurped.
468
+ fn claude_rollout_has_lifecycle_records(path: &std::path::Path) -> bool {
469
+ use std::io::Read;
470
+ const MAX_BYTES: u64 = 65_536;
471
+ let Ok(file) = std::fs::File::open(path) else {
472
+ return false;
473
+ };
474
+ let mut bytes = Vec::new();
475
+ if file.take(MAX_BYTES).read_to_end(&mut bytes).is_err() {
476
+ return false;
477
+ }
478
+ let text = String::from_utf8_lossy(&bytes);
479
+ for line in text.lines() {
480
+ let trimmed = line.trim();
481
+ if trimmed.is_empty() {
482
+ continue;
483
+ }
484
+ let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
485
+ continue;
486
+ };
487
+ let kind = value.get("type").and_then(Value::as_str).unwrap_or("");
488
+ if matches!(kind, "assistant" | "user") {
489
+ return true;
490
+ }
491
+ }
492
+ false
420
493
  }
421
494
 
422
495
  fn allocate_session_candidates(
@@ -874,4 +947,64 @@ mod u1_tests {
874
947
  }]
875
948
  );
876
949
  }
950
+
951
+ /// RM-039-SESS-001 step 1 (architect verdict 2026-06-22): the
952
+ /// `agent_session_complete` predicate must NOT treat a non-empty
953
+ /// `rollout_path` as complete when the file does not exist. The
954
+ /// historical bug was that a stale-positive capture tuple
955
+ /// (`session_id` + `rollout_path` both present, but the path had
956
+ /// been rotated away by the provider) was reported as complete and
957
+ /// blocked `pending_session_capture` from recapturing. Now the
958
+ /// predicate returns false if the path does not exist, so the
959
+ /// session is re-evaluated on the next convergence tick.
960
+ #[test]
961
+ fn rm039_sess001_agent_session_complete_requires_existing_rollout_path() {
962
+ // Case 1: session_id + rollout_path both non-empty, path absent.
963
+ let missing = "/tmp/ta-rm039-sess001-nonexistent-rollout.jsonl";
964
+ // Guard against a previous test run leaving the file behind.
965
+ let _ = std::fs::remove_file(missing);
966
+ let agent_stale = serde_json::json!({
967
+ "session_id": "sess-stale-positive",
968
+ "rollout_path": missing,
969
+ });
970
+ assert!(
971
+ !agent_session_complete(&agent_stale),
972
+ "RM-039-SESS-001: stale-positive tuple (session_id + missing rollout_path) \
973
+ must NOT be reported complete"
974
+ );
975
+
976
+ // Case 2: session_id + rollout_path both non-empty, path exists.
977
+ use std::sync::atomic::{AtomicU64, Ordering};
978
+ static N: AtomicU64 = AtomicU64::new(0);
979
+ let n = N.fetch_add(1, Ordering::Relaxed);
980
+ let existing = std::env::temp_dir().join(format!(
981
+ "ta-rm039-sess001-existing-{}-{}.jsonl",
982
+ std::process::id(),
983
+ n
984
+ ));
985
+ std::fs::write(&existing, b"{}\n").expect("write fixture rollout file");
986
+ let agent_complete = serde_json::json!({
987
+ "session_id": "sess-real",
988
+ "rollout_path": existing.to_string_lossy(),
989
+ });
990
+ assert!(
991
+ agent_session_complete(&agent_complete),
992
+ "an agent with session_id + an existing rollout file remains complete"
993
+ );
994
+ let _ = std::fs::remove_file(&existing);
995
+
996
+ // Case 3: empty session_id must still fail completeness regardless.
997
+ let agent_no_session = serde_json::json!({
998
+ "session_id": "",
999
+ "rollout_path": "/tmp/whatever",
1000
+ });
1001
+ assert!(!agent_session_complete(&agent_no_session));
1002
+
1003
+ // Case 4: empty rollout_path remains incomplete (legacy contract).
1004
+ let agent_no_path = serde_json::json!({
1005
+ "session_id": "sess-x",
1006
+ "rollout_path": "",
1007
+ });
1008
+ assert!(!agent_session_complete(&agent_no_path));
1009
+ }
877
1010
  }
@@ -0,0 +1,13 @@
1
+ //! unit-6 (Stage 2) — provider session namespace.
2
+ //!
3
+ //! Houses session-specific provider concerns: resume preflight,
4
+ //! session capture (moved from `crate::session_capture` in unit-6),
5
+ //! session backing checks, etc.
6
+
7
+ pub mod capture;
8
+ pub mod resume;
9
+
10
+ pub use resume::{
11
+ ProviderBackingCheck, RecoveryHint, ResumeDecisionDetail, ResumePreflight,
12
+ ResumePreflightOutcome, ResumeRefusalReason,
13
+ };
@@ -0,0 +1,417 @@
1
+ //! unit-5 (Stage 2) — resume preflight + closed `ResumeRefusalReason` enum.
2
+ //!
3
+ //! Today the restart resume gate flattens every refusal into one of two
4
+ //! opaque strings:
5
+ //!
6
+ //! * `"no_persisted_session_id"` — no `session_id` in state
7
+ //! * `"session_unresumable"` — `session_id` set but backing missing
8
+ //! OR provider can't resume OR session
9
+ //! ambiguous (all collapsed to one string)
10
+ //!
11
+ //! That string is the user-facing diagnostic, so debugging "why did restart
12
+ //! refuse?" requires reading source. This unit replaces the catch-all
13
+ //! `session_unresumable` with a closed enum carrying the structured
14
+ //! distinction.
15
+ //!
16
+ //! Migration strategy: ADDITIVE. `UnresumableWorker.reason: String` stays
17
+ //! (every CLI/JSON caller already reads it). A new optional
18
+ //! `refusal_reason: Option<ResumeRefusalReason>` carries the structured
19
+ //! value alongside. Callers that need actionable diagnostics flip to the
20
+ //! enum; legacy callers see the same string they always saw.
21
+
22
+ use std::path::PathBuf;
23
+
24
+ /// Layer 2 self-healing hint (architect probe 2026-06-22, §Recommended
25
+ /// design): operator-facing diagnostic that names the agent / cwd /
26
+ /// provider for a missing-backing refusal so the operator can manually
27
+ /// find / repair the dropped session. Carried alongside the refusal —
28
+ /// NEVER consumed for automatic resume. Auto-recovery requires multi-key
29
+ /// filtering + backing revalidation (Layer 3 follow-up, see
30
+ /// `session.recovery.candidate_promoted` event design).
31
+ #[derive(Debug, Clone, PartialEq, Eq)]
32
+ pub struct RecoveryHint {
33
+ /// Agent role label that was passed at launch as `--name` / `-n`
34
+ /// (Claude / Copilot). Codex has no launch-time name flag, so this
35
+ /// is `None` for Codex workers (probe verdict 2026-06-22).
36
+ pub provider_session_name_hint: Option<String>,
37
+ /// Spawn cwd recorded for the worker — the second key in the
38
+ /// "provider+cwd+name+updated_at" filter Layer 3 will require.
39
+ pub spawn_cwd: Option<PathBuf>,
40
+ /// Wire provider name (`codex` / `claude` / `claude_code` /
41
+ /// `copilot`). Used for picker hint string only.
42
+ pub provider: String,
43
+ }
44
+
45
+ impl RecoveryHint {
46
+ /// Build a human-readable picker hint (one line). Layer 2 surfaces
47
+ /// this in the CLI refusal message and in the
48
+ /// `session.recovery.candidate_hint` event payload.
49
+ pub fn picker_hint(&self) -> String {
50
+ match (&self.provider_session_name_hint, &self.spawn_cwd) {
51
+ (Some(name), Some(cwd)) => format!(
52
+ "{} session named '{}' under cwd {}",
53
+ self.provider,
54
+ name,
55
+ cwd.display()
56
+ ),
57
+ (Some(name), None) => {
58
+ format!("{} session named '{}'", self.provider, name)
59
+ }
60
+ (None, Some(cwd)) => format!("{} session under cwd {}", self.provider, cwd.display()),
61
+ (None, None) => format!("{} session", self.provider),
62
+ }
63
+ }
64
+ }
65
+
66
+ /// Structured reason a worker is unresumable. Closed enum — future
67
+ /// reasons require a code change, which is the point.
68
+ #[derive(Debug, Clone, PartialEq, Eq)]
69
+ pub enum ResumeRefusalReason {
70
+ /// State has no `session_id` for this worker. Fresh start would be
71
+ /// safe iff `--allow-fresh` is set.
72
+ NoSessionId,
73
+ /// State has `session_id` but the provider backing file (Codex rollout
74
+ /// JSONL / Claude transcript) is missing on disk. Distinct from
75
+ /// "no session" because it implies provider lost the backing.
76
+ SessionBackingStoreMissing {
77
+ /// Paths the runtime probed for the backing file.
78
+ checked_paths: Vec<PathBuf>,
79
+ /// Layer 2 self-healing diagnostic (architect probe 2026-06-22):
80
+ /// agent / cwd / provider hint the operator can use to find or
81
+ /// repair the dropped session via the provider's own picker
82
+ /// (`claude --resume <name>`, `copilot --resume=<name>`).
83
+ /// **Never auto-consumed for resume** — auto-recovery requires
84
+ /// the Layer 3 multi-key filter + backing revalidation contract.
85
+ recovery_hint: Option<RecoveryHint>,
86
+ },
87
+ /// Provider does not support resume at the protocol level (e.g.
88
+ /// some auth modes).
89
+ ProviderResumeUnsupported {
90
+ /// Provider name (`codex` / `claude` / `copilot` / `fake`).
91
+ provider: String,
92
+ },
93
+ /// Session capture polling did not converge before the deadline
94
+ /// (the existing `RefusedResumeNotReady` shape).
95
+ SessionCaptureIncomplete,
96
+ /// State session_id differs from the provider's observed session
97
+ /// (T6 L5.5 drift). Caller should reconcile before resuming.
98
+ SessionDrift {
99
+ expected: String,
100
+ actual: String,
101
+ },
102
+ /// Catch-all for refusals the structured shape hasn't taxonomized
103
+ /// yet. Carries the legacy free-form string. This variant exists so
104
+ /// the enum is BACKWARD-COMPATIBLE with the historical
105
+ /// `session_unresumable` string — every value the old code emitted
106
+ /// can be lifted into the enum without losing fidelity.
107
+ Other { legacy_reason: String },
108
+ }
109
+
110
+ impl ResumeRefusalReason {
111
+ /// Stable wire string mirroring the historical
112
+ /// `UnresumableWorker.reason` values. Use this when emitting JSON or
113
+ /// log fields so downstream consumers see the same strings they
114
+ /// always have.
115
+ pub fn wire(&self) -> &'static str {
116
+ match self {
117
+ ResumeRefusalReason::NoSessionId => "no_persisted_session_id",
118
+ ResumeRefusalReason::SessionBackingStoreMissing { .. } => {
119
+ "session_backing_store_missing"
120
+ }
121
+ ResumeRefusalReason::ProviderResumeUnsupported { .. } => {
122
+ "provider_resume_unsupported"
123
+ }
124
+ ResumeRefusalReason::SessionCaptureIncomplete => "session_capture_incomplete",
125
+ ResumeRefusalReason::SessionDrift { .. } => "session_drift",
126
+ // For Other we still report the legacy wire so the existing
127
+ // `session_unresumable` JSON shape is preserved end-to-end.
128
+ ResumeRefusalReason::Other { .. } => "session_unresumable",
129
+ }
130
+ }
131
+
132
+ /// Lift a legacy free-form `reason` string into the structured enum.
133
+ /// Round-trip-safe with `wire()` for the canonical names.
134
+ pub fn from_legacy(reason: &str) -> Self {
135
+ match reason {
136
+ "no_persisted_session_id" => ResumeRefusalReason::NoSessionId,
137
+ "session_backing_store_missing" => {
138
+ ResumeRefusalReason::SessionBackingStoreMissing {
139
+ checked_paths: Vec::new(),
140
+ recovery_hint: None,
141
+ }
142
+ }
143
+ "provider_resume_unsupported" => ResumeRefusalReason::ProviderResumeUnsupported {
144
+ provider: String::new(),
145
+ },
146
+ "session_capture_incomplete" => ResumeRefusalReason::SessionCaptureIncomplete,
147
+ other => ResumeRefusalReason::Other {
148
+ legacy_reason: other.to_string(),
149
+ },
150
+ }
151
+ }
152
+ }
153
+
154
+ /// Outcome of `ResumePreflight::check`.
155
+ #[derive(Debug, Clone, PartialEq, Eq)]
156
+ pub enum ResumePreflightOutcome {
157
+ /// Worker can resume from the named session.
158
+ Resume {
159
+ session_id: String,
160
+ },
161
+ /// `--allow-fresh` was set; worker will start fresh.
162
+ FreshStart,
163
+ /// Resume refused. Caller MUST NOT proceed with teardown/spawn.
164
+ Refuse {
165
+ reason: ResumeRefusalReason,
166
+ },
167
+ }
168
+
169
+ /// Information about whether a provider backing file is present on disk.
170
+ #[derive(Debug, Clone, PartialEq, Eq)]
171
+ pub struct ProviderBackingCheck {
172
+ pub paths: Vec<PathBuf>,
173
+ pub exists: bool,
174
+ }
175
+
176
+ /// Per-decision detail emitted to events / JSON.
177
+ #[derive(Debug, Clone, PartialEq, Eq)]
178
+ pub struct ResumeDecisionDetail {
179
+ pub agent_id: String,
180
+ pub session_id: Option<String>,
181
+ pub provider: String,
182
+ pub backing: Option<ProviderBackingCheck>,
183
+ pub outcome: ResumePreflightOutcome,
184
+ }
185
+
186
+ /// Pure resume preflight evaluator. No I/O — callers pass facts in.
187
+ pub struct ResumePreflight;
188
+
189
+ impl ResumePreflight {
190
+ /// Decide a single worker's resume outcome from the facts the
191
+ /// restart code already collected. Mirrors the existing
192
+ /// `classify_restart_plan_with_resume_validation` decision tree:
193
+ ///
194
+ /// * session_id present + provider can resume + backing exists -> Resume
195
+ /// * session_id present + (no resume OR no backing) + allow_fresh -> FreshStart
196
+ /// * session_id present + (no resume OR no backing) + !allow_fresh -> Refuse
197
+ /// * session_id absent + allow_fresh -> FreshStart
198
+ /// * session_id absent + !allow_fresh -> Refuse(NoSessionId)
199
+ pub fn check(
200
+ session_id: Option<&str>,
201
+ provider_can_resume: bool,
202
+ backing: Option<&ProviderBackingCheck>,
203
+ provider_name: &str,
204
+ allow_fresh: bool,
205
+ ) -> ResumePreflightOutcome {
206
+ Self::check_with_hint(
207
+ session_id,
208
+ provider_can_resume,
209
+ backing,
210
+ provider_name,
211
+ allow_fresh,
212
+ None,
213
+ )
214
+ }
215
+
216
+ /// Layer 2 self-healing variant: same decision tree as
217
+ /// [`Self::check`], but propagates an optional `RecoveryHint` into
218
+ /// the `SessionBackingStoreMissing` refusal so the CLI / event log
219
+ /// can surface a "look for session named X under cwd Y" pointer to
220
+ /// the operator. The hint is for HUMAN consumption only — no
221
+ /// auto-resume happens off it (Layer 3 follow-up).
222
+ pub fn check_with_hint(
223
+ session_id: Option<&str>,
224
+ provider_can_resume: bool,
225
+ backing: Option<&ProviderBackingCheck>,
226
+ provider_name: &str,
227
+ allow_fresh: bool,
228
+ recovery_hint: Option<RecoveryHint>,
229
+ ) -> ResumePreflightOutcome {
230
+ match session_id {
231
+ Some(sid) if provider_can_resume => {
232
+ let backing_present = backing.map(|b| b.exists).unwrap_or(true);
233
+ if backing_present {
234
+ ResumePreflightOutcome::Resume {
235
+ session_id: sid.to_string(),
236
+ }
237
+ } else if allow_fresh {
238
+ ResumePreflightOutcome::FreshStart
239
+ } else {
240
+ ResumePreflightOutcome::Refuse {
241
+ reason: ResumeRefusalReason::SessionBackingStoreMissing {
242
+ checked_paths: backing
243
+ .map(|b| b.paths.clone())
244
+ .unwrap_or_default(),
245
+ recovery_hint,
246
+ },
247
+ }
248
+ }
249
+ }
250
+ Some(_) if allow_fresh => ResumePreflightOutcome::FreshStart,
251
+ Some(_) => ResumePreflightOutcome::Refuse {
252
+ reason: ResumeRefusalReason::ProviderResumeUnsupported {
253
+ provider: provider_name.to_string(),
254
+ },
255
+ },
256
+ None if allow_fresh => ResumePreflightOutcome::FreshStart,
257
+ None => ResumePreflightOutcome::Refuse {
258
+ reason: ResumeRefusalReason::NoSessionId,
259
+ },
260
+ }
261
+ }
262
+ }
263
+
264
+ #[cfg(test)]
265
+ mod tests {
266
+ use super::*;
267
+
268
+ #[test]
269
+ fn wire_round_trip_canonical_strings() {
270
+ for wire in [
271
+ "no_persisted_session_id",
272
+ "session_backing_store_missing",
273
+ "provider_resume_unsupported",
274
+ "session_capture_incomplete",
275
+ ] {
276
+ assert_eq!(ResumeRefusalReason::from_legacy(wire).wire(), wire);
277
+ }
278
+ }
279
+
280
+ #[test]
281
+ fn other_preserves_legacy_session_unresumable_wire() {
282
+ let r = ResumeRefusalReason::from_legacy("session_unresumable");
283
+ assert_eq!(r.wire(), "session_unresumable");
284
+ assert!(matches!(r, ResumeRefusalReason::Other { .. }));
285
+ }
286
+
287
+ #[test]
288
+ fn preflight_resume_when_session_and_backing_present() {
289
+ let backing = ProviderBackingCheck {
290
+ paths: vec![PathBuf::from("/tmp/x.jsonl")],
291
+ exists: true,
292
+ };
293
+ let out = ResumePreflight::check(Some("sess-1"), true, Some(&backing), "codex", false);
294
+ assert!(matches!(out, ResumePreflightOutcome::Resume { .. }));
295
+ }
296
+
297
+ #[test]
298
+ fn preflight_refuses_when_session_id_missing_without_allow_fresh() {
299
+ let out = ResumePreflight::check(None, true, None, "codex", false);
300
+ assert_eq!(
301
+ out,
302
+ ResumePreflightOutcome::Refuse {
303
+ reason: ResumeRefusalReason::NoSessionId
304
+ }
305
+ );
306
+ }
307
+
308
+ #[test]
309
+ fn preflight_distinguishes_backing_missing_from_no_session_id() {
310
+ let backing = ProviderBackingCheck {
311
+ paths: vec![PathBuf::from("/missing.jsonl")],
312
+ exists: false,
313
+ };
314
+ let out = ResumePreflight::check(Some("sess-x"), true, Some(&backing), "codex", false);
315
+ match out {
316
+ ResumePreflightOutcome::Refuse { reason } => {
317
+ assert_eq!(reason.wire(), "session_backing_store_missing");
318
+ }
319
+ other => panic!("expected backing-missing refusal; got {other:?}"),
320
+ }
321
+ }
322
+
323
+ #[test]
324
+ fn preflight_allow_fresh_converts_refusals_to_fresh() {
325
+ let backing = ProviderBackingCheck {
326
+ paths: vec![PathBuf::from("/missing.jsonl")],
327
+ exists: false,
328
+ };
329
+ assert_eq!(
330
+ ResumePreflight::check(None, true, None, "codex", true),
331
+ ResumePreflightOutcome::FreshStart
332
+ );
333
+ assert_eq!(
334
+ ResumePreflight::check(Some("sess-x"), true, Some(&backing), "codex", true),
335
+ ResumePreflightOutcome::FreshStart
336
+ );
337
+ }
338
+
339
+ // Layer 2 self-healing tests ────────────────────────────────────────────
340
+
341
+ #[test]
342
+ fn recovery_hint_picker_string_with_all_fields() {
343
+ let hint = RecoveryHint {
344
+ provider_session_name_hint: Some("coder".to_string()),
345
+ spawn_cwd: Some(PathBuf::from("/repo/team-a")),
346
+ provider: "claude".to_string(),
347
+ };
348
+ assert_eq!(
349
+ hint.picker_hint(),
350
+ "claude session named 'coder' under cwd /repo/team-a"
351
+ );
352
+ }
353
+
354
+ #[test]
355
+ fn recovery_hint_picker_string_no_name_no_cwd() {
356
+ // Codex worker — no launch-time name, spawn_cwd missing.
357
+ let hint = RecoveryHint {
358
+ provider_session_name_hint: None,
359
+ spawn_cwd: None,
360
+ provider: "codex".to_string(),
361
+ };
362
+ assert_eq!(hint.picker_hint(), "codex session");
363
+ }
364
+
365
+ #[test]
366
+ fn preflight_with_hint_attaches_to_backing_missing_refusal() {
367
+ let backing = ProviderBackingCheck {
368
+ paths: vec![PathBuf::from("/missing.jsonl")],
369
+ exists: false,
370
+ };
371
+ let hint = RecoveryHint {
372
+ provider_session_name_hint: Some("coder".to_string()),
373
+ spawn_cwd: Some(PathBuf::from("/repo")),
374
+ provider: "claude".to_string(),
375
+ };
376
+ let out = ResumePreflight::check_with_hint(
377
+ Some("sess-1"),
378
+ true,
379
+ Some(&backing),
380
+ "claude",
381
+ false,
382
+ Some(hint.clone()),
383
+ );
384
+ match out {
385
+ ResumePreflightOutcome::Refuse {
386
+ reason: ResumeRefusalReason::SessionBackingStoreMissing {
387
+ recovery_hint: Some(h),
388
+ ..
389
+ },
390
+ } => {
391
+ assert_eq!(h, hint);
392
+ }
393
+ other => panic!("expected SessionBackingStoreMissing with hint; got {other:?}"),
394
+ }
395
+ }
396
+
397
+ #[test]
398
+ fn preflight_no_hint_legacy_behavior_unchanged() {
399
+ // Default check() (no hint) still produces None recovery_hint —
400
+ // wire-string-compatible with pre-Layer-2 callers.
401
+ let backing = ProviderBackingCheck {
402
+ paths: vec![PathBuf::from("/missing.jsonl")],
403
+ exists: false,
404
+ };
405
+ let out = ResumePreflight::check(Some("sess-x"), true, Some(&backing), "codex", false);
406
+ match out {
407
+ ResumePreflightOutcome::Refuse {
408
+ reason: ResumeRefusalReason::SessionBackingStoreMissing {
409
+ recovery_hint, ..
410
+ },
411
+ } => {
412
+ assert!(recovery_hint.is_none());
413
+ }
414
+ other => panic!("expected backing-missing refusal; got {other:?}"),
415
+ }
416
+ }
417
+ }