@team-agent/installer 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +82 -22
- package/crates/team-agent/src/cli/diagnose.rs +6 -2
- package/crates/team-agent/src/cli/emit.rs +301 -38
- package/crates/team-agent/src/cli/mod.rs +7 -14
- package/crates/team-agent/src/cli/status_port.rs +12 -1
- package/crates/team-agent/src/cli/tests/base.rs +6 -2
- package/crates/team-agent/src/cli/tests/lane_c.rs +1 -1
- package/crates/team-agent/src/cli/tests/leader_watch.rs +5 -3
- package/crates/team-agent/src/cli/tests/main_preserved.rs +28 -2
- package/crates/team-agent/src/cli/tests/peer_allow.rs +19 -0
- package/crates/team-agent/src/cli/tests/repair_state_byte_lock.rs +72 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -3
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +35 -2
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +7 -2
- package/crates/team-agent/src/diagnose/comms.rs +10 -2
- package/crates/team-agent/src/leader/lease.rs +98 -19
- package/crates/team-agent/src/leader/rediscover/tests.rs +21 -7
- package/crates/team-agent/src/leader/rediscover.rs +16 -7
- package/crates/team-agent/src/leader/start.rs +25 -12
- package/crates/team-agent/src/leader/tests/byte_findings.rs +11 -2
- package/crates/team-agent/src/leader/tests/lease_claim.rs +248 -7
- package/crates/team-agent/src/lifecycle/launch.rs +126 -100
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +40 -12
- package/crates/team-agent/src/lifecycle/tests/core.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +16 -15
- package/crates/team-agent/src/lifecycle/types.rs +1 -1
- package/crates/team-agent/src/messaging/leader_receiver.rs +22 -8
- package/crates/team-agent/src/messaging/send.rs +21 -4
- package/crates/team-agent/src/provider/adapter.rs +72 -0
- package/crates/team-agent/src/provider/session/capture.rs +228 -0
- package/crates/team-agent/src/state/identity.rs +42 -12
- package/crates/team-agent/src/state/identity_keys.rs +134 -0
- package/crates/team-agent/src/state/mod.rs +8 -0
- package/crates/team-agent/src/state/owner_gate.rs +11 -1
- package/crates/team-agent/src/state/ownership.rs +556 -0
- package/crates/team-agent/src/state/paths.rs +358 -0
- package/crates/team-agent/src/state/persist.rs +43 -5
- package/crates/team-agent/src/state/projection.rs +13 -5
- package/crates/team-agent/src/tmux_backend.rs +12 -0
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +3 -3
|
@@ -127,10 +127,14 @@ fn try_readopt_writes_owner_receiver_dual_state_and_events() {
|
|
|
127
127
|
assert_eq!(got.1["pane"]["pane_id"], json!("%new"));
|
|
128
128
|
assert_eq!(got.1["readopted"], json!(true));
|
|
129
129
|
assert!(got.1.get("warning").is_some_and(Value::is_null));
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
130
|
+
// Stage 3d: owner + receiver live at canonical teams.<team_key>.
|
|
131
|
+
let team_key = crate::state::projection::team_state_key(&state);
|
|
132
|
+
let owner_view = &state["teams"][&team_key]["team_owner"];
|
|
133
|
+
let receiver_view = &state["teams"][&team_key]["leader_receiver"];
|
|
134
|
+
assert_eq!(owner_view["pane_id"], json!("%new"));
|
|
135
|
+
assert_eq!(owner_view["claimed_via"], json!("attach-leader"));
|
|
136
|
+
assert_eq!(owner_view["owner_epoch"], json!(4));
|
|
137
|
+
assert_eq!(receiver_view["discovery"], json!("attach_readopt"));
|
|
134
138
|
assert!(crate::model::paths::runtime_dir(&ws)
|
|
135
139
|
.join("teams")
|
|
136
140
|
.join("sess")
|
|
@@ -185,6 +189,10 @@ fn try_readopt_refuses_when_different_live_owner_uuid_mismatches() {
|
|
|
185
189
|
.unwrap();
|
|
186
190
|
|
|
187
191
|
assert!(got.is_none());
|
|
192
|
+
// Stage 3d: no owner anywhere — check both legacy top-level and the
|
|
193
|
+
// canonical teams.<key> location via the ownership repository.
|
|
194
|
+
let team_key = crate::state::projection::team_state_key(&state);
|
|
195
|
+
assert!(crate::state::ownership::read_owner_value(&state, &team_key).is_none());
|
|
188
196
|
assert!(state.get("team_owner").is_none());
|
|
189
197
|
}
|
|
190
198
|
|
|
@@ -227,7 +235,10 @@ fn rediscover_updates_unique_env_triple_match() {
|
|
|
227
235
|
assert_eq!(out["status"], json!("updated"));
|
|
228
236
|
assert!(out.get("ok").is_none(), "golden rediscover result has no ok wrapper");
|
|
229
237
|
assert_eq!(out["owner_identity"]["pane_id"], json!("%r"));
|
|
230
|
-
|
|
238
|
+
// Stage 3d: receiver lives at canonical teams.<team_key>.leader_receiver.
|
|
239
|
+
let team_key = crate::state::projection::team_state_key(&state);
|
|
240
|
+
let receiver = &state["teams"][&team_key]["leader_receiver"];
|
|
241
|
+
assert_eq!(receiver["pane_id"], json!("%r"));
|
|
231
242
|
let events = event_log.tail(10).unwrap();
|
|
232
243
|
let rediscovered = event_named(&events, "leader_receiver.rediscovered");
|
|
233
244
|
assert_eq!(rediscovered["provider"], json!("codex"));
|
|
@@ -241,7 +252,7 @@ fn rediscover_updates_unique_env_triple_match() {
|
|
|
241
252
|
assert_eq!(rebound["new_pane_id"], json!("%r"));
|
|
242
253
|
assert_eq!(rebound["owner_identity"]["machine_fingerprint"], json!("fp"));
|
|
243
254
|
assert_eq!(rebound["uuid_prefix"], json!("OWNERUUI"));
|
|
244
|
-
assert_eq!(
|
|
255
|
+
assert_eq!(receiver["discovery"], json!("stale_rediscovery_unique_candidate"));
|
|
245
256
|
}
|
|
246
257
|
|
|
247
258
|
#[test]
|
|
@@ -286,7 +297,10 @@ fn rediscover_owner_identity_passthrough_sets_discovery_and_reason() {
|
|
|
286
297
|
|
|
287
298
|
assert_eq!(out["status"], json!("updated"));
|
|
288
299
|
assert_eq!(out["owner_identity"], raw_identity);
|
|
289
|
-
|
|
300
|
+
// Stage 3d: receiver lives at canonical teams.<team_key>.leader_receiver.
|
|
301
|
+
let team_key = crate::state::projection::team_state_key(&state);
|
|
302
|
+
let receiver = &state["teams"][&team_key]["leader_receiver"];
|
|
303
|
+
assert_eq!(receiver["discovery"], json!("stale_rediscovery_owner_identity"));
|
|
290
304
|
let rebound = event_named(&event_log.tail(10).unwrap(), "leader_receiver.rebind_applied");
|
|
291
305
|
assert_eq!(rebound["reason"], json!("stale_receiver"));
|
|
292
306
|
assert_eq!(rebound["owner_identity"], raw_identity);
|
|
@@ -1064,6 +1064,9 @@ fn state_receiver(state: &Value) -> Option<LeaderReceiver> {
|
|
|
1064
1064
|
.and_then(|value| serde_json::from_value(value).ok())
|
|
1065
1065
|
}
|
|
1066
1066
|
|
|
1067
|
+
/// Stage 3a (identity-boundary unified plan, architect direction 2026-06-23):
|
|
1068
|
+
/// route the readopt binding write through the ownership repository.
|
|
1069
|
+
/// Behaviour byte-identical to the pre-3a hand-rolled inserts.
|
|
1067
1070
|
fn write_readopt_state(
|
|
1068
1071
|
workspace: &Path,
|
|
1069
1072
|
state: &mut Value,
|
|
@@ -1073,11 +1076,14 @@ fn write_readopt_state(
|
|
|
1073
1076
|
if !state.is_object() {
|
|
1074
1077
|
*state = json!({});
|
|
1075
1078
|
}
|
|
1076
|
-
|
|
1079
|
+
if !state.is_object() {
|
|
1077
1080
|
return Err(LeaderError::Validation("state root is not an object".to_string()));
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
+
}
|
|
1082
|
+
let team_key = crate::state::projection::team_state_key(state);
|
|
1083
|
+
let record = crate::state::ownership::OwnershipWrite::new()
|
|
1084
|
+
.with_leader_receiver(serde_json::to_value(receiver)?)
|
|
1085
|
+
.with_team_owner(serde_json::to_value(owner)?);
|
|
1086
|
+
crate::state::ownership::write_owner(state, &team_key, record);
|
|
1081
1087
|
crate::leader::write_lease_dual_state(workspace, state)
|
|
1082
1088
|
}
|
|
1083
1089
|
|
|
@@ -1089,10 +1095,13 @@ fn write_receiver_state(
|
|
|
1089
1095
|
if !state.is_object() {
|
|
1090
1096
|
*state = json!({});
|
|
1091
1097
|
}
|
|
1092
|
-
|
|
1098
|
+
if !state.is_object() {
|
|
1093
1099
|
return Err(LeaderError::Validation("state root is not an object".to_string()));
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1100
|
+
}
|
|
1101
|
+
let team_key = crate::state::projection::team_state_key(state);
|
|
1102
|
+
let record = crate::state::ownership::OwnershipWrite::new()
|
|
1103
|
+
.with_leader_receiver(serde_json::to_value(receiver)?);
|
|
1104
|
+
crate::state::ownership::write_owner(state, &team_key, record);
|
|
1096
1105
|
crate::leader::write_lease_dual_state(workspace, state)
|
|
1097
1106
|
}
|
|
1098
1107
|
|
|
@@ -587,10 +587,13 @@ fn persist_managed_leader_binding(
|
|
|
587
587
|
"tmux": std::env::var("TMUX").ok(),
|
|
588
588
|
}),
|
|
589
589
|
);
|
|
590
|
-
obj.insert("leader_receiver".to_string(), receiver);
|
|
591
|
-
obj.insert("team_owner".to_string(), owner);
|
|
592
|
-
obj.insert("owner_epoch".to_string(), serde_json::json!(owner_epoch));
|
|
593
590
|
}
|
|
591
|
+
// Stage 3a/d (identity-boundary unified plan, architect direction
|
|
592
|
+
// 2026-06-23): build the teams.<key> entry from the non-owner fields
|
|
593
|
+
// of state (compact_team_state strips the `teams` key), then publish
|
|
594
|
+
// the canonical owner record AFTER teams.insert so write_owner's
|
|
595
|
+
// teams.<key>.{team_owner,leader_receiver,owner_epoch} is the final
|
|
596
|
+
// word and doesn't get overwritten by the compacted entry.
|
|
594
597
|
let entry = crate::state::projection::compact_team_state(&state);
|
|
595
598
|
if let Some(obj) = state.as_object_mut() {
|
|
596
599
|
let teams = obj
|
|
@@ -600,6 +603,11 @@ fn persist_managed_leader_binding(
|
|
|
600
603
|
teams.insert(identity.team_id.as_str().to_string(), entry);
|
|
601
604
|
}
|
|
602
605
|
}
|
|
606
|
+
let record = crate::state::ownership::OwnershipWrite::new()
|
|
607
|
+
.with_leader_receiver(receiver)
|
|
608
|
+
.with_team_owner(owner)
|
|
609
|
+
.with_owner_epoch(owner_epoch);
|
|
610
|
+
crate::state::ownership::write_owner(&mut state, identity.team_id.as_str(), record);
|
|
603
611
|
crate::state::persist::save_runtime_state(workspace, &state)?;
|
|
604
612
|
Ok(())
|
|
605
613
|
}
|
|
@@ -689,10 +697,11 @@ fn persist_exec_provider_leader_binding(
|
|
|
689
697
|
"tmux": std::env::var("TMUX").ok(),
|
|
690
698
|
}),
|
|
691
699
|
);
|
|
692
|
-
obj.insert("leader_receiver".to_string(), receiver);
|
|
693
|
-
obj.insert("team_owner".to_string(), owner);
|
|
694
|
-
obj.insert("owner_epoch".to_string(), serde_json::json!(owner_epoch));
|
|
695
700
|
}
|
|
701
|
+
// Stage 3a/d (identity-boundary unified plan, architect direction
|
|
702
|
+
// 2026-06-23): compact-then-write-owner ordering as in
|
|
703
|
+
// persist_managed_leader_binding above. write_owner must be the final
|
|
704
|
+
// write so the canonical teams.<key> owner record survives.
|
|
696
705
|
let entry = crate::state::projection::compact_team_state(&state);
|
|
697
706
|
if let Some(obj) = state.as_object_mut() {
|
|
698
707
|
let teams = obj
|
|
@@ -702,6 +711,11 @@ fn persist_exec_provider_leader_binding(
|
|
|
702
711
|
teams.insert(identity.team_id.as_str().to_string(), entry);
|
|
703
712
|
}
|
|
704
713
|
}
|
|
714
|
+
let record = crate::state::ownership::OwnershipWrite::new()
|
|
715
|
+
.with_leader_receiver(receiver)
|
|
716
|
+
.with_team_owner(owner)
|
|
717
|
+
.with_owner_epoch(owner_epoch);
|
|
718
|
+
crate::state::ownership::write_owner(&mut state, identity.team_id.as_str(), record);
|
|
705
719
|
crate::state::persist::save_runtime_state(workspace, &state)?;
|
|
706
720
|
Ok(())
|
|
707
721
|
}
|
|
@@ -1351,16 +1365,15 @@ mod tests {
|
|
|
1351
1365
|
assert_eq!(outcome.status, crate::leader::LeaderLaunchStatus::Exited);
|
|
1352
1366
|
let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
1353
1367
|
assert_eq!(state["is_external_leader"], serde_json::json!(false));
|
|
1354
|
-
|
|
1355
|
-
assert_eq!(
|
|
1356
|
-
state["leader_receiver"]["tmux_socket"],
|
|
1357
|
-
serde_json::json!("/private/tmp/tmux-501/default")
|
|
1358
|
-
);
|
|
1359
|
-
assert_eq!(state["team_owner"]["pane_id"], serde_json::json!("%77"));
|
|
1368
|
+
// Stage 3d: canonical owner/receiver at teams.<team_key>.
|
|
1360
1369
|
assert_eq!(
|
|
1361
1370
|
state["teams"]["current"]["leader_receiver"]["pane_id"],
|
|
1362
1371
|
serde_json::json!("%77")
|
|
1363
1372
|
);
|
|
1373
|
+
assert_eq!(
|
|
1374
|
+
state["teams"]["current"]["leader_receiver"]["tmux_socket"],
|
|
1375
|
+
serde_json::json!("/private/tmp/tmux-501/default")
|
|
1376
|
+
);
|
|
1364
1377
|
assert_eq!(
|
|
1365
1378
|
state["teams"]["current"]["team_owner"]["pane_id"],
|
|
1366
1379
|
serde_json::json!("%77")
|
|
@@ -10,7 +10,12 @@ use super::*;
|
|
|
10
10
|
let r = claim_lease_no_incident(&ws, &mut state, None, &TeamKey::new("current"),
|
|
11
11
|
&PaneId::new("%5"), false, &crate::event_log::EventLog::new(&ws), &seeded_liveness(&["%5"])).unwrap();
|
|
12
12
|
assert!(r.ok && r.status == LeaseStatus::Claimed, "precondition: vacant acquire");
|
|
13
|
-
|
|
13
|
+
// Stage 3d (identity-boundary unified plan, architect direction
|
|
14
|
+
// 2026-06-23): canonical owner now at teams.<team_key>.team_owner.
|
|
15
|
+
let team_key = crate::state::projection::team_state_key(&state);
|
|
16
|
+
let owner = state["teams"][&team_key]["team_owner"]
|
|
17
|
+
.as_object()
|
|
18
|
+
.expect("Stage 3d: team_owner at canonical teams.<team_key>");
|
|
14
19
|
assert!(owner.contains_key("os_user"),
|
|
15
20
|
"golden claim-path team_owner carries os_user even when empty; keys={:?}", owner.keys().collect::<Vec<_>>());
|
|
16
21
|
let keys: Vec<&str> = owner.keys().map(String::as_str).collect();
|
|
@@ -37,7 +42,11 @@ use super::*;
|
|
|
37
42
|
let r = claim_lease_no_incident(&ws, &mut state, None, &TeamKey::new("current"),
|
|
38
43
|
&PaneId::new("%5"), false, &crate::event_log::EventLog::new(&ws), &seeded_liveness(&["%5"])).unwrap();
|
|
39
44
|
assert!(r.ok && r.status == LeaseStatus::Claimed, "precondition: vacant acquire");
|
|
40
|
-
|
|
45
|
+
// Stage 3d: canonical receiver at teams.<team_key>.leader_receiver.
|
|
46
|
+
let team_key = crate::state::projection::team_state_key(&state);
|
|
47
|
+
let recv = state["teams"][&team_key]["leader_receiver"]
|
|
48
|
+
.as_object()
|
|
49
|
+
.expect("Stage 3d: leader_receiver at canonical teams.<team_key>");
|
|
41
50
|
for extra in ["fingerprint", "requested_provider", "warning"] {
|
|
42
51
|
assert!(!recv.contains_key(extra),
|
|
43
52
|
"golden leader_receiver has NO '{extra}' key (Rust must add skip_serializing_if); keys={:?}", recv.keys().collect::<Vec<_>>());
|
|
@@ -35,18 +35,45 @@ use super::*;
|
|
|
35
35
|
let receiver = r.receiver.as_ref().expect("claimed → receiver");
|
|
36
36
|
assert_eq!(receiver.pane_id, caller);
|
|
37
37
|
assert_eq!(receiver.discovery, Some(Discovery::ClaimLeader));
|
|
38
|
-
//
|
|
38
|
+
// Stage 3d (identity-boundary unified plan, architect direction
|
|
39
|
+
// 2026-06-23): canonical owner now lives at teams.<team_key>; the
|
|
40
|
+
// top-level dual-write was dropped. Read through the ownership
|
|
41
|
+
// repository which preserves the legacy precedence for callers
|
|
42
|
+
// that still consult top-level on migration states.
|
|
39
43
|
let ws_path = crate::state::persist::runtime_state_path(&ws);
|
|
40
44
|
let ws_state: serde_json::Value =
|
|
41
45
|
serde_json::from_str(&std::fs::read_to_string(&ws_path).unwrap()).unwrap();
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
// The canonical team_key is what `team_state_key(state)` derives
|
|
47
|
+
// (here: state's session_name "team-agent-x" since no explicit
|
|
48
|
+
// team_key/team_dir/spec_path was seeded). The test's `team_id`
|
|
49
|
+
// parameter is the LOOKUP key but `claim_lease_no_incident` uses
|
|
50
|
+
// the state-derived key for canonical writes. Stage 5 will unify
|
|
51
|
+
// these via TeamRuntimePaths.
|
|
52
|
+
let canonical_key = crate::state::projection::team_state_key(&ws_state);
|
|
53
|
+
let ws_owner =
|
|
54
|
+
crate::state::ownership::read_owner_value(&ws_state, &canonical_key)
|
|
55
|
+
.expect("Stage 3d: vacant acquire writes canonical owner");
|
|
56
|
+
assert_eq!(ws_owner["pane_id"], serde_json::json!("%5"));
|
|
57
|
+
assert_eq!(ws_owner["owner_epoch"], serde_json::json!(1));
|
|
58
|
+
// team_id parameter is currently unused by the canonical write
|
|
59
|
+
// (architect Stage 5 will close this gap); silence the warning.
|
|
60
|
+
let _ = &team_id;
|
|
44
61
|
let snap_path = crate::model::paths::runtime_dir(&ws)
|
|
45
62
|
.join("teams").join("team-agent-x").join("state.json");
|
|
46
63
|
let snap: serde_json::Value =
|
|
47
64
|
serde_json::from_str(&std::fs::read_to_string(&snap_path).unwrap()).unwrap();
|
|
48
|
-
|
|
49
|
-
|
|
65
|
+
// Snapshot is per-session; team_state_key on the snapshot derives
|
|
66
|
+
// from its session_name. Read top-level first (legacy snapshot
|
|
67
|
+
// layout), fall back to derived team key.
|
|
68
|
+
let snap_owner = snap
|
|
69
|
+
.get("team_owner")
|
|
70
|
+
.or_else(|| {
|
|
71
|
+
let key = crate::state::projection::team_state_key(&snap);
|
|
72
|
+
snap.get("teams").and_then(|t| t.get(key)).and_then(|e| e.get("team_owner"))
|
|
73
|
+
})
|
|
74
|
+
.expect("dual-state snapshot owner");
|
|
75
|
+
assert_eq!(snap_owner["pane_id"], serde_json::json!("%5"), "dual-state snapshot owner");
|
|
76
|
+
assert_eq!(snap_owner["owner_epoch"], serde_json::json!(1));
|
|
50
77
|
}
|
|
51
78
|
|
|
52
79
|
// RED — DEAD-OWNER RECOVER: bound pane %1 absent from live set, caller %5
|
|
@@ -173,8 +200,11 @@ use super::*;
|
|
|
173
200
|
&std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
|
|
174
201
|
)
|
|
175
202
|
.unwrap();
|
|
203
|
+
// Stage 3d: canonical receiver at teams.<team_key>.leader_receiver.
|
|
204
|
+
let team_key = crate::state::projection::team_state_key(&persisted);
|
|
205
|
+
let receiver = &persisted["teams"][&team_key]["leader_receiver"];
|
|
176
206
|
assert_eq!(
|
|
177
|
-
|
|
207
|
+
receiver["tmux_socket"],
|
|
178
208
|
serde_json::json!(leader_socket),
|
|
179
209
|
"state.json must carry enough endpoint information for later delivery to use tmux -S"
|
|
180
210
|
);
|
|
@@ -290,8 +320,11 @@ use super::*;
|
|
|
290
320
|
&std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
|
|
291
321
|
)
|
|
292
322
|
.unwrap();
|
|
323
|
+
// Stage 3d: canonical receiver at teams.<team_key>.leader_receiver.
|
|
324
|
+
let team_key = crate::state::projection::team_state_key(&persisted);
|
|
325
|
+
let receiver = &persisted["teams"][&team_key]["leader_receiver"];
|
|
293
326
|
assert_eq!(
|
|
294
|
-
|
|
327
|
+
receiver["tmux_socket"],
|
|
295
328
|
serde_json::json!(current_socket),
|
|
296
329
|
"state must not preserve a short endpoint after explicit claim; state={persisted}"
|
|
297
330
|
);
|
|
@@ -408,3 +441,211 @@ use super::*;
|
|
|
408
441
|
// _lease_epoch:400, _emit_lease_refusal:469, audit :712-729). These REDs lock the EXACT
|
|
409
442
|
// golden shape per divergence; driven via claim_lease_no_incident(+seeded_liveness), OS-safe.
|
|
410
443
|
// ═════════════════════════════════════════════════════════════════════════
|
|
444
|
+
|
|
445
|
+
/// **S3-OWNER-001 regression** (architect Stage 3 owner persist fix,
|
|
446
|
+
/// .team/artifacts/stage3-owner-persist-fix.md). Pre-fix on commit
|
|
447
|
+
/// `5d980c7`:
|
|
448
|
+
/// 1. `claim-leader --team alpha` returns `ok=true/status=claimed`.
|
|
449
|
+
/// 2. In-memory state has `state.teams.alpha.team_owner` set by
|
|
450
|
+
/// `write_binding_to_state` → `state::ownership::write_owner`.
|
|
451
|
+
/// 3. `save_claim_team_scoped_state` calls `compact_team_state(state)` to
|
|
452
|
+
/// build the per-team snapshot. `compact_team_state` strips the
|
|
453
|
+
/// `teams` key entirely — the just-written nested ownership record
|
|
454
|
+
/// is DROPPED on the way to disk.
|
|
455
|
+
/// 4. After Stage 3d removed the top-level dual-write, no copy survives.
|
|
456
|
+
/// 5. Reloading the workspace state.json shows `persisted_owner_locations=[]`.
|
|
457
|
+
///
|
|
458
|
+
/// Fix: `save_claim_team_scoped_state` now uses
|
|
459
|
+
/// `compact_team_state_preserving_ownership(state, target_key)` which
|
|
460
|
+
/// re-injects `team_owner / leader_receiver / owner_epoch` from
|
|
461
|
+
/// `state.teams[target_key]` into the compacted entry. Top-level fields
|
|
462
|
+
/// remain absent (Stage 3d canonical shape preserved).
|
|
463
|
+
#[test]
|
|
464
|
+
#[serial_test::serial(env)]
|
|
465
|
+
fn claim_team_scoped_persists_canonical_owner_under_teams_target_key() {
|
|
466
|
+
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
467
|
+
let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
|
|
468
|
+
let ws = p2_temp_ws("claim_team_persist");
|
|
469
|
+
let target = "alpha";
|
|
470
|
+
let team_id = TeamKey::new(target);
|
|
471
|
+
let caller = PaneId::new("%5");
|
|
472
|
+
// Seed runtime state with a teams entry but no owner fields anywhere
|
|
473
|
+
// (the S3-OWNER-001 starting condition). team_dir is set so
|
|
474
|
+
// team_state_key resolves to the target team_key, matching the
|
|
475
|
+
// claim path's projected-state shape.
|
|
476
|
+
let mut state = serde_json::json!({
|
|
477
|
+
"session_name": "team-agent-x",
|
|
478
|
+
"team_dir": format!("/tmp/{}", target),
|
|
479
|
+
"teams": {target: {}},
|
|
480
|
+
});
|
|
481
|
+
let event_log = crate::event_log::EventLog::new(&ws);
|
|
482
|
+
let live = seeded_liveness(&["%5"]);
|
|
483
|
+
let r = claim_lease_no_incident(
|
|
484
|
+
&ws, &mut state, Some(target), &team_id, &caller, false, &event_log, &live,
|
|
485
|
+
)
|
|
486
|
+
.unwrap();
|
|
487
|
+
assert!(r.ok, "S3-OWNER-001: claim must succeed; got {r:?}");
|
|
488
|
+
assert_eq!(r.status, LeaseStatus::Claimed);
|
|
489
|
+
|
|
490
|
+
// Reload state from disk — this is where the pre-fix loss surfaced.
|
|
491
|
+
let persisted_path = crate::state::persist::runtime_state_path(&ws);
|
|
492
|
+
let persisted: serde_json::Value = serde_json::from_str(
|
|
493
|
+
&std::fs::read_to_string(&persisted_path).unwrap(),
|
|
494
|
+
)
|
|
495
|
+
.unwrap();
|
|
496
|
+
|
|
497
|
+
// Canonical: teams.<target_key>.{team_owner, leader_receiver, owner_epoch}.
|
|
498
|
+
let entry = &persisted["teams"][target];
|
|
499
|
+
assert_eq!(
|
|
500
|
+
entry["team_owner"]["pane_id"],
|
|
501
|
+
serde_json::json!("%5"),
|
|
502
|
+
"S3-OWNER-001 fix: teams.{target}.team_owner.pane_id persisted; \
|
|
503
|
+
persisted={persisted}"
|
|
504
|
+
);
|
|
505
|
+
assert_eq!(
|
|
506
|
+
entry["leader_receiver"]["pane_id"],
|
|
507
|
+
serde_json::json!("%5"),
|
|
508
|
+
"S3-OWNER-001 fix: teams.{target}.leader_receiver.pane_id persisted; \
|
|
509
|
+
persisted={persisted}"
|
|
510
|
+
);
|
|
511
|
+
assert_eq!(
|
|
512
|
+
entry["owner_epoch"], serde_json::json!(1),
|
|
513
|
+
"S3-OWNER-001 fix: teams.{target}.owner_epoch=1 persisted; \
|
|
514
|
+
persisted={persisted}"
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
// Stage 3d invariant: top-level owner fields stay ABSENT (no
|
|
518
|
+
// recurrence of the legacy dual-write).
|
|
519
|
+
assert!(
|
|
520
|
+
persisted.get("team_owner").is_none(),
|
|
521
|
+
"Stage 3d: top-level team_owner must remain absent; persisted={persisted}"
|
|
522
|
+
);
|
|
523
|
+
assert!(
|
|
524
|
+
persisted.get("leader_receiver").is_none(),
|
|
525
|
+
"Stage 3d: top-level leader_receiver must remain absent; persisted={persisted}"
|
|
526
|
+
);
|
|
527
|
+
assert!(
|
|
528
|
+
persisted.get("owner_epoch").is_none(),
|
|
529
|
+
"Stage 3d: top-level owner_epoch must remain absent; persisted={persisted}"
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/// **S3-OWNER-002 regression** (architect Stage 3 top-level cleanup,
|
|
534
|
+
/// .team/artifacts/stage3-toplevel-cleanup-fix.md). Even with the
|
|
535
|
+
/// nested-ownership-preserving compact (aae818f), a SECOND claim on a
|
|
536
|
+
/// state whose root still carries legacy top-level `team_owner` /
|
|
537
|
+
/// `leader_receiver` / `owner_epoch` would leave the stale root copy
|
|
538
|
+
/// on disk while the canonical `teams.<key>` advanced. Evidence:
|
|
539
|
+
/// `state-after-claim.json` showed `team_owner=%2/epoch=1` at root and
|
|
540
|
+
/// `teams.<key>.team_owner=%3/epoch=2` — exact dual-source recurrence.
|
|
541
|
+
///
|
|
542
|
+
/// Fix: `save_claim_team_scoped_state` strips legacy root ownership
|
|
543
|
+
/// fields before save via `state::ownership::strip_top_level_ownership`,
|
|
544
|
+
/// and the legacy "copy top-level owner from incoming state into
|
|
545
|
+
/// merged" loop is removed. After every successful named-team owner
|
|
546
|
+
/// mutation, persisted root is canonical-only.
|
|
547
|
+
#[test]
|
|
548
|
+
#[serial_test::serial(env)]
|
|
549
|
+
fn second_claim_strips_legacy_top_level_ownership_from_persisted_root() {
|
|
550
|
+
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
551
|
+
let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
|
|
552
|
+
let ws = p2_temp_ws("claim_team_second_strip");
|
|
553
|
+
let target = "alpha";
|
|
554
|
+
let team_id = TeamKey::new(target);
|
|
555
|
+
let caller = PaneId::new("%3");
|
|
556
|
+
// Seed a state whose root carries stale legacy top-level owner
|
|
557
|
+
// (%2/epoch=1) AND a teams.<target_key> entry that already has
|
|
558
|
+
// canonical ownership at the previous epoch. This is the
|
|
559
|
+
// post-S3-OWNER-001-fix shape on disk after one prior claim.
|
|
560
|
+
let mut state = serde_json::json!({
|
|
561
|
+
"session_name": "team-agent-x",
|
|
562
|
+
"team_dir": format!("/tmp/{}", target),
|
|
563
|
+
"active_team_key": target,
|
|
564
|
+
"team_owner": {
|
|
565
|
+
"pane_id": "%2",
|
|
566
|
+
"provider": "codex",
|
|
567
|
+
"machine_fingerprint": "fp",
|
|
568
|
+
"leader_session_uuid": "OLDUUID",
|
|
569
|
+
"owner_epoch": 1,
|
|
570
|
+
"claimed_at": "t",
|
|
571
|
+
"claimed_via": "claim-leader",
|
|
572
|
+
},
|
|
573
|
+
"leader_receiver": {
|
|
574
|
+
"pane_id": "%2",
|
|
575
|
+
"owner_epoch": 1,
|
|
576
|
+
"leader_session_uuid": "OLDUUID",
|
|
577
|
+
},
|
|
578
|
+
"owner_epoch": 1,
|
|
579
|
+
"teams": {
|
|
580
|
+
target: {
|
|
581
|
+
"team_owner": {
|
|
582
|
+
"pane_id": "%2",
|
|
583
|
+
"provider": "codex",
|
|
584
|
+
"machine_fingerprint": "fp",
|
|
585
|
+
"leader_session_uuid": "OLDUUID",
|
|
586
|
+
"owner_epoch": 1,
|
|
587
|
+
"claimed_at": "t",
|
|
588
|
+
"claimed_via": "claim-leader",
|
|
589
|
+
},
|
|
590
|
+
"leader_receiver": {
|
|
591
|
+
"pane_id": "%2",
|
|
592
|
+
"owner_epoch": 1,
|
|
593
|
+
"leader_session_uuid": "OLDUUID",
|
|
594
|
+
},
|
|
595
|
+
"owner_epoch": 1,
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
// Persist the seeded shape so `save_claim_team_scoped_state` loads
|
|
600
|
+
// it back as `existing` on save.
|
|
601
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
602
|
+
let event_log = crate::event_log::EventLog::new(&ws);
|
|
603
|
+
// Both panes live so the dead-owner reclaim branch doesn't apply;
|
|
604
|
+
// explicit takeover via confirm=true forces the claim past the
|
|
605
|
+
// live-owner gate.
|
|
606
|
+
let live = seeded_liveness(&["%2", "%3"]);
|
|
607
|
+
let r = claim_lease_no_incident(
|
|
608
|
+
&ws, &mut state, Some(target), &team_id, &caller, true, &event_log, &live,
|
|
609
|
+
)
|
|
610
|
+
.unwrap();
|
|
611
|
+
assert!(r.ok, "S3-OWNER-002: explicit takeover must succeed; got {r:?}");
|
|
612
|
+
assert_eq!(r.status, LeaseStatus::Claimed);
|
|
613
|
+
|
|
614
|
+
let persisted_path = crate::state::persist::runtime_state_path(&ws);
|
|
615
|
+
let persisted: serde_json::Value = serde_json::from_str(
|
|
616
|
+
&std::fs::read_to_string(&persisted_path).unwrap(),
|
|
617
|
+
)
|
|
618
|
+
.unwrap();
|
|
619
|
+
|
|
620
|
+
// Canonical advanced.
|
|
621
|
+
let entry = &persisted["teams"][target];
|
|
622
|
+
assert_eq!(
|
|
623
|
+
entry["team_owner"]["pane_id"],
|
|
624
|
+
serde_json::json!("%3"),
|
|
625
|
+
"S3-OWNER-002 fix: canonical owner advanced to new pane; persisted={persisted}"
|
|
626
|
+
);
|
|
627
|
+
assert_eq!(
|
|
628
|
+
entry["owner_epoch"],
|
|
629
|
+
serde_json::json!(2),
|
|
630
|
+
"S3-OWNER-002 fix: canonical owner_epoch advanced to 2; persisted={persisted}"
|
|
631
|
+
);
|
|
632
|
+
assert_eq!(
|
|
633
|
+
entry["leader_receiver"]["pane_id"],
|
|
634
|
+
serde_json::json!("%3"),
|
|
635
|
+
"S3-OWNER-002 fix: canonical receiver advanced; persisted={persisted}"
|
|
636
|
+
);
|
|
637
|
+
|
|
638
|
+
// Top-level stale legacy owner stripped.
|
|
639
|
+
assert!(
|
|
640
|
+
persisted.get("team_owner").is_none(),
|
|
641
|
+
"S3-OWNER-002 fix: stale top-level team_owner must be stripped; persisted={persisted}"
|
|
642
|
+
);
|
|
643
|
+
assert!(
|
|
644
|
+
persisted.get("leader_receiver").is_none(),
|
|
645
|
+
"S3-OWNER-002 fix: stale top-level leader_receiver must be stripped; persisted={persisted}"
|
|
646
|
+
);
|
|
647
|
+
assert!(
|
|
648
|
+
persisted.get("owner_epoch").is_none(),
|
|
649
|
+
"S3-OWNER-002 fix: stale top-level owner_epoch must be stripped; persisted={persisted}"
|
|
650
|
+
);
|
|
651
|
+
}
|