@team-agent/installer 0.3.25 → 0.3.27
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/emit.rs +1 -0
- package/crates/team-agent/src/cli/mod.rs +193 -7
- package/crates/team-agent/src/cli/send.rs +106 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +270 -13
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +7 -0
- package/crates/team-agent/src/coordinator/tests/energy.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/spine.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/takeover.rs +1 -0
- package/crates/team-agent/src/leader/lease.rs +57 -0
- package/crates/team-agent/src/leader/start.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch.rs +9 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +55 -0
- package/crates/team-agent/src/messaging/delivery.rs +83 -3
- package/crates/team-agent/src/messaging/leader_receiver.rs +61 -24
- package/crates/team-agent/src/messaging/tests/runtime.rs +2 -0
- package/crates/team-agent/src/messaging/tests/spine.rs +1 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +227 -25
- package/crates/team-agent/src/tmux_backend.rs +409 -105
- package/crates/team-agent/src/transport/test_support.rs +1 -0
- package/crates/team-agent/src/transport/tests/mod.rs +1 -0
- package/crates/team-agent/src/transport/tests/wire.rs +2 -1
- package/crates/team-agent/src/transport.rs +77 -0
- package/npm/install.mjs +222 -14
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -392,9 +392,23 @@ pub mod lifecycle_port {
|
|
|
392
392
|
.and_then(Value::as_str)
|
|
393
393
|
.filter(|session| !session.is_empty())
|
|
394
394
|
.map(crate::transport::SessionName::new);
|
|
395
|
+
// E49 (0.3.24 P0, shutdown kills leader CLI): for managed leader topology
|
|
396
|
+
// the target session may carry the leader anchor pane. The pre-fix code
|
|
397
|
+
// pushed it into `to_kill` and then issued `kill_session` — which ended
|
|
398
|
+
// the leader pane (= leader CLI). When the target session has a live
|
|
399
|
+
// anchor pane in `list_targets`, spare it instead.
|
|
400
|
+
let leader_anchor_ids = collect_state_leader_anchor_pane_ids(state);
|
|
401
|
+
let live_targets = transport.list_targets().unwrap_or_default();
|
|
395
402
|
let mut to_kill = Vec::new();
|
|
403
|
+
let mut target_spared_for_anchor: Option<crate::transport::SessionName> = None;
|
|
396
404
|
if let Some(target) = target {
|
|
397
|
-
|
|
405
|
+
let target_has_anchor = live_targets.iter().any(|t| {
|
|
406
|
+
t.session.as_str() == target.as_str()
|
|
407
|
+
&& leader_anchor_ids.contains(t.pane_id.as_str())
|
|
408
|
+
});
|
|
409
|
+
if target_has_anchor {
|
|
410
|
+
target_spared_for_anchor = Some(target);
|
|
411
|
+
} else if sessions.is_empty()
|
|
398
412
|
|| sessions
|
|
399
413
|
.iter()
|
|
400
414
|
.any(|session| session.as_str() == target.as_str())
|
|
@@ -402,7 +416,7 @@ pub mod lifecycle_port {
|
|
|
402
416
|
to_kill.push(target);
|
|
403
417
|
}
|
|
404
418
|
}
|
|
405
|
-
let spared = sessions
|
|
419
|
+
let mut spared = sessions
|
|
406
420
|
.iter()
|
|
407
421
|
.filter(|session| {
|
|
408
422
|
!to_kill
|
|
@@ -411,6 +425,14 @@ pub mod lifecycle_port {
|
|
|
411
425
|
})
|
|
412
426
|
.cloned()
|
|
413
427
|
.collect::<Vec<_>>();
|
|
428
|
+
if let Some(anchored) = target_spared_for_anchor {
|
|
429
|
+
if !spared
|
|
430
|
+
.iter()
|
|
431
|
+
.any(|s| s.as_str() == anchored.as_str())
|
|
432
|
+
{
|
|
433
|
+
spared.push(anchored);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
414
436
|
let _ = event_log.write(
|
|
415
437
|
"shutdown.kill_server_skipped_managed_leader",
|
|
416
438
|
json!({
|
|
@@ -628,13 +650,63 @@ pub mod lifecycle_port {
|
|
|
628
650
|
let mut spared_sessions = Vec::new();
|
|
629
651
|
deadline.check("kill_session")?;
|
|
630
652
|
if let Some(session) = session_name.as_ref() {
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
653
|
+
// E49 (0.3.24 P0, shutdown kills leader CLI): managed-leader topology
|
|
654
|
+
// means the leader pane lives INSIDE state.session_name. The pre-fix
|
|
655
|
+
// `transport.kill_session(state.session_name)` ended the leader pane —
|
|
656
|
+
// i.e. terminated the user's own CLI. User truth: shutdown must never
|
|
657
|
+
// close the leader CLI that launched it. Detect via state.is_external_leader
|
|
658
|
+
// (state_is_managed_leader = !external) AND the presence of a leader
|
|
659
|
+
// anchor pane id (leader_receiver / team_owner) that lives in this
|
|
660
|
+
// session per `list_targets`. When managed-leader, switch to per-pane
|
|
661
|
+
// cleanup: kill the workers individually and SPARE the leader's pane.
|
|
662
|
+
//
|
|
663
|
+
// External leader (is_external_leader=true) keeps the unconditional
|
|
664
|
+
// kill_session — the team session is a disposable worker session in
|
|
665
|
+
// that topology, the leader pane lives elsewhere.
|
|
666
|
+
let leader_anchor_ids = collect_state_leader_anchor_pane_ids(&state);
|
|
667
|
+
let live_targets_now = transport.list_targets().unwrap_or_default();
|
|
668
|
+
let session_has_leader_anchor = live_targets_now.iter().any(|target| {
|
|
669
|
+
target.session.as_str() == session.as_str()
|
|
670
|
+
&& leader_anchor_ids.contains(target.pane_id.as_str())
|
|
671
|
+
});
|
|
672
|
+
if crate::state::projection::state_is_managed_leader(&state)
|
|
673
|
+
&& session_has_leader_anchor
|
|
674
|
+
{
|
|
675
|
+
let _ = event_log_write_session_spared(
|
|
676
|
+
&run_workspace,
|
|
677
|
+
session,
|
|
678
|
+
&leader_anchor_ids,
|
|
679
|
+
);
|
|
680
|
+
push_unique_session(&mut spared_sessions, session.clone());
|
|
681
|
+
let worker_panes = collect_session_worker_panes(
|
|
682
|
+
&state,
|
|
683
|
+
session.as_str(),
|
|
684
|
+
&leader_anchor_ids,
|
|
685
|
+
&live_targets_now,
|
|
686
|
+
);
|
|
687
|
+
for pane in &worker_panes {
|
|
688
|
+
if let Err(error) = transport.kill_pane(pane) {
|
|
689
|
+
if !tmux_absent_error(&error.to_string()) {
|
|
690
|
+
kill_error.get_or_insert_with(|| error.to_string());
|
|
691
|
+
}
|
|
692
|
+
}
|
|
635
693
|
}
|
|
694
|
+
let _ = crate::lifecycle::display::close_team_display_backends(
|
|
695
|
+
&run_workspace,
|
|
696
|
+
session,
|
|
697
|
+
);
|
|
698
|
+
} else {
|
|
699
|
+
push_unique_session(&mut killed_sessions, session.clone());
|
|
700
|
+
if let Err(error) = transport.kill_session(session) {
|
|
701
|
+
if !tmux_absent_error(&error.to_string()) {
|
|
702
|
+
kill_error = Some(error.to_string());
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
let _ = crate::lifecycle::display::close_team_display_backends(
|
|
706
|
+
&run_workspace,
|
|
707
|
+
session,
|
|
708
|
+
);
|
|
636
709
|
}
|
|
637
|
-
let _ = crate::lifecycle::display::close_team_display_backends(&run_workspace, session);
|
|
638
710
|
}
|
|
639
711
|
deadline.check("reap_workspace_residuals")?;
|
|
640
712
|
reap_workspace_process_residuals(
|
|
@@ -1368,6 +1440,120 @@ pub mod lifecycle_port {
|
|
|
1368
1440
|
out
|
|
1369
1441
|
}
|
|
1370
1442
|
|
|
1443
|
+
/// E49 (0.3.24 P0, shutdown kills leader CLI): collect worker pane ids on the
|
|
1444
|
+
/// given `session` from state.agents + teams[*].agents, EXCLUDING any pane id
|
|
1445
|
+
/// in `leader_anchor_ids`. If an agent has no `pane_id` but its window appears
|
|
1446
|
+
/// in `live_targets` under the same session, fall back to the live `pane_id`
|
|
1447
|
+
/// for that window — so we still kill the worker pane via the structural
|
|
1448
|
+
/// addressing path rather than the session-level `kill_session` that would
|
|
1449
|
+
/// also end the leader pane (the E49 bug).
|
|
1450
|
+
fn collect_session_worker_panes(
|
|
1451
|
+
state: &Value,
|
|
1452
|
+
session: &str,
|
|
1453
|
+
leader_anchor_ids: &std::collections::BTreeSet<String>,
|
|
1454
|
+
live_targets: &[crate::transport::PaneInfo],
|
|
1455
|
+
) -> Vec<crate::transport::PaneId> {
|
|
1456
|
+
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
|
|
1457
|
+
let mut out: Vec<crate::transport::PaneId> = Vec::new();
|
|
1458
|
+
collect_session_worker_panes_from_agents(
|
|
1459
|
+
state.get("agents"),
|
|
1460
|
+
session,
|
|
1461
|
+
leader_anchor_ids,
|
|
1462
|
+
live_targets,
|
|
1463
|
+
&mut seen,
|
|
1464
|
+
&mut out,
|
|
1465
|
+
);
|
|
1466
|
+
if let Some(teams) = state.get("teams").and_then(Value::as_object) {
|
|
1467
|
+
for (_, team_state) in teams {
|
|
1468
|
+
collect_session_worker_panes_from_agents(
|
|
1469
|
+
team_state.get("agents"),
|
|
1470
|
+
session,
|
|
1471
|
+
leader_anchor_ids,
|
|
1472
|
+
live_targets,
|
|
1473
|
+
&mut seen,
|
|
1474
|
+
&mut out,
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
out
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
fn collect_session_worker_panes_from_agents(
|
|
1482
|
+
agents: Option<&Value>,
|
|
1483
|
+
session: &str,
|
|
1484
|
+
leader_anchor_ids: &std::collections::BTreeSet<String>,
|
|
1485
|
+
live_targets: &[crate::transport::PaneInfo],
|
|
1486
|
+
seen: &mut std::collections::BTreeSet<String>,
|
|
1487
|
+
out: &mut Vec<crate::transport::PaneId>,
|
|
1488
|
+
) {
|
|
1489
|
+
let Some(map) = agents.and_then(Value::as_object) else {
|
|
1490
|
+
return;
|
|
1491
|
+
};
|
|
1492
|
+
for (agent_id, agent) in map {
|
|
1493
|
+
let claimed_pane = agent
|
|
1494
|
+
.get("pane_id")
|
|
1495
|
+
.and_then(Value::as_str)
|
|
1496
|
+
.filter(|s| !s.is_empty());
|
|
1497
|
+
// Try state-claimed pane id first; cross-check with live_targets to
|
|
1498
|
+
// skip dead state entries (a stale pane_id from a respawn).
|
|
1499
|
+
if let Some(pane_id) = claimed_pane {
|
|
1500
|
+
if leader_anchor_ids.contains(pane_id) {
|
|
1501
|
+
continue;
|
|
1502
|
+
}
|
|
1503
|
+
let in_live = live_targets
|
|
1504
|
+
.iter()
|
|
1505
|
+
.any(|info| info.pane_id.as_str() == pane_id);
|
|
1506
|
+
if in_live && seen.insert(pane_id.to_string()) {
|
|
1507
|
+
out.push(crate::transport::PaneId::new(pane_id));
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
// Fallback: agent window + session match a live pane (E43 lineage:
|
|
1512
|
+
// tmux respawn-pane bumps the %id while the window stays). Use the
|
|
1513
|
+
// live pane id for that window.
|
|
1514
|
+
let window = agent
|
|
1515
|
+
.get("window")
|
|
1516
|
+
.and_then(Value::as_str)
|
|
1517
|
+
.filter(|s| !s.is_empty())
|
|
1518
|
+
.unwrap_or(agent_id.as_str());
|
|
1519
|
+
if let Some(info) = live_targets.iter().find(|info| {
|
|
1520
|
+
info.session.as_str() == session
|
|
1521
|
+
&& info
|
|
1522
|
+
.window_name
|
|
1523
|
+
.as_ref()
|
|
1524
|
+
.is_some_and(|name| name.as_str() == window)
|
|
1525
|
+
}) {
|
|
1526
|
+
let pane_str = info.pane_id.as_str();
|
|
1527
|
+
if leader_anchor_ids.contains(pane_str) {
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
if seen.insert(pane_str.to_string()) {
|
|
1531
|
+
out.push(info.pane_id.clone());
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/// E49 (0.3.24 P0): emit a structured event documenting the session was
|
|
1538
|
+
/// spared because it carries a managed-leader anchor pane. Lets operators
|
|
1539
|
+
/// see why kill_session was skipped.
|
|
1540
|
+
fn event_log_write_session_spared(
|
|
1541
|
+
workspace: &Path,
|
|
1542
|
+
session: &crate::transport::SessionName,
|
|
1543
|
+
leader_anchor_ids: &std::collections::BTreeSet<String>,
|
|
1544
|
+
) -> Result<(), CliError> {
|
|
1545
|
+
let event_log = crate::event_log::EventLog::new(workspace);
|
|
1546
|
+
let _ = event_log.write(
|
|
1547
|
+
"shutdown.session_spared_managed_leader",
|
|
1548
|
+
json!({
|
|
1549
|
+
"session": session.as_str(),
|
|
1550
|
+
"reason": "managed_leader_anchor_in_session",
|
|
1551
|
+
"leader_anchor_pane_ids": leader_anchor_ids.iter().collect::<Vec<_>>(),
|
|
1552
|
+
}),
|
|
1553
|
+
);
|
|
1554
|
+
Ok(())
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1371
1557
|
/// 单帧扫 team_owner.pane_id + leader_receiver.pane_id → BTreeSet 累加。
|
|
1372
1558
|
fn push_anchor_pane_id(state: &Value, out: &mut std::collections::BTreeSet<String>) {
|
|
1373
1559
|
for key in &["team_owner", "leader_receiver"] {
|
|
@@ -7,6 +7,32 @@ use crate::messaging::{DeliveryOutcome, DeliveryRefusal, DeliveryStage, Delivery
|
|
|
7
7
|
/// `cmd_send`(`commands.py:164`)。解析 target(`--to` fanout / 单 target / `*`)→ [`MessageTarget`],
|
|
8
8
|
/// 拼 [`SendOptions`](no_ack→requires_ack 取反、no_wait→wait_visible 取反等)→ `messaging::send_message`。
|
|
9
9
|
pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
10
|
+
// F1 (0.3.26, cross-team send): --pane <pane_id> direct targeting.
|
|
11
|
+
// Mutually exclusive with target / --to (agent-name routing).
|
|
12
|
+
if let Some(ref pane_id) = args.pane {
|
|
13
|
+
if args.target.is_some() || args.targets.is_some() {
|
|
14
|
+
return Err(CliError::Usage(
|
|
15
|
+
"--pane and TARGET/--to are mutually exclusive: \
|
|
16
|
+
--pane bypasses agent-name routing and injects directly into the \
|
|
17
|
+
specified tmux pane (cross-team capable)"
|
|
18
|
+
.to_string(),
|
|
19
|
+
));
|
|
20
|
+
}
|
|
21
|
+
let content = args.message.join(" ");
|
|
22
|
+
if content.is_empty() {
|
|
23
|
+
return Err(CliError::Usage("--pane requires a non-empty message".to_string()));
|
|
24
|
+
}
|
|
25
|
+
let value = send_to_pane_direct(
|
|
26
|
+
&args.workspace,
|
|
27
|
+
pane_id,
|
|
28
|
+
&content,
|
|
29
|
+
&args.sender,
|
|
30
|
+
args.task.as_deref(),
|
|
31
|
+
args.team.as_deref(),
|
|
32
|
+
args.json,
|
|
33
|
+
)?;
|
|
34
|
+
return Ok(CmdResult::from_json(value, args.json));
|
|
35
|
+
}
|
|
10
36
|
let selected = crate::state::selector::resolve_active_team(
|
|
11
37
|
&args.workspace,
|
|
12
38
|
args.team.as_deref(),
|
|
@@ -37,6 +63,86 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
|
37
63
|
Ok(CmdResult::from_json(value, args.json))
|
|
38
64
|
}
|
|
39
65
|
|
|
66
|
+
/// F1 (0.3.26): direct pane-id send — bypasses agent-name routing + team
|
|
67
|
+
/// membership check. Constructs `Target::Pane`, renders the message with
|
|
68
|
+
/// the standard protocol block (Team Agent message from sender + token),
|
|
69
|
+
/// injects via the default tmux backend, and surfaces the inject report as
|
|
70
|
+
/// a JSON result. Cross-team capable: no restriction on which tmux session
|
|
71
|
+
/// or socket the pane belongs to.
|
|
72
|
+
fn send_to_pane_direct(
|
|
73
|
+
workspace: &Path,
|
|
74
|
+
pane_id: &str,
|
|
75
|
+
content: &str,
|
|
76
|
+
sender: &str,
|
|
77
|
+
task_id: Option<&str>,
|
|
78
|
+
team: Option<&str>,
|
|
79
|
+
json: bool,
|
|
80
|
+
) -> Result<serde_json::Value, CliError> {
|
|
81
|
+
use crate::messaging::delivery::render_message;
|
|
82
|
+
use crate::transport::{InjectPayload, Key, PaneId, Target};
|
|
83
|
+
|
|
84
|
+
let message_id = format!("pane_send_{}", chrono::Utc::now().timestamp_millis());
|
|
85
|
+
let rendered = render_message(sender, task_id, content, &message_id);
|
|
86
|
+
let target = Target::Pane(PaneId::new(pane_id));
|
|
87
|
+
let run_workspace = crate::model::paths::canonical_run_workspace(workspace)
|
|
88
|
+
.unwrap_or_else(|_| workspace.to_path_buf());
|
|
89
|
+
let transport = crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
|
|
90
|
+
&run_workspace,
|
|
91
|
+
team,
|
|
92
|
+
)
|
|
93
|
+
.unwrap_or_else(|_| crate::tmux_backend::TmuxBackend::for_workspace(&run_workspace));
|
|
94
|
+
let event_log = crate::event_log::EventLog::new(&run_workspace);
|
|
95
|
+
// Warn if the pane is not in the team's known agents (cross-team usage).
|
|
96
|
+
let state = crate::state::persist::load_runtime_state(&run_workspace).ok();
|
|
97
|
+
let in_team = state
|
|
98
|
+
.as_ref()
|
|
99
|
+
.and_then(|s| s.get("agents"))
|
|
100
|
+
.and_then(serde_json::Value::as_object)
|
|
101
|
+
.is_some_and(|agents| {
|
|
102
|
+
agents.values().any(|agent| {
|
|
103
|
+
agent
|
|
104
|
+
.get("pane_id")
|
|
105
|
+
.and_then(serde_json::Value::as_str)
|
|
106
|
+
.is_some_and(|p| p == pane_id)
|
|
107
|
+
})
|
|
108
|
+
});
|
|
109
|
+
if !in_team {
|
|
110
|
+
eprintln!(
|
|
111
|
+
"warning: pane {pane_id} is not in the team's known agents — \
|
|
112
|
+
cross-team delivery (F1)"
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
let transport: &dyn crate::transport::Transport = &transport;
|
|
116
|
+
let report = transport
|
|
117
|
+
.inject(&target, &InjectPayload::Text(rendered), Key::Enter, true)
|
|
118
|
+
.map_err(|e| CliError::Runtime(format!("inject to pane {pane_id} failed: {e}")))?;
|
|
119
|
+
let _ = event_log.write(
|
|
120
|
+
"send.pane_direct",
|
|
121
|
+
serde_json::json!({
|
|
122
|
+
"pane_id": pane_id,
|
|
123
|
+
"sender": sender,
|
|
124
|
+
"message_id": message_id,
|
|
125
|
+
"submit_verification": crate::transport::submit_verification_wire(report.submit_verification),
|
|
126
|
+
"inject_verification": format!("{:?}", report.inject_verification),
|
|
127
|
+
"in_team": in_team,
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
let ok = matches!(
|
|
131
|
+
report.submit_verification,
|
|
132
|
+
crate::transport::SubmitVerification::EnterSentWithoutPlaceholderCheck
|
|
133
|
+
| crate::transport::SubmitVerification::PastedContentPromptAbsentAfterSubmit
|
|
134
|
+
| crate::transport::SubmitVerification::KeySentAfterVisibleToken { .. }
|
|
135
|
+
);
|
|
136
|
+
Ok(serde_json::json!({
|
|
137
|
+
"ok": ok,
|
|
138
|
+
"pane_id": pane_id,
|
|
139
|
+
"message_id": message_id,
|
|
140
|
+
"submit_verification": crate::transport::submit_verification_wire(report.submit_verification),
|
|
141
|
+
"inject_verification": format!("{:?}", report.inject_verification),
|
|
142
|
+
"in_team": in_team,
|
|
143
|
+
}))
|
|
144
|
+
}
|
|
145
|
+
|
|
40
146
|
pub fn cmd_fallback_send_leader(args: &FallbackSendLeaderArgs) -> Result<CmdResult, CliError> {
|
|
41
147
|
if let Some(value) = fallback_business_refusal(&args.primary_error, args.json) {
|
|
42
148
|
return Ok(value);
|