@team-agent/installer 0.3.35 → 0.3.37
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 +33 -11
- package/crates/team-agent/src/cli/mod.rs +30 -0
- package/crates/team-agent/src/cli/send.rs +12 -1
- package/crates/team-agent/src/cli/status_port.rs +2 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +32 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +134 -7
- package/crates/team-agent/src/messaging/delivery.rs +80 -2
- package/crates/team-agent/src/messaging/tests/runtime.rs +82 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -152,7 +152,7 @@ fn quickstart_human(value: &Value) -> String {
|
|
|
152
152
|
.map(|items| items.iter().filter_map(Value::as_str).collect())
|
|
153
153
|
.unwrap_or_default();
|
|
154
154
|
if attach.is_empty() {
|
|
155
|
-
return summary.to_string();
|
|
155
|
+
return append_reminder(summary.to_string(), crate::cli::QUICK_START_REMINDER);
|
|
156
156
|
}
|
|
157
157
|
let mut out = String::from(summary);
|
|
158
158
|
out.push_str("\n\nattach:");
|
|
@@ -160,7 +160,15 @@ fn quickstart_human(value: &Value) -> String {
|
|
|
160
160
|
out.push_str("\n ");
|
|
161
161
|
out.push_str(cmd);
|
|
162
162
|
}
|
|
163
|
-
out
|
|
163
|
+
append_reminder(out, crate::cli::QUICK_START_REMINDER)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
fn append_reminder(text: String, reminder: &str) -> String {
|
|
167
|
+
if text.is_empty() {
|
|
168
|
+
reminder.to_string()
|
|
169
|
+
} else {
|
|
170
|
+
format!("{text}\n{reminder}")
|
|
171
|
+
}
|
|
164
172
|
}
|
|
165
173
|
|
|
166
174
|
/// `cmd_compile`(`commands.py:42`)。
|
|
@@ -240,7 +248,10 @@ pub fn cmd_status_for_team(args: &StatusArgs, team: Option<&str>) -> Result<CmdR
|
|
|
240
248
|
true,
|
|
241
249
|
false,
|
|
242
250
|
)?;
|
|
243
|
-
return Ok(CmdResult::human(
|
|
251
|
+
return Ok(CmdResult::human(append_reminder(
|
|
252
|
+
format_status_summary(&value),
|
|
253
|
+
crate::cli::STATUS_REMINDER,
|
|
254
|
+
)));
|
|
244
255
|
}
|
|
245
256
|
if args.json {
|
|
246
257
|
let value = status_port::status_scoped(
|
|
@@ -252,12 +263,15 @@ pub fn cmd_status_for_team(args: &StatusArgs, team: Option<&str>) -> Result<CmdR
|
|
|
252
263
|
)?;
|
|
253
264
|
return Ok(CmdResult::from_json(value, true));
|
|
254
265
|
}
|
|
255
|
-
Ok(CmdResult::human(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
266
|
+
Ok(CmdResult::human(append_reminder(
|
|
267
|
+
status_port::format_status_scoped(
|
|
268
|
+
&selected.run_workspace,
|
|
269
|
+
&selected.state,
|
|
270
|
+
Some(&selected.team_key),
|
|
271
|
+
args.agent.as_deref(),
|
|
272
|
+
)?,
|
|
273
|
+
crate::cli::STATUS_REMINDER,
|
|
274
|
+
)))
|
|
261
275
|
}
|
|
262
276
|
|
|
263
277
|
fn status_selector_error_payload(error: &str, workspace: &Path) -> Value {
|
|
@@ -276,6 +290,7 @@ fn status_selector_error_payload(error: &str, workspace: &Path) -> Value {
|
|
|
276
290
|
"action": "run `team-agent doctor` or inspect the log path shown here",
|
|
277
291
|
"log": log_path.to_string_lossy().to_string(),
|
|
278
292
|
"workspace": workspace.to_string_lossy().to_string(),
|
|
293
|
+
"reminder": crate::cli::STATUS_REMINDER,
|
|
279
294
|
})
|
|
280
295
|
}
|
|
281
296
|
|
|
@@ -1583,15 +1598,22 @@ mod tests {
|
|
|
1583
1598
|
assert!(out.contains("team started"), "must keep summary; got {out}");
|
|
1584
1599
|
assert!(out.contains("attach:"), "must render attach block; got {out}");
|
|
1585
1600
|
assert!(out.contains("team-y:w1") && out.contains("team-y:w2"), "must list each attach cmd; got {out}");
|
|
1601
|
+
assert!(out.ends_with(crate::cli::QUICK_START_REMINDER), "must append harness reminder; got {out}");
|
|
1586
1602
|
}
|
|
1587
1603
|
|
|
1588
1604
|
#[test]
|
|
1589
1605
|
fn e13_quickstart_human_summary_only_when_no_attach() {
|
|
1590
1606
|
let value = json!({"summary": "quick-start complete"});
|
|
1591
|
-
assert_eq!(
|
|
1607
|
+
assert_eq!(
|
|
1608
|
+
quickstart_human(&value),
|
|
1609
|
+
format!("quick-start complete\n{}", crate::cli::QUICK_START_REMINDER)
|
|
1610
|
+
);
|
|
1592
1611
|
// 空数组也只 summary。
|
|
1593
1612
|
let value2 = json!({"summary": "s", "attach_commands": []});
|
|
1594
|
-
assert_eq!(
|
|
1613
|
+
assert_eq!(
|
|
1614
|
+
quickstart_human(&value2),
|
|
1615
|
+
format!("s\n{}", crate::cli::QUICK_START_REMINDER)
|
|
1616
|
+
);
|
|
1595
1617
|
}
|
|
1596
1618
|
|
|
1597
1619
|
#[test]
|
|
@@ -48,6 +48,9 @@ use crate::messaging::{self, AlertType, MessageTarget, SendOptions};
|
|
|
48
48
|
use crate::model::ids::{TaskId, TeamKey};
|
|
49
49
|
|
|
50
50
|
pub(crate) const COMMS_BOUNDARY_TEXT: &str = "validates live pane binding consistency and zero-token comms contracts. Does NOT perform live runtime message round-trip. (zero token, zero pollution)";
|
|
51
|
+
pub(crate) const QUICK_START_REMINDER: &str = "Reminder: Do not inspect raw worker terminal output during normal operation. Use team-agent status / inbox / collect instead. Wait for report_result.";
|
|
52
|
+
pub(crate) const SEND_REMINDER: &str = "Message delivered. Wait for the worker to report_result. Do not poll the worker terminal with capture-pane.";
|
|
53
|
+
pub(crate) const STATUS_REMINDER: &str = "To wait for results use --watch-result or team-agent collect. Do not capture-pane worker terminals.";
|
|
51
54
|
|
|
52
55
|
pub mod adapters;
|
|
53
56
|
pub mod diagnose;
|
|
@@ -2528,6 +2531,7 @@ pub mod lifecycle_port {
|
|
|
2528
2531
|
"display_backend": display_backend,
|
|
2529
2532
|
"next_actions": next_actions,
|
|
2530
2533
|
"attach_commands": attach_commands,
|
|
2534
|
+
"reminder": crate::cli::QUICK_START_REMINDER,
|
|
2531
2535
|
"readiness": readiness_json.clone(),
|
|
2532
2536
|
"worker_readiness": readiness_json,
|
|
2533
2537
|
})
|
|
@@ -2546,6 +2550,7 @@ pub mod lifecycle_port {
|
|
|
2546
2550
|
"state_path": state_path.map(|p| p.to_string_lossy().to_string()),
|
|
2547
2551
|
"next_actions": next_actions,
|
|
2548
2552
|
"attach_commands": attach_commands,
|
|
2553
|
+
"reminder": crate::cli::QUICK_START_REMINDER,
|
|
2549
2554
|
}),
|
|
2550
2555
|
crate::lifecycle::QuickStartReport::PreflightBlocked {
|
|
2551
2556
|
summary,
|
|
@@ -2558,6 +2563,7 @@ pub mod lifecycle_port {
|
|
|
2558
2563
|
"blockers": blockers,
|
|
2559
2564
|
"next_actions": next_actions,
|
|
2560
2565
|
"attach_commands": attach_commands,
|
|
2566
|
+
"reminder": crate::cli::QUICK_START_REMINDER,
|
|
2561
2567
|
}),
|
|
2562
2568
|
}
|
|
2563
2569
|
}
|
|
@@ -2582,6 +2588,10 @@ pub mod lifecycle_port {
|
|
|
2582
2588
|
Some("tmux -S /tmp/tmux-501/ta-test attach -t team-teamA:worker"),
|
|
2583
2589
|
"B-2: ExistingRuntime JSON must preserve attach_commands instead of only next_actions; value={value}"
|
|
2584
2590
|
);
|
|
2591
|
+
assert_eq!(
|
|
2592
|
+
value.get("reminder").and_then(Value::as_str),
|
|
2593
|
+
Some(crate::cli::QUICK_START_REMINDER)
|
|
2594
|
+
);
|
|
2585
2595
|
}
|
|
2586
2596
|
|
|
2587
2597
|
#[test]
|
|
@@ -2598,6 +2608,20 @@ pub mod lifecycle_port {
|
|
|
2598
2608
|
"B-2: PreflightBlocked JSON must include attach_commands: [] for schema parity with Ready/Restart; value={value}"
|
|
2599
2609
|
);
|
|
2600
2610
|
}
|
|
2611
|
+
|
|
2612
|
+
#[test]
|
|
2613
|
+
fn restart_json_includes_harness_reminder() {
|
|
2614
|
+
let value = restart_value(crate::lifecycle::RestartReport::RefusedResumeAtomicity {
|
|
2615
|
+
unresumable: Vec::new(),
|
|
2616
|
+
allow_fresh: false,
|
|
2617
|
+
error: "resume refused".to_string(),
|
|
2618
|
+
});
|
|
2619
|
+
|
|
2620
|
+
assert_eq!(
|
|
2621
|
+
value.get("reminder").and_then(Value::as_str),
|
|
2622
|
+
Some(crate::cli::QUICK_START_REMINDER)
|
|
2623
|
+
);
|
|
2624
|
+
}
|
|
2601
2625
|
}
|
|
2602
2626
|
|
|
2603
2627
|
fn restart_value(report: crate::lifecycle::RestartReport) -> Value {
|
|
@@ -2616,6 +2640,7 @@ pub mod lifecycle_port {
|
|
|
2616
2640
|
"coordinator_started": coordinator_started,
|
|
2617
2641
|
"next_actions": next_actions,
|
|
2618
2642
|
"attach_commands": attach_commands,
|
|
2643
|
+
"reminder": crate::cli::QUICK_START_REMINDER,
|
|
2619
2644
|
}),
|
|
2620
2645
|
crate::lifecycle::RestartReport::Partial {
|
|
2621
2646
|
session_name,
|
|
@@ -2650,6 +2675,7 @@ pub mod lifecycle_port {
|
|
|
2650
2675
|
"coordinator_started": coordinator_started,
|
|
2651
2676
|
"next_actions": next_actions,
|
|
2652
2677
|
"attach_commands": attach_commands,
|
|
2678
|
+
"reminder": crate::cli::QUICK_START_REMINDER,
|
|
2653
2679
|
}),
|
|
2654
2680
|
crate::lifecycle::RestartReport::Failed {
|
|
2655
2681
|
session_name,
|
|
@@ -2681,6 +2707,7 @@ pub mod lifecycle_port {
|
|
|
2681
2707
|
})).collect::<Vec<_>>(),
|
|
2682
2708
|
"next_actions": next_actions,
|
|
2683
2709
|
"attach_commands": attach_commands,
|
|
2710
|
+
"reminder": crate::cli::QUICK_START_REMINDER,
|
|
2684
2711
|
}),
|
|
2685
2712
|
crate::lifecycle::RestartReport::RefusedResumeAtomicity {
|
|
2686
2713
|
unresumable,
|
|
@@ -2692,6 +2719,7 @@ pub mod lifecycle_port {
|
|
|
2692
2719
|
"allow_fresh": allow_fresh,
|
|
2693
2720
|
"error": error,
|
|
2694
2721
|
"unresumable": unresumable.iter().map(|w| w.agent_id.as_str()).collect::<Vec<_>>(),
|
|
2722
|
+
"reminder": crate::cli::QUICK_START_REMINDER,
|
|
2695
2723
|
}),
|
|
2696
2724
|
crate::lifecycle::RestartReport::RefusedResumeNotReady {
|
|
2697
2725
|
missing,
|
|
@@ -2716,6 +2744,7 @@ pub mod lifecycle_port {
|
|
|
2716
2744
|
"pending_agent_ids": missing.iter().map(|w| w.as_str()).collect::<Vec<_>>(),
|
|
2717
2745
|
},
|
|
2718
2746
|
"next_action": "rerun restart after session capture completes, or pass --allow-fresh to deliberately discard missing context",
|
|
2747
|
+
"reminder": crate::cli::QUICK_START_REMINDER,
|
|
2719
2748
|
}),
|
|
2720
2749
|
crate::lifecycle::RestartReport::RefusedInvalidFirstSendAt {
|
|
2721
2750
|
invalid,
|
|
@@ -2727,6 +2756,7 @@ pub mod lifecycle_port {
|
|
|
2727
2756
|
"allow_fresh": allow_fresh,
|
|
2728
2757
|
"error": error,
|
|
2729
2758
|
"invalid": invalid.iter().map(|w| w.worker_id.as_str()).collect::<Vec<_>>(),
|
|
2759
|
+
"reminder": crate::cli::QUICK_START_REMINDER,
|
|
2730
2760
|
}),
|
|
2731
2761
|
}
|
|
2732
2762
|
}
|
|
@@ -22,7 +22,7 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
|
22
22
|
if content.is_empty() {
|
|
23
23
|
return Err(CliError::Usage("--pane requires a non-empty message".to_string()));
|
|
24
24
|
}
|
|
25
|
-
let value = send_to_pane_direct(
|
|
25
|
+
let mut value = send_to_pane_direct(
|
|
26
26
|
&args.workspace,
|
|
27
27
|
pane_id,
|
|
28
28
|
&content,
|
|
@@ -31,6 +31,7 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
|
31
31
|
args.team.as_deref(),
|
|
32
32
|
args.json,
|
|
33
33
|
)?;
|
|
34
|
+
add_send_reminder_if_ok(&mut value);
|
|
34
35
|
return Ok(CmdResult::from_json(value, args.json));
|
|
35
36
|
}
|
|
36
37
|
let selected = crate::state::selector::resolve_active_team(
|
|
@@ -60,6 +61,7 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
|
60
61
|
obj.insert("watch".to_string(), watch_notice_json(&target, &opts));
|
|
61
62
|
}
|
|
62
63
|
}
|
|
64
|
+
add_send_reminder_if_ok(&mut value);
|
|
63
65
|
Ok(CmdResult::from_json(value, args.json))
|
|
64
66
|
}
|
|
65
67
|
|
|
@@ -471,6 +473,15 @@ fn delivery_outcome_json(
|
|
|
471
473
|
})
|
|
472
474
|
}
|
|
473
475
|
|
|
476
|
+
fn add_send_reminder_if_ok(value: &mut Value) {
|
|
477
|
+
if value.get("ok").and_then(Value::as_bool) != Some(true) {
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if let Some(obj) = value.as_object_mut() {
|
|
481
|
+
obj.insert("reminder".to_string(), json!(crate::cli::SEND_REMINDER));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
474
485
|
fn target_json(target: &MessageTarget) -> Value {
|
|
475
486
|
match target {
|
|
476
487
|
MessageTarget::Single(agent) => json!(agent),
|
|
@@ -99,6 +99,7 @@ use rusqlite::params;
|
|
|
99
99
|
"readiness": readiness,
|
|
100
100
|
"coordinator": coordinator_health_value(health),
|
|
101
101
|
"runtime": runtime_block,
|
|
102
|
+
"reminder": crate::cli::STATUS_REMINDER,
|
|
102
103
|
"last_events": Value::Array(
|
|
103
104
|
crate::event_log::EventLog::new(workspace)
|
|
104
105
|
.tail(10)
|
|
@@ -598,6 +599,7 @@ use rusqlite::params;
|
|
|
598
599
|
"latest_results": take_array(full.get("latest_results"), 5),
|
|
599
600
|
"readiness": full.get("readiness").cloned().unwrap_or_else(|| json!({})),
|
|
600
601
|
"coordinator": compact_object(full.get("coordinator"), &["status", "pid", "metadata_ok", "schema_ok"]),
|
|
602
|
+
"reminder": full.get("reminder").cloned().unwrap_or_else(|| json!(crate::cli::STATUS_REMINDER)),
|
|
601
603
|
"last_events": take_array_tail(full.get("last_events"), 10),
|
|
602
604
|
})
|
|
603
605
|
}
|
|
@@ -50,10 +50,15 @@ use super::*;
|
|
|
50
50
|
"results",
|
|
51
51
|
"latest_results",
|
|
52
52
|
"coordinator",
|
|
53
|
+
"reminder",
|
|
53
54
|
"last_events",
|
|
54
55
|
] {
|
|
55
56
|
assert!(obj.contains_key(key), "compact status missing key `{key}`");
|
|
56
57
|
}
|
|
58
|
+
assert_eq!(
|
|
59
|
+
obj.get("reminder").and_then(|v| v.as_str()),
|
|
60
|
+
Some(crate::cli::STATUS_REMINDER)
|
|
61
|
+
);
|
|
57
62
|
// seeded agent surfaces through the projection.
|
|
58
63
|
assert!(
|
|
59
64
|
obj["agents"].as_object().unwrap().contains_key("a1"),
|
|
@@ -65,6 +70,27 @@ use super::*;
|
|
|
65
70
|
let _ = std::fs::remove_dir_all(&ws);
|
|
66
71
|
}
|
|
67
72
|
|
|
73
|
+
#[test]
|
|
74
|
+
fn cmd_status_human_appends_harness_reminder() {
|
|
75
|
+
let ws = seed_status_workspace();
|
|
76
|
+
let args = StatusArgs {
|
|
77
|
+
agent: None,
|
|
78
|
+
workspace: ws.clone(),
|
|
79
|
+
detail: false,
|
|
80
|
+
summary: false,
|
|
81
|
+
json: false,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
let r = cmd_status_for_team(&args, None).expect("status");
|
|
85
|
+
let text = match r.output {
|
|
86
|
+
CmdOutput::Human(text) => text,
|
|
87
|
+
other => panic!("expected human status output, got {other:?}"),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
assert!(text.ends_with(crate::cli::STATUS_REMINDER), "{text}");
|
|
91
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
92
|
+
}
|
|
93
|
+
|
|
68
94
|
#[test]
|
|
69
95
|
fn status_port_status_reports_managed_leader_topology_and_attach_command() {
|
|
70
96
|
let ws = seed_status_workspace();
|
|
@@ -255,6 +281,12 @@ use super::*;
|
|
|
255
281
|
match r.output {
|
|
256
282
|
CmdOutput::Json(ref v) => {
|
|
257
283
|
assert!(v.get("ok").is_some(), "send result Json must carry `ok`");
|
|
284
|
+
if v.get("ok").and_then(|ok| ok.as_bool()) == Some(true) {
|
|
285
|
+
assert_eq!(
|
|
286
|
+
v.get("reminder").and_then(|reminder| reminder.as_str()),
|
|
287
|
+
Some(crate::cli::SEND_REMINDER)
|
|
288
|
+
);
|
|
289
|
+
}
|
|
258
290
|
}
|
|
259
291
|
other => panic!("cmd_send must emit Json DeliveryOutcome, got {other:?}"),
|
|
260
292
|
}
|
|
@@ -308,14 +308,13 @@ fn pane_conflicts_with_leader_or_other(
|
|
|
308
308
|
else {
|
|
309
309
|
return false;
|
|
310
310
|
};
|
|
311
|
+
let state_socket = runtime_tmux_socket(state);
|
|
312
|
+
let agent_socket = tmux_socket_field(agent).or(state_socket);
|
|
311
313
|
// Check leader anchor.
|
|
312
314
|
for key in ["leader_receiver", "team_owner"] {
|
|
313
|
-
if state
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
.and_then(serde_json::Value::as_str)
|
|
317
|
-
.is_some_and(|lp| lp == pane_id)
|
|
318
|
-
{
|
|
315
|
+
if state.get(key).and_then(pane_socket_binding).is_some_and(|leader| {
|
|
316
|
+
pane_conflicts_on_same_socket(pane_id, agent_socket, leader)
|
|
317
|
+
}) {
|
|
319
318
|
return true;
|
|
320
319
|
}
|
|
321
320
|
}
|
|
@@ -325,10 +324,13 @@ fn pane_conflicts_with_leader_or_other(
|
|
|
325
324
|
if id == agent_id.as_str() {
|
|
326
325
|
continue;
|
|
327
326
|
}
|
|
327
|
+
let other_socket = tmux_socket_field(other).or(state_socket);
|
|
328
328
|
if other
|
|
329
329
|
.get("pane_id")
|
|
330
330
|
.and_then(serde_json::Value::as_str)
|
|
331
|
-
.is_some_and(|op|
|
|
331
|
+
.is_some_and(|op| {
|
|
332
|
+
op == pane_id && !tmux_sockets_known_different(agent_socket, other_socket)
|
|
333
|
+
})
|
|
332
334
|
{
|
|
333
335
|
return true;
|
|
334
336
|
}
|
|
@@ -337,6 +339,53 @@ fn pane_conflicts_with_leader_or_other(
|
|
|
337
339
|
false
|
|
338
340
|
}
|
|
339
341
|
|
|
342
|
+
#[derive(Clone, Copy)]
|
|
343
|
+
struct PaneSocketBinding<'a> {
|
|
344
|
+
pane_id: &'a str,
|
|
345
|
+
tmux_socket: Option<&'a str>,
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
fn pane_conflicts_on_same_socket(
|
|
349
|
+
pane_id: &str,
|
|
350
|
+
agent_socket: Option<&str>,
|
|
351
|
+
other: PaneSocketBinding<'_>,
|
|
352
|
+
) -> bool {
|
|
353
|
+
other.pane_id == pane_id && !tmux_sockets_known_different(agent_socket, other.tmux_socket)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
fn tmux_sockets_known_different(left: Option<&str>, right: Option<&str>) -> bool {
|
|
357
|
+
let (Some(left), Some(right)) = (left, right) else {
|
|
358
|
+
return false;
|
|
359
|
+
};
|
|
360
|
+
if left == right {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
std::path::Path::new(left).is_absolute() && std::path::Path::new(right).is_absolute()
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
fn runtime_tmux_socket(state: &serde_json::Value) -> Option<&str> {
|
|
367
|
+
tmux_socket_field(state)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
fn tmux_socket_field(value: &serde_json::Value) -> Option<&str> {
|
|
371
|
+
value
|
|
372
|
+
.get("tmux_endpoint")
|
|
373
|
+
.or_else(|| value.get("tmux_socket"))
|
|
374
|
+
.and_then(serde_json::Value::as_str)
|
|
375
|
+
.filter(|value| !value.is_empty())
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
fn pane_socket_binding(value: &serde_json::Value) -> Option<PaneSocketBinding<'_>> {
|
|
379
|
+
let pane_id = value
|
|
380
|
+
.get("pane_id")
|
|
381
|
+
.and_then(serde_json::Value::as_str)
|
|
382
|
+
.filter(|s| !s.is_empty() && *s != "__team_agent_unbound__")?;
|
|
383
|
+
Some(PaneSocketBinding {
|
|
384
|
+
pane_id,
|
|
385
|
+
tmux_socket: tmux_socket_field(value),
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
|
|
340
389
|
fn agent_pane_live(
|
|
341
390
|
transport: &dyn crate::transport::Transport,
|
|
342
391
|
agent: &serde_json::Value,
|
|
@@ -950,3 +999,81 @@ fn write_reset_complete_event(
|
|
|
950
999
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
951
1000
|
Ok(())
|
|
952
1001
|
}
|
|
1002
|
+
|
|
1003
|
+
#[cfg(test)]
|
|
1004
|
+
mod tests {
|
|
1005
|
+
use super::*;
|
|
1006
|
+
|
|
1007
|
+
#[test]
|
|
1008
|
+
fn e51_restart_allows_leader_same_pane_id_on_different_tmux_sockets() {
|
|
1009
|
+
let state = serde_json::json!({
|
|
1010
|
+
"tmux_endpoint": "/private/tmp/tmux-501/ta-worker",
|
|
1011
|
+
"leader_receiver": {
|
|
1012
|
+
"pane_id": "%0",
|
|
1013
|
+
"tmux_socket": "/private/tmp/tmux-501/default"
|
|
1014
|
+
},
|
|
1015
|
+
"agents": {
|
|
1016
|
+
"architect": {
|
|
1017
|
+
"pane_id": "%0",
|
|
1018
|
+
"tmux_socket": "/private/tmp/tmux-501/ta-worker"
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
});
|
|
1022
|
+
let agent_id = AgentId::new("architect");
|
|
1023
|
+
let agent = state["agents"]["architect"].clone();
|
|
1024
|
+
|
|
1025
|
+
assert!(
|
|
1026
|
+
!pane_conflicts_with_leader_or_other(&state, &agent_id, &agent),
|
|
1027
|
+
"same pane id on different tmux sockets must not force a fresh restart"
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
#[test]
|
|
1032
|
+
fn e51_restart_keeps_leader_conflict_on_same_tmux_socket() {
|
|
1033
|
+
let socket = "/private/tmp/tmux-501/default";
|
|
1034
|
+
let state = serde_json::json!({
|
|
1035
|
+
"tmux_endpoint": socket,
|
|
1036
|
+
"leader_receiver": {
|
|
1037
|
+
"pane_id": "%0",
|
|
1038
|
+
"tmux_socket": socket
|
|
1039
|
+
},
|
|
1040
|
+
"agents": {
|
|
1041
|
+
"architect": {
|
|
1042
|
+
"pane_id": "%0",
|
|
1043
|
+
"tmux_socket": socket
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
let agent_id = AgentId::new("architect");
|
|
1048
|
+
let agent = state["agents"]["architect"].clone();
|
|
1049
|
+
|
|
1050
|
+
assert!(
|
|
1051
|
+
pane_conflicts_with_leader_or_other(&state, &agent_id, &agent),
|
|
1052
|
+
"same pane id on the same tmux socket must keep the E51 guard"
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
#[test]
|
|
1057
|
+
fn e51_restart_allows_other_agent_same_pane_id_on_different_tmux_sockets() {
|
|
1058
|
+
let state = serde_json::json!({
|
|
1059
|
+
"tmux_endpoint": "/private/tmp/tmux-501/ta-worker",
|
|
1060
|
+
"agents": {
|
|
1061
|
+
"architect": {
|
|
1062
|
+
"pane_id": "%0",
|
|
1063
|
+
"tmux_socket": "/private/tmp/tmux-501/ta-worker"
|
|
1064
|
+
},
|
|
1065
|
+
"reviewer": {
|
|
1066
|
+
"pane_id": "%0",
|
|
1067
|
+
"tmux_socket": "/private/tmp/tmux-501/default"
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
let agent_id = AgentId::new("architect");
|
|
1072
|
+
let agent = state["agents"]["architect"].clone();
|
|
1073
|
+
|
|
1074
|
+
assert!(
|
|
1075
|
+
!pane_conflicts_with_leader_or_other(&state, &agent_id, &agent),
|
|
1076
|
+
"other-agent collision checks must also include tmux_socket"
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
@@ -587,8 +587,10 @@ fn resolve_inject_target(
|
|
|
587
587
|
// is in lease.rs (E51 guard #1) which prevents the conflation; this is the
|
|
588
588
|
// defence-in-depth in the delivery layer.
|
|
589
589
|
if let Some(pane) = cached_pane.as_ref() {
|
|
590
|
-
let
|
|
591
|
-
if
|
|
590
|
+
let worker_socket = worker_tmux_socket(state, agent, transport);
|
|
591
|
+
if leader_receiver_pane_binding(state).is_some_and(|leader| {
|
|
592
|
+
pane_conflicts_on_same_socket(pane.as_str(), worker_socket.as_deref(), leader)
|
|
593
|
+
}) {
|
|
592
594
|
return Target::SessionWindow {
|
|
593
595
|
session: SessionName::new(session),
|
|
594
596
|
window: WindowName::new(format!("{recipient}_pane_conflicts_with_leader")),
|
|
@@ -639,6 +641,57 @@ fn cached_pane_known_dead(transport: &dyn Transport, pane: &PaneId) -> bool {
|
|
|
639
641
|
matches!(transport.liveness(pane), Ok(PaneLiveness::Dead))
|
|
640
642
|
}
|
|
641
643
|
|
|
644
|
+
#[derive(Clone, Copy)]
|
|
645
|
+
struct PaneSocketBinding<'a> {
|
|
646
|
+
pane_id: &'a str,
|
|
647
|
+
tmux_socket: Option<&'a str>,
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
fn pane_conflicts_on_same_socket(
|
|
651
|
+
pane_id: &str,
|
|
652
|
+
worker_socket: Option<&str>,
|
|
653
|
+
leader: PaneSocketBinding<'_>,
|
|
654
|
+
) -> bool {
|
|
655
|
+
leader.pane_id == pane_id
|
|
656
|
+
&& !tmux_sockets_known_different(worker_socket, leader.tmux_socket)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
fn tmux_sockets_known_different(left: Option<&str>, right: Option<&str>) -> bool {
|
|
660
|
+
let (Some(left), Some(right)) = (left, right) else {
|
|
661
|
+
return false;
|
|
662
|
+
};
|
|
663
|
+
if left == right {
|
|
664
|
+
return false;
|
|
665
|
+
}
|
|
666
|
+
std::path::Path::new(left).is_absolute() && std::path::Path::new(right).is_absolute()
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
fn worker_tmux_socket(
|
|
670
|
+
state: &serde_json::Value,
|
|
671
|
+
agent: Option<&serde_json::Value>,
|
|
672
|
+
transport: &dyn Transport,
|
|
673
|
+
) -> Option<String> {
|
|
674
|
+
agent
|
|
675
|
+
.and_then(tmux_socket_field)
|
|
676
|
+
.or_else(|| runtime_tmux_socket(state))
|
|
677
|
+
.map(str::to_string)
|
|
678
|
+
.or_else(|| transport.tmux_endpoint())
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
fn runtime_tmux_socket(state: &serde_json::Value) -> Option<&str> {
|
|
682
|
+
tmux_socket_field(state)
|
|
683
|
+
.or_else(|| active_team_entry(state).and_then(tmux_socket_field))
|
|
684
|
+
.or_else(|| only_team_entry(state).and_then(tmux_socket_field))
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
fn tmux_socket_field(value: &serde_json::Value) -> Option<&str> {
|
|
688
|
+
value
|
|
689
|
+
.get("tmux_endpoint")
|
|
690
|
+
.or_else(|| value.get("tmux_socket"))
|
|
691
|
+
.and_then(serde_json::Value::as_str)
|
|
692
|
+
.filter(|value| !value.is_empty())
|
|
693
|
+
}
|
|
694
|
+
|
|
642
695
|
/// Read the bound leader pane id off the projected or team-scoped runtime state.
|
|
643
696
|
fn leader_receiver_pane_id(state: &serde_json::Value) -> Option<&str> {
|
|
644
697
|
leader_receiver_pane_id_in_state(state)
|
|
@@ -646,6 +699,12 @@ fn leader_receiver_pane_id(state: &serde_json::Value) -> Option<&str> {
|
|
|
646
699
|
.or_else(|| only_team_entry(state).and_then(leader_receiver_pane_id_in_state))
|
|
647
700
|
}
|
|
648
701
|
|
|
702
|
+
fn leader_receiver_pane_binding(state: &serde_json::Value) -> Option<PaneSocketBinding<'_>> {
|
|
703
|
+
leader_receiver_pane_binding_in_state(state)
|
|
704
|
+
.or_else(|| active_team_entry(state).and_then(leader_receiver_pane_binding_in_state))
|
|
705
|
+
.or_else(|| only_team_entry(state).and_then(leader_receiver_pane_binding_in_state))
|
|
706
|
+
}
|
|
707
|
+
|
|
649
708
|
/// `state` is "usable" for leader delivery when the bound `leader_receiver.pane_id`
|
|
650
709
|
/// is present and alive (in `live_targets` or `liveness` probe returns not-Dead).
|
|
651
710
|
///
|
|
@@ -761,6 +820,25 @@ fn leader_receiver_pane_id_in_state(state: &serde_json::Value) -> Option<&str> {
|
|
|
761
820
|
})
|
|
762
821
|
}
|
|
763
822
|
|
|
823
|
+
fn leader_receiver_pane_binding_in_state(
|
|
824
|
+
state: &serde_json::Value,
|
|
825
|
+
) -> Option<PaneSocketBinding<'_>> {
|
|
826
|
+
["leader_receiver", "team_owner"]
|
|
827
|
+
.into_iter()
|
|
828
|
+
.find_map(|key| state.get(key).and_then(pane_socket_binding))
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
fn pane_socket_binding(value: &serde_json::Value) -> Option<PaneSocketBinding<'_>> {
|
|
832
|
+
let pane_id = value
|
|
833
|
+
.get("pane_id")
|
|
834
|
+
.and_then(serde_json::Value::as_str)
|
|
835
|
+
.filter(|s| !s.is_empty() && *s != "__team_agent_unbound__")?;
|
|
836
|
+
Some(PaneSocketBinding {
|
|
837
|
+
pane_id,
|
|
838
|
+
tmux_socket: tmux_socket_field(value),
|
|
839
|
+
})
|
|
840
|
+
}
|
|
841
|
+
|
|
764
842
|
fn leader_receiver_tmux_socket(state: &serde_json::Value) -> Option<&str> {
|
|
765
843
|
leader_receiver_field(state, "tmux_socket")
|
|
766
844
|
}
|
|
@@ -1812,6 +1812,88 @@ fn u1_resolve_inject_target_uses_live_pane_when_cached_pane_drifted() {
|
|
|
1812
1812
|
);
|
|
1813
1813
|
}
|
|
1814
1814
|
|
|
1815
|
+
#[test]
|
|
1816
|
+
fn e51_delivery_allows_same_pane_id_on_different_tmux_sockets() {
|
|
1817
|
+
let ws = tmp_ws("e51diffsocket");
|
|
1818
|
+
let store = store_for(&ws);
|
|
1819
|
+
let log = EventLog::new(&ws);
|
|
1820
|
+
let state = serde_json::json!({
|
|
1821
|
+
"session_name": "team-e51",
|
|
1822
|
+
"tmux_endpoint": "/private/tmp/tmux-501/ta-worker",
|
|
1823
|
+
"leader_receiver": {
|
|
1824
|
+
"pane_id": "%0",
|
|
1825
|
+
"tmux_socket": "/private/tmp/tmux-501/default"
|
|
1826
|
+
},
|
|
1827
|
+
"agents": {
|
|
1828
|
+
"architect": {
|
|
1829
|
+
"provider": "fake",
|
|
1830
|
+
"pane_id": "%0",
|
|
1831
|
+
"window": "architect"
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
1836
|
+
let _ = store
|
|
1837
|
+
.create_message(None, "peer", "architect", "socket-aware", None, false, None)
|
|
1838
|
+
.unwrap();
|
|
1839
|
+
let transport = OfflineTransport::new()
|
|
1840
|
+
.with_tmux_endpoint("/private/tmp/tmux-501/ta-worker")
|
|
1841
|
+
.with_targets(vec![pane_info("%0", "team-e51", "architect")]);
|
|
1842
|
+
|
|
1843
|
+
let delivered = deliver_pending_messages(&ws, &state, &transport, &log)
|
|
1844
|
+
.expect("same pane id on different sockets must not poison delivery");
|
|
1845
|
+
|
|
1846
|
+
assert_eq!(delivered.len(), 1);
|
|
1847
|
+
assert_eq!(
|
|
1848
|
+
transport.inject_targets(),
|
|
1849
|
+
vec![Target::Pane(PaneId::new("%0"))],
|
|
1850
|
+
"E51 must compare pane_id with tmux_socket; different sockets are not a leader conflict"
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
#[test]
|
|
1855
|
+
fn e51_delivery_keeps_same_socket_pane_conflict_guard() {
|
|
1856
|
+
let ws = tmp_ws("e51samesocket");
|
|
1857
|
+
let store = store_for(&ws);
|
|
1858
|
+
let log = EventLog::new(&ws);
|
|
1859
|
+
let socket = "/private/tmp/tmux-501/default";
|
|
1860
|
+
let state = serde_json::json!({
|
|
1861
|
+
"session_name": "team-e51",
|
|
1862
|
+
"tmux_endpoint": socket,
|
|
1863
|
+
"leader_receiver": {
|
|
1864
|
+
"pane_id": "%0",
|
|
1865
|
+
"tmux_socket": socket
|
|
1866
|
+
},
|
|
1867
|
+
"agents": {
|
|
1868
|
+
"architect": {
|
|
1869
|
+
"provider": "fake",
|
|
1870
|
+
"pane_id": "%0",
|
|
1871
|
+
"window": "architect"
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
});
|
|
1875
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
1876
|
+
let _ = store
|
|
1877
|
+
.create_message(None, "peer", "architect", "same-socket", None, false, None)
|
|
1878
|
+
.unwrap();
|
|
1879
|
+
let transport = OfflineTransport::new()
|
|
1880
|
+
.with_tmux_endpoint(socket)
|
|
1881
|
+
.with_targets(vec![pane_info("%0", "team-e51", "architect")]);
|
|
1882
|
+
|
|
1883
|
+
let delivered = deliver_pending_messages(&ws, &state, &transport, &log)
|
|
1884
|
+
.expect("same-socket E51 guard should still resolve to a structured target");
|
|
1885
|
+
|
|
1886
|
+
assert_eq!(delivered.len(), 1);
|
|
1887
|
+
assert_eq!(
|
|
1888
|
+
transport.inject_targets(),
|
|
1889
|
+
vec![Target::SessionWindow {
|
|
1890
|
+
session: SessionName::new("team-e51"),
|
|
1891
|
+
window: WindowName::new("architect_pane_conflicts_with_leader"),
|
|
1892
|
+
}],
|
|
1893
|
+
"same pane id on the same socket must keep the E51 loop guard"
|
|
1894
|
+
);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1815
1897
|
#[test]
|
|
1816
1898
|
fn u1_multi_team_send_does_not_backfill_top_level_leader_binding() {
|
|
1817
1899
|
let ws = tmp_ws("u1backfill");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.37",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.3.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.3.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.3.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.3.37",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.3.37",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.3.37"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|