@team-agent/installer 0.5.7 → 0.5.8

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
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.7"
578
+ version = "0.5.8"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.7"
12
+ version = "0.5.8"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -18,28 +18,34 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
18
18
  Ok(true) => {}
19
19
  Ok(false) => {
20
20
  issues.push(json!("tmux_session_missing"));
21
- repairs.push(json!({
22
- "issue": "tmux_session_missing",
23
- "action": format!("restart or relaunch tmux session `{session_name}`"),
24
- }));
21
+ repairs.push(recovery_hint(
22
+ session_name,
23
+ "tmux_session_missing",
24
+ "team-agent restart",
25
+ ));
25
26
  }
26
27
  Err(error) => {
27
28
  issues.push(json!("tmux_session_missing"));
28
- repairs.push(json!({
29
- "issue": "tmux_session_missing",
30
- "action": format!("restart or relaunch tmux session `{session_name}`"),
31
- "reason": error.to_string(),
32
- }));
29
+ let mut hint =
30
+ recovery_hint(session_name, "tmux_session_missing", "team-agent restart");
31
+ if let Some(obj) = hint.as_object_mut() {
32
+ obj.insert("reason".to_string(), Value::String(error.to_string()));
33
+ }
34
+ repairs.push(hint);
33
35
  }
34
36
  }
35
37
  }
36
38
 
