@team-agent/installer 0.5.115 → 0.5.117

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.
Files changed (36) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/mod.rs +143 -12
  4. package/crates/team-agent/src/cli/shutdown_socket.rs +288 -0
  5. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +11 -4
  6. package/crates/team-agent/src/contract_runtime/backend.rs +88 -8
  7. package/crates/team-agent/src/contract_runtime/commands.rs +88 -7
  8. package/crates/team-agent/src/contract_runtime/discovery.rs +42 -10
  9. package/crates/team-agent/src/contract_runtime/mcp.rs +3 -0
  10. package/crates/team-agent/src/contract_runtime/pump.rs +2 -0
  11. package/crates/team-agent/src/contract_runtime/registry.rs +86 -9
  12. package/crates/team-agent/src/contract_runtime/workspace.rs +79 -11
  13. package/crates/team-agent/src/leader/lease.rs +1 -18
  14. package/crates/team-agent/src/lifecycle/pane_input_lock.rs +86 -8
  15. package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -0
  16. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +101 -72
  17. package/crates/team-agent/src/lifecycle/restart/selection.rs +30 -8
  18. package/crates/team-agent/src/messaging/delivery.rs +132 -27
  19. package/crates/team-agent/src/messaging/enter_rescue.rs +334 -0
  20. package/crates/team-agent/src/messaging/enter_rescue_tests.rs +566 -0
  21. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  22. package/crates/team-agent/src/messaging/mod.rs +1 -0
  23. package/crates/team-agent/src/messaging/scheduler.rs +8 -0
  24. package/crates/team-agent/src/messaging/tests/basic.rs +2 -0
  25. package/crates/team-agent/src/messaging/types.rs +1 -0
  26. package/crates/team-agent/src/provider/session/capture.rs +24 -1
  27. package/crates/team-agent/src/state/persist.rs +72 -2
  28. package/crates/team-agent/src/state/projection.rs +29 -0
  29. package/crates/team-agent/src/state/repository/tests.rs +70 -0
  30. package/crates/team-agent/src/state/repository.rs +52 -1
  31. package/crates/team-agent/src/tmux_backend/native_input.rs +494 -0
  32. package/crates/team-agent/src/tmux_backend/native_input_tests.rs +781 -0
  33. package/crates/team-agent/src/tmux_backend.rs +49 -6
  34. package/crates/team-agent/src/transport/native_input.rs +594 -0
  35. package/crates/team-agent/src/transport.rs +19 -0
  36. package/package.json +4 -4
@@ -371,7 +371,15 @@ fn restart_with_selected_team_and_transport(
371
371
  team: Some(selected.team_key.as_str()),
372
372
  agent_id: None,
373
373
  })?;
374
- let mut state = selected.state;
374
+ // Selection happened before the lifecycle lock. A stop-agent that won the
375
+ // lock must not be undone by the old running snapshot used by restart.
376
+ let mut state = crate::state::selector::resolve_active_team(
377
+ &selected.run_workspace,
378
+ Some(selected.team_key.as_str()),
379
+ crate::state::selector::SelectorMode::RequireSpec,
380
+ )
381
+ .map_err(|error| LifecycleError::TeamSelect(error.to_string()))?
382
+ .state;
375
383
  crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, None)?;
376
384
  let topology_issue_ids = crate::topology::restart_dirty_topology_issue_ids(&state);
