@team-agent/installer 0.5.49 → 0.5.51
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/adapters.rs +6 -4
- package/crates/team-agent/src/cli/emit.rs +91 -39
- package/crates/team-agent/src/cli/mod.rs +33 -15
- package/crates/team-agent/src/cli/named_address.rs +82 -53
- package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
- package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
- package/crates/team-agent/src/cli/send/persist.rs +154 -0
- package/crates/team-agent/src/cli/send/presentation.rs +333 -0
- package/crates/team-agent/src/cli/send/resolve.rs +361 -0
- package/crates/team-agent/src/cli/send.rs +103 -1308
- package/crates/team-agent/src/cli/spec.rs +2 -2
- package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
- package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
- package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
- package/crates/team-agent/src/cli/status_port/format.rs +145 -0
- package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
- package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
- package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
- package/crates/team-agent/src/cli/status_port/store.rs +412 -0
- package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
- package/crates/team-agent/src/cli/status_port.rs +47 -1548
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -1
- package/crates/team-agent/src/cli/tests/named_address.rs +9 -7
- package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -3
- package/crates/team-agent/src/cli/tests/status_send.rs +17 -33
- package/crates/team-agent/src/cli/types.rs +5 -8
- package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
- package/crates/team-agent/src/coordinator/tick.rs +37 -0
- package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
- package/crates/team-agent/src/db/message_store.rs +154 -44
- package/crates/team-agent/src/event_log.rs +73 -0
- package/crates/team-agent/src/leader/start.rs +28 -4
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +297 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
- package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
- package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
- package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
- package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
- package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
- package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
- package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -26
- package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +67 -26
- package/crates/team-agent/src/lifecycle/restart/remove.rs +435 -72
- package/crates/team-agent/src/lifecycle/restart.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +575 -17
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +55 -2
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -1
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
- package/crates/team-agent/src/mcp_server/mod.rs +2 -2
- package/crates/team-agent/src/mcp_server/tests/send.rs +22 -15
- package/crates/team-agent/src/mcp_server/tests/wire.rs +6 -0
- package/crates/team-agent/src/mcp_server/tools.rs +26 -15
- package/crates/team-agent/src/mcp_server/wire.rs +2 -18
- package/crates/team-agent/src/messaging/activity.rs +4 -2
- package/crates/team-agent/src/messaging/address.rs +86 -0
- package/crates/team-agent/src/messaging/delivery.rs +165 -39
- package/crates/team-agent/src/messaging/helpers.rs +17 -13
- package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
- package/crates/team-agent/src/messaging/mod.rs +11 -2
- package/crates/team-agent/src/messaging/persist.rs +309 -0
- package/crates/team-agent/src/messaging/results.rs +16 -24
- package/crates/team-agent/src/messaging/scheduler.rs +4 -2
- package/crates/team-agent/src/messaging/selftest.rs +19 -12
- package/crates/team-agent/src/messaging/send.rs +133 -58
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
- package/crates/team-agent/src/messaging/watchers.rs +13 -3
- package/crates/team-agent/src/redaction.rs +72 -2
- package/crates/team-agent/src/state/persist.rs +2 -1
- package/crates/team-agent/src/state/repository/tests.rs +47 -0
- package/crates/team-agent/src/state/repository.rs +59 -16
- package/package.json +4 -4
|
@@ -4,136 +4,87 @@
|
|
|
4
4
|
use super::*;
|
|
5
5
|
use crate::messaging::{DeliveryOutcome, DeliveryRefusal, DeliveryStage, DeliveryStatus};
|
|
6
6
|
|
|
7
|
+
mod coordinator;
|
|
8
|
+
mod mailbox;
|
|
9
|
+
mod persist;
|
|
10
|
+
mod presentation;
|
|
11
|
+
mod resolve;
|
|
12
|
+
|
|
13
|
+
use coordinator::{
|
|
14
|
+
append_loud_ensure_fields, dirty_topology_refusal_value, loud_ensure_coordinator,
|
|
15
|
+
target_has_known_worker,
|
|
16
|
+
};
|
|
17
|
+
pub use persist::send_options_from_args;
|
|
18
|
+
use persist::{
|
|
19
|
+
initial_delivery_allows_watch, observe_initial_delivery_for_watch, routing_ambiguous_value,
|
|
20
|
+
};
|
|
21
|
+
use presentation::{
|
|
22
|
+
add_send_reminder_if_ok, attach_positional_typo_suggestions, cmd_send_result,
|
|
23
|
+
delivery_outcome_json, watch_notice_json,
|
|
24
|
+
};
|
|
25
|
+
use resolve::{
|
|
26
|
+
decorate_host_leader_alias, logical_to_from_args, resolve_host_leader_alias,
|
|
27
|
+
send_to_logical_to, warn_send_alias,
|
|
28
|
+
};
|
|
29
|
+
|
|
7
30
|
/// `cmd_send`(`commands.py:164`)。解析 target(`--to` fanout / 单 target / `*`)→ [`MessageTarget`],
|
|
8
31
|
/// 拼 [`SendOptions`](no_ack→requires_ack 取反、no_wait→wait_visible 取反等)→ `messaging::send_message`。
|
|
9
32
|
pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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()
|
|
33
|
+
// F1 (0.3.26, cross-team send): --pane <pane_id> direct targeting.
|
|
34
|
+
// Mutually exclusive with target / --to (agent-name routing).
|
|
35
|
+
if let Some(ref pane_id) = args.pane {
|
|
36
|
+
warn_send_alias("--pane");
|
|
37
|
+
if args.target.is_some()
|
|
21
38
|
|| args.targets.is_some()
|
|
39
|
+
|| args.to_name.is_some()
|
|
40
|
+
|| args.to_leader.is_some()
|
|
22
41
|
{
|
|
23
|
-
|
|
24
|
-
"--to-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
42
|
+
let message = if args.to_name.is_some() {
|
|
43
|
+
"--to-name and --pane/TARGET/--to are mutually exclusive"
|
|
44
|
+
} else {
|
|
45
|
+
"--pane and TARGET/--to are mutually exclusive; --pane also conflicts with --to-leader"
|
|
46
|
+
};
|
|
47
|
+
return Err(CliError::Usage(message.to_string()));
|
|
28
48
|
}
|
|
29
49
|
let content = args.message.join(" ");
|
|
30
50
|
if content.is_empty() {
|
|
31
51
|
return Err(CliError::Usage(
|
|
32
|
-
"--
|
|
52
|
+
"--pane requires a non-empty message".to_string(),
|
|
33
53
|
));
|
|
34
54
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
&content,
|
|
39
|
-
&args.sender,
|
|
40
|
-
args.task.as_deref(),
|
|
41
|
-
)?;
|
|
42
|
-
return Ok(cmd_send_result(value, args.json));
|
|
55
|
+
return Err(CliError::Usage(format!(
|
|
56
|
+
"--pane {pane_id} is deprecated and sunset; use a logical TARGET so the message is persisted before delivery"
|
|
57
|
+
)));
|
|
43
58
|
}
|
|
44
|
-
if
|
|
45
|
-
|
|
46
|
-
return Err(CliError::Usage(
|
|
47
|
-
"--to-name and --pane/TARGET/--to are mutually exclusive: \
|
|
48
|
-
--to-name resolves a stable workspace/team/name to a live pane"
|
|
49
|
-
.to_string(),
|
|
50
|
-
));
|
|
51
|
-
}
|
|
52
|
-
let content = args.message.join(" ");
|
|
53
|
-
if content.is_empty() {
|
|
54
|
-
return Err(CliError::Usage(
|
|
55
|
-
"--to-name requires a non-empty message".to_string(),
|
|
56
|
-
));
|
|
57
|
-
}
|
|
58
|
-
// 0.5.45 naming-addressing (design §3.1, RED-1): thread
|
|
59
|
-
// `--team` down to resolver so bare `--to-name agent --team T`
|
|
60
|
-
// scopes to `T` BEFORE workspace scanning. Qualified addresses
|
|
61
|
-
// (`team/agent`, `workspace::team/agent`) ignore the scope
|
|
62
|
-
// per §1 priority ladder.
|
|
63
|
-
let (resolved, transport) =
|
|
64
|
-
match crate::cli::named_address::resolve_name_for_cli(
|
|
65
|
-
&args.workspace,
|
|
66
|
-
to_name,
|
|
67
|
-
args.team.as_deref(),
|
|
68
|
-
) {
|
|
69
|
-
Ok(resolved) => resolved,
|
|
70
|
-
Err(error) => {
|
|
71
|
-
// E6 (0.5.9 offline-mailbox-toname-design §3.1/§6.2): when
|
|
72
|
-
// the resolver refuses with `leader_not_attached`, the team
|
|
73
|
-
// itself may still be alive (worker + coordinator running
|
|
74
|
-
// without a bound leader). Third-party senders in that
|
|
75
|
-
// shape must land in the offline mailbox — same
|
|
76
|
-
// canonical team.db row + queued_until_leader_attach
|
|
77
|
-
// status the coordinator/attach hook replay through the
|
|
78
|
-
// existing pipeline exactly once. Owner-scope refusals
|
|
79
|
-
// (same workspace as target) keep the actionable attach
|
|
80
|
-
// hint — E6 owner copy is documented as
|
|
81
|
-
// `run team-agent attach-leader`.
|
|
82
|
-
if let Some(mut value) = maybe_enqueue_offline_leader_mailbox(
|
|
83
|
-
&args.workspace,
|
|
84
|
-
to_name,
|
|
85
|
-
&content,
|
|
86
|
-
&args.sender,
|
|
87
|
-
args.task.as_deref(),
|
|
88
|
-
&error,
|
|
89
|
-
)? {
|
|
90
|
-
add_send_reminder_if_ok(&mut value);
|
|
91
|
-
return Ok(cmd_send_result(value, args.json));
|
|
92
|
-
}
|
|
93
|
-
if args.json {
|
|
94
|
-
return Ok(CmdResult::from_json(error.to_json(), args.json));
|
|
95
|
-
}
|
|
96
|
-
return Err(CliError::Usage(error.n38_message()));
|
|
97
|
-
}
|
|
98
|
-
};
|
|
99
|
-
let mut value = send_to_named_pane_direct(
|
|
100
|
-
&args.workspace,
|
|
101
|
-
transport.as_ref(),
|
|
102
|
-
&resolved,
|
|
103
|
-
&content,
|
|
104
|
-
&args.sender,
|
|
105
|
-
args.task.as_deref(),
|
|
106
|
-
args.json,
|
|
107
|
-
)?;
|
|
108
|
-
add_send_reminder_if_ok(&mut value);
|
|
109
|
-
return Ok(cmd_send_result(value, args.json));
|
|
59
|
+
if args.targets.is_some() {
|
|
60
|
+
warn_send_alias("--targets");
|
|
110
61
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
))
|
|
62
|
+
if args.to_name.is_some() {
|
|
63
|
+
warn_send_alias("--to-name");
|
|
64
|
+
}
|
|
65
|
+
if args.to_leader.is_some() {
|
|
66
|
+
warn_send_alias("--to-leader");
|
|
67
|
+
}
|
|
68
|
+
let host_leader_alias = if let Some(name) = args.to_leader.as_deref() {
|
|
69
|
+
match resolve_host_leader_alias(name) {
|
|
70
|
+
Ok(resolved) => Some(resolved),
|
|
71
|
+
Err(value) => return Ok(cmd_send_result(value, args.json)),
|
|
121
72
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
73
|
+
} else {
|
|
74
|
+
None
|
|
75
|
+
};
|
|
76
|
+
let logical_to = logical_to_from_args(
|
|
77
|
+
args,
|
|
78
|
+
host_leader_alias
|
|
79
|
+
.as_ref()
|
|
80
|
+
.map(|(logical_to, _)| logical_to.as_str()),
|
|
81
|
+
)?;
|
|
82
|
+
let content = args.message.join(" ");
|
|
83
|
+
if !logical_to.is_empty() && logical_to != "*" && !content.is_empty() {
|
|
84
|
+
let mut value = send_to_logical_to(args, &logical_to, &content)?;
|
|
85
|
+
if let Some((_, entry)) = host_leader_alias.as_ref() {
|
|
86
|
+
decorate_host_leader_alias(&mut value, entry);
|
|
127
87
|
}
|
|
128
|
-
let mut value = send_to_pane_direct(
|
|
129
|
-
&args.workspace,
|
|
130
|
-
pane_id,
|
|
131
|
-
&content,
|
|
132
|
-
&args.sender,
|
|
133
|
-
args.task.as_deref(),
|
|
134
|
-
args.team.as_deref(),
|
|
135
|
-
args.json,
|
|
136
|
-
)?;
|
|
137
88
|
add_send_reminder_if_ok(&mut value);
|
|
138
89
|
return Ok(cmd_send_result(value, args.json));
|
|
139
90
|
}
|
|
@@ -142,13 +93,12 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
|
142
93
|
args.team.as_deref(),
|
|
143
94
|
crate::state::selector::SelectorMode::RuntimeOnly,
|
|
144
95
|
)?;
|
|
145
|
-
let target = send_target(
|
|
96
|
+
let target = send_target(None, Some(logical_to.as_str()));
|
|
146
97
|
let mut opts = send_options_from_args(args);
|
|
147
98
|
// `args.team` is a selector and may be a legacy session/team-dir alias.
|
|
148
99
|
// All downstream membership and DB scope must use the canonical key that
|
|
149
100
|
// resolve_active_team returned, never the original selector spelling.
|
|
150
101
|
opts.team = Some(TeamKey::new(selected.team_key.clone()));
|
|
151
|
-
let content = args.message.join(" ");
|
|
152
102
|
// CR-061/N27 routing-ambiguous: a single positional with no `--to`/`--targets` and an
|
|
153
103
|
// empty message body is a prompt-only invocation (`team-agent send "fix the build"`).
|
|
154
104
|
// The lone positional is CONTENT, not a target — reject with `routing_ambiguous`
|
|
@@ -161,16 +111,12 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
|
161
111
|
if let Some(value) = dirty_topology_refusal_value(&selected, args.team.as_deref()) {
|
|
162
112
|
return Ok(cmd_send_result(value, args.json));
|
|
163
113
|
}
|
|
164
|
-
let coordinator_ensure =
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
coordinator_ensure_unavailable_value(coordinator_ensure.as_ref(), &target, &content, &opts)
|
|
171
|
-
{
|
|
172
|
-
return Ok(cmd_send_result(value, args.json));
|
|
173
|
-
}
|
|
114
|
+
let coordinator_ensure =
|
|
115
|
+
if target_has_known_worker(&selected.state, &target, opts.sender.as_str()) {
|
|
116
|
+
loud_ensure_coordinator(&selected)?
|
|
117
|
+
} else {
|
|
118
|
+
None
|
|
119
|
+
};
|
|
174
120
|
let mut outcome = messaging::send_message(&selected.run_workspace, &target, &content, &opts)?;
|
|
175
121
|
if opts.watch_result {
|
|
176
122
|
outcome = observe_initial_delivery_for_watch(&selected, &target, &outcome, &opts)?;
|
|
@@ -195,545 +141,41 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
|
195
141
|
Ok(cmd_send_result(value, args.json))
|
|
196
142
|
}
|
|
197
143
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if issue_ids.is_empty() {
|
|
204
|
-
return None;
|
|
205
|
-
}
|
|
206
|
-
let session_name = selected
|
|
207
|
-
.state
|
|
208
|
-
.get("session_name")
|
|
209
|
-
.and_then(Value::as_str)
|
|
210
|
-
.unwrap_or_default()
|
|
211
|
-
.to_string();
|
|
212
|
-
let reason = issue_ids
|
|
213
|
-
.first()
|
|
214
|
-
.cloned()
|
|
215
|
-
.unwrap_or_else(|| "dirty_topology".to_string());
|
|
216
|
-
let repair_team = requested_team
|
|
217
|
-
.filter(|team| !team.is_empty())
|
|
218
|
-
.unwrap_or(selected.team_key.as_str());
|
|
219
|
-
Some(json!({
|
|
220
|
-
"ok": false,
|
|
221
|
-
"status": "refused_dirty_topology",
|
|
222
|
-
"reason": reason,
|
|
223
|
-
"session_name": session_name,
|
|
224
|
-
"error": "send refused: tmux endpoint/socket topology is inconsistent; run diagnose from the intended leader socket before sending",
|
|
225
|
-
"issues": issue_ids
|
|
226
|
-
.iter()
|
|
227
|
-
.map(|id| json!({"id": id}))
|
|
228
|
-
.collect::<Vec<_>>(),
|
|
229
|
-
"next_actions": [
|
|
230
|
-
"team-agent diagnose --json",
|
|
231
|
-
format!("team-agent claim-leader --team {repair_team} --confirm --json"),
|
|
232
|
-
format!("team-agent takeover --team {repair_team} --confirm --json")
|
|
233
|
-
],
|
|
234
|
-
}))
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
fn target_has_known_worker(state: &Value, target: &MessageTarget, sender: &str) -> bool {
|
|
238
|
-
let Some(agents) = state.get("agents").and_then(Value::as_object) else {
|
|
239
|
-
return false;
|
|
240
|
-
};
|
|
241
|
-
match target {
|
|
242
|
-
MessageTarget::Single(target) => agents.contains_key(target),
|
|
243
|
-
MessageTarget::Broadcast => agents.keys().any(|agent| agent != sender),
|
|
244
|
-
MessageTarget::Fanout(recipients) => recipients
|
|
245
|
-
.iter()
|
|
246
|
-
.any(|recipient| agents.contains_key(recipient)),
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
#[derive(Debug, Clone)]
|
|
251
|
-
struct LoudEnsureResult {
|
|
252
|
-
previous_status: String,
|
|
253
|
-
start: crate::coordinator::StartReport,
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
fn loud_ensure_coordinator(
|
|
257
|
-
selected: &crate::state::selector::SelectedTeam,
|
|
258
|
-
) -> Result<Option<LoudEnsureResult>, CliError> {
|
|
259
|
-
if in_process_unit_test() {
|
|
260
|
-
return Ok(None);
|
|
261
|
-
}
|
|
262
|
-
let workspace = crate::coordinator::WorkspacePath::new(selected.run_workspace.clone());
|
|
263
|
-
let previous = crate::coordinator::coordinator_health(&workspace);
|
|
264
|
-
if previous.ok {
|
|
265
|
-
return Ok(None);
|
|
266
|
-
}
|
|
267
|
-
if previous.service_available
|
|
268
|
-
&& matches!(
|
|
269
|
-
previous.binary_identity_relation,
|
|
270
|
-
crate::coordinator::CoordinatorBinaryIdentityRelation::DaemonNewerThanCaller
|
|
271
|
-
)
|
|
272
|
-
{
|
|
273
|
-
return Ok(None);
|
|
274
|
-
}
|
|
275
|
-
let previous_status = coordinator_health_status_wire(previous.status).to_string();
|
|
276
|
-
let start = crate::coordinator::start_coordinator_with_team(
|
|
277
|
-
&workspace,
|
|
278
|
-
Some(selected.team_key.as_str()),
|
|
279
|
-
)
|
|
280
|
-
.map_err(|error| CliError::Runtime(error.to_string()))?;
|
|
281
|
-
if !start.ok {
|
|
282
|
-
return Ok(Some(LoudEnsureResult {
|
|
283
|
-
previous_status,
|
|
284
|
-
start,
|
|
285
|
-
}));
|
|
286
|
-
}
|
|
287
|
-
if matches!(
|
|
288
|
-
start.status,
|
|
289
|
-
crate::coordinator::StartOutcome::Started
|
|
290
|
-
| crate::coordinator::StartOutcome::StartedAfterRotation
|
|
291
|
-
) {
|
|
292
|
-
crate::event_log::EventLog::new(&selected.run_workspace)
|
|
293
|
-
.write(
|
|
294
|
-
"coordinator.ensure_restarted",
|
|
295
|
-
json!({
|
|
296
|
-
"coordinator_previous_status": previous_status,
|
|
297
|
-
"status": start.status,
|
|
298
|
-
"pid": start.pid.map(|pid| pid.get()),
|
|
299
|
-
"previous_pid": start.previous_pid.map(|pid| pid.get()),
|
|
300
|
-
"binary_path": start.binary_path,
|
|
301
|
-
"binary_version": start.binary_version,
|
|
302
|
-
"rotation_reason": start.rotation_reason,
|
|
303
|
-
}),
|
|
304
|
-
)
|
|
305
|
-
.map_err(|error| CliError::Runtime(error.to_string()))?;
|
|
306
|
-
return Ok(Some(LoudEnsureResult {
|
|
307
|
-
previous_status,
|
|
308
|
-
start,
|
|
309
|
-
}));
|
|
310
|
-
}
|
|
311
|
-
Ok(None)
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
#[cfg(test)]
|
|
315
|
-
fn in_process_unit_test() -> bool {
|
|
316
|
-
true
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
#[cfg(not(test))]
|
|
320
|
-
fn in_process_unit_test() -> bool {
|
|
321
|
-
false
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
fn coordinator_ensure_unavailable_value(
|
|
325
|
-
ensure: Option<&LoudEnsureResult>,
|
|
326
|
-
target: &MessageTarget,
|
|
327
|
-
content: &str,
|
|
328
|
-
opts: &SendOptions,
|
|
329
|
-
) -> Option<Value> {
|
|
330
|
-
let ensure = ensure?;
|
|
331
|
-
if ensure.start.ok {
|
|
332
|
-
return None;
|
|
333
|
-
}
|
|
334
|
-
let warning = format!(
|
|
335
|
-
"coordinator is not running; message was not queued for {}. Run `team-agent diagnose` or restart the team before sending again.",
|
|
336
|
-
first_target(target)
|
|
337
|
-
);
|
|
338
|
-
let mut value = json!({
|
|
339
|
-
"ok": false,
|
|
340
|
-
"status": "degraded",
|
|
341
|
-
"delivery_status": "degraded",
|
|
342
|
-
"delivered": false,
|
|
343
|
-
"target": target_json(target),
|
|
344
|
-
"agent_id": first_target(target),
|
|
345
|
-
"content_length_bytes": content.len(),
|
|
346
|
-
"sender": opts.sender,
|
|
347
|
-
"message_id": Value::Null,
|
|
348
|
-
"message_status": "degraded",
|
|
349
|
-
"verification": warning,
|
|
350
|
-
"stage": Value::Null,
|
|
351
|
-
"reason": "coordinator_unavailable",
|
|
352
|
-
"channel": "coordinator_unavailable",
|
|
353
|
-
});
|
|
354
|
-
append_loud_ensure_fields(&mut value, Some(ensure));
|
|
355
|
-
Some(value)
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
fn append_loud_ensure_fields(value: &mut Value, ensure: Option<&LoudEnsureResult>) {
|
|
359
|
-
let Some(ensure) = ensure else {
|
|
360
|
-
return;
|
|
361
|
-
};
|
|
362
|
-
if !ensure.start.ok {
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
if let Some(obj) = value.as_object_mut() {
|
|
366
|
-
obj.insert("coordinator_auto_restarted".to_string(), json!(true));
|
|
367
|
-
obj.insert(
|
|
368
|
-
"coordinator_previous_status".to_string(),
|
|
369
|
-
json!(ensure.previous_status),
|
|
370
|
-
);
|
|
371
|
-
obj.insert(
|
|
372
|
-
"coordinator".to_string(),
|
|
373
|
-
coordinator_start_json(&ensure.start),
|
|
374
|
-
);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
fn coordinator_start_json(report: &crate::coordinator::StartReport) -> Value {
|
|
379
|
-
let summary = crate::lifecycle::CoordinatorStartSummary::from_start_report(report);
|
|
380
|
-
crate::lifecycle::coordinator_start_summary_value(&summary)
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
fn coordinator_health_status_wire(
|
|
384
|
-
status: crate::coordinator::CoordinatorHealthStatus,
|
|
385
|
-
) -> &'static str {
|
|
386
|
-
match status {
|
|
387
|
-
crate::coordinator::CoordinatorHealthStatus::Missing => "missing",
|
|
388
|
-
crate::coordinator::CoordinatorHealthStatus::InvalidPid => "invalid_pid",
|
|
389
|
-
crate::coordinator::CoordinatorHealthStatus::Running => "running",
|
|
390
|
-
crate::coordinator::CoordinatorHealthStatus::Stale => "stale",
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/// F1 (0.3.26): direct pane-id send — bypasses agent-name routing + team
|
|
395
|
-
/// membership check. Constructs `Target::Pane`, renders the message with
|
|
396
|
-
/// the standard protocol block (Team Agent message from sender + token),
|
|
397
|
-
/// injects via the selected team's endpoint-local tmux transport, and
|
|
398
|
-
/// surfaces the inject report as a JSON result.
|
|
399
|
-
///
|
|
400
|
-
/// 0.5.43 debt-sweep (§6.2): the pre-0.5.43 comment overstated the
|
|
401
|
-
/// scope. pane_id is inherently endpoint-local —
|
|
402
|
-
/// `lifecycle_worker_tmux_backend_for_selected_state` below builds the
|
|
403
|
-
/// transport from the SELECTED team's persisted endpoint. For cross-
|
|
404
|
-
/// workspace delivery, use `--to-name` / `--to-leader` instead; there
|
|
405
|
-
/// is intentionally no `--socket` flag.
|
|
406
|
-
fn send_to_pane_direct(
|
|
407
|
-
workspace: &Path,
|
|
408
|
-
pane_id: &str,
|
|
409
|
-
content: &str,
|
|
410
|
-
sender: &str,
|
|
411
|
-
task_id: Option<&str>,
|
|
412
|
-
team: Option<&str>,
|
|
413
|
-
json: bool,
|
|
414
|
-
) -> Result<serde_json::Value, CliError> {
|
|
415
|
-
use crate::messaging::delivery::render_message;
|
|
416
|
-
use crate::transport::{InjectPayload, Key, PaneId, Target};
|
|
417
|
-
|
|
418
|
-
let message_id = format!("pane_send_{}", chrono::Utc::now().timestamp_millis());
|
|
419
|
-
let rendered = render_message(sender, task_id, content, &message_id);
|
|
420
|
-
let target = Target::Pane(PaneId::new(pane_id));
|
|
421
|
-
let run_workspace = crate::model::paths::canonical_run_workspace(workspace)
|
|
422
|
-
.unwrap_or_else(|_| workspace.to_path_buf());
|
|
423
|
-
let transport = crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
|
|
424
|
-
&run_workspace,
|
|
425
|
-
team,
|
|
426
|
-
)
|
|
427
|
-
.unwrap_or_else(|_| crate::tmux_backend::TmuxBackend::for_workspace(&run_workspace));
|
|
428
|
-
let event_log = crate::event_log::EventLog::new(&run_workspace);
|
|
429
|
-
// Warn if the pane is not in the team's known agents (cross-team usage).
|
|
430
|
-
let state = crate::state::persist::load_runtime_state(&run_workspace).ok();
|
|
431
|
-
let in_team = state
|
|
432
|
-
.as_ref()
|
|
433
|
-
.and_then(|s| s.get("agents"))
|
|
434
|
-
.and_then(serde_json::Value::as_object)
|
|
435
|
-
.is_some_and(|agents| {
|
|
436
|
-
agents.values().any(|agent| {
|
|
437
|
-
agent
|
|
438
|
-
.get("pane_id")
|
|
439
|
-
.and_then(serde_json::Value::as_str)
|
|
440
|
-
.is_some_and(|p| p == pane_id)
|
|
441
|
-
})
|
|
442
|
-
});
|
|
443
|
-
if !in_team {
|
|
444
|
-
eprintln!(
|
|
445
|
-
"warning: pane {pane_id} is not in the team's known agents — \
|
|
446
|
-
cross-team delivery (F1)"
|
|
447
|
-
);
|
|
448
|
-
}
|
|
449
|
-
let transport: &dyn crate::transport::Transport = &transport;
|
|
450
|
-
let report = transport
|
|
451
|
-
.inject(&target, &InjectPayload::Text(rendered), Key::Enter, true)
|
|
452
|
-
.map_err(|e| CliError::Runtime(format!("inject to pane {pane_id} failed: {e}")))?;
|
|
453
|
-
let _ = event_log.write(
|
|
454
|
-
"send.pane_direct",
|
|
455
|
-
serde_json::json!({
|
|
456
|
-
"pane_id": pane_id,
|
|
457
|
-
"sender": sender,
|
|
458
|
-
"message_id": message_id,
|
|
459
|
-
"submit_verification": crate::transport::submit_verification_wire(report.submit_verification),
|
|
460
|
-
"inject_verification": format!("{:?}", report.inject_verification),
|
|
461
|
-
"in_team": in_team,
|
|
462
|
-
}),
|
|
463
|
-
);
|
|
464
|
-
let ok = matches!(
|
|
465
|
-
report.submit_verification,
|
|
466
|
-
crate::transport::SubmitVerification::EnterSentWithoutPlaceholderCheck
|
|
467
|
-
| crate::transport::SubmitVerification::PastedContentPromptAbsentAfterSubmit
|
|
468
|
-
| crate::transport::SubmitVerification::KeySentAfterVisibleToken { .. }
|
|
469
|
-
);
|
|
470
|
-
Ok(serde_json::json!({
|
|
471
|
-
"ok": ok,
|
|
472
|
-
"pane_id": pane_id,
|
|
473
|
-
"message_id": message_id,
|
|
474
|
-
"submit_verification": crate::transport::submit_verification_wire(report.submit_verification),
|
|
475
|
-
"inject_verification": format!("{:?}", report.inject_verification),
|
|
476
|
-
"in_team": in_team,
|
|
477
|
-
}))
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
fn send_to_named_pane_direct(
|
|
481
|
-
sender_workspace: &Path,
|
|
482
|
-
transport: &dyn crate::transport::Transport,
|
|
483
|
-
resolved: &crate::cli::named_address::ResolvedNamedAddress,
|
|
144
|
+
/// Resolve a host-leader alias, send through the canonical persisted funnel,
|
|
145
|
+
/// then decorate the already-produced result for the host-leader surface.
|
|
146
|
+
pub fn send_to_canonical_leader_target(
|
|
147
|
+
sender_workspace: &std::path::Path,
|
|
148
|
+
name: &str,
|
|
484
149
|
content: &str,
|
|
485
|
-
sender: &
|
|
150
|
+
sender: &TrustedSender,
|
|
486
151
|
task_id: Option<&str>,
|
|
487
|
-
_json: bool,
|
|
488
152
|
) -> Result<serde_json::Value, CliError> {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
let message_id = format!("named_send_{}", chrono::Utc::now().timestamp_millis());
|
|
493
|
-
let rendered = render_message(sender, task_id, content, &message_id);
|
|
494
|
-
let sender_run_workspace = crate::model::paths::canonical_run_workspace(sender_workspace)
|
|
495
|
-
.unwrap_or_else(|_| sender_workspace.to_path_buf());
|
|
496
|
-
let event_log = crate::event_log::EventLog::new(&sender_run_workspace);
|
|
497
|
-
if let Some(warning) = &resolved.warning {
|
|
498
|
-
eprintln!("warning: {warning}");
|
|
499
|
-
}
|
|
500
|
-
if resolved.transport_kind.as_deref() == Some("codex_app_server") {
|
|
501
|
-
return send_to_named_app_server_leader(
|
|
502
|
-
&event_log,
|
|
503
|
-
resolved,
|
|
504
|
-
&message_id,
|
|
505
|
-
&rendered,
|
|
506
|
-
sender,
|
|
507
|
-
);
|
|
508
|
-
}
|
|
509
|
-
let target = Target::Pane(PaneId::new(&resolved.pane_id));
|
|
510
|
-
let report = transport
|
|
511
|
-
.inject(&target, &InjectPayload::Text(rendered), Key::Enter, true)
|
|
512
|
-
.map_err(|e| {
|
|
513
|
-
CliError::Runtime(format!(
|
|
514
|
-
"inject to named target {} pane {} failed: {e}",
|
|
515
|
-
resolved.raw_name, resolved.pane_id
|
|
516
|
-
))
|
|
517
|
-
})?;
|
|
518
|
-
let target_kind = named_target_kind_wire(resolved.target_kind);
|
|
519
|
-
let event = serde_json::json!({
|
|
520
|
-
"to_name": resolved.raw_name,
|
|
521
|
-
"target_kind": target_kind,
|
|
522
|
-
"sender": sender,
|
|
523
|
-
"sender_workspace": sender_run_workspace.display().to_string(),
|
|
524
|
-
"target_workspace": resolved.target_workspace.display().to_string(),
|
|
525
|
-
"team_key": resolved.team_key,
|
|
526
|
-
"agent_id": resolved.agent_id,
|
|
527
|
-
"pane_id": resolved.pane_id,
|
|
528
|
-
"session_name": resolved.session_name,
|
|
529
|
-
"window_name": resolved.window_name,
|
|
530
|
-
"tmux_endpoint": resolved.tmux_endpoint,
|
|
531
|
-
"state_pane_id": resolved.state_pane_id,
|
|
532
|
-
"state_pane_stale": resolved.state_pane_stale,
|
|
533
|
-
"agent_status": resolved.agent_status,
|
|
534
|
-
"warning": resolved.warning,
|
|
535
|
-
"message_id": message_id,
|
|
536
|
-
"submit_verification": crate::transport::submit_verification_wire(report.submit_verification),
|
|
537
|
-
"inject_verification": format!("{:?}", report.inject_verification),
|
|
538
|
-
});
|
|
539
|
-
let _ = event_log.write("send.name_direct", event.clone());
|
|
540
|
-
let ok = matches!(
|
|
541
|
-
report.submit_verification,
|
|
542
|
-
crate::transport::SubmitVerification::EnterSentWithoutPlaceholderCheck
|
|
543
|
-
| crate::transport::SubmitVerification::PastedContentPromptAbsentAfterSubmit
|
|
544
|
-
| crate::transport::SubmitVerification::KeySentAfterVisibleToken { .. }
|
|
545
|
-
);
|
|
546
|
-
let mut value = event;
|
|
547
|
-
if let Some(obj) = value.as_object_mut() {
|
|
548
|
-
obj.insert("ok".to_string(), serde_json::json!(ok));
|
|
549
|
-
}
|
|
550
|
-
Ok(value)
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
fn send_to_named_app_server_leader(
|
|
554
|
-
event_log: &crate::event_log::EventLog,
|
|
555
|
-
resolved: &crate::cli::named_address::ResolvedNamedAddress,
|
|
556
|
-
message_id: &str,
|
|
557
|
-
rendered: &str,
|
|
558
|
-
sender: &str,
|
|
559
|
-
) -> Result<serde_json::Value, CliError> {
|
|
560
|
-
let receiver = serde_json::json!({
|
|
561
|
-
"mode": "codex_app_server",
|
|
562
|
-
"transport_kind": "codex_app_server",
|
|
563
|
-
"app_server": resolved.app_server.clone().unwrap_or(serde_json::Value::Null),
|
|
564
|
-
});
|
|
565
|
-
let binding = crate::codex_app_server::binding_from_receiver(&receiver)
|
|
566
|
-
.map_err(|error| CliError::Runtime(format!("invalid app-server named leader: {error}")))?;
|
|
567
|
-
let target_kind = named_target_kind_wire(resolved.target_kind);
|
|
568
|
-
let base_event = serde_json::json!({
|
|
569
|
-
"to_name": resolved.raw_name,
|
|
570
|
-
"target_kind": target_kind,
|
|
571
|
-
"sender": sender,
|
|
572
|
-
"sender_workspace": resolved.sender_workspace.display().to_string(),
|
|
573
|
-
"target_workspace": resolved.target_workspace.display().to_string(),
|
|
574
|
-
"team_key": resolved.team_key,
|
|
575
|
-
"agent_id": resolved.agent_id,
|
|
576
|
-
"transport_kind": "codex_app_server",
|
|
577
|
-
"socket": binding.socket,
|
|
578
|
-
"thread_id": binding.thread_id,
|
|
579
|
-
"message_id": message_id,
|
|
580
|
-
});
|
|
581
|
-
match crate::codex_app_server::submit_to_bound_thread(&binding, message_id, rendered) {
|
|
582
|
-
Ok(submit) => {
|
|
583
|
-
let event = merge_json(
|
|
584
|
-
base_event.clone(),
|
|
585
|
-
serde_json::json!({
|
|
586
|
-
"ok": true,
|
|
587
|
-
"turn_id": submit.turn_id,
|
|
588
|
-
"turn_status": submit.turn_status,
|
|
589
|
-
}),
|
|
590
|
-
);
|
|
591
|
-
let _ = event_log.write("send.name_app_server", event.clone());
|
|
592
|
-
Ok(event)
|
|
593
|
-
}
|
|
594
|
-
Err(crate::codex_app_server::AppServerError::LeaderBusy(message)) => {
|
|
595
|
-
let event = merge_json(
|
|
596
|
-
base_event.clone(),
|
|
597
|
-
serde_json::json!({
|
|
598
|
-
"ok": false,
|
|
599
|
-
"status": "retry_scheduled",
|
|
600
|
-
"reason": "leader_busy",
|
|
601
|
-
"channel": "leader_busy",
|
|
602
|
-
"error": message,
|
|
603
|
-
}),
|
|
604
|
-
);
|
|
605
|
-
let _ = event_log.write("send.name_app_server", event.clone());
|
|
606
|
-
Ok(event)
|
|
607
|
-
}
|
|
608
|
-
Err(error) => {
|
|
609
|
-
let event = merge_json(
|
|
610
|
-
base_event.clone(),
|
|
611
|
-
serde_json::json!({
|
|
612
|
-
"ok": false,
|
|
613
|
-
"status": "refused",
|
|
614
|
-
"reason": error.code(),
|
|
615
|
-
"channel": "rebind_required",
|
|
616
|
-
"error": error.to_string(),
|
|
617
|
-
"action": "run team-agent attach-app-server-leader for the target team",
|
|
618
|
-
}),
|
|
619
|
-
);
|
|
620
|
-
let _ = event_log.write("send.name_app_server", event.clone());
|
|
621
|
-
Ok(event)
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
fn merge_json(mut left: serde_json::Value, right: serde_json::Value) -> serde_json::Value {
|
|
627
|
-
if let (Some(left), Some(right)) = (left.as_object_mut(), right.as_object()) {
|
|
628
|
-
for (key, value) in right {
|
|
629
|
-
left.insert(key.clone(), value.clone());
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
left
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
fn named_target_kind_wire(kind: crate::cli::named_address::NamedTargetKind) -> &'static str {
|
|
636
|
-
match kind {
|
|
637
|
-
crate::cli::named_address::NamedTargetKind::Worker => "worker",
|
|
638
|
-
crate::cli::named_address::NamedTargetKind::Leader => "leader",
|
|
639
|
-
crate::cli::named_address::NamedTargetKind::SessionWindow => "session_window",
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
fn routing_ambiguous_value(
|
|
644
|
-
workspace: &Path,
|
|
645
|
-
args: &SendArgs,
|
|
646
|
-
target: &MessageTarget,
|
|
647
|
-
content: &str,
|
|
648
|
-
opts: &SendOptions,
|
|
649
|
-
) -> Option<Value> {
|
|
650
|
-
if args.targets.is_some() || !content.is_empty() {
|
|
651
|
-
return None;
|
|
652
|
-
}
|
|
653
|
-
let MessageTarget::Single(name) = target else {
|
|
654
|
-
return None;
|
|
153
|
+
let (logical_to, entry) = match resolve_host_leader_alias(name) {
|
|
154
|
+
Ok(resolved) => resolved,
|
|
155
|
+
Err(value) => return Ok(value),
|
|
655
156
|
};
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
"agent_id": null,
|
|
675
|
-
"content_length_bytes": name.len(),
|
|
676
|
-
"sender": opts.sender,
|
|
677
|
-
"message_id": null,
|
|
678
|
-
"message_status": "refused",
|
|
679
|
-
"verification": null,
|
|
680
|
-
"stage": null,
|
|
681
|
-
"reason": "routing_ambiguous",
|
|
682
|
-
"channel": null,
|
|
683
|
-
}))
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
fn selected_state_with_active_key(selected: &crate::state::selector::SelectedTeam) -> Value {
|
|
687
|
-
let mut state = selected.state.clone();
|
|
688
|
-
if let Some(obj) = state.as_object_mut() {
|
|
689
|
-
obj.insert(
|
|
690
|
-
"active_team_key".to_string(),
|
|
691
|
-
Value::String(selected.team_key.clone()),
|
|
692
|
-
);
|
|
693
|
-
}
|
|
694
|
-
state
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
fn initial_delivery_allows_watch(status: DeliveryStatus) -> bool {
|
|
698
|
-
matches!(
|
|
699
|
-
status,
|
|
700
|
-
DeliveryStatus::Delivered | DeliveryStatus::AlreadyDelivered
|
|
701
|
-
)
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
fn observe_initial_delivery_for_watch(
|
|
705
|
-
selected: &crate::state::selector::SelectedTeam,
|
|
706
|
-
target: &MessageTarget,
|
|
707
|
-
outcome: &DeliveryOutcome,
|
|
708
|
-
opts: &SendOptions,
|
|
709
|
-
) -> Result<DeliveryOutcome, CliError> {
|
|
710
|
-
if !matches!(target, MessageTarget::Single(agent) if !agent.is_empty()) {
|
|
711
|
-
return Ok(outcome.clone());
|
|
712
|
-
}
|
|
713
|
-
if !matches!(outcome.status, DeliveryStatus::Queued) {
|
|
714
|
-
return Ok(outcome.clone());
|
|
715
|
-
}
|
|
716
|
-
let Some(message_id) = outcome.message_id.as_deref() else {
|
|
717
|
-
return Ok(outcome.clone());
|
|
157
|
+
let args = SendArgs {
|
|
158
|
+
target: Some(logical_to.clone()),
|
|
159
|
+
message: vec![content.to_string()],
|
|
160
|
+
targets: None,
|
|
161
|
+
workspace: sender_workspace.to_path_buf(),
|
|
162
|
+
team: None,
|
|
163
|
+
task: task_id.map(str::to_string),
|
|
164
|
+
sender: sender.clone(),
|
|
165
|
+
no_ack: false,
|
|
166
|
+
no_wait: true,
|
|
167
|
+
watch_result: false,
|
|
168
|
+
timeout: 0.0,
|
|
169
|
+
confirm_human: false,
|
|
170
|
+
json: true,
|
|
171
|
+
message_id: None,
|
|
172
|
+
pane: None,
|
|
173
|
+
to_name: None,
|
|
174
|
+
to_leader: None,
|
|
718
175
|
};
|
|
719
|
-
let
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
&selected.run_workspace,
|
|
723
|
-
opts.team.as_ref().map(TeamKey::as_str),
|
|
724
|
-
)
|
|
725
|
-
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
726
|
-
let event_log = crate::event_log::EventLog::new(&selected.run_workspace);
|
|
727
|
-
let state = selected_state_with_active_key(selected);
|
|
728
|
-
crate::messaging::delivery::deliver_pending_message(
|
|
729
|
-
&selected.run_workspace,
|
|
730
|
-
&store,
|
|
731
|
-
&transport,
|
|
732
|
-
message_id,
|
|
733
|
-
&event_log,
|
|
734
|
-
&state,
|
|
735
|
-
)
|
|
736
|
-
.map_err(CliError::from)
|
|
176
|
+
let mut value = send_to_logical_to(&args, &logical_to, content)?;
|
|
177
|
+
decorate_host_leader_alias(&mut value, &entry);
|
|
178
|
+
Ok(value)
|
|
737
179
|
}
|
|
738
180
|
|
|
739
181
|
/// `_send_target`(`commands.py:181-184`):`--to` comma-split fanout / `target` 单值 / None。
|
|
@@ -753,650 +195,3 @@ pub fn send_target(targets: Option<&str>, target: Option<&str>) -> MessageTarget
|
|
|
753
195
|
None => MessageTarget::Single(String::new()),
|
|
754
196
|
}
|
|
755
197
|
}
|
|
756
|
-
|
|
757
|
-
/// `cmd_send` 的 [`SendArgs`]→[`SendOptions`] 翻译(`commands.py:170-177`)。CLI **独占**的
|
|
758
|
-
/// 旗标取反语义(经典 off-by-inversion bug 面):`no_ack→!requires_ack`、`no_wait→!wait_visible`、
|
|
759
|
-
/// `watch_result` 直传、`task_id`/`sender`/`confirm_human`/`timeout`/`team` 透传。
|
|
760
|
-
/// (其余 `lock_timeout`/`block_until_delivered` 用 [`SendOptions::default`]。)
|
|
761
|
-
pub fn send_options_from_args(args: &SendArgs) -> SendOptions {
|
|
762
|
-
SendOptions {
|
|
763
|
-
task_id: args.task.as_ref().map(|s| TaskId::new(s.clone())),
|
|
764
|
-
route_task_id: true,
|
|
765
|
-
sender: args.sender.clone(),
|
|
766
|
-
requires_ack: !args.no_ack,
|
|
767
|
-
confirm_human: args.confirm_human,
|
|
768
|
-
wait_visible: !args.no_wait,
|
|
769
|
-
timeout: args.timeout,
|
|
770
|
-
watch_result: args.watch_result,
|
|
771
|
-
team: args.team.as_ref().map(|s| TeamKey::new(s.clone())),
|
|
772
|
-
message_id: args.message_id.clone(),
|
|
773
|
-
..SendOptions::default()
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
fn watch_notice_json(target: &MessageTarget, opts: &SendOptions) -> Value {
|
|
778
|
-
let agent_id = match target {
|
|
779
|
-
MessageTarget::Single(agent) => agent.clone(),
|
|
780
|
-
MessageTarget::Broadcast => "*".to_string(),
|
|
781
|
-
MessageTarget::Fanout(recipients) => recipients
|
|
782
|
-
.first()
|
|
783
|
-
.cloned()
|
|
784
|
-
.unwrap_or_else(|| "-".to_string()),
|
|
785
|
-
};
|
|
786
|
-
json!({
|
|
787
|
-
"status": "registered",
|
|
788
|
-
"watcher_id": format!("watch-{agent_id}"),
|
|
789
|
-
"task_id": opts.task_id.as_ref().map(|t| t.as_str().to_string()),
|
|
790
|
-
"agent_id": agent_id,
|
|
791
|
-
"notice": "Team Agent will collect the result and notify the leader when this task reports completion."
|
|
792
|
-
})
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
/// 0.5.45 naming-addressing (design §3.5, RED-2/RED-3 positional):
|
|
796
|
-
/// after `messaging::send_message` refuses with `target_not_in_team`
|
|
797
|
-
/// for a Single non-special short id, attach scope-safe advisory
|
|
798
|
-
/// suggestions to the outbound JSON envelope. Candidate source =
|
|
799
|
-
/// selected team's projected `agents` map (never the raw workspace
|
|
800
|
-
/// `teams`) so sibling teams cannot leak. Zero DB write, zero inject
|
|
801
|
-
/// — the refusal exit code is unchanged.
|
|
802
|
-
fn attach_positional_typo_suggestions(
|
|
803
|
-
value: &mut Value,
|
|
804
|
-
target: &MessageTarget,
|
|
805
|
-
selected_state: &Value,
|
|
806
|
-
) {
|
|
807
|
-
use crate::model::name_similarity::{rank, Candidate};
|
|
808
|
-
let requested = match target {
|
|
809
|
-
MessageTarget::Single(id) if id != "*" && id != "leader" => id.clone(),
|
|
810
|
-
_ => return,
|
|
811
|
-
};
|
|
812
|
-
let Some(obj) = value.as_object_mut() else {
|
|
813
|
-
return;
|
|
814
|
-
};
|
|
815
|
-
if obj.get("reason").and_then(Value::as_str) != Some("target_not_in_team") {
|
|
816
|
-
return;
|
|
817
|
-
}
|
|
818
|
-
let team_key = selected_state
|
|
819
|
-
.get("active_team_key")
|
|
820
|
-
.or_else(|| selected_state.get("team_key"))
|
|
821
|
-
.and_then(Value::as_str)
|
|
822
|
-
.unwrap_or("");
|
|
823
|
-
let candidates: Vec<Candidate<String>> = selected_state
|
|
824
|
-
.get("agents")
|
|
825
|
-
.and_then(Value::as_object)
|
|
826
|
-
.map(|agents| {
|
|
827
|
-
agents
|
|
828
|
-
.keys()
|
|
829
|
-
.map(|agent_id| Candidate {
|
|
830
|
-
match_key: agent_id.clone(),
|
|
831
|
-
stable_key: agent_id.clone(),
|
|
832
|
-
payload: agent_id.clone(),
|
|
833
|
-
})
|
|
834
|
-
.collect()
|
|
835
|
-
})
|
|
836
|
-
.unwrap_or_default();
|
|
837
|
-
let ranked = rank(&requested, &candidates);
|
|
838
|
-
let candidate_values: Vec<Value> = ranked
|
|
839
|
-
.iter()
|
|
840
|
-
.map(|agent_id| {
|
|
841
|
-
json!({
|
|
842
|
-
"name": agent_id,
|
|
843
|
-
"team_key": team_key,
|
|
844
|
-
"agent_id": agent_id,
|
|
845
|
-
"advisory": true,
|
|
846
|
-
})
|
|
847
|
-
})
|
|
848
|
-
.collect();
|
|
849
|
-
obj.insert("requested_name".to_string(), json!(requested));
|
|
850
|
-
if let Some(best) = ranked.first() {
|
|
851
|
-
obj.insert("suggested_name".to_string(), json!(best));
|
|
852
|
-
}
|
|
853
|
-
obj.insert("candidates".to_string(), Value::Array(candidate_values));
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
fn delivery_outcome_json(
|
|
857
|
-
outcome: &DeliveryOutcome,
|
|
858
|
-
target: &MessageTarget,
|
|
859
|
-
content: &str,
|
|
860
|
-
opts: &SendOptions,
|
|
861
|
-
) -> Value {
|
|
862
|
-
// Pre-release 0.4.0 user directive: send result MUST NOT carry the
|
|
863
|
-
// message body — neither in human form (cli/emit.rs) NOR in --json.
|
|
864
|
-
// External consumers who need the message content read it via `inbox`,
|
|
865
|
-
// not from the send response. We surface `content_length_bytes` as a
|
|
866
|
-
// size sanity field so callers can verify the body size they intended
|
|
867
|
-
// to send arrived intact without exposing the body itself.
|
|
868
|
-
let target_wire = target_json(target);
|
|
869
|
-
json!({
|
|
870
|
-
"ok": outcome.ok,
|
|
871
|
-
"status": delivery_status_wire(outcome.status),
|
|
872
|
-
"delivery_status": api_delivery_status(outcome),
|
|
873
|
-
"delivered": delivery_proven(outcome.status),
|
|
874
|
-
"target": target_wire,
|
|
875
|
-
"agent_id": first_target(target),
|
|
876
|
-
"content_length_bytes": content.len(),
|
|
877
|
-
"sender": opts.sender,
|
|
878
|
-
"message_id": outcome.message_id,
|
|
879
|
-
"message_status": outcome.message_status.0,
|
|
880
|
-
"verification": outcome.verification,
|
|
881
|
-
"stage": outcome.stage.map(delivery_stage_wire),
|
|
882
|
-
"reason": outcome.reason.map(delivery_refusal_wire),
|
|
883
|
-
"channel": outcome.channel,
|
|
884
|
-
})
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
fn api_delivery_status(outcome: &DeliveryOutcome) -> &'static str {
|
|
888
|
-
if delivery_proven(outcome.status) {
|
|
889
|
-
return "delivered";
|
|
890
|
-
}
|
|
891
|
-
if matches!(outcome.status, DeliveryStatus::Queued) && outcome.message_status.0 == "accepted" {
|
|
892
|
-
return "pending";
|
|
893
|
-
}
|
|
894
|
-
delivery_status_wire(outcome.status)
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
fn delivery_proven(status: DeliveryStatus) -> bool {
|
|
898
|
-
matches!(
|
|
899
|
-
status,
|
|
900
|
-
DeliveryStatus::Delivered
|
|
901
|
-
| DeliveryStatus::AlreadyDelivered
|
|
902
|
-
| DeliveryStatus::BroadcastDelivered
|
|
903
|
-
| DeliveryStatus::FanoutDelivered
|
|
904
|
-
)
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
fn add_send_reminder_if_ok(value: &mut Value) {
|
|
908
|
-
if value.get("ok").and_then(Value::as_bool) != Some(true) {
|
|
909
|
-
return;
|
|
910
|
-
}
|
|
911
|
-
let reminder = send_reminder_for_value(value);
|
|
912
|
-
if let Some(obj) = value.as_object_mut() {
|
|
913
|
-
obj.insert("reminder".to_string(), json!(reminder));
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
/// E6 (0.5.9 offline-mailbox-toname-design §§3.1/6.2/8, real-machine
|
|
918
|
-
/// escape evidence
|
|
919
|
-
/// `.team/artifacts/0.5.9-subscription-gate.md` +
|
|
920
|
-
/// `.team/evidence/0.5.9-subscription-gate-20260707T143241Z-4645/`):
|
|
921
|
-
/// when the `--to-name <ws>::<team>/leader` resolver refused with
|
|
922
|
-
/// `leader_not_attached`, decide whether the target team is still alive
|
|
923
|
-
/// (worker + coordinator running without a bound leader) and, if so,
|
|
924
|
-
/// enqueue the mailbox row so `attach-leader` replays it exactly once.
|
|
925
|
-
///
|
|
926
|
-
/// Only queues for third-party senders (sender workspace ≠ target
|
|
927
|
-
/// workspace). Owner-scope refusals stay refused so status/diagnose can
|
|
928
|
-
/// keep pushing the operator toward `attach-leader`.
|
|
929
|
-
fn maybe_enqueue_offline_leader_mailbox(
|
|
930
|
-
sender_workspace: &Path,
|
|
931
|
-
to_name: &str,
|
|
932
|
-
content: &str,
|
|
933
|
-
sender: &str,
|
|
934
|
-
task_id: Option<&str>,
|
|
935
|
-
error: &crate::cli::named_address::NamedAddressError,
|
|
936
|
-
) -> Result<Option<Value>, CliError> {
|
|
937
|
-
if error.kind != crate::cli::named_address::NamedAddressErrorKind::LeaderNotAttached {
|
|
938
|
-
return Ok(None);
|
|
939
|
-
}
|
|
940
|
-
let parsed = match crate::cli::named_address::parse_leader_target_workspace_and_team(
|
|
941
|
-
sender_workspace,
|
|
942
|
-
to_name,
|
|
943
|
-
) {
|
|
944
|
-
Ok(Some(v)) => v,
|
|
945
|
-
Ok(None) => return Ok(None),
|
|
946
|
-
Err(_) => return Ok(None),
|
|
947
|
-
};
|
|
948
|
-
let (target_workspace, team_key) = parsed;
|
|
949
|
-
// Owner-scope refusal: sender workspace == target workspace. Keep
|
|
950
|
-
// the actionable attach hint (owner sees status/diagnose copy that
|
|
951
|
-
// points at `attach-leader`).
|
|
952
|
-
let sender_canonical =
|
|
953
|
-
std::fs::canonicalize(sender_workspace).unwrap_or_else(|_| sender_workspace.to_path_buf());
|
|
954
|
-
let target_canonical =
|
|
955
|
-
std::fs::canonicalize(&target_workspace).unwrap_or_else(|_| target_workspace.clone());
|
|
956
|
-
if sender_canonical == target_canonical {
|
|
957
|
-
return Ok(None);
|
|
958
|
-
}
|
|
959
|
-
// Verify the target team is actually alive on this host — mailbox
|
|
960
|
-
// is only for `team live + leader unattached`. Fail-closed otherwise
|
|
961
|
-
// so we never leave a message in a permanently-dead workspace's DB.
|
|
962
|
-
let state = match crate::state::persist::load_runtime_state(&target_workspace) {
|
|
963
|
-
Ok(s) => s,
|
|
964
|
-
Err(_) => return Ok(None),
|
|
965
|
-
};
|
|
966
|
-
let team_alive = target_team_is_alive_for_mailbox(&state, &team_key);
|
|
967
|
-
if !team_alive {
|
|
968
|
-
return Ok(None);
|
|
969
|
-
}
|
|
970
|
-
let event_log = crate::event_log::EventLog::new(&target_workspace);
|
|
971
|
-
let task = task_id.map(|s| crate::model::ids::TaskId::new(s.to_string()));
|
|
972
|
-
let outcome = messaging::enqueue_leader_mailbox_until_attach(
|
|
973
|
-
&target_workspace,
|
|
974
|
-
&team_key,
|
|
975
|
-
content,
|
|
976
|
-
task.as_ref(),
|
|
977
|
-
sender,
|
|
978
|
-
&event_log,
|
|
979
|
-
)
|
|
980
|
-
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
981
|
-
let message_id = outcome.message_id.clone().unwrap_or_else(|| "".to_string());
|
|
982
|
-
Ok(Some(json!({
|
|
983
|
-
"ok": true,
|
|
984
|
-
"status": "queued_until_leader_attach",
|
|
985
|
-
"message_status": "queued_until_leader_attach",
|
|
986
|
-
"channel": "leader_mailbox",
|
|
987
|
-
"delivered": false,
|
|
988
|
-
"to_name": to_name,
|
|
989
|
-
"target_workspace": target_workspace.display().to_string(),
|
|
990
|
-
"team_key": team_key,
|
|
991
|
-
"recipient": "leader",
|
|
992
|
-
"leader_attached": false,
|
|
993
|
-
"message_id": message_id,
|
|
994
|
-
})))
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
/// Positive-source liveness heuristic per offline-mailbox-toname-design.md §4:
|
|
998
|
-
/// - target workspace has state and the team key is present + not archived/down;
|
|
999
|
-
/// - AND at least one live tmux fact — a persisted `session_name` OR any
|
|
1000
|
-
/// agent with a recorded pane on the recorded socket.
|
|
1001
|
-
///
|
|
1002
|
-
/// We deliberately do NOT poll coordinator health here — enqueuing is
|
|
1003
|
-
/// safe even when the coordinator is transiently down; attach-leader
|
|
1004
|
-
/// itself replays via `requeue_blocked_leader_messages` regardless.
|
|
1005
|
-
fn target_team_is_alive_for_mailbox(state: &Value, team_key: &str) -> bool {
|
|
1006
|
-
let team = state
|
|
1007
|
-
.get("teams")
|
|
1008
|
-
.and_then(|v| v.as_object())
|
|
1009
|
-
.and_then(|teams| teams.get(team_key));
|
|
1010
|
-
let Some(team) = team else {
|
|
1011
|
-
return false;
|
|
1012
|
-
};
|
|
1013
|
-
let status = team
|
|
1014
|
-
.get("status")
|
|
1015
|
-
.and_then(|v| v.as_str())
|
|
1016
|
-
.unwrap_or("alive");
|
|
1017
|
-
if matches!(status, "archived" | "down" | "stopped") {
|
|
1018
|
-
return false;
|
|
1019
|
-
}
|
|
1020
|
-
// A recorded session_name is enough — target's coordinator/attach
|
|
1021
|
-
// path will re-verify tmux presence when the replay fires.
|
|
1022
|
-
team.get("session_name")
|
|
1023
|
-
.and_then(|v| v.as_str())
|
|
1024
|
-
.is_some_and(|s| !s.is_empty())
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
fn cmd_send_result(value: Value, as_json: bool) -> CmdResult {
|
|
1028
|
-
let exit = if value.get("ok").and_then(Value::as_bool) == Some(false) {
|
|
1029
|
-
ExitCode::Error
|
|
1030
|
-
} else {
|
|
1031
|
-
ExitCode::Ok
|
|
1032
|
-
};
|
|
1033
|
-
if as_json {
|
|
1034
|
-
CmdResult::from_json(value, true)
|
|
1035
|
-
} else {
|
|
1036
|
-
CmdResult {
|
|
1037
|
-
output: CmdOutput::Human(send_human_output(&value)),
|
|
1038
|
-
exit,
|
|
1039
|
-
as_json: false,
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
fn send_human_output(value: &Value) -> String {
|
|
1045
|
-
let mut parts = vec![
|
|
1046
|
-
send_human_field(value, "ok"),
|
|
1047
|
-
format!("status: {}", send_human_status(value)),
|
|
1048
|
-
send_human_field(value, "message_id"),
|
|
1049
|
-
format!("target: {}", send_human_target(value)),
|
|
1050
|
-
];
|
|
1051
|
-
for key in ["verification", "stage", "reason", "channel"] {
|
|
1052
|
-
if !value.get(key).is_none_or(Value::is_null) {
|
|
1053
|
-
parts.push(send_human_field(value, key));
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
// 0.5.45 naming-addressing (design §3.4/§3.5, RED-3 positional):
|
|
1057
|
-
// when the refusal envelope carries a scope-safe suggestion,
|
|
1058
|
-
// surface it verbatim in human output so users can copy the
|
|
1059
|
-
// right short id. `requested_name` echoes the typo, `suggested_
|
|
1060
|
-
// name` is the copyable canonical.
|
|
1061
|
-
if let Some(requested) = value
|
|
1062
|
-
.get("requested_name")
|
|
1063
|
-
.and_then(Value::as_str)
|
|
1064
|
-
.filter(|s| !s.is_empty())
|
|
1065
|
-
{
|
|
1066
|
-
parts.push(format!("requested_name: {requested}"));
|
|
1067
|
-
}
|
|
1068
|
-
if let Some(suggested) = value
|
|
1069
|
-
.get("suggested_name")
|
|
1070
|
-
.and_then(Value::as_str)
|
|
1071
|
-
.filter(|s| !s.is_empty())
|
|
1072
|
-
{
|
|
1073
|
-
parts.push(format!("Did you mean `{suggested}`? suggested_name: {suggested}"));
|
|
1074
|
-
}
|
|
1075
|
-
parts.join(" ")
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
fn send_human_field(value: &Value, key: &str) -> String {
|
|
1079
|
-
let rendered = value
|
|
1080
|
-
.get(key)
|
|
1081
|
-
.map(send_human_value)
|
|
1082
|
-
.unwrap_or_else(|| "None".to_string());
|
|
1083
|
-
format!("{key}: {rendered}")
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
fn send_human_target(value: &Value) -> String {
|
|
1087
|
-
["target", "agent_id", "pane_id", "to_name"]
|
|
1088
|
-
.iter()
|
|
1089
|
-
.find_map(|key| value.get(*key).filter(|v| !v.is_null()))
|
|
1090
|
-
.map(send_human_value)
|
|
1091
|
-
.unwrap_or_else(|| "None".to_string())
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
fn send_human_status(value: &Value) -> String {
|
|
1095
|
-
value
|
|
1096
|
-
.get("status")
|
|
1097
|
-
.map(send_human_value)
|
|
1098
|
-
.unwrap_or_else(|| {
|
|
1099
|
-
if value.get("ok").and_then(Value::as_bool) == Some(true) {
|
|
1100
|
-
"delivered".to_string()
|
|
1101
|
-
} else {
|
|
1102
|
-
"failed".to_string()
|
|
1103
|
-
}
|
|
1104
|
-
})
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
fn send_human_value(value: &Value) -> String {
|
|
1108
|
-
let text = match value {
|
|
1109
|
-
Value::Null => "None".to_string(),
|
|
1110
|
-
Value::Bool(true) => "True".to_string(),
|
|
1111
|
-
Value::Bool(false) => "False".to_string(),
|
|
1112
|
-
Value::Number(n) => n.to_string(),
|
|
1113
|
-
Value::String(s) => s.clone(),
|
|
1114
|
-
Value::Array(_) | Value::Object(_) => {
|
|
1115
|
-
serde_json::to_string(value).unwrap_or_else(|_| "None".to_string())
|
|
1116
|
-
}
|
|
1117
|
-
};
|
|
1118
|
-
text.replace(['\r', '\n'], " ")
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
fn send_reminder_for_value(value: &Value) -> &'static str {
|
|
1122
|
-
let delivered = value.get("delivered").and_then(Value::as_bool);
|
|
1123
|
-
let status = value.get("status").and_then(Value::as_str);
|
|
1124
|
-
let delivery_status = value.get("delivery_status").and_then(Value::as_str);
|
|
1125
|
-
if delivered == Some(false)
|
|
1126
|
-
|| matches!(status, Some("queued"))
|
|
1127
|
-
|| matches!(delivery_status, Some("pending"))
|
|
1128
|
-
{
|
|
1129
|
-
"Message queued; coordinator will notify when the worker receives it. Do not poll the worker terminal with capture-pane."
|
|
1130
|
-
} else {
|
|
1131
|
-
crate::cli::SEND_REMINDER
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
fn target_json(target: &MessageTarget) -> Value {
|
|
1136
|
-
match target {
|
|
1137
|
-
MessageTarget::Single(agent) => json!(agent),
|
|
1138
|
-
MessageTarget::Broadcast => json!("*"),
|
|
1139
|
-
MessageTarget::Fanout(recipients) => json!(recipients),
|
|
1140
|
-
}
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
fn first_target(target: &MessageTarget) -> String {
|
|
1144
|
-
match target {
|
|
1145
|
-
MessageTarget::Single(agent) => agent.clone(),
|
|
1146
|
-
MessageTarget::Broadcast => "*".to_string(),
|
|
1147
|
-
MessageTarget::Fanout(recipients) => recipients.first().cloned().unwrap_or_default(),
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
fn delivery_status_wire(status: DeliveryStatus) -> &'static str {
|
|
1152
|
-
match status {
|
|
1153
|
-
DeliveryStatus::Delivered => "delivered",
|
|
1154
|
-
DeliveryStatus::Failed => "failed",
|
|
1155
|
-
DeliveryStatus::Queued => "queued",
|
|
1156
|
-
DeliveryStatus::Blocked => "blocked",
|
|
1157
|
-
DeliveryStatus::Refused => "refused",
|
|
1158
|
-
DeliveryStatus::Degraded => "degraded",
|
|
1159
|
-
DeliveryStatus::RetryScheduled => "retry_scheduled",
|
|
1160
|
-
DeliveryStatus::TrustAutoAnswerExhausted => "trust_auto_answer_exhausted",
|
|
1161
|
-
DeliveryStatus::AlreadyDelivered => "already_delivered",
|
|
1162
|
-
DeliveryStatus::FallbackLog => "fallback_log",
|
|
1163
|
-
DeliveryStatus::BroadcastDelivered => "broadcast_delivered",
|
|
1164
|
-
DeliveryStatus::BroadcastPartial => "broadcast_partial",
|
|
1165
|
-
DeliveryStatus::FanoutDelivered => "fanout_delivered",
|
|
1166
|
-
DeliveryStatus::FanoutPartial => "fanout_partial",
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
fn delivery_refusal_wire(reason: DeliveryRefusal) -> &'static str {
|
|
1171
|
-
match reason {
|
|
1172
|
-
DeliveryRefusal::TargetNotInTeam => "target_not_in_team",
|
|
1173
|
-
DeliveryRefusal::HumanConfirmationRequired => "human_confirmation_required",
|
|
1174
|
-
DeliveryRefusal::MissingPermissions => "missing_permissions",
|
|
1175
|
-
DeliveryRefusal::RecipientBusy => "recipient_busy",
|
|
1176
|
-
DeliveryRefusal::UnknownRecipient => "unknown_recipient",
|
|
1177
|
-
DeliveryRefusal::TmuxTargetMissing => "tmux_target_missing",
|
|
1178
|
-
DeliveryRefusal::MessageAlreadyClaimed => "message_already_claimed",
|
|
1179
|
-
DeliveryRefusal::LeaderNotAttached => "leader_not_attached",
|
|
1180
|
-
DeliveryRefusal::CoordinatorUnavailable => "coordinator_unavailable",
|
|
1181
|
-
DeliveryRefusal::NoCallerPane => "no_caller_pane",
|
|
1182
|
-
DeliveryRefusal::TeamOwnerMismatch => "team_owner_mismatch",
|
|
1183
|
-
DeliveryRefusal::Ambiguous => "ambiguous",
|
|
1184
|
-
DeliveryRefusal::RecipientPaneInNonInputMode => "recipient_pane_in_non_input_mode",
|
|
1185
|
-
DeliveryRefusal::SessionDrift => "session_drift",
|
|
1186
|
-
DeliveryRefusal::Duplicate => "duplicate",
|
|
1187
|
-
DeliveryRefusal::RoutingAmbiguous => "routing_ambiguous",
|
|
1188
|
-
DeliveryRefusal::EmptyTargetList => "empty_target_list",
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
fn delivery_stage_wire(stage: DeliveryStage) -> &'static str {
|
|
1193
|
-
match stage {
|
|
1194
|
-
DeliveryStage::TrustAutoAnswerDismissalWait => "trust_auto_answer_dismissal_wait",
|
|
1195
|
-
DeliveryStage::Inject => "inject",
|
|
1196
|
-
DeliveryStage::Submit => "submit",
|
|
1197
|
-
DeliveryStage::VisibleCheck => "visible_check",
|
|
1198
|
-
}
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
/// E7 (0.5.9 host-leader-registry-design §8.3): resolve `NAME` through
|
|
1202
|
-
/// `~/.team-agent/leaders`, then delegate to the E6 leader delivery path
|
|
1203
|
-
/// so a resolved live target physically injects and a leader-not-attached
|
|
1204
|
-
/// target queues via `enqueue_leader_mailbox_until_attach`. Ambiguous
|
|
1205
|
-
/// short names refuse with `name_ambiguous` and expose `candidates` —
|
|
1206
|
-
/// no priority heuristic ever picks a winner (host-leader-registry-design §5.2).
|
|
1207
|
-
///
|
|
1208
|
-
/// Return shape reserves the following markers for downstream consumers:
|
|
1209
|
-
/// - `resolved_via = "host_leader_registry"` when a registry entry
|
|
1210
|
-
/// selected the canonical target (E7 test 2).
|
|
1211
|
-
/// - `reason = "leader_name_not_found"` for missing entries; `reason =
|
|
1212
|
-
/// "registry_stale"` when canonical validation refuses; `reason =
|
|
1213
|
-
/// "name_ambiguous"` for collisions with a candidate list including
|
|
1214
|
-
/// `workspace_hash` and `stable_qualified_name`.
|
|
1215
|
-
///
|
|
1216
|
-
/// The first slice ships the marker/return-shape surface so E6 wiring is
|
|
1217
|
-
/// available at the CLI; the full canonical-validate loop follows in a
|
|
1218
|
-
/// later commit alongside the registry read implementation.
|
|
1219
|
-
pub fn send_to_canonical_leader_target(
|
|
1220
|
-
sender_workspace: &std::path::Path,
|
|
1221
|
-
name: &str,
|
|
1222
|
-
content: &str,
|
|
1223
|
-
sender: &str,
|
|
1224
|
-
task_id: Option<&str>,
|
|
1225
|
-
) -> Result<serde_json::Value, CliError> {
|
|
1226
|
-
// Resolve NAME through the registry. Ambiguity is decided *before*
|
|
1227
|
-
// canonical validation so an ambiguous short name never picks a
|
|
1228
|
-
// winner — even when only one candidate happens to be live. Send
|
|
1229
|
-
// uses the no-GC listing so stale entries can still refuse with
|
|
1230
|
-
// `registry_stale` — the leaders CLI is the one that prunes.
|
|
1231
|
-
let classified = crate::leader::registry::list_validated_no_gc();
|
|
1232
|
-
let mut candidates_all: Vec<crate::leader::registry::LeaderRegistryEntry> = Vec::new();
|
|
1233
|
-
for (entry, _status, _reason) in &classified {
|
|
1234
|
-
let matches = entry.delivery_name == name
|
|
1235
|
-
|| entry.qualified_name == name
|
|
1236
|
-
|| entry.stable_qualified_name == name
|
|
1237
|
-
|| entry.aliases.iter().any(|a| a == name);
|
|
1238
|
-
if matches {
|
|
1239
|
-
candidates_all.push(entry.clone());
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
if candidates_all.is_empty() {
|
|
1243
|
-
return Ok(serde_json::json!({
|
|
1244
|
-
"ok": false,
|
|
1245
|
-
"status": "refused",
|
|
1246
|
-
"reason": "leader_name_not_found",
|
|
1247
|
-
"requested_name": name,
|
|
1248
|
-
"resolved_via": "host_leader_registry",
|
|
1249
|
-
"candidates": Vec::<serde_json::Value>::new(),
|
|
1250
|
-
"workspace_hash": null,
|
|
1251
|
-
"stable_qualified_name": null,
|
|
1252
|
-
"channel": "leader_mailbox",
|
|
1253
|
-
"delivered": false,
|
|
1254
|
-
"message_status": "queued_until_leader_attach",
|
|
1255
|
-
"action": "run `team-agent leaders` to see registered leaders; inspect queued leader messages with `team-agent inbox`; retry with a qualified name",
|
|
1256
|
-
"registry_stale": false,
|
|
1257
|
-
}));
|
|
1258
|
-
}
|
|
1259
|
-
if candidates_all.len() > 1 {
|
|
1260
|
-
let cand_json: Vec<serde_json::Value> = candidates_all
|
|
1261
|
-
.iter()
|
|
1262
|
-
.map(|e| {
|
|
1263
|
-
serde_json::json!({
|
|
1264
|
-
"name": e.qualified_name,
|
|
1265
|
-
"workspace": e.workspace.display().to_string(),
|
|
1266
|
-
"team_key": e.team_key,
|
|
1267
|
-
"workspace_hash": e.workspace_hash,
|
|
1268
|
-
"stable_qualified_name": e.stable_qualified_name,
|
|
1269
|
-
})
|
|
1270
|
-
})
|
|
1271
|
-
.collect();
|
|
1272
|
-
return Ok(serde_json::json!({
|
|
1273
|
-
"ok": false,
|
|
1274
|
-
"status": "refused",
|
|
1275
|
-
"reason": "name_ambiguous",
|
|
1276
|
-
"requested_name": name,
|
|
1277
|
-
"resolved_via": "host_leader_registry",
|
|
1278
|
-
"candidates": cand_json,
|
|
1279
|
-
"channel": "leader_mailbox",
|
|
1280
|
-
"delivered": false,
|
|
1281
|
-
"action": "run `team-agent leaders` and retry with the qualified name",
|
|
1282
|
-
}));
|
|
1283
|
-
}
|
|
1284
|
-
let entry = candidates_all.into_iter().next().ok_or_else(|| {
|
|
1285
|
-
CliError::Runtime("internal: candidate list must have at least one entry".to_string())
|
|
1286
|
-
})?;
|
|
1287
|
-
// Canonical-validate the entry against target workspace state. Send
|
|
1288
|
-
// through the same E6 --to-name path so live inject and mailbox both
|
|
1289
|
-
// funnel through one code path.
|
|
1290
|
-
let (status, reason) = crate::leader::registry::classify(&entry);
|
|
1291
|
-
if status == "STALE" {
|
|
1292
|
-
// Check whether the underlying team is still alive — if so we
|
|
1293
|
-
// may still queue via the E6 mailbox path (leader-not-attached
|
|
1294
|
-
// shape). If the workspace/team is gone we refuse `registry_stale`.
|
|
1295
|
-
let state = crate::state::persist::load_runtime_state(&entry.workspace).ok();
|
|
1296
|
-
let team_alive = state
|
|
1297
|
-
.as_ref()
|
|
1298
|
-
.and_then(|s| s.get("teams"))
|
|
1299
|
-
.and_then(|v| v.as_object())
|
|
1300
|
-
.and_then(|teams| teams.get(&entry.team_key))
|
|
1301
|
-
.and_then(|t| t.get("status"))
|
|
1302
|
-
.and_then(serde_json::Value::as_str)
|
|
1303
|
-
.map(|s| s == "alive" || s.is_empty())
|
|
1304
|
-
.unwrap_or(false);
|
|
1305
|
-
if !team_alive {
|
|
1306
|
-
return Ok(serde_json::json!({
|
|
1307
|
-
"ok": false,
|
|
1308
|
-
"status": "refused",
|
|
1309
|
-
"reason": "registry_stale",
|
|
1310
|
-
"requested_name": name,
|
|
1311
|
-
"resolved_via": "host_leader_registry",
|
|
1312
|
-
"stale_reason": reason,
|
|
1313
|
-
"workspace_hash": entry.workspace_hash,
|
|
1314
|
-
"stable_qualified_name": entry.stable_qualified_name,
|
|
1315
|
-
"channel": "leader_mailbox",
|
|
1316
|
-
"delivered": false,
|
|
1317
|
-
"action": "target team is not alive; run `team-agent leaders` for current state",
|
|
1318
|
-
}));
|
|
1319
|
-
}
|
|
1320
|
-
// Team alive but leader unattached → E6 mailbox.
|
|
1321
|
-
let event_log = crate::event_log::EventLog::new(&entry.workspace);
|
|
1322
|
-
let task = task_id.map(|s| crate::model::ids::TaskId::new(s.to_string()));
|
|
1323
|
-
let outcome = crate::messaging::enqueue_leader_mailbox_until_attach(
|
|
1324
|
-
&entry.workspace,
|
|
1325
|
-
&entry.team_key,
|
|
1326
|
-
content,
|
|
1327
|
-
task.as_ref(),
|
|
1328
|
-
sender,
|
|
1329
|
-
&event_log,
|
|
1330
|
-
)
|
|
1331
|
-
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
1332
|
-
return Ok(serde_json::json!({
|
|
1333
|
-
"ok": true,
|
|
1334
|
-
"status": "queued_until_leader_attach",
|
|
1335
|
-
"message_status": "queued_until_leader_attach",
|
|
1336
|
-
"channel": "leader_mailbox",
|
|
1337
|
-
"delivered": false,
|
|
1338
|
-
"resolved_via": "host_leader_registry",
|
|
1339
|
-
"requested_name": name,
|
|
1340
|
-
"to_leader": entry.qualified_name,
|
|
1341
|
-
"target_workspace": entry.workspace.display().to_string(),
|
|
1342
|
-
"workspace_hash": entry.workspace_hash,
|
|
1343
|
-
"stable_qualified_name": entry.stable_qualified_name,
|
|
1344
|
-
"team_key": entry.team_key,
|
|
1345
|
-
"message_id": outcome.message_id,
|
|
1346
|
-
}));
|
|
1347
|
-
}
|
|
1348
|
-
// LIVE: canonical-validated. Delegate to the E6 --to-name path via a
|
|
1349
|
-
// synthesized `<workspace>::<team_key>/leader` name so live inject +
|
|
1350
|
-
// mailbox both go through one code path.
|
|
1351
|
-
let to_name = format!("{}::{}/leader", entry.workspace.display(), entry.team_key);
|
|
1352
|
-
// 0.5.45 naming-addressing (design §3.1 / §4.1): internal
|
|
1353
|
-
// registry-to-E6 delegation MUST pass None for bare_team_scope.
|
|
1354
|
-
// The synthesized name above is a full `workspace::team/leader`
|
|
1355
|
-
// form, and the caller's `--team` flag (if any) must not
|
|
1356
|
-
// override the registry's authoritative team_key.
|
|
1357
|
-
let (resolved, transport) =
|
|
1358
|
-
match crate::cli::named_address::resolve_name_for_cli(sender_workspace, &to_name, None) {
|
|
1359
|
-
Ok(r) => r,
|
|
1360
|
-
Err(err) => {
|
|
1361
|
-
// Named-address refusal — surface it verbatim but tag as
|
|
1362
|
-
// registry-resolved so callers can trace the origin.
|
|
1363
|
-
let mut body = err.to_json();
|
|
1364
|
-
if let Some(obj) = body.as_object_mut() {
|
|
1365
|
-
obj.insert(
|
|
1366
|
-
"resolved_via".to_string(),
|
|
1367
|
-
serde_json::Value::String("host_leader_registry".to_string()),
|
|
1368
|
-
);
|
|
1369
|
-
obj.insert("delivered".to_string(), serde_json::Value::Bool(false));
|
|
1370
|
-
}
|
|
1371
|
-
return Ok(body);
|
|
1372
|
-
}
|
|
1373
|
-
};
|
|
1374
|
-
let mut value = send_to_named_pane_direct(
|
|
1375
|
-
sender_workspace,
|
|
1376
|
-
transport.as_ref(),
|
|
1377
|
-
&resolved,
|
|
1378
|
-
content,
|
|
1379
|
-
sender,
|
|
1380
|
-
task_id,
|
|
1381
|
-
true,
|
|
1382
|
-
)?;
|
|
1383
|
-
if let Some(obj) = value.as_object_mut() {
|
|
1384
|
-
obj.insert(
|
|
1385
|
-
"resolved_via".to_string(),
|
|
1386
|
-
serde_json::Value::String("host_leader_registry".to_string()),
|
|
1387
|
-
);
|
|
1388
|
-
obj.insert(
|
|
1389
|
-
"to_leader".to_string(),
|
|
1390
|
-
serde_json::Value::String(entry.qualified_name.clone()),
|
|
1391
|
-
);
|
|
1392
|
-
// Honest delivered marker. `send_to_named_pane_direct` sets `ok`
|
|
1393
|
-
// to whether physical inject verified — mirror that as
|
|
1394
|
-
// `delivered`.
|
|
1395
|
-
let ok = obj
|
|
1396
|
-
.get("ok")
|
|
1397
|
-
.and_then(serde_json::Value::as_bool)
|
|
1398
|
-
.unwrap_or(false);
|
|
1399
|
-
obj.insert("delivered".to_string(), serde_json::Value::Bool(ok));
|
|
1400
|
-
}
|
|
1401
|
-
Ok(value)
|
|
1402
|
-
}
|