@team-agent/installer 0.5.112 → 0.5.114
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 +252 -84
- package/Cargo.toml +3 -1
- package/crates/team-agent/Cargo.toml +3 -0
- package/crates/team-agent/src/cli/adapters.rs +119 -50
- package/crates/team-agent/src/cli/diagnose.rs +36 -13
- package/crates/team-agent/src/cli/emit.rs +173 -109
- package/crates/team-agent/src/cli/leader.rs +16 -0
- package/crates/team-agent/src/cli/leader_prompt.rs +261 -0
- package/crates/team-agent/src/cli/leaders.rs +32 -32
- package/crates/team-agent/src/cli/mod.rs +158 -14
- package/crates/team-agent/src/cli/models.rs +68 -18
- package/crates/team-agent/src/cli/named_address.rs +17 -16
- package/crates/team-agent/src/cli/route.rs +4 -4
- package/crates/team-agent/src/cli/send/presentation.rs +19 -18
- package/crates/team-agent/src/cli/send/resolve.rs +11 -11
- package/crates/team-agent/src/cli/send.rs +4 -4
- package/crates/team-agent/src/cli/spec.rs +35 -29
- package/crates/team-agent/src/cli/status.rs +7 -7
- package/crates/team-agent/src/cli/status_port/brief.rs +268 -101
- package/crates/team-agent/src/cli/tests/base.rs +94 -6
- package/crates/team-agent/src/cli/tests/divergence.rs +3 -2
- package/crates/team-agent/src/cli/tests/lane_c.rs +1 -1
- package/crates/team-agent/src/cli/tests/named_address.rs +3 -3
- package/crates/team-agent/src/cli/tests/status_send.rs +9 -7
- package/crates/team-agent/src/cli/triage.rs +53 -2
- package/crates/team-agent/src/cli/types.rs +8 -8
- package/crates/team-agent/src/compiler/tests.rs +12 -0
- package/crates/team-agent/src/compiler.rs +65 -26
- package/crates/team-agent/src/contract_runtime/backend.rs +646 -0
- package/crates/team-agent/src/contract_runtime/config.rs +309 -0
- package/crates/team-agent/src/contract_runtime/discovery.rs +237 -0
- package/crates/team-agent/src/contract_runtime/forward.rs +229 -0
- package/crates/team-agent/src/contract_runtime/framework.rs +261 -0
- package/crates/team-agent/src/contract_runtime/mcp.rs +396 -0
- package/crates/team-agent/src/contract_runtime/mod.rs +14 -0
- package/crates/team-agent/src/contract_runtime/prompt.rs +216 -0
- package/crates/team-agent/src/contract_runtime/pump.rs +207 -0
- package/crates/team-agent/src/contract_runtime/registry.rs +30 -0
- package/crates/team-agent/src/coordinator/tick.rs +26 -8
- package/crates/team-agent/src/leader/mod.rs +1 -0
- package/crates/team-agent/src/leader/prompt.rs +906 -0
- package/crates/team-agent/src/leader/start.rs +265 -16
- package/crates/team-agent/src/leader/tests/identity.rs +21 -0
- package/crates/team-agent/src/leader/tests/mod.rs +2 -0
- package/crates/team-agent/src/leader/tests/prompt_contract.rs +268 -0
- package/crates/team-agent/src/leader/types.rs +6 -0
- package/crates/team-agent/src/lib.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/identity.rs +172 -13
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +760 -365
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +38 -9
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +17 -0
- package/crates/team-agent/src/lifecycle/launch.rs +39 -28
- package/crates/team-agent/src/lifecycle/profile_launch.rs +58 -46
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +97 -52
- package/crates/team-agent/src/lifecycle/restart.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/stale_team_saveconflict_contract.rs +3 -2
- package/crates/team-agent/src/lifecycle/types.rs +4 -1
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +37 -27
- package/crates/team-agent/src/main.rs +6 -0
- package/crates/team-agent/src/messaging/delivery.rs +50 -1
- package/crates/team-agent/src/messaging/send.rs +10 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +131 -0
- package/crates/team-agent/src/model/spec.rs +24 -12
- package/crates/team-agent/src/provider/adapter.rs +8 -59
- package/crates/team-agent/src/provider/adapters/claude.rs +6 -14
- package/crates/team-agent/src/provider/adapters/codex.rs +8 -18
- package/crates/team-agent/src/provider/adapters/copilot.rs +5 -22
- package/crates/team-agent/src/provider/adapters/cursor_agent.rs +8 -45
- package/crates/team-agent/src/provider/adapters/grok.rs +7 -31
- package/crates/team-agent/src/provider/adapters/pi.rs +7 -18
- package/crates/team-agent/src/provider/command_helpers.rs +45 -0
- package/crates/team-agent/src/provider/mod.rs +1 -0
- package/crates/team-agent/src/provider/model_catalog.rs +21 -28
- package/crates/team-agent/src/provider/tests/adapter.rs +70 -0
- package/crates/team-agent/src/provider/tests/command_helpers.rs +55 -0
- package/crates/team-agent/src/state/persist.rs +73 -0
- package/package.json +4 -4
|
@@ -192,7 +192,9 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
192
192
|
let (error, applied_grant) = error.into_parts();
|
|
193
193
|
self.applied_grant = applied_grant.map(|grant| (grant.owner, grant.receiver));
|
|
194
194
|
self.cas_conflict = match &error {
|
|
195
|
-
crate::leader::LeaderError::State(crate::state::StateError::SaveConflict(
|
|
195
|
+
crate::leader::LeaderError::State(crate::state::StateError::SaveConflict(
|
|
196
|
+
_,
|
|
197
|
+
)) => true,
|
|
196
198
|
_ => false,
|
|
197
199
|
};
|
|
198
200
|
self.last_failure_reason = Some(if self.cas_conflict {
|
|
@@ -206,7 +208,8 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
206
208
|
crate::leader::LeaderError::Io(_)
|
|
207
209
|
| crate::leader::LeaderError::Json(_)
|
|
208
210
|
| crate::leader::LeaderError::MessageStore(_)
|
|
209
|
-
| crate::leader::LeaderError::Start(_)
|
|
211
|
+
| crate::leader::LeaderError::Start(_)
|
|
212
|
+
| crate::leader::LeaderError::Prompt(_) => "attach_failed",
|
|
210
213
|
crate::leader::LeaderError::EventLog(_) => "attach_event_log_failed",
|
|
211
214
|
crate::leader::LeaderError::Messaging(_) => "attach_watcher_failed",
|
|
212
215
|
}
|
|
@@ -273,16 +276,13 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
273
276
|
});
|
|
274
277
|
return false;
|
|
275
278
|
}
|
|
276
|
-
let Ok(resolved) =
|
|
277
|
-
workspace,
|
|
278
|
-
|
|
279
|
-
) else {
|
|
279
|
+
let Ok(resolved) =
|
|
280
|
+
crate::state::projection::resolve_runtime_team_scope(workspace, Some(team_key))
|
|
281
|
+
else {
|
|
280
282
|
self.last_failure_reason = Some("receiver_scope_unavailable");
|
|
281
283
|
return false;
|
|
282
284
|
};
|
|
283
|
-
let Some(receiver) =
|
|
284
|
-
super::selected_team_leader_receiver(&resolved.state, team_key)
|
|
285
|
-
else {
|
|
285
|
+
let Some(receiver) = super::selected_team_leader_receiver(&resolved.state, team_key) else {
|
|
286
286
|
self.last_failure_reason = Some("receiver_missing");
|
|
287
287
|
return false;
|
|
288
288
|
};
|
|
@@ -291,9 +291,8 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
291
291
|
.and_then(serde_json::Value::as_str)
|
|
292
292
|
.filter(|endpoint| !endpoint.is_empty())
|
|
293
293
|
.map(crate::transport_factory::leader_endpoint_transport);
|
|
294
|
-
let live_channel_transport: &dyn Transport =
|
|
295
|
-
.as_deref()
|
|
296
|
-
.unwrap_or(self.transport);
|
|
294
|
+
let live_channel_transport: &dyn Transport =
|
|
295
|
+
leader_transport.as_deref().unwrap_or(self.transport);
|
|
297
296
|
if !matches!(
|
|
298
297
|
crate::messaging::resolve_live_leader_channel(
|
|
299
298
|
workspace,
|
|
@@ -314,9 +313,7 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
314
313
|
}
|
|
315
314
|
|
|
316
315
|
fn frozen_seed_receipt(&self) -> Option<(serde_json::Value, serde_json::Value)> {
|
|
317
|
-
self.frozen_owner
|
|
318
|
-
.clone()
|
|
319
|
-
.zip(self.frozen_receiver.clone())
|
|
316
|
+
self.frozen_owner.clone().zip(self.frozen_receiver.clone())
|
|
320
317
|
}
|
|
321
318
|
}
|
|
322
319
|
|
|
@@ -376,7 +373,6 @@ impl BindingFileSnapshot {
|
|
|
376
373
|
let bytes = std::fs::read(&path).ok();
|
|
377
374
|
Self { path, bytes }
|
|
378
375
|
}
|
|
379
|
-
|
|
380
376
|
}
|
|
381
377
|
|
|
382
378
|
fn binding_snapshots(workspace: &Path, team_key: &str) -> Vec<BindingFileSnapshot> {
|
|
@@ -397,6 +393,25 @@ fn same_workspace(left: &Path, right: &Path) -> bool {
|
|
|
397
393
|
== std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf())
|
|
398
394
|
}
|
|
399
395
|
|
|
396
|
+
fn quick_start_tmux_endpoints_match(bound: &str, endpoint: &str) -> bool {
|
|
397
|
+
if bound == endpoint {
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
// A workspace transport uses a short `-L` name, while the native launcher
|
|
401
|
+
// persists an absolute `-S` path. Compare full resolved paths, never basenames.
|
|
402
|
+
let socket_path = |value: &str| {
|
|
403
|
+
let path = if Path::new(value).is_absolute() {
|
|
404
|
+
Some(PathBuf::from(value))
|
|
405
|
+
} else {
|
|
406
|
+
crate::tmux_backend::socket_path_for_name(value)
|
|
407
|
+
};
|
|
408
|
+
path.map(|path| crate::state::owner_gate::realpath_like(&path))
|
|
409
|
+
};
|
|
410
|
+
socket_path(bound)
|
|
411
|
+
.zip(socket_path(endpoint))
|
|
412
|
+
.is_some_and(|(bound, endpoint)| bound == endpoint)
|
|
413
|
+
}
|
|
414
|
+
|
|
400
415
|
fn persisted_binding_matches_verified_pane(
|
|
401
416
|
state: &serde_json::Value,
|
|
402
417
|
workspace: &Path,
|
|
@@ -415,7 +430,10 @@ fn persisted_binding_matches_verified_pane(
|
|
|
415
430
|
{
|
|
416
431
|
return false;
|
|
417
432
|
}
|
|
418
|
-
let Some(owner) = state
|
|
433
|
+
let Some(owner) = state
|
|
434
|
+
.get("team_owner")
|
|
435
|
+
.and_then(serde_json::Value::as_object)
|
|
436
|
+
else {
|
|
419
437
|
return false;
|
|
420
438
|
};
|
|
421
439
|
let Some(receiver) = state
|
|
@@ -455,7 +473,7 @@ fn persisted_binding_matches_verified_pane(
|
|
|
455
473
|
&& receiver
|
|
456
474
|
.get("tmux_socket")
|
|
457
475
|
.and_then(serde_json::Value::as_str)
|
|
458
|
-
|
|
476
|
+
.is_some_and(|bound| quick_start_tmux_endpoints_match(bound, endpoint))
|
|
459
477
|
&& receiver
|
|
460
478
|
.get("authorized_team_workspace")
|
|
461
479
|
.and_then(serde_json::Value::as_str)
|
|
@@ -469,10 +487,9 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
|
|
|
469
487
|
seeded_owner: Option<&serde_json::Value>,
|
|
470
488
|
ops: &mut O,
|
|
471
489
|
) -> Result<bool, LifecycleError> {
|
|
472
|
-
let Ok(resolved) =
|
|
473
|
-
workspace,
|
|
474
|
-
|
|
475
|
-
) else {
|
|
490
|
+
let Ok(resolved) =
|
|
491
|
+
crate::state::projection::resolve_runtime_team_scope(workspace, Some(team_key))
|
|
492
|
+
else {
|
|
476
493
|
return refuse_fresh_bind(workspace, team_key, "scope", "scope_unresolved");
|
|
477
494
|
};
|
|
478
495
|
if resolved.canonical_team_key != team_key {
|
|
@@ -531,27 +548,27 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
|
|
|
531
548
|
// projected state still contains that seed.
|
|
532
549
|
let current_receiver = state.get("leader_receiver");
|
|
533
550
|
let frozen_receipt = ops.frozen_seed_receipt();
|
|
534
|
-
let fresh_seeded_binding =
|
|
535
|
-
|
|
536
|
-
state
|
|
537
|
-
.get("team_owner")
|
|
538
|
-
.is_some_and(|current| current == frozen_owner)
|
|
539
|
-
&& current_receiver.is_some_and(|current| current == frozen_receiver)
|
|
540
|
-
} else {
|
|
541
|
-
let pending_seed_receiver = current_receiver.is_some_and(|receiver| {
|
|
542
|
-
receiver.get("status").and_then(serde_json::Value::as_str) == Some("pending")
|
|
543
|
-
&& receiver
|
|
544
|
-
.get("discovery")
|
|
545
|
-
.and_then(serde_json::Value::as_str)
|
|
546
|
-
== Some("quick_start_seed")
|
|
547
|
-
});
|
|
548
|
-
seeded_owner.is_some_and(|seed| {
|
|
551
|
+
let fresh_seeded_binding =
|
|
552
|
+
if let Some((frozen_owner, frozen_receiver)) = frozen_receipt.as_ref() {
|
|
549
553
|
state
|
|
550
554
|
.get("team_owner")
|
|
551
|
-
.is_some_and(|current| current ==
|
|
552
|
-
&&
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
+
.is_some_and(|current| current == frozen_owner)
|
|
556
|
+
&& current_receiver.is_some_and(|current| current == frozen_receiver)
|
|
557
|
+
} else {
|
|
558
|
+
let pending_seed_receiver = current_receiver.is_some_and(|receiver| {
|
|
559
|
+
receiver.get("status").and_then(serde_json::Value::as_str) == Some("pending")
|
|
560
|
+
&& receiver
|
|
561
|
+
.get("discovery")
|
|
562
|
+
.and_then(serde_json::Value::as_str)
|
|
563
|
+
== Some("quick_start_seed")
|
|
564
|
+
});
|
|
565
|
+
seeded_owner.is_some_and(|seed| {
|
|
566
|
+
state
|
|
567
|
+
.get("team_owner")
|
|
568
|
+
.is_some_and(|current| current == seed)
|
|
569
|
+
&& pending_seed_receiver
|
|
570
|
+
})
|
|
571
|
+
};
|
|
555
572
|
let seeded_receiver = frozen_receipt
|
|
556
573
|
.as_ref()
|
|
557
574
|
.map(|(_, receiver)| receiver.clone())
|
|
@@ -702,9 +719,9 @@ fn combine_rollback_errors(
|
|
|
702
719
|
match (state, registry) {
|
|
703
720
|
(None, None) => None,
|
|
704
721
|
(Some(error), None) | (None, Some(error)) => Some(error),
|
|
705
|
-
(Some(state), Some(registry)) =>
|
|
706
|
-
"{state}; {registry}"
|
|
707
|
-
|
|
722
|
+
(Some(state), Some(registry)) => {
|
|
723
|
+
Some(LifecycleError::StatePersist(format!("{state}; {registry}")))
|
|
724
|
+
}
|
|
708
725
|
}
|
|
709
726
|
}
|
|
710
727
|
|
|
@@ -738,9 +755,11 @@ fn restore_this_attempt_state(
|
|
|
738
755
|
});
|
|
739
756
|
};
|
|
740
757
|
let previous = match snapshot.map(|snapshot| snapshot.bytes.as_deref()) {
|
|
741
|
-
Some(Some(bytes)) =>
|
|
742
|
-
|
|
743
|
-
|
|
758
|
+
Some(Some(bytes)) => {
|
|
759
|
+
serde_json::from_slice::<serde_json::Value>(bytes).map_err(|error| {
|
|
760
|
+
LifecycleError::StatePersist(format!("quick-start binding cleanup failed: {error}"))
|
|
761
|
+
})?
|
|
762
|
+
}
|
|
744
763
|
Some(None) | None => serde_json::Value::Null,
|
|
745
764
|
};
|
|
746
765
|
let previous_owner = previous
|
|
@@ -860,9 +879,9 @@ fn clear_fresh_binding_on_refusal(
|
|
|
860
879
|
};
|
|
861
880
|
crate::state::repository::StateRepository::new(workspace)
|
|
862
881
|
.save(intent, state)
|
|
863
|
-
.map_err(|error|
|
|
864
|
-
"quick-start binding cleanup failed: {error}"
|
|
865
|
-
)
|
|
882
|
+
.map_err(|error| {
|
|
883
|
+
LifecycleError::StatePersist(format!("quick-start binding cleanup failed: {error}"))
|
|
884
|
+
})
|
|
866
885
|
}
|
|
867
886
|
|
|
868
887
|
fn existing_runtime_identity_view(
|
|
@@ -888,16 +907,14 @@ fn caller_read_transport() -> Option<Box<dyn Transport>> {
|
|
|
888
907
|
if !Path::new(&endpoint).exists() {
|
|
889
908
|
return None;
|
|
890
909
|
}
|
|
891
|
-
Some(Box::new(
|
|
892
|
-
&endpoint,
|
|
893
|
-
))
|
|
910
|
+
Some(Box::new(
|
|
911
|
+
crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&endpoint),
|
|
912
|
+
))
|
|
894
913
|
}
|
|
895
914
|
|
|
896
915
|
fn query_caller_pane_command(transport: &dyn Transport, pane: &PaneId) -> Option<String> {
|
|
897
916
|
let caller_transport = caller_read_transport();
|
|
898
|
-
let query_transport: &dyn Transport = caller_transport
|
|
899
|
-
.as_deref()
|
|
900
|
-
.unwrap_or(transport);
|
|
917
|
+
let query_transport: &dyn Transport = caller_transport.as_deref().unwrap_or(transport);
|
|
901
918
|
query_transport
|
|
902
919
|
.query(&Target::Pane(pane.clone()), PaneField::PaneCurrentCommand)
|
|
903
920
|
.ok()
|
|
@@ -940,8 +957,8 @@ fn preflight_fresh_leader_identity(
|
|
|
940
957
|
let Some(pane) = pane else {
|
|
941
958
|
return None;
|
|
942
959
|
};
|
|
943
|
-
let command =
|
|
944
|
-
.unwrap_or_default();
|
|
960
|
+
let command =
|
|
961
|
+
query_caller_pane_command(transport, &PaneId::new(pane)).unwrap_or_default();
|
|
945
962
|
let explicit = std::env::var("TEAM_AGENT_LEADER_PROVIDER")
|
|
946
963
|
.ok()
|
|
947
964
|
.filter(|provider| !provider.is_empty());
|
|
@@ -949,16 +966,11 @@ fn preflight_fresh_leader_identity(
|
|
|
949
966
|
return Some((
|
|
950
967
|
"quick-start refused before spawn: command unobservable".to_string(),
|
|
951
968
|
vec!["stage=command_observation reason=command_unobservable".to_string()],
|
|
952
|
-
vec![
|
|
953
|
-
"run from a live provider pane; do not run claim-leader".to_string(),
|
|
954
|
-
],
|
|
969
|
+
vec!["run from a live provider pane; do not run claim-leader".to_string()],
|
|
955
970
|
));
|
|
956
971
|
}
|
|
957
|
-
if crate::leader::owner_bind::strict_owner_bind_provider(
|
|
958
|
-
|
|
959
|
-
&command,
|
|
960
|
-
)
|
|
961
|
-
.is_some()
|
|
972
|
+
if crate::leader::owner_bind::strict_owner_bind_provider(explicit.as_deref(), &command)
|
|
973
|
+
.is_some()
|
|
962
974
|
{
|
|
963
975
|
None
|
|
964
976
|
} else {
|
|
@@ -1078,12 +1090,7 @@ pub fn quick_start_in_workspace_with_backend(
|
|
|
1078
1090
|
if is_tmux_or_default {
|
|
1079
1091
|
let transport = quick_start_tmux_backend(&workspace);
|
|
1080
1092
|
return quick_start_with_transport_in_workspace(
|
|
1081
|
-
&workspace,
|
|
1082
|
-
agents_dir,
|
|
1083
|
-
name,
|
|
1084
|
-
yes,
|
|
1085
|
-
team_id,
|
|
1086
|
-
&transport,
|
|
1093
|
+
&workspace, agents_dir, name, yes, team_id, &transport,
|
|
1087
1094
|
);
|
|
1088
1095
|
}
|
|
1089
1096
|
// Explicit non-tmux backend: route through the factory. Parse the
|
|
@@ -1256,6 +1263,32 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
|
|
|
1256
1263
|
)));
|
|
1257
1264
|
}
|
|
1258
1265
|
crate::lifecycle::launch::run_pi_catalog_preflight(agents_dir, discover)?;
|
|
1266
|
+
#[cfg(unix)]
|
|
1267
|
+
{
|
|
1268
|
+
let input = crate::cli::QuickStartArgs {
|
|
1269
|
+
workspace: workspace.into(),
|
|
1270
|
+
agents_dir: agents_dir.into(),
|
|
1271
|
+
name: name.map(str::to_owned),
|
|
1272
|
+
team_id: team_id.map(str::to_owned),
|
|
1273
|
+
yes: false,
|
|
1274
|
+
json: false,
|
|
1275
|
+
detail: false,
|
|
1276
|
+
backend: Some(
|
|
1277
|
+
if matches!(transport.kind(), crate::transport::BackendKind::Tmux) {
|
|
1278
|
+
"tmux"
|
|
1279
|
+
} else {
|
|
1280
|
+
"conpty"
|
|
1281
|
+
}
|
|
1282
|
+
.into(),
|
|
1283
|
+
),
|
|
1284
|
+
};
|
|
1285
|
+
if let Some(config) = crate::contract_runtime::config::read_team(&input)
|
|
1286
|
+
.map_err(|error| LifecycleError::Compile(error.to_string()))?
|
|
1287
|
+
{
|
|
1288
|
+
crate::contract_runtime::backend::preflight(&config)
|
|
1289
|
+
.map_err(|error| LifecycleError::Provider(error.to_string()))?;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1259
1292
|
let workspace = workspace.to_path_buf();
|
|
1260
1293
|
let mut spec = crate::compiler::compile_team(agents_dir)
|
|
1261
1294
|
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
@@ -1286,6 +1319,7 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
|
|
|
1286
1319
|
)));
|
|
1287
1320
|
}
|
|
1288
1321
|
let state_path = crate::state::persist::runtime_state_path(&workspace);
|
|
1322
|
+
let mut leader_bootstrap = None;
|
|
1289
1323
|
if state_path.exists() {
|
|
1290
1324
|
let state = crate::state::persist::load_runtime_state(&workspace)
|
|
1291
1325
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
@@ -1308,7 +1342,9 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
|
|
|
1308
1342
|
.get("tmux_endpoint")
|
|
1309
1343
|
.and_then(serde_json::Value::as_str)
|
|
1310
1344
|
.or_else(|| {
|
|
1311
|
-
identity
|
|
1345
|
+
identity
|
|
1346
|
+
.get("tmux_socket")
|
|
1347
|
+
.and_then(serde_json::Value::as_str)
|
|
1312
1348
|
})
|
|
1313
1349
|
.or_else(|| {
|
|
1314
1350
|
state
|
|
@@ -1363,6 +1399,16 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
|
|
|
1363
1399
|
agent_ids,
|
|
1364
1400
|
});
|
|
1365
1401
|
}
|
|
1402
|
+
let entry = state
|
|
1403
|
+
.get("teams")
|
|
1404
|
+
.and_then(serde_json::Value::as_object)
|
|
1405
|
+
.and_then(|teams| teams.get(&canonical_team_key))
|
|
1406
|
+
.unwrap_or(&state);
|
|
1407
|
+
if is_quick_start_leader_bootstrap(entry) {
|
|
1408
|
+
let mut bootstrap = entry.clone();
|
|
1409
|
+
bootstrap["team_key"] = serde_json::json!(canonical_team_key);
|
|
1410
|
+
leader_bootstrap = Some(bootstrap);
|
|
1411
|
+
}
|
|
1366
1412
|
}
|
|
1367
1413
|
// CR-040/042: repeated quick-start from one template with distinct --team-id/--name
|
|
1368
1414
|
// must NOT collide on the template-derived tmux session. Override the compiled
|
|
@@ -1384,12 +1430,20 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
|
|
|
1384
1430
|
attach_commands: Vec::new(),
|
|
1385
1431
|
});
|
|
1386
1432
|
}
|
|
1433
|
+
if let Some(bootstrap) = &leader_bootstrap {
|
|
1434
|
+
// Allow initialization, not takeover of the existing leader binding.
|
|
1435
|
+
ensure_owner_allowed_for_state(bootstrap, None)?;
|
|
1436
|
+
}
|
|
1387
1437
|
// E5 spec 迁移:spec 写到 .team/runtime/<team_key>/(中间产物,绝不落用户目录 agents_dir)。
|
|
1388
1438
|
// Bug2:原子写(tmp+rename),避免半截 spec。
|
|
1389
1439
|
let spec_path = crate::model::paths::runtime_spec_path(&workspace, &state_team_key);
|
|
1390
1440
|
write_spec_atomic(&spec_path, &spec)?;
|
|
1391
|
-
let _store = crate::message_store::MessageStore::open(&workspace)
|
|
1392
|
-
|
|
1441
|
+
let _store = crate::message_store::MessageStore::open(&workspace).map_err(|error| {
|
|
1442
|
+
LifecycleError::StatePersist(format!(
|
|
1443
|
+
"database initialization failed at {}: {error}; coordinator start was not attempted",
|
|
1444
|
+
workspace.join(".team/team.db").display()
|
|
1445
|
+
))
|
|
1446
|
+
})?;
|
|
1393
1447
|
let resolved_spec_path =
|
|
1394
1448
|
std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
|
|
1395
1449
|
let scoped_endpoint = transport.tmux_endpoint();
|
|
@@ -1401,6 +1455,9 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
|
|
|
1401
1455
|
&state_team_key,
|
|
1402
1456
|
scoped_endpoint.as_deref(),
|
|
1403
1457
|
);
|
|
1458
|
+
if let Some(bootstrap) = &leader_bootstrap {
|
|
1459
|
+
preserve_quick_start_bootstrap_binding(&mut state, bootstrap, &state_team_key);
|
|
1460
|
+
}
|
|
1404
1461
|
// Keep this attempt-local seed separate from persisted `claimed_via`;
|
|
1405
1462
|
// historical quick-start rows are never sufficient cleanup authority.
|
|
1406
1463
|
let seeded_owner = state
|
|
@@ -1409,10 +1466,7 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
|
|
|
1409
1466
|
.and_then(|teams| teams.get(&state_team_key))
|
|
1410
1467
|
.and_then(|team| team.get("team_owner"))
|
|
1411
1468
|
.filter(|owner| {
|
|
1412
|
-
owner
|
|
1413
|
-
.get("claimed_via")
|
|
1414
|
-
.and_then(serde_json::Value::as_str)
|
|
1415
|
-
== Some("quick-start")
|
|
1469
|
+
owner.get("claimed_via").and_then(serde_json::Value::as_str) == Some("quick-start")
|
|
1416
1470
|
})
|
|
1417
1471
|
.cloned();
|
|
1418
1472
|
let seeded_receiver = state
|
|
@@ -1475,15 +1529,21 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
|
|
|
1475
1529
|
launch.session_capture_incomplete_agents =
|
|
1476
1530
|
quick_start_session_capture_incomplete_agents(&workspace, &state_team_key);
|
|
1477
1531
|
let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.clone());
|
|
1478
|
-
let
|
|
1479
|
-
.
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1532
|
+
let coordinator_report = crate::coordinator::start_coordinator(&coordinator_workspace)
|
|
1533
|
+
.map_err(|error| {
|
|
1534
|
+
LifecycleError::StatePersist(format!(
|
|
1535
|
+
"coordinator start failed for {}: {error}; inspect {} before retrying",
|
|
1536
|
+
workspace.display(),
|
|
1537
|
+
crate::coordinator::coordinator_log_path(&coordinator_workspace).display()
|
|
1538
|
+
))
|
|
1539
|
+
})?;
|
|
1540
|
+
quick_start_phase_timer.emit(
|
|
1541
|
+
&workspace,
|
|
1542
|
+
"launch.phase",
|
|
1543
|
+
crate::lifecycle::restart::LifecyclePhase::CoordinatorStart,
|
|
1544
|
+
);
|
|
1545
|
+
require_quick_start_coordinator_started(&coordinator_report, &workspace)?;
|
|
1546
|
+
let coordinator_action = "coordinator started";
|
|
1487
1547
|
// BUG-7: build an honest readiness verdict from the post-spawn runtime state.
|
|
1488
1548
|
// - If persist_spawn_agent_state (BUG-2 fix) marked any agent non-running, the
|
|
1489
1549
|
// team is observably Degraded.
|
|
@@ -1491,7 +1551,11 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
|
|
|
1491
1551
|
// loaded successfully (provider-side codex/claude schema rejections happen
|
|
1492
1552
|
// asynchronously after spawn), so the verdict is PendingToolLoad — never
|
|
1493
1553
|
// bare Ready.
|
|
1494
|
-
quick_start_phase_timer.emit(
|
|
1554
|
+
quick_start_phase_timer.emit(
|
|
1555
|
+
&workspace,
|
|
1556
|
+
"launch.phase",
|
|
1557
|
+
crate::lifecycle::restart::LifecyclePhase::ReadinessWait,
|
|
1558
|
+
);
|
|
1495
1559
|
let worker_readiness = quick_start_worker_readiness(&workspace, &state_team_key);
|
|
1496
1560
|
let attach_windows = load_runtime_state(&workspace)
|
|
1497
1561
|
.ok()
|
|
@@ -1531,7 +1595,11 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
|
|
|
1531
1595
|
));
|
|
1532
1596
|
}
|
|
1533
1597
|
next_actions.extend(attach_commands.iter().cloned());
|
|
1534
|
-
quick_start_phase_timer.emit(
|
|
1598
|
+
quick_start_phase_timer.emit(
|
|
1599
|
+
&workspace,
|
|
1600
|
+
"launch.phase",
|
|
1601
|
+
crate::lifecycle::restart::LifecyclePhase::Completed,
|
|
1602
|
+
);
|
|
1535
1603
|
Ok(QuickStartReport::Ready {
|
|
1536
1604
|
session_name,
|
|
1537
1605
|
launch: Box::new(launch),
|
|
@@ -1542,8 +1610,80 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
|
|
|
1542
1610
|
})
|
|
1543
1611
|
}
|
|
1544
1612
|
|
|
1613
|
+
fn preserve_quick_start_bootstrap_binding(
|
|
1614
|
+
state: &mut serde_json::Value,
|
|
1615
|
+
bootstrap: &serde_json::Value,
|
|
1616
|
+
team_key: &str,
|
|
1617
|
+
) {
|
|
1618
|
+
let mut binding = crate::state::ownership::OwnershipWrite::new();
|
|
1619
|
+
if let Some(owner) = bootstrap.get("team_owner") {
|
|
1620
|
+
binding = binding.with_team_owner(owner.clone());
|
|
1621
|
+
}
|
|
1622
|
+
if let Some(receiver) = bootstrap.get("leader_receiver") {
|
|
1623
|
+
binding = binding.with_leader_receiver(receiver.clone());
|
|
1624
|
+
}
|
|
1625
|
+
if let Some(epoch) = bootstrap
|
|
1626
|
+
.get("owner_epoch")
|
|
1627
|
+
.and_then(serde_json::Value::as_u64)
|
|
1628
|
+
.or_else(|| {
|
|
1629
|
+
bootstrap
|
|
1630
|
+
.get("team_owner")
|
|
1631
|
+
.and_then(|owner| owner.get("owner_epoch"))
|
|
1632
|
+
.and_then(serde_json::Value::as_u64)
|
|
1633
|
+
})
|
|
1634
|
+
{
|
|
1635
|
+
binding = binding.with_owner_epoch(epoch);
|
|
1636
|
+
}
|
|
1637
|
+
crate::state::ownership::write_owner(state, team_key, binding);
|
|
1638
|
+
// This is the new launch's transient view, not the persisted bootstrap.
|
|
1639
|
+
// The canonical writer intentionally leaves root fields alone; remove the
|
|
1640
|
+
// provisional epoch-1 seed so save_launched_team_state cannot merge it back
|
|
1641
|
+
// over the preserved canonical binding. Reuse its existing view promotion.
|
|
1642
|
+
if let Some(root) = state.as_object_mut() {
|
|
1643
|
+
for key in ["team_owner", "leader_receiver", "owner_epoch"] {
|
|
1644
|
+
root.remove(key);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
super::state_projection::promote_launched_binding_from_team_entry(state, team_key);
|
|
1648
|
+
for key in ["leader_client", "is_external_leader"] {
|
|
1649
|
+
if let Some(value) = bootstrap.get(key) {
|
|
1650
|
+
state[key] = value.clone();
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
fn require_quick_start_coordinator_started(
|
|
1656
|
+
report: &crate::coordinator::StartReport,
|
|
1657
|
+
workspace: &Path,
|
|
1658
|
+
) -> Result<(), LifecycleError> {
|
|
1659
|
+
if report.ok {
|
|
1660
|
+
return Ok(());
|
|
1661
|
+
}
|
|
1662
|
+
let details = serde_json::json!({
|
|
1663
|
+
"ok": report.ok,
|
|
1664
|
+
"status": report.status,
|
|
1665
|
+
"pid": report.pid,
|
|
1666
|
+
"previous_pid": report.previous_pid,
|
|
1667
|
+
"binary_path": report.binary_path,
|
|
1668
|
+
"binary_version": report.binary_version,
|
|
1669
|
+
"rotation_reason": report.rotation_reason,
|
|
1670
|
+
"binary_identity_relation": report.binary_identity_relation,
|
|
1671
|
+
"log": report.log,
|
|
1672
|
+
"schema_error": report.schema_error.as_ref().map(|error| format!("{error:?}")),
|
|
1673
|
+
"action": report.action,
|
|
1674
|
+
"workspace": workspace,
|
|
1675
|
+
"coordinator_log_path": crate::coordinator::coordinator_log_path(
|
|
1676
|
+
&crate::coordinator::WorkspacePath::new(workspace.to_path_buf())
|
|
1677
|
+
),
|
|
1678
|
+
});
|
|
1679
|
+
Err(LifecycleError::StatePersist(format!(
|
|
1680
|
+
"coordinator start refused: {details}; workers may already be started; inspect the report before retrying quick-start"
|
|
1681
|
+
)))
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1545
1684
|
#[cfg(test)]
|
|
1546
1685
|
mod fresh_quick_start_leader_binding_tests {
|
|
1686
|
+
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
1547
1687
|
use super::*;
|
|
1548
1688
|
use crate::layout::worker_env::{CALLER_ENDPOINT_ENV, CALLER_PANE_ENV, CALLER_PROVIDER_ENV};
|
|
1549
1689
|
use crate::transport::test_support::OfflineTransport;
|
|
@@ -1556,6 +1696,139 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1556
1696
|
|
|
1557
1697
|
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
|
1558
1698
|
|
|
1699
|
+
#[test]
|
|
1700
|
+
fn bootstrap_binding_is_preserved_without_epoch_reset_or_takeover() {
|
|
1701
|
+
let bootstrap = json!({
|
|
1702
|
+
"team_owner": {"pane_id": "%0", "provider": "pi", "owner_epoch": 7, "claimed_via": "claim-leader"},
|
|
1703
|
+
"leader_receiver": {"pane_id": "%0", "status": "attached", "owner_epoch": 7},
|
|
1704
|
+
"owner_epoch": 7,
|
|
1705
|
+
"leader_client": {"diagnostic_only": true},
|
|
1706
|
+
"is_external_leader": true
|
|
1707
|
+
});
|
|
1708
|
+
let mut launched = json!({
|
|
1709
|
+
"team_key": "current", "agents": {"worker": {}},
|
|
1710
|
+
"team_owner": {"owner_epoch": 1, "claimed_via": "quick-start"},
|
|
1711
|
+
"leader_receiver": {"status": "pending"}, "owner_epoch": 1,
|
|
1712
|
+
"is_external_leader": false
|
|
1713
|
+
});
|
|
1714
|
+
preserve_quick_start_bootstrap_binding(&mut launched, &bootstrap, "current");
|
|
1715
|
+
for key in ["team_owner", "leader_receiver", "owner_epoch"] {
|
|
1716
|
+
assert_eq!(launched.get(key), bootstrap.get(key));
|
|
1717
|
+
assert_eq!(launched["teams"]["current"].get(key), bootstrap.get(key));
|
|
1718
|
+
}
|
|
1719
|
+
assert_eq!(
|
|
1720
|
+
launched.get("leader_client"),
|
|
1721
|
+
bootstrap.get("leader_client")
|
|
1722
|
+
);
|
|
1723
|
+
assert_eq!(launched.get("is_external_leader"), Some(&json!(true)));
|
|
1724
|
+
assert_eq!(launched["agents"], json!({"worker": {}}));
|
|
1725
|
+
let merged = super::super::state_projection::merge_workspace_team_state_with_key(
|
|
1726
|
+
&bootstrap, &launched, "current",
|
|
1727
|
+
);
|
|
1728
|
+
assert_eq!(
|
|
1729
|
+
merged["teams"]["current"]["team_owner"],
|
|
1730
|
+
bootstrap["team_owner"]
|
|
1731
|
+
);
|
|
1732
|
+
assert_eq!(merged["teams"]["current"]["owner_epoch"], json!(7));
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
#[test]
|
|
1736
|
+
fn coordinator_negative_report_retains_actual_details_and_does_not_become_ready() {
|
|
1737
|
+
let mut report = crate::coordinator::StartReport {
|
|
1738
|
+
ok: false,
|
|
1739
|
+
pid: None,
|
|
1740
|
+
status: crate::coordinator::StartOutcome::SchemaIncompatible,
|
|
1741
|
+
previous_pid: Some(crate::coordinator::Pid(17)),
|
|
1742
|
+
binary_path: Some("/tmp/candidate/team-agent".to_string()),
|
|
1743
|
+
binary_version: Some("0.5.112".to_string()),
|
|
1744
|
+
rotation_reason: Some("original rotation reason".to_string()),
|
|
1745
|
+
binary_identity_relation:
|
|
1746
|
+
crate::coordinator::CoordinatorBinaryIdentityRelation::Unknown,
|
|
1747
|
+
log: Some(PathBuf::from("/tmp/current/.team/runtime/coordinator.log")),
|
|
1748
|
+
schema_error: Some(crate::coordinator::SchemaError::InitFailed {
|
|
1749
|
+
message: "original database permission denied".to_string(),
|
|
1750
|
+
}),
|
|
1751
|
+
action: Some("repair the exact database permissions".to_string()),
|
|
1752
|
+
};
|
|
1753
|
+
let error = require_quick_start_coordinator_started(&report, Path::new("/tmp/current"))
|
|
1754
|
+
.unwrap_err()
|
|
1755
|
+
.to_string();
|
|
1756
|
+
for detail in [
|
|
1757
|
+
"schema_incompatible",
|
|
1758
|
+
"original database permission denied",
|
|
1759
|
+
"repair the exact database permissions",
|
|
1760
|
+
"/tmp/candidate/team-agent",
|
|
1761
|
+
"/tmp/current/.team/runtime/coordinator.log",
|
|
1762
|
+
"workers may already be started",
|
|
1763
|
+
] {
|
|
1764
|
+
assert!(error.contains(detail), "lost {detail}: {error}");
|
|
1765
|
+
}
|
|
1766
|
+
report.ok = true;
|
|
1767
|
+
report.status = crate::coordinator::StartOutcome::AlreadyRunning;
|
|
1768
|
+
report.binary_identity_relation =
|
|
1769
|
+
crate::coordinator::CoordinatorBinaryIdentityRelation::DaemonNewerThanCaller;
|
|
1770
|
+
assert!(
|
|
1771
|
+
require_quick_start_coordinator_started(&report, Path::new("/tmp/current")).is_ok()
|
|
1772
|
+
);
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
#[test]
|
|
1776
|
+
#[serial_test::serial(env)]
|
|
1777
|
+
fn same_current_leader_bootstrap_reaches_fresh_preflight_without_mutating_seed() {
|
|
1778
|
+
let env = HermeticTestEnv::enter("current-bootstrap-preflight");
|
|
1779
|
+
env.scrub_tmux();
|
|
1780
|
+
env.assert_no_real_tmux();
|
|
1781
|
+
let workspace = std::env::temp_dir().join(format!(
|
|
1782
|
+
"ta-current-bootstrap-{}-{}",
|
|
1783
|
+
std::process::id(),
|
|
1784
|
+
SEQUENCE.fetch_add(1, Ordering::Relaxed)
|
|
1785
|
+
));
|
|
1786
|
+
let team = workspace.join(".team/current");
|
|
1787
|
+
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
1788
|
+
std::fs::write(
|
|
1789
|
+
team.join("TEAM.md"),
|
|
1790
|
+
"---\nname: current\nobjective: Bootstrap fixture.\n---\n",
|
|
1791
|
+
)
|
|
1792
|
+
.unwrap();
|
|
1793
|
+
std::fs::write(team.join("agents/worker.md"), "---\nname: worker\nrole: Worker\nprovider: codex\nauth_mode: subscription\ndangerously_skip_permissions: false\n---\nBounded fixture.\n").unwrap();
|
|
1794
|
+
let state_path = crate::state::persist::runtime_state_path(&workspace);
|
|
1795
|
+
std::fs::create_dir_all(state_path.parent().unwrap()).unwrap();
|
|
1796
|
+
let state = json!({
|
|
1797
|
+
"active_team_key": "current", "team_key": "current", "tasks": [],
|
|
1798
|
+
"teams": {"current": {
|
|
1799
|
+
"agents": {}, "tasks": [], "session_name": null,
|
|
1800
|
+
"team_owner": {"pane_id": "%0", "provider": "pi", "owner_epoch": 1},
|
|
1801
|
+
"leader_receiver": {"pane_id": "%0", "status": "attached"}
|
|
1802
|
+
}}
|
|
1803
|
+
});
|
|
1804
|
+
std::fs::write(&state_path, serde_json::to_vec(&state).unwrap()).unwrap();
|
|
1805
|
+
let before = std::fs::read(&state_path).unwrap();
|
|
1806
|
+
// The real entrypoint must reach its existing strict identity preflight,
|
|
1807
|
+
// not return ExistingRuntime merely because the launcher registered current.
|
|
1808
|
+
std::env::set_var("TMUX", "/private/tmp/bootstrap-test/default,1,0");
|
|
1809
|
+
std::env::set_var("TMUX_PANE", "%0");
|
|
1810
|
+
std::env::set_var(CALLER_PANE_ENV, "%0");
|
|
1811
|
+
std::env::set_var(CALLER_PROVIDER_ENV, "pi");
|
|
1812
|
+
std::env::set_var(
|
|
1813
|
+
CALLER_ENDPOINT_ENV,
|
|
1814
|
+
"/private/tmp/different-bootstrap/default",
|
|
1815
|
+
);
|
|
1816
|
+
let transport =
|
|
1817
|
+
OfflineTransport::new().with_tmux_endpoint("/private/tmp/bootstrap-test/default");
|
|
1818
|
+
let result = quick_start_with_transport_in_workspace(
|
|
1819
|
+
&workspace, &team, None, true, None, &transport,
|
|
1820
|
+
)
|
|
1821
|
+
.unwrap();
|
|
1822
|
+
assert!(
|
|
1823
|
+
matches!(result, QuickStartReport::PreflightBlocked { .. }),
|
|
1824
|
+
"unexpected outcome: {result:?}"
|
|
1825
|
+
);
|
|
1826
|
+
assert_eq!(std::fs::read(&state_path).unwrap(), before);
|
|
1827
|
+
assert!(!workspace.join(".team/team.db").exists());
|
|
1828
|
+
assert!(!crate::model::paths::runtime_spec_path(&workspace, "current").exists());
|
|
1829
|
+
std::fs::remove_dir_all(&workspace).unwrap();
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1559
1832
|
fn workspace_with_provider(tag: &str, provider: &str) -> PathBuf {
|
|
1560
1833
|
let path = std::env::temp_dir().join(format!(
|
|
1561
1834
|
"ta-quick-start-bind-{tag}-{}-{}",
|
|
@@ -1719,10 +1992,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1719
1992
|
)
|
|
1720
1993
|
}
|
|
1721
1994
|
_ => crate::state::persist::save_runtime_state_with_receiver_authority(
|
|
1722
|
-
workspace,
|
|
1723
|
-
state,
|
|
1724
|
-
&team_key,
|
|
1725
|
-
None,
|
|
1995
|
+
workspace, state, &team_key, None,
|
|
1726
1996
|
),
|
|
1727
1997
|
};
|
|
1728
1998
|
if saved.is_ok() {
|
|
@@ -1738,9 +2008,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1738
2008
|
}
|
|
1739
2009
|
|
|
1740
2010
|
fn frozen_seed_receipt(&self) -> Option<(serde_json::Value, serde_json::Value)> {
|
|
1741
|
-
self.frozen_owner
|
|
1742
|
-
.clone()
|
|
1743
|
-
.zip(self.frozen_receiver.clone())
|
|
2011
|
+
self.frozen_owner.clone().zip(self.frozen_receiver.clone())
|
|
1744
2012
|
}
|
|
1745
2013
|
|
|
1746
2014
|
fn this_attempt_extra_state_keys(&self) -> Vec<&'static str> {
|
|
@@ -1787,12 +2055,15 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1787
2055
|
}
|
|
1788
2056
|
|
|
1789
2057
|
#[test]
|
|
1790
|
-
fn fresh_binding_persists_then_registers_then_requires_canonical_readback_provider_independent()
|
|
2058
|
+
fn fresh_binding_persists_then_registers_then_requires_canonical_readback_provider_independent()
|
|
2059
|
+
{
|
|
1791
2060
|
let workspace = workspace("positive");
|
|
1792
2061
|
let mut ops = MockOps::default();
|
|
1793
|
-
assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
1794
|
-
|
|
1795
|
-
|
|
2062
|
+
assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
|
|
2063
|
+
assert_eq!(
|
|
2064
|
+
ops.attached_provider,
|
|
2065
|
+
Some(crate::provider::Provider::Codex)
|
|
2066
|
+
);
|
|
1796
2067
|
assert_eq!(ops.attach_calls, 1);
|
|
1797
2068
|
assert_eq!(ops.register_calls, 1);
|
|
1798
2069
|
assert_eq!(ops.readback_calls, 1);
|
|
@@ -1827,11 +2098,13 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1827
2098
|
leader_session_uuid: "uuid-fresh".to_string(),
|
|
1828
2099
|
leader_session_uuid_source: "env".to_string(),
|
|
1829
2100
|
};
|
|
1830
|
-
assert!(
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
2101
|
+
assert!(
|
|
2102
|
+
super::spec_state::seed_launched_owner_from_caller_with_provider_lookup(
|
|
2103
|
+
&mut state,
|
|
2104
|
+
caller,
|
|
2105
|
+
|_| None
|
|
2106
|
+
)
|
|
2107
|
+
);
|
|
1835
2108
|
let owner = state
|
|
1836
2109
|
.pointer("/teams/fresh/team_owner")
|
|
1837
2110
|
.cloned()
|
|
@@ -1858,13 +2131,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1858
2131
|
frozen_receiver: Some(frozen_receiver.clone()),
|
|
1859
2132
|
..MockOps::default()
|
|
1860
2133
|
};
|
|
1861
|
-
assert!(
|
|
1862
|
-
&workspace,
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
&mut ops
|
|
1866
|
-
)
|
|
1867
|
-
.unwrap());
|
|
2134
|
+
assert!(
|
|
2135
|
+
bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&owner), &mut ops)
|
|
2136
|
+
.unwrap()
|
|
2137
|
+
);
|
|
1868
2138
|
assert_eq!(ops.attach_calls, 1);
|
|
1869
2139
|
assert_eq!(ops.last_expected_receiver, Some(frozen_receiver));
|
|
1870
2140
|
let _ = std::fs::remove_dir_all(&workspace);
|
|
@@ -1881,10 +2151,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1881
2151
|
state["leader_receiver"] = newer.clone();
|
|
1882
2152
|
state["teams"]["fresh"]["leader_receiver"] = newer.clone();
|
|
1883
2153
|
crate::state::persist::save_runtime_state_with_receiver_authority(
|
|
1884
|
-
&workspace,
|
|
1885
|
-
&state,
|
|
1886
|
-
"fresh",
|
|
1887
|
-
None,
|
|
2154
|
+
&workspace, &state, "fresh", None,
|
|
1888
2155
|
)
|
|
1889
2156
|
.unwrap();
|
|
1890
2157
|
let on_disk = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
@@ -1901,13 +2168,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1901
2168
|
frozen_receiver: Some(frozen_receiver),
|
|
1902
2169
|
..MockOps::default()
|
|
1903
2170
|
};
|
|
1904
|
-
assert!(
|
|
1905
|
-
&workspace,
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
&mut ops
|
|
1909
|
-
)
|
|
1910
|
-
.unwrap());
|
|
2171
|
+
assert!(
|
|
2172
|
+
!bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&owner), &mut ops)
|
|
2173
|
+
.unwrap()
|
|
2174
|
+
);
|
|
1911
2175
|
assert_eq!(ops.attach_calls, 0);
|
|
1912
2176
|
assert_eq!(ops.last_expected_receiver, None);
|
|
1913
2177
|
let _ = std::fs::remove_dir_all(&workspace);
|
|
@@ -1924,10 +2188,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1924
2188
|
state["leader_receiver"] = winner.clone();
|
|
1925
2189
|
state["teams"]["fresh"]["leader_receiver"] = winner.clone();
|
|
1926
2190
|
crate::state::persist::save_runtime_state_with_receiver_authority(
|
|
1927
|
-
&workspace,
|
|
1928
|
-
&state,
|
|
1929
|
-
"fresh",
|
|
1930
|
-
None,
|
|
2191
|
+
&workspace, &state, "fresh", None,
|
|
1931
2192
|
)
|
|
1932
2193
|
.unwrap();
|
|
1933
2194
|
let on_disk = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
@@ -1944,13 +2205,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1944
2205
|
frozen_receiver: Some(frozen_receiver),
|
|
1945
2206
|
..MockOps::default()
|
|
1946
2207
|
};
|
|
1947
|
-
assert!(
|
|
1948
|
-
&workspace,
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
&mut ops
|
|
1952
|
-
)
|
|
1953
|
-
.unwrap());
|
|
2208
|
+
assert!(
|
|
2209
|
+
!bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&owner), &mut ops)
|
|
2210
|
+
.unwrap()
|
|
2211
|
+
);
|
|
1954
2212
|
assert_eq!(ops.attach_calls, 0);
|
|
1955
2213
|
assert_eq!(ops.last_expected_receiver, None);
|
|
1956
2214
|
let _ = std::fs::remove_dir_all(&workspace);
|
|
@@ -1960,7 +2218,11 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1960
2218
|
fn fresh_bind_refusal_event_keeps_first_safe_stage_and_reason() {
|
|
1961
2219
|
for (case, expected_stage, expected_reason) in [
|
|
1962
2220
|
("missing_pane", "caller_pane", "caller_pane_missing"),
|
|
1963
|
-
(
|
|
2221
|
+
(
|
|
2222
|
+
"empty_command",
|
|
2223
|
+
"command_observation",
|
|
2224
|
+
"command_unobservable",
|
|
2225
|
+
),
|
|
1964
2226
|
("unknown_provider", "strict_provider", "provider_unresolved"),
|
|
1965
2227
|
(
|
|
1966
2228
|
"existing_owner",
|
|
@@ -1997,12 +2259,16 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1997
2259
|
}
|
|
1998
2260
|
crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
|
|
1999
2261
|
|
|
2000
|
-
assert!(
|
|
2001
|
-
.unwrap()
|
|
2262
|
+
assert!(
|
|
2263
|
+
!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap()
|
|
2264
|
+
);
|
|
2002
2265
|
let events = crate::event_log::EventLog::new(&workspace).tail(0).unwrap();
|
|
2003
2266
|
assert_eq!(events.len(), 1, "{case} must emit one first-refusal event");
|
|
2004
2267
|
assert_eq!(events[0]["event"], json!(FRESH_BIND_REFUSAL_EVENT));
|
|
2005
|
-
assert_eq!(
|
|
2268
|
+
assert_eq!(
|
|
2269
|
+
events[0]["operation"],
|
|
2270
|
+
json!("fresh_quick_start_leader_bind")
|
|
2271
|
+
);
|
|
2006
2272
|
assert_eq!(events[0]["team_key"], json!("fresh"));
|
|
2007
2273
|
assert_eq!(events[0]["stage"], json!(expected_stage));
|
|
2008
2274
|
assert_eq!(events[0]["reason"], json!(expected_reason));
|
|
@@ -2020,9 +2286,12 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2020
2286
|
));
|
|
2021
2287
|
std::fs::create_dir_all(&empty_workspace).unwrap();
|
|
2022
2288
|
let mut ops = MockOps::default();
|
|
2023
|
-
assert!(
|
|
2024
|
-
.unwrap()
|
|
2025
|
-
|
|
2289
|
+
assert!(
|
|
2290
|
+
!bind_fresh_quick_start_leader_with(&empty_workspace, "fresh", None, &mut ops).unwrap()
|
|
2291
|
+
);
|
|
2292
|
+
let events = crate::event_log::EventLog::new(&empty_workspace)
|
|
2293
|
+
.tail(0)
|
|
2294
|
+
.unwrap();
|
|
2026
2295
|
assert_eq!(events.len(), 1);
|
|
2027
2296
|
assert_eq!(events[0]["stage"], json!("scope"));
|
|
2028
2297
|
assert_eq!(events[0]["reason"], json!("scope_unresolved"));
|
|
@@ -2037,8 +2306,9 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2037
2306
|
state["teams"] = json!({"fresh": {"team_key": "fresh", "agents": {}}});
|
|
2038
2307
|
crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
|
|
2039
2308
|
let mut ops = MockOps::default();
|
|
2040
|
-
assert!(
|
|
2041
|
-
.unwrap()
|
|
2309
|
+
assert!(
|
|
2310
|
+
!bind_fresh_quick_start_leader_with(&workspace, "current", None, &mut ops).unwrap()
|
|
2311
|
+
);
|
|
2042
2312
|
let events = crate::event_log::EventLog::new(&workspace).tail(0).unwrap();
|
|
2043
2313
|
assert_eq!(events.len(), 1);
|
|
2044
2314
|
assert_eq!(events[0]["stage"], json!("scope"));
|
|
@@ -2082,10 +2352,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2082
2352
|
ops.command = Some("codex".to_string());
|
|
2083
2353
|
}
|
|
2084
2354
|
"existing_owner" => state["team_owner"] = json!({"pane_id": "%old"}),
|
|
2085
|
-
"existing_receiver" => {
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2355
|
+
"existing_receiver" => state["leader_receiver"] = json!({"pane_id": "%old"}),
|
|
2356
|
+
"provider_mismatch"
|
|
2357
|
+
| "socket_mismatch"
|
|
2358
|
+
| "workspace_mismatch"
|
|
2089
2359
|
| "dual_state_mismatch" => {
|
|
2090
2360
|
state["workspace"] = json!(workspace);
|
|
2091
2361
|
state["team_owner"] = json!({
|
|
@@ -2100,8 +2370,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2100
2370
|
match case {
|
|
2101
2371
|
"provider_mismatch" => {
|
|
2102
2372
|
state["leader_receiver"]["provider"] = json!("claude")
|
|
2103
|
-
}
|
|
2104
|
-
"socket_mismatch" =>
|
|
2373
|
+
}
|
|
2374
|
+
"socket_mismatch" => {
|
|
2375
|
+
state["leader_receiver"]["tmux_socket"] = json!("/tmp/other")
|
|
2376
|
+
}
|
|
2105
2377
|
"workspace_mismatch" => state["workspace"] = json!(workspace.join("other")),
|
|
2106
2378
|
"dual_state_mismatch" => state["team_owner"]["owner_epoch"] = json!(2),
|
|
2107
2379
|
_ => unreachable!(),
|
|
@@ -2111,11 +2383,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2111
2383
|
_ => unreachable!(),
|
|
2112
2384
|
}
|
|
2113
2385
|
crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
|
|
2114
|
-
let before =
|
|
2115
|
-
.unwrap();
|
|
2386
|
+
let before =
|
|
2387
|
+
std::fs::read(crate::state::persist::runtime_state_path(&workspace)).unwrap();
|
|
2116
2388
|
assert!(
|
|
2117
|
-
!bind_fresh_quick_start_leader_with(&workspace, team_key, None, &mut ops)
|
|
2118
|
-
.unwrap(),
|
|
2389
|
+
!bind_fresh_quick_start_leader_with(&workspace, team_key, None, &mut ops).unwrap(),
|
|
2119
2390
|
"{case} must refuse"
|
|
2120
2391
|
);
|
|
2121
2392
|
assert_eq!(
|
|
@@ -2287,15 +2558,23 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2287
2558
|
|| bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
2288
2559
|
)
|
|
2289
2560
|
.unwrap());
|
|
2290
|
-
assert_eq!(
|
|
2291
|
-
|
|
2292
|
-
"
|
|
2293
|
-
|
|
2294
|
-
)
|
|
2561
|
+
assert_eq!(
|
|
2562
|
+
ops.attach_calls, 0,
|
|
2563
|
+
"preseeded dual state must not reattach"
|
|
2564
|
+
);
|
|
2565
|
+
let path = crate::leader::registry::registry_dir()
|
|
2566
|
+
.unwrap()
|
|
2567
|
+
.join(format!(
|
|
2568
|
+
"{}__fresh.json",
|
|
2569
|
+
crate::leader::registry::workspace_hash(&workspace)
|
|
2570
|
+
));
|
|
2295
2571
|
let entry: crate::leader::registry::LeaderRegistryEntry =
|
|
2296
2572
|
serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
|
2297
2573
|
assert_eq!(
|
|
2298
|
-
entry
|
|
2574
|
+
entry
|
|
2575
|
+
.channel
|
|
2576
|
+
.get("tmux_socket")
|
|
2577
|
+
.and_then(serde_json::Value::as_str),
|
|
2299
2578
|
Some("/private/tmp/tmux-test/default")
|
|
2300
2579
|
);
|
|
2301
2580
|
}
|
|
@@ -2325,8 +2604,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2325
2604
|
let before = std::fs::read(crate::state::persist::runtime_state_path(&workspace)).unwrap();
|
|
2326
2605
|
let mut ops = MockOps::default();
|
|
2327
2606
|
|
|
2328
|
-
assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
2329
|
-
.unwrap());
|
|
2607
|
+
assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
|
|
2330
2608
|
assert_eq!(
|
|
2331
2609
|
before,
|
|
2332
2610
|
std::fs::read(crate::state::persist::runtime_state_path(&workspace)).unwrap(),
|
|
@@ -2506,10 +2784,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2506
2784
|
persisted["teams"][team_key]["leader_receiver"] = takeover.receiver.clone();
|
|
2507
2785
|
persisted["teams"][team_key]["owner_epoch"] = json!(takeover.epoch);
|
|
2508
2786
|
crate::state::persist::save_runtime_state_with_receiver_authority(
|
|
2509
|
-
workspace,
|
|
2510
|
-
&persisted,
|
|
2511
|
-
team_key,
|
|
2512
|
-
None,
|
|
2787
|
+
workspace, &persisted, team_key, None,
|
|
2513
2788
|
)
|
|
2514
2789
|
.unwrap();
|
|
2515
2790
|
assert_fresh_binding_kept(
|
|
@@ -2541,18 +2816,13 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2541
2816
|
}
|
|
2542
2817
|
}
|
|
2543
2818
|
|
|
2544
|
-
fn assert_fresh_binding_kept(
|
|
2545
|
-
persisted: &serde_json::Value,
|
|
2546
|
-
takeover: &ConcurrentTakeover,
|
|
2547
|
-
) {
|
|
2819
|
+
fn assert_fresh_binding_kept(persisted: &serde_json::Value, takeover: &ConcurrentTakeover) {
|
|
2548
2820
|
assert_eq!(
|
|
2549
|
-
persisted["teams"]["fresh"]["team_owner"],
|
|
2550
|
-
takeover.owner,
|
|
2821
|
+
persisted["teams"]["fresh"]["team_owner"], takeover.owner,
|
|
2551
2822
|
"concurrent team_owner must survive seeded cleanup"
|
|
2552
2823
|
);
|
|
2553
2824
|
assert_eq!(
|
|
2554
|
-
persisted["teams"]["fresh"]["leader_receiver"],
|
|
2555
|
-
takeover.receiver,
|
|
2825
|
+
persisted["teams"]["fresh"]["leader_receiver"], takeover.receiver,
|
|
2556
2826
|
"concurrent leader_receiver must survive seeded cleanup"
|
|
2557
2827
|
);
|
|
2558
2828
|
assert_eq!(
|
|
@@ -2566,13 +2836,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2566
2836
|
fn seeded_cleanup_clears_exact_seed_through_bind_repository_entry() {
|
|
2567
2837
|
let (workspace, seed) = workspace_with_fresh_seed("seed-cleanup-success");
|
|
2568
2838
|
let mut ops = failed_register_ops(None, false);
|
|
2569
|
-
assert!(
|
|
2570
|
-
&workspace,
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
&mut ops
|
|
2574
|
-
)
|
|
2575
|
-
.unwrap());
|
|
2839
|
+
assert!(
|
|
2840
|
+
!bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops)
|
|
2841
|
+
.unwrap()
|
|
2842
|
+
);
|
|
2576
2843
|
assert_eq!(ops.inner.attach_calls, 1);
|
|
2577
2844
|
assert_eq!(ops.inner.register_calls, 1);
|
|
2578
2845
|
assert!(ops
|
|
@@ -2591,11 +2858,9 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2591
2858
|
"matching seed must be cleared via bind/repository; got {}",
|
|
2592
2859
|
persisted["teams"]["fresh"]["team_owner"]
|
|
2593
2860
|
);
|
|
2594
|
-
assert!(
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
.is_some_and(serde_json::Value::is_null)
|
|
2598
|
-
);
|
|
2861
|
+
assert!(persisted
|
|
2862
|
+
.pointer("/teams/fresh/leader_receiver")
|
|
2863
|
+
.is_some_and(serde_json::Value::is_null));
|
|
2599
2864
|
assert_eq!(
|
|
2600
2865
|
persisted["teams"]["fresh"]["owner_epoch"],
|
|
2601
2866
|
json!(1),
|
|
@@ -2608,13 +2873,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2608
2873
|
let (workspace, seed) = workspace_with_matching_seed("seed-cleanup-concurrent");
|
|
2609
2874
|
let takeover = takeover_binding(1);
|
|
2610
2875
|
let mut ops = failed_register_ops(Some(takeover_binding(1)), false);
|
|
2611
|
-
assert!(
|
|
2612
|
-
&workspace,
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
&mut ops
|
|
2616
|
-
)
|
|
2617
|
-
.unwrap());
|
|
2876
|
+
assert!(
|
|
2877
|
+
!bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops)
|
|
2878
|
+
.unwrap()
|
|
2879
|
+
);
|
|
2618
2880
|
assert_fresh_binding_kept(
|
|
2619
2881
|
&crate::state::persist::load_runtime_state(&workspace).unwrap(),
|
|
2620
2882
|
&takeover,
|
|
@@ -2626,13 +2888,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2626
2888
|
let (workspace, seed) = workspace_with_matching_seed("seed-cleanup-epoch2");
|
|
2627
2889
|
let takeover = takeover_binding(2);
|
|
2628
2890
|
let mut ops = failed_register_ops(Some(takeover_binding(2)), false);
|
|
2629
|
-
assert!(
|
|
2630
|
-
&workspace,
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
&mut ops
|
|
2634
|
-
)
|
|
2635
|
-
.unwrap());
|
|
2891
|
+
assert!(
|
|
2892
|
+
!bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops)
|
|
2893
|
+
.unwrap()
|
|
2894
|
+
);
|
|
2636
2895
|
assert_fresh_binding_kept(
|
|
2637
2896
|
&crate::state::persist::load_runtime_state(&workspace).unwrap(),
|
|
2638
2897
|
&takeover,
|
|
@@ -2643,13 +2902,8 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2643
2902
|
fn seeded_cleanup_propagates_persist_error() {
|
|
2644
2903
|
let (workspace, seed) = workspace_with_fresh_seed("seed-cleanup-persist-err");
|
|
2645
2904
|
let mut ops = failed_register_ops(None, true);
|
|
2646
|
-
let error = bind_fresh_quick_start_leader_with(
|
|
2647
|
-
|
|
2648
|
-
"fresh",
|
|
2649
|
-
Some(&seed),
|
|
2650
|
-
&mut ops,
|
|
2651
|
-
)
|
|
2652
|
-
.expect_err("poisoned state.json must surface cleanup persist failure");
|
|
2905
|
+
let error = bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops)
|
|
2906
|
+
.expect_err("poisoned state.json must surface cleanup persist failure");
|
|
2653
2907
|
assert_eq!(ops.inner.attach_calls, 1);
|
|
2654
2908
|
assert_eq!(ops.inner.register_calls, 1);
|
|
2655
2909
|
assert!(ops
|
|
@@ -2729,7 +2983,9 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2729
2983
|
"owner_epoch": 1
|
|
2730
2984
|
}
|
|
2731
2985
|
});
|
|
2732
|
-
serde_json::to_string_pretty(&expected)
|
|
2986
|
+
serde_json::to_string_pretty(&expected)
|
|
2987
|
+
.unwrap()
|
|
2988
|
+
.into_bytes()
|
|
2733
2989
|
}
|
|
2734
2990
|
|
|
2735
2991
|
fn assert_failure_rolls_back_this_attempt(
|
|
@@ -2828,8 +3084,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2828
3084
|
attach_ok: false,
|
|
2829
3085
|
..MockOps::default()
|
|
2830
3086
|
};
|
|
2831
|
-
assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
2832
|
-
.unwrap());
|
|
3087
|
+
assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
|
|
2833
3088
|
let after = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
2834
3089
|
assert_eq!(after.get("tasks"), Some(&json!({"keep": true})));
|
|
2835
3090
|
assert!(after.get("failed_attach_mutation").is_none());
|
|
@@ -2863,6 +3118,153 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2863
3118
|
})
|
|
2864
3119
|
}
|
|
2865
3120
|
|
|
3121
|
+
fn native_persisted_binding(workspace: &Path, socket: &str) -> serde_json::Value {
|
|
3122
|
+
let uuid = "9e1ceba443966bc206a776a7c621e808";
|
|
3123
|
+
json!({
|
|
3124
|
+
"workspace": workspace,
|
|
3125
|
+
"agents": {}, "tasks": [], "session_name": null,
|
|
3126
|
+
"team_owner": complete_owner("%0", "pi", uuid, 1),
|
|
3127
|
+
"leader_receiver": complete_receiver("%0", "pi", uuid, 1, socket)
|
|
3128
|
+
})
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
#[cfg(unix)]
|
|
3132
|
+
#[test]
|
|
3133
|
+
#[serial_test::serial(env)]
|
|
3134
|
+
fn native_persisted_binding_matches_workspace_short_socket_without_weakening_identity() {
|
|
3135
|
+
let hermetic = HermeticTestEnv::enter("native-persisted-socket");
|
|
3136
|
+
let workspace = hermetic.root();
|
|
3137
|
+
let transport = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
|
|
3138
|
+
let endpoint = transport.tmux_endpoint().unwrap();
|
|
3139
|
+
let socket = crate::tmux_backend::socket_path_for_workspace(workspace).unwrap();
|
|
3140
|
+
let state = native_persisted_binding(workspace, socket.to_str().unwrap());
|
|
3141
|
+
let before = state.clone();
|
|
3142
|
+
let pane = PaneId::new("%0");
|
|
3143
|
+
assert_ne!(socket.to_str(), Some(endpoint.as_str()));
|
|
3144
|
+
assert!(persisted_binding_matches_verified_pane(
|
|
3145
|
+
&state,
|
|
3146
|
+
workspace,
|
|
3147
|
+
&pane,
|
|
3148
|
+
Provider::Pi,
|
|
3149
|
+
Some(&endpoint),
|
|
3150
|
+
));
|
|
3151
|
+
assert_eq!(state, before);
|
|
3152
|
+
assert!(!persisted_binding_matches_verified_pane(
|
|
3153
|
+
&state,
|
|
3154
|
+
workspace,
|
|
3155
|
+
&PaneId::new("%1"),
|
|
3156
|
+
Provider::Pi,
|
|
3157
|
+
Some(&endpoint),
|
|
3158
|
+
));
|
|
3159
|
+
assert!(!persisted_binding_matches_verified_pane(
|
|
3160
|
+
&state,
|
|
3161
|
+
workspace,
|
|
3162
|
+
&pane,
|
|
3163
|
+
Provider::Codex,
|
|
3164
|
+
Some(&endpoint),
|
|
3165
|
+
));
|
|
3166
|
+
for endpoint in [None, Some("")] {
|
|
3167
|
+
assert!(!persisted_binding_matches_verified_pane(
|
|
3168
|
+
&state,
|
|
3169
|
+
workspace,
|
|
3170
|
+
&pane,
|
|
3171
|
+
Provider::Pi,
|
|
3172
|
+
endpoint,
|
|
3173
|
+
));
|
|
3174
|
+
}
|
|
3175
|
+
for (field, value) in [
|
|
3176
|
+
("/team_owner/pane_id", json!("%1")),
|
|
3177
|
+
("/leader_receiver/pane_id", json!("%1")),
|
|
3178
|
+
("/team_owner/provider", json!("codex")),
|
|
3179
|
+
("/leader_receiver/provider", json!("codex")),
|
|
3180
|
+
("/team_owner/owner_epoch", json!(0)),
|
|
3181
|
+
("/leader_receiver/owner_epoch", json!(2)),
|
|
3182
|
+
("/team_owner/leader_session_uuid", json!("")),
|
|
3183
|
+
("/leader_receiver/leader_session_uuid", json!("foreign")),
|
|
3184
|
+
("/leader_receiver/status", json!("pending")),
|
|
3185
|
+
("/leader_receiver/mode", json!("external")),
|
|
3186
|
+
("/leader_receiver/tmux_socket", json!(null)),
|
|
3187
|
+
("/leader_receiver/tmux_socket", json!("")),
|
|
3188
|
+
("/workspace", json!(workspace.join("foreign"))),
|
|
3189
|
+
] {
|
|
3190
|
+
let mut conflict = state.clone();
|
|
3191
|
+
*conflict.pointer_mut(field).unwrap() = value;
|
|
3192
|
+
assert!(
|
|
3193
|
+
!persisted_binding_matches_verified_pane(
|
|
3194
|
+
&conflict,
|
|
3195
|
+
workspace,
|
|
3196
|
+
&pane,
|
|
3197
|
+
Provider::Pi,
|
|
3198
|
+
Some(&endpoint),
|
|
3199
|
+
),
|
|
3200
|
+
"conflicting {field} must refuse"
|
|
3201
|
+
);
|
|
3202
|
+
}
|
|
3203
|
+
let mut unauthorized = state.clone();
|
|
3204
|
+
unauthorized["leader_receiver"]["authorized_team_workspace"] =
|
|
3205
|
+
json!(workspace.join("foreign"));
|
|
3206
|
+
assert!(!persisted_binding_matches_verified_pane(
|
|
3207
|
+
&unauthorized,
|
|
3208
|
+
workspace,
|
|
3209
|
+
&pane,
|
|
3210
|
+
Provider::Pi,
|
|
3211
|
+
Some(&endpoint),
|
|
3212
|
+
));
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
#[cfg(unix)]
|
|
3216
|
+
#[test]
|
|
3217
|
+
#[serial_test::serial(env)]
|
|
3218
|
+
fn native_persisted_binding_rejects_same_socket_basename_in_foreign_root() {
|
|
3219
|
+
let hermetic = HermeticTestEnv::enter("native-persisted-foreign-root");
|
|
3220
|
+
let workspace = hermetic.root();
|
|
3221
|
+
let transport = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
|
|
3222
|
+
let endpoint = transport.tmux_endpoint().unwrap();
|
|
3223
|
+
let foreign_socket = workspace.join("foreign-socket-root").join(&endpoint);
|
|
3224
|
+
let state = native_persisted_binding(workspace, foreign_socket.to_str().unwrap());
|
|
3225
|
+
assert_eq!(
|
|
3226
|
+
foreign_socket.file_name().unwrap().to_str(),
|
|
3227
|
+
Some(endpoint.as_str())
|
|
3228
|
+
);
|
|
3229
|
+
assert!(!persisted_binding_matches_verified_pane(
|
|
3230
|
+
&state,
|
|
3231
|
+
workspace,
|
|
3232
|
+
&PaneId::new("%0"),
|
|
3233
|
+
Provider::Pi,
|
|
3234
|
+
Some(&endpoint),
|
|
3235
|
+
));
|
|
3236
|
+
assert!(!quick_start_tmux_endpoints_match(
|
|
3237
|
+
foreign_socket.to_str().unwrap(),
|
|
3238
|
+
"default",
|
|
3239
|
+
));
|
|
3240
|
+
assert!(!quick_start_tmux_endpoints_match(
|
|
3241
|
+
foreign_socket.to_str().unwrap(),
|
|
3242
|
+
"",
|
|
3243
|
+
));
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
#[cfg(unix)]
|
|
3247
|
+
#[test]
|
|
3248
|
+
#[serial_test::serial(env)]
|
|
3249
|
+
fn native_persisted_binding_matches_full_socket_path_symlink_alias() {
|
|
3250
|
+
let hermetic = HermeticTestEnv::enter("native-persisted-path-alias");
|
|
3251
|
+
let workspace = hermetic.root();
|
|
3252
|
+
let root = workspace.join("socket-root");
|
|
3253
|
+
std::fs::create_dir_all(&root).unwrap();
|
|
3254
|
+
let alias = workspace.join("socket-alias");
|
|
3255
|
+
std::os::unix::fs::symlink(&root, &alias).unwrap();
|
|
3256
|
+
let socket = root.join("ta-native.sock");
|
|
3257
|
+
std::fs::write(&socket, "fixture endpoint; no tmux server").unwrap();
|
|
3258
|
+
let state = native_persisted_binding(workspace, socket.to_str().unwrap());
|
|
3259
|
+
assert!(persisted_binding_matches_verified_pane(
|
|
3260
|
+
&state,
|
|
3261
|
+
workspace,
|
|
3262
|
+
&PaneId::new("%0"),
|
|
3263
|
+
Provider::Pi,
|
|
3264
|
+
alias.join("ta-native.sock").to_str(),
|
|
3265
|
+
));
|
|
3266
|
+
}
|
|
3267
|
+
|
|
2866
3268
|
fn commit_fresh_binding(
|
|
2867
3269
|
workspace: &Path,
|
|
2868
3270
|
owner: &serde_json::Value,
|
|
@@ -2874,10 +3276,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2874
3276
|
state["teams"]["fresh"]["leader_receiver"] = receiver.clone();
|
|
2875
3277
|
state["teams"]["fresh"]["owner_epoch"] = json!(epoch);
|
|
2876
3278
|
crate::state::persist::save_runtime_state_with_receiver_authority(
|
|
2877
|
-
workspace,
|
|
2878
|
-
&state,
|
|
2879
|
-
"fresh",
|
|
2880
|
-
None,
|
|
3279
|
+
workspace, &state, "fresh", None,
|
|
2881
3280
|
)
|
|
2882
3281
|
.unwrap();
|
|
2883
3282
|
let raw = std::fs::read(crate::state::persist::runtime_state_path(workspace)).unwrap();
|
|
@@ -2937,7 +3336,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2937
3336
|
.unwrap();
|
|
2938
3337
|
let after = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
2939
3338
|
assert_eq!(after["teams"]["fresh"]["team_owner"], previous_owner);
|
|
2940
|
-
assert_eq!(
|
|
3339
|
+
assert_eq!(
|
|
3340
|
+
after["teams"]["fresh"]["leader_receiver"],
|
|
3341
|
+
previous_receiver
|
|
3342
|
+
);
|
|
2941
3343
|
assert_eq!(after["teams"]["fresh"]["owner_epoch"], json!(1));
|
|
2942
3344
|
assert_eq!(after.get("tasks"), Some(&json!({"keep": true})));
|
|
2943
3345
|
let _ = std::fs::remove_dir_all(&workspace);
|
|
@@ -2987,7 +3389,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2987
3389
|
assert_eq!(after["teams"]["fresh"]["leader_receiver"], winner_receiver);
|
|
2988
3390
|
assert_eq!(after["teams"]["fresh"]["owner_epoch"], json!(2));
|
|
2989
3391
|
assert_ne!(after["teams"]["fresh"]["team_owner"], previous_owner);
|
|
2990
|
-
assert_ne!(
|
|
3392
|
+
assert_ne!(
|
|
3393
|
+
after["teams"]["fresh"]["leader_receiver"],
|
|
3394
|
+
previous_receiver
|
|
3395
|
+
);
|
|
2991
3396
|
let _ = std::fs::remove_dir_all(&workspace);
|
|
2992
3397
|
}
|
|
2993
3398
|
|
|
@@ -3032,19 +3437,23 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3032
3437
|
let _home = HomeGuard::set(&home);
|
|
3033
3438
|
let state_path = crate::state::persist::runtime_state_path(&workspace);
|
|
3034
3439
|
let before = std::fs::read(&state_path).unwrap();
|
|
3035
|
-
let registry_path = crate::leader::registry::registry_dir()
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3440
|
+
let registry_path = crate::leader::registry::registry_dir()
|
|
3441
|
+
.unwrap()
|
|
3442
|
+
.join(format!(
|
|
3443
|
+
"{}__fresh.json",
|
|
3444
|
+
crate::leader::registry::workspace_hash(&workspace)
|
|
3445
|
+
));
|
|
3039
3446
|
let mut ops = AmbientRegistryOps {
|
|
3040
3447
|
readback_ok: false,
|
|
3041
3448
|
attach_calls: 0,
|
|
3042
3449
|
registry_receipt: None,
|
|
3043
3450
|
};
|
|
3044
|
-
assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
3045
|
-
.unwrap());
|
|
3451
|
+
assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
|
|
3046
3452
|
assert_eq!(std::fs::read(state_path).unwrap(), before);
|
|
3047
|
-
assert!(
|
|
3453
|
+
assert!(
|
|
3454
|
+
!registry_path.exists(),
|
|
3455
|
+
"failed readback left registry bytes"
|
|
3456
|
+
);
|
|
3048
3457
|
}
|
|
3049
3458
|
|
|
3050
3459
|
struct RecordingRuntimeOps<'a> {
|
|
@@ -3252,7 +3661,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3252
3661
|
let mut focused = caller_target(parent.clone(), Some("shared-live-nonce"));
|
|
3253
3662
|
focused.active = true;
|
|
3254
3663
|
let (active_ok, active_reason) = bind_seeded_caller(focused, "runtime-focus-active");
|
|
3255
|
-
assert!(
|
|
3664
|
+
assert!(
|
|
3665
|
+
active_ok,
|
|
3666
|
+
"focused live caller must bind: {active_reason:?}"
|
|
3667
|
+
);
|
|
3256
3668
|
|
|
3257
3669
|
let mut background = caller_target(parent, Some("shared-live-nonce"));
|
|
3258
3670
|
background.active = false;
|
|
@@ -3288,8 +3700,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3288
3700
|
frozen_receiver: None,
|
|
3289
3701
|
nonce_writer: None,
|
|
3290
3702
|
};
|
|
3291
|
-
assert!(
|
|
3292
|
-
|
|
3703
|
+
assert!(
|
|
3704
|
+
!bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops)
|
|
3705
|
+
.unwrap()
|
|
3706
|
+
);
|
|
3293
3707
|
assert_eq!(ops.last_failure_reason, Some("caller_pane_not_live"));
|
|
3294
3708
|
}
|
|
3295
3709
|
|
|
@@ -3336,7 +3750,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3336
3750
|
.map(|agents| agents.keys().cloned().collect::<Vec<_>>())
|
|
3337
3751
|
.unwrap_or_default();
|
|
3338
3752
|
assert_eq!(ids, vec!["bob".to_string()]);
|
|
3339
|
-
assert_eq!(
|
|
3753
|
+
assert_eq!(
|
|
3754
|
+
view.get("session_name").and_then(serde_json::Value::as_str),
|
|
3755
|
+
Some("team-bob")
|
|
3756
|
+
);
|
|
3340
3757
|
}
|
|
3341
3758
|
|
|
3342
3759
|
#[test]
|
|
@@ -3439,11 +3856,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3439
3856
|
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
3440
3857
|
.with_targets(vec![stale_target]);
|
|
3441
3858
|
assert!(matches!(
|
|
3442
|
-
crate::messaging::resolve_live_leader_channel(
|
|
3443
|
-
&workspace,
|
|
3444
|
-
&receiver,
|
|
3445
|
-
&stale_transport
|
|
3446
|
-
),
|
|
3859
|
+
crate::messaging::resolve_live_leader_channel(&workspace, &receiver, &stale_transport),
|
|
3447
3860
|
crate::messaging::LeaderChannelResolution::Unbound(
|
|
3448
3861
|
crate::messaging::LeaderChannelUnbound::PaneWorkspaceMismatch(_)
|
|
3449
3862
|
)
|
|
@@ -3481,10 +3894,17 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3481
3894
|
));
|
|
3482
3895
|
|
|
3483
3896
|
let mut foreign_receiver = receiver.clone();
|
|
3484
|
-
foreign_receiver["authorized_team_workspace"] =
|
|
3485
|
-
|
|
3897
|
+
foreign_receiver["authorized_team_workspace"] = json!(hermetic
|
|
3898
|
+
.root()
|
|
3899
|
+
.join("foreign")
|
|
3900
|
+
.to_string_lossy()
|
|
3901
|
+
.to_string());
|
|
3486
3902
|
assert!(matches!(
|
|
3487
|
-
crate::messaging::resolve_live_leader_channel(
|
|
3903
|
+
crate::messaging::resolve_live_leader_channel(
|
|
3904
|
+
&workspace,
|
|
3905
|
+
&foreign_receiver,
|
|
3906
|
+
&transport
|
|
3907
|
+
),
|
|
3488
3908
|
crate::messaging::LeaderChannelResolution::Unbound(
|
|
3489
3909
|
crate::messaging::LeaderChannelUnbound::PaneWorkspaceMismatch(_)
|
|
3490
3910
|
)
|
|
@@ -3687,10 +4107,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3687
4107
|
let mut winner = crate::state::persist::load_runtime_state(workspace).unwrap();
|
|
3688
4108
|
winner["teams"][team_key]["leader_receiver"]["pane_id"] = json!("%winner");
|
|
3689
4109
|
crate::state::persist::save_runtime_state_with_receiver_authority(
|
|
3690
|
-
workspace,
|
|
3691
|
-
&winner,
|
|
3692
|
-
team_key,
|
|
3693
|
-
None,
|
|
4110
|
+
workspace, &winner, team_key, None,
|
|
3694
4111
|
)
|
|
3695
4112
|
.unwrap();
|
|
3696
4113
|
let _ = crate::leader::registry::register_binding_from_state_best_effort(
|
|
@@ -3726,10 +4143,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3726
4143
|
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
3727
4144
|
.with_pane_current_command("%1", "bash")
|
|
3728
4145
|
.with_targets(vec![target_with_nonce.clone()])
|
|
3729
|
-
.with_target_snapshots(vec![
|
|
3730
|
-
vec![target_without_nonce],
|
|
3731
|
-
vec![target_with_nonce],
|
|
3732
|
-
]);
|
|
4146
|
+
.with_target_snapshots(vec![vec![target_without_nonce], vec![target_with_nonce]]);
|
|
3733
4147
|
let mut ops = RuntimeCommitFailureOps {
|
|
3734
4148
|
inner: RuntimeFreshQuickStartLeaderBindingOps {
|
|
3735
4149
|
transport: &transport,
|
|
@@ -3748,13 +4162,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3748
4162
|
break_registry_lock: false,
|
|
3749
4163
|
break_registry_read: false,
|
|
3750
4164
|
};
|
|
3751
|
-
assert!(
|
|
3752
|
-
&workspace,
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
&mut ops
|
|
3756
|
-
)
|
|
3757
|
-
.unwrap());
|
|
4165
|
+
assert!(
|
|
4166
|
+
!bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops)
|
|
4167
|
+
.unwrap()
|
|
4168
|
+
);
|
|
3758
4169
|
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
3759
4170
|
assert!(persisted
|
|
3760
4171
|
.pointer("/teams/fresh/team_owner")
|
|
@@ -3769,7 +4180,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3769
4180
|
"{}__fresh.json",
|
|
3770
4181
|
crate::leader::registry::workspace_hash(&workspace)
|
|
3771
4182
|
));
|
|
3772
|
-
assert!(
|
|
4183
|
+
assert!(
|
|
4184
|
+
!registry_path.exists(),
|
|
4185
|
+
"failed child grant left registry row"
|
|
4186
|
+
);
|
|
3773
4187
|
let observed = transport.list_targets().unwrap();
|
|
3774
4188
|
assert_eq!(
|
|
3775
4189
|
observed[0]
|
|
@@ -3817,14 +4231,14 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3817
4231
|
break_registry_lock: false,
|
|
3818
4232
|
break_registry_read: false,
|
|
3819
4233
|
};
|
|
3820
|
-
assert!(
|
|
3821
|
-
&workspace,
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
4234
|
+
assert!(
|
|
4235
|
+
!bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops,)
|
|
4236
|
+
.unwrap()
|
|
4237
|
+
);
|
|
4238
|
+
assert_eq!(
|
|
4239
|
+
ops.inner.last_failure_reason,
|
|
4240
|
+
Some("attach_event_log_failed")
|
|
4241
|
+
);
|
|
3828
4242
|
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
3829
4243
|
assert!(persisted
|
|
3830
4244
|
.pointer("/teams/fresh/team_owner")
|
|
@@ -3881,7 +4295,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3881
4295
|
|| bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops)
|
|
3882
4296
|
)
|
|
3883
4297
|
.unwrap());
|
|
3884
|
-
assert_eq!(
|
|
4298
|
+
assert_eq!(
|
|
4299
|
+
ops.last_failure_reason,
|
|
4300
|
+
Some("receiver_live_channel_unavailable")
|
|
4301
|
+
);
|
|
3885
4302
|
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
3886
4303
|
assert!(persisted
|
|
3887
4304
|
.pointer("/teams/fresh/team_owner")
|
|
@@ -3934,13 +4351,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3934
4351
|
break_registry_lock: false,
|
|
3935
4352
|
break_registry_read: false,
|
|
3936
4353
|
};
|
|
3937
|
-
assert!(
|
|
3938
|
-
&workspace,
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
&mut ops
|
|
3942
|
-
)
|
|
3943
|
-
.unwrap());
|
|
4354
|
+
assert!(
|
|
4355
|
+
!bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops)
|
|
4356
|
+
.unwrap()
|
|
4357
|
+
);
|
|
3944
4358
|
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
3945
4359
|
assert!(persisted
|
|
3946
4360
|
.pointer("/teams/fresh/team_owner")
|
|
@@ -3954,7 +4368,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3954
4368
|
"{}__fresh.json",
|
|
3955
4369
|
crate::leader::registry::workspace_hash(&workspace)
|
|
3956
4370
|
));
|
|
3957
|
-
assert!(
|
|
4371
|
+
assert!(
|
|
4372
|
+
!registry_path.exists(),
|
|
4373
|
+
"readback failure left registry row"
|
|
4374
|
+
);
|
|
3958
4375
|
}
|
|
3959
4376
|
|
|
3960
4377
|
#[test]
|
|
@@ -3993,13 +4410,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3993
4410
|
break_registry_lock: false,
|
|
3994
4411
|
break_registry_read: false,
|
|
3995
4412
|
};
|
|
3996
|
-
assert!(
|
|
3997
|
-
&workspace,
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
&mut ops,
|
|
4001
|
-
)
|
|
4002
|
-
.unwrap());
|
|
4413
|
+
assert!(
|
|
4414
|
+
!bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops,)
|
|
4415
|
+
.unwrap()
|
|
4416
|
+
);
|
|
4003
4417
|
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
4004
4418
|
assert_eq!(
|
|
4005
4419
|
persisted
|
|
@@ -4037,13 +4451,11 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
4037
4451
|
#[test]
|
|
4038
4452
|
#[serial_test::serial(env)]
|
|
4039
4453
|
fn seeded_runtime_registry_rollback_errors_are_visible_after_state_cleanup() {
|
|
4040
|
-
for (tag, break_registry_lock, break_registry_read) in
|
|
4041
|
-
("lock", true, false),
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
"runtime-seeded-registry-rollback-{tag}"
|
|
4046
|
-
));
|
|
4454
|
+
for (tag, break_registry_lock, break_registry_read) in
|
|
4455
|
+
[("lock", true, false), ("read", false, true)]
|
|
4456
|
+
{
|
|
4457
|
+
let hermetic =
|
|
4458
|
+
HermeticTestEnv::enter(&format!("runtime-seeded-registry-rollback-{tag}"));
|
|
4047
4459
|
let workspace = runtime_workspace(&hermetic, "fresh");
|
|
4048
4460
|
let parent = hermetic.root().join("parent");
|
|
4049
4461
|
std::fs::create_dir_all(&parent).unwrap();
|
|
@@ -4076,13 +4488,9 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
4076
4488
|
break_registry_lock,
|
|
4077
4489
|
break_registry_read,
|
|
4078
4490
|
};
|
|
4079
|
-
let error =
|
|
4080
|
-
&workspace,
|
|
4081
|
-
|
|
4082
|
-
Some(&seed),
|
|
4083
|
-
&mut ops,
|
|
4084
|
-
)
|
|
4085
|
-
.expect_err("registry rollback failure must be visible");
|
|
4491
|
+
let error =
|
|
4492
|
+
bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops)
|
|
4493
|
+
.expect_err("registry rollback failure must be visible");
|
|
4086
4494
|
assert!(error
|
|
4087
4495
|
.to_string()
|
|
4088
4496
|
.contains("quick-start registry rollback failed"));
|
|
@@ -4166,7 +4574,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
4166
4574
|
)
|
|
4167
4575
|
.unwrap());
|
|
4168
4576
|
assert_eq!(ops.last_failure_reason, Some(expected_reason));
|
|
4169
|
-
assert_eq!(
|
|
4577
|
+
assert_eq!(
|
|
4578
|
+
refusal_reason(&workspace).map(|(_, reason)| reason),
|
|
4579
|
+
Some(expected_reason.to_string())
|
|
4580
|
+
);
|
|
4170
4581
|
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
4171
4582
|
assert!(persisted
|
|
4172
4583
|
.pointer("/teams/fresh/team_owner")
|
|
@@ -4190,12 +4601,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
4190
4601
|
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
4191
4602
|
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
4192
4603
|
let seed = seeded_runtime_owner(&workspace);
|
|
4193
|
-
let mut state =
|
|
4194
|
-
&workspace,
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
.unwrap()
|
|
4198
|
-
.state;
|
|
4604
|
+
let mut state =
|
|
4605
|
+
crate::state::projection::resolve_runtime_team_scope(&workspace, Some("fresh"))
|
|
4606
|
+
.unwrap()
|
|
4607
|
+
.state;
|
|
4199
4608
|
let expected_owner = state["team_owner"].clone();
|
|
4200
4609
|
let expected_receiver = state["leader_receiver"].clone();
|
|
4201
4610
|
let target = caller_target(parent.clone(), None);
|
|
@@ -4227,12 +4636,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
4227
4636
|
|
|
4228
4637
|
let workspace = runtime_workspace(&hermetic, "writer-failure");
|
|
4229
4638
|
let seed = seeded_runtime_owner(&workspace);
|
|
4230
|
-
let mut state =
|
|
4231
|
-
&workspace,
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
.unwrap()
|
|
4235
|
-
.state;
|
|
4639
|
+
let mut state =
|
|
4640
|
+
crate::state::projection::resolve_runtime_team_scope(&workspace, Some("fresh"))
|
|
4641
|
+
.unwrap()
|
|
4642
|
+
.state;
|
|
4236
4643
|
let expected_owner = state["team_owner"].clone();
|
|
4237
4644
|
let expected_receiver = state["leader_receiver"].clone();
|
|
4238
4645
|
let target = caller_target(hermetic.root().join("parent"), None);
|
|
@@ -4272,27 +4679,22 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
4272
4679
|
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
4273
4680
|
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
4274
4681
|
let seed = seeded_runtime_owner(&workspace);
|
|
4275
|
-
let concurrent_writer =
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4682
|
+
let concurrent_writer =
|
|
4683
|
+
|_: &str, _: &PaneId, _: &str| -> Result<String, crate::leader::LeaderError> {
|
|
4684
|
+
let mut winner = crate::state::persist::load_runtime_state(&workspace)
|
|
4685
|
+
.map_err(crate::leader::LeaderError::State)?;
|
|
4686
|
+
winner["teams"]["fresh"]["leader_receiver"]["pane_id"] = json!("%winner");
|
|
4687
|
+
crate::state::persist::save_runtime_state_with_receiver_authority(
|
|
4688
|
+
&workspace, &winner, "fresh", None,
|
|
4689
|
+
)
|
|
4280
4690
|
.map_err(crate::leader::LeaderError::State)?;
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
.map_err(crate::leader::LeaderError::State)?;
|
|
4289
|
-
let _ = crate::leader::registry::register_binding_from_state_best_effort(
|
|
4290
|
-
&workspace,
|
|
4291
|
-
Some("fresh"),
|
|
4292
|
-
"concurrent",
|
|
4293
|
-
);
|
|
4294
|
-
Ok("writer-winner".to_string())
|
|
4295
|
-
};
|
|
4691
|
+
let _ = crate::leader::registry::register_binding_from_state_best_effort(
|
|
4692
|
+
&workspace,
|
|
4693
|
+
Some("fresh"),
|
|
4694
|
+
"concurrent",
|
|
4695
|
+
);
|
|
4696
|
+
Ok("writer-winner".to_string())
|
|
4697
|
+
};
|
|
4296
4698
|
let target = caller_target(parent, None);
|
|
4297
4699
|
let transport = OfflineTransport::new()
|
|
4298
4700
|
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
@@ -4308,30 +4710,35 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
4308
4710
|
frozen_receiver: None,
|
|
4309
4711
|
nonce_writer: Some(&concurrent_writer),
|
|
4310
4712
|
};
|
|
4311
|
-
assert!(
|
|
4312
|
-
&workspace,
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
&mut ops
|
|
4316
|
-
)
|
|
4317
|
-
.unwrap());
|
|
4713
|
+
assert!(
|
|
4714
|
+
!bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops)
|
|
4715
|
+
.unwrap()
|
|
4716
|
+
);
|
|
4318
4717
|
assert!(ops.cas_conflict);
|
|
4319
4718
|
assert_eq!(ops.last_failure_reason, Some("attach_cas_conflict"));
|
|
4320
4719
|
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
4321
4720
|
assert_eq!(
|
|
4322
|
-
persisted
|
|
4721
|
+
persisted
|
|
4722
|
+
.pointer("/teams/fresh/team_owner/pane_id")
|
|
4723
|
+
.and_then(serde_json::Value::as_str),
|
|
4323
4724
|
Some("%1")
|
|
4324
4725
|
);
|
|
4325
4726
|
assert_eq!(
|
|
4326
|
-
persisted
|
|
4727
|
+
persisted
|
|
4728
|
+
.pointer("/teams/fresh/leader_receiver/pane_id")
|
|
4729
|
+
.and_then(serde_json::Value::as_str),
|
|
4327
4730
|
Some("%winner")
|
|
4328
4731
|
);
|
|
4329
4732
|
assert_eq!(
|
|
4330
|
-
persisted
|
|
4733
|
+
persisted
|
|
4734
|
+
.pointer("/teams/fresh/team_owner/owner_epoch")
|
|
4735
|
+
.and_then(serde_json::Value::as_u64),
|
|
4331
4736
|
Some(1)
|
|
4332
4737
|
);
|
|
4333
4738
|
assert_eq!(
|
|
4334
|
-
persisted
|
|
4739
|
+
persisted
|
|
4740
|
+
.pointer("/teams/fresh/leader_receiver/owner_epoch")
|
|
4741
|
+
.and_then(serde_json::Value::as_u64),
|
|
4335
4742
|
Some(1)
|
|
4336
4743
|
);
|
|
4337
4744
|
let registry_path = crate::leader::registry::registry_dir()
|
|
@@ -4340,7 +4747,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
4340
4747
|
"{}__fresh.json",
|
|
4341
4748
|
crate::leader::registry::workspace_hash(&workspace)
|
|
4342
4749
|
));
|
|
4343
|
-
assert!(
|
|
4750
|
+
assert!(
|
|
4751
|
+
registry_path.exists(),
|
|
4752
|
+
"concurrent winner registry was removed"
|
|
4753
|
+
);
|
|
4344
4754
|
let winner_entry: crate::leader::registry::LeaderRegistryEntry =
|
|
4345
4755
|
serde_json::from_slice(&std::fs::read(registry_path).unwrap()).unwrap();
|
|
4346
4756
|
assert_eq!(winner_entry.channel["pane_id"], json!("%winner"));
|
|
@@ -4350,18 +4760,8 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
4350
4760
|
#[serial_test::serial(env)]
|
|
4351
4761
|
fn runtime_socket_and_explicit_conflicts_refuse_without_command_fallback() {
|
|
4352
4762
|
for (tag, endpoint, leader, scoped) in [
|
|
4353
|
-
(
|
|
4354
|
-
|
|
4355
|
-
"/tmp/other.sock",
|
|
4356
|
-
None,
|
|
4357
|
-
"/tmp/tmux.sock",
|
|
4358
|
-
),
|
|
4359
|
-
(
|
|
4360
|
-
"scoped",
|
|
4361
|
-
"/tmp/tmux.sock",
|
|
4362
|
-
None,
|
|
4363
|
-
"/tmp/product.sock",
|
|
4364
|
-
),
|
|
4763
|
+
("socket", "/tmp/other.sock", None, "/tmp/tmux.sock"),
|
|
4764
|
+
("scoped", "/tmp/tmux.sock", None, "/tmp/product.sock"),
|
|
4365
4765
|
(
|
|
4366
4766
|
// The explicit leader provider intentionally conflicts with the
|
|
4367
4767
|
// caller provider; worker_env must reject this before command fallback.
|
|
@@ -4378,7 +4778,8 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
4378
4778
|
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
|
|
4379
4779
|
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
4380
4780
|
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, endpoint);
|
|
4381
|
-
let _leader =
|
|
4781
|
+
let _leader =
|
|
4782
|
+
leader.map(|value| hermetic.with_env("TEAM_AGENT_LEADER_PROVIDER", value));
|
|
4382
4783
|
let transport = OfflineTransport::new()
|
|
4383
4784
|
.with_tmux_endpoint(scoped)
|
|
4384
4785
|
.with_pane_current_command("%1", "codex");
|
|
@@ -4391,7 +4792,6 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
4391
4792
|
}
|
|
4392
4793
|
}
|
|
4393
4794
|
|
|
4394
|
-
|
|
4395
4795
|
#[test]
|
|
4396
4796
|
fn pi_tool_category_is_transparently_accepted_by_compiler() {
|
|
4397
4797
|
let root = std::env::temp_dir().join(format!(
|
|
@@ -4418,9 +4818,9 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
4418
4818
|
.get("agents")
|
|
4419
4819
|
.and_then(Value::as_list)
|
|
4420
4820
|
.and_then(|agents| {
|
|
4421
|
-
agents
|
|
4422
|
-
|
|
4423
|
-
|
|
4821
|
+
agents
|
|
4822
|
+
.iter()
|
|
4823
|
+
.find(|agent| agent.get("id").and_then(Value::as_str) == Some("worker"))
|
|
4424
4824
|
})
|
|
4425
4825
|
.expect("compiled spec must retain the worker role");
|
|
4426
4826
|
assert!(
|
|
@@ -4434,8 +4834,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
4434
4834
|
fn fresh_binding_persists_then_registers_then_requires_canonical_readback() {
|
|
4435
4835
|
let workspace = workspace_with_provider("positive-pi", "pi");
|
|
4436
4836
|
let mut ops = MockOps::with_provider("pi");
|
|
4437
|
-
assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
4438
|
-
.unwrap());
|
|
4837
|
+
assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
|
|
4439
4838
|
assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
|
|
4440
4839
|
assert_eq!(ops.attach_calls, 1);
|
|
4441
4840
|
assert_eq!(ops.register_calls, 1);
|
|
@@ -4492,12 +4891,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
4492
4891
|
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
4493
4892
|
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
4494
4893
|
let seed = seeded_runtime_owner(&workspace);
|
|
4495
|
-
let mut state =
|
|
4496
|
-
&workspace,
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
.unwrap()
|
|
4500
|
-
.state;
|
|
4894
|
+
let mut state =
|
|
4895
|
+
crate::state::projection::resolve_runtime_team_scope(&workspace, Some("fresh"))
|
|
4896
|
+
.unwrap()
|
|
4897
|
+
.state;
|
|
4501
4898
|
let expected_owner = state["team_owner"].clone();
|
|
4502
4899
|
let expected_receiver = state["leader_receiver"].clone();
|
|
4503
4900
|
let target = caller_target(parent.clone(), None);
|
|
@@ -4529,12 +4926,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
4529
4926
|
|
|
4530
4927
|
let workspace = runtime_workspace_with_provider(&hermetic, "writer-failure", "pi");
|
|
4531
4928
|
let seed = seeded_runtime_owner(&workspace);
|
|
4532
|
-
let mut state =
|
|
4533
|
-
&workspace,
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
.unwrap()
|
|
4537
|
-
.state;
|
|
4929
|
+
let mut state =
|
|
4930
|
+
crate::state::projection::resolve_runtime_team_scope(&workspace, Some("fresh"))
|
|
4931
|
+
.unwrap()
|
|
4932
|
+
.state;
|
|
4538
4933
|
let expected_owner = state["team_owner"].clone();
|
|
4539
4934
|
let expected_receiver = state["leader_receiver"].clone();
|
|
4540
4935
|
let target = caller_target(hermetic.root().join("parent"), None);
|