377
385
  if !topology_issue_ids.is_empty() {
@@ -1092,50 +1100,23 @@ fn restart_with_selected_team_and_transport(
1092
1100
  )?;
1093
1101
  phase_timer.emit(&selected.run_workspace, "restart.phase", LifecyclePhase::SaveState);
1094
1102
  if fatal_resume_failure {
1095
- let attach_commands = Vec::new();
1096
- let next_actions = restart_failure_next_actions(&failed_agents);
1097
- write_restart_completed_event(
1098
- &selected.run_workspace,
1099
- &successful_agents,
1100
- &failed_agents,
1101
- "fail",
1102
- None,
1103
- )?;
1104
- return Ok(RestartReport::Failed {
1105
- session_name,
1106
- failed_agents,
1107
- next_actions,
1108
- attach_commands,
1109
- });
1103
+ // The legacy cohort did not commit; its snapshot was restored above.
1104
+ // Do not promote tentative survivors, but still attempt the separately
1105
+ // admitted contract cohort before reporting the combined outcome.
1106
+ successful_agents.clear();
1110
1107
  }
1111
- if successful_agents.is_empty() && !failed_agents.is_empty() {
1112
- let attach_commands = Vec::new();
1113
- let next_actions = restart_failure_next_actions(&failed_agents);
1114
- write_restart_completed_event(
1115
- &selected.run_workspace,
1116
- &successful_agents,
1117
- &failed_agents,
1118
- "fail",
1119
- None,
1120
- )?;
1121
- return Ok(RestartReport::Failed {
1122
- session_name,
1123
- failed_agents,
1124
- next_actions,
1125
- attach_commands,
1126
- });
1127
- }
1128
- // Stamp the physical session birth after durable state. Replacing workers
1129
- // in a retained session does not create a new session generation.
1130
- if let Some(generation) = crate::state::projection::session_generation(&state) {
1131
- transport
1132
- .set_session_owner_with_generation(
1133
- &session_name,
1134
- &selected.run_workspace,
1135
- &selected.team_key,
1136
- generation,
1137
- )
1138
- .map_err(|error| LifecycleError::Transport(error.to_string()))?;
1108
+ // A native-only active cohort has no legacy replacement session to stamp.
1109
+ if !successful_agents.is_empty() {
1110
+ if let Some(generation) = crate::state::projection::session_generation(&state) {
1111
+ transport
1112
+ .set_session_owner_with_generation(
1113
+ &session_name,
1114
+ &selected.run_workspace,
1115
+ &selected.team_key,
1116
+ generation,
1117
+ )
1118
+ .map_err(|error| LifecycleError::Transport(error.to_string()))?;
1119
+ }
1139
1120
  }
1140
1121
  // RM-039-SESS-001 step 2 (architect verdict 2026-06-22): post-respawn
1141
1122
  // resume backing validation.
@@ -1344,8 +1325,16 @@ fn restart_with_selected_team_and_transport(
1344
1325
  )?;
1345
1326
  }
1346
1327
  }
1328
+ // Only legacy survivors enter legacy readiness; contract results are
1329
+ // aggregated afterwards and must never enter backing scans/Codex fallback.
1330
+ let legacy_successful_agents = successful_agents.clone();
1331
+ #[cfg(unix)]
1332
+ append_native_restart_results(&mut successful_agents, &mut failed_agents, || {
1333
+ crate::contract_runtime::commands::restart_prepared(
1334
+ &selected.run_workspace, &selected.team_key, &mut state, native_restart,
1335
+ )
1336
+ });
1347
1337
  if successful_agents.is_empty() && !failed_agents.is_empty() {
1348
- // Postflight demoted every survivor — emit Failed before coordinator start.
1349
1338
  let attach_commands = Vec::new();
1350
1339
  let next_actions = restart_failure_next_actions(&failed_agents);
1351
1340
  write_restart_completed_event(
@@ -1362,20 +1351,6 @@ fn restart_with_selected_team_and_transport(
1362
1351
  attach_commands,
1363
1352
  });
1364
1353
  }
1365
- let legacy_successful_agents = successful_agents.clone();
1366
- #[cfg(unix)]
1367
- for result in crate::contract_runtime::commands::restart_prepared(
1368
- &selected.run_workspace, &selected.team_key, &mut state, native_restart,
1369
- ) {
1370
- let decision = RestartedAgent {
1371
- agent_id: AgentId::new(result.agent_id), restart_mode: StartMode::Resumed,
1372
- decision: ResumeDecision::Resume, session_id: result.session_id.map(SessionId::new),
1373
- };
1374
- match result.outcome {
1375
- Ok(()) => successful_agents.push(decision),
1376
- Err(error) => failed_agents.push(restart_failed_agent(&decision, "native_resume", error)),
1377
- }
1378
- }
1379
1354
  drop(lifecycle_lock);
1380
1355
  let coordinator =
1381
1356
  start_coordinator_for_workspace(&selected.run_workspace, Some(&selected.team_key))?;
@@ -1386,15 +1361,17 @@ fn restart_with_selected_team_and_transport(
1386
1361
  );
1387
1362
  let coordinator_started = coordinator.ok;
1388
1363
  phase_timer.emit(&selected.run_workspace, "restart.phase", LifecyclePhase::ReadinessWait);
