@team-agent/installer 0.3.23 → 0.3.25

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.
package/Cargo.lock CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.3.23"
569
+ version = "0.3.25"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.3.23"
12
+ version = "0.3.25"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -544,13 +544,27 @@ impl Coordinator {
544
544
  .get("last_output_at")
545
545
  .and_then(Value::as_str)
546
546
  .map(str::to_string);
547
- let activity = crate::messaging::classify_agent_activity(
548
- &snapshot,
549
- &captured.text,
550
- pane_in_mode,
551
- current_command.as_deref(),
552
- last_output_at_now.as_deref(),
553
- );
547
+ // E47 (0.3.24 P0, idle/busy 假阳): consult the AUTHORITATIVE
548
+ // provider JSONL classifier BEFORE the TUI keyword scan. The
549
+ // scrollback Tail(40) keeps historical `• Working (...· esc to
550
+ // interrupt)` + spinners within the 40-line window long after a
551
+ // turn completed, so `classify_agent_activity`'s rfind-recency
552
+ // grep flips a truly-idle worker to Working/Stuck. The provider
553
+ // JSONL (codex task_complete / claude end_turn) is the single
554
+ // source of truth for "turn closed". Only fall back to TUI
555
+ // scanning when the JSONL is unreadable or no lifecycle fact has
556
+ // been written yet (TurnState::Unknown) — never silently fall
557
+ // through into the leaky grep on a CONFIRMED IDLE.
558
+ let activity_from_jsonl = jsonl_activity_for_agent(agent);
559
+ let activity = activity_from_jsonl.unwrap_or_else(|| {
560
+ crate::messaging::classify_agent_activity(
561
+ &snapshot,
562
+ &captured.text,
563
+ pane_in_mode,
564
+ current_command.as_deref(),
565
+ last_output_at_now.as_deref(),
566
+ )
567
+ });
554
568
  remember_idle_capture_schedule(agent, &activity);
555
569
  write_activity(agent, &activity, false);
556
570
  let last_output_at = last_output_at_now;
@@ -2519,6 +2533,40 @@ fn agent_rollout_path(agent: &Value) -> Option<PathBuf> {
2519
2533
  .map(PathBuf::from)
2520
2534
  }
2521
2535
 
2536
+ /// E47 (0.3.24 P0, idle/busy 假阳): consult the authoritative provider JSONL
2537
+ /// classifier and map to neutral `AgentActivity`. Returns `None` when the
2538
+ /// classifier reports `TurnState::Unknown` (unreadable JSONL / no lifecycle
2539
+ /// fact yet) so the caller falls back to the TUI scan — this honours the
2540
+ /// IRON LAW (activity.rs:3 / bug-071/077/085): no-signal = Uncertain (not
2541
+ /// silently coerced to Idle); but here Unknown means "JSONL gave no signal",
2542
+ /// so we hand off to the TUI scanner which has its OWN no-signal → Uncertain
2543
+ /// path. Copilot/Gemini/Fake providers (which don't have JSONL — classify.rs
2544
+ /// returns Unknown for them) thus keep using TUI scanning unchanged.
2545
+ fn jsonl_activity_for_agent(agent: &Value) -> Option<crate::messaging::AgentActivity> {
2546
+ let rollout_path = agent_rollout_path(agent)?;
2547
+ let provider = agent
2548
+ .get("provider")
2549
+ .and_then(Value::as_str)
2550
+ .and_then(parse_provider)?;
2551
+ let log_text = std::fs::read_to_string(&rollout_path).ok()?;
2552
+ let process = explicit_process_liveness(agent).unwrap_or(ProcessLiveness::Unverifiable);
2553
+ let result = crate::provider::classify(provider, &log_text, process, 0.0).ok()?;
2554
+ use crate::messaging::{ActivityStatus, AgentActivity};
2555
+ use crate::provider::types::TurnState;
2556
+ let status = match result.state {
2557
+ TurnState::Idle => ActivityStatus::Idle,
2558
+ TurnState::IdleInterrupted => ActivityStatus::Idle,
2559
+ TurnState::Working => ActivityStatus::Working,
2560
+ TurnState::BlockedOnHuman | TurnState::Abnormal => ActivityStatus::Uncertain,
2561
+ TurnState::Unknown => return None,
2562
+ };
2563
+ Some(AgentActivity {
2564
+ status,
2565
+ confidence: 0.95,
2566
+ rationale: format!("provider_jsonl:{}", result.reason),
2567
+ })
2568
+ }
2569
+
2522
2570
  fn runtime_approval_target(
2523
2571
  agent: &Value,
2524
2572
  session_name: Option<&str>,
@@ -2,7 +2,7 @@
2
2
  //! + 双写 / 分叉检测。
3
3
 
4
4
  use std::collections::BTreeSet;
5
- use std::path::Path;
5
+ use std::path::{Path, PathBuf};
6
6
 
7
7
  use serde_json::{json, Value};
8
8
 
@@ -1024,14 +1024,27 @@ fn state_owner(state: &Value) -> Option<TeamOwner> {
1024
1024
  pub fn write_lease_dual_state(workspace: &Path, state: &Value) -> Result<(), LeaderError> {
1025
1025
  crate::state::persist::save_runtime_state(workspace, state)?;
1026
1026
  if let Some(session_name) = state.get("session_name").and_then(Value::as_str) {
1027
- let snap_path = crate::model::paths::runtime_dir(workspace)
1028
- .join("teams")
1029
- .join(session_name)
1030
- .join("state.json");
1031
- if let Some(parent) = snap_path.parent() {
1032
- std::fs::create_dir_all(parent)?;
1033
- }
1034
- std::fs::write(snap_path, serde_json::to_string_pretty(state)?)?;
1027
+ write_team_snapshot_atomic(workspace, session_name, state)?;
1028
+ }
1029
+ Ok(())
1030
+ }
1031
+
1032
+ fn write_team_snapshot_atomic(
1033
+ workspace: &Path,
1034
+ session_name: &str,
1035
+ state: &Value,
1036
+ ) -> Result<(), LeaderError> {
1037
+ let snap_path = crate::lifecycle::helpers::team_snapshot_path(workspace, session_name);
1038
+ let parent = snap_path
1039
+ .parent()
1040
+ .ok_or_else(|| LeaderError::Validation("team snapshot path has no parent".to_string()))?;
1041
+ std::fs::create_dir_all(parent)?;
1042
+ let tmp = parent.join(format!("state.json.tmp-{}", std::process::id()));
1043
+ let data = serde_json::to_vec_pretty(state)?;
1044
+ std::fs::write(&tmp, data)?;
1045
+ if let Err(error) = std::fs::rename(&tmp, &snap_path) {
1046
+ let _ = std::fs::remove_file(&tmp);
1047
+ return Err(error.into());
1035
1048
  }
1036
1049
  Ok(())
1037
1050
  }
@@ -1113,10 +1126,7 @@ pub fn detect_dual_state_divergence(
1113
1126
  let Some(session_name) = state.get("session_name").and_then(Value::as_str) else {
1114
1127
  return Ok(None);
1115
1128
  };
1116
- let snap_path = crate::model::paths::runtime_dir(workspace)
1117
- .join("teams")
1118
- .join(session_name)
1119
- .join("state.json");
1129
+ let snap_path = readable_team_snapshot_path(workspace, session_name);
1120
1130
  if !snap_path.exists() {
1121
1131
  return Ok(None);
1122
1132
  }
@@ -1150,6 +1160,17 @@ pub fn detect_dual_state_divergence(
1150
1160
  })))
1151
1161
  }
