@team-agent/installer 0.5.72 → 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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +19 -13
- package/crates/team-agent/src/cli/emit.rs +6 -3
- package/crates/team-agent/src/cli/mod.rs +151 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +28 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +3 -0
- package/crates/team-agent/src/cli/types.rs +6 -0
- package/crates/team-agent/src/compiler.rs +14 -1
- package/crates/team-agent/src/leader/lease.rs +106 -21
- package/crates/team-agent/src/leader/registry.rs +0 -89
- package/crates/team-agent/src/leader/start.rs +8 -0
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +43 -7
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +457 -73
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +16 -6
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +5 -3
- package/crates/team-agent/src/lifecycle/restart/common.rs +1 -11
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +60 -0
- package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +71 -1
- package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +34 -1
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +25 -23
- package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +396 -3
- package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +3 -1
- package/crates/team-agent/src/messaging/delivery.rs +26 -8
- package/crates/team-agent/src/messaging/tests/runtime.rs +234 -0
- package/crates/team-agent/src/model/spec.rs +81 -7
- package/crates/team-agent/src/provider/adapters/pi.rs +26 -6
- package/crates/team-agent/src/state/persist.rs +272 -3
- package/crates/team-agent/src/state/repository.rs +9 -2
- package/crates/team-agent/src/tmux_backend/tests.rs +64 -0
- package/crates/team-agent/src/tmux_backend.rs +57 -0
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +4 -1
- package/skills/team-agent/command-coverage.json +21 -0
- 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
|
|
296
|
-
//
|
|
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
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|