1389
- wait_restart_readiness_or_timeout(
1390
- &selected.run_workspace,
1391
- &state,
1392
- &session_name,
1393
- &legacy_successful_agents,
1394
- transport,
1395
- restart_readiness_deadline(readiness_deadline_ms),
1396
- restart_readiness_poll_interval(),
1397
- )?;
1364
+ if !legacy_successful_agents.is_empty() {
1365
+ wait_restart_readiness_or_timeout(
1366
+ &selected.run_workspace,
1367
+ &state,
1368
+ &session_name,
1369
+ &legacy_successful_agents,
1370
+ transport,
1371
+ restart_readiness_deadline(readiness_deadline_ms),
1372
+ restart_readiness_poll_interval(),
1373
+ )?;
1374
+ }
1398
1375
  let attach_commands =
1399
1376
  crate::tmux_backend::attach_command_for_transport_session(transport, &session_name)
1400
1377
  .or_else(|| {
@@ -1491,10 +1468,9 @@ fn repair_resume_sessions_from_event_log(
1491
1468
  .and_then(|agents| agents.get(&agent_id))
1492
1469
  .cloned()
1493
1470
  .unwrap_or(serde_json::Value::Null);
1494
- if previous
1495
- .get("session_id")
1496
- .and_then(serde_json::Value::as_str)
1497
- .is_some_and(|session| !session.is_empty())
1471
+ if crate::state::projection::agent_stopped_for_restart(&previous)
1472
+ || previous.get("session_id").and_then(serde_json::Value::as_str)
1473
+ .is_some_and(|session| !session.is_empty())
1498
1474
  {
1499
1475
  continue;
1500
1476
  }
@@ -1724,12 +1700,65 @@ fn parse_duration_value_seconds_ms(value: &str) -> Option<u64> {
1724
1700
  }
1725
1701
  }
1726
1702
 
1703
+ /// Run the admitted native cohort before classifying any legacy failure. Keeping
1704
+ /// the execution inside this seam makes the mixed-failure ordering testable.
1705
+ #[cfg(unix)]
1706
+ fn append_native_restart_results(
1707
+ successful: &mut Vec<RestartedAgent>,
1708
+ failed: &mut Vec<RestartFailedAgent>,
1709
+ restart: impl FnOnce() -> Vec<crate::contract_runtime::commands::NativeRestartResult>,
1710
+ ) {
1711
+ for result in restart() {
1712
+ let decision = RestartedAgent {
1713
+ agent_id: AgentId::new(result.agent_id), restart_mode: StartMode::Resumed,
1714
+ decision: ResumeDecision::Resume, session_id: result.session_id.map(SessionId::new),
1715
+ };
1716
+ match result.outcome {
1717
+ Ok(()) => successful.push(decision),
1718
+ Err(error) => failed.push(restart_failed_agent(&decision, "native_resume", error)),
1719
+ }
1720
+ }
1721
+ }
1722
+
1727
1723
  fn restart_readiness_deadline(requested_ms: Option<u64>) -> std::time::Duration {
1728
1724
  requested_ms
1729
1725
  .map(std::time::Duration::from_millis)
1730
1726
  .unwrap_or_else(|| env_duration_ms(&["TEAM_AGENT_RESTART_READINESS_DEADLINE_MS"], 30_000))
1731
1727
  }
1732
1728
 
1729
+ #[cfg(all(test, unix))]
1730
+ mod mixed_native_restart_tests {
1731
+ use super::*;
1732
+ use crate::contract_runtime::commands::NativeRestartResult;
1733
+
1734
+ #[test]
1735
+ fn failed_legacy_cohort_does_not_skip_native_resume_or_hide_its_result() {
1736
+ for legacy_failed in [false, true] {
1737
+ for native_failed in [false, true] {
1738
+ let mut successful = Vec::new();
1739
+ let legacy = RestartedAgent { agent_id: AgentId::new("legacy"),
1740
+ restart_mode: StartMode::Fresh, decision: ResumeDecision::FreshStart, session_id: None };
1741
+ let mut failed = if legacy_failed { vec![restart_failed_agent(&legacy, "spawn", "failed".into())] } else { vec![] };
1742
+ let mut attempts = 0;
1743
+ append_native_restart_results(&mut successful, &mut failed, || {
1744
+ attempts += 1;
1745
+ vec![NativeRestartResult { agent_id: "native".into(), session_id: Some("same-session".into()),
1746
+ outcome: if native_failed { Err("native failed".into()) } else { Ok(()) } }]
1747
+ });
1748
+ assert_eq!(attempts, 1, "no legacy survivor must not short-circuit native execution");
1749
+ assert_eq!(successful.len(), usize::from(!native_failed));
1750
+ assert_eq!(failed.len(), usize::from(legacy_failed) + usize::from(native_failed));
1751
+ if !native_failed {
1752
+ assert_eq!(successful[0].agent_id.as_str(), "native");
1753
+ assert_eq!(successful[0].session_id.as_ref().unwrap().as_str(), "same-session");
1754
+ } else {
1755
+ assert_eq!(failed.last().unwrap().phase, "native_resume");
1756
+ }
1757
+ }
1758
+ }
1759
+ }
1760
+ }
1761
+
1733
1762
  fn restart_readiness_poll_interval() -> std::time::Duration {
1734
1763
  env_duration_ms(&["TEAM_AGENT_RESTART_READINESS_POLL_MS"], 200)
1735
1764
  }
@@ -45,7 +45,7 @@ mod contract_route_tests {
45
45
  fn mixed_team_retains_legacy_classification_without_native_codex_fallback() {
46
46
  let state = serde_json::json!({"agents": {
47
47
  "native": {"provider":"cursor","status":"running","session_id":"native-id"},
48
- "legacy": {"provider":"pi","status":"stopped"},
48
+ "legacy": {"provider":"pi","status":"running"},
49
49
  }});
50
50
  let plan = classify_restart_plan(&state, false).unwrap();
51
51
  assert_eq!(plan.decisions.len(), 1);
@@ -54,6 +54,31 @@ mod contract_route_tests {
54
54
  }
55
55
  }
56
56
 
57
+ #[cfg(test)]
58
+ mod stopped_restart_tests {
59
+ use super::*;
60
+
61
+ #[test]
62
+ fn stopped_rows_never_enter_validation_or_fresh_spawn_even_with_allow_fresh() {
63
+ let state = serde_json::json!({"agents": {
64
+ "stopped": {"provider":"pi","status":"stopped","explicit_stop":true,
65
+ "session_id":"preserved","generation":7,"first_send_at":false},
66
+ "legacy_stopped": {"provider":"pi","status":"stopped"},
67
+ "marker": {"provider":"pi","status":"running","explicit_stop":true},
68
+ "active": {"provider":"pi","status":"running"}
69
+ }});
70
+ let before = state.clone();
71
+ for allow_fresh in [false, true] {
72
+ let plan = classify_restart_plan(&state, allow_fresh).unwrap();
73
+ assert_eq!(plan.decisions.len(), 1);
74
+ assert_eq!(plan.decisions[0].agent_id.as_str(), "active");
75
+ assert!(plan.corrupt_entries.is_empty());
76
+ assert!(plan.unresumable.is_empty());
77
+ assert_eq!(state, before);
78
+ }
79
+ }
80
+ }
81
+
57
82
  /// ---
58
83
  /// purpose: 定出该席位这次的启动模式
59
84
  /// params:
@@ -266,13 +291,13 @@ pub(crate) fn python_type_name(value: &serde_json::Value) -> &'static str {
266
291
  /// purpose: 纯计算出整队的 restart 计划
267
292
  /// params:
268
293
  /// allow_fresh: 允许无会话席位改跑全新会话
269
- /// returns: 每个非 paused 席位的 resume 决策、损坏的 first_send_at 条目与不可 resume 名单
294
+ /// returns: 每个非 paused/非 stopped 席位的 resume 决策、损坏的 first_send_at 条目与不可 resume 名单
270
295
  /// errors: 计算过程中的错误以 LifecycleError 返回
271
296
  /// contract_id: lifecycle.selection.classify_restart_plan
272
297
  /// ---
273
298
  /// Route B 全量验证(纯计算,**无破坏性副作用**;`_emit_resume_decisions` +
274
299
  /// `_collect_corrupt_first_send_at`,`orchestration.py:430/467`)。读 fixture state 的
275
- /// `agents.<id>`,对每非 paused worker:
300
+ /// `agents.<id>`,对每非 paused/非 stopped worker(explicit_stop 也跳过):
276
301
  /// (1) corrupt first_send_at → 收进 `corrupt_entries`(carry python type-name);
277
302
  /// (2) 算 resume 决策(`session_id→Resume` / `null session && allow_fresh→FreshStart` /
278
303
  /// 否则 `Refuse`;E6 层2:null session 不再因 first_send_at=null 静默 fresh);
@@ -317,11 +342,8 @@ pub(crate) fn classify_restart_plan_with_resume_validation(
317
342
  // not this legacy enum's unknown-provider Codex default.
318
343
  #[cfg(unix)]
319
344
  if crate::contract_runtime::framework::is_contract(agent) { continue; }
320
- if agent
321
- .get("status")
322
- .and_then(|v| v.as_str())
323
- .map(|s| s == "paused")
324
- .unwrap_or(false)
345
+ if crate::state::projection::agent_stopped_for_restart(agent)
346
+ || agent.get("status").and_then(|v| v.as_str()) == Some("paused")
325
347
  {
326
348
  continue;
327
349
  }
@@ -683,26 +683,33 @@ fn deliver_prepared_message(
683
683
  resolved.metadata.as_ref(),
684
684
  );
685
685
  let recipient_cursor = recipient_is_cursor_agent(state, &message.recipient);
686
- let inject_attempt = crate::tmux_backend::with_paste_to_submit_floor(
687
- paste_to_submit_floor_for_recipient(state, &message.recipient),
688
- || {
689
- crate::tmux_backend::with_cursor_single_enter(recipient_cursor, || {
690
- transport.inject_with_submit_observer(
691
- &target,
692
- &payload,
693
- Key::Enter,
694
- true,
695
- Some(&submit_observer),
696
- )
697
- })
698
- },
699
- );
686
+ let native_request = recipient_provider(state, &message.recipient)
687
+ .filter(|provider| matches!(provider, Provider::Pi | Provider::CursorAgent))
688
+ .filter(|_| message.recipient != "leader" && transport.supports_owned_native_input())
689
+ .map(|provider| crate::transport::native_input::NativeInputRequest {
690
+ message_id: message_id.to_string(),
691
+ owner_team_id: canonical_owner_team_id.clone().unwrap_or_default(),
692
+ recipient: message.recipient.clone(), provider,
693
+ pane_id: resolved.metadata.as_ref().map(|meta| meta.target_pane_id.clone()).unwrap_or_default(),
694
+ pane_pid: resolved.metadata.as_ref().and_then(|meta| meta.target_pane_pid).unwrap_or_default(),
695
+ session: resolved.metadata.as_ref().map(|meta| meta.target_session.clone()).unwrap_or_default(),
696
+ window: resolved.metadata.as_ref().map(|meta| meta.target_window.clone()).unwrap_or_default(),
697
+ });
698
+ let guarded_native = native_request.is_some();
699
+ let (inject_attempt, native_evidence) = crate::transport::native_input::with_native_input(native_request, || {
700
+ crate::tmux_backend::with_paste_to_submit_floor(
701
+ paste_to_submit_floor_for_recipient(state, &message.recipient),
702
+ || crate::tmux_backend::with_cursor_single_enter(recipient_cursor, || {
703
+ transport.inject_with_submit_observer(&target, &payload, Key::Enter, true, Some(&submit_observer))
704
+ }),
705
+ )
706
+ });
700
707
  let inject_report = match inject_attempt {
701
708
  Ok(report) => {
702
709
  // grok 忙时第一次回车只入显式队列。默认 send-now:只重按回车顶出去。
703
710
  // 无 `Enter:send now` 时零次额外按键,claude/codex 行为不变。
704
711
  // cursor:第二下 Enter 打断进行中回合,不走 flush。
705
- if !crate::provider::submit_now::keep_provider_queue_requested() && !recipient_cursor {
712
+ if !guarded_native && !crate::provider::submit_now::keep_provider_queue_requested() && !recipient_cursor {
706
713
  match crate::provider::submit_now::flush_explicit_queue(transport, &target) {
707
714
  Ok(flush) if flush.extra_enters > 0 => {
708
715
  let _ = event_log.write(
@@ -732,6 +739,28 @@ fn deliver_prepared_message(
732
739
  }
733
740
  Err(error) => {
734
741
  let reason = format!("inject_failed:{error}");
742
+ if guarded_native {
743
+ let pasted = native_evidence.as_ref().filter(|evidence|
744
+ evidence.phase >= crate::transport::native_input::NativeInputPhase::MayHavePasted);
745
+ let status = if let Some(evidence) = pasted {
746
+ super::enter_rescue::park(store, evidence, attempt, &reason, event_log)?;
747
+ "submitted_unverified"
748
+ } else {
749
+ super::enter_rescue::release_no_effect(store, message_id, attempt)?;
750
+ "target_resolved"
751
+ };
752
+ event_log.write("native_input.deferred", serde_json::json!({
753
+ "message_id": message_id, "reason": reason, "status": status,
754
+ "may_have_pasted": pasted.is_some(),
755
+ }))?;
756
+ return Ok(DeliveryOutcome {
757
+ ok: false, status: DeliveryStatus::Queued,
758
+ message_status: MessageStatusShadow(status.to_string()),
759
+ message_id: Some(message_id.to_string()), verification: Some(reason),
760
+ stage: Some(DeliveryStage::Inject), reason: None, channel: None,
761
+ ack_forced_off: false, turn_verification: None,
762
+ });
763
+ }
735
764
  if message.recipient == "leader" {
736
765
  store.mark(message_id, "failed", Some("leader_not_attached"))?;
737
766
  event_log.write(
@@ -897,7 +926,11 @@ fn deliver_prepared_message(
897
926
  turn_verification: Some(turn_verification),
898
927
  });
899
928
  }
900
- store.mark(message_id, "submitted_unverified", Some(&reason))?;
929
+ if let Some(evidence) = native_evidence.as_ref() {
930
+ super::enter_rescue::park(store, evidence, attempt, &reason, event_log)?;
931
+ } else {
932
+ store.mark(message_id, "submitted_unverified", Some(&reason))?;
933
+ }
901
934
  return Ok(DeliveryOutcome {
902
935
  ok: false,
903
936
  status: DeliveryStatus::Failed,
@@ -2800,6 +2833,40 @@ pub(crate) fn record_turn_open_if_leader_to_worker(
2800
2833
  )
2801
2834
  }
2802
2835
 
2836
+ /// A rescue that supplies the first successful Enter must arm the same turn
2837
+ /// linkage as normal delivery, immediately after the key (not after UI polling).
2838
+ pub(super) fn with_rescue_submit_observer<R>(
2839
+ workspace: &Path,
2840
+ store: &MessageStore,
2841
+ evidence: &crate::transport::native_input::NativeInputEvidence,
2842
+ event_log: &EventLog,
2843
+ execute: impl FnOnce(&dyn SubmitObserver) -> Result<R, MessagingError>,
2844
+ ) -> Result<R, MessagingError> {
2845
+ let request = &evidence.request;
2846
+ let conn = crate::db::schema::open_db(store.db_path())?;
2847
+ let sender: String = conn.query_row(
2848
+ "select sender from messages where message_id=?1 and owner_team_id=?2 and recipient=?3 and status='submitted_unverified'",
2849
+ params![request.message_id, request.owner_team_id, request.recipient],
2850
+ |row| row.get(0),
2851
+ )?;
2852
+ let metadata = TargetMetadata {
2853
+ tmux_endpoint: Some(evidence.binding.endpoint.clone()),
2854
+ target_session: evidence.binding.session.clone(),
2855
+ target_window: evidence.binding.window.clone(),
2856
+ target_pane_id: evidence.binding.pane_id.clone(),
2857
+ target_pane_pid: Some(evidence.binding.pane_pid),
2858
+ resolved_from: "enter_rescue_owned_binding",
2859
+ };
2860
+ let mut observer = CurrentTurnSubmitObserver::new(
2861
+ workspace, &sender, &request.recipient, &request.message_id, event_log,
2862
+ Some(&request.owner_team_id), Some(&metadata),
2863
+ );
2864
+ observer.rescue_evidence = Some(evidence);
2865
+ let result = execute(&observer)?;
2866
+ if let Some(error) = observer.take_error() { return Err(error); }
2867
+ Ok(result)
2868
+ }
2869
+
2803
2870
  struct CurrentTurnSubmitObserver<'a> {
2804
2871
  workspace: &'a Path,
2805
2872
  sender: &'a str,
@@ -2808,6 +2875,7 @@ struct CurrentTurnSubmitObserver<'a> {
2808
2875
  event_log: &'a EventLog,
2809
2876
  owner_team_id: Option<&'a str>,
2810
2877
  metadata: Option<&'a TargetMetadata>,
2878
+ rescue_evidence: Option<&'a crate::transport::native_input::NativeInputEvidence>,
2811
2879
  fired: Cell<bool>,
2812
2880
  error: RefCell<Option<MessagingError>>,
2813
2881
  }
@@ -2830,6 +2898,7 @@ impl<'a> CurrentTurnSubmitObserver<'a> {
2830
2898
  event_log,
2831
2899
  owner_team_id,
2832
2900
  metadata,
2901
+ rescue_evidence: None,
2833
2902
  fired: Cell::new(false),
2834
2903
  error: RefCell::new(None),
2835
2904
  }
@@ -2845,20 +2914,56 @@ impl SubmitObserver for CurrentTurnSubmitObserver<'_> {
2845
2914
  if self.fired.replace(true) {
2846
2915
  return;
2847
2916
  }
2848
- if let Err(error) = record_current_turn_after_inject_if_leader_to_worker_scoped(
2849
- self.workspace,
2850
- self.sender,
2851
- self.recipient,
2852
- self.message_id,
2853
- self.event_log,
2854
- self.owner_team_id,
2855
- self.metadata,
2856
- ) {
2857
- *self.error.borrow_mut() = Some(error);
2858
- }
2917
+ let result = if let Some(evidence) = self.rescue_evidence {
2918
+ record_rescue_turn_after_submit(self.workspace, self.sender, evidence, self.event_log, self.metadata)
2919
+ } else {
2920
+ record_current_turn_after_inject_if_leader_to_worker_scoped(
2921
+ self.workspace, self.sender, self.recipient, self.message_id,
2922
+ self.event_log, self.owner_team_id, self.metadata,
2923
+ )
2924
+ };
2925
+ if let Err(error) = result { *self.error.borrow_mut() = Some(error); }
2859
2926
  }
2860
2927
  }
2861
2928
 
2929
+ fn record_rescue_turn_after_submit(
2930
+ workspace: &Path,
2931
+ sender: &str,
2932
+ evidence: &crate::transport::native_input::NativeInputEvidence,
2933
+ event_log: &EventLog,
2934
+ metadata: Option<&TargetMetadata>,
2935
+ ) -> Result<(), MessagingError> {
2936
+ let request = &evidence.request;
2937
+ if !matches!(sender, "leader" | "Leader") { return Ok(()); }
2938
+ crate::state::repository::StateRepository::new(workspace).commit_messaging_checked(
2939
+ crate::state::repository::StateWriteIntent::MessagingDeliveryState {
2940
+ owner_team_id: Some(&request.owner_team_id),
2941
+ },
2942
+ |selected| {
2943
+ let agent = selected.get("agents").and_then(|agents| agents.get(&request.recipient));
2944
+ let matches = agent.is_some_and(|agent| {
2945
+ agent.get("status").and_then(serde_json::Value::as_str) == Some("running")
2946
+ && agent.get("explicit_stop").and_then(serde_json::Value::as_bool) != Some(true)
2947
+ && agent.get("pane_id").and_then(serde_json::Value::as_str) == Some(request.pane_id.as_str())
2948
+ && agent.get("pane_pid").and_then(serde_json::Value::as_u64) == Some(u64::from(request.pane_pid))
2949
+ && request.pane_pid == evidence.binding.pane_pid
2950
+ && agent.get("window").and_then(serde_json::Value::as_str).unwrap_or(&request.recipient) == request.window
2951
+ && agent.get("provider").cloned().and_then(|value| serde_json::from_value::<Provider>(value).ok()) == Some(request.provider)
2952
+ && selected.get("session_name").and_then(serde_json::Value::as_str) == Some(request.session.as_str())
2953
+ });
2954
+ if !matches {
2955
+ return Err(crate::state::StateError::SaveConflict("rescue receiver changed before turn commit".into()));
2956
+ }
2957
+ arm_turn_open(selected, &request.recipient, &Some(request.message_id.clone()));
2958
+ Ok(())
2959
+ },
2960
+ )?;
2961
+ let mut event = serde_json::json!({"agent_id": request.recipient, "message_id": request.message_id});
2962
+ if let Some(metadata) = metadata { append_target_metadata(&mut event, metadata); }
2963
+ event_log.write("turn_open.armed_after_inject", event)?;
2964
+ Ok(())
2965
+ }
2966
+
2862
2967
  fn record_current_turn_after_inject_if_leader_to_worker_scoped(
2863
2968
  workspace: &Path,
2864
2969
  sender: &str,