1152
1162
 
1163
+ fn readable_team_snapshot_path(workspace: &Path, session_name: &str) -> PathBuf {
1164
+ let safe_path = crate::lifecycle::helpers::team_snapshot_path(workspace, session_name);
1165
+ if safe_path.exists() {
1166
+ return safe_path;
1167
+ }
1168
+ crate::model::paths::runtime_dir(workspace)
1169
+ .join("teams")
1170
+ .join(session_name)
1171
+ .join("state.json")
1172
+ }
1173
+
1153
1174
  #[cfg(test)]
1154
1175
  mod tests {
1155
1176
  use super::*;
@@ -145,6 +145,85 @@ use super::*;
145
145
  );
146
146
  }
147
147
 
148
+ #[test]
149
+ fn lease_divergence_reads_restart_snapshot_for_special_session_names() {
150
+ let ws = std::env::temp_dir().join(format!("ta_rs_dual_special_{}", std::process::id()));
151
+ std::fs::create_dir_all(&ws).unwrap();
152
+ let state = serde_json::json!({
153
+ "session_name": "team:proj-中文",
154
+ "team_owner": {"pane_id": "%1", "owner_epoch": 2, "leader_session_uuid": "uuuu"},
155
+ "leader_receiver": {"pane_id": "%1", "owner_epoch": 2},
156
+ });
157
+ write_lease_dual_state(&ws, &state).unwrap();
158
+ let safe_path = crate::lifecycle::helpers::team_snapshot_path(&ws, "team:proj-中文");
159
+ let raw_path = crate::model::paths::runtime_dir(&ws)
160
+ .join("teams")
161
+ .join("team:proj-中文")
162
+ .join("state.json");
163
+ assert!(
164
+ safe_path.exists(),
165
+ "lease snapshot must use the restart-safe path: {}",
166
+ safe_path.display()
167
+ );
168
+ assert!(
169
+ !raw_path.exists(),
170
+ "new lease writes must not create the legacy raw session path: {}",
171
+ raw_path.display()
172
+ );
173
+
174
+ let restart_state = serde_json::json!({
175
+ "session_name": "team:proj-中文",
176
+ "team_owner": {"pane_id": "%9", "owner_epoch": 2, "leader_session_uuid": "uuuu"},
177
+ "leader_receiver": {"pane_id": "%9", "owner_epoch": 2},
178
+ });
179
+ let restart_path = crate::lifecycle::save_team_runtime_snapshot(&ws, &restart_state).unwrap();
180
+ assert_eq!(restart_path, safe_path);
181
+ assert!(
182
+ restart_path.exists(),
183
+ "restart snapshot must be written to a real file: {}",
184
+ restart_path.display()
185
+ );
186
+
187
+ let d = detect_dual_state_divergence(&ws, &state)
188
+ .unwrap()
189
+ .expect("divergence detector must read the restart-written snapshot");
190
+ assert_eq!(d["workspace_owner_pane"], serde_json::json!("%1"));
191
+ assert_eq!(d["team_owner_pane"], serde_json::json!("%9"));
192
+ assert_eq!(d["workspace_receiver_pane"], serde_json::json!("%1"));
193
+ assert_eq!(d["team_receiver_pane"], serde_json::json!("%9"));
194
+ }
195
+
196
+ #[test]
197
+ fn detect_dual_state_divergence_reads_legacy_raw_snapshot_when_safe_absent() {
198
+ let ws = std::env::temp_dir().join(format!("ta_rs_dual_legacy_{}", std::process::id()));
199
+ std::fs::create_dir_all(&ws).unwrap();
200
+ let state = serde_json::json!({
201
+ "session_name": "team:legacy-中文",
202
+ "team_owner": {"pane_id": "%1", "leader_session_uuid": "uuuu", "owner_epoch": 2},
203
+ "leader_receiver": {"pane_id": "%1", "owner_epoch": 2},
204
+ });
205
+ let safe_path = crate::lifecycle::helpers::team_snapshot_path(&ws, "team:legacy-中文");
206
+ assert!(!safe_path.exists(), "test setup requires safe path absent");
207
+ let raw_dir = crate::model::paths::runtime_dir(&ws)
208
+ .join("teams")
209
+ .join("team:legacy-中文");
210
+ std::fs::create_dir_all(&raw_dir).unwrap();
211
+ let snap = serde_json::json!({
212
+ "session_name": "team:legacy-中文",
213
+ "team_owner": {"pane_id": "%8", "leader_session_uuid": "uuuu", "owner_epoch": 2},
214
+ "leader_receiver": {"pane_id": "%8", "owner_epoch": 2},
215
+ });
216
+ std::fs::write(raw_dir.join("state.json"), serde_json::to_string(&snap).unwrap()).unwrap();
217
+
218
+ let d = detect_dual_state_divergence(&ws, &state)
219
+ .unwrap()
220
+ .expect("legacy raw snapshot fallback must preserve old teams");
221
+ assert_eq!(d["workspace_owner_pane"], serde_json::json!("%1"));
222
+ assert_eq!(d["team_owner_pane"], serde_json::json!("%8"));
223
+ assert_eq!(d["workspace_receiver_pane"], serde_json::json!("%1"));
224
+ assert_eq!(d["team_receiver_pane"], serde_json::json!("%8"));
225
+ }
226
+
148
227
  // detect_dual_state_divergence:无 session_name → None(__init__.py:560-561);unimplemented → RED。
