@team-agent/installer 0.5.114 → 0.5.116
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/emit.rs +2 -2
- package/crates/team-agent/src/cli/mod.rs +155 -19
- package/crates/team-agent/src/cli/models.rs +52 -1
- package/crates/team-agent/src/cli/shutdown_socket.rs +288 -0
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +11 -4
- package/crates/team-agent/src/contract_runtime/backend.rs +490 -97
- package/crates/team-agent/src/contract_runtime/commands.rs +326 -0
- package/crates/team-agent/src/contract_runtime/config.rs +39 -8
- package/crates/team-agent/src/contract_runtime/config_tests.rs +56 -0
- package/crates/team-agent/src/contract_runtime/discovery.rs +218 -40
- package/crates/team-agent/src/contract_runtime/framework.rs +38 -5
- package/crates/team-agent/src/contract_runtime/mcp.rs +3 -0
- package/crates/team-agent/src/contract_runtime/mod.rs +2 -0
- package/crates/team-agent/src/contract_runtime/pump.rs +97 -33
- package/crates/team-agent/src/contract_runtime/registry.rs +248 -4
- package/crates/team-agent/src/contract_runtime/workspace.rs +228 -0
- package/crates/team-agent/src/leader/start.rs +93 -5
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +99 -1
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +3 -5
- package/crates/team-agent/src/lifecycle/launch.rs +6 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +109 -58
- package/crates/team-agent/src/lifecycle/restart/selection.rs +63 -7
- package/crates/team-agent/src/lifecycle/role_config.rs +17 -1
- 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 +5 -1
- 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() {
|
|
@@ -405,6 +413,14 @@ fn restart_with_selected_team_and_transport(
|
|
|
405
413
|
let spec =
|
|
406
414
|
rebuild_runtime_spec_from_roles(&selected.run_workspace, &selected.team_key, &state)?;
|
|
407
415
|
phase_timer.emit(&selected.run_workspace, "restart.phase", LifecyclePhase::CompileSpec);
|
|
416
|
+
// All native H2/model/session/cwd gates run before any legacy teardown.
|
|
417
|
+
// Their private sockets and Node images must not enter legacy selection.
|
|
418
|
+
#[cfg(unix)]
|
|
419
|
+
let native_restart = if crate::contract_runtime::framework::has_contract(&state) {
|
|
420
|
+
crate::contract_runtime::backend::prepare_restart(
|
|
421
|
+
&selected.run_workspace, &selected.team_key, &selected.team_dir, &state,
|
|
422
|
+
).map_err(|error| LifecycleError::Provider(error.to_string()))?
|
|
423
|
+
} else { Vec::new() };
|
|
408
424
|
// 重建后 spec_workspace 恒为 runtime spec 的父目录(.team/runtime/<team_key>/)。
|
|
409
425
|
let runtime_spec =
|
|
410
426
|
crate::model::paths::runtime_spec_path(&selected.run_workspace, &selected.team_key);
|
|
@@ -1084,50 +1100,23 @@ fn restart_with_selected_team_and_transport(
|
|
|
1084
1100
|
)?;
|
|
1085
1101
|
phase_timer.emit(&selected.run_workspace, "restart.phase", LifecyclePhase::SaveState);
|
|
1086
1102
|
if fatal_resume_failure {
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
&successful_agents,
|
|
1092
|
-
&failed_agents,
|
|
1093
|
-
"fail",
|
|
1094
|
-
None,
|
|
1095
|
-
)?;
|
|
1096
|
-
return Ok(RestartReport::Failed {
|
|
1097
|
-
session_name,
|
|
1098
|
-
failed_agents,
|
|
1099
|
-
next_actions,
|
|
1100
|
-
attach_commands,
|
|
1101
|
-
});
|
|
1102
|
-
}
|
|
1103
|
-
if successful_agents.is_empty() && !failed_agents.is_empty() {
|
|
1104
|
-
let attach_commands = Vec::new();
|
|
1105
|
-
let next_actions = restart_failure_next_actions(&failed_agents);
|
|
1106
|
-
write_restart_completed_event(
|
|
1107
|
-
&selected.run_workspace,
|
|
1108
|
-
&successful_agents,
|
|
1109
|
-
&failed_agents,
|
|
1110
|
-
"fail",
|
|
1111
|
-
None,
|
|
1112
|
-
)?;
|
|
1113
|
-
return Ok(RestartReport::Failed {
|
|
1114
|
-
session_name,
|
|
1115
|
-
failed_agents,
|
|
1116
|
-
next_actions,
|
|
1117
|
-
attach_commands,
|
|
1118
|
-
});
|
|
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();
|
|
1119
1107
|
}
|
|
1120
|
-
//
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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
|
+
}
|
|
1131
1120
|
}
|
|
1132
1121
|
// RM-039-SESS-001 step 2 (architect verdict 2026-06-22): post-respawn
|
|
1133
1122
|
// resume backing validation.
|
|
@@ -1336,8 +1325,16 @@ fn restart_with_selected_team_and_transport(
|
|
|
1336
1325
|
)?;
|
|
1337
1326
|
}
|
|
1338
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
|
+
});
|
|
1339
1337
|
if successful_agents.is_empty() && !failed_agents.is_empty() {
|
|
1340
|
-
// Postflight demoted every survivor — emit Failed before coordinator start.
|
|
1341
1338
|
let attach_commands = Vec::new();
|
|
1342
1339
|
let next_actions = restart_failure_next_actions(&failed_agents);
|
|
1343
1340
|
write_restart_completed_event(
|
|
@@ -1364,15 +1361,17 @@ fn restart_with_selected_team_and_transport(
|
|
|
1364
1361
|
);
|
|
1365
1362
|
let coordinator_started = coordinator.ok;
|
|
1366
1363
|
phase_timer.emit(&selected.run_workspace, "restart.phase", LifecyclePhase::ReadinessWait);
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
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
|
+
}
|
|
1376
1375
|
let attach_commands =
|
|
1377
1376
|
crate::tmux_backend::attach_command_for_transport_session(transport, &session_name)
|
|
1378
1377
|
.or_else(|| {
|
|
@@ -1469,10 +1468,9 @@ fn repair_resume_sessions_from_event_log(
|
|
|
1469
1468
|
.and_then(|agents| agents.get(&agent_id))
|
|
1470
1469
|
.cloned()
|
|
1471
1470
|
.unwrap_or(serde_json::Value::Null);
|
|
1472
|
-
if previous
|
|
1473
|
-
.get("session_id")
|
|
1474
|
-
|
|
1475
|
-
.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())
|
|
1476
1474
|
{
|
|
1477
1475
|
continue;
|
|
1478
1476
|
}
|
|
@@ -1702,12 +1700,65 @@ fn parse_duration_value_seconds_ms(value: &str) -> Option<u64> {
|
|
|
1702
1700
|
}
|
|
1703
1701
|
}
|
|
1704
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
|
+
|
|
1705
1723
|
fn restart_readiness_deadline(requested_ms: Option<u64>) -> std::time::Duration {
|
|
1706
1724
|
requested_ms
|
|
1707
1725
|
.map(std::time::Duration::from_millis)
|
|
1708
1726
|
.unwrap_or_else(|| env_duration_ms(&["TEAM_AGENT_RESTART_READINESS_DEADLINE_MS"], 30_000))
|
|
1709
1727
|
}
|
|
1710
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
|
+
|
|
1711
1762
|
fn restart_readiness_poll_interval() -> std::time::Duration {
|
|
1712
1763
|
env_duration_ms(&["TEAM_AGENT_RESTART_READINESS_POLL_MS"], 200)
|
|
1713
1764
|
}
|
|
@@ -24,6 +24,61 @@ use super::common::*;
|
|
|
24
24
|
use super::*;
|
|
25
25
|
use crate::provider::wire::{parse_canonical_provider, requires_resume_backing};
|
|
26
26
|
|
|
27
|
+
#[cfg(all(test, unix))]
|
|
28
|
+
mod contract_route_tests {
|
|
29
|
+
use super::*;
|
|
30
|
+
|
|
31
|
+
#[test]
|
|
32
|
+
fn contract_seats_never_enter_legacy_resume_or_fresh_classification() {
|
|
33
|
+
let state = serde_json::json!({"agents": {
|
|
34
|
+
"cursor": {"provider":"cursor","status":"running","session_id":"native-cursor"},
|
|
35
|
+
"kiro": {"provider":"kiro","status":"running","session_id":"native-kiro"},
|
|
36
|
+
}});
|
|
37
|
+
for allow_fresh in [false, true] {
|
|
38
|
+
let plan = classify_restart_plan(&state, allow_fresh).unwrap();
|
|
39
|
+
assert!(plan.decisions.is_empty());
|
|
40
|
+
assert!(plan.unresumable.is_empty());
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#[test]
|
|
45
|
+
fn mixed_team_retains_legacy_classification_without_native_codex_fallback() {
|
|
46
|
+
let state = serde_json::json!({"agents": {
|
|
47
|
+
"native": {"provider":"cursor","status":"running","session_id":"native-id"},
|
|
48
|
+
"legacy": {"provider":"pi","status":"running"},
|
|
49
|
+
}});
|
|
50
|
+
let plan = classify_restart_plan(&state, false).unwrap();
|
|
51
|
+
assert_eq!(plan.decisions.len(), 1);
|
|
52
|
+
assert_eq!(plan.decisions[0].agent_id.as_str(), "legacy");
|
|
53
|
+
assert_eq!(plan.decisions[0].restart_mode, StartMode::Fresh);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
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
|
+
|
|
27
82
|
/// ---
|
|
28
83
|
/// purpose: 定出该席位这次的启动模式
|
|
29
84
|
/// params:
|
|
@@ -236,13 +291,13 @@ pub(crate) fn python_type_name(value: &serde_json::Value) -> &'static str {
|
|
|
236
291
|
/// purpose: 纯计算出整队的 restart 计划
|
|
237
292
|
/// params:
|
|
238
293
|
/// allow_fresh: 允许无会话席位改跑全新会话
|
|
239
|
-
/// returns: 每个非 paused 席位的 resume 决策、损坏的 first_send_at 条目与不可 resume 名单
|
|
294
|
+
/// returns: 每个非 paused/非 stopped 席位的 resume 决策、损坏的 first_send_at 条目与不可 resume 名单
|
|
240
295
|
/// errors: 计算过程中的错误以 LifecycleError 返回
|
|
241
296
|
/// contract_id: lifecycle.selection.classify_restart_plan
|
|
242
297
|
/// ---
|
|
243
298
|
/// Route B 全量验证(纯计算,**无破坏性副作用**;`_emit_resume_decisions` +
|
|
244
299
|
/// `_collect_corrupt_first_send_at`,`orchestration.py:430/467`)。读 fixture state 的
|
|
245
|
-
/// `agents.<id>`,对每非 paused worker
|
|
300
|
+
/// `agents.<id>`,对每非 paused/非 stopped worker(explicit_stop 也跳过):
|
|
246
301
|
/// (1) corrupt first_send_at → 收进 `corrupt_entries`(carry python type-name);
|
|
247
302
|
/// (2) 算 resume 决策(`session_id→Resume` / `null session && allow_fresh→FreshStart` /
|
|
248
303
|
/// 否则 `Refuse`;E6 层2:null session 不再因 first_send_at=null 静默 fresh);
|
|
@@ -283,11 +338,12 @@ pub(crate) fn classify_restart_plan_with_resume_validation(
|
|
|
283
338
|
};
|
|
284
339
|
|
|
285
340
|
for (worker_id, agent) in agents {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
341
|
+
// Contract resumes are admitted against their canonical native store,
|
|
342
|
+
// not this legacy enum's unknown-provider Codex default.
|
|
343
|
+
#[cfg(unix)]
|
|
344
|
+
if crate::contract_runtime::framework::is_contract(agent) { continue; }
|
|
345
|
+
if crate::state::projection::agent_stopped_for_restart(agent)
|
|
346
|
+
|| agent.get("status").and_then(|v| v.as_str()) == Some("paused")
|
|
291
347
|
{
|
|
292
348
|
continue;
|
|
293
349
|
}
|
|
@@ -486,7 +486,7 @@ fn raw_line_end(text: &str, start: usize) -> (usize, usize) {
|
|
|
486
486
|
}
|
|
487
487
|
}
|
|
488
488
|
|
|
489
|
-
fn patched_role(
|
|
489
|
+
pub(crate) fn patched_role(
|
|
490
490
|
text: &str,
|
|
491
491
|
agent_id: &AgentId,
|
|
492
492
|
patch: &RoleConfigPatch,
|
|
@@ -775,6 +775,22 @@ impl RoleSnapshot {
|
|
|
775
775
|
}
|
|
776
776
|
}
|
|
777
777
|
|
|
778
|
+
/// Compare-and-replace a role only after its complete native plan is admitted.
|
|
779
|
+
/// No rollback of a live/uncertain native generation is implied by this write.
|
|
780
|
+
pub(crate) fn commit_contract_role(
|
|
781
|
+
path: &Path,
|
|
782
|
+
expected: &str,
|
|
783
|
+
replacement: &str,
|
|
784
|
+
) -> Result<(), LifecycleError> {
|
|
785
|
+
if read_text(path)? != expected {
|
|
786
|
+
return Err(LifecycleError::RequirementUnmet("role source changed during admission".into()));
|
|
787
|
+
}
|
|
788
|
+
if expected != replacement {
|
|
789
|
+
atomic_write(path, replacement.as_bytes())?;
|
|
790
|
+
}
|
|
791
|
+
Ok(())
|
|
792
|
+
}
|
|
793
|
+
|
|
778
794
|
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), LifecycleError> {
|
|
779
795
|
static NEXT: AtomicU64 = AtomicU64::new(0);
|
|
780
796
|
if let Some(parent) = path.parent() {
|
|
@@ -868,7 +868,8 @@ fn incomplete_resumable_provider<F>(agent: &Value, adapter_for: &mut F) -> Optio
|
|
|
868
868
|
where
|
|
869
869
|
F: FnMut(Provider) -> Box<dyn ProviderAdapter>,
|
|
870
870
|
{
|
|
871
|
-
if agent
|
|
871
|
+
if crate::state::projection::agent_explicit_stop(agent)
|
|
872
|
+
|| agent
|
|
872
873
|
.get("status")
|
|
873
874
|
.and_then(Value::as_str)
|
|
874
875
|
.is_some_and(|status| status != "running")
|
|
@@ -892,6 +893,28 @@ where
|
|
|
892
893
|
Some(provider)
|
|
893
894
|
}
|
|
894
895
|
|
|
896
|
+
#[cfg(test)]
|
|
897
|
+
mod stopped_capture_tests {
|
|
898
|
+
use super::*;
|
|
899
|
+
|
|
900
|
+
#[test]
|
|
901
|
+
fn explicit_stop_is_checked_before_adapter_or_backing_probes() {
|
|
902
|
+
for row in [
|
|
903
|
+
serde_json::json!({"status":"stopped","provider":"pi","spawn_cwd":"/controlled"}),
|
|
904
|
+
serde_json::json!({"status":"running","explicit_stop":true,"provider":"pi","spawn_cwd":"/controlled"}),
|
|
905
|
+
serde_json::json!({"status":"running","explicit_stop":"false","provider":"pi","spawn_cwd":"/controlled"}),
|
|
906
|
+
] {
|
|
907
|
+
assert!(incomplete_resumable_provider(&row, &mut |_| panic!("held seat reached adapter lookup")).is_none());
|
|
908
|
+
}
|
|
909
|
+
let mut calls = 0;
|
|
910
|
+
let _ = incomplete_resumable_provider(
|
|
911
|
+
&serde_json::json!({"status":"running","explicit_stop":false,"provider":"pi","spawn_cwd":"/controlled"}),
|
|
912
|
+
&mut |provider| { calls += 1; crate::provider::get_adapter(provider) },
|
|
913
|
+
);
|
|
914
|
+
assert_eq!(calls, 1, "active control must still be eligible for capture");
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
895
918
|
fn agent_session_complete(agent: &Value) -> bool {
|
|
896
919
|
// RM-039-SESS-001 step 1 (architect verdict 2026-06-22): historically
|
|
897
920
|
// this returned true the moment `rollout_path` was non-empty — even when
|
|
@@ -48,7 +48,7 @@ const SESSION_STATE_FIELDS: [&str; 6] = [
|
|
|
48
48
|
];
|
|
49
49
|
const LIVE_TOPOLOGY_FIELDS: [&str; 5] =
|
|
50
50
|
["pane_id", "pane_pid", "window", "spawned_at", "spawn_epoch"];
|
|
51
|
-
const ROSTER_STUB_ALLOWLIST: [&str;
|
|
51
|
+
const ROSTER_STUB_ALLOWLIST: [&str; 17] = [
|
|
52
52
|
"agent_id",
|
|
53
53
|
"provider",
|
|
54
54
|
"auth_mode",
|
|
@@ -65,6 +65,7 @@ const ROSTER_STUB_ALLOWLIST: [&str; 16] = [
|
|
|
65
65
|
"claude_config_dir",
|
|
66
66
|
"claude_projects_root",
|
|
67
67
|
"profile_launch",
|
|
68
|
+
"explicit_stop",
|
|
68
69
|
];
|
|
69
70
|
|
|
70
71
|
/// `state.py:_RUNTIME_STATE_CACHE`:进程级 path→state 缓存(deep-equal 早返回)。
|
|
@@ -626,6 +627,16 @@ fn persist_runtime_state_with_merge_options_and_expected(
|
|
|
626
627
|
pub(super) fn update_runtime_state(
|
|
627
628
|
workspace: &Path,
|
|
628
629
|
update: impl FnOnce(&Value) -> Result<Value, StateError>,
|
|
630
|
+
) -> Result<Value, StateError> {
|
|
631
|
+
update_runtime_state_with_lifecycle_target(workspace, None, update)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/// Keep bounded StartAgent's exact team/seat authority inside the same state-save
|
|
635
|
+
/// transaction. Ordinary deltas retain every existing stale-writer guard.
|
|
636
|
+
pub(super) fn update_runtime_state_with_lifecycle_target(
|
|
637
|
+
workspace: &Path,
|
|
638
|
+
target: Option<(&str, &str)>,
|
|
639
|
+
update: impl FnOnce(&Value) -> Result<Value, StateError>,
|
|
629
640
|
) -> Result<Value, StateError> {
|
|
630
641
|
let path = runtime_state_path(workspace);
|
|
631
642
|
let _lock = RuntimeLock::acquire(workspace, "state-save", 2.0)?;
|
|
@@ -639,7 +650,12 @@ pub(super) fn update_runtime_state(
|
|
|
639
650
|
migrate_state_identity(&mut latest, &SystemEnv, workspace)?;
|
|
640
651
|
let _ = migrate_active_team_key(&mut latest);
|
|
641
652
|
let mut committed = update(&latest)?;
|
|
642
|
-
|
|
653
|
+
let topology_updates = target.into_iter()
|
|
654
|
+
.map(|(team, agent)| (team.to_owned(), agent.to_owned())).collect();
|
|
655
|
+
apply_persist_merge_contract(
|
|
656
|
+
&mut committed, &latest, &BTreeSet::new(), None, &BTreeSet::new(),
|
|
657
|
+
&topology_updates, &BTreeSet::new(), None, None, None,
|
|
658
|
+
)?;
|
|
643
659
|
migrate_state_identity(&mut committed, &SystemEnv, workspace)?;
|
|
644
660
|
crate::state::ownership::strip_top_level_ownership_if_canonical_present(&mut committed);
|
|
645
661
|
if committed == latest {
|
|
@@ -1264,6 +1280,13 @@ fn merge_agent_projection(
|
|
|
1264
1280
|
.is_some_and(|source| !source.is_empty());
|
|
1265
1281
|
match incoming_map.entry(agent_id.clone()) {
|
|
1266
1282
|
serde_json::map::Entry::Vacant(slot) => {
|
|
1283
|
+
// Omission by an ordinary/stale writer cannot retire a stopped
|
|
1284
|
+
// seat or discard its resume metadata. Explicit RemoveAgent
|
|
1285
|
+
// has already been admitted through deleted_agent_ids above.
|
|
1286
|
+
if crate::state::projection::agent_explicit_stop(latest_agent) {
|
|
1287
|
+
slot.insert(latest_agent.clone());
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1267
1290
|
if fork_target {
|
|
1268
1291
|
slot.insert(latest_agent.clone());
|
|
1269
1292
|
continue;
|
|
@@ -1295,6 +1318,17 @@ fn merge_agent_projection(
|
|
|
1295
1318
|
}
|
|
1296
1319
|
}
|
|
1297
1320
|
serde_json::map::Entry::Occupied(mut existing) => {
|
|
1321
|
+
if !topology_update_agent_ids.contains(agent_id) {
|
|
1322
|
+
if crate::state::projection::agent_explicit_stop(latest_agent) {
|
|
1323
|
+
*existing.get_mut() = latest_agent.clone();
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
if let Some(marker) = latest_agent.get("explicit_stop") {
|
|
1327
|
+
if let Some(row) = existing.get_mut().as_object_mut() {
|
|
1328
|
+
row.insert("explicit_stop".into(), marker.clone());
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1298
1332
|
if !tombstoned_for_projection && !topology_update_agent_ids.contains(agent_id) {
|
|
1299
1333
|
let fields = topology_conflict_fields(existing.get(), latest_agent, team_alive);
|
|
1300
1334
|
if !fields.is_empty() {
|
|
@@ -2453,6 +2487,42 @@ mod tests {
|
|
|
2453
2487
|
);
|
|
2454
2488
|
}
|
|
2455
2489
|
|
|
2490
|
+
#[test]
|
|
2491
|
+
fn stopped_intent_and_resume_metadata_survive_stale_writers_until_targeted_start() {
|
|
2492
|
+
let ws = temp_ws();
|
|
2493
|
+
let held = json!({"agent_id":"held","provider":"codex","status":"stopped","explicit_stop":true,
|
|
2494
|
+
"session_id":"same-session","rollout_path":"/controlled/rollout","captured_at":"2026-10-12T00:00:00Z",
|
|
2495
|
+
"captured_via":"native","generation":7});
|
|
2496
|
+
let mut latest = json!({"session_name":"team-a","active_team_key":"team-a","agents":{"held":held},
|
|
2497
|
+
"teams":{"team-a":{"session_name":"team-a","agents":{"held":held}}}});
|
|
2498
|
+
normalize_agent_session_state(&mut latest);
|
|
2499
|
+
for omit in [false, true] {
|
|
2500
|
+
write_state(&ws, &latest);
|
|
2501
|
+
let before = read_state(&ws);
|
|
2502
|
+
let held_root = before.pointer("/agents/held").unwrap().clone();
|
|
2503
|
+
let held_team = before.pointer("/teams/team-a/agents/held").unwrap().clone();
|
|
2504
|
+
let rows = if omit { json!({}) } else { json!({"held":{"agent_id":"held","provider":"codex","status":"running","explicit_stop":false,"session_id":"stale"}}) };
|
|
2505
|
+
let stale = json!({"session_name":"team-a","active_team_key":"team-a","agents":rows,
|
|
2506
|
+
"teams":{"team-a":{"session_name":"team-a","agents":rows}},"delivery":{"message":"observed"}});
|
|
2507
|
+
save_runtime_state(&ws, &stale).unwrap();
|
|
2508
|
+
let saved = read_state(&ws);
|
|
2509
|
+
assert_eq!(saved.pointer("/agents/held"), Some(&held_root));
|
|
2510
|
+
assert_eq!(saved.pointer("/teams/team-a/agents/held"), Some(&held_team));
|
|
2511
|
+
assert_eq!(saved.pointer("/delivery/message"), Some(&json!("observed")));
|
|
2512
|
+
}
|
|
2513
|
+
let mut started = latest;
|
|
2514
|
+
for pointer in ["/agents/held", "/teams/team-a/agents/held"] {
|
|
2515
|
+
let row = started.pointer_mut(pointer).unwrap();
|
|
2516
|
+
row["status"] = json!("running");
|
|
2517
|
+
row["explicit_stop"] = json!(false);
|
|
2518
|
+
}
|
|
2519
|
+
save_runtime_state_with_lifecycle_topology_authority(&ws, &started, "team-a", &["held"]).unwrap();
|
|
2520
|
+
let saved = read_state(&ws);
|
|
2521
|
+
assert_eq!(saved.pointer("/agents/held/explicit_stop"), Some(&json!(false)));
|
|
2522
|
+
assert_eq!(saved.pointer("/agents/held/session_id"), Some(&json!("same-session")));
|
|
2523
|
+
assert_eq!(saved.pointer("/teams/team-a/agents/held/explicit_stop"), Some(&json!(false)));
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2456
2526
|
#[test]
|
|
2457
2527
|
fn lifecycle_topology_authority_only_applies_to_target_agent() {
|
|
2458
2528
|
let ws = temp_ws();
|
|
@@ -363,6 +363,20 @@ fn all_present_agents_terminal(team: &Value) -> bool {
|
|
|
363
363
|
})
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
+
/// Team restart never consumes an explicit stop or legacy stopped row.
|
|
367
|
+
/// A malformed persisted marker is held closed, not coerced to false.
|
|
368
|
+
pub(crate) fn agent_stopped_for_restart(agent: &Value) -> bool {
|
|
369
|
+
agent.get("status").and_then(Value::as_str)
|
|
370
|
+
.is_some_and(|status| status.eq_ignore_ascii_case("stopped"))
|
|
371
|
+
|| agent_explicit_stop(agent)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/// Only an explicit persisted stop freezes metadata against ordinary writers.
|
|
375
|
+
/// Historical stopped rows still follow existing roster-pruning semantics.
|
|
376
|
+
pub(crate) fn agent_explicit_stop(agent: &Value) -> bool {
|
|
377
|
+
!matches!(agent.get("explicit_stop"), None | Some(Value::Bool(false)))
|
|
378
|
+
}
|
|
379
|
+
|
|
366
380
|
pub(crate) fn agent_status_is_terminal(status: &str) -> bool {
|
|
367
381
|
matches!(
|
|
368
382
|
status.to_ascii_lowercase().as_str(),
|
|
@@ -1149,6 +1163,21 @@ mod tests {
|
|
|
1149
1163
|
ws
|
|
1150
1164
|
}
|
|
1151
1165
|
|
|
1166
|
+
#[test]
|
|
1167
|
+
fn restart_stop_marker_is_conservative_without_blocking_crash_states() {
|
|
1168
|
+
for row in [
|
|
1169
|
+
json!({"status":"stopped"}),
|
|
1170
|
+
json!({"status":"stopped","explicit_stop":false}),
|
|
1171
|
+
json!({"status":"running","explicit_stop":true}),
|
|
1172
|
+
json!({"status":"running","explicit_stop":"false"}),
|
|
1173
|
+
] {
|
|
1174
|
+
assert!(agent_stopped_for_restart(&row));
|
|
1175
|
+
}
|
|
1176
|
+
for status in ["running", "dead", "restart_failed"] {
|
|
1177
|
+
assert!(!agent_stopped_for_restart(&json!({"status":status,"explicit_stop":false})));
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1152
1181
|
#[test]
|
|
1153
1182
|
fn team_state_key_cases() {
|
|
1154
1183
|
assert_eq!(
|
|
@@ -112,3 +112,73 @@ fn coordinator_observations_keep_legacy_identity_and_shape()
|
|
|
112
112
|
std::fs::remove_dir_all(workspace)?;
|
|
113
113
|
Ok(())
|
|
114
114
|
}
|
|
115
|
+
|
|
116
|
+
#[test]
|
|
117
|
+
fn stopped_start_commit_updates_exact_target_and_both_mirrors()
|
|
118
|
+
-> Result<(), Box<dyn std::error::Error>> {
|
|
119
|
+
use serde_json::json;
|
|
120
|
+
for target in ["team-a", "team-b"] {
|
|
121
|
+
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_nanos();
|
|
122
|
+
let workspace = std::env::temp_dir().join(format!("state-start-stopped-{target}-{nonce}"));
|
|
123
|
+
let held = json!({"agent_id":"worker","provider":"grok_build","runtime_family":"contract",
|
|
124
|
+
"status":"stopped","explicit_stop":true,"generation":1,"contract_instance":"instance-one",
|
|
125
|
+
"session_id":"same-session","native_endpoint":"old-endpoint"});
|
|
126
|
+
let peer = json!({"agent_id":"peer","provider":"pi","status":"stopped","explicit_stop":true,
|
|
127
|
+
"session_id":"peer-session","generation":1});
|
|
128
|
+
let control = json!({"agent_id":"control","provider":"pi","status":"running","session_id":"control-session"});
|
|
129
|
+
let agents = json!({"worker":held,"peer":peer,"control":control});
|
|
130
|
+
let initial = json!({"session_name":"team-a","active_team_key":"team-a","agents":agents,
|
|
131
|
+
"teams":{"team-a":{"session_name":"team-a","status":"alive","agents":agents},
|
|
132
|
+
"team-b":{"session_name":"team-b","status":"alive","agents":agents}}});
|
|
133
|
+
let path = super::helper_workspace_path(&workspace);
|
|
134
|
+
std::fs::create_dir_all(path.parent().unwrap())?;
|
|
135
|
+
std::fs::write(&path, serde_json::to_vec(&initial)?)?;
|
|
136
|
+
let repository = super::StateRepository::new(&workspace);
|
|
137
|
+
let before = repository.load_workspace()?;
|
|
138
|
+
let other = if target == "team-a" { "team-b" } else { "team-a" };
|
|
139
|
+
let committed = repository.commit(StateWriteIntent::StartAgent { team_key: target, agent_id: "worker" }, |selected| {
|
|
140
|
+
let row = &mut selected["agents"]["worker"];
|
|
141
|
+
row["status"] = json!("running");
|
|
142
|
+
row["explicit_stop"] = json!(false);
|
|
143
|
+
row["generation"] = json!(2);
|
|
144
|
+
row["contract_instance"] = json!("instance-two");
|
|
145
|
+
row["native_endpoint"] = json!("new-endpoint");
|
|
146
|
+
// Start authority must not leak to an unrelated stopped peer.
|
|
147
|
+
selected["agents"]["peer"]["status"] = json!("running");
|
|
148
|
+
selected["agents"]["peer"]["explicit_stop"] = json!(false);
|
|
149
|
+
})?;
|
|
150
|
+
let saved = repository.load_workspace()?;
|
|
151
|
+
let team = &saved["teams"][target];
|
|
152
|
+
assert_eq!(team["agents"]["worker"]["status"], "running");
|
|
153
|
+
assert_eq!(team["agents"]["worker"]["generation"], 2);
|
|
154
|
+
assert_eq!(team["agents"]["worker"]["explicit_stop"], false);
|
|
155
|
+
assert_eq!(team["agents"]["worker"]["session_id"], "same-session");
|
|
156
|
+
assert_eq!(committed["agents"]["worker"], team["agents"]["worker"]);
|
|
157
|
+
assert!(!crate::state::projection::agent_stopped_for_restart(&team["agents"]["worker"]));
|
|
158
|
+
assert_eq!(team["agents"]["peer"], before["teams"][target]["agents"]["peer"]);
|
|
159
|
+
assert_eq!(team["agents"]["control"], before["teams"][target]["agents"]["control"]);
|
|
160
|
+
assert_eq!(saved["teams"][other], before["teams"][other]);
|
|
161
|
+
// Root normalization retains null session fields that compact team
|
|
162
|
+
// rows omit. Build an independent root oracle, not the compact schema.
|
|
163
|
+
let mut expected_root = before["agents"].clone();
|
|
164
|
+
if target == "team-a" {
|
|
165
|
+
let row = &mut expected_root["worker"];
|
|
166
|
+
row["status"] = json!("running");
|
|
167
|
+
row["explicit_stop"] = json!(false);
|
|
168
|
+
row["generation"] = json!(2);
|
|
169
|
+
row["contract_instance"] = json!("instance-two");
|
|
170
|
+
row["native_endpoint"] = json!("new-endpoint");
|
|
171
|
+
}
|
|
172
|
+
assert_eq!(saved["agents"], expected_root);
|
|
173
|
+
// An ordinary bounded delta still cannot wake a held peer.
|
|
174
|
+
repository.commit(StateWriteIntent::ResultCollection { owner_team_id: Some(target) }, |selected| {
|
|
175
|
+
selected["agents"]["peer"]["status"] = json!("running");
|
|
176
|
+
selected["agents"]["peer"]["explicit_stop"] = json!(false);
|
|
177
|
+
})?;
|
|
178
|
+
let ordinary = repository.load_workspace()?;
|
|
179
|
+
assert_eq!(ordinary["teams"][target]["agents"], team["agents"]);
|
|
180
|
+
assert_eq!(ordinary["agents"], saved["agents"]);
|
|
181
|
+
std::fs::remove_dir_all(workspace)?;
|
|
182
|
+
}
|
|
183
|
+
Ok(())
|
|
184
|
+
}
|
|
@@ -160,6 +160,10 @@ impl<'a> StateRepository<'a> {
|
|
|
160
160
|
{
|
|
161
161
|
#[cfg(test)]
|
|
162
162
|
test_support::before_commit();
|
|
163
|
+
let lifecycle_target = match &intent {
|
|
164
|
+
StateWriteIntent::StartAgent { team_key, agent_id } => Some((*team_key, *agent_id)),
|
|
165
|
+
_ => None,
|
|
166
|
+
};
|
|
163
167
|
let team_key = match intent {
|
|
164
168
|
StateWriteIntent::ResultCollection { owner_team_id } => owner_team_id,
|
|
165
169
|
StateWriteIntent::ForkAgent { team_key, .. }
|
|
@@ -170,7 +174,7 @@ impl<'a> StateRepository<'a> {
|
|
|
170
174
|
))
|
|
171
175
|
}
|
|
172
176
|
};
|
|
173
|
-
let committed = super::persist::
|
|
177
|
+
let committed = super::persist::update_runtime_state_with_lifecycle_target(self.workspace, lifecycle_target, |latest| {
|
|
174
178
|
let mut selected = bounded_team_view(latest, team_key)?;
|
|
175
179
|
update(&mut selected);
|
|
176
180
|
Ok(match team_key {
|