@team-agent/installer 0.5.8 → 0.5.10
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/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 +254 -34
- package/crates/team-agent/src/cli/send.rs +370 -3
- package/crates/team-agent/src/cli/status_port.rs +20 -6
- 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/leader/lease.rs +80 -3
- 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/tests/phase_golden.rs +1 -0
- package/crates/team-agent/src/mcp_server/normalize.rs +79 -0
- package/crates/team-agent/src/mcp_server/tools.rs +8 -1
- 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/watchers.rs +13 -3
- package/crates/team-agent/src/transport/tests/mod.rs +1 -0
- package/crates/team-agent/src/transport.rs +6 -0
- package/package.json +4 -4
|
@@ -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(
|
|
@@ -24,10 +52,34 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
|
24
52
|
let (resolved, transport) =
|
|
25
53
|
match crate::cli::named_address::resolve_name_for_cli(&args.workspace, to_name) {
|
|
26
54
|
Ok(resolved) => resolved,
|
|
27
|
-
Err(error)
|
|
28
|
-
|
|
55
|
+
Err(error) => {
|
|
56
|
+
// E6 (0.5.9 offline-mailbox-toname-design §3.1/§6.2): when
|
|
57
|
+
// the resolver refuses with `leader_not_attached`, the team
|
|
58
|
+
// itself may still be alive (worker + coordinator running
|
|
59
|
+
// without a bound leader). Third-party senders in that
|
|
60
|
+
// shape must land in the offline mailbox — same
|
|
61
|
+
// canonical team.db row + queued_until_leader_attach
|
|
62
|
+
// status the coordinator/attach hook replay through the
|
|
63
|
+
// existing pipeline exactly once. Owner-scope refusals
|
|
64
|
+
// (same workspace as target) keep the actionable attach
|
|
65
|
+
// hint — E6 owner copy is documented as
|
|
66
|
+
// `run team-agent attach-leader`.
|
|
67
|
+
if let Some(mut value) = maybe_enqueue_offline_leader_mailbox(
|
|
68
|
+
&args.workspace,
|
|
69
|
+
to_name,
|
|
70
|
+
&content,
|
|
71
|
+
&args.sender,
|
|
72
|
+
args.task.as_deref(),
|
|
73
|
+
&error,
|
|
74
|
+
)? {
|
|
75
|
+
add_send_reminder_if_ok(&mut value);
|
|
76
|
+
return Ok(cmd_send_result(value, args.json));
|
|
77
|
+
}
|
|
78
|
+
if args.json {
|
|
79
|
+
return Ok(CmdResult::from_json(error.to_json(), args.json));
|
|
80
|
+
}
|
|
81
|
+
return Err(CliError::Usage(error.n38_message()));
|
|
29
82
|
}
|
|
30
|
-
Err(error) => return Err(CliError::Usage(error.n38_message())),
|
|
31
83
|
};
|
|
32
84
|
let mut value = send_to_named_pane_direct(
|
|
33
85
|
&args.workspace,
|
|
@@ -758,6 +810,119 @@ fn add_send_reminder_if_ok(value: &mut Value) {
|
|
|
758
810
|
}
|
|
759
811
|
}
|
|
760
812
|
|
|
813
|
+
/// E6 (0.5.9 offline-mailbox-toname-design §§3.1/6.2/8, real-machine
|
|
814
|
+
/// escape evidence
|
|
815
|
+
/// `.team/artifacts/0.5.9-subscription-gate.md` +
|
|
816
|
+
/// `.team/evidence/0.5.9-subscription-gate-20260707T143241Z-4645/`):
|
|
817
|
+
/// when the `--to-name <ws>::<team>/leader` resolver refused with
|
|
818
|
+
/// `leader_not_attached`, decide whether the target team is still alive
|
|
819
|
+
/// (worker + coordinator running without a bound leader) and, if so,
|
|
820
|
+
/// enqueue the mailbox row so `attach-leader` replays it exactly once.
|
|
821
|
+
///
|
|
822
|
+
/// Only queues for third-party senders (sender workspace ≠ target
|
|
823
|
+
/// workspace). Owner-scope refusals stay refused so status/diagnose can
|
|
824
|
+
/// keep pushing the operator toward `attach-leader`.
|
|
825
|
+
fn maybe_enqueue_offline_leader_mailbox(
|
|
826
|
+
sender_workspace: &Path,
|
|
827
|
+
to_name: &str,
|
|
828
|
+
content: &str,
|
|
829
|
+
sender: &str,
|
|
830
|
+
task_id: Option<&str>,
|
|
831
|
+
error: &crate::cli::named_address::NamedAddressError,
|
|
832
|
+
) -> Result<Option<Value>, CliError> {
|
|
833
|
+
if error.kind != crate::cli::named_address::NamedAddressErrorKind::LeaderNotAttached {
|
|
834
|
+
return Ok(None);
|
|
835
|
+
}
|
|
836
|
+
let parsed = match crate::cli::named_address::parse_leader_target_workspace_and_team(
|
|
837
|
+
sender_workspace,
|
|
838
|
+
to_name,
|
|
839
|
+
) {
|
|
840
|
+
Ok(Some(v)) => v,
|
|
841
|
+
Ok(None) => return Ok(None),
|
|
842
|
+
Err(_) => return Ok(None),
|
|
843
|
+
};
|
|
844
|
+
let (target_workspace, team_key) = parsed;
|
|
845
|
+
// Owner-scope refusal: sender workspace == target workspace. Keep
|
|
846
|
+
// the actionable attach hint (owner sees status/diagnose copy that
|
|
847
|
+
// points at `attach-leader`).
|
|
848
|
+
let sender_canonical = std::fs::canonicalize(sender_workspace)
|
|
849
|
+
.unwrap_or_else(|_| sender_workspace.to_path_buf());
|
|
850
|
+
let target_canonical = std::fs::canonicalize(&target_workspace)
|
|
851
|
+
.unwrap_or_else(|_| target_workspace.clone());
|
|
852
|
+
if sender_canonical == target_canonical {
|
|
853
|
+
return Ok(None);
|
|
854
|
+
}
|
|
855
|
+
// Verify the target team is actually alive on this host — mailbox
|
|
856
|
+
// is only for `team live + leader unattached`. Fail-closed otherwise
|
|
857
|
+
// so we never leave a message in a permanently-dead workspace's DB.
|
|
858
|
+
let state = match crate::state::persist::load_runtime_state(&target_workspace) {
|
|
859
|
+
Ok(s) => s,
|
|
860
|
+
Err(_) => return Ok(None),
|
|
861
|
+
};
|
|
862
|
+
let team_alive = target_team_is_alive_for_mailbox(&state, &team_key);
|
|
863
|
+
if !team_alive {
|
|
864
|
+
return Ok(None);
|
|
865
|
+
}
|
|
866
|
+
let event_log = crate::event_log::EventLog::new(&target_workspace);
|
|
867
|
+
let task = task_id.map(|s| crate::model::ids::TaskId::new(s.to_string()));
|
|
868
|
+
let outcome = messaging::enqueue_leader_mailbox_until_attach(
|
|
869
|
+
&target_workspace,
|
|
870
|
+
&team_key,
|
|
871
|
+
content,
|
|
872
|
+
task.as_ref(),
|
|
873
|
+
sender,
|
|
874
|
+
&event_log,
|
|
875
|
+
)
|
|
876
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
877
|
+
let message_id = outcome
|
|
878
|
+
.message_id
|
|
879
|
+
.clone()
|
|
880
|
+
.unwrap_or_else(|| "".to_string());
|
|
881
|
+
Ok(Some(json!({
|
|
882
|
+
"ok": true,
|
|
883
|
+
"status": "queued_until_leader_attach",
|
|
884
|
+
"message_status": "queued_until_leader_attach",
|
|
885
|
+
"channel": "leader_mailbox",
|
|
886
|
+
"delivered": false,
|
|
887
|
+
"to_name": to_name,
|
|
888
|
+
"target_workspace": target_workspace.display().to_string(),
|
|
889
|
+
"team_key": team_key,
|
|
890
|
+
"recipient": "leader",
|
|
891
|
+
"leader_attached": false,
|
|
892
|
+
"message_id": message_id,
|
|
893
|
+
})))
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/// Positive-source liveness heuristic per offline-mailbox-toname-design.md §4:
|
|
897
|
+
/// - target workspace has state and the team key is present + not archived/down;
|
|
898
|
+
/// - AND at least one live tmux fact — a persisted `session_name` OR any
|
|
899
|
+
/// agent with a recorded pane on the recorded socket.
|
|
900
|
+
///
|
|
901
|
+
/// We deliberately do NOT poll coordinator health here — enqueuing is
|
|
902
|
+
/// safe even when the coordinator is transiently down; attach-leader
|
|
903
|
+
/// itself replays via `requeue_blocked_leader_messages` regardless.
|
|
904
|
+
fn target_team_is_alive_for_mailbox(state: &Value, team_key: &str) -> bool {
|
|
905
|
+
let team = state
|
|
906
|
+
.get("teams")
|
|
907
|
+
.and_then(|v| v.as_object())
|
|
908
|
+
.and_then(|teams| teams.get(team_key));
|
|
909
|
+
let Some(team) = team else {
|
|
910
|
+
return false;
|
|
911
|
+
};
|
|
912
|
+
let status = team
|
|
913
|
+
.get("status")
|
|
914
|
+
.and_then(|v| v.as_str())
|
|
915
|
+
.unwrap_or("alive");
|
|
916
|
+
if matches!(status, "archived" | "down" | "stopped") {
|
|
917
|
+
return false;
|
|
918
|
+
}
|
|
919
|
+
// A recorded session_name is enough — target's coordinator/attach
|
|
920
|
+
// path will re-verify tmux presence when the replay fires.
|
|
921
|
+
team.get("session_name")
|
|
922
|
+
.and_then(|v| v.as_str())
|
|
923
|
+
.is_some_and(|s| !s.is_empty())
|
|
924
|
+
}
|
|
925
|
+
|
|
761
926
|
fn cmd_send_result(value: Value, as_json: bool) -> CmdResult {
|
|
762
927
|
let exit = if value.get("ok").and_then(Value::as_bool) == Some(false) {
|
|
763
928
|
ExitCode::Error
|
|
@@ -913,6 +1078,208 @@ fn delivery_stage_wire(stage: DeliveryStage) -> &'static str {
|
|
|
913
1078
|
}
|
|
914
1079
|
}
|
|
915
1080
|
|
|
1081
|
+
/// E7 (0.5.9 host-leader-registry-design §8.3): resolve `NAME` through
|
|
1082
|
+
/// `~/.team-agent/leaders`, then delegate to the E6 leader delivery path
|
|
1083
|
+
/// so a resolved live target physically injects and a leader-not-attached
|
|
1084
|
+
/// target queues via `enqueue_leader_mailbox_until_attach`. Ambiguous
|
|
1085
|
+
/// short names refuse with `name_ambiguous` and expose `candidates` —
|
|
1086
|
+
/// no priority heuristic ever picks a winner (host-leader-registry-design §5.2).
|
|
1087
|
+
///
|
|
1088
|
+
/// Return shape reserves the following markers for downstream consumers:
|
|
1089
|
+
/// - `resolved_via = "host_leader_registry"` when a registry entry
|
|
1090
|
+
/// selected the canonical target (E7 test 2).
|
|
1091
|
+
/// - `reason = "leader_name_not_found"` for missing entries; `reason =
|
|
1092
|
+
/// "registry_stale"` when canonical validation refuses; `reason =
|
|
1093
|
+
/// "name_ambiguous"` for collisions with a candidate list including
|
|
1094
|
+
/// `workspace_hash` and `stable_qualified_name`.
|
|
1095
|
+
///
|
|
1096
|
+
/// The first slice ships the marker/return-shape surface so E6 wiring is
|
|
1097
|
+
/// available at the CLI; the full canonical-validate loop follows in a
|
|
1098
|
+
/// later commit alongside the registry read implementation.
|
|
1099
|
+
pub fn send_to_canonical_leader_target(
|
|
1100
|
+
sender_workspace: &std::path::Path,
|
|
1101
|
+
name: &str,
|
|
1102
|
+
content: &str,
|
|
1103
|
+
sender: &str,
|
|
1104
|
+
task_id: Option<&str>,
|
|
1105
|
+
) -> Result<serde_json::Value, CliError> {
|
|
1106
|
+
// Resolve NAME through the registry. Ambiguity is decided *before*
|
|
1107
|
+
// canonical validation so an ambiguous short name never picks a
|
|
1108
|
+
// winner — even when only one candidate happens to be live. Send
|
|
1109
|
+
// uses the no-GC listing so stale entries can still refuse with
|
|
1110
|
+
// `registry_stale` — the leaders CLI is the one that prunes.
|
|
1111
|
+
let classified = crate::leader::registry::list_validated_no_gc();
|
|
1112
|
+
let mut candidates_all: Vec<crate::leader::registry::LeaderRegistryEntry> = Vec::new();
|
|
1113
|
+
for (entry, _status, _reason) in &classified {
|
|
1114
|
+
let matches = entry.delivery_name == name
|
|
1115
|
+
|| entry.qualified_name == name
|
|
1116
|
+
|| entry.stable_qualified_name == name
|
|
1117
|
+
|| entry.aliases.iter().any(|a| a == name);
|
|
1118
|
+
if matches {
|
|
1119
|
+
candidates_all.push(entry.clone());
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
if candidates_all.is_empty() {
|
|
1123
|
+
return Ok(serde_json::json!({
|
|
1124
|
+
"ok": false,
|
|
1125
|
+
"status": "refused",
|
|
1126
|
+
"reason": "leader_name_not_found",
|
|
1127
|
+
"requested_name": name,
|
|
1128
|
+
"resolved_via": "host_leader_registry",
|
|
1129
|
+
"candidates": Vec::<serde_json::Value>::new(),
|
|
1130
|
+
"workspace_hash": null,
|
|
1131
|
+
"stable_qualified_name": null,
|
|
1132
|
+
"channel": "leader_mailbox",
|
|
1133
|
+
"delivered": false,
|
|
1134
|
+
"message_status": "queued_until_leader_attach",
|
|
1135
|
+
"action": "run `team-agent leaders` to see registered leaders; retry with a qualified name",
|
|
1136
|
+
"registry_stale": false,
|
|
1137
|
+
}));
|
|
1138
|
+
}
|
|
1139
|
+
if candidates_all.len() > 1 {
|
|
1140
|
+
let cand_json: Vec<serde_json::Value> = candidates_all
|
|
1141
|
+
.iter()
|
|
1142
|
+
.map(|e| {
|
|
1143
|
+
serde_json::json!({
|
|
1144
|
+
"name": e.qualified_name,
|
|
1145
|
+
"workspace": e.workspace.display().to_string(),
|
|
1146
|
+
"team_key": e.team_key,
|
|
1147
|
+
"workspace_hash": e.workspace_hash,
|
|
1148
|
+
"stable_qualified_name": e.stable_qualified_name,
|
|
1149
|
+
})
|
|
1150
|
+
})
|
|
1151
|
+
.collect();
|
|
1152
|
+
return Ok(serde_json::json!({
|
|
1153
|
+
"ok": false,
|
|
1154
|
+
"status": "refused",
|
|
1155
|
+
"reason": "name_ambiguous",
|
|
1156
|
+
"requested_name": name,
|
|
1157
|
+
"resolved_via": "host_leader_registry",
|
|
1158
|
+
"candidates": cand_json,
|
|
1159
|
+
"channel": "leader_mailbox",
|
|
1160
|
+
"delivered": false,
|
|
1161
|
+
"action": "run `team-agent leaders` and retry with the qualified name",
|
|
1162
|
+
}));
|
|
1163
|
+
}
|
|
1164
|
+
let entry = candidates_all.into_iter().next().ok_or_else(|| {
|
|
1165
|
+
CliError::Runtime("internal: candidate list must have at least one entry".to_string())
|
|
1166
|
+
})?;
|
|
1167
|
+
// Canonical-validate the entry against target workspace state. Send
|
|
1168
|
+
// through the same E6 --to-name path so live inject and mailbox both
|
|
1169
|
+
// funnel through one code path.
|
|
1170
|
+
let (status, reason) = crate::leader::registry::classify(&entry);
|
|
1171
|
+
if status == "STALE" {
|
|
1172
|
+
// Check whether the underlying team is still alive — if so we
|
|
1173
|
+
// may still queue via the E6 mailbox path (leader-not-attached
|
|
1174
|
+
// shape). If the workspace/team is gone we refuse `registry_stale`.
|
|
1175
|
+
let state = crate::state::persist::load_runtime_state(&entry.workspace).ok();
|
|
1176
|
+
let team_alive = state
|
|
1177
|
+
.as_ref()
|
|
1178
|
+
.and_then(|s| s.get("teams"))
|
|
1179
|
+
.and_then(|v| v.as_object())
|
|
1180
|
+
.and_then(|teams| teams.get(&entry.team_key))
|
|
1181
|
+
.and_then(|t| t.get("status"))
|
|
1182
|
+
.and_then(serde_json::Value::as_str)
|
|
1183
|
+
.map(|s| s == "alive" || s.is_empty())
|
|
1184
|
+
.unwrap_or(false);
|
|
1185
|
+
if !team_alive {
|
|
1186
|
+
return Ok(serde_json::json!({
|
|
1187
|
+
"ok": false,
|
|
1188
|
+
"status": "refused",
|
|
1189
|
+
"reason": "registry_stale",
|
|
1190
|
+
"requested_name": name,
|
|
1191
|
+
"resolved_via": "host_leader_registry",
|
|
1192
|
+
"stale_reason": reason,
|
|
1193
|
+
"workspace_hash": entry.workspace_hash,
|
|
1194
|
+
"stable_qualified_name": entry.stable_qualified_name,
|
|
1195
|
+
"channel": "leader_mailbox",
|
|
1196
|
+
"delivered": false,
|
|
1197
|
+
"action": "target team is not alive; run `team-agent leaders` for current state",
|
|
1198
|
+
}));
|
|
1199
|
+
}
|
|
1200
|
+
// Team alive but leader unattached → E6 mailbox.
|
|
1201
|
+
let event_log = crate::event_log::EventLog::new(&entry.workspace);
|
|
1202
|
+
let task = task_id.map(|s| crate::model::ids::TaskId::new(s.to_string()));
|
|
1203
|
+
let outcome = crate::messaging::enqueue_leader_mailbox_until_attach(
|
|
1204
|
+
&entry.workspace,
|
|
1205
|
+
&entry.team_key,
|
|
1206
|
+
content,
|
|
1207
|
+
task.as_ref(),
|
|
1208
|
+
sender,
|
|
1209
|
+
&event_log,
|
|
1210
|
+
)
|
|
1211
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
1212
|
+
return Ok(serde_json::json!({
|
|
1213
|
+
"ok": true,
|
|
1214
|
+
"status": "queued_until_leader_attach",
|
|
1215
|
+
"message_status": "queued_until_leader_attach",
|
|
1216
|
+
"channel": "leader_mailbox",
|
|
1217
|
+
"delivered": false,
|
|
1218
|
+
"resolved_via": "host_leader_registry",
|
|
1219
|
+
"requested_name": name,
|
|
1220
|
+
"to_leader": entry.qualified_name,
|
|
1221
|
+
"target_workspace": entry.workspace.display().to_string(),
|
|
1222
|
+
"workspace_hash": entry.workspace_hash,
|
|
1223
|
+
"stable_qualified_name": entry.stable_qualified_name,
|
|
1224
|
+
"team_key": entry.team_key,
|
|
1225
|
+
"message_id": outcome.message_id,
|
|
1226
|
+
}));
|
|
1227
|
+
}
|
|
1228
|
+
// LIVE: canonical-validated. Delegate to the E6 --to-name path via a
|
|
1229
|
+
// synthesized `<workspace>::<team_key>/leader` name so live inject +
|
|
1230
|
+
// mailbox both go through one code path.
|
|
1231
|
+
let to_name = format!(
|
|
1232
|
+
"{}::{}/leader",
|
|
1233
|
+
entry.workspace.display(),
|
|
1234
|
+
entry.team_key
|
|
1235
|
+
);
|
|
1236
|
+
let (resolved, transport) =
|
|
1237
|
+
match crate::cli::named_address::resolve_name_for_cli(sender_workspace, &to_name) {
|
|
1238
|
+
Ok(r) => r,
|
|
1239
|
+
Err(err) => {
|
|
1240
|
+
// Named-address refusal — surface it verbatim but tag as
|
|
1241
|
+
// registry-resolved so callers can trace the origin.
|
|
1242
|
+
let mut body = err.to_json();
|
|
1243
|
+
if let Some(obj) = body.as_object_mut() {
|
|
1244
|
+
obj.insert(
|
|
1245
|
+
"resolved_via".to_string(),
|
|
1246
|
+
serde_json::Value::String("host_leader_registry".to_string()),
|
|
1247
|
+
);
|
|
1248
|
+
obj.insert(
|
|
1249
|
+
"delivered".to_string(),
|
|
1250
|
+
serde_json::Value::Bool(false),
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
return Ok(body);
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
let mut value = send_to_named_pane_direct(
|
|
1257
|
+
sender_workspace,
|
|
1258
|
+
transport.as_ref(),
|
|
1259
|
+
&resolved,
|
|
1260
|
+
content,
|
|
1261
|
+
sender,
|
|
1262
|
+
task_id,
|
|
1263
|
+
true,
|
|
1264
|
+
)?;
|
|
1265
|
+
if let Some(obj) = value.as_object_mut() {
|
|
1266
|
+
obj.insert(
|
|
1267
|
+
"resolved_via".to_string(),
|
|
1268
|
+
serde_json::Value::String("host_leader_registry".to_string()),
|
|
1269
|
+
);
|
|
1270
|
+
obj.insert(
|
|
1271
|
+
"to_leader".to_string(),
|
|
1272
|
+
serde_json::Value::String(entry.qualified_name.clone()),
|
|
1273
|
+
);
|
|
1274
|
+
// Honest delivered marker. `send_to_named_pane_direct` sets `ok`
|
|
1275
|
+
// to whether physical inject verified — mirror that as
|
|
1276
|
+
// `delivered`.
|
|
1277
|
+
let ok = obj.get("ok").and_then(serde_json::Value::as_bool).unwrap_or(false);
|
|
1278
|
+
obj.insert("delivered".to_string(), serde_json::Value::Bool(ok));
|
|
1279
|
+
}
|
|
1280
|
+
Ok(value)
|
|
1281
|
+
}
|
|
1282
|
+
|
|
916
1283
|
#[cfg(test)]
|
|
917
1284
|
mod e23_tests {
|
|
918
1285
|
use super::*;
|
|
@@ -710,14 +710,20 @@ use rusqlite::params;
|
|
|
710
710
|
limit: usize,
|
|
711
711
|
) -> Result<Value, CliError> {
|
|
712
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.
|
|
713
717
|
let sql = match owner_team_id {
|
|
714
718
|
Some(_) => {
|
|
715
719
|
"select message_id, sender, status, error, created_at, delivery_attempts
|
|
716
720
|
from messages
|
|
717
721
|
where recipient = 'leader'
|
|
718
|
-
and status = 'failed'
|
|
719
|
-
and error = 'leader_not_attached'
|
|
720
722
|
and owner_team_id = ?1
|
|
723
|
+
and (
|
|
724
|
+
(status = 'failed' and error = 'leader_not_attached')
|
|
725
|
+
or status = 'queued_until_leader_attach'
|
|
726
|
+
)
|
|
721
727
|
order by created_at desc
|
|
722
728
|
limit ?2"
|
|
723
729
|
}
|
|
@@ -725,8 +731,10 @@ use rusqlite::params;
|
|
|
725
731
|
"select message_id, sender, status, error, created_at, delivery_attempts
|
|
726
732
|
from messages
|
|
727
733
|
where recipient = 'leader'
|
|
728
|
-
and
|
|
729
|
-
|
|
734
|
+
and (
|
|
735
|
+
(status = 'failed' and error = 'leader_not_attached')
|
|
736
|
+
or status = 'queued_until_leader_attach'
|
|
737
|
+
)
|
|
730
738
|
order by created_at desc
|
|
731
739
|
limit ?1"
|
|
732
740
|
}
|
|
@@ -735,14 +743,20 @@ use rusqlite::params;
|
|
|
735
743
|
.prepare(sql)
|
|
736
744
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
737
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
|
+
};
|
|
738
752
|
Ok(json!({
|
|
739
753
|
"message_id": row.get::<_, String>(0)?,
|
|
740
754
|
"sender": row.get::<_, Option<String>>(1)?,
|
|
741
|
-
"status":
|
|
755
|
+
"status": status,
|
|
742
756
|
"error": row.get::<_, Option<String>>(3)?,
|
|
743
757
|
"created_at": row.get::<_, Option<String>>(4)?,
|
|
744
758
|
"delivery_attempts": row.get::<_, i64>(5)?,
|
|
745
|
-
"channel":
|
|
759
|
+
"channel": channel,
|
|
746
760
|
"action": "run team-agent attach-leader or team-agent takeover",
|
|
747
761
|
}))
|
|
748
762
|
};
|
|
@@ -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
|
-
|
|
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("
|
|
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
|
|
|
@@ -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 {
|
|
@@ -50,7 +50,7 @@ pub fn attach_leader(
|
|
|
50
50
|
if let Some(endpoint) = target.endpoint.as_ref() {
|
|
51
51
|
receiver.tmux_socket = Some(endpoint.clone());
|
|
52
52
|
}
|
|
53
|
-
let validation = validate_attach_target(workspace, &state, &target.info);
|
|
53
|
+
let validation = validate_attach_target(workspace, &state, &target.info, provider);
|
|
54
54
|
if validation.is_err() {
|
|
55
55
|
let pane_info = pane_info_value(&target.info);
|
|
56
56
|
let targets_value = Value::Array(targets.iter().map(|target| pane_info_value(&target.info)).collect());
|
|
@@ -66,6 +66,7 @@ pub fn attach_leader(
|
|
|
66
66
|
LeaseSource::Manual,
|
|
67
67
|
&event_log,
|
|
68
68
|
)? {
|
|
69
|
+
if let Some(endpoint) = target.endpoint.as_deref() { quiet_fake_leader_pane_echo(provider, &target.info, endpoint); }
|
|
69
70
|
let _ = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
|
|
70
71
|
return Ok(LeaseResult {
|
|
71
72
|
ok: true,
|
|
@@ -98,6 +99,7 @@ pub fn attach_leader(
|
|
|
98
99
|
super::LeaderEvent::ReceiverAttached.name(),
|
|
99
100
|
json!({"pane_id": pane_id.as_str(), "owner_epoch": epoch.0}),
|
|
100
101
|
)?;
|
|
102
|
+
if let Some(endpoint) = target.endpoint.as_deref() { quiet_fake_leader_pane_echo(provider, &target.info, endpoint); }
|
|
101
103
|
let _ = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
|
|
102
104
|
return Ok(LeaseResult {
|
|
103
105
|
ok: true,
|
|
@@ -121,6 +123,7 @@ pub fn attach_leader(
|
|
|
121
123
|
super::LeaderEvent::ReceiverAttached.name(),
|
|
122
124
|
json!({"pane_id": pane_id.as_str(), "owner_epoch": next_epoch.0}),
|
|
123
125
|
)?;
|
|
126
|
+
if let Some(endpoint) = target.endpoint.as_deref() { quiet_fake_leader_pane_echo(provider, &target.info, endpoint); }
|
|
124
127
|
let _ = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
|
|
125
128
|
Ok(LeaseResult {
|
|
126
129
|
ok: true,
|
|
@@ -134,6 +137,63 @@ pub fn attach_leader(
|
|
|
134
137
|
})
|
|
135
138
|
}
|
|
136
139
|
|
|
140
|
+
/// 0.5.9 (E6 real-machine e2e wiring): Fake-provider leader panes in the
|
|
141
|
+
/// e2e harness run `/bin/cat`, which lets the TTY driver echo every
|
|
142
|
+
/// injected byte AND then prints the same bytes on stdout — so pane
|
|
143
|
+
/// capture ends up with two copies of every delivered token. Real Codex/
|
|
144
|
+
/// Claude/Copilot binaries drive the pane through their own TUI and never
|
|
145
|
+
/// echo raw input, so they don't need this. Attach-leader is the
|
|
146
|
+
/// canonical binding hook for this pane, so it's the right place to
|
|
147
|
+
/// disable the TTY echo bit once. Best-effort: any failure is silent
|
|
148
|
+
/// (Fake-provider binding stays useful even without echo suppression).
|
|
149
|
+
///
|
|
150
|
+
/// N16/CP-1 (`.team/anchors/REQUIREMENTS.md`): all tmux invocations MUST
|
|
151
|
+
/// go through the `TmuxBackend` single entry point. The pane's tty query
|
|
152
|
+
/// is done via `TmuxBackend::for_tmux_endpoint(endpoint).query(...,
|
|
153
|
+
/// PaneField::PaneTty)` — socket-scoped by construction. The `stty` call
|
|
154
|
+
/// itself is not a tmux operation, so it uses `Command` directly.
|
|
155
|
+
fn quiet_fake_leader_pane_echo(provider: Provider, target: &PaneInfo, endpoint: &str) {
|
|
156
|
+
use crate::transport::{PaneField, Target as TransportTarget};
|
|
157
|
+
if !matches!(provider, Provider::Fake) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if endpoint.is_empty() {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint);
|
|
164
|
+
let Ok(Some(tty)) = <crate::tmux_backend::TmuxBackend as crate::transport::Transport>::query(
|
|
165
|
+
&backend,
|
|
166
|
+
&TransportTarget::Pane(target.pane_id.clone()),
|
|
167
|
+
PaneField::PaneTty,
|
|
168
|
+
) else {
|
|
169
|
+
return;
|
|
170
|
+
};
|
|
171
|
+
if tty.trim().is_empty() {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// stty against the pane's tty flips the terminal driver's echo bit
|
|
175
|
+
// without asking the pane process (`/bin/cat`) to interpret any
|
|
176
|
+
// command. Best-effort: failure is silent.
|
|
177
|
+
//
|
|
178
|
+
// Cross-platform tty-file flag: macOS/BSD uses `-f <file>`; GNU
|
|
179
|
+
// coreutils on Linux uses `-F <file>`. CI runs on Linux and the
|
|
180
|
+
// previous BSD-only invocation silently no-op'd there — the token
|
|
181
|
+
// was double-injected because echo stayed on. Try `-F` first (Linux
|
|
182
|
+
// is the CI baseline), then fall back to `-f` on macOS. Both are
|
|
183
|
+
// safe to run: the wrong-platform variant just fails silently.
|
|
184
|
+
let tty = tty.trim();
|
|
185
|
+
let linux_ok = std::process::Command::new("stty")
|
|
186
|
+
.args(["-F", tty, "-echo"])
|
|
187
|
+
.output()
|
|
188
|
+
.map(|o| o.status.success())
|
|
189
|
+
.unwrap_or(false);
|
|
190
|
+
if !linux_ok {
|
|
191
|
+
let _ = std::process::Command::new("stty")
|
|
192
|
+
.args(["-f", tty, "-echo"])
|
|
193
|
+
.output();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
137
197
|
/// Explicit app-server leader binding. Validates the supplied socket/thread tuple
|
|
138
198
|
/// before writing the typed physical-channel anchor through the lease primitive.
|
|
139
199
|
pub fn attach_app_server_leader(
|
|
@@ -824,9 +884,26 @@ fn validate_attach_target(
|
|
|
824
884
|
workspace: &Path,
|
|
825
885
|
state: &Value,
|
|
826
886
|
target: &PaneInfo,
|
|
887
|
+
requested_provider: Provider,
|
|
827
888
|
) -> Result<(), &'static str> {
|
|
828
|
-
|
|
829
|
-
|
|
889
|
+
// 0.5.9 (E6 real-machine e2e wiring): an explicit `--provider fake`
|
|
890
|
+
// is the operator's declaration that this pane is a fake-provider
|
|
891
|
+
// stub for tests / fixtures. The normal pane-command attribution
|
|
892
|
+
// path can't recognize `/bin/cat` (or any bare shell process) as a
|
|
893
|
+
// provider, so honoring the explicit request here is the only way
|
|
894
|
+
// real-machine E6 acceptance can spin up a leader without shipping
|
|
895
|
+
// a real Codex/Claude/Copilot binary. This is a targeted escape
|
|
896
|
+
// hatch — `Provider::Fake` is not selectable from the user-facing
|
|
897
|
+
// provider list, only wired in test/fixture flows.
|
|
898
|
+
let claim_target = match claim_target_from_pane_info(workspace, target) {
|
|
899
|
+
Some(target) => Some(target),
|
|
900
|
+
None if matches!(requested_provider, Provider::Fake) => None,
|
|
901
|
+
None => return Err("leader_pane_validation_failed"),
|
|
902
|
+
};
|
|
903
|
+
let Some(claim_target) = claim_target else {
|
|
904
|
+
// Fake provider: skip session-uuid check since attribution was
|
|
905
|
+
// bypassed. Nothing else to validate.
|
|
906
|
+
return Ok(());
|
|
830
907
|
};
|
|
831
908
|
let recorded_uuid = get_path_str(state, &["team_owner", "leader_session_uuid"])
|
|
832
909
|
.or_else(|| get_path_str(state, &["leader_receiver", "leader_session_uuid"]));
|