@team-agent/installer 0.5.7 → 0.5.9

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 (32) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/attach_app_server_leader.rs +82 -1
  4. package/crates/team-agent/src/cli/diagnose.rs +46 -17
  5. package/crates/team-agent/src/cli/emit.rs +30 -1
  6. package/crates/team-agent/src/cli/leaders.rs +94 -0
  7. package/crates/team-agent/src/cli/mod.rs +138 -2
  8. package/crates/team-agent/src/cli/named_address.rs +288 -28
  9. package/crates/team-agent/src/cli/send.rs +230 -0
  10. package/crates/team-agent/src/cli/status_port.rs +117 -14
  11. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  12. package/crates/team-agent/src/cli/tests/named_address.rs +9 -3
  13. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  14. package/crates/team-agent/src/cli/types.rs +14 -0
  15. package/crates/team-agent/src/coordinator/tick.rs +7 -1
  16. package/crates/team-agent/src/db/agent_health_capture.rs +96 -0
  17. package/crates/team-agent/src/db/mod.rs +1 -0
  18. package/crates/team-agent/src/leader/mod.rs +1 -0
  19. package/crates/team-agent/src/leader/registry.rs +402 -0
  20. package/crates/team-agent/src/lifecycle/restart/remove.rs +13 -61
  21. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
  22. package/crates/team-agent/src/mcp_server/helpers.rs +18 -1
  23. package/crates/team-agent/src/mcp_server/normalize.rs +79 -0
  24. package/crates/team-agent/src/mcp_server/tests/send.rs +125 -13
  25. package/crates/team-agent/src/mcp_server/tools.rs +24 -2
  26. package/crates/team-agent/src/messaging/delivery.rs +12 -5
  27. package/crates/team-agent/src/messaging/leader_receiver.rs +58 -0
  28. package/crates/team-agent/src/messaging/mod.rs +2 -1
  29. package/crates/team-agent/src/messaging/results.rs +53 -0
  30. package/crates/team-agent/src/messaging/tests/runtime.rs +7 -2
  31. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  32. package/package.json +4 -4
@@ -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();
@@ -654,14 +710,20 @@ use rusqlite::params;
654
710
  limit: usize,
655
711
  ) -> Result<Value, CliError> {
656
712
  let limit = i64::try_from(limit).unwrap_or(i64::MAX);
713
+ // E6 (0.5.9 offline-mailbox §6.6): also surface `queued_until_leader_attach`
714
+ // rows (third-party sends into the leader mailbox) so the target owner can
715
+ // see them alongside the rebind-required failures. Channel wire label
716
+ // distinguishes the two so the operator can tell the two shapes apart.
657
717
  let sql = match owner_team_id {
658
718
  Some(_) => {
659
719
  "select message_id, sender, status, error, created_at, delivery_attempts
660
720
  from messages
661
721
  where recipient = 'leader'
662
- and status = 'failed'
663
- and error = 'leader_not_attached'
664
722
  and owner_team_id = ?1
723
+ and (
724
+ (status = 'failed' and error = 'leader_not_attached')
725
+ or status = 'queued_until_leader_attach'
726
+ )
665
727
  order by created_at desc
666
728
  limit ?2"
667
729
  }
@@ -669,8 +731,10 @@ use rusqlite::params;
669
731
  "select message_id, sender, status, error, created_at, delivery_attempts
670
732
  from messages
671
733
  where recipient = 'leader'
672
- and status = 'failed'
673
- and error = 'leader_not_attached'
734
+ and (
735
+ (status = 'failed' and error = 'leader_not_attached')
736
+ or status = 'queued_until_leader_attach'
737
+ )
674
738
  order by created_at desc
675
739
  limit ?1"
676
740
  }
@@ -679,14 +743,20 @@ use rusqlite::params;
679
743
  .prepare(sql)
680
744
  .map_err(|e| CliError::Runtime(e.to_string()))?;
681
745
  let map_row = |row: &rusqlite::Row<'_>| {
746
+ let status: String = row.get(2)?;
747
+ let channel = if status == "queued_until_leader_attach" {
748
+ "leader_mailbox"
749
+ } else {
750
+ "rebind_required"
751
+ };
682
752
  Ok(json!({
683
753
  "message_id": row.get::<_, String>(0)?,
684
754
  "sender": row.get::<_, Option<String>>(1)?,
685
- "status": row.get::<_, String>(2)?,
755
+ "status": status,
686
756
  "error": row.get::<_, Option<String>>(3)?,
687
757
  "created_at": row.get::<_, Option<String>>(4)?,
688
758
  "delivery_attempts": row.get::<_, i64>(5)?,
689
- "channel": "rebind_required",
759
+ "channel": channel,
690
760
  "action": "run team-agent attach-leader or team-agent takeover",
691
761
  }))
692
762
  };
