@team-agent/installer 0.5.73 → 0.5.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +19 -13
  4. package/crates/team-agent/src/cli/emit.rs +6 -3
  5. package/crates/team-agent/src/cli/mod.rs +151 -0
  6. package/crates/team-agent/src/cli/tests/leader_watch.rs +28 -0
  7. package/crates/team-agent/src/cli/tests/run_delegation.rs +3 -0
  8. package/crates/team-agent/src/cli/types.rs +6 -0
  9. package/crates/team-agent/src/compiler.rs +14 -1
  10. package/crates/team-agent/src/leader/lease.rs +106 -21
  11. package/crates/team-agent/src/leader/registry.rs +0 -89
  12. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +457 -73
  13. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +16 -6
  14. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +5 -3
  15. package/crates/team-agent/src/lifecycle/restart/common.rs +0 -11
  16. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +60 -0
  17. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +31 -0
  18. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +25 -23
  19. package/crates/team-agent/src/messaging/delivery.rs +26 -8
  20. package/crates/team-agent/src/messaging/tests/runtime.rs +234 -0
  21. package/crates/team-agent/src/model/spec.rs +81 -7
  22. package/crates/team-agent/src/provider/adapters/pi.rs +14 -0
  23. package/crates/team-agent/src/state/persist.rs +272 -3
  24. package/crates/team-agent/src/state/repository.rs +9 -2
  25. package/crates/team-agent/src/tmux_backend/tests.rs +64 -0
  26. package/crates/team-agent/src/tmux_backend.rs +57 -0
  27. package/package.json +4 -4
  28. package/skills/team-agent/SKILL.md +3 -0
  29. package/skills/team-agent/command-coverage.json +21 -0
  30. package/skills/team-agent/references/team-agent-operator.md +9 -2
