@team-agent/installer 0.5.4 → 0.5.6
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 +9 -1
- package/crates/team-agent/src/cli/send.rs +69 -2
- package/crates/team-agent/src/cli/status_port.rs +62 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +12 -19
- package/crates/team-agent/src/lifecycle/restart/agent.rs +29 -0
- package/crates/team-agent/src/mcp_server/helpers.rs +144 -34
- package/crates/team-agent/src/mcp_server/mod.rs +3 -3
- package/crates/team-agent/src/mcp_server/tests/tools.rs +128 -0
- package/crates/team-agent/src/mcp_server/tools.rs +6 -7
- package/crates/team-agent/src/messaging/delivery.rs +255 -31
- package/crates/team-agent/src/messaging/leader_receiver.rs +60 -25
- package/crates/team-agent/src/messaging/tests/runtime.rs +457 -0
- package/crates/team-agent/src/messaging/watchers.rs +36 -19
- package/package.json +4 -4
|
@@ -21,9 +21,8 @@ use crate::messaging::{self, MessageTarget, SendOptions};
|
|
|
21
21
|
|
|
22
22
|
use super::helpers::{
|
|
23
23
|
delivery_outcome_value, ensure_object, enum_value, insert_array, is_worker_recipient,
|
|
24
|
-
json_dumps_default, latest_task_for_assignee,
|
|
25
|
-
|
|
26
|
-
tool_runtime_error,
|
|
24
|
+
json_dumps_default, latest_reportable_message_for, latest_task_for_assignee, non_empty_string,
|
|
25
|
+
normalized_envelope_value, object_fields, requires_ack_for_target, tool_runtime_error,
|
|
27
26
|
};
|
|
28
27
|
use super::normalize::{
|
|
29
28
|
compact_tool_result, normalize_report_envelope, normalize_result_status_observed,
|
|
@@ -326,9 +325,9 @@ impl TeamOrchestratorTools {
|
|
|
326
325
|
// 1. explicit arg
|
|
327
326
|
// 2. latest nonterminal task assigned to this agent in
|
|
328
327
|
// teams.<owner>.tasks (scoped) → top-level tasks
|
|
329
|
-
// 3.
|
|
330
|
-
// task id and no result yet (message-scope
|
|
331
|
-
// collect path: is_message_scoped_result)
|
|
328
|
+
// 3. current/in-flight reportable direct message to this agent
|
|
329
|
+
// with no task id and no result yet (message-scope
|
|
330
|
+
// correlation — collect path: is_message_scoped_result)
|
|
332
331
|
// 4. "manual" — truly uncorrelated; collect still rejects
|
|
333
332
|
let owner_team_id_str = self.owner_team_id.as_ref().map(|t| t.as_str().to_string());
|
|
334
333
|
let resolved = task_id
|
|
@@ -344,7 +343,7 @@ impl TeamOrchestratorTools {
|
|
|
344
343
|
})
|
|
345
344
|
.or_else(|| {
|
|
346
345
|
self.agent_id.as_ref().and_then(|agent| {
|
|
347
|
-
|
|
346
|
+
latest_reportable_message_for(
|
|
348
347
|
&self.workspace,
|
|
349
348
|
agent.as_str(),
|
|
350
349
|
owner_team_id_str.as_deref(),
|
|
@@ -165,7 +165,7 @@ pub fn deliver_pending_message(
|
|
|
165
165
|
};
|
|
166
166
|
let attempt = if store.claim_for_delivery(message_id)? {
|
|
167
167
|
message.delivery_attempts.saturating_add(1)
|
|
168
|
-
} else if message.status == "target_resolved" {
|
|
168
|
+
} else if message.status == "target_resolved" && message.error.is_some() {
|
|
169
169
|
bump_delivery_attempts(store, message_id)?
|
|
170
170
|
} else {
|
|
171
171
|
return Ok(DeliveryOutcome {
|
|
@@ -190,6 +190,42 @@ pub fn deliver_pending_message(
|
|
|
190
190
|
)? {
|
|
191
191
|
return Ok(outcome);
|
|
192
192
|
}
|
|
193
|
+
// 0.5.4 gate054 cross-team leak fix: a `leader_receiver.status`
|
|
194
|
+
// other than `"attached"` (restart demotes killed-session receivers
|
|
195
|
+
// to `"rebind_required"` — lifecycle/restart/rebuild.rs:1311) MUST
|
|
196
|
+
// refuse delivery before any pane injection or cross-server pane-id
|
|
197
|
+
// probing. Without this gate, a stale receiver on team A's socket
|
|
198
|
+
// could otherwise fall back to the same pane id on the main tmux
|
|
199
|
+
// server (delivery.rs:1150-1201) and inject A's leader traffic into
|
|
200
|
+
// team B's leader pane. See .team/artifacts/0.5.4-gate-crossteam-notify-triage.md.
|
|
201
|
+
if let Some(status) = leader_receiver_status(state) {
|
|
202
|
+
if status != "attached" {
|
|
203
|
+
store.mark(message_id, "failed", Some("leader_not_attached"))?;
|
|
204
|
+
event_log.write(
|
|
205
|
+
"leader_receiver.delivery_blocked",
|
|
206
|
+
serde_json::json!({
|
|
207
|
+
"message_id": message_id,
|
|
208
|
+
"sender": message.sender,
|
|
209
|
+
"reason": "leader_receiver_not_attached",
|
|
210
|
+
"channel": "rebind_required",
|
|
211
|
+
"action": "run team-agent attach-leader or team-agent takeover",
|
|
212
|
+
"status": status,
|
|
213
|
+
}),
|
|
214
|
+
)?;
|
|
215
|
+
return Ok(DeliveryOutcome {
|
|
216
|
+
ok: false,
|
|
217
|
+
status: DeliveryStatus::Refused,
|
|
218
|
+
message_status: MessageStatusShadow("failed".to_string()),
|
|
219
|
+
message_id: Some(message_id.to_string()),
|
|
220
|
+
verification: Some(
|
|
221
|
+
"run team-agent attach-leader or team-agent takeover".to_string(),
|
|
222
|
+
),
|
|
223
|
+
stage: None,
|
|
224
|
+
reason: Some(DeliveryRefusal::LeaderNotAttached),
|
|
225
|
+
channel: Some("rebind_required".to_string()),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
193
229
|
if crate::codex_app_server::receiver_is_app_server(receiver) {
|
|
194
230
|
return deliver_leader_via_app_server(
|
|
195
231
|
store,
|
|
@@ -288,6 +324,16 @@ pub fn deliver_pending_message(
|
|
|
288
324
|
});
|
|
289
325
|
}
|
|
290
326
|
let target = resolve_inject_target(state, &message.recipient, transport, &live_targets);
|
|
327
|
+
if let Some(outcome) = block_missing_worker_target(
|
|
328
|
+
store,
|
|
329
|
+
event_log,
|
|
330
|
+
message_id,
|
|
331
|
+
&message.recipient,
|
|
332
|
+
&target,
|
|
333
|
+
&live_targets,
|
|
334
|
+
)? {
|
|
335
|
+
return Ok(outcome);
|
|
336
|
+
}
|
|
291
337
|
// Contract B / MUST-10 / N31/N32: physical paste+Enter into a startup trust/update
|
|
292
338
|
// menu is NOT provider delivery — the menu consumes the Enter and the task text
|
|
293
339
|
// is lost (PROBE-2 root-cause). Before injection, peek at the recipient's pane for
|
|
@@ -358,6 +404,15 @@ pub fn deliver_pending_message(
|
|
|
358
404
|
channel: Some("rebind_required".to_string()),
|
|
359
405
|
});
|
|
360
406
|
}
|
|
407
|
+
if transport_error_is_target_missing(&error) {
|
|
408
|
+
return mark_worker_target_missing(
|
|
409
|
+
store,
|
|
410
|
+
event_log,
|
|
411
|
+
message_id,
|
|
412
|
+
&message.recipient,
|
|
413
|
+
Some(reason),
|
|
414
|
+
);
|
|
415
|
+
}
|
|
361
416
|
event_log.write(
|
|
362
417
|
"send.inject_failed",
|
|
363
418
|
serde_json::json!({
|
|
@@ -405,6 +460,14 @@ pub fn deliver_pending_message(
|
|
|
405
460
|
});
|
|
406
461
|
}
|
|
407
462
|
};
|
|
463
|
+
record_current_turn_after_inject_if_leader_to_worker_scoped(
|
|
464
|
+
workspace,
|
|
465
|
+
&message.sender,
|
|
466
|
+
&message.recipient,
|
|
467
|
+
message_id,
|
|
468
|
+
event_log,
|
|
469
|
+
canonical_owner_team_id.as_deref(),
|
|
470
|
+
)?;
|
|
408
471
|
let submit_verified = inject_submit_verified(&inject_report);
|
|
409
472
|
let readback_verified = pane_readback_verified(&inject_report);
|
|
410
473
|
// Leader pane: inject success is delivery proof. Worker pane: post-submit
|
|
@@ -574,6 +637,136 @@ pub fn deliver_pending_message(
|
|
|
574
637
|
Ok(outcome)
|
|
575
638
|
}
|
|
576
639
|
|
|
640
|
+
fn block_missing_worker_target(
|
|
641
|
+
store: &MessageStore,
|
|
642
|
+
event_log: &EventLog,
|
|
643
|
+
message_id: &str,
|
|
644
|
+
recipient: &str,
|
|
645
|
+
target: &Target,
|
|
646
|
+
live_targets: &[PaneInfo],
|
|
647
|
+
) -> Result<Option<DeliveryOutcome>, MessagingError> {
|
|
648
|
+
let Target::SessionWindow { session, window } = target else {
|
|
649
|
+
return Ok(None);
|
|
650
|
+
};
|
|
651
|
+
if live_targets.is_empty() {
|
|
652
|
+
return Ok(None);
|
|
653
|
+
}
|
|
654
|
+
if window.as_str().ends_with("_pane_conflicts_with_leader") {
|
|
655
|
+
return Ok(None);
|
|
656
|
+
}
|
|
657
|
+
if live_targets.iter().any(|pane| {
|
|
658
|
+
pane.session.as_str() == session.as_str()
|
|
659
|
+
&& pane
|
|
660
|
+
.window_name
|
|
661
|
+
.as_ref()
|
|
662
|
+
.is_some_and(|name| name.as_str() == window.as_str())
|
|
663
|
+
}) {
|
|
664
|
+
return Ok(None);
|
|
665
|
+
}
|
|
666
|
+
mark_worker_target_missing(store, event_log, message_id, recipient, None).map(Some)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
fn mark_worker_target_missing(
|
|
670
|
+
store: &MessageStore,
|
|
671
|
+
event_log: &EventLog,
|
|
672
|
+
message_id: &str,
|
|
673
|
+
recipient: &str,
|
|
674
|
+
verification: Option<String>,
|
|
675
|
+
) -> Result<DeliveryOutcome, MessagingError> {
|
|
676
|
+
let action = format!("run team-agent start-agent {recipient} --allow-fresh");
|
|
677
|
+
store.mark(
|
|
678
|
+
message_id,
|
|
679
|
+
"queued_pane_missing",
|
|
680
|
+
Some("tmux_target_missing"),
|
|
681
|
+
)?;
|
|
682
|
+
event_log.write(
|
|
683
|
+
"send.inject_blocked",
|
|
684
|
+
serde_json::json!({
|
|
685
|
+
"message_id": message_id,
|
|
686
|
+
"recipient": recipient,
|
|
687
|
+
"reason": "tmux_target_missing",
|
|
688
|
+
"channel": "delivery_blocked",
|
|
689
|
+
"action": action,
|
|
690
|
+
"verification": verification,
|
|
691
|
+
}),
|
|
692
|
+
)?;
|
|
693
|
+
Ok(DeliveryOutcome {
|
|
694
|
+
ok: false,
|
|
695
|
+
status: DeliveryStatus::Blocked,
|
|
696
|
+
message_status: MessageStatusShadow("queued_pane_missing".to_string()),
|
|
697
|
+
message_id: Some(message_id.to_string()),
|
|
698
|
+
verification: verification.or(Some(action)),
|
|
699
|
+
stage: Some(DeliveryStage::Inject),
|
|
700
|
+
reason: Some(DeliveryRefusal::TmuxTargetMissing),
|
|
701
|
+
channel: Some("delivery_blocked".to_string()),
|
|
702
|
+
})
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
fn transport_error_is_target_missing(error: &crate::transport::TransportError) -> bool {
|
|
706
|
+
match error {
|
|
707
|
+
crate::transport::TransportError::TargetNotFound { .. } => true,
|
|
708
|
+
crate::transport::TransportError::Subprocess { stderr, .. } => {
|
|
709
|
+
let stderr = stderr.to_ascii_lowercase();
|
|
710
|
+
(stderr.contains("can't find") || stderr.contains("no such"))
|
|
711
|
+
&& (stderr.contains("pane")
|
|
712
|
+
|| stderr.contains("window")
|
|
713
|
+
|| stderr.contains("session"))
|
|
714
|
+
}
|
|
715
|
+
_ => false,
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
pub fn requeue_worker_target_missing_messages(
|
|
720
|
+
workspace: &Path,
|
|
721
|
+
store: &MessageStore,
|
|
722
|
+
event_log: &EventLog,
|
|
723
|
+
recipient: &str,
|
|
724
|
+
owner_team_id: Option<&str>,
|
|
725
|
+
) -> Result<Vec<String>, MessagingError> {
|
|
726
|
+
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
727
|
+
let now = chrono::Utc::now().to_rfc3339();
|
|
728
|
+
let mut stmt = conn.prepare(
|
|
729
|
+
"select message_id from messages
|
|
730
|
+
where recipient = ?1
|
|
731
|
+
and status = 'queued_pane_missing'
|
|
732
|
+
and error = 'tmux_target_missing'
|
|
733
|
+
and (
|
|
734
|
+
(?2 is null and owner_team_id is null)
|
|
735
|
+
or owner_team_id = ?2
|
|
736
|
+
)
|
|
737
|
+
order by created_at, message_id",
|
|
738
|
+
)?;
|
|
739
|
+
let ids = stmt
|
|
740
|
+
.query_map(params![recipient, owner_team_id], |row| {
|
|
741
|
+
row.get::<_, String>(0)
|
|
742
|
+
})?
|
|
743
|
+
.collect::<Result<Vec<_>, _>>()?;
|
|
744
|
+
drop(stmt);
|
|
745
|
+
for message_id in &ids {
|
|
746
|
+
conn.execute(
|
|
747
|
+
"update messages
|
|
748
|
+
set status = 'accepted',
|
|
749
|
+
error = null,
|
|
750
|
+
updated_at = ?2
|
|
751
|
+
where message_id = ?1",
|
|
752
|
+
params![message_id, now.as_str()],
|
|
753
|
+
)?;
|
|
754
|
+
}
|
|
755
|
+
if !ids.is_empty() {
|
|
756
|
+
event_log.write(
|
|
757
|
+
"worker_receiver.blocked_messages_requeued",
|
|
758
|
+
serde_json::json!({
|
|
759
|
+
"recipient": recipient,
|
|
760
|
+
"owner_team_id": owner_team_id,
|
|
761
|
+
"count": ids.len(),
|
|
762
|
+
"message_ids": ids,
|
|
763
|
+
}),
|
|
764
|
+
)?;
|
|
765
|
+
}
|
|
766
|
+
let _ = workspace;
|
|
767
|
+
Ok(ids)
|
|
768
|
+
}
|
|
769
|
+
|
|
577
770
|
/// 0.3.27: promoted to pub(crate) for leader_receiver.rs verification gate.
|
|
578
771
|
pub(crate) fn inject_submit_verified(report: &InjectReport) -> bool {
|
|
579
772
|
match report.submit_verification {
|
|
@@ -1170,34 +1363,17 @@ fn delivery_transport_for_recipient<'a>(
|
|
|
1170
1363
|
if socket == crate::tmux_backend::socket_name_for_workspace(workspace) {
|
|
1171
1364
|
DeliveryTransport::Borrowed(product_transport)
|
|
1172
1365
|
} else {
|
|
1366
|
+
// 0.5.4 gate054 cross-team leak fix: when `leader_receiver.tmux_socket`
|
|
1367
|
+
// is present and non-canonical, it is a hard channel boundary — the
|
|
1368
|
+
// caller (team A on socket X) explicitly recorded which tmux server
|
|
1369
|
+
// hosts its leader pane. Pane ids (`%N`) are only unique per tmux
|
|
1370
|
+
// server, so probing product/default servers for the same pane id and
|
|
1371
|
+
// switching transports on a match can cross the team/tmux boundary and
|
|
1372
|
+
// inject A's leader traffic into team B's leader pane. If the pane is
|
|
1373
|
+
// absent on the recorded endpoint, let the downstream
|
|
1374
|
+
// `leader_receiver_pane_is_usable` guard produce `rebind_required`
|
|
1375
|
+
// instead of falling back to another server.
|
|
1173
1376
|
let endpoint_backend = crate::transport_factory::tmux_endpoint_transport(socket);
|
|
1174
|
-
if let Some(pane_id) = pane_id {
|
|
1175
|
-
if endpoint_backend
|
|
1176
|
-
.list_targets()
|
|
1177
|
-
.unwrap_or_default()
|
|
1178
|
-
.iter()
|
|
1179
|
-
.any(|target| target.pane_id.as_str() == pane_id)
|
|
1180
|
-
{
|
|
1181
|
-
return DeliveryTransport::Owned(Box::new(endpoint_backend));
|
|
1182
|
-
}
|
|
1183
|
-
if product_transport
|
|
1184
|
-
.list_targets()
|
|
1185
|
-
.unwrap_or_default()
|
|
1186
|
-
.iter()
|
|
1187
|
-
.any(|target| target.pane_id.as_str() == pane_id)
|
|
1188
|
-
{
|
|
1189
|
-
return DeliveryTransport::Borrowed(product_transport);
|
|
1190
|
-
}
|
|
1191
|
-
let default_backend = crate::transport_factory::tmux_default_transport();
|
|
1192
|
-
if default_backend
|
|
1193
|
-
.list_targets()
|
|
1194
|
-
.unwrap_or_default()
|
|
1195
|
-
.iter()
|
|
1196
|
-
.any(|target| target.pane_id.as_str() == pane_id)
|
|
1197
|
-
{
|
|
1198
|
-
return DeliveryTransport::Owned(Box::new(default_backend));
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
1377
|
DeliveryTransport::Owned(Box::new(endpoint_backend))
|
|
1202
1378
|
}
|
|
1203
1379
|
}
|
|
@@ -1237,6 +1413,15 @@ fn leader_receiver_tmux_socket(state: &serde_json::Value) -> Option<&str> {
|
|
|
1237
1413
|
leader_receiver_field(state, "tmux_socket")
|
|
1238
1414
|
}
|
|
1239
1415
|
|
|
1416
|
+
/// 0.5.4 gate054 cross-team leak fix: read the bound `leader_receiver.status`
|
|
1417
|
+
/// through the same team-scope resolution as [`leader_receiver_tmux_socket`].
|
|
1418
|
+
/// Restart marks demoted receivers as `"rebind_required"`
|
|
1419
|
+
/// (lifecycle/restart/rebuild.rs:1311); delivery must refuse before injecting
|
|
1420
|
+
/// or cross-server pane-id probing (delivery.rs:1150-1201).
|
|
1421
|
+
fn leader_receiver_status(state: &serde_json::Value) -> Option<&str> {
|
|
1422
|
+
leader_receiver_field(state, "status")
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1240
1425
|
fn leader_receiver_has_noncanonical_tmux_socket(state: &serde_json::Value) -> bool {
|
|
1241
1426
|
leader_receiver_tmux_socket(state)
|
|
1242
1427
|
.is_some_and(|socket| socket != "default" && !std::path::Path::new(socket).is_absolute())
|
|
@@ -1494,6 +1679,7 @@ struct PendingMessage {
|
|
|
1494
1679
|
task_id: Option<String>,
|
|
1495
1680
|
owner_team_id: Option<String>,
|
|
1496
1681
|
status: String,
|
|
1682
|
+
error: Option<String>,
|
|
1497
1683
|
delivery_attempts: u32,
|
|
1498
1684
|
}
|
|
1499
1685
|
|
|
@@ -1504,7 +1690,7 @@ fn message_for_delivery(
|
|
|
1504
1690
|
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
1505
1691
|
let message = conn
|
|
1506
1692
|
.query_row(
|
|
1507
|
-
"select sender, recipient, content, task_id, owner_team_id, status, delivery_attempts from messages where message_id = ?1",
|
|
1693
|
+
"select sender, recipient, content, task_id, owner_team_id, status, error, delivery_attempts from messages where message_id = ?1",
|
|
1508
1694
|
params![message_id],
|
|
1509
1695
|
|row| {
|
|
1510
1696
|
Ok(PendingMessage {
|
|
@@ -1514,7 +1700,8 @@ fn message_for_delivery(
|
|
|
1514
1700
|
task_id: row.get::<_, Option<String>>(3)?,
|
|
1515
1701
|
owner_team_id: row.get::<_, Option<String>>(4)?,
|
|
1516
1702
|
status: row.get::<_, String>(5)?,
|
|
1517
|
-
|
|
1703
|
+
error: row.get::<_, Option<String>>(6)?,
|
|
1704
|
+
delivery_attempts: row.get::<_, i64>(7)?.max(0) as u32,
|
|
1518
1705
|
})
|
|
1519
1706
|
},
|
|
1520
1707
|
)
|
|
@@ -1713,7 +1900,7 @@ pub fn execute_trust_retry(
|
|
|
1713
1900
|
}
|
|
1714
1901
|
|
|
1715
1902
|
/// `_record_turn_open_if_leader_to_worker` (`delivery.py:430`):**take-over arm 来自真实投递**
|
|
1716
|
-
/// (card §121) —— 仅 leader→worker
|
|
1903
|
+
/// (card §121) —— 仅 leader→worker 注入/投递成功后才 arm,绝不凭空 arm。
|
|
1717
1904
|
pub fn record_turn_open_if_leader_to_worker(
|
|
1718
1905
|
workspace: &Path,
|
|
1719
1906
|
state: &serde_json::Value,
|
|
@@ -1728,6 +1915,30 @@ pub fn record_turn_open_if_leader_to_worker(
|
|
|
1728
1915
|
)
|
|
1729
1916
|
}
|
|
1730
1917
|
|
|
1918
|
+
fn record_current_turn_after_inject_if_leader_to_worker_scoped(
|
|
1919
|
+
workspace: &Path,
|
|
1920
|
+
sender: &str,
|
|
1921
|
+
recipient: &str,
|
|
1922
|
+
message_id: &str,
|
|
1923
|
+
event_log: &EventLog,
|
|
1924
|
+
owner_team_id: Option<&str>,
|
|
1925
|
+
) -> Result<(), MessagingError> {
|
|
1926
|
+
if !matches!(sender, "leader" | "Leader") || recipient == "leader" {
|
|
1927
|
+
return Ok(());
|
|
1928
|
+
}
|
|
1929
|
+
let message_id = Some(message_id.to_string());
|
|
1930
|
+
let mut state = scoped_state_for_write(workspace, owner_team_id)?;
|
|
1931
|
+
arm_turn_open(&mut state, recipient, &message_id);
|
|
1932
|
+
save_scoped_state_reapplying_after_conflict(workspace, &state, owner_team_id, |latest| {
|
|
1933
|
+
arm_turn_open(latest, recipient, &message_id);
|
|
1934
|
+
})?;
|
|
1935
|
+
event_log.write(
|
|
1936
|
+
"turn_open.armed_after_inject",
|
|
1937
|
+
serde_json::json!({"agent_id": recipient, "message_id": message_id}),
|
|
1938
|
+
)?;
|
|
1939
|
+
Ok(())
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1731
1942
|
fn record_turn_open_if_leader_to_worker_scoped(
|
|
1732
1943
|
workspace: &Path,
|
|
1733
1944
|
sender: &str,
|
|
@@ -1764,6 +1975,19 @@ fn arm_turn_open(state: &mut serde_json::Value, recipient: &str, message_id: &Op
|
|
|
1764
1975
|
serde_json::json!({"armed": true, "node_id": recipient, "turn_id": message_id}),
|
|
1765
1976
|
);
|
|
1766
1977
|
}
|
|
1978
|
+
if let Some(agent) = root
|
|
1979
|
+
.get_mut("agents")
|
|
1980
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
1981
|
+
.and_then(|agents| agents.get_mut(recipient))
|
|
1982
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
1983
|
+
{
|
|
1984
|
+
agent.insert(
|
|
1985
|
+
"current_task_id".to_string(),
|
|
1986
|
+
message_id.as_ref().map_or(serde_json::Value::Null, |id| {
|
|
1987
|
+
serde_json::Value::String(id.clone())
|
|
1988
|
+
}),
|
|
1989
|
+
);
|
|
1990
|
+
}
|
|
1767
1991
|
}
|
|
1768
1992
|
|
|
1769
1993
|
/// `_stamp_first_send_at_if_leader_to_worker` (`delivery.py:380`):首次 leader→worker 投递戳
|
|
@@ -8,7 +8,7 @@ use serde_json::Value;
|
|
|
8
8
|
use crate::event_log::EventLog;
|
|
9
9
|
use crate::message_store::{MessageStore, NotificationClaimParams};
|
|
10
10
|
use crate::model::ids::TaskId;
|
|
11
|
-
use crate::transport::{InjectPayload, Key, PaneId, Target, Transport};
|
|
11
|
+
use crate::transport::{InjectPayload, InjectReport, Key, PaneId, Target, Transport, TransportError};
|
|
12
12
|
|
|
13
13
|
use super::helpers::MessageStatusShadow;
|
|
14
14
|
use super::{DeliveryOutcome, DeliveryRefusal, DeliveryStage, DeliveryStatus, MessagingError};
|
|
@@ -286,27 +286,41 @@ pub fn deliver_to_leader_fallback_pane(
|
|
|
286
286
|
let rendered = render_fallback_pane_message(content, message_id, primary_error);
|
|
287
287
|
let target = Target::Pane(PaneId::new(&pane_id));
|
|
288
288
|
let payload = InjectPayload::Text(rendered);
|
|
289
|
-
// 0.5.
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
|
|
289
|
+
// 0.5.5 gate054 cross-team notify boundary: when the leader receiver
|
|
290
|
+
// has a recorded `tmux_socket` (canonical), that socket is a HARD
|
|
291
|
+
// channel boundary. Pane ids (`%N`) are only unique per tmux server,
|
|
292
|
+
// so falling back from a missing/dead pane on the recorded endpoint
|
|
293
|
+
// to the workspace-canonical or default tmux server can inject team
|
|
294
|
+
// A's leader traffic into team B's leader pane on a different socket
|
|
295
|
+
// (real-machine gate054 acceptance B-arm: private socket %1 absent
|
|
296
|
+
// after leader kill, default server's %1 was the main leader — the
|
|
297
|
+
// prior chain silently landed A's `report_result` there).
|
|
298
|
+
//
|
|
299
|
+
// Loud-not-silent (user tie-break E23 / N32): if the recorded endpoint
|
|
300
|
+
// rejects the inject we return `Blocked` with `channel=rebind_required`
|
|
301
|
+
// and audit `leader_receiver.fallback_pane_failed` — the message row
|
|
302
|
+
// stays pending in the store, coordinator surfaces the pending
|
|
303
|
+
// notification via status/monitor, and no cross-server injection is
|
|
304
|
+
// attempted.
|
|
305
|
+
let inject_result: Result<InjectReport, TransportError> = match leader_tmux_socket(state) {
|
|
306
|
+
Some(socket) => {
|
|
298
307
|
let backend = crate::transport_factory::tmux_endpoint_transport(socket);
|
|
299
|
-
backend.inject(&target, &payload, Key::Enter, true).ok()
|
|
300
|
-
})
|
|
301
|
-
.map(Ok)
|
|
302
|
-
.unwrap_or_else(|| {
|
|
303
|
-
let backend = crate::transport_factory::tmux_workspace_transport(workspace);
|
|
304
|
-
backend.inject(&target, &payload, Key::Enter, true)
|
|
305
|
-
})
|
|
306
|
-
.or_else(|_| {
|
|
307
|
-
let backend = crate::transport_factory::tmux_default_transport();
|
|
308
308
|
backend.inject(&target, &payload, Key::Enter, true)
|
|
309
|
-
}
|
|
309
|
+
}
|
|
310
|
+
None => {
|
|
311
|
+
// No socket recorded → legacy (pre-0.5) shape where the receiver
|
|
312
|
+
// was implicitly bound to the workspace-canonical tmux server.
|
|
313
|
+
// Keep the workspace→default chain here (single team assumption)
|
|
314
|
+
// but never mix it with a recorded-socket team.
|
|
315
|
+
let backend = crate::transport_factory::tmux_workspace_transport(workspace);
|
|
316
|
+
backend
|
|
317
|
+
.inject(&target, &payload, Key::Enter, true)
|
|
318
|
+
.or_else(|_| {
|
|
319
|
+
let backend = crate::transport_factory::tmux_default_transport();
|
|
320
|
+
backend.inject(&target, &payload, Key::Enter, true)
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
};
|
|
310
324
|
|
|
311
325
|
match inject_result {
|
|
312
326
|
Ok(report) => {
|
|
@@ -367,6 +381,14 @@ pub fn deliver_to_leader_fallback_pane(
|
|
|
367
381
|
}
|
|
368
382
|
}
|
|
369
383
|
Err(error) => {
|
|
384
|
+
// 0.5.5 gate054: when a leader_receiver.tmux_socket is recorded,
|
|
385
|
+
// a fallback inject error is a HARD team-boundary event — we do
|
|
386
|
+
// not cross to workspace/default tmux (see the inject_result
|
|
387
|
+
// branch above). Surface `rebind_required` so callers wire the
|
|
388
|
+
// notification status accordingly; the row stays pending in the
|
|
389
|
+
// store and the audit event carries the same forensic fields as
|
|
390
|
+
// the pre-fix path.
|
|
391
|
+
let socket_bound = leader_tmux_socket(state).is_some();
|
|
370
392
|
event_log.write(
|
|
371
393
|
"leader_receiver.fallback_pane_failed",
|
|
372
394
|
serde_json::json!({
|
|
@@ -377,17 +399,28 @@ pub fn deliver_to_leader_fallback_pane(
|
|
|
377
399
|
"primary_error": primary_error,
|
|
378
400
|
"delivered_via": "fallback_pane",
|
|
379
401
|
"reason": error.to_string(),
|
|
402
|
+
"socket_bound": socket_bound,
|
|
380
403
|
}),
|
|
381
404
|
)?;
|
|
405
|
+
// Row status is left untouched so status/monitor surfaces the
|
|
406
|
+
// pending notification until the operator rebinds the leader.
|
|
407
|
+
let (status, channel) = if socket_bound {
|
|
408
|
+
(
|
|
409
|
+
DeliveryStatus::Blocked,
|
|
410
|
+
"rebind_required".to_string(),
|
|
411
|
+
)
|
|
412
|
+
} else {
|
|
413
|
+
(DeliveryStatus::Failed, "fallback_pane".to_string())
|
|
414
|
+
};
|
|
382
415
|
Ok(DeliveryOutcome {
|
|
383
416
|
ok: false,
|
|
384
|
-
status
|
|
417
|
+
status,
|
|
385
418
|
message_status: MessageStatusShadow("failed".to_string()),
|
|
386
419
|
message_id: Some(message_id.to_string()),
|
|
387
420
|
verification: Some(error.to_string()),
|
|
388
421
|
stage: None,
|
|
389
422
|
reason: Some(DeliveryRefusal::TmuxTargetMissing),
|
|
390
|
-
channel: Some(
|
|
423
|
+
channel: Some(channel),
|
|
391
424
|
})
|
|
392
425
|
}
|
|
393
426
|
}
|
|
@@ -445,9 +478,11 @@ pub(crate) fn leader_pane_bound_but_not_live(workspace: &Path, state: &Value) ->
|
|
|
445
478
|
}
|
|
446
479
|
|
|
447
480
|
fn leader_pane_is_live(workspace: &Path, state: &Value, pane_id: &str) -> bool {
|
|
448
|
-
// 0.5.
|
|
449
|
-
//
|
|
450
|
-
//
|
|
481
|
+
// 0.5.5 gate054 boundary: when a socket is recorded, liveness is
|
|
482
|
+
// scoped to that endpoint. Never union with workspace/default targets
|
|
483
|
+
// (pane ids are only unique per tmux server; a stray same-numbered
|
|
484
|
+
// pane on another server would falsely report "live" and steer the
|
|
485
|
+
// fallback path into a cross-team inject).
|
|
451
486
|
if let Some(socket) = leader_tmux_socket(state) {
|
|
452
487
|
return crate::transport_factory::tmux_endpoint_transport(socket)
|
|
453
488
|
.list_targets()
|