@@ -846,7 +916,15 @@ use rusqlite::params;
846
916
  // 0.4.x Phase 1: add `worker_state` (canonical 5-state product
847
917
  // surface). `activity` is preserved alongside as the deprecated
848
918
  // legacy classifier output (CR R3 same-source contract).
849
- for key in ["status", "provider", "worker_state", "activity", "last_output_at"] {
919
+ for key in [
920
+ "status",
921
+ "provider",
922
+ "worker_state",
923
+ "activity",
924
+ "last_output_at",
925
+ "stale",
926
+ "stale_reason",
927
+ ] {
850
928
  if let Some(value) = input.get(key) {
851
929
  out.insert(key.to_string(), value.clone());
852
930
  }
@@ -964,10 +1042,35 @@ use rusqlite::params;
964
1042
  );
965
1043
  insert_optional_string(&mut item, "last_output_at", row.get(2).map_err(|e| CliError::Runtime(e.to_string()))?);
966
1044
  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()))?);
1045
+ let current_task_id: Option<String> =
1046
+ row.get(4).map_err(|e| CliError::Runtime(e.to_string()))?;
1047
+ let has_current_task = current_task_id.is_some();
1048
+ insert_optional_string(&mut item, "current_task_id", current_task_id);
1049
+ let updated_at: String =
1050
+ row.get(5).map_err(|e| CliError::Runtime(e.to_string()))?;
1051
+ // Phase-DX E2 (plan §4 / CR supplement A): expose the last agent_health
1052
+ // observation timestamp as `health_updated_at` alongside the legacy
1053
+ // `updated_at` alias. Two names for one column keep old scrapers working
1054
+ // while surfacing the semantic (heartbeat, not row bookkeeping).
1055
+ item.insert("updated_at".to_string(), json!(updated_at.clone()));
1056
+ item.insert("health_updated_at".to_string(), json!(updated_at));
1057
+ // Phase-DX E2 (CR P0 red line #6, supplements A/B): current_task is a
1058
+ // best-effort *display* field until A1 makes task FSM authoritative.
1059
+ // The structured source/confidence markers stop downstream code from
1060
+ // treating agent_health.current_task_id as authority. `current_task_source`
1061
+ // records where the display value came from (only "health" today —
1062
+ // Phase-DX never merges state tasks into this projection); the
1063
+ // `current_task_confidence` enum stays "best_effort" for the whole
1064
+ // Phase-DX slice (A1 will later flip it to "authoritative" when the
1065
+ // task FSM lands). Field is written unconditionally so consumers can
1066
+ // switch on it even when `current_task_id` is null.
1067
+ item.insert(
1068
+ "current_task_source".to_string(),
1069
+ json!(if has_current_task { "health" } else { "none" }),
1070
+ );
968
1071
  item.insert(
969
- "updated_at".to_string(),
970
- json!(row.get::<_, String>(5).map_err(|e| CliError::Runtime(e.to_string()))?),
1072
+ "current_task_confidence".to_string(),
1073
+ json!("best_effort"),
971
1074
  );
972
1075
  insert_optional_string(&mut item, "owner_team_id", row.get(6).map_err(|e| CliError::Runtime(e.to_string()))?);
973
1076
  out.insert(agent_id, Value::Object(item));
