@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
|
@@ -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 {
|
|
@@ -181,6 +185,53 @@ impl<'a> StateRepository<'a> {
|
|
|
181
185
|
bounded_team_view(&committed, team_key)
|
|
182
186
|
}
|
|
183
187
|
|
|
188
|
+
/// Commit a messaging delta against its exact canonical owner under state-save.
|
|
189
|
+
/// Unlike ordinary compatibility writes, owner loss cannot select raw/default
|
|
190
|
+
/// state, and a failed receiver check cannot be reapplied to another team.
|
|
191
|
+
pub(crate) fn commit_messaging_checked<F>(
|
|
192
|
+
&self,
|
|
193
|
+
intent: StateWriteIntent<'_>,
|
|
194
|
+
update: F,
|
|
195
|
+
) -> Result<(), StateError>
|
|
196
|
+
where
|
|
197
|
+
F: FnOnce(&mut Value) -> Result<(), StateError>,
|
|
198
|
+
{
|
|
199
|
+
let StateWriteIntent::MessagingDeliveryState { owner_team_id: Some(team) } = intent else {
|
|
200
|
+
return Err(StateError::SaveFailed("checked messaging requires an owner".into()));
|
|
201
|
+
};
|
|
202
|
+
#[cfg(test)]
|
|
203
|
+
test_support::before_commit();
|
|
204
|
+
super::persist::update_runtime_state(self.workspace, |latest| {
|
|
205
|
+
if team.is_empty() || !matches!(
|
|
206
|
+
super::projection::resolve_owner_team_id(latest, team),
|
|
207
|
+
super::projection::OwnerTeamResolution::Canonical(key) if key == team
|
|
208
|
+
) {
|
|
209
|
+
return Err(StateError::SaveConflict("messaging owner changed before commit".into()));
|
|
210
|
+
}
|
|
211
|
+
let nested = latest.get("teams").and_then(Value::as_object)
|
|
212
|
+
.is_some_and(|teams| teams.contains_key(team));
|
|
213
|
+
let mut selected = if nested {
|
|
214
|
+
super::projection::project_top_level_view(latest, team)
|
|
215
|
+
} else if legacy_single_team_state(latest)
|
|
216
|
+
&& (latest.get("active_team_key").and_then(Value::as_str) == Some(team)
|
|
217
|
+
|| super::projection::team_state_key(latest) == team
|
|
218
|
+
|| latest.get("session_name").and_then(Value::as_str)
|
|
219
|
+
.and_then(|session| session.strip_prefix("team-")) == Some(team))
|
|
220
|
+
{
|
|
221
|
+
latest.clone()
|
|
222
|
+
} else {
|
|
223
|
+
return Err(StateError::SaveConflict("messaging owner no longer exists".into()));
|
|
224
|
+
};
|
|
225
|
+
update(&mut selected)?;
|
|
226
|
+
Ok(if nested {
|
|
227
|
+
super::projection::merge_committed_team(latest, &selected, team)
|
|
228
|
+
} else {
|
|
229
|
+
selected
|
|
230
|
+
})
|
|
231
|
+
})?;
|
|
232
|
+
Ok(())
|
|
233
|
+
}
|
|
234
|
+
|
|
184
235
|
/// Atomically replace only the fork target row in the selected team.
|
|
185
236
|
pub(crate) fn commit_fork_agent(
|
|
186
237
|
&self,
|