@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/mod.rs +143 -12
- package/crates/team-agent/src/cli/shutdown_socket.rs +288 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +11 -4
- package/crates/team-agent/src/contract_runtime/backend.rs +88 -8
- package/crates/team-agent/src/contract_runtime/commands.rs +88 -7
- package/crates/team-agent/src/contract_runtime/discovery.rs +42 -10
- package/crates/team-agent/src/contract_runtime/mcp.rs +3 -0
- package/crates/team-agent/src/contract_runtime/pump.rs +2 -0
- package/crates/team-agent/src/contract_runtime/registry.rs +86 -9
- package/crates/team-agent/src/contract_runtime/workspace.rs +79 -11
- package/crates/team-agent/src/leader/lease.rs +1 -18
- package/crates/team-agent/src/lifecycle/pane_input_lock.rs +86 -8
- package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +101 -72
- package/crates/team-agent/src/lifecycle/restart/selection.rs +30 -8
- package/crates/team-agent/src/messaging/delivery.rs +132 -27
- package/crates/team-agent/src/messaging/enter_rescue.rs +334 -0
- package/crates/team-agent/src/messaging/enter_rescue_tests.rs +566 -0
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/mod.rs +1 -0
- package/crates/team-agent/src/messaging/scheduler.rs +8 -0
- package/crates/team-agent/src/messaging/tests/basic.rs +2 -0
- package/crates/team-agent/src/messaging/types.rs +1 -0
- package/crates/team-agent/src/provider/session/capture.rs +24 -1
- package/crates/team-agent/src/state/persist.rs +72 -2
- package/crates/team-agent/src/state/projection.rs +29 -0
- package/crates/team-agent/src/state/repository/tests.rs +70 -0
- package/crates/team-agent/src/state/repository.rs +52 -1
- package/crates/team-agent/src/tmux_backend/native_input.rs +494 -0
- package/crates/team-agent/src/tmux_backend/native_input_tests.rs +781 -0
- package/crates/team-agent/src/tmux_backend.rs +49 -6
- package/crates/team-agent/src/transport/native_input.rs +594 -0
- package/crates/team-agent/src/transport.rs +19 -0
- 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
|
-
|
|
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
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
-
|
|
1112
|
-
|
|
1113
|
-
let
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
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
|
-
|
|
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":"
|
|
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
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
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
|
|
2849
|
-
self.workspace,
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
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,
|