@team-agent/installer 0.5.4 → 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/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/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/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -1468,7 +1468,15 @@ fn format_inbox_human(
|
|
|
1468
1468
|
for message in messages {
|
|
1469
1469
|
let sender = message.get("sender").and_then(Value::as_str).unwrap_or("-");
|
|
1470
1470
|
let content = message.get("content").and_then(Value::as_str).unwrap_or("");
|
|
1471
|
-
|
|
1471
|
+
let status = message.get("status").and_then(Value::as_str).unwrap_or("-");
|
|
1472
|
+
let attempts = message
|
|
1473
|
+
.get("delivery_attempts")
|
|
1474
|
+
.and_then(Value::as_i64)
|
|
1475
|
+
.unwrap_or(0);
|
|
1476
|
+
let error = message.get("error").and_then(Value::as_str).unwrap_or("-");
|
|
1477
|
+
lines.push(format!(
|
|
1478
|
+
"- {sender}: {content} [status={status} attempts={attempts} error={error}]"
|
|
1479
|
+
));
|
|
1472
1480
|
}
|
|
1473
1481
|
}
|
|
1474
1482
|
let pending = uncollected_result_count(workspace, owner_team_id)?;
|
|
@@ -90,9 +90,12 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
|
90
90
|
{
|
|
91
91
|
return Ok(CmdResult::from_json(amb, args.json));
|
|
92
92
|
}
|
|
93
|
-
let outcome = messaging::send_message(&selected.run_workspace, &target, &content, &opts)?;
|
|
94
|
-
let mut value = delivery_outcome_json(&outcome, &target, &content, &opts);
|
|
93
|
+
let mut outcome = messaging::send_message(&selected.run_workspace, &target, &content, &opts)?;
|
|
95
94
|
if opts.watch_result {
|
|
95
|
+
outcome = observe_initial_delivery_for_watch(&selected, &target, &outcome, &opts)?;
|
|
96
|
+
}
|
|
97
|
+
let mut value = delivery_outcome_json(&outcome, &target, &content, &opts);
|
|
98
|
+
if opts.watch_result && initial_delivery_allows_watch(outcome.status) {
|
|
96
99
|
if let Some(obj) = value.as_object_mut() {
|
|
97
100
|
obj.insert("watch".to_string(), watch_notice_json(&target, &opts));
|
|
98
101
|
}
|
|
@@ -575,6 +578,48 @@ fn primary_delivery_succeeded(status: DeliveryStatus) -> bool {
|
|
|
575
578
|
)
|
|
576
579
|
}
|
|
577
580
|
|
|
581
|
+
fn initial_delivery_allows_watch(status: DeliveryStatus) -> bool {
|
|
582
|
+
matches!(
|
|
583
|
+
status,
|
|
584
|
+
DeliveryStatus::Delivered | DeliveryStatus::AlreadyDelivered
|
|
585
|
+
)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
fn observe_initial_delivery_for_watch(
|
|
589
|
+
selected: &crate::state::selector::SelectedTeam,
|
|
590
|
+
target: &MessageTarget,
|
|
591
|
+
outcome: &DeliveryOutcome,
|
|
592
|
+
opts: &SendOptions,
|
|
593
|
+
) -> Result<DeliveryOutcome, CliError> {
|
|
594
|
+
if !matches!(target, MessageTarget::Single(agent) if !agent.is_empty()) {
|
|
595
|
+
return Ok(outcome.clone());
|
|
596
|
+
}
|
|
597
|
+
if !matches!(outcome.status, DeliveryStatus::Queued) {
|
|
598
|
+
return Ok(outcome.clone());
|
|
599
|
+
}
|
|
600
|
+
let Some(message_id) = outcome.message_id.as_deref() else {
|
|
601
|
+
return Ok(outcome.clone());
|
|
602
|
+
};
|
|
603
|
+
let store = crate::message_store::MessageStore::open(&selected.run_workspace)
|
|
604
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
605
|
+
let transport = crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
|
|
606
|
+
&selected.run_workspace,
|
|
607
|
+
opts.team.as_ref().map(TeamKey::as_str),
|
|
608
|
+
)
|
|
609
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
610
|
+
let event_log = crate::event_log::EventLog::new(&selected.run_workspace);
|
|
611
|
+
let state = selected_state_with_active_key(selected);
|
|
612
|
+
crate::messaging::delivery::deliver_pending_message(
|
|
613
|
+
&selected.run_workspace,
|
|
614
|
+
&store,
|
|
615
|
+
&transport,
|
|
616
|
+
message_id,
|
|
617
|
+
&event_log,
|
|
618
|
+
&state,
|
|
619
|
+
)
|
|
620
|
+
.map_err(CliError::from)
|
|
621
|
+
}
|
|
622
|
+
|
|
578
623
|
fn is_business_reject_text(error: &str) -> bool {
|
|
579
624
|
let lower = error.to_ascii_lowercase();
|
|
580
625
|
[
|
|
@@ -668,6 +713,8 @@ fn delivery_outcome_json(
|
|
|
668
713
|
json!({
|
|
669
714
|
"ok": outcome.ok,
|
|
670
715
|
"status": delivery_status_wire(outcome.status),
|
|
716
|
+
"delivery_status": api_delivery_status(outcome),
|
|
717
|
+
"delivered": delivery_proven(outcome.status),
|
|
671
718
|
"target": target_wire,
|
|
672
719
|
"agent_id": first_target(target),
|
|
673
720
|
"content_length_bytes": content.len(),
|
|
@@ -681,6 +728,26 @@ fn delivery_outcome_json(
|
|
|
681
728
|
})
|
|
682
729
|
}
|
|
683
730
|
|
|
731
|
+
fn api_delivery_status(outcome: &DeliveryOutcome) -> &'static str {
|
|
732
|
+
if delivery_proven(outcome.status) {
|
|
733
|
+
return "delivered";
|
|
734
|
+
}
|
|
735
|
+
if matches!(outcome.status, DeliveryStatus::Queued) && outcome.message_status.0 == "accepted" {
|
|
736
|
+
return "pending";
|
|
737
|
+
}
|
|
738
|
+
delivery_status_wire(outcome.status)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
fn delivery_proven(status: DeliveryStatus) -> bool {
|
|
742
|
+
matches!(
|
|
743
|
+
status,
|
|
744
|
+
DeliveryStatus::Delivered
|
|
745
|
+
| DeliveryStatus::AlreadyDelivered
|
|
746
|
+
| DeliveryStatus::BroadcastDelivered
|
|
747
|
+
| DeliveryStatus::FanoutDelivered
|
|
748
|
+
)
|
|
749
|
+
}
|
|
750
|
+
|
|
684
751
|
fn add_send_reminder_if_ok(value: &mut Value) {
|
|
685
752
|
if value.get("ok").and_then(Value::as_bool) != Some(true) {
|
|
686
753
|
return;
|
|
@@ -99,6 +99,7 @@ use rusqlite::params;
|
|
|
99
99
|
"tasks": tasks,
|
|
100
100
|
"messages": message_counts(&conn, owner_team_id)?,
|
|
101
101
|
"queued_messages": queued_messages(&conn, owner_team_id, 8)?,
|
|
102
|
+
"pending_leader_notifications": pending_leader_notifications(&conn, owner_team_id, 8)?,
|
|
102
103
|
"results": result_counts(&conn, owner_team_id)?,
|
|
103
104
|
"latest_results": latest_result_summaries(&store, owner_team_id)?,
|
|
104
105
|
"readiness": readiness,
|
|
@@ -639,6 +640,67 @@ use rusqlite::params;
|
|
|
639
640
|
Ok(Value::Array(values))
|
|
640
641
|
}
|
|
641
642
|
|
|
643
|
+
/// 0.5.5 gate054 round-2: leader notifications that were refused with
|
|
644
|
+
/// `rebind_required` (status=failed, error=leader_not_attached) sit as
|
|
645
|
+
/// failed rows in the store; without a dedicated status field the
|
|
646
|
+
/// operator sees only `messages.failed=N` and cannot tell that the
|
|
647
|
+
/// notifications are waiting for a rebind. This field surfaces them
|
|
648
|
+
/// alongside `queued_messages` so `attach-leader` / `takeover` is
|
|
649
|
+
/// visibly the fix. Once the pane is rebound the requeue path flips
|
|
650
|
+
/// each row back to `status=accepted` and it drops out of this list.
|
|
651
|
+
fn pending_leader_notifications(
|
|
652
|
+
conn: &rusqlite::Connection,
|
|
653
|
+
owner_team_id: Option<&str>,
|
|
654
|
+
limit: usize,
|
|
655
|
+
) -> Result<Value, CliError> {
|
|
656
|
+
let limit = i64::try_from(limit).unwrap_or(i64::MAX);
|
|
657
|
+
let sql = match owner_team_id {
|
|
658
|
+
Some(_) => {
|
|
659
|
+
"select message_id, sender, status, error, created_at, delivery_attempts
|
|
660
|
+
from messages
|
|
661
|
+
where recipient = 'leader'
|
|
662
|
+
and status = 'failed'
|
|
663
|
+
and error = 'leader_not_attached'
|
|
664
|
+
and owner_team_id = ?1
|
|
665
|
+
order by created_at desc
|
|
666
|
+
limit ?2"
|
|
667
|
+
}
|
|
668
|
+
None => {
|
|
669
|
+
"select message_id, sender, status, error, created_at, delivery_attempts
|
|
670
|
+
from messages
|
|
671
|
+
where recipient = 'leader'
|
|
672
|
+
and status = 'failed'
|
|
673
|
+
and error = 'leader_not_attached'
|
|
674
|
+
order by created_at desc
|
|
675
|
+
limit ?1"
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
let mut stmt = conn
|
|
679
|
+
.prepare(sql)
|
|
680
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
681
|
+
let map_row = |row: &rusqlite::Row<'_>| {
|
|
682
|
+
Ok(json!({
|
|
683
|
+
"message_id": row.get::<_, String>(0)?,
|
|
684
|
+
"sender": row.get::<_, Option<String>>(1)?,
|
|
685
|
+
"status": row.get::<_, String>(2)?,
|
|
686
|
+
"error": row.get::<_, Option<String>>(3)?,
|
|
687
|
+
"created_at": row.get::<_, Option<String>>(4)?,
|
|
688
|
+
"delivery_attempts": row.get::<_, i64>(5)?,
|
|
689
|
+
"channel": "rebind_required",
|
|
690
|
+
"action": "run team-agent attach-leader or team-agent takeover",
|
|
691
|
+
}))
|
|
692
|
+
};
|
|
693
|
+
let rows = match owner_team_id {
|
|
694
|
+
Some(team) => stmt.query_map(params![team, limit], map_row),
|
|
695
|
+
None => stmt.query_map(params![limit], map_row),
|
|
696
|
+
}
|
|
697
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
698
|
+
let values = rows
|
|
699
|
+
.collect::<Result<Vec<_>, _>>()
|
|
700
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
701
|
+
Ok(Value::Array(values))
|
|
702
|
+
}
|
|
703
|
+
|
|
642
704
|
/// 0.4.x: slim default compact payload — exactly 7 top-level fields.
|
|
643
705
|
/// Diagnostic detail moves to `--detail`. Plan:
|
|
644
706
|
/// /Users/alauda/Documents/code/team-agent-public/.team/artifacts/status-compact-plan.md
|
|
@@ -511,33 +511,26 @@ use super::*;
|
|
|
511
511
|
}
|
|
512
512
|
|
|
513
513
|
#[test]
|
|
514
|
-
fn
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
// (send.py:322-337). That dict MUST survive verbatim into CmdResult Json output.
|
|
514
|
+
fn cmd_send_watch_result_does_not_register_before_delivery() {
|
|
515
|
+
// 0.5.x send contract: --watch-result may only advertise a watcher after
|
|
516
|
+
// initial worker delivery is physically proven.
|
|
518
517
|
let r = cmd_send(&send_args_fixture()).expect("cmd_send returns CmdResult");
|
|
519
518
|
let v = match r.output {
|
|
520
519
|
CmdOutput::Json(v) => v,
|
|
521
520
|
other => panic!("expected Json, got {other:?}"),
|
|
522
521
|
};
|
|
523
|
-
|
|
524
|
-
.get("
|
|
525
|
-
|
|
526
|
-
assert_eq!(
|
|
527
|
-
watch.get("status").and_then(|s| s.as_str()),
|
|
528
|
-
Some("registered"),
|
|
529
|
-
"registered-watcher notice status must be exactly 'registered'"
|
|
522
|
+
assert!(
|
|
523
|
+
v.get("delivery_status").and_then(|s| s.as_str()).is_some(),
|
|
524
|
+
"send output must expose delivery_status; got {v}"
|
|
530
525
|
);
|
|
531
|
-
assert!(watch.get("watcher_id").is_some(), "watch notice carries watcher_id");
|
|
532
526
|
assert_eq!(
|
|
533
|
-
|
|
534
|
-
Some(
|
|
535
|
-
"
|
|
527
|
+
v.get("delivered").and_then(|s| s.as_bool()),
|
|
528
|
+
Some(false),
|
|
529
|
+
"undelivered send outcome must not look delivered"
|
|
536
530
|
);
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
watch
|
|
540
|
-
Some("Team Agent will collect the result and notify the leader when this task reports completion.")
|
|
531
|
+
assert!(
|
|
532
|
+
!v.as_object().unwrap().contains_key("watch"),
|
|
533
|
+
"watch_result:true must not attach result['watch'] before delivery; got {v}"
|
|
541
534
|
);
|
|
542
535
|
}
|
|
543
536
|
|
|
@@ -143,6 +143,7 @@ pub(crate) fn start_agent_at_paths(
|
|
|
143
143
|
if let Ok(spec) = load_team_spec(spec_workspace) {
|
|
144
144
|
write_team_state(spec_workspace, &spec, &state)?;
|
|
145
145
|
}
|
|
146
|
+
replay_worker_target_missing_messages(workspace, agent_id, &team_key, &state, transport)?;
|
|
146
147
|
let coordinator_started = start_coordinator_for_workspace(workspace)?;
|
|
147
148
|
let target = format!("{}:{window}", session_name.as_str());
|
|
148
149
|
write_start_agent_noop_event(workspace, agent_id, &target, coordinator_started)?;
|
|
@@ -282,6 +283,7 @@ pub(crate) fn start_agent_at_paths(
|
|
|
282
283
|
spawn_session_id,
|
|
283
284
|
tmux_start_mode_for_spawn(&spawn, into_existing_session),
|
|
284
285
|
)?;
|
|
286
|
+
replay_worker_target_missing_messages(workspace, agent_id, &team_key, &state, transport)?;
|
|
285
287
|
let coordinator_started = start_coordinator_for_workspace(workspace)?;
|
|
286
288
|
Ok(StartAgentOutcome::Running {
|
|
287
289
|
env: AgentActionEnvelope {
|
|
@@ -297,6 +299,33 @@ pub(crate) fn start_agent_at_paths(
|
|
|
297
299
|
})
|
|
298
300
|
}
|
|
299
301
|
|
|
302
|
+
fn replay_worker_target_missing_messages(
|
|
303
|
+
workspace: &Path,
|
|
304
|
+
agent_id: &AgentId,
|
|
305
|
+
team_key: &str,
|
|
306
|
+
state: &serde_json::Value,
|
|
307
|
+
transport: &dyn crate::transport::Transport,
|
|
308
|
+
) -> Result<(), LifecycleError> {
|
|
309
|
+
let store = crate::message_store::MessageStore::open(workspace)
|
|
310
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
311
|
+
let event_log = crate::event_log::EventLog::new(workspace);
|
|
312
|
+
let ids = crate::messaging::delivery::requeue_worker_target_missing_messages(
|
|
313
|
+
workspace,
|
|
314
|
+
&store,
|
|
315
|
+
&event_log,
|
|
316
|
+
agent_id.as_str(),
|
|
317
|
+
Some(team_key),
|
|
318
|
+
)
|
|
319
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
320
|
+
if !ids.is_empty() {
|
|
321
|
+
crate::messaging::delivery::deliver_pending_messages(
|
|
322
|
+
workspace, state, transport, &event_log,
|
|
323
|
+
)
|
|
324
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
325
|
+
}
|
|
326
|
+
Ok(())
|
|
327
|
+
}
|
|
328
|
+
|
|
300
329
|
fn verify_spawned_pane_matches_target(
|
|
301
330
|
transport: &dyn crate::transport::Transport,
|
|
302
331
|
pane: &crate::transport::PaneId,
|
|
@@ -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()
|
|
@@ -1683,6 +1683,58 @@ fn slice1_inject_failures_are_bounded_and_stop_retrying_same_message() {
|
|
|
1683
1683
|
);
|
|
1684
1684
|
}
|
|
1685
1685
|
|
|
1686
|
+
#[test]
|
|
1687
|
+
fn target_missing_session_window_blocks_without_generic_inject_exhaustion() {
|
|
1688
|
+
let ws = tmp_ws("targetmissing");
|
|
1689
|
+
let store = store_for(&ws);
|
|
1690
|
+
let log = EventLog::new(&ws);
|
|
1691
|
+
let state = serde_json::json!({
|
|
1692
|
+
"session_name": "team-targetmissing",
|
|
1693
|
+
"leader_receiver": {"pane_id": "%leader"},
|
|
1694
|
+
"agents": {"w1": {"provider": "fake", "window": "w1"}}
|
|
1695
|
+
});
|
|
1696
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
1697
|
+
let message_id = store
|
|
1698
|
+
.create_message(None, "leader", "w1", "ping", None, false, None)
|
|
1699
|
+
.unwrap();
|
|
1700
|
+
let transport = OfflineTransport::new()
|
|
1701
|
+
.with_session_present(true)
|
|
1702
|
+
.with_targets(vec![pane_info("%2", "team-targetmissing", "other")]);
|
|
1703
|
+
|
|
1704
|
+
let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
|
|
1705
|
+
.expect("target-missing classification should be a delivery outcome");
|
|
1706
|
+
|
|
1707
|
+
assert!(!out.ok);
|
|
1708
|
+
assert_eq!(out.status, DeliveryStatus::Blocked);
|
|
1709
|
+
assert_eq!(out.message_status.0, "queued_pane_missing");
|
|
1710
|
+
assert_eq!(out.reason, Some(DeliveryRefusal::TmuxTargetMissing));
|
|
1711
|
+
assert!(
|
|
1712
|
+
transport.inject_targets().is_empty(),
|
|
1713
|
+
"target-missing must block before physical inject; targets={:?}",
|
|
1714
|
+
transport.inject_targets()
|
|
1715
|
+
);
|
|
1716
|
+
|
|
1717
|
+
let conn = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
1718
|
+
let (status, error): (String, Option<String>) = conn
|
|
1719
|
+
.query_row(
|
|
1720
|
+
"select status, error from messages where message_id = ?1",
|
|
1721
|
+
[&message_id],
|
|
1722
|
+
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
1723
|
+
)
|
|
1724
|
+
.unwrap();
|
|
1725
|
+
assert_eq!(status, "queued_pane_missing");
|
|
1726
|
+
assert_eq!(error.as_deref(), Some("tmux_target_missing"));
|
|
1727
|
+
let events = log.tail(0).unwrap();
|
|
1728
|
+
assert!(
|
|
1729
|
+
events.iter().any(|event| {
|
|
1730
|
+
event.get("event").and_then(serde_json::Value::as_str) == Some("send.inject_blocked")
|
|
1731
|
+
&& event.get("reason").and_then(serde_json::Value::as_str)
|
|
1732
|
+
== Some("tmux_target_missing")
|
|
1733
|
+
}),
|
|
1734
|
+
"target-missing block must be explicit in events; events={events:?}"
|
|
1735
|
+
);
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1686
1738
|
#[test]
|
|
1687
1739
|
fn u1_projection_refused_emits_delivery_event() {
|
|
1688
1740
|
let ws = tmp_ws("u1projref");
|
|
@@ -2730,3 +2782,397 @@ fn e15_direct_inject_is_gated_by_deliver_failure_not_unconditional() {
|
|
|
2730
2782
|
"E23: private direct inject must stay deleted; all callers use deliver_to_leader_fallback_pane"
|
|
2731
2783
|
);
|
|
2732
2784
|
}
|
|
2785
|
+
|
|
2786
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
2787
|
+
// 0.5.4 gate054 — cross-team notify boundary.
|
|
2788
|
+
//
|
|
2789
|
+
// Triage: .team/artifacts/0.5.4-gate-crossteam-notify-triage.md
|
|
2790
|
+
//
|
|
2791
|
+
// Shape (regression):
|
|
2792
|
+
// - Gate team's `leader_receiver` records `pane_id=%1`, `tmux_socket=/tmp/gate`,
|
|
2793
|
+
// `status="rebind_required"` (disposable leader was killed post-restart).
|
|
2794
|
+
// - The main tmux server has a live `%1` on the workspace-canonical socket.
|
|
2795
|
+
// - `deliver_pending_message` MUST refuse with `channel=rebind_required` and
|
|
2796
|
+
// MUST NOT probe the main/default tmux server for the same pane id.
|
|
2797
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
2798
|
+
|
|
2799
|
+
#[test]
|
|
2800
|
+
fn gate054_rebind_required_status_refuses_leader_delivery_without_cross_server_probe() {
|
|
2801
|
+
let ws = tmp_ws("gate054status");
|
|
2802
|
+
let store = store_for(&ws);
|
|
2803
|
+
let log = EventLog::new(&ws);
|
|
2804
|
+
// Explicit endpoint socket (non-canonical for this workspace) with a
|
|
2805
|
+
// stale receiver demoted to `rebind_required` after the gate's leader
|
|
2806
|
+
// window was killed.
|
|
2807
|
+
let state = serde_json::json!({
|
|
2808
|
+
"session_name": "team-gate054",
|
|
2809
|
+
"tmux_endpoint": "/tmp/gate-socket-does-not-exist",
|
|
2810
|
+
"leader_receiver": {
|
|
2811
|
+
"pane_id": "%1",
|
|
2812
|
+
"tmux_socket": "/tmp/gate-socket-does-not-exist",
|
|
2813
|
+
"status": "rebind_required"
|
|
2814
|
+
},
|
|
2815
|
+
"agents": {
|
|
2816
|
+
"probe": {"provider": "fake", "pane_id": "%0", "window": "probe"}
|
|
2817
|
+
}
|
|
2818
|
+
});
|
|
2819
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
2820
|
+
let message_id = store
|
|
2821
|
+
.create_message(
|
|
2822
|
+
None,
|
|
2823
|
+
"probe",
|
|
2824
|
+
"leader",
|
|
2825
|
+
"gate054 must not leak",
|
|
2826
|
+
None,
|
|
2827
|
+
false,
|
|
2828
|
+
None,
|
|
2829
|
+
)
|
|
2830
|
+
.unwrap();
|
|
2831
|
+
|
|
2832
|
+
// The transport we pass is the "main"/default server backend which does
|
|
2833
|
+
// have a live `%1`. If the fix regresses and delivery falls back cross-
|
|
2834
|
+
// server by pane id, it would inject here.
|
|
2835
|
+
let default_backend = OfflineTransport::new()
|
|
2836
|
+
.with_targets(vec![pane_info("%1", "team-main", "leader")]);
|
|
2837
|
+
|
|
2838
|
+
let out = deliver_pending_message(&ws, &store, &default_backend, &message_id, &log, &state)
|
|
2839
|
+
.expect("status guard must be a business refusal, not panic");
|
|
2840
|
+
|
|
2841
|
+
assert!(
|
|
2842
|
+
!out.ok,
|
|
2843
|
+
"0.5.4 gate054: leader_receiver.status='rebind_required' must refuse delivery"
|
|
2844
|
+
);
|
|
2845
|
+
assert_eq!(out.channel.as_deref(), Some("rebind_required"));
|
|
2846
|
+
assert_eq!(out.message_status.0, "failed");
|
|
2847
|
+
assert_eq!(out.reason, Some(DeliveryRefusal::LeaderNotAttached));
|
|
2848
|
+
// Cross-server invariant: even though the default backend has a live `%1`
|
|
2849
|
+
// matching the recorded receiver pane id, delivery MUST NOT inject there.
|
|
2850
|
+
assert!(
|
|
2851
|
+
default_backend.inject_targets().is_empty(),
|
|
2852
|
+
"0.5.4 gate054: leader delivery must not cross to a different tmux server by pane id; \
|
|
2853
|
+
inject_targets={:?}",
|
|
2854
|
+
default_backend.inject_targets()
|
|
2855
|
+
);
|
|
2856
|
+
let events = log.tail(0).unwrap();
|
|
2857
|
+
assert!(
|
|
2858
|
+
events.iter().any(|event| {
|
|
2859
|
+
event.get("event").and_then(serde_json::Value::as_str)
|
|
2860
|
+
== Some("leader_receiver.delivery_blocked")
|
|
2861
|
+
&& event.get("reason").and_then(serde_json::Value::as_str)
|
|
2862
|
+
== Some("leader_receiver_not_attached")
|
|
2863
|
+
&& event.get("channel").and_then(serde_json::Value::as_str)
|
|
2864
|
+
== Some("rebind_required")
|
|
2865
|
+
&& event.get("status").and_then(serde_json::Value::as_str)
|
|
2866
|
+
== Some("rebind_required")
|
|
2867
|
+
}),
|
|
2868
|
+
"0.5.4 gate054: refused status must audit as leader_receiver_not_attached / rebind_required; events={events:?}"
|
|
2869
|
+
);
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
#[test]
|
|
2873
|
+
fn gate054_attached_status_still_delivers_when_pane_is_live() {
|
|
2874
|
+
// Companion negative: prove the new status gate does NOT over-refuse when
|
|
2875
|
+
// `status="attached"` and the workspace-canonical socket has the pane.
|
|
2876
|
+
let ws = tmp_ws("gate054ok");
|
|
2877
|
+
let store = store_for(&ws);
|
|
2878
|
+
let log = EventLog::new(&ws);
|
|
2879
|
+
let state = serde_json::json!({
|
|
2880
|
+
"session_name": "team-gate054ok",
|
|
2881
|
+
"leader_receiver": {
|
|
2882
|
+
"pane_id": "%leader",
|
|
2883
|
+
"status": "attached"
|
|
2884
|
+
},
|
|
2885
|
+
"agents": {"w1": {"provider": "fake", "pane_id": "%1"}}
|
|
2886
|
+
});
|
|
2887
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
2888
|
+
let message_id = store
|
|
2889
|
+
.create_message(None, "w1", "leader", "hi", None, false, None)
|
|
2890
|
+
.unwrap();
|
|
2891
|
+
let transport = OfflineTransport::new()
|
|
2892
|
+
.with_targets(vec![pane_info("%leader", "team-gate054ok", "leader")]);
|
|
2893
|
+
|
|
2894
|
+
let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
|
|
2895
|
+
.expect("attached leader must still deliver");
|
|
2896
|
+
|
|
2897
|
+
assert!(
|
|
2898
|
+
out.ok,
|
|
2899
|
+
"0.5.4 gate054: status='attached' with a live pane must not be over-refused: {out:?}"
|
|
2900
|
+
);
|
|
2901
|
+
assert_eq!(out.message_status.0, "delivered");
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
2905
|
+
// 0.5.5 gate054 B-arm — deliver_to_leader_fallback_pane must not cross to
|
|
2906
|
+
// another tmux server by pane id when a socket is recorded.
|
|
2907
|
+
//
|
|
2908
|
+
// Triage: real-machine acceptance B-fail — primary path (delivery.rs) refused
|
|
2909
|
+
// with rebind_required, but report_result's fallback_pane bailout selected a
|
|
2910
|
+
// same-numbered pane (%1) on the default tmux server (the main team's leader)
|
|
2911
|
+
// and returned leader_notified:true / delivered_via=fallback_pane.
|
|
2912
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
2913
|
+
|
|
2914
|
+
#[test]
|
|
2915
|
+
fn gate054_fallback_pane_socket_recorded_does_not_cross_to_default_server() {
|
|
2916
|
+
use crate::messaging::deliver_to_leader_fallback_pane;
|
|
2917
|
+
let ws = tmp_ws("gate054fallback");
|
|
2918
|
+
let store = store_for(&ws);
|
|
2919
|
+
let log = EventLog::new(&ws);
|
|
2920
|
+
// Recorded socket points at a non-existent endpoint (mimics the gate
|
|
2921
|
+
// team's private socket after its leader session was killed). The main
|
|
2922
|
+
// team's default tmux server MAY have a same-numbered `%1` — the fix
|
|
2923
|
+
// must refuse to inject there.
|
|
2924
|
+
let bogus_socket = format!("/tmp/team-agent-gate054-nonexistent-{}", std::process::id());
|
|
2925
|
+
let state = serde_json::json!({
|
|
2926
|
+
"session_name": "team-gate054",
|
|
2927
|
+
"leader_receiver": {
|
|
2928
|
+
"pane_id": "%1",
|
|
2929
|
+
"tmux_socket": bogus_socket,
|
|
2930
|
+
"status": "rebind_required"
|
|
2931
|
+
}
|
|
2932
|
+
});
|
|
2933
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
2934
|
+
let message_id = store
|
|
2935
|
+
.create_message(None, "coordinator", "leader", "fallback payload", None, false, None)
|
|
2936
|
+
.unwrap();
|
|
2937
|
+
|
|
2938
|
+
let outcome = deliver_to_leader_fallback_pane(
|
|
2939
|
+
&ws,
|
|
2940
|
+
&state,
|
|
2941
|
+
&message_id,
|
|
2942
|
+
Some("res_gate054_fallback"),
|
|
2943
|
+
"S3 restart complete",
|
|
2944
|
+
false, // primary was NOT ok (report_result path)
|
|
2945
|
+
Some("primary_delivery_error:leader_not_attached"),
|
|
2946
|
+
&log,
|
|
2947
|
+
)
|
|
2948
|
+
.expect("fallback pane primitive must not panic");
|
|
2949
|
+
|
|
2950
|
+
// Must NOT succeed by crossing to another server.
|
|
2951
|
+
assert!(
|
|
2952
|
+
!outcome.ok,
|
|
2953
|
+
"0.5.5 gate054: fallback_pane must not cross a recorded socket boundary: outcome={outcome:?}"
|
|
2954
|
+
);
|
|
2955
|
+
// Loud-not-silent: caller wire status resolves to rebind_required
|
|
2956
|
+
// (results.rs matches channel or Blocked → 'rebind_required').
|
|
2957
|
+
assert!(
|
|
2958
|
+
outcome.channel.as_deref() == Some("rebind_required")
|
|
2959
|
+
|| matches!(outcome.status, DeliveryStatus::Blocked),
|
|
2960
|
+
"0.5.5 gate054: cross-boundary refusal must surface as rebind_required, got outcome={outcome:?}"
|
|
2961
|
+
);
|
|
2962
|
+
// Audit noise: fallback_pane_attempt + fallback_pane_failed with the
|
|
2963
|
+
// recorded socket_bound=true forensic field.
|
|
2964
|
+
let events = log.tail(0).unwrap();
|
|
2965
|
+
let attempt = events
|
|
2966
|
+
.iter()
|
|
2967
|
+
.find(|e| {
|
|
2968
|
+
e.get("event").and_then(serde_json::Value::as_str)
|
|
2969
|
+
== Some("leader_receiver.fallback_pane_attempt")
|
|
2970
|
+
});
|
|
2971
|
+
assert!(
|
|
2972
|
+
attempt.is_some(),
|
|
2973
|
+
"fallback_pane_attempt audit must fire even on socket-bounded refusal; events={events:?}"
|
|
2974
|
+
);
|
|
2975
|
+
let failed = events.iter().find(|e| {
|
|
2976
|
+
e.get("event").and_then(serde_json::Value::as_str)
|
|
2977
|
+
== Some("leader_receiver.fallback_pane_failed")
|
|
2978
|
+
});
|
|
2979
|
+
assert!(
|
|
2980
|
+
failed.is_some(),
|
|
2981
|
+
"fallback_pane_failed audit must fire when the recorded socket rejects inject; events={events:?}"
|
|
2982
|
+
);
|
|
2983
|
+
assert_eq!(
|
|
2984
|
+
failed
|
|
2985
|
+
.and_then(|e| e.get("socket_bound"))
|
|
2986
|
+
.and_then(serde_json::Value::as_bool),
|
|
2987
|
+
Some(true),
|
|
2988
|
+
"socket_bound forensic field must record the boundary state"
|
|
2989
|
+
);
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
#[test]
|
|
2993
|
+
fn gate054_rebind_replay_requeues_failed_leader_message_on_attach() {
|
|
2994
|
+
// Round-2 real-machine shape: direct report_result → primary refused with
|
|
2995
|
+
// rebind_required → row is `status=failed, error=leader_not_attached`.
|
|
2996
|
+
// `attach-leader` must requeue it back to `status=accepted` so the
|
|
2997
|
+
// coordinator tick's `deliver_pending_messages` replays it to the new
|
|
2998
|
+
// leader pane. Same message_id — no second replay mechanism, no new
|
|
2999
|
+
// watcher, exactly-once via leader_notification_log PK.
|
|
3000
|
+
use crate::model::ids::TeamKey;
|
|
3001
|
+
let ws = tmp_ws("gate054replay");
|
|
3002
|
+
let store = store_for(&ws);
|
|
3003
|
+
let log = EventLog::new(&ws);
|
|
3004
|
+
let team_id = "gate055r2b";
|
|
3005
|
+
|
|
3006
|
+
// Seed the same shape the primary-path refusal leaves behind.
|
|
3007
|
+
let message_id = store
|
|
3008
|
+
.create_message(
|
|
3009
|
+
None,
|
|
3010
|
+
"probe-worker",
|
|
3011
|
+
"leader",
|
|
3012
|
+
"B2_NOTIFY report_result payload",
|
|
3013
|
+
None,
|
|
3014
|
+
false,
|
|
3015
|
+
Some(team_id),
|
|
3016
|
+
)
|
|
3017
|
+
.unwrap();
|
|
3018
|
+
store
|
|
3019
|
+
.mark(&message_id, "failed", Some("leader_not_attached"))
|
|
3020
|
+
.unwrap();
|
|
3021
|
+
// Sanity: precondition matches acceptance evidence.
|
|
3022
|
+
let conn = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
3023
|
+
let (status_before, error_before): (String, Option<String>) = conn
|
|
3024
|
+
.query_row(
|
|
3025
|
+
"select status, error from messages where message_id = ?1",
|
|
3026
|
+
[&message_id],
|
|
3027
|
+
|r| Ok((r.get(0)?, r.get(1)?)),
|
|
3028
|
+
)
|
|
3029
|
+
.unwrap();
|
|
3030
|
+
assert_eq!(status_before, "failed");
|
|
3031
|
+
assert_eq!(error_before.as_deref(), Some("leader_not_attached"));
|
|
3032
|
+
|
|
3033
|
+
// The attach-leader path calls requeue_delivery_exhausted_watchers with
|
|
3034
|
+
// the newly-claimed pane. Even with NO exhausted watchers to requeue,
|
|
3035
|
+
// it must flip the blocked leader row back to accepted.
|
|
3036
|
+
let team = TeamKey::new(team_id);
|
|
3037
|
+
let new_pane = PaneId::new("%1"); // new leader window on the private socket
|
|
3038
|
+
let notices = crate::messaging::requeue_delivery_exhausted_watchers(
|
|
3039
|
+
&ws, &store, &log, &team, &new_pane,
|
|
3040
|
+
)
|
|
3041
|
+
.unwrap();
|
|
3042
|
+
assert!(
|
|
3043
|
+
notices.is_empty(),
|
|
3044
|
+
"no exhausted watchers seeded → no watcher notices; only the message row should have been requeued"
|
|
3045
|
+
);
|
|
3046
|
+
|
|
3047
|
+
let (status_after, error_after): (String, Option<String>) = conn
|
|
3048
|
+
.query_row(
|
|
3049
|
+
"select status, error from messages where message_id = ?1",
|
|
3050
|
+
[&message_id],
|
|
3051
|
+
|r| Ok((r.get(0)?, r.get(1)?)),
|
|
3052
|
+
)
|
|
3053
|
+
.unwrap();
|
|
3054
|
+
assert_eq!(
|
|
3055
|
+
status_after, "accepted",
|
|
3056
|
+
"gate054 round-2: rebind_required leader row must flip back to 'accepted' on attach-leader"
|
|
3057
|
+
);
|
|
3058
|
+
assert!(
|
|
3059
|
+
error_after.is_none(),
|
|
3060
|
+
"gate054 round-2: error must be cleared on requeue so deliver_pending_message replays cleanly"
|
|
3061
|
+
);
|
|
3062
|
+
|
|
3063
|
+
// Audit noise: leader_receiver.blocked_messages_requeued fired with the
|
|
3064
|
+
// exact team + claimed pane + count for status/monitor observability.
|
|
3065
|
+
let events = log.tail(0).unwrap();
|
|
3066
|
+
let requeued = events.iter().find(|e| {
|
|
3067
|
+
e.get("event").and_then(serde_json::Value::as_str)
|
|
3068
|
+
== Some("leader_receiver.blocked_messages_requeued")
|
|
3069
|
+
});
|
|
3070
|
+
assert!(
|
|
3071
|
+
requeued.is_some(),
|
|
3072
|
+
"attach-leader requeue of blocked leader messages must audit; events={events:?}"
|
|
3073
|
+
);
|
|
3074
|
+
assert_eq!(
|
|
3075
|
+
requeued
|
|
3076
|
+
.and_then(|e| e.get("count"))
|
|
3077
|
+
.and_then(serde_json::Value::as_u64),
|
|
3078
|
+
Some(1)
|
|
3079
|
+
);
|
|
3080
|
+
assert_eq!(
|
|
3081
|
+
requeued
|
|
3082
|
+
.and_then(|e| e.get("team_id"))
|
|
3083
|
+
.and_then(serde_json::Value::as_str),
|
|
3084
|
+
Some(team_id)
|
|
3085
|
+
);
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
#[test]
|
|
3089
|
+
fn gate054_status_surfaces_pending_leader_notifications() {
|
|
3090
|
+
// Round-2: user-facing visibility — status must show the blocked leader
|
|
3091
|
+
// notification as pending, not just as `messages.failed=1`.
|
|
3092
|
+
use crate::cli::status_port::status_scoped;
|
|
3093
|
+
let ws = tmp_ws("gate054status");
|
|
3094
|
+
let store = store_for(&ws);
|
|
3095
|
+
let team_id = "gate055r2b";
|
|
3096
|
+
|
|
3097
|
+
let message_id = store
|
|
3098
|
+
.create_message(
|
|
3099
|
+
None,
|
|
3100
|
+
"probe-worker",
|
|
3101
|
+
"leader",
|
|
3102
|
+
"B2_NOTIFY report_result payload",
|
|
3103
|
+
None,
|
|
3104
|
+
false,
|
|
3105
|
+
Some(team_id),
|
|
3106
|
+
)
|
|
3107
|
+
.unwrap();
|
|
3108
|
+
store
|
|
3109
|
+
.mark(&message_id, "failed", Some("leader_not_attached"))
|
|
3110
|
+
.unwrap();
|
|
3111
|
+
|
|
3112
|
+
// Minimal runtime state so status can render.
|
|
3113
|
+
let state = serde_json::json!({
|
|
3114
|
+
"session_name": format!("team-{team_id}"),
|
|
3115
|
+
"active_team_key": team_id,
|
|
3116
|
+
"teams": {team_id: {"team_key": team_id}},
|
|
3117
|
+
"leader_receiver": {
|
|
3118
|
+
"pane_id": "%1",
|
|
3119
|
+
"status": "rebind_required",
|
|
3120
|
+
}
|
|
3121
|
+
});
|
|
3122
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
3123
|
+
|
|
3124
|
+
let payload = status_scoped(&ws, &state, Some(team_id), false, false).unwrap();
|
|
3125
|
+
let pending = payload
|
|
3126
|
+
.get("pending_leader_notifications")
|
|
3127
|
+
.and_then(serde_json::Value::as_array)
|
|
3128
|
+
.expect("status must expose pending_leader_notifications as an array");
|
|
3129
|
+
assert_eq!(
|
|
3130
|
+
pending.len(),
|
|
3131
|
+
1,
|
|
3132
|
+
"the blocked leader message must appear in pending_leader_notifications"
|
|
3133
|
+
);
|
|
3134
|
+
let entry = &pending[0];
|
|
3135
|
+
assert_eq!(
|
|
3136
|
+
entry.get("message_id").and_then(serde_json::Value::as_str),
|
|
3137
|
+
Some(message_id.as_str())
|
|
3138
|
+
);
|
|
3139
|
+
assert_eq!(
|
|
3140
|
+
entry.get("channel").and_then(serde_json::Value::as_str),
|
|
3141
|
+
Some("rebind_required")
|
|
3142
|
+
);
|
|
3143
|
+
assert!(entry
|
|
3144
|
+
.get("action")
|
|
3145
|
+
.and_then(serde_json::Value::as_str)
|
|
3146
|
+
.is_some_and(|action| action.contains("attach-leader")
|
|
3147
|
+
|| action.contains("takeover")));
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
#[test]
|
|
3151
|
+
fn gate054_fallback_pane_grep_guard_no_cross_server_chain_when_socket_recorded() {
|
|
3152
|
+
// Structural grep: the recorded-socket branch of the fallback inject
|
|
3153
|
+
// chain must NOT compose the workspace or default tmux backends after
|
|
3154
|
+
// the endpoint backend rejects. This is the byte-level fence against
|
|
3155
|
+
// silent regression.
|
|
3156
|
+
let src = include_str!("../leader_receiver.rs");
|
|
3157
|
+
let inject_start = src
|
|
3158
|
+
.find("let inject_result: Result<InjectReport, TransportError>")
|
|
3159
|
+
.expect("fallback inject chain must remain single-typed");
|
|
3160
|
+
let inject_end = src[inject_start..]
|
|
3161
|
+
.find("};\n\n match inject_result")
|
|
3162
|
+
.map(|off| inject_start + off + 2)
|
|
3163
|
+
.expect("fallback inject chain must terminate before match");
|
|
3164
|
+
let chain = &src[inject_start..inject_end];
|
|
3165
|
+
// The socket-recorded arm is the `Some(socket) => {..}` block. It must
|
|
3166
|
+
// NOT contain a workspace or default fallback.
|
|
3167
|
+
let some_arm_start = chain.find("Some(socket) => {").expect("Some(socket) arm");
|
|
3168
|
+
let some_arm_end = chain[some_arm_start..]
|
|
3169
|
+
.find("None => {")
|
|
3170
|
+
.map(|off| some_arm_start + off)
|
|
3171
|
+
.expect("None arm must follow");
|
|
3172
|
+
let some_arm = &chain[some_arm_start..some_arm_end];
|
|
3173
|
+
assert!(
|
|
3174
|
+
!some_arm.contains("tmux_workspace_transport")
|
|
3175
|
+
&& !some_arm.contains("tmux_default_transport"),
|
|
3176
|
+
"0.5.5 gate054: socket-recorded fallback arm must not compose workspace/default backends; arm={some_arm}"
|
|
3177
|
+
);
|
|
3178
|
+
}
|
|
@@ -436,45 +436,61 @@ pub fn requeue_after_claim_leader(
|
|
|
436
436
|
}),
|
|
437
437
|
)?;
|
|
438
438
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
439
|
+
let requeued_blocked =
|
|
440
|
+
requeue_blocked_leader_messages(&conn, event_log, owner_team_id, claimed_pane_id)?;
|
|
441
|
+
if !out.is_empty() || requeued_blocked > 0 {
|
|
442
|
+
let _ = retry_result_deliveries(workspace, event_log)?;
|
|
443
|
+
}
|
|
444
|
+
Ok(out)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/// 0.5.5 gate054 round-2: attach-leader (and claim-leader) requeue for leader messages
|
|
448
|
+
/// that were refused with `rebind_required` while no leader pane was attached.
|
|
449
|
+
///
|
|
450
|
+
/// #231 C-5 semantics: same row, same message_id — flip `status` back from
|
|
451
|
+
/// `failed`/`leader_not_attached` to `accepted` so `deliver_pending_messages`
|
|
452
|
+
/// replays it through the SAME pipeline. The `leader_notification_log` PK is
|
|
453
|
+
/// already there (primitive wrote it before the unbound check), so this replay
|
|
454
|
+
/// cannot create a duplicate notification — exactly-once across rebind, no new
|
|
455
|
+
/// send/notify rows and no second replay mechanism.
|
|
456
|
+
pub(crate) fn requeue_blocked_leader_messages(
|
|
457
|
+
conn: &rusqlite::Connection,
|
|
458
|
+
event_log: &EventLog,
|
|
459
|
+
owner_team_id: &TeamKey,
|
|
460
|
+
claimed_pane_id: &PaneId,
|
|
461
|
+
) -> Result<usize, MessagingError> {
|
|
462
|
+
let requeued = conn.execute(
|
|
446
463
|
"update messages
|
|
447
464
|
set status = 'accepted',
|
|
448
465
|
error = null,
|
|
449
|
-
updated_at = ?
|
|
466
|
+
updated_at = ?2
|
|
450
467
|
where recipient = 'leader'
|
|
451
468
|
and status = 'failed'
|
|
452
469
|
and error = 'leader_not_attached'
|
|
453
470
|
and owner_team_id = ?1",
|
|
454
|
-
params![
|
|
455
|
-
owner_team_id.as_str(),
|
|
456
|
-
claimed_pane_id.as_str(),
|
|
457
|
-
chrono::Utc::now().to_rfc3339(),
|
|
458
|
-
],
|
|
471
|
+
params![owner_team_id.as_str(), chrono::Utc::now().to_rfc3339()],
|
|
459
472
|
)?;
|
|
460
|
-
if
|
|
473
|
+
if requeued > 0 {
|
|
461
474
|
event_log.write(
|
|
462
475
|
"leader_receiver.blocked_messages_requeued",
|
|
463
476
|
serde_json::json!({
|
|
464
477
|
"team_id": owner_team_id.as_str(),
|
|
465
478
|
"claimed_pane_id": claimed_pane_id.as_str(),
|
|
466
|
-
"count":
|
|
479
|
+
"count": requeued,
|
|
467
480
|
}),
|
|
468
481
|
)?;
|
|
469
482
|
}
|
|
470
|
-
|
|
471
|
-
let _ = retry_result_deliveries(workspace, event_log)?;
|
|
472
|
-
}
|
|
473
|
-
Ok(out)
|
|
483
|
+
Ok(requeued)
|
|
474
484
|
}
|
|
475
485
|
|
|
476
486
|
/// `requeue_delivery_exhausted_watchers`: attach-leader 成功后把已经耗尽投递
|
|
477
487
|
/// 重试的 watcher 放回 notify_failed, 留给 coordinator tick 重试投递。
|
|
488
|
+
///
|
|
489
|
+
/// 0.5.5 gate054 round-2: also requeue direct leader messages that were refused
|
|
490
|
+
/// with `rebind_required` (status=failed / error=leader_not_attached). Without
|
|
491
|
+
/// this the direct `report_result` path leaves a failed row that
|
|
492
|
+
/// `deliver_pending_messages` never re-picks — the new leader would never see the
|
|
493
|
+
/// pending notification.
|
|
478
494
|
pub fn requeue_delivery_exhausted_watchers(
|
|
479
495
|
_workspace: &Path,
|
|
480
496
|
store: &MessageStore,
|
|
@@ -522,6 +538,7 @@ pub fn requeue_delivery_exhausted_watchers(
|
|
|
522
538
|
)?;
|
|
523
539
|
}
|
|
524
540
|
drop(stmt);
|
|
541
|
+
let _ = requeue_blocked_leader_messages(&conn, event_log, owner_team_id, claimed_pane_id)?;
|
|
525
542
|
Ok(out)
|
|
526
543
|
}
|
|
527
544
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
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.5.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.5.5",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.5",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.5"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|