149
228
  #[test]
150
229
  fn detect_dual_state_divergence_none_without_session_name() {
@@ -16,14 +16,15 @@ pub fn save_team_runtime_snapshot(
16
16
  .get("session_name")
17
17
  .and_then(|v| v.as_str())
18
18
  .ok_or_else(|| LifecycleError::StatePersist("session_name is required".to_string()))?;
19
- let safe = safe_snapshot_name(session_name);
20
19
  // golden restart/snapshot.py:46-47 — team_runtime_snapshot_dir = runtime_dir(workspace)/teams/<safe>,
21
20
  // and paths.py:25-26 runtime_dir = workspace/.team/runtime. Use the crate path helper so the snapshot
22
21
  // lands at <ws>/.team/runtime/teams/<safe>, matching golden AND the rest of the crate (not the
23
22
  // ".team"-less <ws>/runtime/teams that the original port wrote).
24
- let dir = crate::model::paths::runtime_dir(workspace).join("teams").join(safe);
23
+ let path = team_snapshot_path(workspace, session_name);
24
+ let dir = path
25
+ .parent()
26
+ .ok_or_else(|| LifecycleError::StatePersist("snapshot path has no parent".to_string()))?;
25
27
  fs::create_dir_all(&dir).map_err(|e| persist_err("create snapshot dir", &e))?;
26
- let path = dir.join("state.json");
27
28
  let tmp = dir.join("state.json.tmp");
28
29
  let data = serde_json::to_vec_pretty(state)
29
30
  .map_err(|e| LifecycleError::StatePersist(format!("serialize snapshot: {e}")))?;
@@ -32,6 +33,13 @@ pub fn save_team_runtime_snapshot(
32
33
  Ok(path)
33
34
  }
34
35
 
36
+ pub fn team_snapshot_path(workspace: &Path, session_name: &str) -> PathBuf {
37
+ crate::model::paths::runtime_dir(workspace)
38
+ .join("teams")
39
+ .join(safe_snapshot_name(session_name))
40
+ .join("state.json")
41
+ }
42
+
35
43
  fn persist_err(action: &str, err: &io::Error) -> LifecycleError {
36
44
  LifecycleError::StatePersist(format!("{action}: {err}"))
37
45
  }