@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/attach_app_server_leader.rs +82 -1
- package/crates/team-agent/src/cli/diagnose.rs +46 -17
- package/crates/team-agent/src/cli/emit.rs +30 -1
- package/crates/team-agent/src/cli/leaders.rs +94 -0
- package/crates/team-agent/src/cli/mod.rs +138 -2
- package/crates/team-agent/src/cli/named_address.rs +288 -28
- package/crates/team-agent/src/cli/send.rs +230 -0
- package/crates/team-agent/src/cli/status_port.rs +117 -14
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +9 -3
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +14 -0
- package/crates/team-agent/src/coordinator/tick.rs +7 -1
- package/crates/team-agent/src/db/agent_health_capture.rs +96 -0
- package/crates/team-agent/src/db/mod.rs +1 -0
- package/crates/team-agent/src/leader/mod.rs +1 -0
- package/crates/team-agent/src/leader/registry.rs +402 -0
- package/crates/team-agent/src/lifecycle/restart/remove.rs +13 -61
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
- package/crates/team-agent/src/mcp_server/helpers.rs +18 -1
- package/crates/team-agent/src/mcp_server/normalize.rs +79 -0
- package/crates/team-agent/src/mcp_server/tests/send.rs +125 -13
- package/crates/team-agent/src/mcp_server/tools.rs +24 -2
- package/crates/team-agent/src/messaging/delivery.rs +12 -5
- package/crates/team-agent/src/messaging/leader_receiver.rs +58 -0
- package/crates/team-agent/src/messaging/mod.rs +2 -1
- package/crates/team-agent/src/messaging/results.rs +53 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +7 -2
- package/crates/team-agent/src/messaging/watchers.rs +13 -3
- package/package.json +4 -4
|
@@ -18,6 +18,14 @@ pub(crate) enum NamedAddressErrorKind {
|
|
|
18
18
|
NameInvalid,
|
|
19
19
|
WorkspaceNotFound,
|
|
20
20
|
StateNotFound,
|
|
21
|
+
// E6 (0.5.9 offline-mailbox-toname-design §3.1): three-way taxonomy for
|
|
22
|
+
// `--to-name <team>/leader` refusal — required by the E6 RED so the top-level
|
|
23
|
+
// `leader_receiver` fallback is no longer reached for a wrong runtime key.
|
|
24
|
+
// `WorkspaceNoState` mirrors `StateNotFound` semantics but keeps the wire
|
|
25
|
+
// reason distinct for third-party sender copy.
|
|
26
|
+
WorkspaceNoState,
|
|
27
|
+
TeamKeyNotFound,
|
|
28
|
+
LeaderNotAttached,
|
|
21
29
|
NameNotResolvable,
|
|
22
30
|
NameNotLive,
|
|
23
31
|
NameAmbiguous,
|
|
@@ -29,6 +37,9 @@ impl NamedAddressErrorKind {
|
|
|
29
37
|
Self::NameInvalid => "name_invalid",
|
|
30
38
|
Self::WorkspaceNotFound => "workspace_not_found",
|
|
31
39
|
Self::StateNotFound => "state_not_found",
|
|
40
|
+
Self::WorkspaceNoState => "workspace_no_state",
|
|
41
|
+
Self::TeamKeyNotFound => "team_key_not_found",
|
|
42
|
+
Self::LeaderNotAttached => "leader_not_attached",
|
|
32
43
|
Self::NameNotResolvable => "name_not_resolvable",
|
|
33
44
|
Self::NameNotLive => "name_not_live",
|
|
34
45
|
Self::NameAmbiguous => "name_ambiguous",
|
|
@@ -43,6 +54,13 @@ pub(crate) struct NamedAddressError {
|
|
|
43
54
|
pub action: String,
|
|
44
55
|
pub log: String,
|
|
45
56
|
pub candidates: Vec<Value>,
|
|
57
|
+
// E6: when the caller supplied a `<team>/leader` name that isn't a runtime
|
|
58
|
+
// team_key, echo the requested value + the canonical alternatives so
|
|
59
|
+
// spec/display-name confusion is visibly diagnosed. Empty for other
|
|
60
|
+
// refusal kinds.
|
|
61
|
+
pub requested_team: Option<String>,
|
|
62
|
+
pub available_team_keys: Vec<String>,
|
|
63
|
+
pub suggested_name: Option<String>,
|
|
46
64
|
}
|
|
47
65
|
|
|
48
66
|
impl NamedAddressError {
|
|
@@ -53,6 +71,9 @@ impl NamedAddressError {
|
|
|
53
71
|
action: "inspect team-agent status and use an explicit workspace/team name".to_string(),
|
|
54
72
|
log: "named-address resolver refused before injection".to_string(),
|
|
55
73
|
candidates: Vec::new(),
|
|
74
|
+
requested_team: None,
|
|
75
|
+
available_team_keys: Vec::new(),
|
|
76
|
+
suggested_name: None,
|
|
56
77
|
}
|
|
57
78
|
}
|
|
58
79
|
|
|
@@ -66,15 +87,35 @@ impl NamedAddressError {
|
|
|
66
87
|
}
|
|
67
88
|
|
|
68
89
|
pub(crate) fn to_json(&self) -> Value {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
let mut obj = serde_json::Map::new();
|
|
91
|
+
obj.insert("ok".to_string(), Value::Bool(false));
|
|
92
|
+
obj.insert("status".to_string(), Value::String("refused".to_string()));
|
|
93
|
+
obj.insert(
|
|
94
|
+
"reason".to_string(),
|
|
95
|
+
Value::String(self.kind.as_str().to_string()),
|
|
96
|
+
);
|
|
97
|
+
obj.insert("error".to_string(), Value::String(self.message.clone()));
|
|
98
|
+
obj.insert("action".to_string(), Value::String(self.action.clone()));
|
|
99
|
+
obj.insert("log".to_string(), Value::String(self.log.clone()));
|
|
100
|
+
obj.insert("candidates".to_string(), Value::Array(self.candidates.clone()));
|
|
101
|
+
if let Some(requested) = self.requested_team.as_deref() {
|
|
102
|
+
obj.insert("requested_team".to_string(), Value::String(requested.to_string()));
|
|
103
|
+
}
|
|
104
|
+
if !self.available_team_keys.is_empty() {
|
|
105
|
+
obj.insert(
|
|
106
|
+
"available_team_keys".to_string(),
|
|
107
|
+
Value::Array(
|
|
108
|
+
self.available_team_keys
|
|
109
|
+
.iter()
|
|
110
|
+
.map(|k| Value::String(k.clone()))
|
|
111
|
+
.collect(),
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if let Some(suggested) = self.suggested_name.as_deref() {
|
|
116
|
+
obj.insert("suggested_name".to_string(), Value::String(suggested.to_string()));
|
|
117
|
+
}
|
|
118
|
+
Value::Object(obj)
|
|
78
119
|
}
|
|
79
120
|
}
|
|
80
121
|
|
|
@@ -531,6 +572,61 @@ fn resolve_bare_agent(
|
|
|
531
572
|
}
|
|
532
573
|
}
|
|
533
574
|
|
|
575
|
+
/// Phase-DX E3 (plan §4 / CR supplements A/B/C, P0 red lines #1 & #6):
|
|
576
|
+
/// build advisory diagnostic candidates for a failed `--to-name leader`
|
|
577
|
+
/// resolution. The candidate list is deliberately shaped to be **read by
|
|
578
|
+
/// humans**, not consumed as route authority: every entry carries
|
|
579
|
+
/// `advisory: true` and a `source` tag identifying where it was observed.
|
|
580
|
+
///
|
|
581
|
+
/// Candidate source rules:
|
|
582
|
+
/// - The transport passed in is already scoped by [`transport_for_cli_target`]
|
|
583
|
+
/// to the team's recorded `leader_receiver.tmux_socket` (or the workspace
|
|
584
|
+
/// default when no socket was recorded), so `transport.list_targets()`
|
|
585
|
+
/// enumerates only that endpoint. We do NOT scan other tmux sockets or
|
|
586
|
+
/// fall back to reverse-authority tmux-session discovery.
|
|
587
|
+
/// - `pane_id` fields are copied verbatim from `list_targets`. `session` is
|
|
588
|
+
/// the tmux session, and `socket` records the tmux endpoint the candidate
|
|
589
|
+
/// was seen on (whichever the scoped transport reports).
|
|
590
|
+
///
|
|
591
|
+
/// Callers must never treat any candidate as authoritative; MUST-NOT-12 stays
|
|
592
|
+
/// intact — resolver refusal is still the outcome even when candidates fire.
|
|
593
|
+
fn leader_advisory_candidates(
|
|
594
|
+
state: &Value,
|
|
595
|
+
team: &str,
|
|
596
|
+
transport: &dyn Transport,
|
|
597
|
+
source: &str,
|
|
598
|
+
) -> Vec<Value> {
|
|
599
|
+
let team_scoped_socket = team_entry(state, team)
|
|
600
|
+
.and_then(|entry| entry.get("leader_receiver"))
|
|
601
|
+
.and_then(|receiver| string_field(receiver, "tmux_socket"));
|
|
602
|
+
let socket = match team_scoped_socket {
|
|
603
|
+
Some(s) => Some(s.to_string()),
|
|
604
|
+
None => state
|
|
605
|
+
// ALLOWED-LEGACY-SINGLE-TEAM: diagnostic-only socket label for the advisory candidates; never fed back into routing.
|
|
606
|
+
.get("leader_receiver")
|
|
607
|
+
.and_then(|receiver| string_field(receiver, "tmux_socket"))
|
|
608
|
+
.map(str::to_string)
|
|
609
|
+
.or_else(|| transport.tmux_endpoint()),
|
|
610
|
+
};
|
|
611
|
+
let targets = match transport.list_targets() {
|
|
612
|
+
Ok(targets) => targets,
|
|
613
|
+
Err(_) => return Vec::new(),
|
|
614
|
+
};
|
|
615
|
+
targets
|
|
616
|
+
.into_iter()
|
|
617
|
+
.map(|pane| {
|
|
618
|
+
json!({
|
|
619
|
+
"pane_id": pane.pane_id.as_str(),
|
|
620
|
+
"session": pane.session.as_str(),
|
|
621
|
+
"window": pane.window_name.as_ref().map(|w| w.as_str()),
|
|
622
|
+
"socket": socket,
|
|
623
|
+
"source": source,
|
|
624
|
+
"advisory": json!(true),
|
|
625
|
+
})
|
|
626
|
+
})
|
|
627
|
+
.collect()
|
|
628
|
+
}
|
|
629
|
+
|
|
534
630
|
fn resolve_leader(
|
|
535
631
|
sender_workspace: &Path,
|
|
536
632
|
target_workspace: &Path,
|
|
@@ -539,12 +635,27 @@ fn resolve_leader(
|
|
|
539
635
|
parsed: &ParsedNamedAddress,
|
|
540
636
|
transport: &dyn Transport,
|
|
541
637
|
) -> Result<ResolvedNamedAddress, NamedAddressError> {
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
.
|
|
638
|
+
// E6 taxonomy split (0.5.9 offline-mailbox-toname-design §3.1): before
|
|
639
|
+
// reading any leader_receiver, decide whether `<team>` is actually the
|
|
640
|
+
// runtime team key. Wrong keys must not fall through to top-level
|
|
641
|
+
// leader_receiver (that was the pre-fix bug where `twitter-autopub/leader`
|
|
642
|
+
// silently reused the `current` team's receiver and reported a stale
|
|
643
|
+
// pane_id). Legacy single-team compat is preserved only when the state
|
|
644
|
+
// has no `teams` map yet and the requested key equals `active_team_key`.
|
|
645
|
+
let receiver = resolve_team_scoped_leader_receiver(state, team, target_workspace)?
|
|
646
|
+
.ok_or_else(|| {
|
|
647
|
+
let mut err = leader_not_attached_third_party(
|
|
648
|
+
target_workspace,
|
|
649
|
+
team,
|
|
650
|
+
None,
|
|
651
|
+
None,
|
|
652
|
+
None,
|
|
653
|
+
transport.tmux_endpoint(),
|
|
654
|
+
);
|
|
655
|
+
err.candidates =
|
|
656
|
+
leader_advisory_candidates(state, team, transport, "state_recorded_socket");
|
|
657
|
+
err
|
|
658
|
+
})?;
|
|
548
659
|
if let Some((mode, transport_kind)) = receiver_transport_conflict(receiver) {
|
|
549
660
|
let mut err = NamedAddressError::new(
|
|
550
661
|
NamedAddressErrorKind::NameNotLive,
|
|
@@ -569,7 +680,19 @@ fn resolve_leader(
|
|
|
569
680
|
}
|
|
570
681
|
let pane_id = string_field(receiver, "pane_id")
|
|
571
682
|
.filter(|pane| !pane.is_empty())
|
|
572
|
-
.ok_or_else(||
|
|
683
|
+
.ok_or_else(|| {
|
|
684
|
+
let mut err = leader_not_attached_third_party(
|
|
685
|
+
target_workspace,
|
|
686
|
+
team,
|
|
687
|
+
None,
|
|
688
|
+
None,
|
|
689
|
+
None,
|
|
690
|
+
transport.tmux_endpoint(),
|
|
691
|
+
);
|
|
692
|
+
err.candidates =
|
|
693
|
+
leader_advisory_candidates(state, team, transport, "state_recorded_socket");
|
|
694
|
+
err
|
|
695
|
+
})?;
|
|
573
696
|
let session = string_field(receiver, "session_name");
|
|
574
697
|
let window = string_field(receiver, "window_name");
|
|
575
698
|
let socket = string_field(receiver, "tmux_socket")
|
|
@@ -599,13 +722,19 @@ fn resolve_leader(
|
|
|
599
722
|
agent_status: None,
|
|
600
723
|
warning: None,
|
|
601
724
|
}),
|
|
602
|
-
0 =>
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
725
|
+
0 => {
|
|
726
|
+
let mut err = leader_not_attached_third_party(
|
|
727
|
+
target_workspace,
|
|
728
|
+
team,
|
|
729
|
+
Some(pane_id),
|
|
730
|
+
session,
|
|
731
|
+
window,
|
|
732
|
+
socket,
|
|
733
|
+
);
|
|
734
|
+
err.candidates =
|
|
735
|
+
leader_advisory_candidates(state, team, transport, "state_recorded_socket");
|
|
736
|
+
Err(err)
|
|
737
|
+
}
|
|
609
738
|
_ => Err(name_ambiguous(
|
|
610
739
|
"multiple live panes matched leader_receiver pane id",
|
|
611
740
|
matches
|
|
@@ -775,12 +904,18 @@ fn transport_for_cli_target(
|
|
|
775
904
|
) -> Box<dyn Transport> {
|
|
776
905
|
if let ParsedTarget::TeamEntity { team, entity } = &parsed.target {
|
|
777
906
|
if entity == "leader" {
|
|
778
|
-
|
|
907
|
+
let team_scoped_socket = team_entry(state, team)
|
|
779
908
|
.and_then(|entry| entry.get("leader_receiver"))
|
|
780
|
-
.
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
909
|
+
.and_then(|receiver| string_field(receiver, "tmux_socket"));
|
|
910
|
+
let socket = team_scoped_socket.map(str::to_string).or_else(|| {
|
|
911
|
+
state
|
|
912
|
+
// ALLOWED-LEGACY-SINGLE-TEAM: pre-teams-map compat probe endpoint; canonical taxonomy in `resolve_team_scoped_leader_receiver` still gates identity.
|
|
913
|
+
.get("leader_receiver")
|
|
914
|
+
.and_then(|receiver| string_field(receiver, "tmux_socket"))
|
|
915
|
+
.map(str::to_string)
|
|
916
|
+
});
|
|
917
|
+
if let Some(socket) = socket {
|
|
918
|
+
return Box::new(crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&socket));
|
|
784
919
|
}
|
|
785
920
|
}
|
|
786
921
|
if let Some(entry) = team_entry(state, team) {
|
|
@@ -866,6 +1001,46 @@ fn team_entry<'a>(state: &'a Value, team: &str) -> Option<&'a Value> {
|
|
|
866
1001
|
.and_then(|teams| teams.get(team))
|
|
867
1002
|
}
|
|
868
1003
|
|
|
1004
|
+
/// E6 (0.5.9 offline-mailbox-toname-design §3.1): decide which
|
|
1005
|
+
/// `leader_receiver` object, if any, applies to `<team>/leader` before any
|
|
1006
|
+
/// downstream code reads it. Wrong runtime keys are rejected here with
|
|
1007
|
+
/// `team_key_not_found` — the pre-fix behavior was to silently reuse the
|
|
1008
|
+
/// top-level `leader_receiver` (the `current` team's receiver in the gate
|
|
1009
|
+
/// evidence), producing misleading `expected_pane=<missing>` messages.
|
|
1010
|
+
///
|
|
1011
|
+
/// Legacy single-team compat is preserved only when the state has no
|
|
1012
|
+
/// `teams` map at all AND the requested team equals `active_team_key`.
|
|
1013
|
+
/// That branch is a documented exception to the E6 fallback grep guard;
|
|
1014
|
+
/// the exception is tagged inline with `ALLOWED-LEGACY-SINGLE-TEAM` so
|
|
1015
|
+
/// the guard admits it explicitly (see the R2 rework in
|
|
1016
|
+
/// `.team/artifacts/059-impl-cr-verdict.md §4`). The exception is
|
|
1017
|
+
/// scheduled to be removed when B1 canonical state layout lands
|
|
1018
|
+
/// (`.team/artifacts/next-version-staged-plan.md §5 Phase-Foundation-1`),
|
|
1019
|
+
/// at which point every runtime state carries a `teams` map and the
|
|
1020
|
+
/// legacy branch becomes unreachable dead code.
|
|
1021
|
+
fn resolve_team_scoped_leader_receiver<'a>(
|
|
1022
|
+
state: &'a Value,
|
|
1023
|
+
team: &str,
|
|
1024
|
+
target_workspace: &Path,
|
|
1025
|
+
) -> Result<Option<&'a Value>, NamedAddressError> {
|
|
1026
|
+
if let Some(entry) = team_entry(state, team) {
|
|
1027
|
+
return Ok(entry.get("leader_receiver"));
|
|
1028
|
+
}
|
|
1029
|
+
if is_legacy_single_team_active(state, team) {
|
|
1030
|
+
// ALLOWED-LEGACY-SINGLE-TEAM: pre-teams-map compat; state has no teams map AND request matches active_team_key.
|
|
1031
|
+
return Ok(state.get("leader_receiver"));
|
|
1032
|
+
}
|
|
1033
|
+
Err(team_key_not_found(target_workspace, state, team))
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
fn is_legacy_single_team_active(state: &Value, team: &str) -> bool {
|
|
1037
|
+
state.get("teams").is_none()
|
|
1038
|
+
&& state
|
|
1039
|
+
.get("active_team_key")
|
|
1040
|
+
.and_then(Value::as_str)
|
|
1041
|
+
.is_some_and(|k| k == team)
|
|
1042
|
+
}
|
|
1043
|
+
|
|
869
1044
|
fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
|
|
870
1045
|
value
|
|
871
1046
|
.get(key)
|
|
@@ -944,6 +1119,91 @@ fn name_not_live_worker(
|
|
|
944
1119
|
err
|
|
945
1120
|
}
|
|
946
1121
|
|
|
1122
|
+
/// E6 (0.5.9 offline-mailbox §3.1): requested `<team>` is not a runtime team_key.
|
|
1123
|
+
/// The refusal echoes the requested string plus the canonical alternatives so
|
|
1124
|
+
/// the caller can retry with `::<canonical_key>/leader`. The action copy is
|
|
1125
|
+
/// deliberately neutral — no `claim-leader` / `takeover` — because a third-party
|
|
1126
|
+
/// sender must never be told to seize the target team's leader.
|
|
1127
|
+
fn team_key_not_found(target_workspace: &Path, state: &Value, team: &str) -> NamedAddressError {
|
|
1128
|
+
let available = available_team_keys(state);
|
|
1129
|
+
let workspace = target_workspace.display();
|
|
1130
|
+
let mut err = NamedAddressError::new(
|
|
1131
|
+
NamedAddressErrorKind::TeamKeyNotFound,
|
|
1132
|
+
format!(
|
|
1133
|
+
"team key `{team}` is not a runtime key in target workspace `{workspace}` (spec/display name may differ from runtime key)"
|
|
1134
|
+
),
|
|
1135
|
+
);
|
|
1136
|
+
let suggested_key = available.first().cloned();
|
|
1137
|
+
err.action = match suggested_key.as_deref() {
|
|
1138
|
+
Some(k) => format!(
|
|
1139
|
+
"Retry with the canonical key, e.g. `{workspace}::{k}/leader`; check `team-agent status --workspace {workspace} --json` for team_key"
|
|
1140
|
+
),
|
|
1141
|
+
None => format!(
|
|
1142
|
+
"No runtime team keys are present; check `team-agent status --workspace {workspace} --json`"
|
|
1143
|
+
),
|
|
1144
|
+
};
|
|
1145
|
+
err.log = format!(
|
|
1146
|
+
"requested_team={team} available_team_keys={:?} workspace={workspace}",
|
|
1147
|
+
available
|
|
1148
|
+
);
|
|
1149
|
+
err.requested_team = Some(team.to_string());
|
|
1150
|
+
err.suggested_name = suggested_key
|
|
1151
|
+
.as_deref()
|
|
1152
|
+
.map(|k| format!("{workspace}::{k}/leader"));
|
|
1153
|
+
err.available_team_keys = available;
|
|
1154
|
+
err
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/// E6 (0.5.9 offline-mailbox §3.1): key hit but the leader receiver is either
|
|
1158
|
+
/// absent or not live. `leader_not_attached_third_party` is the neutral variant
|
|
1159
|
+
/// used by the resolver: it never suggests `claim-leader` / `takeover` so the
|
|
1160
|
+
/// caller (which may be a third-party sender) never sees ownership-seizing copy.
|
|
1161
|
+
/// The owner-facing action (attach-leader) belongs to `status_port`
|
|
1162
|
+
/// pending_leader_notifications and diagnose.
|
|
1163
|
+
fn leader_not_attached_third_party(
|
|
1164
|
+
target_workspace: &Path,
|
|
1165
|
+
team: &str,
|
|
1166
|
+
pane_id: Option<&str>,
|
|
1167
|
+
session: Option<&str>,
|
|
1168
|
+
window: Option<&str>,
|
|
1169
|
+
socket: Option<String>,
|
|
1170
|
+
) -> NamedAddressError {
|
|
1171
|
+
let workspace = target_workspace.display();
|
|
1172
|
+
let mut err = NamedAddressError::new(
|
|
1173
|
+
NamedAddressErrorKind::LeaderNotAttached,
|
|
1174
|
+
"target team leader is not attached on its recorded tmux endpoint",
|
|
1175
|
+
);
|
|
1176
|
+
err.action = format!(
|
|
1177
|
+
"Ask the target team owner to run `team-agent attach-leader --workspace {workspace} --team {team}`; the sender-side offline mailbox will requeue the message once attach succeeds"
|
|
1178
|
+
);
|
|
1179
|
+
err.log = format!(
|
|
1180
|
+
"name={team}/leader expected_pane={} expected_session={} expected_window={} tmux_socket={}",
|
|
1181
|
+
pane_id.unwrap_or("<missing>"),
|
|
1182
|
+
session.unwrap_or("<unknown>"),
|
|
1183
|
+
window.unwrap_or("<unknown>"),
|
|
1184
|
+
socket.unwrap_or_else(|| "<unknown>".to_string())
|
|
1185
|
+
);
|
|
1186
|
+
err.requested_team = Some(team.to_string());
|
|
1187
|
+
err
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
pub(crate) fn available_team_keys(state: &Value) -> Vec<String> {
|
|
1191
|
+
let mut keys: Vec<String> = state
|
|
1192
|
+
.get("teams")
|
|
1193
|
+
.and_then(Value::as_object)
|
|
1194
|
+
.map(|teams| teams.keys().cloned().collect())
|
|
1195
|
+
.unwrap_or_default();
|
|
1196
|
+
if keys.is_empty() {
|
|
1197
|
+
if let Some(active) = state.get("active_team_key").and_then(Value::as_str) {
|
|
1198
|
+
if !active.is_empty() {
|
|
1199
|
+
keys.push(active.to_string());
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
keys.sort();
|
|
1204
|
+
keys
|
|
1205
|
+
}
|
|
1206
|
+
|
|
947
1207
|
fn name_not_live_leader(
|
|
948
1208
|
team: &str,
|
|
949
1209
|
pane_id: Option<&str>,
|
|
@@ -7,6 +7,34 @@ use crate::messaging::{DeliveryOutcome, DeliveryRefusal, DeliveryStage, Delivery
|
|
|
7
7
|
/// `cmd_send`(`commands.py:164`)。解析 target(`--to` fanout / 单 target / `*`)→ [`MessageTarget`],
|
|
8
8
|
/// 拼 [`SendOptions`](no_ack→requires_ack 取反、no_wait→wait_visible 取反等)→ `messaging::send_message`。
|
|
9
9
|
pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
10
|
+
if let Some(ref to_leader) = args.to_leader {
|
|
11
|
+
// E7 (0.5.9 host-leader-registry-design §4.2): `--to-leader NAME`
|
|
12
|
+
// resolves NAME through `~/.team-agent/leaders`, canonical-validates
|
|
13
|
+
// the entry, and delegates to the same E6 leader delivery path
|
|
14
|
+
// (`send_to_canonical_leader_target`) — so live inject and offline
|
|
15
|
+
// mailbox (`queued_until_leader_attach` / `leader_mailbox`) both
|
|
16
|
+
// funnel through one code path. Mutually exclusive with
|
|
17
|
+
// `--to-name`, TARGET/--to, `--pane`.
|
|
18
|
+
if args.to_name.is_some()
|
|
19
|
+
|| args.pane.is_some()
|
|
20
|
+
|| args.target.is_some()
|
|
21
|
+
|| args.targets.is_some()
|
|
22
|
+
{
|
|
23
|
+
return Err(CliError::Usage(
|
|
24
|
+
"--to-leader and --to-name/--pane/TARGET/--to are mutually exclusive: \
|
|
25
|
+
--to-leader resolves a host leader delivery name via the leader registry"
|
|
26
|
+
.to_string(),
|
|
27
|
+
));
|
|
28
|
+
}
|
|
29
|
+
let content = args.message.join(" ");
|
|
30
|
+
if content.is_empty() {
|
|
31
|
+
return Err(CliError::Usage(
|
|
32
|
+
"--to-leader requires a non-empty message".to_string(),
|
|
33
|
+
));
|
|
34
|
+
}
|
|
35
|
+
let value = send_to_canonical_leader_target(&args.workspace, to_leader, &content, &args.sender, args.task.as_deref())?;
|
|
36
|
+
return Ok(cmd_send_result(value, args.json));
|
|
37
|
+
}
|
|
10
38
|
if let Some(ref to_name) = args.to_name {
|
|
11
39
|
if args.pane.is_some() || args.target.is_some() || args.targets.is_some() {
|
|
12
40
|
return Err(CliError::Usage(
|
|
@@ -913,6 +941,208 @@ fn delivery_stage_wire(stage: DeliveryStage) -> &'static str {
|
|
|
913
941
|
}
|
|
914
942
|
}
|
|
915
943
|
|
|
944
|
+
/// E7 (0.5.9 host-leader-registry-design §8.3): resolve `NAME` through
|
|
945
|
+
/// `~/.team-agent/leaders`, then delegate to the E6 leader delivery path
|
|
946
|
+
/// so a resolved live target physically injects and a leader-not-attached
|
|
947
|
+
/// target queues via `enqueue_leader_mailbox_until_attach`. Ambiguous
|
|
948
|
+
/// short names refuse with `name_ambiguous` and expose `candidates` —
|
|
949
|
+
/// no priority heuristic ever picks a winner (host-leader-registry-design §5.2).
|
|
950
|
+
///
|
|
951
|
+
/// Return shape reserves the following markers for downstream consumers:
|
|
952
|
+
/// - `resolved_via = "host_leader_registry"` when a registry entry
|
|
953
|
+
/// selected the canonical target (E7 test 2).
|
|
954
|
+
/// - `reason = "leader_name_not_found"` for missing entries; `reason =
|
|
955
|
+
/// "registry_stale"` when canonical validation refuses; `reason =
|
|
956
|
+
/// "name_ambiguous"` for collisions with a candidate list including
|
|
957
|
+
/// `workspace_hash` and `stable_qualified_name`.
|
|
958
|
+
///
|
|
959
|
+
/// The first slice ships the marker/return-shape surface so E6 wiring is
|
|
960
|
+
/// available at the CLI; the full canonical-validate loop follows in a
|
|
961
|
+
/// later commit alongside the registry read implementation.
|
|
962
|
+
pub fn send_to_canonical_leader_target(
|
|
963
|
+
sender_workspace: &std::path::Path,
|
|
964
|
+
name: &str,
|
|
965
|
+
content: &str,
|
|
966
|
+
sender: &str,
|
|
967
|
+
task_id: Option<&str>,
|
|
968
|
+
) -> Result<serde_json::Value, CliError> {
|
|
969
|
+
// Resolve NAME through the registry. Ambiguity is decided *before*
|
|
970
|
+
// canonical validation so an ambiguous short name never picks a
|
|
971
|
+
// winner — even when only one candidate happens to be live. Send
|
|
972
|
+
// uses the no-GC listing so stale entries can still refuse with
|
|
973
|
+
// `registry_stale` — the leaders CLI is the one that prunes.
|
|
974
|
+
let classified = crate::leader::registry::list_validated_no_gc();
|
|
975
|
+
let mut candidates_all: Vec<crate::leader::registry::LeaderRegistryEntry> = Vec::new();
|
|
976
|
+
for (entry, _status, _reason) in &classified {
|
|
977
|
+
let matches = entry.delivery_name == name
|
|
978
|
+
|| entry.qualified_name == name
|
|
979
|
+
|| entry.stable_qualified_name == name
|
|
980
|
+
|| entry.aliases.iter().any(|a| a == name);
|
|
981
|
+
if matches {
|
|
982
|
+
candidates_all.push(entry.clone());
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
if candidates_all.is_empty() {
|
|
986
|
+
return Ok(serde_json::json!({
|
|
987
|
+
"ok": false,
|
|
988
|
+
"status": "refused",
|
|
989
|
+
"reason": "leader_name_not_found",
|
|
990
|
+
"requested_name": name,
|
|
991
|
+
"resolved_via": "host_leader_registry",
|
|
992
|
+
"candidates": Vec::<serde_json::Value>::new(),
|
|
993
|
+
"workspace_hash": null,
|
|
994
|
+
"stable_qualified_name": null,
|
|
995
|
+
"channel": "leader_mailbox",
|
|
996
|
+
"delivered": false,
|
|
997
|
+
"message_status": "queued_until_leader_attach",
|
|
998
|
+
"action": "run `team-agent leaders` to see registered leaders; retry with a qualified name",
|
|
999
|
+
"registry_stale": false,
|
|
1000
|
+
}));
|
|
1001
|
+
}
|
|
1002
|
+
if candidates_all.len() > 1 {
|
|
1003
|
+
let cand_json: Vec<serde_json::Value> = candidates_all
|
|
1004
|
+
.iter()
|
|
1005
|
+
.map(|e| {
|
|
1006
|
+
serde_json::json!({
|
|
1007
|
+
"name": e.qualified_name,
|
|
1008
|
+
"workspace": e.workspace.display().to_string(),
|
|
1009
|
+
"team_key": e.team_key,
|
|
1010
|
+
"workspace_hash": e.workspace_hash,
|
|
1011
|
+
"stable_qualified_name": e.stable_qualified_name,
|
|
1012
|
+
})
|
|
1013
|
+
})
|
|
1014
|
+
.collect();
|
|
1015
|
+
return Ok(serde_json::json!({
|
|
1016
|
+
"ok": false,
|
|
1017
|
+
"status": "refused",
|
|
1018
|
+
"reason": "name_ambiguous",
|
|
1019
|
+
"requested_name": name,
|
|
1020
|
+
"resolved_via": "host_leader_registry",
|
|
1021
|
+
"candidates": cand_json,
|
|
1022
|
+
"channel": "leader_mailbox",
|
|
1023
|
+
"delivered": false,
|
|
1024
|
+
"action": "run `team-agent leaders` and retry with the qualified name",
|
|
1025
|
+
}));
|
|
1026
|
+
}
|
|
1027
|
+
let entry = candidates_all.into_iter().next().ok_or_else(|| {
|
|
1028
|
+
CliError::Runtime("internal: candidate list must have at least one entry".to_string())
|
|
1029
|
+
})?;
|
|
1030
|
+
// Canonical-validate the entry against target workspace state. Send
|
|
1031
|
+
// through the same E6 --to-name path so live inject and mailbox both
|
|
1032
|
+
// funnel through one code path.
|
|
1033
|
+
let (status, reason) = crate::leader::registry::classify(&entry);
|
|
1034
|
+
if status == "STALE" {
|
|
1035
|
+
// Check whether the underlying team is still alive — if so we
|
|
1036
|
+
// may still queue via the E6 mailbox path (leader-not-attached
|
|
1037
|
+
// shape). If the workspace/team is gone we refuse `registry_stale`.
|
|
1038
|
+
let state = crate::state::persist::load_runtime_state(&entry.workspace).ok();
|
|
1039
|
+
let team_alive = state
|
|
1040
|
+
.as_ref()
|
|
1041
|
+
.and_then(|s| s.get("teams"))
|
|
1042
|
+
.and_then(|v| v.as_object())
|
|
1043
|
+
.and_then(|teams| teams.get(&entry.team_key))
|
|
1044
|
+
.and_then(|t| t.get("status"))
|
|
1045
|
+
.and_then(serde_json::Value::as_str)
|
|
1046
|
+
.map(|s| s == "alive" || s.is_empty())
|
|
1047
|
+
.unwrap_or(false);
|
|
1048
|
+
if !team_alive {
|
|
1049
|
+
return Ok(serde_json::json!({
|
|
1050
|
+
"ok": false,
|
|
1051
|
+
"status": "refused",
|
|
1052
|
+
"reason": "registry_stale",
|
|
1053
|
+
"requested_name": name,
|
|
1054
|
+
"resolved_via": "host_leader_registry",
|
|
1055
|
+
"stale_reason": reason,
|
|
1056
|
+
"workspace_hash": entry.workspace_hash,
|
|
1057
|
+
"stable_qualified_name": entry.stable_qualified_name,
|
|
1058
|
+
"channel": "leader_mailbox",
|
|
1059
|
+
"delivered": false,
|
|
1060
|
+
"action": "target team is not alive; run `team-agent leaders` for current state",
|
|
1061
|
+
}));
|
|
1062
|
+
}
|
|
1063
|
+
// Team alive but leader unattached → E6 mailbox.
|
|
1064
|
+
let event_log = crate::event_log::EventLog::new(&entry.workspace);
|
|
1065
|
+
let task = task_id.map(|s| crate::model::ids::TaskId::new(s.to_string()));
|
|
1066
|
+
let outcome = crate::messaging::enqueue_leader_mailbox_until_attach(
|
|
1067
|
+
&entry.workspace,
|
|
1068
|
+
&entry.team_key,
|
|
1069
|
+
content,
|
|
1070
|
+
task.as_ref(),
|
|
1071
|
+
sender,
|
|
1072
|
+
&event_log,
|
|
1073
|
+
)
|
|
1074
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
1075
|
+
return Ok(serde_json::json!({
|
|
1076
|
+
"ok": true,
|
|
1077
|
+
"status": "queued_until_leader_attach",
|
|
1078
|
+
"message_status": "queued_until_leader_attach",
|
|
1079
|
+
"channel": "leader_mailbox",
|
|
1080
|
+
"delivered": false,
|
|
1081
|
+
"resolved_via": "host_leader_registry",
|
|
1082
|
+
"requested_name": name,
|
|
1083
|
+
"to_leader": entry.qualified_name,
|
|
1084
|
+
"target_workspace": entry.workspace.display().to_string(),
|
|
1085
|
+
"workspace_hash": entry.workspace_hash,
|
|
1086
|
+
"stable_qualified_name": entry.stable_qualified_name,
|
|
1087
|
+
"team_key": entry.team_key,
|
|
1088
|
+
"message_id": outcome.message_id,
|
|
1089
|
+
}));
|
|
1090
|
+
}
|
|
1091
|
+
// LIVE: canonical-validated. Delegate to the E6 --to-name path via a
|
|
1092
|
+
// synthesized `<workspace>::<team_key>/leader` name so live inject +
|
|
1093
|
+
// mailbox both go through one code path.
|
|
1094
|
+
let to_name = format!(
|
|
1095
|
+
"{}::{}/leader",
|
|
1096
|
+
entry.workspace.display(),
|
|
1097
|
+
entry.team_key
|
|
1098
|
+
);
|
|
1099
|
+
let (resolved, transport) =
|
|
1100
|
+
match crate::cli::named_address::resolve_name_for_cli(sender_workspace, &to_name) {
|
|
1101
|
+
Ok(r) => r,
|
|
1102
|
+
Err(err) => {
|
|
1103
|
+
// Named-address refusal — surface it verbatim but tag as
|
|
1104
|
+
// registry-resolved so callers can trace the origin.
|
|
1105
|
+
let mut body = err.to_json();
|
|
1106
|
+
if let Some(obj) = body.as_object_mut() {
|
|
1107
|
+
obj.insert(
|
|
1108
|
+
"resolved_via".to_string(),
|
|
1109
|
+
serde_json::Value::String("host_leader_registry".to_string()),
|
|
1110
|
+
);
|
|
1111
|
+
obj.insert(
|
|
1112
|
+
"delivered".to_string(),
|
|
1113
|
+
serde_json::Value::Bool(false),
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
return Ok(body);
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
let mut value = send_to_named_pane_direct(
|
|
1120
|
+
sender_workspace,
|
|
1121
|
+
transport.as_ref(),
|
|
1122
|
+
&resolved,
|
|
1123
|
+
content,
|
|
1124
|
+
sender,
|
|
1125
|
+
task_id,
|
|
1126
|
+
true,
|
|
1127
|
+
)?;
|
|
1128
|
+
if let Some(obj) = value.as_object_mut() {
|
|
1129
|
+
obj.insert(
|
|
1130
|
+
"resolved_via".to_string(),
|
|
1131
|
+
serde_json::Value::String("host_leader_registry".to_string()),
|
|
1132
|
+
);
|
|
1133
|
+
obj.insert(
|
|
1134
|
+
"to_leader".to_string(),
|
|
1135
|
+
serde_json::Value::String(entry.qualified_name.clone()),
|
|
1136
|
+
);
|
|
1137
|
+
// Honest delivered marker. `send_to_named_pane_direct` sets `ok`
|
|
1138
|
+
// to whether physical inject verified — mirror that as
|
|
1139
|
+
// `delivered`.
|
|
1140
|
+
let ok = obj.get("ok").and_then(serde_json::Value::as_bool).unwrap_or(false);
|
|
1141
|
+
obj.insert("delivered".to_string(), serde_json::Value::Bool(ok));
|
|
1142
|
+
}
|
|
1143
|
+
Ok(value)
|
|
1144
|
+
}
|
|
1145
|
+
|
|
916
1146
|
#[cfg(test)]
|
|
917
1147
|
mod e23_tests {
|
|
918
1148
|
use super::*;
|