37
39
  if !leader_receiver_attached(state) {
38
40
  issues.push(json!("leader_not_attached"));
39
- repairs.push(json!({
40
- "issue": "leader_not_attached",
41
- "action": "attach or claim a leader receiver before sending work",
42
- }));
41
+ repairs.push(recovery_hint(
42
+ state
43
+ .get("session_name")
44
+ .and_then(Value::as_str)
45
+ .unwrap_or("unknown"),
46
+ "leader_not_attached",
47
+ "team-agent attach-leader",
48
+ ));
43
49
  } else {
44
50
  // 0.4.x (CR R2 P0): leader provider health reconciliation. The
45
51
  // leader_receiver may be marked `attached` (pane addressable) but the
@@ -57,6 +63,11 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
57
63
  issues.push(json!("leader_provider_exited"));
58
64
  repairs.push(json!({
59
65
  "issue": "leader_provider_exited",
66
+ "action_required": true,
67
+ "advisory": true,
68
+ "broken_class": "leader_provider_exited",
69
+ "hint_action": "team-agent restart",
70
+ "dedupe_key": "leader_provider_exited",
60
71
  "action": format!(
61
72
  "leader pane fell back to shell — provider `{provider_label}` exited; \
62
73
  relaunch with `team-agent {provider_label}` to restart the provider"
@@ -65,10 +76,14 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
65
76
  }
66
77
  crate::leader::LeaderProviderHealth::Unreachable => {
67
78
  issues.push(json!("leader_provider_unreachable"));
68
- repairs.push(json!({
69
- "issue": "leader_provider_unreachable",
70
- "action": "leader pane is dead — relaunch the leader",
71
- }));
79
+ repairs.push(recovery_hint(
80
+ state
81
+ .get("session_name")
82
+ .and_then(Value::as_str)
83
+ .unwrap_or("unknown"),
84
+ "leader_provider_unreachable",
85
+ "team-agent claim-leader",
86
+ ));
72
87
  }
73
88
  crate::leader::LeaderProviderHealth::Alive => {}
74
89
  }
@@ -179,6 +194,20 @@ fn leader_receiver_attached(state: &Value) -> bool {
179
194
  mode_direct && status_attached && pane_present
180
195
  }
181
196
 
197
+ fn recovery_hint(team: &str, broken_class: &str, hint_action: &str) -> Value {
198
+ json!({
199
+ "issue": broken_class,
200
+ "action_required": true,
201
+ "advisory": true,
202
+ "broken_class": broken_class,
203
+ "hint_action": hint_action,
204
+ "dedupe_key": format!("{team}:{broken_class}"),
205
+ "action": format!(
206
+ "{hint_action} # alternatives: team-agent restart; team-agent claim-leader; team-agent quick-start; team-agent attach-leader"
207
+ ),
208
+ })
209
+ }
210
+
182
211
  #[cfg(test)]
183
212
  mod tests {
184
213
  use super::*;
@@ -531,6 +531,55 @@ fn resolve_bare_agent(
531
531
  }
532
532
  }
533
533
 
534
+ /// Phase-DX E3 (plan §4 / CR supplements A/B/C, P0 red lines #1 & #6):
535
+ /// build advisory diagnostic candidates for a failed `--to-name leader`
536
+ /// resolution. The candidate list is deliberately shaped to be **read by
537
+ /// humans**, not consumed as route authority: every entry carries
538
+ /// `advisory: true` and a `source` tag identifying where it was observed.
539
+ ///
540
+ /// Candidate source rules:
541
+ /// - The transport passed in is already scoped by [`transport_for_cli_target`]
542
+ /// to the team's recorded `leader_receiver.tmux_socket` (or the workspace
543
+ /// default when no socket was recorded), so `transport.list_targets()`
544
+ /// enumerates only that endpoint. We do NOT scan other tmux sockets or
545
+ /// fall back to reverse-authority tmux-session discovery.
546
+ /// - `pane_id` fields are copied verbatim from `list_targets`. `session` is
547
+ /// the tmux session, and `socket` records the tmux endpoint the candidate
548
+ /// was seen on (whichever the scoped transport reports).
549
+ ///
550
+ /// Callers must never treat any candidate as authoritative; MUST-NOT-12 stays
551
+ /// intact — resolver refusal is still the outcome even when candidates fire.
552
+ fn leader_advisory_candidates(
553
+ state: &Value,
554
+ team: &str,
555
+ transport: &dyn Transport,
556
+ source: &str,
557
+ ) -> Vec<Value> {
558
+ let socket = team_entry(state, team)
559
+ .and_then(|entry| entry.get("leader_receiver"))
560
+ .or_else(|| state.get("leader_receiver"))
561
+ .and_then(|receiver| string_field(receiver, "tmux_socket"))
562
+ .map(str::to_string)
563
+ .or_else(|| transport.tmux_endpoint());
564
+ let targets = match transport.list_targets() {
565
+ Ok(targets) => targets,
566
+ Err(_) => return Vec::new(),
567
+ };
568
+ targets
569
+ .into_iter()
570
+ .map(|pane| {
571
+ json!({
572
+ "pane_id": pane.pane_id.as_str(),
573
+ "session": pane.session.as_str(),
574
+ "window": pane.window_name.as_ref().map(|w| w.as_str()),
575
+ "socket": socket,
576
+ "source": source,
577
+ "advisory": json!(true),
578
+ })
579
+ })
580
+ .collect()
581
+ }
582
+
534
583
  fn resolve_leader(
535
584
  sender_workspace: &Path,
536
585
  target_workspace: &Path,
@@ -544,7 +593,12 @@ fn resolve_leader(
544
593
  } else {
545
594
  state.get("leader_receiver")
546
595
  }
547
- .ok_or_else(|| name_not_live_leader(team, None, None, None, transport.tmux_endpoint()))?;
596
+ .ok_or_else(|| {
597
+ let mut err = name_not_live_leader(team, None, None, None, transport.tmux_endpoint());
598
+ err.candidates =
599
+ leader_advisory_candidates(state, team, transport, "state_recorded_socket");
600
+ err
601
+ })?;
548
602
  if let Some((mode, transport_kind)) = receiver_transport_conflict(receiver) {
549
603
  let mut err = NamedAddressError::new(
550
604
  NamedAddressErrorKind::NameNotLive,
@@ -569,7 +623,13 @@ fn resolve_leader(
569
623
  }
570
624
  let pane_id = string_field(receiver, "pane_id")
571
625
  .filter(|pane| !pane.is_empty())
572
- .ok_or_else(|| name_not_live_leader(team, None, None, None, transport.tmux_endpoint()))?;
626
+ .ok_or_else(|| {
627
+ let mut err =
628
+ name_not_live_leader(team, None, None, None, transport.tmux_endpoint());
629
+ err.candidates =
630
+ leader_advisory_candidates(state, team, transport, "state_recorded_socket");
631
+ err
632
+ })?;
573
633
  let session = string_field(receiver, "session_name");
574
634
  let window = string_field(receiver, "window_name");
575
635
  let socket = string_field(receiver, "tmux_socket")
@@ -599,13 +659,13 @@ fn resolve_leader(
599
659
  agent_status: None,
600
660
  warning: None,
601
661
  }),
602
- 0 => Err(name_not_live_leader(
603
- team,
604
- Some(pane_id),
605
- session,
606
- window,
607
- socket,
608
- )),
662
+ 0 => {
663
+ let mut err =
664
+ name_not_live_leader(team, Some(pane_id), session, window, socket);
665
+ err.candidates =
666
+ leader_advisory_candidates(state, team, transport, "state_recorded_socket");
667
+ Err(err)
668
+ }
609
669
  _ => Err(name_ambiguous(
610
670
  "multiple live panes matched leader_receiver pane id",
611
671
  matches
@@ -38,7 +38,9 @@ use rusqlite::params;
38
38
  coordinator_running,
39
39
  undelivered_backlog,
40
40
  );
41
- let agents = enrich_agents(state.get("agents"));
41
+ let session_name = state.get("session_name").cloned().unwrap_or(Value::Null);
42
+ let tmux_present = tmux_session_present(workspace, state, session_name.as_str());
43
+ let agents = enrich_agents(state.get("agents"), tmux_present);
42
44
  let tasks = state
43
45
  .get("tasks")
44
46
  .cloned()
@@ -47,7 +49,6 @@ use rusqlite::params;
47
49
  .get("leader_receiver")
48
50
  .cloned()
49
51
  .unwrap_or_else(|| json!({}));
50
- let session_name = state.get("session_name").cloned().unwrap_or(Value::Null);
51
52
  let is_external_leader = crate::state::projection::state_is_external_leader(state);
52
53
  let leader_topology = if is_external_leader { "external" } else { "managed" };
53
54
  let leader_attach_command = if is_external_leader {
@@ -70,7 +71,6 @@ use rusqlite::params;
70
71
  )
71
72
  })
72
73
  };
73
- let tmux_present = tmux_session_present(workspace, state, session_name.as_str());
74
74
  let mut readiness_state = state.clone();
75
75
  if let Some(obj) = readiness_state.as_object_mut() {
76
76
  obj.insert("tmux_session_present".to_string(), serde_json::json!(tmux_present));
@@ -437,7 +437,7 @@ use rusqlite::params;
437
437
  .to_string()
438
438
  }
439
439
 
440
- fn enrich_agents(agents: Option<&Value>) -> Value {
440
+ fn enrich_agents(agents: Option<&Value>, tmux_session_present: bool) -> Value {
441
441
  let Some(Value::Object(input)) = agents else {
442
442
  return json!({});
443
443
  };
@@ -450,6 +450,15 @@ use rusqlite::params;
450
450
  "interacted".to_string(),
451
451
  Value::String(interacted_marker(obj.get("first_send_at"))),
452
452
  );
453
+ if let Some(reason) =
454
+ stale_reason_for_agent(&Value::Object(obj.clone()), tmux_session_present)
455
+ {
456
+ enriched.insert("stale".to_string(), Value::Bool(true));
457
+ enriched.insert(
458
+ "stale_reason".to_string(),
459
+ Value::String(reason.to_string()),
460
+ );
461
+ }
453
462
  out.insert(agent_id.clone(), Value::Object(enriched));
454
463
  }
455
464
  _ => {
@@ -460,6 +469,53 @@ use rusqlite::params;
460
469
  Value::Object(out)
461
470
  }
462
471
 
472
+ fn stale_reason_for_agent(agent: &Value, tmux_session_present: bool) -> Option<&'static str> {
473
+ let pane_dead = !tmux_session_present && agent_has_pane_fact(agent);
474
+ let process_dead =
475
+ agent_process_dead(agent) || (!tmux_session_present && agent_has_process_fact(agent));
476
+ match (pane_dead, process_dead) {
477
+ (true, true) => Some("both"),
478
+ (false, true) => Some("process_dead"),
479
+ (true, false) => Some("pane_dead"),
480
+ (false, false) => None,
481
+ }
482
+ }
483
+
484
+ fn agent_has_pane_fact(agent: &Value) -> bool {
485
+ ["pane_id", "window", "window_name"].iter().any(|key| {
486
+ agent
487
+ .get(*key)
488
+ .and_then(Value::as_str)
489
+ .is_some_and(|value| !value.is_empty())
490
+ })
491
+ }
492
+
493
+ fn agent_has_process_fact(agent: &Value) -> bool {
494
+ agent.get("pid").and_then(Value::as_i64).is_some()
495
+ || agent.get("process_started").and_then(Value::as_bool) == Some(true)
496
+ || agent.get("provider_process_dead").and_then(Value::as_bool).is_some()
497
+ || agent.get("process_liveness").and_then(Value::as_str).is_some()
498
+ }
499
+
500
+ fn agent_process_dead(agent: &Value) -> bool {
501
+ if agent.get("provider_process_dead").and_then(Value::as_bool) == Some(true) {
502
+ return true;
503
+ }
504
+ ["process_liveness", "worker_state"].iter().any(|key| {
505
+ agent
506
+ .get(*key)
507
+ .and_then(Value::as_str)
508
+ .is_some_and(is_dead_process_state)
509
+ })
510
+ }
511
+
512
+ fn is_dead_process_state(value: &str) -> bool {
513
+ matches!(
514
+ value,
515
+ "dead" | "missing" | "stopped" | "exited" | "terminated"
516
+ )
517
+ }
518
+
463
519
  fn interacted_marker(value: Option<&Value>) -> String {
464
520
  let Some(raw) = value.and_then(Value::as_str) else {
465
521
  return "never".to_string();
@@ -846,7 +902,15 @@ use rusqlite::params;
846
902
  // 0.4.x Phase 1: add `worker_state` (canonical 5-state product
847
903
  // surface). `activity` is preserved alongside as the deprecated
848
904
  // legacy classifier output (CR R3 same-source contract).
849
- for key in ["status", "provider", "worker_state", "activity", "last_output_at"] {
905
+ for key in [
906
+ "status",
907
+ "provider",
908
+ "worker_state",
909
+ "activity",
910
+ "last_output_at",
911
+ "stale",
912
+ "stale_reason",
913
+ ] {
850
914
  if let Some(value) = input.get(key) {
851
915
  out.insert(key.to_string(), value.clone());
852
916
  }
@@ -964,10 +1028,35 @@ use rusqlite::params;
964
1028
  );
965
1029
  insert_optional_string(&mut item, "last_output_at", row.get(2).map_err(|e| CliError::Runtime(e.to_string()))?);
966
1030
  insert_optional_i64(&mut item, "context_usage_pct", row.get(3).map_err(|e| CliError::Runtime(e.to_string()))?);
967
- insert_optional_string(&mut item, "current_task_id", row.get(4).map_err(|e| CliError::Runtime(e.to_string()))?);
1031
+ let current_task_id: Option<String> =
1032
+ row.get(4).map_err(|e| CliError::Runtime(e.to_string()))?;
1033
+ let has_current_task = current_task_id.is_some();
1034
+ insert_optional_string(&mut item, "current_task_id", current_task_id);
1035
+ let updated_at: String =
1036
+ row.get(5).map_err(|e| CliError::Runtime(e.to_string()))?;
1037
+ // Phase-DX E2 (plan §4 / CR supplement A): expose the last agent_health
1038
+ // observation timestamp as `health_updated_at` alongside the legacy
1039
+ // `updated_at` alias. Two names for one column keep old scrapers working
1040
+ // while surfacing the semantic (heartbeat, not row bookkeeping).
1041
+ item.insert("updated_at".to_string(), json!(updated_at.clone()));
1042
+ item.insert("health_updated_at".to_string(), json!(updated_at));
1043
+ // Phase-DX E2 (CR P0 red line #6, supplements A/B): current_task is a
1044
+ // best-effort *display* field until A1 makes task FSM authoritative.
1045
+ // The structured source/confidence markers stop downstream code from
1046
+ // treating agent_health.current_task_id as authority. `current_task_source`
1047
+ // records where the display value came from (only "health" today —
1048
+ // Phase-DX never merges state tasks into this projection); the
1049
+ // `current_task_confidence` enum stays "best_effort" for the whole
1050
+ // Phase-DX slice (A1 will later flip it to "authoritative" when the
1051
+ // task FSM lands). Field is written unconditionally so consumers can
1052
+ // switch on it even when `current_task_id` is null.
1053
+ item.insert(
1054
+ "current_task_source".to_string(),
1055
+ json!(if has_current_task { "health" } else { "none" }),
1056
+ );
968
1057
  item.insert(
969
- "updated_at".to_string(),
970
- json!(row.get::<_, String>(5).map_err(|e| CliError::Runtime(e.to_string()))?),
1058
+ "current_task_confidence".to_string(),
1059
+ json!("best_effort"),
971
1060
  );
972
1061
  insert_optional_string(&mut item, "owner_team_id", row.get(6).map_err(|e| CliError::Runtime(e.to_string()))?);
973
1062
  out.insert(agent_id, Value::Object(item));
@@ -2366,8 +2366,14 @@ fn write_agent_health(
2366
2366
  .get("context_usage_pct")
2367
2367
  .or_else(|| agent.get("context_usage_percent"))
2368
2368
  .and_then(Value::as_i64);
2369
+ // Phase-DX E2: read the renamed `current_turn_message_id` (leader→worker turn
2370
+ // proxy, written by delivery::arm_turn_open) with fallbacks to the legacy field
2371
+ // names for backwards state compatibility. The SQL column stays `current_task_id`
2372
+ // (agent_health schema) — a rename would require a DB migration, which Phase-DX
2373
+ // forbids.
2369
2374
  let current_task_id = agent
2370
- .get("current_task_id")
2375
+ .get("current_turn_message_id")
2376
+ .or_else(|| agent.get("current_task_id"))
2371
2377
  .or_else(|| agent.get("task_id"))
2372
2378
  .and_then(Value::as_str);
2373
2379
  conn.execute(
@@ -0,0 +1,96 @@
1
+ //! Phase-DX E2: `agent_health` row capture/restore for the remove-agent flow.
2
+ //!
3
+ //! Extracted from `lifecycle/restart/remove.rs` so the SQL column reference to
4
+ //! `current_task_id` sits in the persistence layer (whitelisted by the E2 grep
5
+ //! guard) rather than in lifecycle policy code. Semantics are the golden Python
6
+ //! `_capture_agent_health` / `_restore_agent_health` — a plain
7
+ //! backup-across-delete that never treats the stored column as authoritative
8
+ //! task state.
9
+
10
+ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
11
+
12
+ use std::path::Path;
13
+
14
+ use crate::model::ids::AgentId;
15
+
16
+ #[derive(Clone, Debug)]
17
+ pub struct CapturedHealth {
18
+ pub owner_team_id: Option<String>,
19
+ pub status: Option<String>,
20
+ pub last_output_at: Option<String>,
21
+ pub context_usage_pct: Option<i64>,
22
+ pub current_task_id: Option<String>,
23
+ }
24
+
25
+ /// golden agents.py:185 `copy.deepcopy(store.agent_health().get(agent_id))` — read the row BEFORE
26
+ /// delete so the rollback can re-upsert it. Returns the captured columns, or `None` if the row is
27
+ /// absent.
28
+ pub fn select_agent_health(
29
+ workspace: &Path,
30
+ agent_id: &AgentId,
31
+ ) -> Result<Option<CapturedHealth>, crate::db::DbError> {
32
+ let store = crate::message_store::MessageStore::open(workspace)
33
+ .map_err(|e| crate::db::DbError::Schema(e.to_string()))?;
34
+ let conn = crate::db::schema::open_db(store.db_path())?;
35
+ let row = conn
36
+ .query_row(
37
+ "select owner_team_id, status, last_output_at, context_usage_pct, current_task_id \
38
+ from agent_health where agent_id = ?1",
39
+ [agent_id.as_str()],
40
+ |r| {
41
+ Ok(CapturedHealth {
42
+ owner_team_id: r.get::<_, Option<String>>(0)?,
43
+ status: r.get::<_, Option<String>>(1)?,
44
+ last_output_at: r.get::<_, Option<String>>(2)?,
45
+ context_usage_pct: r.get::<_, Option<i64>>(3)?,
46
+ current_task_id: r.get::<_, Option<String>>(4)?,
47
+ })
48
+ },
49
+ )
50
+ .ok();
51
+ Ok(row)
52
+ }
53
+
54
+ /// golden agents.py:268-278 `_restore_agent_health`: re-upsert the captured row (status||"IDLE"),
55
+ /// or delete the row when there was nothing to restore.
56
+ pub fn restore_agent_health(
57
+ workspace: &Path,
58
+ agent_id: &AgentId,
59
+ row: &Option<CapturedHealth>,
60
+ ) -> Result<(), crate::db::DbError> {
61
+ let Some(row) = row else {
62
+ return delete_agent_health(workspace, agent_id);
63
+ };
64
+ let store = crate::message_store::MessageStore::open(workspace)
65
+ .map_err(|e| crate::db::DbError::Schema(e.to_string()))?;
66
+ let conn = crate::db::schema::open_db(store.db_path())?;
67
+ let status = row.status.clone().unwrap_or_else(|| "IDLE".to_string());
68
+ let now = chrono::Utc::now()
69
+ .format("%Y-%m-%dT%H:%M:%S%.6f+00:00")
70
+ .to_string();
71
+ conn.execute(
72
+ "insert into agent_health (owner_team_id, agent_id, status, last_output_at, context_usage_pct, current_task_id, updated_at) \
73
+ values (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
74
+ rusqlite::params![
75
+ row.owner_team_id,
76
+ agent_id.as_str(),
77
+ status,
78
+ row.last_output_at,
79
+ row.context_usage_pct,
80
+ row.current_task_id,
81
+ now,
82
+ ],
83
+ )?;
84
+ Ok(())
85
+ }
86
+
87
+ fn delete_agent_health(workspace: &Path, agent_id: &AgentId) -> Result<(), crate::db::DbError> {
88
+ let store = crate::message_store::MessageStore::open(workspace)
89
+ .map_err(|e| crate::db::DbError::Schema(e.to_string()))?;
90
+ let conn = crate::db::schema::open_db(store.db_path())?;
91
+ conn.execute(
92
+ "delete from agent_health where agent_id = ?1",
93
+ [agent_id.as_str()],
94
+ )?;
95
+ Ok(())
96
+ }
@@ -9,6 +9,7 @@
9
9
  //! §10:db 层无 unwrap/expect/panic;rusqlite 错误经 [`DbError`] 传播。
10
10
  #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
11
11
 
12
+ pub mod agent_health_capture;
12
13
  pub mod message_store;
13
14
  pub mod migration;
14
15
  pub mod schema;
@@ -670,78 +670,30 @@ fn delete_agent_health(workspace: &Path, agent_id: &AgentId) -> Result<bool, Lif
670
670
  Ok(changed > 0)
671
671
  }
672
672
 
673
- /// golden agents.py:185 `copy.deepcopy(store.agent_health().get(agent_id))` read the row BEFORE delete
674
- /// so the rollback can re-upsert it. Returns the captured health columns, or None if absent.
673
+ // Phase-DX E2: `select_agent_health` / `restore_agent_health` / `CapturedHealth` moved to
674
+ // `db::agent_health_capture` so the SQL column references (agent_health backup columns)
675
+ // live in the persistence layer (whitelisted by the E2 grep guard) rather than lifecycle
676
+ // policy code. The wrappers below preserve the existing `LifecycleError` surface.
677
+ use crate::db::agent_health_capture::{
678
+ restore_agent_health as capture_restore_agent_health,
679
+ select_agent_health as capture_select_agent_health, CapturedHealth,
680
+ };
681
+
675
682
  fn select_agent_health(
676
683
  workspace: &Path,
677
684
  agent_id: &AgentId,
678
685
  ) -> Result<Option<CapturedHealth>, LifecycleError> {
679
- let store = crate::message_store::MessageStore::open(workspace)
680
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
681
- let conn = crate::db::schema::open_db(store.db_path())
682
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
683
- let row = conn
684
- .query_row(
685
- "select owner_team_id, status, last_output_at, context_usage_pct, current_task_id \
686
- from agent_health where agent_id = ?1",
687
- [agent_id.as_str()],
688
- |r| {
689
- Ok(CapturedHealth {
690
- owner_team_id: r.get::<_, Option<String>>(0)?,
691
- status: r.get::<_, Option<String>>(1)?,
692
- last_output_at: r.get::<_, Option<String>>(2)?,
693
- context_usage_pct: r.get::<_, Option<i64>>(3)?,
694
- current_task_id: r.get::<_, Option<String>>(4)?,
695
- })
696
- },
697
- )
698
- .ok();
699
- Ok(row)
686
+ capture_select_agent_health(workspace, agent_id)
687
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))
700
688
  }
701
689
 
702
- /// golden agents.py:268-278 `_restore_agent_health`: re-upsert the captured row (status||"IDLE"), or
703
- /// delete the row when there was nothing to restore.
704
690
  fn restore_agent_health(
705
691
  workspace: &Path,
706
692
  agent_id: &AgentId,
707
693
  row: &Option<CapturedHealth>,
708
694
  ) -> Result<(), LifecycleError> {
709
- let Some(row) = row else {
710
- delete_agent_health(workspace, agent_id)?;
711
- return Ok(());
712
- };
713
- let store = crate::message_store::MessageStore::open(workspace)
714
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
715
- let conn = crate::db::schema::open_db(store.db_path())
716
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
717
- let status = row.status.clone().unwrap_or_else(|| "IDLE".to_string());
718
- let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6f+00:00").to_string();
719
- // The restore always follows a delete of this row, so a plain insert re-materializes the captured
720
- // health (golden _restore_agent_health re-upserts status||"IDLE" + the captured columns).
721
- conn.execute(
722
- "insert into agent_health (owner_team_id, agent_id, status, last_output_at, context_usage_pct, current_task_id, updated_at) \
723
- values (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
724
- rusqlite::params![
725
- row.owner_team_id,
726
- agent_id.as_str(),
727
- status,
728
- row.last_output_at,
729
- row.context_usage_pct,
730
- row.current_task_id,
731
- now,
732
- ],
733
- )
734
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
735
- Ok(())
736
- }
737
-
738
- #[derive(Clone)]
739
- struct CapturedHealth {
740
- owner_team_id: Option<String>,
741
- status: Option<String>,
742
- last_output_at: Option<String>,
743
- context_usage_pct: Option<i64>,
744
- current_task_id: Option<String>,
695
+ capture_restore_agent_health(workspace, agent_id, row)
696
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))
745
697
  }
746
698
 
747
699
  fn maybe_fail_remove_after_agent_health_delete() -> Result<(), LifecycleError> {
@@ -336,10 +336,27 @@ fn current_turn_id_from_state(state: &Value, agent_id: &str) -> Option<String> {
336
336
  }
337
337
  }
338
338
  }
339
+ // Phase-DX E2: read the renamed `current_turn_message_id` (leader→worker turn
340
+ // proxy from `delivery::arm_turn_open`). Legacy state written by 0.5.x may
341
+ // still carry the pre-rename JSON key; the fallback keeps current-turn
342
+ // attribution stable during the transition. Neither field is treated as
343
+ // authoritative task state — that stays A1 territory (task FSM).
344
+ //
345
+ // The next line reads the pre-rename JSON key verbatim; that is deliberate
346
+ // and marked so the E2 grep guard admits it as a documented exception. The
347
+ // marker distinguishes a legitimate read-only backwards-compat bridge from
348
+ // an authority-consuming read (which the guard still forbids). Delete this
349
+ // fallback and its marker when the A1 task FSM lands (task attribution
350
+ // becomes authoritative and legacy state layouts are no longer produced).
351
+ let legacy_field = "current_task_id"; // ALLOWED-LEGACY-READ: backward-compat bridge for pre-rename key
339
352
  state
340
353
  .get("agents")
341
354
  .and_then(|agents| agents.get(agent_id))
342
- .and_then(|agent| agent.get("current_task_id"))
355
+ .and_then(|agent| {
356
+ agent
357
+ .get("current_turn_message_id")
358
+ .or_else(|| agent.get(legacy_field))
359
+ })
343
360
  .and_then(Value::as_str)
344
361
  .and_then(non_empty_string)
345
362
  .map(ToString::to_string)
@@ -112,29 +112,43 @@ fn send_outcome_direct_renders_compact_body() {
112
112
  // ════════════════════════════════════════════════════════════════════════
113
113
  // CONTROL-PLANE: send_message worker recipient → WorkerAccepted (tools.py:135-183)
114
114
  // ════════════════════════════════════════════════════════════════════════
115
- #[test]
116
- fn send_message_worker_recipient_returns_accepted_with_poll_hint() {
117
- // A worker recipient w/ a delivered message_id → async accepted carrying the
118
- // byte-stable poll hint. Identity anchored on injected env (no candidate scan).
119
- // golden: a leader WITH owner_team_id on an unseeded ws would hit the C23 cross-team
120
- // refusal first (worker-1 not in visible peers) -> PeerNotInScope. owner_team_id=None
121
- // (legacy single-team) bypasses that, isolating the worker-recipient accepted path.
122
- // The cross-team refusal has its own tests (refuse_cross_team_peer_* / send_message_cross_team_*).
123
- // A-7: the accepted path needs a REAL stored message_id (tools.py:175-181 —
124
- // fabricated ids are gone), so the workspace seeds a running worker-1 the
125
- // delivery layer can actually queue for.
126
- let ws = unique_ws("send-worker");
115
+ fn seed_current_worker_state(tag: &str) -> std::path::PathBuf {
116
+ let ws = unique_ws(tag);
127
117
  crate::state::persist::save_runtime_state(
128
118
  &ws,
129
119
  &serde_json::json!({
130
120
  "session_name": "team-x",
121
+ "active_team_key": "current",
131
122
  "agents": {
132
123
  "worker-1": {"status": "running", "agent_id": "worker-1", "window": "worker-1"},
133
124
  },
125
+ "teams": {
126
+ "current": {
127
+ "status": "alive",
128
+ "agents": {
129
+ "worker-1": {"status": "running", "agent_id": "worker-1", "window": "worker-1"},
130
+ },
131
+ },
132
+ },
134
133
  }),
135
134
  )
136
135
  .unwrap();
137
- let tools = TeamOrchestratorTools::with_identity(&ws, Some(AgentId::new("leader")), None);
136
+ ws
137
+ }
138
+
139
+ #[test]
140
+ fn send_message_worker_recipient_returns_accepted_with_poll_hint() {
141
+ // A worker recipient w/ a delivered message_id → async accepted carrying the
142
+ // byte-stable poll hint. Identity anchored on injected env (no candidate scan).
143
+ // A-7: the accepted path needs a REAL stored message_id (tools.py:175-181 —
144
+ // fabricated ids are gone), so the workspace seeds a running worker-1 the
145
+ // delivery layer can actually queue for.
146
+ let ws = seed_current_worker_state("send-worker");
147
+ let tools = TeamOrchestratorTools::with_identity(
148
+ &ws,
149
+ Some(AgentId::new("leader")),
150
+ Some(TeamKey::new("current")),
151
+ );
138
152
  let outcome = tools.send_message(
139
153
  &MessageTarget::Single("worker-1".to_string()),
140
154
  "do the thing",
@@ -155,6 +169,104 @@ fn send_message_worker_recipient_returns_accepted_with_poll_hint() {
155
169
  }
156
170
  }
157
171
 
172
+ #[test]
173
+ fn ordinary_send_assign_shape_has_no_recovery_marker() {
174
+ let ws = seed_current_worker_state("ordinary-no-recovery");
175
+ let tools = TeamOrchestratorTools::with_identity(
176
+ &ws,
177
+ Some(AgentId::new("leader")),
178
+ Some(TeamKey::new("current")),
179
+ );
180
+
181
+ let sent = tools
182
+ .send_message(
183
+ &MessageTarget::Single("worker-1".to_string()),
184
+ "ordinary send",
185
+ None,
186
+ None,
187
+ None,
188
+ None,
189
+ )
190
+ .expect("ordinary send ok")
191
+ .to_value();
192
+ assert!(
193
+ !sent.as_object().unwrap().contains_key("recovery"),
194
+ "ordinary send must not carry recovery marker: {sent}"
195
+ );
196
+ assert!(
197
+ sent.get("acceptance_marker").is_none(),
198
+ "ordinary send must not carry acceptance marker: {sent}"
199
+ );
200
+
201
+ let assigned = tools
202
+ .assign_task(
203
+ &json!({"id": "ordinary-task", "assignee": "worker-1", "title": "ordinary"}),
204
+ None,
205
+ )
206
+ .expect("ordinary assign ok");
207
+ let assigned_value = serde_json::to_value(&assigned).expect("serialize ordinary assign");
208
+ assert!(
209
+ !assigned_value.as_object().unwrap().contains_key("recovery"),
210
+ "ordinary assign must not carry recovery marker: {assigned_value}"
211
+ );
212
+ assert!(
213
+ assigned_value.get("acceptance_marker").is_none(),
214
+ "ordinary assign must not carry acceptance marker: {assigned_value}"
215
+ );
216
+
217
+ let recovery_false_assigned = tools
218
+ .assign_task(
219
+ &json!({
220
+ "id": "ordinary-recovery-false-task",
221
+ "assignee": "worker-1",
222
+ "title": "ordinary recovery false",
223
+ "recovery": false,
224
+ }),
225
+ None,
226
+ )
227
+ .expect("recovery=false assign ok");
228
+ let recovery_false_value =
229
+ serde_json::to_value(&recovery_false_assigned).expect("serialize recovery=false assign");
230
+ assert!(
231
+ !recovery_false_value
232
+ .as_object()
233
+ .unwrap()
234
+ .contains_key("recovery"),
235
+ "recovery=false assign must not carry recovery marker: {recovery_false_value}"
236
+ );
237
+ assert!(
238
+ recovery_false_value.get("acceptance_marker").is_none(),
239
+ "recovery=false assign must not carry acceptance marker: {recovery_false_value}"
240
+ );
241
+ }
242
+
243
+ #[test]
244
+ fn recovery_assign_shape_has_structured_marker() {
245
+ let ws = seed_current_worker_state("recovery-marker");
246
+ let tools = TeamOrchestratorTools::with_identity(
247
+ &ws,
248
+ Some(AgentId::new("leader")),
249
+ Some(TeamKey::new("current")),
250
+ );
251
+
252
+ let assigned = tools
253
+ .assign_task(
254
+ &json!({
255
+ "id": "recovery-task",
256
+ "assignee": "worker-1",
257
+ "title": "recover",
258
+ "recovery": true,
259
+ }),
260
+ None,
261
+ )
262
+ .expect("recovery assign ok");
263
+ assert_eq!(assigned.fields.get("recovery"), Some(&json!(true)));
264
+ assert_eq!(
265
+ assigned.fields.get("acceptance_marker"),
266
+ Some(&json!("recovery"))
267
+ );
268
+ }
269
+
158
270
  #[test]
159
271
  fn send_message_worker_recipient_surfaces_dead_coordinator_warning() {
160
272
  let ws = unique_ws("send-worker-dead-coord");
@@ -116,6 +116,7 @@ impl TeamOrchestratorTools {
116
116
  };
117
117
 
118
118
  let task_value = Value::Object(task_obj.clone());
119
+ let recovery = task_recovery_marker(&task_value);
119
120
  let mut state = load_runtime_state(&self.workspace).map_err(tool_runtime_error)?;
120
121
  ensure_object(&mut state);
121
122
  let team_key = self
@@ -139,7 +140,16 @@ impl TeamOrchestratorTools {
139
140
  None,
140
141
  None,
141
142
  )?;
142
- compact_tool_result(&out.to_value())
143
+ let mut ok = compact_tool_result(&out.to_value())?;
144
+ if recovery {
145
+ ok.fields
146
+ .insert("recovery".to_string(), serde_json::json!(true));
147
+ ok.fields.insert(
148
+ "acceptance_marker".to_string(),
149
+ Value::String("recovery".to_string()),
150
+ );
151
+ }
152
+ Ok(ok)
143
153
  }
144
154
 
145
155
  /// `send_message` (`tools.py:135-183`): C14/C15/C17 scope resolution.
@@ -989,6 +999,11 @@ fn assignment_message(task: &Value, explicit: Option<&str>) -> String {
989
999
  json_dumps_default(task)
990
1000
  }
991
1001
 
1002
+ fn task_recovery_marker(task: &Value) -> bool {
1003
+ task.get("recovery").and_then(Value::as_bool) == Some(true)
1004
+ || task.get("acceptance_marker").and_then(Value::as_str) == Some("recovery")
1005
+ }
1006
+
992
1007
  fn scope_override_name(scope: Scope) -> Option<&'static str> {
993
1008
  match scope {
994
1009
  Scope::Team => Some("team"),
@@ -1981,12 +1981,19 @@ fn arm_turn_open(state: &mut serde_json::Value, recipient: &str, message_id: &Op
1981
1981
  .and_then(|agents| agents.get_mut(recipient))
1982
1982
  .and_then(serde_json::Value::as_object_mut)
1983
1983
  {
1984
- agent.insert(
1985
- "current_task_id".to_string(),
1986
- message_id.as_ref().map_or(serde_json::Value::Null, |id| {
1984
+ // Phase-DX E2 (CR P0 red line #5): the leader→worker turn message id.
1985
+ // Renamed from a misleading legacy field name (which was never a task
1986
+ // FSM id — task FSM is A1) to `current_turn_message_id`, matching its
1987
+ // actual semantic. Reader compatibility lives in `coordinator/tick.rs`
1988
+ // (whitelisted for the transition) and `mcp_server/helpers.rs`; other
1989
+ // messaging/lifecycle/mcp_server code MUST NOT treat this as
1990
+ // authoritative task state.
1991
+ let field = message_id
1992
+ .as_ref()
1993
+ .map_or(serde_json::Value::Null, |id| {
1987
1994
  serde_json::Value::String(id.clone())
1988
- }),
1989
- );
1995
+ });
1996
+ agent.insert("current_turn_message_id".to_string(), field);
1990
1997
  }
1991
1998
  }
1992
1999
 
@@ -1581,10 +1581,15 @@ fn deliver_pending_submit_unverified_is_not_saved_by_readback() {
1581
1581
  Some(&serde_json::json!(message_id)),
1582
1582
  "current-turn fact is armed immediately after physical inject succeeds"
1583
1583
  );
1584
+ // Phase-DX E2 rename: the leader→worker turn message id lives under
1585
+ // `current_turn_message_id` (the previous name misleadingly implied a task
1586
+ // FSM id — that is A1 territory). Same value written by
1587
+ // `delivery::arm_turn_open`; the invariant of arming-without-delivered is
1588
+ // unchanged.
1584
1589
  assert_eq!(
1585
- updated.pointer("/agents/w1/current_task_id"),
1590
+ updated.pointer("/agents/w1/current_turn_message_id"),
1586
1591
  Some(&serde_json::json!(message_id)),
1587
- "agent current_task_id is armed without marking the message delivered"
1592
+ "agent current-turn message id is armed without marking the message delivered"
1588
1593
  );
1589
1594
  }
1590
1595
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.7",
3
+ "version": "0.5.8",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.7",
24
- "@team-agent/cli-darwin-x64": "0.5.7",
25
- "@team-agent/cli-linux-x64": "0.5.7"
23
+ "@team-agent/cli-darwin-arm64": "0.5.8",
24
+ "@team-agent/cli-darwin-x64": "0.5.8",
25
+ "@team-agent/cli-linux-x64": "0.5.8"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",