@@ -41,7 +41,6 @@ trait FreshQuickStartLeaderBindingOps {
41
41
  fn explicit_provider(&mut self) -> Option<String>;
42
42
  fn tmux_endpoint(&mut self) -> Option<String>;
43
43
  fn observe_command(&mut self, pane: &PaneId) -> Option<String>;
44
- fn live_binding_elsewhere(&mut self, pane: &PaneId, workspace: &Path, team_key: &str) -> bool;
45
44
  fn attach(
46
45
  &mut self,
47
46
  workspace: &Path,
@@ -82,14 +81,6 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
82
81
  .filter(|command| !command.trim().is_empty())
83
82
  }
84
83
 
85
- fn live_binding_elsewhere(&mut self, pane: &PaneId, workspace: &Path, team_key: &str) -> bool {
86
- crate::leader::registry::live_same_pane_binding_elsewhere(
87
- pane.as_str(),
88
- workspace,
89
- team_key,
90
- )
91
- }
92
-
93
84
  fn attach(
94
85
  &mut self,
95
86
  workspace: &Path,
@@ -242,45 +233,52 @@ fn persisted_binding_matches_verified_pane(
242
233
  fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
243
234
  workspace: &Path,
244
235
  team_key: &str,
236
+ seeded_owner: Option<&serde_json::Value>,
245
237
  ops: &mut O,
246
- ) -> bool {
238
+ ) -> Result<bool, LifecycleError> {
247
239
  let Ok(resolved) = crate::state::projection::resolve_runtime_team_scope(
248
240
  workspace,
249
241
  Some(team_key),
250
242
  ) else {
251
- return false;
243
+ return Ok(false);
252
244
  };
253
245
  if resolved.canonical_team_key != team_key {
254
- return false;
246
+ return Ok(false);
255
247
  }
256
248
  let mut state = resolved.state;
257
249
  let Some(pane) = ops.caller_pane().filter(|pane| !pane.is_empty()) else {
258
- return false;
250
+ return Ok(false);
259
251
  };
260
252
  let pane = PaneId::new(pane);
261
253
  let Some(command) = ops
262
254
  .observe_command(&pane)
263
255
  .filter(|command| !command.trim().is_empty())
264
256
  else {
265
- return false;
257
+ return Ok(false);
266
258
  };
267
259
  let explicit_provider = ops.explicit_provider();
268
260
  let Some(provider) = crate::leader::owner_bind::strict_owner_bind_provider(
269
261
  explicit_provider.as_deref(),
270
262
  &command,
271
263
  ) else {
272
- return false;
264
+ return Ok(false);
273
265
  };
274
- if ops.live_binding_elsewhere(&pane, workspace, team_key) {
275
- return false;
276
- }
277
266
  let endpoint = ops.tmux_endpoint();
278
- // initial_runtime_state seeds the verified caller into the launched team's
279
- // dual state before spawn. That is the transaction's candidate, not an
280
- // already-owned-team refusal: it still needs the canonical registry commit.
281
267
  let has_persisted_binding = ["team_owner", "leader_receiver"]
282
268
  .iter()
283
269
  .any(|key| state.get(*key).is_some_and(|value| !value.is_null()));
270
+ // `claimed_via` is historical state, not proof that this invocation seeded
271
+ // the owner. Cleanup is allowed only when the caller supplies the exact
272
+ // in-memory seed created immediately before this bind attempt and the
273
+ // projected state still contains that seed.
274
+ let fresh_seeded_binding = seeded_owner.is_some_and(|seed| {
275
+ state
276
+ .get("team_owner")
277
+ .is_some_and(|current| current == seed)
278
+ });
279
+ // Same-Team owner collision remains fail-closed. Cross-Team registry rows
280
+ // are intentionally irrelevant: pane ids are scoped to their tmux server,
281
+ // and independent endpoints may reuse the same numeric id.
284
282
  if has_persisted_binding
285
283
  && !persisted_binding_matches_verified_pane(
286
284
  &state,
@@ -290,21 +288,38 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
290
288
  endpoint.as_deref(),
291
289
  )
292
290
  {
293
- return false;
291
+ return Ok(false);
294
292
  }
295
- // Registry publication is the commit point. Restore both persisted surfaces
296
- // byte for byte when attach, publication, or canonical readback does not finish.
293
+ // Registry publication is the commit point. Restore persisted surfaces on
294
+ // failure, except for the caller-seeded owner that must be cleared.
297
295
  let snapshots = binding_snapshots(workspace, team_key);
298
296
  let attached = has_persisted_binding || ops.attach(workspace, &mut state, &pane, provider);
299
297
  let committed = attached
300
298
  && ops.register(workspace, team_key)
301
299
  && ops.canonical_readback(workspace, team_key);
302
300
  if !committed {
303
- for snapshot in snapshots.iter().rev() {
304
- snapshot.restore();
301
+ if let Some(seed) = seeded_owner.filter(|seed| {
302
+ fresh_seeded_binding
303
+ && state
304
+ .get("team_owner")
305
+ .is_some_and(|current| current == *seed)
306
+ }) {
307
+ // Restore only the registry surface. The initial runtime state may
308
+ // already contain a caller-seeded owner; restoring it after a failed
309
+ // commit would make a later claim falsely report already_bound.
310
+ for snapshot in snapshots.iter().skip(1).rev() {
311
+ snapshot.restore();
312
+ }
313
+ clear_fresh_binding_on_refusal(workspace, &mut state, team_key, seed)?;
314
+ } else {
315
+ // No caller-seeded owner was ours to clean up, so restore all
316
+ // surfaces byte-for-byte after an attach/register/readback failure.
317
+ for snapshot in snapshots.iter().rev() {
318
+ snapshot.restore();
319
+ }
305
320
  }
306
321
  }
307
- committed
322
+ Ok(committed)
308
323
  }
309
324
 
310
325
  fn should_emit_workspace_socket_missing_hint(
@@ -314,14 +329,49 @@ fn should_emit_workspace_socket_missing_hint(
314
329
  workspace_socket_missing && selected_source != Some("leader_env")
315
330
  }
316
331
 
332
+ fn clear_fresh_binding_on_refusal(
333
+ workspace: &Path,
334
+ state: &mut serde_json::Value,
335
+ team_key: &str,
336
+ seed: &serde_json::Value,
337
+ ) -> Result<(), LifecycleError> {
338
+ // Tombstone only this attempt's seed. The lock-held persist merge compares
339
+ // the on-disk owner to `seed`; a concurrent equal-epoch owner is copied
340
+ // back and the epoch is not bumped.
341
+ if let Some(obj) = state.as_object_mut() {
342
+ obj.remove("leader_receiver");
343
+ obj.remove("team_owner");
344
+ obj.remove("owner_epoch");
345
+ }
346
+ if let Some(team) = state
347
+ .get_mut("teams")
348
+ .and_then(serde_json::Value::as_object_mut)
349
+ .and_then(|teams| teams.get_mut(team_key))
350
+ .and_then(serde_json::Value::as_object_mut)
351
+ {
352
+ team.insert("leader_receiver".to_string(), serde_json::Value::Null);
353
+ team.insert("team_owner".to_string(), serde_json::Value::Null);
354
+ }
355
+ crate::state::repository::StateRepository::new(workspace)
356
+ .save(
357
+ crate::state::repository::StateWriteIntent::ClearExactTeamOwner { team_key, seed },
358
+ state,
359
+ )
360
+ .map_err(|error| LifecycleError::StatePersist(format!(
361
+ "quick-start binding cleanup failed: {error}"
362
+ )))
363
+ }
364
+
317
365
  fn bind_fresh_quick_start_leader(
318
366
  workspace: &Path,
319
367
  team_key: &str,
368
+ seeded_owner: Option<&serde_json::Value>,
320
369
  transport: &dyn Transport,
321
- ) -> bool {
370
+ ) -> Result<bool, LifecycleError> {
322
371
  bind_fresh_quick_start_leader_with(
323
372
  workspace,
324
373
  team_key,
374
+ seeded_owner,
325
375
  &mut RuntimeFreshQuickStartLeaderBindingOps { transport },
326
376
  )
327
377
  }
@@ -742,6 +792,20 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
742
792
  agents_dir,
743
793
  &state_team_key,
744
794
  );
795
+ // Keep this attempt-local seed separate from persisted `claimed_via`;
796
+ // historical quick-start rows are never sufficient cleanup authority.
797
+ let seeded_owner = state
798
+ .get("teams")
799
+ .and_then(serde_json::Value::as_object)
800
+ .and_then(|teams| teams.get(&state_team_key))
801
+ .and_then(|team| team.get("team_owner"))
802
+ .filter(|owner| {
803
+ owner
804
+ .get("claimed_via")
805
+ .and_then(serde_json::Value::as_str)
806
+ == Some("quick-start")
807
+ })
808
+ .cloned();
745
809
  // 0.5.x Phase 1d hot-path 接线(裁决1 msg_76e1d98202b8): use the
746
810
  // generic annotator that writes `state.transport = { kind, source }`
747
811
  // for every backend AND (for tmux) preserves the existing
@@ -778,8 +842,12 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
778
842
  // Fresh initialization owns this one fail-closed bind attempt. It is
779
843
  // independent of display layout, so --no-display never suppresses receiver
780
844
  // binding. Readiness receives true only after canonical registry readback.
781
- launch.leader_receiver_attached =
782
- bind_fresh_quick_start_leader(&workspace, &state_team_key, transport);
845
+ launch.leader_receiver_attached = bind_fresh_quick_start_leader(
846
+ &workspace,
847
+ &state_team_key,
848
+ seeded_owner.as_ref(),
849
+ transport,
850
+ )?;
783
851
  launch.session_capture_incomplete_agents =
784
852
  quick_start_session_capture_incomplete_agents(&workspace, &state_team_key);
785
853
  let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.clone());
@@ -888,7 +956,6 @@ mod fresh_quick_start_leader_binding_tests {
888
956
  explicit_provider: Option<String>,
889
957
  endpoint: Option<String>,
890
958
  command: Option<String>,
891
- live_elsewhere: bool,
892
959
  attach_ok: bool,
893
960
  register_ok: bool,
894
961
  readback_ok: bool,
@@ -905,7 +972,6 @@ mod fresh_quick_start_leader_binding_tests {
905
972
  explicit_provider: Some("pi".to_string()),
906
973
  endpoint: Some("/private/tmp/tmux-test/default".to_string()),
907
974
  command: Some("pi".to_string()),
908
- live_elsewhere: false,
909
975
  attach_ok: true,
910
976
  register_ok: true,
911
977
  readback_ok: true,
@@ -917,6 +983,53 @@ mod fresh_quick_start_leader_binding_tests {
917
983
  }
918
984
  }
919
985
 
986
+ #[test]
987
+ fn invalid_pi_tool_category_is_rejected_before_runtime_persistence() {
988
+ let root = std::env::temp_dir().join(format!(
989
+ "ta-quick-start-pi-tools-{}-{}",
990
+ std::process::id(),
991
+ SEQUENCE.fetch_add(1, Ordering::Relaxed)
992
+ ));
993
+ let team = root.join(".team/current");
994
+ std::fs::create_dir_all(team.join("agents")).unwrap();
995
+ std::fs::write(
996
+ team.join("TEAM.md"),
997
+ "---\nname: pi-tools\nprovider: pi\n---\n\nPi team.\n",
998
+ )
999
+ .unwrap();
1000
+ std::fs::write(
1001
+ team.join("agents/worker.md"),
1002
+ "---\nname: worker\nrole: Worker\nprovider: pi\nauth_mode: subscription\ndangerously_skip_permissions: false\ntools:\n - mcp_team\n - fs_read\n - provider_builtin\n---\n\nWorker.\n",
1003
+ )
1004
+ .unwrap();
1005
+
1006
+ let transport = crate::transport::test_support::OfflineTransport::new();
1007
+ let mut discover = |_requested: &str| -> Result<Vec<String>, ()> { Err(()) };
1008
+ let error = quick_start_with_transport_in_workspace_with_display_pi_preflight(
1009
+ &root,
1010
+ &team,
1011
+ None,
1012
+ true,
1013
+ None,
1014
+ &transport,
1015
+ false,
1016
+ &mut discover,
1017
+ )
1018
+ .expect_err("invalid Pi category must fail before quick-start persistence");
1019
+ let error = error.to_string();
1020
+ assert!(error.contains("Pi does not support Team Agent tool category \"provider_builtin\""));
1021
+ assert!(error.contains("remove it from the role's tools"));
1022
+ assert!(
1023
+ !crate::state::persist::runtime_state_path(&root).exists(),
1024
+ "quick-start validation failure must not persist runtime state"
1025
+ );
1026
+ assert!(
1027
+ !root.join(".team/runtime").exists(),
1028
+ "quick-start validation failure must not create runtime artifacts"
1029
+ );
1030
+ let _ = std::fs::remove_dir_all(root);
1031
+ }
1032
+
920
1033
  impl FreshQuickStartLeaderBindingOps for MockOps {
921
1034
  fn caller_pane(&mut self) -> Option<String> {
922
1035
  self.pane.clone()
@@ -934,15 +1047,6 @@ mod fresh_quick_start_leader_binding_tests {
934
1047
  self.command.clone()
935
1048
  }
936
1049
 
937
- fn live_binding_elsewhere(
938
- &mut self,
939
- _pane: &PaneId,
940
- _workspace: &Path,
941
- _team_key: &str,
942
- ) -> bool {
943
- self.live_elsewhere
944
- }
945
-
946
1050
  fn attach(
947
1051
  &mut self,
948
1052
  workspace: &Path,
@@ -1009,11 +1113,8 @@ mod fresh_quick_start_leader_binding_tests {
1009
1113
  fn fresh_binding_persists_then_registers_then_requires_canonical_readback() {
1010
1114
  let workspace = workspace("positive");
1011
1115
  let mut ops = MockOps::default();
1012
- assert!(bind_fresh_quick_start_leader_with(
1013
- &workspace,
1014
- "fresh",
1015
- &mut ops
1016
- ));
1116
+ assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
1117
+ .unwrap());
1017
1118
  assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
1018
1119
  assert_eq!(ops.attach_calls, 1);
1019
1120
  assert_eq!(ops.register_calls, 1);
@@ -1042,7 +1143,6 @@ mod fresh_quick_start_leader_binding_tests {
1042
1143
  "socket_mismatch",
1043
1144
  "workspace_mismatch",
1044
1145
  "dual_state_mismatch",
1045
- "live_other_scope",
1046
1146
  ] {
1047
1147
  let workspace = workspace(case);
1048
1148
  let mut state = crate::state::persist::load_runtime_state(&workspace).unwrap();
@@ -1088,7 +1188,6 @@ mod fresh_quick_start_leader_binding_tests {
1088
1188
  _ => unreachable!(),
1089
1189
  }
1090
1190
  }
1091
- "live_other_scope" => ops.live_elsewhere = true,
1092
1191
  "team_mismatch" => {}
1093
1192
  _ => unreachable!(),
1094
1193
  }
@@ -1096,7 +1195,8 @@ mod fresh_quick_start_leader_binding_tests {
1096
1195
  let before = std::fs::read(crate::state::persist::runtime_state_path(&workspace))
1097
1196
  .unwrap();
1098
1197
  assert!(
1099
- !bind_fresh_quick_start_leader_with(&workspace, team_key, &mut ops),
1198
+ !bind_fresh_quick_start_leader_with(&workspace, team_key, None, &mut ops)
1199
+ .unwrap(),
1100
1200
  "{case} must refuse"
1101
1201
  );
1102
1202
  assert_eq!(
@@ -1198,15 +1298,6 @@ mod fresh_quick_start_leader_binding_tests {
1198
1298
  Some("pi".to_string())
1199
1299
  }
1200
1300
 
1201
- fn live_binding_elsewhere(
1202
- &mut self,
1203
- _pane: &PaneId,
1204
- _workspace: &Path,
1205
- _team_key: &str,
1206
- ) -> bool {
1207
- false
1208
- }
1209
-
1210
1301
  fn attach(
1211
1302
  &mut self,
1212
1303
  _workspace: &Path,
@@ -1246,11 +1337,8 @@ mod fresh_quick_start_leader_binding_tests {
1246
1337
  readback_ok: true,
1247
1338
  attach_calls: 0,
1248
1339
  };
1249
- assert!(bind_fresh_quick_start_leader_with(
1250
- &workspace,
1251
- "fresh",
1252
- &mut ops
1253
- ));
1340
+ assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
1341
+ .unwrap());
1254
1342
  assert_eq!(ops.attach_calls, 0, "preseeded dual state must not reattach");
1255
1343
  let path = crate::leader::registry::registry_dir().unwrap().join(format!(
1256
1344
  "{}__fresh.json",
@@ -1264,6 +1352,308 @@ mod fresh_quick_start_leader_binding_tests {
1264
1352
  );
1265
1353
  }
1266
1354
 
1355
+ #[test]
1356
+ fn historical_quick_start_owner_is_not_cleanup_authority() {
1357
+ let workspace = workspace("historical-quick-start-owner");
1358
+ let mut state = crate::state::persist::load_runtime_state(&workspace).unwrap();
1359
+ state["workspace"] = json!(workspace);
1360
+ state["team_owner"] = json!({
1361
+ "pane_id": "%old",
1362
+ "provider": "pi",
1363
+ "leader_session_uuid": "uuid-old",
1364
+ "owner_epoch": 4,
1365
+ "claimed_via": "quick-start"
1366
+ });
1367
+ state["leader_receiver"] = json!({
1368
+ "mode": "direct_tmux",
1369
+ "status": "attached",
1370
+ "pane_id": "%old",
1371
+ "provider": "pi",
1372
+ "leader_session_uuid": "uuid-old",
1373
+ "owner_epoch": 4,
1374
+ "tmux_socket": "/private/tmp/tmux-test/old"
1375
+ });
1376
+ crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
1377
+ let before = std::fs::read(crate::state::persist::runtime_state_path(&workspace)).unwrap();
1378
+ let mut ops = MockOps::default();
1379
+
1380
+ assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
1381
+ .unwrap());
1382
+ assert_eq!(
1383
+ before,
1384
+ std::fs::read(crate::state::persist::runtime_state_path(&workspace)).unwrap(),
1385
+ "historical claimed_via alone must not authorize cleanup"
1386
+ );
1387
+ }
1388
+
1389
+ fn matching_seed() -> serde_json::Value {
1390
+ json!({
1391
+ "pane_id": "%42",
1392
+ "provider": "pi",
1393
+ "leader_session_uuid": "uuid-fresh",
1394
+ "owner_epoch": 1,
1395
+ "claimed_via": "quick-start"
1396
+ })
1397
+ }
1398
+
1399
+ fn matching_receiver() -> serde_json::Value {
1400
+ json!({
1401
+ "mode": "direct_tmux",
1402
+ "status": "attached",
1403
+ "pane_id": "%42",
1404
+ "provider": "pi",
1405
+ "leader_session_uuid": "uuid-fresh",
1406
+ "owner_epoch": 1,
1407
+ "tmux_socket": "/private/tmp/tmux-test/default"
1408
+ })
1409
+ }
1410
+
1411
+ fn workspace_with_matching_seed(tag: &str) -> (PathBuf, serde_json::Value) {
1412
+ let workspace = workspace(tag);
1413
+ let seed = matching_seed();
1414
+ crate::state::persist::save_runtime_state(
1415
+ &workspace,
1416
+ &json!({
1417
+ "workspace": workspace,
1418
+ "active_team_key": "fresh",
1419
+ "team_key": "fresh",
1420
+ "session_name": "team-fresh",
1421
+ "agents": {"sol": {"status": "running", "provider": "pi"}},
1422
+ "teams": {
1423
+ "fresh": {
1424
+ "workspace": workspace,
1425
+ "team_key": "fresh",
1426
+ "session_name": "team-fresh",
1427
+ "agents": {"sol": {"status": "running", "provider": "pi"}},
1428
+ "team_owner": seed,
1429
+ "leader_receiver": matching_receiver(),
1430
+ "owner_epoch": 1
1431
+ }
1432
+ }
1433
+ }),
1434
+ )
1435
+ .unwrap();
1436
+ let seed = crate::state::persist::load_runtime_state(&workspace).unwrap()["teams"]["fresh"]
1437
+ ["team_owner"]
1438
+ .clone();
1439
+ (workspace, seed)
1440
+ }
1441
+
1442
+ struct ConcurrentTakeover {
1443
+ owner: serde_json::Value,
1444
+ receiver: serde_json::Value,
1445
+ epoch: u64,
1446
+ }
1447
+
1448
+ fn takeover_binding(epoch: u64) -> ConcurrentTakeover {
1449
+ ConcurrentTakeover {
1450
+ owner: json!({
1451
+ "pane_id": "%99",
1452
+ "provider": "codex",
1453
+ "leader_session_uuid": "uuid-other",
1454
+ "owner_epoch": epoch,
1455
+ "claimed_via": "claim-leader"
1456
+ }),
1457
+ receiver: json!({
1458
+ "mode": "direct_tmux",
1459
+ "status": "attached",
1460
+ "pane_id": "%99",
1461
+ "provider": "codex",
1462
+ "leader_session_uuid": "uuid-other",
1463
+ "owner_epoch": epoch,
1464
+ "tmux_socket": "/private/tmp/tmux-test/other"
1465
+ }),
1466
+ epoch,
1467
+ }
1468
+ }
1469
+
1470
+ struct SeedCleanupOps {
1471
+ inner: MockOps,
1472
+ takeover: Option<ConcurrentTakeover>,
1473
+ poison_state_file: bool,
1474
+ }
1475
+
1476
+ impl FreshQuickStartLeaderBindingOps for SeedCleanupOps {
1477
+ fn caller_pane(&mut self) -> Option<String> {
1478
+ self.inner.caller_pane()
1479
+ }
1480
+
1481
+ fn explicit_provider(&mut self) -> Option<String> {
1482
+ self.inner.explicit_provider()
1483
+ }
1484
+
1485
+ fn tmux_endpoint(&mut self) -> Option<String> {
1486
+ self.inner.tmux_endpoint()
1487
+ }
1488
+
1489
+ fn observe_command(&mut self, pane: &PaneId) -> Option<String> {
1490
+ self.inner.observe_command(pane)
1491
+ }
1492
+
1493
+ fn attach(
1494
+ &mut self,
1495
+ workspace: &Path,
1496
+ state: &mut serde_json::Value,
1497
+ pane: &PaneId,
1498
+ provider: crate::provider::Provider,
1499
+ ) -> bool {
1500
+ self.inner.attach(workspace, state, pane, provider)
1501
+ }
1502
+
1503
+ fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
1504
+ self.inner.register_calls += 1;
1505
+ if let Some(takeover) = &self.takeover {
1506
+ let mut persisted = crate::state::persist::load_runtime_state(workspace).unwrap();
1507
+ persisted["teams"][team_key]["team_owner"] = takeover.owner.clone();
1508
+ persisted["teams"][team_key]["leader_receiver"] = takeover.receiver.clone();
1509
+ persisted["teams"][team_key]["owner_epoch"] = json!(takeover.epoch);
1510
+ crate::state::persist::save_runtime_state_with_receiver_authority(
1511
+ workspace,
1512
+ &persisted,
1513
+ team_key,
1514
+ None,
1515
+ )
1516
+ .unwrap();
1517
+ assert_fresh_binding_kept(
1518
+ &crate::state::persist::load_runtime_state(workspace).unwrap(),
1519
+ takeover,
1520
+ );
1521
+ }
1522
+ if self.poison_state_file {
1523
+ let path = crate::state::persist::runtime_state_path(workspace);
1524
+ let _ = std::fs::remove_file(&path);
1525
+ std::fs::create_dir_all(&path).unwrap();
1526
+ }
1527
+ false
1528
+ }
1529
+
1530
+ fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
1531
+ self.inner.canonical_readback(workspace, team_key)
1532
+ }
1533
+ }
1534
+
1535
+ fn failed_register_ops(takeover: Option<ConcurrentTakeover>, poison: bool) -> SeedCleanupOps {
1536
+ SeedCleanupOps {
1537
+ inner: MockOps {
1538
+ register_ok: false,
1539
+ ..MockOps::default()
1540
+ },
1541
+ takeover,
1542
+ poison_state_file: poison,
1543
+ }
1544
+ }
1545
+
1546
+ fn assert_fresh_binding_kept(
1547
+ persisted: &serde_json::Value,
1548
+ takeover: &ConcurrentTakeover,
1549
+ ) {
1550
+ assert_eq!(
1551
+ persisted["teams"]["fresh"]["team_owner"],
1552
+ takeover.owner,
1553
+ "concurrent team_owner must survive seeded cleanup"
1554
+ );
1555
+ assert_eq!(
1556
+ persisted["teams"]["fresh"]["leader_receiver"],
1557
+ takeover.receiver,
1558
+ "concurrent leader_receiver must survive seeded cleanup"
1559
+ );
1560
+ assert_eq!(
1561
+ persisted["teams"]["fresh"]["owner_epoch"],
1562
+ json!(takeover.epoch),
1563
+ "concurrent owner_epoch must survive seeded cleanup"
1564
+ );
1565
+ }
1566
+
1567
+ #[test]
1568
+ fn seeded_cleanup_clears_exact_seed_through_bind_repository_entry() {
1569
+ let (workspace, seed) = workspace_with_matching_seed("seed-cleanup-success");
1570
+ let mut ops = failed_register_ops(None, false);
1571
+ assert!(!bind_fresh_quick_start_leader_with(
1572
+ &workspace,
1573
+ "fresh",
1574
+ Some(&seed),
1575
+ &mut ops
1576
+ )
1577
+ .unwrap());
1578
+ let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
1579
+ assert!(
1580
+ persisted
1581
+ .pointer("/teams/fresh/team_owner")
1582
+ .is_some_and(serde_json::Value::is_null),
1583
+ "matching seed must be cleared via bind/repository; got {}",
1584
+ persisted["teams"]["fresh"]["team_owner"]
1585
+ );
1586
+ assert!(
1587
+ persisted
1588
+ .pointer("/teams/fresh/leader_receiver")
1589
+ .is_some_and(serde_json::Value::is_null)
1590
+ );
1591
+ assert_eq!(
1592
+ persisted["teams"]["fresh"]["owner_epoch"],
1593
+ json!(1),
1594
+ "seed cleanup must not bump owner_epoch"
1595
+ );
1596
+ }
1597
+
1598
+ #[test]
1599
+ fn seeded_cleanup_preserves_equal_epoch_persisted_concurrent_owner() {
1600
+ let (workspace, seed) = workspace_with_matching_seed("seed-cleanup-concurrent");
1601
+ let takeover = takeover_binding(1);
1602
+ let mut ops = failed_register_ops(Some(takeover_binding(1)), false);
1603
+ assert!(!bind_fresh_quick_start_leader_with(
1604
+ &workspace,
1605
+ "fresh",
1606
+ Some(&seed),
1607
+ &mut ops
1608
+ )
1609
+ .unwrap());
1610
+ assert_fresh_binding_kept(
1611
+ &crate::state::persist::load_runtime_state(&workspace).unwrap(),
1612
+ &takeover,
1613
+ );
1614
+ }
1615
+
1616
+ #[test]
1617
+ fn seeded_cleanup_preserves_epoch2_takeover_owner_receiver_epoch() {
1618
+ let (workspace, seed) = workspace_with_matching_seed("seed-cleanup-epoch2");
1619
+ let takeover = takeover_binding(2);
1620
+ let mut ops = failed_register_ops(Some(takeover_binding(2)), false);
1621
+ assert!(!bind_fresh_quick_start_leader_with(
1622
+ &workspace,
1623
+ "fresh",
1624
+ Some(&seed),
1625
+ &mut ops
1626
+ )
1627
+ .unwrap());
1628
+ assert_fresh_binding_kept(
1629
+ &crate::state::persist::load_runtime_state(&workspace).unwrap(),
1630
+ &takeover,
1631
+ );
1632
+ }
1633
+
1634
+ #[test]
1635
+ fn seeded_cleanup_propagates_persist_error() {
1636
+ let (workspace, seed) = workspace_with_matching_seed("seed-cleanup-persist-err");
1637
+ let mut ops = failed_register_ops(None, true);
1638
+ let error = bind_fresh_quick_start_leader_with(
1639
+ &workspace,
1640
+ "fresh",
1641
+ Some(&seed),
1642
+ &mut ops,
1643
+ )
1644
+ .expect_err("poisoned state.json must surface cleanup persist failure");
1645
+ match error {
1646
+ LifecycleError::StatePersist(text) => {
1647
+ assert!(
1648
+ text.contains("quick-start binding cleanup failed"),
1649
+ "unexpected persist error: {text}"
1650
+ );
1651
+ }
1652
+ other => panic!("expected StatePersist, got {other:?}"),
1653
+ }
1654
+ let _ = std::fs::remove_dir_all(crate::state::persist::runtime_state_path(&workspace));
1655
+ }
1656
+
1267
1657
  #[test]
1268
1658
  fn attach_registry_or_readback_failure_rolls_back_exact_state_bytes() {
1269
1659
  for (case, mut ops) in [
@@ -1292,11 +1682,8 @@ mod fresh_quick_start_leader_binding_tests {
1292
1682
  let workspace = workspace(case);
1293
1683
  let state_path = crate::state::persist::runtime_state_path(&workspace);
1294
1684
  let before = std::fs::read(&state_path).unwrap();
1295
- assert!(!bind_fresh_quick_start_leader_with(
1296
- &workspace,
1297
- "fresh",
1298
- &mut ops
1299
- ));
1685
+ assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
1686
+ .unwrap());
1300
1687
  assert_eq!(
1301
1688
  std::fs::read(&state_path).unwrap(),
1302
1689
  before,
@@ -1357,11 +1744,8 @@ mod fresh_quick_start_leader_binding_tests {
1357
1744
  readback_ok: false,
1358
1745
  attach_calls: 0,
1359
1746
  };
1360
- assert!(!bind_fresh_quick_start_leader_with(
1361
- &workspace,
1362
- "fresh",
1363
- &mut ops
1364
- ));
1747
+ assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
1748
+ .unwrap());
1365
1749
  assert_eq!(std::fs::read(state_path).unwrap(), before);
1366
1750
  assert!(!registry_path.exists(), "failed readback left registry bytes");
1367
1751
  }