@team-agent/installer 0.5.3 → 0.5.5
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/diagnose.rs +191 -31
- package/crates/team-agent/src/cli/mod.rs +93 -34
- 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/coordinator/tick.rs +13 -0
- package/crates/team-agent/src/lifecycle/launch.rs +14 -6
- package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +53 -2
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +108 -6
- package/crates/team-agent/src/lifecycle/restart/selection.rs +47 -18
- package/crates/team-agent/src/lifecycle/restart.rs +16 -6
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +287 -53
- package/crates/team-agent/src/lifecycle/tests/restart.rs +144 -5
- package/crates/team-agent/src/lifecycle/types.rs +1 -0
- package/crates/team-agent/src/messaging/delivery.rs +209 -30
- package/crates/team-agent/src/messaging/leader_receiver.rs +60 -25
- package/crates/team-agent/src/messaging/tests/runtime.rs +446 -0
- package/crates/team-agent/src/messaging/watchers.rs +36 -19
- package/crates/team-agent/src/provider/adapter.rs +103 -41
- package/crates/team-agent/src/provider/session/capture.rs +328 -66
- package/crates/team-agent/src/provider/session/resume.rs +30 -28
- package/crates/team-agent/src/provider/session_scan/common.rs +117 -1
- package/crates/team-agent/src/provider/session_scan/copilot.rs +1 -0
- package/crates/team-agent/src/provider/session_scan.rs +1 -0
- package/crates/team-agent/src/state/persist.rs +17 -0
- package/crates/team-agent/src/state/projection.rs +59 -4
- package/package.json +4 -4
|
@@ -101,11 +101,8 @@ fn layer2_backing_missing_refusal_carries_checked_paths_and_recovery_hint() {
|
|
|
101
101
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
102
102
|
static N: AtomicU32 = AtomicU32::new(0);
|
|
103
103
|
let n = N.fetch_add(1, Ordering::Relaxed);
|
|
104
|
-
let ws =
|
|
105
|
-
"ta_rs_l2_backingmiss_{}_{}",
|
|
106
|
-
std::process::id(),
|
|
107
|
-
n
|
|
108
|
-
));
|
|
104
|
+
let ws =
|
|
105
|
+
std::env::temp_dir().join(format!("ta_rs_l2_backingmiss_{}_{}", std::process::id(), n));
|
|
109
106
|
std::fs::create_dir_all(&ws).unwrap();
|
|
110
107
|
|
|
111
108
|
let missing_rollout = ws.join(".missing-rollout.jsonl");
|
|
@@ -168,6 +165,148 @@ fn layer2_backing_missing_refusal_carries_checked_paths_and_recovery_hint() {
|
|
|
168
165
|
}
|
|
169
166
|
}
|
|
170
167
|
|
|
168
|
+
fn write_codex_identity_rollout(
|
|
169
|
+
workspace: &std::path::Path,
|
|
170
|
+
file_name: &str,
|
|
171
|
+
session_id: &str,
|
|
172
|
+
embedded_agent_id: &str,
|
|
173
|
+
) -> std::path::PathBuf {
|
|
174
|
+
let path = workspace.join(file_name);
|
|
175
|
+
let text = format!(
|
|
176
|
+
"{{\"session_meta\":{{\"payload\":{{\"id\":\"{session_id}\",\"cwd\":\"{}\"}}}}}}\n\
|
|
177
|
+
{{\"type\":\"turn_context\",\"payload\":{{}}}}\n\
|
|
178
|
+
{{\"type\":\"response_item\",\"payload\":{{\"content\":[{{\"type\":\"input_text\",\"text\":\"You are Team Agent worker `{embedded_agent_id}` with role `fixture`.\"}}]}}}}\n",
|
|
179
|
+
workspace.to_string_lossy()
|
|
180
|
+
);
|
|
181
|
+
std::fs::write(&path, text).unwrap();
|
|
182
|
+
path
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#[test]
|
|
186
|
+
fn restart_refuses_codex_session_identity_mismatch_without_allow_fresh() {
|
|
187
|
+
let ws = temp_ws();
|
|
188
|
+
let rollout = write_codex_identity_rollout(
|
|
189
|
+
&ws,
|
|
190
|
+
"rollout-frontend-poison.jsonl",
|
|
191
|
+
"019f3327-c35a-7023-b3cd-1bea93a7a157",
|
|
192
|
+
"ios-dev",
|
|
193
|
+
);
|
|
194
|
+
let state = json!({
|
|
195
|
+
"agents": {
|
|
196
|
+
"frontend": {
|
|
197
|
+
"provider": "codex",
|
|
198
|
+
"status": "running",
|
|
199
|
+
"session_id": "019f3327-c35a-7023-b3cd-1bea93a7a157",
|
|
200
|
+
"rollout_path": rollout.to_string_lossy(),
|
|
201
|
+
"captured_at": "2026-07-05T17:04:04Z",
|
|
202
|
+
"captured_via": "fs_watch",
|
|
203
|
+
"spawn_cwd": ws.to_string_lossy(),
|
|
204
|
+
"first_send_at": "2026-07-05T17:10:00Z"
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
let plan = crate::lifecycle::restart::classify_restart_plan_with_resume_validation(
|
|
210
|
+
Some(&ws),
|
|
211
|
+
&state,
|
|
212
|
+
false,
|
|
213
|
+
)
|
|
214
|
+
.unwrap();
|
|
215
|
+
|
|
216
|
+
assert_eq!(plan.decisions.len(), 1);
|
|
217
|
+
assert_eq!(plan.decisions[0].decision, ResumeDecision::Refuse);
|
|
218
|
+
assert_eq!(plan.unresumable.len(), 1);
|
|
219
|
+
let entry = &plan.unresumable[0];
|
|
220
|
+
assert_eq!(entry.agent_id.as_str(), "frontend");
|
|
221
|
+
assert_eq!(entry.reason, "session_identity_mismatch");
|
|
222
|
+
assert_eq!(
|
|
223
|
+
entry
|
|
224
|
+
.refusal_reason
|
|
225
|
+
.as_ref()
|
|
226
|
+
.map(crate::provider::session::ResumeRefusalReason::wire),
|
|
227
|
+
Some("session_identity_mismatch")
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#[test]
|
|
232
|
+
fn restart_identity_probe_reports_real_frontend_ios_dev_mismatch_shape() {
|
|
233
|
+
let ws = temp_ws();
|
|
234
|
+
let rollout = write_codex_identity_rollout(
|
|
235
|
+
&ws,
|
|
236
|
+
"rollout-frontend-poison.jsonl",
|
|
237
|
+
"019f3327-c35a-7023-b3cd-1bea93a7a157",
|
|
238
|
+
"ios-dev",
|
|
239
|
+
);
|
|
240
|
+
let rollout_path = crate::provider::RolloutPath::new(rollout.clone());
|
|
241
|
+
let probe = crate::lifecycle::restart::session_identity_probe_for_agent(
|
|
242
|
+
&aid("frontend"),
|
|
243
|
+
crate::provider::Provider::Codex,
|
|
244
|
+
Some(&rollout_path),
|
|
245
|
+
);
|
|
246
|
+
assert_eq!(probe.identity_ok, Some(false));
|
|
247
|
+
assert_eq!(probe.embedded_agent_id.as_deref(), Some("ios-dev"));
|
|
248
|
+
assert_eq!(probe.rollout_path.as_deref(), Some(rollout.as_path()));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
#[test]
|
|
252
|
+
fn restart_allow_fresh_only_fresh_starts_mismatched_codex_worker() {
|
|
253
|
+
let ws = temp_ws();
|
|
254
|
+
let frontend_rollout = write_codex_identity_rollout(
|
|
255
|
+
&ws,
|
|
256
|
+
"rollout-frontend-poison.jsonl",
|
|
257
|
+
"019f3327-c35a-7023-b3cd-1bea93a7a157",
|
|
258
|
+
"ios-dev",
|
|
259
|
+
);
|
|
260
|
+
let backend_rollout = write_codex_identity_rollout(
|
|
261
|
+
&ws,
|
|
262
|
+
"rollout-backend-valid.jsonl",
|
|
263
|
+
"019f3327-backend-valid",
|
|
264
|
+
"backend",
|
|
265
|
+
);
|
|
266
|
+
let state = json!({
|
|
267
|
+
"agents": {
|
|
268
|
+
"frontend": {
|
|
269
|
+
"provider": "codex",
|
|
270
|
+
"status": "running",
|
|
271
|
+
"session_id": "019f3327-c35a-7023-b3cd-1bea93a7a157",
|
|
272
|
+
"rollout_path": frontend_rollout.to_string_lossy(),
|
|
273
|
+
"captured_at": "2026-07-05T17:04:04Z",
|
|
274
|
+
"captured_via": "fs_watch",
|
|
275
|
+
"spawn_cwd": ws.to_string_lossy()
|
|
276
|
+
},
|
|
277
|
+
"backend": {
|
|
278
|
+
"provider": "codex",
|
|
279
|
+
"status": "running",
|
|
280
|
+
"session_id": "019f3327-backend-valid",
|
|
281
|
+
"rollout_path": backend_rollout.to_string_lossy(),
|
|
282
|
+
"captured_at": "2026-07-05T17:04:05Z",
|
|
283
|
+
"captured_via": "fs_watch",
|
|
284
|
+
"spawn_cwd": ws.to_string_lossy()
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
let plan = crate::lifecycle::restart::classify_restart_plan_with_resume_validation(
|
|
290
|
+
Some(&ws),
|
|
291
|
+
&state,
|
|
292
|
+
true,
|
|
293
|
+
)
|
|
294
|
+
.unwrap();
|
|
295
|
+
|
|
296
|
+
assert!(
|
|
297
|
+
plan.unresumable.is_empty(),
|
|
298
|
+
"allow_fresh should convert the poisoned worker to fresh without refusing; got {:?}",
|
|
299
|
+
plan.unresumable
|
|
300
|
+
);
|
|
301
|
+
let decisions = plan
|
|
302
|
+
.decisions
|
|
303
|
+
.iter()
|
|
304
|
+
.map(|decision| (decision.agent_id.as_str(), decision.decision))
|
|
305
|
+
.collect::<std::collections::BTreeMap<_, _>>();
|
|
306
|
+
assert_eq!(decisions.get("frontend"), Some(&ResumeDecision::FreshStart));
|
|
307
|
+
assert_eq!(decisions.get("backend"), Some(&ResumeDecision::Resume));
|
|
308
|
+
}
|
|
309
|
+
|
|
171
310
|
#[test]
|
|
172
311
|
fn unit0_restart_corrupt_first_send_at_blocks_before_resume_classification() {
|
|
173
312
|
// The corrupt-first_send_at branch is the hard-refuse gate that fires
|
|
@@ -440,6 +440,7 @@ pub struct StartedAgent {
|
|
|
440
440
|
pub agent_id: AgentId,
|
|
441
441
|
pub start_mode: StartMode,
|
|
442
442
|
pub target: String,
|
|
443
|
+
pub spawned_at: String,
|
|
443
444
|
pub session_id: Option<SessionId>,
|
|
444
445
|
pub rollout_path: Option<RolloutPath>,
|
|
445
446
|
pub pending_session_id: Option<SessionId>,
|
|
@@ -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!({
|
|
@@ -574,6 +629,136 @@ pub fn deliver_pending_message(
|
|
|
574
629
|
Ok(outcome)
|
|
575
630
|
}
|
|
576
631
|
|
|
632
|
+
fn block_missing_worker_target(
|
|
633
|
+
store: &MessageStore,
|
|
634
|
+
event_log: &EventLog,
|
|
635
|
+
message_id: &str,
|
|
636
|
+
recipient: &str,
|
|
637
|
+
target: &Target,
|
|
638
|
+
live_targets: &[PaneInfo],
|
|
639
|
+
) -> Result<Option<DeliveryOutcome>, MessagingError> {
|
|
640
|
+
let Target::SessionWindow { session, window } = target else {
|
|
641
|
+
return Ok(None);
|
|
642
|
+
};
|
|
643
|
+
if live_targets.is_empty() {
|
|
644
|
+
return Ok(None);
|
|
645
|
+
}
|
|
646
|
+
if window.as_str().ends_with("_pane_conflicts_with_leader") {
|
|
647
|
+
return Ok(None);
|
|
648
|
+
}
|
|
649
|
+
if live_targets.iter().any(|pane| {
|
|
650
|
+
pane.session.as_str() == session.as_str()
|
|
651
|
+
&& pane
|
|
652
|
+
.window_name
|
|
653
|
+
.as_ref()
|
|
654
|
+
.is_some_and(|name| name.as_str() == window.as_str())
|
|
655
|
+
}) {
|
|
656
|
+
return Ok(None);
|
|
657
|
+
}
|
|
658
|
+
mark_worker_target_missing(store, event_log, message_id, recipient, None).map(Some)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
fn mark_worker_target_missing(
|
|
662
|
+
store: &MessageStore,
|
|
663
|
+
event_log: &EventLog,
|
|
664
|
+
message_id: &str,
|
|
665
|
+
recipient: &str,
|
|
666
|
+
verification: Option<String>,
|
|
667
|
+
) -> Result<DeliveryOutcome, MessagingError> {
|
|
668
|
+
let action = format!("run team-agent start-agent {recipient} --allow-fresh");
|
|
669
|
+
store.mark(
|
|
670
|
+
message_id,
|
|
671
|
+
"queued_pane_missing",
|
|
672
|
+
Some("tmux_target_missing"),
|
|
673
|
+
)?;
|
|
674
|
+
event_log.write(
|
|
675
|
+
"send.inject_blocked",
|
|
676
|
+
serde_json::json!({
|
|
677
|
+
"message_id": message_id,
|
|
678
|
+
"recipient": recipient,
|
|
679
|
+
"reason": "tmux_target_missing",
|
|
680
|
+
"channel": "delivery_blocked",
|
|
681
|
+
"action": action,
|
|
682
|
+
"verification": verification,
|
|
683
|
+
}),
|
|
684
|
+
)?;
|
|
685
|
+
Ok(DeliveryOutcome {
|
|
686
|
+
ok: false,
|
|
687
|
+
status: DeliveryStatus::Blocked,
|
|
688
|
+
message_status: MessageStatusShadow("queued_pane_missing".to_string()),
|
|
689
|
+
message_id: Some(message_id.to_string()),
|
|
690
|
+
verification: verification.or(Some(action)),
|
|
691
|
+
stage: Some(DeliveryStage::Inject),
|
|
692
|
+
reason: Some(DeliveryRefusal::TmuxTargetMissing),
|
|
693
|
+
channel: Some("delivery_blocked".to_string()),
|
|
694
|
+
})
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
fn transport_error_is_target_missing(error: &crate::transport::TransportError) -> bool {
|
|
698
|
+
match error {
|
|
699
|
+
crate::transport::TransportError::TargetNotFound { .. } => true,
|
|
700
|
+
crate::transport::TransportError::Subprocess { stderr, .. } => {
|
|
701
|
+
let stderr = stderr.to_ascii_lowercase();
|
|
702
|
+
(stderr.contains("can't find") || stderr.contains("no such"))
|
|
703
|
+
&& (stderr.contains("pane")
|
|
704
|
+
|| stderr.contains("window")
|
|
705
|
+
|| stderr.contains("session"))
|
|
706
|
+
}
|
|
707
|
+
_ => false,
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
pub fn requeue_worker_target_missing_messages(
|
|
712
|
+
workspace: &Path,
|
|
713
|
+
store: &MessageStore,
|
|
714
|
+
event_log: &EventLog,
|
|
715
|
+
recipient: &str,
|
|
716
|
+
owner_team_id: Option<&str>,
|
|
717
|
+
) -> Result<Vec<String>, MessagingError> {
|
|
718
|
+
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
719
|
+
let now = chrono::Utc::now().to_rfc3339();
|
|
720
|
+
let mut stmt = conn.prepare(
|
|
721
|
+
"select message_id from messages
|
|
722
|
+
where recipient = ?1
|
|
723
|
+
and status = 'queued_pane_missing'
|
|
724
|
+
and error = 'tmux_target_missing'
|
|
725
|
+
and (
|
|
726
|
+
(?2 is null and owner_team_id is null)
|
|
727
|
+
or owner_team_id = ?2
|
|
728
|
+
)
|
|
729
|
+
order by created_at, message_id",
|
|
730
|
+
)?;
|
|
731
|
+
let ids = stmt
|
|
732
|
+
.query_map(params![recipient, owner_team_id], |row| {
|
|
733
|
+
row.get::<_, String>(0)
|
|
734
|
+
})?
|
|
735
|
+
.collect::<Result<Vec<_>, _>>()?;
|
|
736
|
+
drop(stmt);
|
|
737
|
+
for message_id in &ids {
|
|
738
|
+
conn.execute(
|
|
739
|
+
"update messages
|
|
740
|
+
set status = 'accepted',
|
|
741
|
+
error = null,
|
|
742
|
+
updated_at = ?2
|
|
743
|
+
where message_id = ?1",
|
|
744
|
+
params![message_id, now.as_str()],
|
|
745
|
+
)?;
|
|
746
|
+
}
|
|
747
|
+
if !ids.is_empty() {
|
|
748
|
+
event_log.write(
|
|
749
|
+
"worker_receiver.blocked_messages_requeued",
|
|
750
|
+
serde_json::json!({
|
|
751
|
+
"recipient": recipient,
|
|
752
|
+
"owner_team_id": owner_team_id,
|
|
753
|
+
"count": ids.len(),
|
|
754
|
+
"message_ids": ids,
|
|
755
|
+
}),
|
|
756
|
+
)?;
|
|
757
|
+
}
|
|
758
|
+
let _ = workspace;
|
|
759
|
+
Ok(ids)
|
|
760
|
+
}
|
|
761
|
+
|
|
577
762
|
/// 0.3.27: promoted to pub(crate) for leader_receiver.rs verification gate.
|
|
578
763
|
pub(crate) fn inject_submit_verified(report: &InjectReport) -> bool {
|
|
579
764
|
match report.submit_verification {
|
|
@@ -1170,34 +1355,17 @@ fn delivery_transport_for_recipient<'a>(
|
|
|
1170
1355
|
if socket == crate::tmux_backend::socket_name_for_workspace(workspace) {
|
|
1171
1356
|
DeliveryTransport::Borrowed(product_transport)
|
|
1172
1357
|
} else {
|
|
1358
|
+
// 0.5.4 gate054 cross-team leak fix: when `leader_receiver.tmux_socket`
|
|
1359
|
+
// is present and non-canonical, it is a hard channel boundary — the
|
|
1360
|
+
// caller (team A on socket X) explicitly recorded which tmux server
|
|
1361
|
+
// hosts its leader pane. Pane ids (`%N`) are only unique per tmux
|
|
1362
|
+
// server, so probing product/default servers for the same pane id and
|
|
1363
|
+
// switching transports on a match can cross the team/tmux boundary and
|
|
1364
|
+
// inject A's leader traffic into team B's leader pane. If the pane is
|
|
1365
|
+
// absent on the recorded endpoint, let the downstream
|
|
1366
|
+
// `leader_receiver_pane_is_usable` guard produce `rebind_required`
|
|
1367
|
+
// instead of falling back to another server.
|
|
1173
1368
|
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
1369
|
DeliveryTransport::Owned(Box::new(endpoint_backend))
|
|
1202
1370
|
}
|
|
1203
1371
|
}
|
|
@@ -1237,6 +1405,15 @@ fn leader_receiver_tmux_socket(state: &serde_json::Value) -> Option<&str> {
|
|
|
1237
1405
|
leader_receiver_field(state, "tmux_socket")
|
|
1238
1406
|
}
|
|
1239
1407
|
|
|
1408
|
+
/// 0.5.4 gate054 cross-team leak fix: read the bound `leader_receiver.status`
|
|
1409
|
+
/// through the same team-scope resolution as [`leader_receiver_tmux_socket`].
|
|
1410
|
+
/// Restart marks demoted receivers as `"rebind_required"`
|
|
1411
|
+
/// (lifecycle/restart/rebuild.rs:1311); delivery must refuse before injecting
|
|
1412
|
+
/// or cross-server pane-id probing (delivery.rs:1150-1201).
|
|
1413
|
+
fn leader_receiver_status(state: &serde_json::Value) -> Option<&str> {
|
|
1414
|
+
leader_receiver_field(state, "status")
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1240
1417
|
fn leader_receiver_has_noncanonical_tmux_socket(state: &serde_json::Value) -> bool {
|
|
1241
1418
|
leader_receiver_tmux_socket(state)
|
|
1242
1419
|
.is_some_and(|socket| socket != "default" && !std::path::Path::new(socket).is_absolute())
|
|
@@ -1494,6 +1671,7 @@ struct PendingMessage {
|
|
|
1494
1671
|
task_id: Option<String>,
|
|
1495
1672
|
owner_team_id: Option<String>,
|
|
1496
1673
|
status: String,
|
|
1674
|
+
error: Option<String>,
|
|
1497
1675
|
delivery_attempts: u32,
|
|
1498
1676
|
}
|
|
1499
1677
|
|
|
@@ -1504,7 +1682,7 @@ fn message_for_delivery(
|
|
|
1504
1682
|
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
1505
1683
|
let message = conn
|
|
1506
1684
|
.query_row(
|
|
1507
|
-
"select sender, recipient, content, task_id, owner_team_id, status, delivery_attempts from messages where message_id = ?1",
|
|
1685
|
+
"select sender, recipient, content, task_id, owner_team_id, status, error, delivery_attempts from messages where message_id = ?1",
|
|
1508
1686
|
params![message_id],
|
|
1509
1687
|
|row| {
|
|
1510
1688
|
Ok(PendingMessage {
|
|
@@ -1514,7 +1692,8 @@ fn message_for_delivery(
|
|
|
1514
1692
|
task_id: row.get::<_, Option<String>>(3)?,
|
|
1515
1693
|
owner_team_id: row.get::<_, Option<String>>(4)?,
|
|
1516
1694
|
status: row.get::<_, String>(5)?,
|
|
1517
|
-
|
|
1695
|
+
error: row.get::<_, Option<String>>(6)?,
|
|
1696
|
+
delivery_attempts: row.get::<_, i64>(7)?.max(0) as u32,
|
|
1518
1697
|
})
|
|
1519
1698
|
},
|
|
1520
1699
|
)
|
|
@@ -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()
|