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