@@ -51,6 +51,7 @@ use super::*;
51
51
  message_id: None,
52
52
  pane: None,
53
53
  to_name: None,
54
+ to_leader: None,
54
55
  };
55
56
  let _ = cmd_send(&args);
56
57
 
@@ -159,6 +159,7 @@ fn named_send_args(
159
159
  message_id: None,
160
160
  pane: pane.map(str::to_string),
161
161
  to_name: to_name.map(str::to_string),
162
+ to_leader: None,
162
163
  }
163
164
  }
164
165
 
@@ -328,11 +329,16 @@ fn resolve_leader_name_not_live() {
328
329
  .with_targets(vec![pane("other", "codex", "%other")]);
329
330
 
330
331
  let err = resolve_name_with_transport(&ws, "alpha/leader", &transport).unwrap_err();
331
- assert_eq!(err.kind, NamedAddressErrorKind::NameNotLive);
332
+ // 0.5.9 E6 taxonomy split: `--to-name <team>/leader` when the team
333
+ // exists but the leader receiver's pane is missing is now
334
+ // `leader_not_attached` (not the coarser `name_not_live`), so a
335
+ // third-party sender can be told what to do without being pushed
336
+ // toward `claim-leader`/`takeover` (owner-only actions).
337
+ assert_eq!(err.kind, NamedAddressErrorKind::LeaderNotAttached);
332
338
  let n38 = err.n38_message();
333
- assert!(n38.contains("claim-leader"), "{n38}");
334
339
  assert!(n38.contains("attach-leader"), "{n38}");
335
- assert!(n38.contains("takeover"), "{n38}");
340
+ assert!(!n38.contains("claim-leader"), "third-party copy must not suggest claim-leader: {n38}");
341
+ assert!(!n38.contains("takeover"), "third-party copy must not suggest takeover: {n38}");
336
342
  let _ = std::fs::remove_dir_all(&ws);
337
343
  }
338
344
 
@@ -448,6 +448,7 @@ use super::*;
448
448
  message_id: None,
449
449
  pane: None,
450
450
  to_name: None,
451
+ to_leader: None,
451
452
  }
452
453
  }
453
454
 
@@ -334,6 +334,11 @@ pub struct SendArgs {
334
334
  /// `target` / `targets` / `--pane`; resolves workspace/team/agent or leader
335
335
  /// name to the current live pane before using direct pane injection.
336
336
  pub to_name: Option<String>,
337
+ /// E7 (0.5.9 host-leader-registry-design): `--to-leader NAME` resolves
338
+ /// NAME through `~/.team-agent/leaders`, canonical-validates, and then
339
+ /// delegates to the E6 leader delivery path (live inject or offline
340
+ /// mailbox with `queued_until_leader_attach`).
341
+ pub to_leader: Option<String>,
337
342
  }
338
343
 
339
344
  /// E23 worker-side emergency fallback for `team_orchestrator.send_message`
@@ -617,6 +622,15 @@ pub struct SessionsArgs {
617
622
  pub team: Option<String>,
618
623
  }
619
624
 
625
+ /// E7 (0.5.9 host-leader-registry-design §4.1): `team-agent leaders`
626
+ /// enumerates the host discovery index and classifies each entry as
627
+ /// LIVE, STALE, or AMBIGUOUS after re-validating against canonical
628
+ /// workspace state.
629
+ #[derive(Debug, Clone, PartialEq, Eq)]
630
+ pub struct LeadersArgs {
631
+ pub json: bool,
632
+ }
633
+
620
634
  /// `validate [spec=team.spec.yaml] --json`(`parser.py:120`)。
621
635
  #[derive(Debug, Clone, PartialEq, Eq)]
622
636
  pub struct ValidateArgs {
@@ -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;
@@ -81,6 +81,7 @@ pub mod lease;
81
81
  pub mod owner_bind;
82
82
  pub mod provider_attribution;
83
83
  pub mod rediscover;
84
+ pub mod registry;
84
85
  pub mod start;
85
86
  pub mod takeover;
86
87
  pub mod types;