@team-agent/installer 0.5.77 → 0.5.80
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 +161 -29
- package/crates/team-agent/src/cli/diagnose.rs +333 -68
- package/crates/team-agent/src/cli/emit.rs +24 -8
- package/crates/team-agent/src/cli/mod.rs +543 -98
- package/crates/team-agent/src/cli/models.rs +393 -32
- package/crates/team-agent/src/cli/named_address.rs +19 -5
- package/crates/team-agent/src/cli/profile.rs +35 -4
- package/crates/team-agent/src/cli/spec.rs +7 -2
- package/crates/team-agent/src/cli/status_port/compact.rs +12 -8
- package/crates/team-agent/src/cli/status_port/runtime.rs +3 -1
- package/crates/team-agent/src/cli/status_port/snapshot.rs +15 -12
- package/crates/team-agent/src/cli/status_port/store.rs +1 -1
- package/crates/team-agent/src/cli/status_port/tests.rs +37 -1
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
- package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
- package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
- package/crates/team-agent/src/cli/types.rs +1 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +122 -2
- package/crates/team-agent/src/leader/lease.rs +492 -33
- package/crates/team-agent/src/leader/owner_bind.rs +40 -12
- package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
- package/crates/team-agent/src/leader/rediscover.rs +36 -6
- package/crates/team-agent/src/leader/start.rs +197 -88
- package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +86 -23
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
- package/crates/team-agent/src/lifecycle/launch.rs +30 -7
- package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
- package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
- package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +996 -66
- package/crates/team-agent/src/lifecycle/restart.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
- package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
- package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -4
- package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
- package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
- package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
- package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
- package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt +9 -0
- package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
- package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
- package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
- package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
- package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
- package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +3 -2
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +54 -9
- package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +71 -0
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
- package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
- package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
- package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/crates/team-agent/src/mcp_server/wire.rs +140 -11
- package/crates/team-agent/src/messaging/delivery.rs +221 -80
- package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +127 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
- package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
- package/crates/team-agent/src/provider/session_scan/common/tests.rs +204 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +24 -2
- package/crates/team-agent/src/state/abnormal_watch/tests/mod.rs +248 -0
- package/crates/team-agent/src/state/abnormal_watch.rs +144 -0
- package/crates/team-agent/src/state/mod.rs +1 -0
- package/crates/team-agent/src/state/persist.rs +102 -4
- package/crates/team-agent/src/state/projection.rs +7 -1
- package/crates/team-agent/src/state/repository.rs +29 -0
- package/crates/team-agent/src/test_support/models_timeout_fixture.rs +43 -21
- package/crates/team-agent/src/tmux_backend/tests.rs +149 -4
- package/crates/team-agent/src/tmux_backend.rs +239 -127
- package/crates/team-agent/src/transport.rs +78 -0
- package/crates/team-agent/src/transport_factory.rs +53 -0
- package/package.json +4 -5
- package/skills/team-agent/SKILL.md +45 -17
- package/skills/team-agent/command-coverage.json +4 -29
- package/npm/check-skill-links.mjs +0 -88
- package/skills/team-agent/references/team-agent-operator.md +0 -672
|
@@ -75,6 +75,12 @@ trait FreshQuickStartLeaderBindingOps {
|
|
|
75
75
|
fn readback_failure_reason(&self) -> Option<&'static str> {
|
|
76
76
|
None
|
|
77
77
|
}
|
|
78
|
+
fn frozen_seed_receipt(&self) -> Option<(serde_json::Value, serde_json::Value)> {
|
|
79
|
+
None
|
|
80
|
+
}
|
|
81
|
+
fn this_attempt_extra_state_keys(&self) -> Vec<&'static str> {
|
|
82
|
+
Vec::new()
|
|
83
|
+
}
|
|
78
84
|
}
|
|
79
85
|
|
|
80
86
|
struct RuntimeFreshQuickStartLeaderBindingOps<'a> {
|
|
@@ -83,6 +89,8 @@ struct RuntimeFreshQuickStartLeaderBindingOps<'a> {
|
|
|
83
89
|
cas_conflict: bool,
|
|
84
90
|
applied_grant: Option<(serde_json::Value, serde_json::Value)>,
|
|
85
91
|
registry_receipt: Option<crate::leader::registry::RegistryWriteReceipt>,
|
|
92
|
+
frozen_owner: Option<serde_json::Value>,
|
|
93
|
+
frozen_receiver: Option<serde_json::Value>,
|
|
86
94
|
nonce_writer:
|
|
87
95
|
Option<&'a dyn Fn(&str, &PaneId, &str) -> Result<String, crate::leader::LeaderError>>,
|
|
88
96
|
}
|
|
@@ -149,17 +157,22 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
149
157
|
self.last_failure_reason = Some("caller_pane_not_live");
|
|
150
158
|
return false;
|
|
151
159
|
};
|
|
152
|
-
if
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
.is_none_or(|path| path.as_os_str().is_empty())
|
|
160
|
+
if caller_target
|
|
161
|
+
.current_path
|
|
162
|
+
.as_ref()
|
|
163
|
+
.is_none_or(|path| path.as_os_str().is_empty())
|
|
157
164
|
{
|
|
158
165
|
self.last_failure_reason = Some("caller_cwd_unobservable");
|
|
159
166
|
return false;
|
|
160
167
|
}
|
|
161
|
-
let expected_owner =
|
|
162
|
-
|
|
168
|
+
let expected_owner = self
|
|
169
|
+
.frozen_owner
|
|
170
|
+
.clone()
|
|
171
|
+
.or_else(|| state.get("team_owner").cloned());
|
|
172
|
+
let expected_receiver = self
|
|
173
|
+
.frozen_receiver
|
|
174
|
+
.clone()
|
|
175
|
+
.or_else(|| state.get("leader_receiver").cloned());
|
|
163
176
|
match crate::leader::attach_leader_to_state_with_target_and_controls(
|
|
164
177
|
workspace,
|
|
165
178
|
state,
|
|
@@ -255,8 +268,14 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
255
268
|
}
|
|
256
269
|
|
|
257
270
|
fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
258
|
-
|
|
259
|
-
|
|
271
|
+
let class = crate::lifecycle::launch::classify_leader_binding(workspace, team_key);
|
|
272
|
+
if class != super::LeaderBindingClass::Attached {
|
|
273
|
+
self.last_failure_reason = Some(match class {
|
|
274
|
+
super::LeaderBindingClass::IndexMissing => "registry_readback_unavailable",
|
|
275
|
+
super::LeaderBindingClass::Unknown => "receiver_live_channel_unavailable",
|
|
276
|
+
super::LeaderBindingClass::Unbound => "receiver_missing",
|
|
277
|
+
super::LeaderBindingClass::Attached => "registry_identity_mismatch",
|
|
278
|
+
});
|
|
260
279
|
return false;
|
|
261
280
|
}
|
|
262
281
|
let Ok(resolved) = crate::state::projection::resolve_runtime_team_scope(
|
|
@@ -266,7 +285,9 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
266
285
|
self.last_failure_reason = Some("receiver_scope_unavailable");
|
|
267
286
|
return false;
|
|
268
287
|
};
|
|
269
|
-
let Some(receiver) =
|
|
288
|
+
let Some(receiver) =
|
|
289
|
+
super::selected_team_leader_receiver(&resolved.state, team_key)
|
|
290
|
+
else {
|
|
270
291
|
self.last_failure_reason = Some("receiver_missing");
|
|
271
292
|
return false;
|
|
272
293
|
};
|
|
@@ -288,6 +309,12 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
288
309
|
fn readback_failure_reason(&self) -> Option<&'static str> {
|
|
289
310
|
self.last_failure_reason
|
|
290
311
|
}
|
|
312
|
+
|
|
313
|
+
fn frozen_seed_receipt(&self) -> Option<(serde_json::Value, serde_json::Value)> {
|
|
314
|
+
self.frozen_owner
|
|
315
|
+
.clone()
|
|
316
|
+
.zip(self.frozen_receiver.clone())
|
|
317
|
+
}
|
|
291
318
|
}
|
|
292
319
|
|
|
293
320
|
const FRESH_BIND_REFUSAL_EVENT: &str = "quick_start.leader_bind_refused";
|
|
@@ -300,6 +327,9 @@ fn record_fresh_bind_refusal(
|
|
|
300
327
|
stage: &'static str,
|
|
301
328
|
reason: &'static str,
|
|
302
329
|
) {
|
|
330
|
+
LAST_FRESH_BIND_REFUSAL.with(|cell| {
|
|
331
|
+
*cell.borrow_mut() = Some((stage, reason));
|
|
332
|
+
});
|
|
303
333
|
let _ = crate::event_log::EventLog::new(workspace).write(
|
|
304
334
|
FRESH_BIND_REFUSAL_EVENT,
|
|
305
335
|
serde_json::json!({
|
|
@@ -311,12 +341,24 @@ fn record_fresh_bind_refusal(
|
|
|
311
341
|
);
|
|
312
342
|
}
|
|
313
343
|
|
|
344
|
+
thread_local! {
|
|
345
|
+
static LAST_FRESH_BIND_REFUSAL: std::cell::RefCell<Option<(&'static str, &'static str)>> =
|
|
346
|
+
const { std::cell::RefCell::new(None) };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
fn take_fresh_bind_refusal() -> Option<(&'static str, &'static str)> {
|
|
350
|
+
LAST_FRESH_BIND_REFUSAL.with(|cell| cell.borrow_mut().take())
|
|
351
|
+
}
|
|
352
|
+
|
|
314
353
|
fn refuse_fresh_bind(
|
|
315
354
|
workspace: &Path,
|
|
316
355
|
team_key: &str,
|
|
317
356
|
stage: &'static str,
|
|
318
357
|
reason: &'static str,
|
|
319
358
|
) -> Result<bool, LifecycleError> {
|
|
359
|
+
LAST_FRESH_BIND_REFUSAL.with(|cell| {
|
|
360
|
+
*cell.borrow_mut() = Some((stage, reason));
|
|
361
|
+
});
|
|
320
362
|
record_fresh_bind_refusal(workspace, team_key, stage, reason);
|
|
321
363
|
Ok(false)
|
|
322
364
|
}
|
|
@@ -332,25 +374,6 @@ impl BindingFileSnapshot {
|
|
|
332
374
|
Self { path, bytes }
|
|
333
375
|
}
|
|
334
376
|
|
|
335
|
-
fn restore(&self) {
|
|
336
|
-
match &self.bytes {
|
|
337
|
-
Some(bytes) => {
|
|
338
|
-
let tmp = self.path.with_extension(format!(
|
|
339
|
-
"rollback-{}",
|
|
340
|
-
std::process::id()
|
|
341
|
-
));
|
|
342
|
-
if std::fs::write(&tmp, bytes).is_ok()
|
|
343
|
-
&& std::fs::rename(&tmp, &self.path).is_err()
|
|
344
|
-
{
|
|
345
|
-
let _ = std::fs::remove_file(tmp);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
None => {
|
|
349
|
-
let _ = std::fs::remove_file(&self.path);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
377
|
}
|
|
355
378
|
|
|
356
379
|
fn binding_snapshots(workspace: &Path, team_key: &str) -> Vec<BindingFileSnapshot> {
|
|
@@ -475,7 +498,7 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
|
|
|
475
498
|
workspace,
|
|
476
499
|
team_key,
|
|
477
500
|
"strict_provider",
|
|
478
|
-
"
|
|
501
|
+
"invalid_caller_tuple",
|
|
479
502
|
);
|
|
480
503
|
}
|
|
481
504
|
crate::layout::worker_env::CallerProviderResolution::Absent => {
|
|
@@ -503,20 +526,44 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
|
|
|
503
526
|
// the owner. Cleanup is allowed only when the caller supplies the exact
|
|
504
527
|
// in-memory seed created immediately before this bind attempt and the
|
|
505
528
|
// projected state still contains that seed.
|
|
506
|
-
let
|
|
529
|
+
let current_receiver = state.get("leader_receiver");
|
|
530
|
+
let frozen_receipt = ops.frozen_seed_receipt();
|
|
531
|
+
let fresh_seeded_binding = if let Some((frozen_owner, frozen_receiver)) = frozen_receipt.as_ref()
|
|
532
|
+
{
|
|
507
533
|
state
|
|
508
534
|
.get("team_owner")
|
|
509
|
-
.is_some_and(|current| current ==
|
|
510
|
-
|
|
511
|
-
let seeded_receiver = if fresh_seeded_binding {
|
|
512
|
-
state.get("leader_receiver").cloned()
|
|
535
|
+
.is_some_and(|current| current == frozen_owner)
|
|
536
|
+
&& current_receiver.is_some_and(|current| current == frozen_receiver)
|
|
513
537
|
} else {
|
|
514
|
-
|
|
538
|
+
let pending_seed_receiver = current_receiver.is_some_and(|receiver| {
|
|
539
|
+
receiver.get("status").and_then(serde_json::Value::as_str) == Some("pending")
|
|
540
|
+
&& receiver
|
|
541
|
+
.get("discovery")
|
|
542
|
+
.and_then(serde_json::Value::as_str)
|
|
543
|
+
== Some("quick_start_seed")
|
|
544
|
+
});
|
|
545
|
+
seeded_owner.is_some_and(|seed| {
|
|
546
|
+
state
|
|
547
|
+
.get("team_owner")
|
|
548
|
+
.is_some_and(|current| current == seed)
|
|
549
|
+
&& pending_seed_receiver
|
|
550
|
+
})
|
|
515
551
|
};
|
|
552
|
+
let seeded_receiver = frozen_receipt
|
|
553
|
+
.as_ref()
|
|
554
|
+
.map(|(_, receiver)| receiver.clone())
|
|
555
|
+
.or_else(|| {
|
|
556
|
+
if fresh_seeded_binding {
|
|
557
|
+
current_receiver.cloned()
|
|
558
|
+
} else {
|
|
559
|
+
None
|
|
560
|
+
}
|
|
561
|
+
});
|
|
516
562
|
// Same-Team owner collision remains fail-closed. Cross-Team registry rows
|
|
517
563
|
// are intentionally irrelevant: pane ids are scoped to their tmux server,
|
|
518
564
|
// and independent endpoints may reuse the same numeric id.
|
|
519
565
|
if has_persisted_binding
|
|
566
|
+
&& !fresh_seeded_binding
|
|
520
567
|
&& !persisted_binding_matches_verified_pane(
|
|
521
568
|
&state,
|
|
522
569
|
workspace,
|
|
@@ -591,35 +638,53 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
|
|
|
591
638
|
let registry_rollback_error = ops
|
|
592
639
|
.registry_receipt()
|
|
593
640
|
.and_then(|receipt| restore_registry_receipt(&snapshots, &receipt).err());
|
|
594
|
-
if let Some((expected_owner, expected_receiver)) =
|
|
641
|
+
let state_rollback_error = if let Some((expected_owner, expected_receiver)) =
|
|
642
|
+
cleanup_grant.as_ref()
|
|
643
|
+
{
|
|
595
644
|
clear_fresh_binding_on_refusal(
|
|
596
645
|
workspace,
|
|
597
646
|
&mut state,
|
|
598
647
|
team_key,
|
|
599
648
|
expected_owner,
|
|
600
649
|
Some((expected_owner, expected_receiver)),
|
|
601
|
-
)
|
|
650
|
+
)
|
|
651
|
+
.err()
|
|
602
652
|
} else if let Some(seed) = seeded_owner.filter(|seed| {
|
|
603
653
|
state
|
|
604
654
|
.get("team_owner")
|
|
605
655
|
.is_some_and(|current| current == *seed)
|
|
606
656
|
}) {
|
|
607
|
-
clear_fresh_binding_on_refusal(workspace, &mut state, team_key, seed, None)
|
|
608
|
-
}
|
|
609
|
-
|
|
657
|
+
clear_fresh_binding_on_refusal(workspace, &mut state, team_key, seed, None).err()
|
|
658
|
+
} else {
|
|
659
|
+
restore_this_attempt_state(
|
|
660
|
+
workspace,
|
|
661
|
+
team_key,
|
|
662
|
+
snapshots.first(),
|
|
663
|
+
ops.applied_grant().as_ref(),
|
|
664
|
+
&ops.this_attempt_extra_state_keys(),
|
|
665
|
+
)
|
|
666
|
+
.err()
|
|
667
|
+
};
|
|
668
|
+
if let Some(error) =
|
|
669
|
+
combine_rollback_errors(state_rollback_error, registry_rollback_error)
|
|
670
|
+
{
|
|
610
671
|
return Err(error);
|
|
611
672
|
}
|
|
612
673
|
} else {
|
|
613
|
-
// No caller-seeded owner was ours to clean up. Restore only the
|
|
614
|
-
// runtime snapshot; a registry write is reverted only with its
|
|
615
|
-
// exact receipt under the registry owner's lock.
|
|
616
674
|
let registry_rollback_error = ops
|
|
617
675
|
.registry_receipt()
|
|
618
676
|
.and_then(|receipt| restore_registry_receipt(&snapshots, &receipt).err());
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
677
|
+
let state_rollback_error = restore_this_attempt_state(
|
|
678
|
+
workspace,
|
|
679
|
+
team_key,
|
|
680
|
+
snapshots.first(),
|
|
681
|
+
ops.applied_grant().as_ref(),
|
|
682
|
+
&ops.this_attempt_extra_state_keys(),
|
|
683
|
+
)
|
|
684
|
+
.err();
|
|
685
|
+
if let Some(error) =
|
|
686
|
+
combine_rollback_errors(state_rollback_error, registry_rollback_error)
|
|
687
|
+
{
|
|
623
688
|
return Err(error);
|
|
624
689
|
}
|
|
625
690
|
}
|
|
@@ -627,6 +692,112 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
|
|
|
627
692
|
Ok(committed)
|
|
628
693
|
}
|
|
629
694
|
|
|
695
|
+
fn combine_rollback_errors(
|
|
696
|
+
state: Option<LifecycleError>,
|
|
697
|
+
registry: Option<LifecycleError>,
|
|
698
|
+
) -> Option<LifecycleError> {
|
|
699
|
+
match (state, registry) {
|
|
700
|
+
(None, None) => None,
|
|
701
|
+
(Some(error), None) | (None, Some(error)) => Some(error),
|
|
702
|
+
(Some(state), Some(registry)) => Some(LifecycleError::StatePersist(format!(
|
|
703
|
+
"{state}; {registry}"
|
|
704
|
+
))),
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
fn restore_this_attempt_state(
|
|
709
|
+
workspace: &Path,
|
|
710
|
+
team_key: &str,
|
|
711
|
+
snapshot: Option<&BindingFileSnapshot>,
|
|
712
|
+
this_grant: Option<&(serde_json::Value, serde_json::Value)>,
|
|
713
|
+
extra_keys: &[&str],
|
|
714
|
+
) -> Result<(), LifecycleError> {
|
|
715
|
+
if this_grant.is_none() && extra_keys.is_empty() {
|
|
716
|
+
return Ok(());
|
|
717
|
+
}
|
|
718
|
+
let mut current = match crate::state::persist::load_runtime_state(workspace) {
|
|
719
|
+
Ok(state) => state,
|
|
720
|
+
Err(error) => {
|
|
721
|
+
return Err(LifecycleError::StatePersist(format!(
|
|
722
|
+
"quick-start binding cleanup failed: {error}"
|
|
723
|
+
)));
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
strip_this_attempt_extra_keys(&mut current, extra_keys);
|
|
727
|
+
let Some((expected_owner, expected_receiver)) = this_grant else {
|
|
728
|
+
return crate::state::repository::StateRepository::new(workspace)
|
|
729
|
+
.save(
|
|
730
|
+
crate::state::repository::StateWriteIntent::QuickStartBindingCleanup { team_key },
|
|
731
|
+
¤t,
|
|
732
|
+
)
|
|
733
|
+
.map_err(|error| {
|
|
734
|
+
LifecycleError::StatePersist(format!("quick-start binding cleanup failed: {error}"))
|
|
735
|
+
});
|
|
736
|
+
};
|
|
737
|
+
let previous = match snapshot.map(|snapshot| snapshot.bytes.as_deref()) {
|
|
738
|
+
Some(Some(bytes)) => serde_json::from_slice::<serde_json::Value>(bytes).map_err(|error| {
|
|
739
|
+
LifecycleError::StatePersist(format!("quick-start binding cleanup failed: {error}"))
|
|
740
|
+
})?,
|
|
741
|
+
Some(None) | None => serde_json::Value::Null,
|
|
742
|
+
};
|
|
743
|
+
let previous_owner = previous
|
|
744
|
+
.pointer(&format!("/teams/{team_key}/team_owner"))
|
|
745
|
+
.cloned()
|
|
746
|
+
.or_else(|| previous.get("team_owner").cloned())
|
|
747
|
+
.unwrap_or(serde_json::Value::Null);
|
|
748
|
+
let previous_receiver = previous
|
|
749
|
+
.pointer(&format!("/teams/{team_key}/leader_receiver"))
|
|
750
|
+
.cloned()
|
|
751
|
+
.or_else(|| previous.get("leader_receiver").cloned())
|
|
752
|
+
.unwrap_or(serde_json::Value::Null);
|
|
753
|
+
let no_previous_binding = previous_owner.is_null() && previous_receiver.is_null();
|
|
754
|
+
if no_previous_binding {
|
|
755
|
+
if let Some(team) = current
|
|
756
|
+
.get_mut("teams")
|
|
757
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
758
|
+
.and_then(|teams| teams.get_mut(team_key))
|
|
759
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
760
|
+
{
|
|
761
|
+
team.insert("team_owner".to_string(), serde_json::Value::Null);
|
|
762
|
+
team.insert("leader_receiver".to_string(), serde_json::Value::Null);
|
|
763
|
+
}
|
|
764
|
+
return crate::state::repository::StateRepository::new(workspace)
|
|
765
|
+
.save(
|
|
766
|
+
crate::state::repository::StateWriteIntent::ClearExactTeamOwnerAndReceiver {
|
|
767
|
+
team_key,
|
|
768
|
+
seed: expected_owner,
|
|
769
|
+
expected_receiver,
|
|
770
|
+
},
|
|
771
|
+
¤t,
|
|
772
|
+
)
|
|
773
|
+
.map_err(|error| {
|
|
774
|
+
LifecycleError::StatePersist(format!("quick-start binding cleanup failed: {error}"))
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
crate::state::repository::StateRepository::new(workspace)
|
|
778
|
+
.save(
|
|
779
|
+
crate::state::repository::StateWriteIntent::RestoreExactTeamOwnerAndReceiver {
|
|
780
|
+
team_key,
|
|
781
|
+
expected_owner,
|
|
782
|
+
expected_receiver,
|
|
783
|
+
previous_owner: &previous_owner,
|
|
784
|
+
previous_receiver: &previous_receiver,
|
|
785
|
+
},
|
|
786
|
+
¤t,
|
|
787
|
+
)
|
|
788
|
+
.map_err(|error| {
|
|
789
|
+
LifecycleError::StatePersist(format!("quick-start binding cleanup failed: {error}"))
|
|
790
|
+
})
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
fn strip_this_attempt_extra_keys(state: &mut serde_json::Value, extra_keys: &[&str]) {
|
|
794
|
+
if let Some(root) = state.as_object_mut() {
|
|
795
|
+
for key in extra_keys {
|
|
796
|
+
root.remove(*key);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
630
801
|
fn restore_registry_receipt(
|
|
631
802
|
snapshots: &[BindingFileSnapshot],
|
|
632
803
|
receipt: &crate::leader::registry::RegistryWriteReceipt,
|
|
@@ -691,10 +862,91 @@ fn clear_fresh_binding_on_refusal(
|
|
|
691
862
|
)))
|
|
692
863
|
}
|
|
693
864
|
|
|
865
|
+
fn existing_runtime_identity_view(
|
|
866
|
+
state: &serde_json::Value,
|
|
867
|
+
requested_team: Option<&str>,
|
|
868
|
+
) -> serde_json::Value {
|
|
869
|
+
let Some(team) = requested_team.filter(|team| !team.is_empty()) else {
|
|
870
|
+
return state.clone();
|
|
871
|
+
};
|
|
872
|
+
let teams = state.get("teams").and_then(serde_json::Value::as_object);
|
|
873
|
+
if teams.is_some_and(|teams| teams.contains_key(team) || !teams.is_empty()) {
|
|
874
|
+
return crate::state::projection::project_top_level_view(state, team);
|
|
875
|
+
}
|
|
876
|
+
state.clone()
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
fn preflight_fresh_leader_identity(
|
|
880
|
+
transport: &dyn Transport,
|
|
881
|
+
) -> Option<(String, Vec<String>, Vec<String>)> {
|
|
882
|
+
let pane = std::env::var("TMUX_PANE")
|
|
883
|
+
.ok()
|
|
884
|
+
.filter(|pane| !pane.is_empty());
|
|
885
|
+
let current_endpoint = crate::tmux_backend::socket_name_from_tmux_env();
|
|
886
|
+
match crate::layout::worker_env::caller_provider_resolution(
|
|
887
|
+
pane.as_deref(),
|
|
888
|
+
current_endpoint.as_deref(),
|
|
889
|
+
transport.tmux_endpoint().as_deref(),
|
|
890
|
+
) {
|
|
891
|
+
crate::layout::worker_env::CallerProviderResolution::Invalid => Some((
|
|
892
|
+
"quick-start refused before spawn: invalid caller tuple".to_string(),
|
|
893
|
+
vec!["stage=strict_provider reason=invalid_caller_tuple".to_string()],
|
|
894
|
+
vec![
|
|
895
|
+
"fix conflicting CALLER_* / TEAM_AGENT_LEADER_* identity; do not run claim-leader"
|
|
896
|
+
.to_string(),
|
|
897
|
+
],
|
|
898
|
+
)),
|
|
899
|
+
crate::layout::worker_env::CallerProviderResolution::Valid(_) => None,
|
|
900
|
+
crate::layout::worker_env::CallerProviderResolution::Absent => {
|
|
901
|
+
let Some(pane) = pane else {
|
|
902
|
+
return None;
|
|
903
|
+
};
|
|
904
|
+
let command = transport
|
|
905
|
+
.query(
|
|
906
|
+
&Target::Pane(PaneId::new(pane)),
|
|
907
|
+
PaneField::PaneCurrentCommand,
|
|
908
|
+
)
|
|
909
|
+
.ok()
|
|
910
|
+
.flatten()
|
|
911
|
+
.unwrap_or_default();
|
|
912
|
+
let explicit = std::env::var("TEAM_AGENT_LEADER_PROVIDER")
|
|
913
|
+
.ok()
|
|
914
|
+
.filter(|provider| !provider.is_empty());
|
|
915
|
+
if command.trim().is_empty() {
|
|
916
|
+
return Some((
|
|
917
|
+
"quick-start refused before spawn: command unobservable".to_string(),
|
|
918
|
+
vec!["stage=command_observation reason=command_unobservable".to_string()],
|
|
919
|
+
vec![
|
|
920
|
+
"run from a live provider pane; do not run claim-leader".to_string(),
|
|
921
|
+
],
|
|
922
|
+
));
|
|
923
|
+
}
|
|
924
|
+
if crate::leader::owner_bind::strict_owner_bind_provider(
|
|
925
|
+
explicit.as_deref(),
|
|
926
|
+
&command,
|
|
927
|
+
)
|
|
928
|
+
.is_some()
|
|
929
|
+
{
|
|
930
|
+
None
|
|
931
|
+
} else {
|
|
932
|
+
Some((
|
|
933
|
+
"quick-start refused before spawn: provider unresolved".to_string(),
|
|
934
|
+
vec!["stage=strict_provider reason=provider_unresolved".to_string()],
|
|
935
|
+
vec![
|
|
936
|
+
"set TEAM_AGENT_LEADER_PROVIDER or run from a provider pane; do not run claim-leader"
|
|
937
|
+
.to_string(),
|
|
938
|
+
],
|
|
939
|
+
))
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
694
945
|
fn bind_fresh_quick_start_leader(
|
|
695
946
|
workspace: &Path,
|
|
696
947
|
team_key: &str,
|
|
697
948
|
seeded_owner: Option<&serde_json::Value>,
|
|
949
|
+
seeded_receiver: Option<&serde_json::Value>,
|
|
698
950
|
transport: &dyn Transport,
|
|
699
951
|
) -> Result<bool, LifecycleError> {
|
|
700
952
|
bind_fresh_quick_start_leader_with(
|
|
@@ -707,6 +959,8 @@ fn bind_fresh_quick_start_leader(
|
|
|
707
959
|
cas_conflict: false,
|
|
708
960
|
applied_grant: None,
|
|
709
961
|
registry_receipt: None,
|
|
962
|
+
frozen_owner: seeded_owner.cloned(),
|
|
963
|
+
frozen_receiver: seeded_receiver.cloned(),
|
|
710
964
|
nonce_writer: None,
|
|
711
965
|
},
|
|
712
966
|
)
|
|
@@ -982,32 +1236,24 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
|
|
|
982
1236
|
open_display: bool,
|
|
983
1237
|
discover: &mut dyn FnMut(&str) -> Result<Vec<String>, ()>,
|
|
984
1238
|
) -> Result<QuickStartReport, LifecycleError> {
|
|
1239
|
+
// B-7 / 036b N38 三行 fail-fast — TEAM_AGENT_LEADER_PANE_ID 主动路径在 quick-start
|
|
1240
|
+
// 入口验活;死/缺(Dead)的 pane 必须明确报错,不可 silent bind 到 spawner /
|
|
1241
|
+
// owner_bind / lease / display 任一消费点。被动路径(display/seed 等)各自走
|
|
1242
|
+
// 降级+event,不在这里挡。错误三行式:error(含 pane id 字面)/action(unset
|
|
1243
|
+
// 或修 env)/log(env var 名)。
|
|
1244
|
+
let team_workspace = team_workspace(agents_dir);
|
|
1245
|
+
let warning_workspaces = [workspace, team_workspace.as_path()];
|
|
1246
|
+
validate_active_leader_pane_env_with_workspaces(transport, &warning_workspaces)?;
|
|
985
1247
|
if !agents_dir.exists() {
|
|
986
1248
|
return Err(LifecycleError::Compile(format!(
|
|
987
1249
|
"agents dir not found: {}",
|
|
988
1250
|
agents_dir.display()
|
|
989
1251
|
)));
|
|
990
1252
|
}
|
|
991
|
-
crate::
|
|
992
|
-
LifecycleError::PiModelPreflight {
|
|
993
|
-
requested: error.requested,
|
|
994
|
-
candidates: error.candidates,
|
|
995
|
-
action: error.action,
|
|
996
|
-
not_ready: error.not_ready,
|
|
997
|
-
}
|
|
998
|
-
})?;
|
|
1253
|
+
crate::lifecycle::launch::run_pi_catalog_preflight(agents_dir, discover)?;
|
|
999
1254
|
let workspace = workspace.to_path_buf();
|
|
1000
1255
|
let mut spec = crate::compiler::compile_team(agents_dir)
|
|
1001
1256
|
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
1002
|
-
// B-7 / 036b N38 三行 fail-fast — TEAM_AGENT_LEADER_PANE_ID 主动路径在 quick-start
|
|
1003
|
-
// 入口验活;死/缺(Dead)的 pane 必须明确报错,不可 silent bind 到 spawner /
|
|
1004
|
-
// owner_bind / lease / display 任一消费点。被动路径(display/seed 等)各自走
|
|
1005
|
-
// 降级+event,不在这里挡。错误三行式:error(含 pane id 字面)/action(unset
|
|
1006
|
-
// 或修 env)/log(env var 名)。 Role/schema and Pi model admission above is pure and
|
|
1007
|
-
// must finish before this validation can emit a warning event.
|
|
1008
|
-
let team_workspace = team_workspace(agents_dir);
|
|
1009
|
-
let warning_workspaces = [workspace.as_path(), team_workspace.as_path()];
|
|
1010
|
-
validate_active_leader_pane_env_with_workspaces(transport, &warning_workspaces)?;
|
|
1011
1257
|
override_spec_workspace(&mut spec, &workspace);
|
|
1012
1258
|
if !open_display {
|
|
1013
1259
|
override_spec_display_backend(&mut spec, "none");
|
|
@@ -1045,7 +1291,8 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
|
|
|
1045
1291
|
.as_deref()
|
|
1046
1292
|
.is_none_or(|team| runtime_state_has_quick_start_team(&state, team))
|
|
1047
1293
|
{
|
|
1048
|
-
let
|
|
1294
|
+
let identity = existing_runtime_identity_view(&state, requested_team.as_deref());
|
|
1295
|
+
let session_name = identity
|
|
1049
1296
|
.get("session_name")
|
|
1050
1297
|
.and_then(serde_json::Value::as_str)
|
|
1051
1298
|
.filter(|s| !s.is_empty())
|
|
@@ -1053,11 +1300,19 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
|
|
|
1053
1300
|
let attach_commands = session_name
|
|
1054
1301
|
.as_ref()
|
|
1055
1302
|
.map(|session| {
|
|
1056
|
-
let windows = quick_start_attach_window_names(&
|
|
1303
|
+
let windows = quick_start_attach_window_names(&identity);
|
|
1057
1304
|
attach_commands_for_runtime_windows(
|
|
1058
|
-
|
|
1305
|
+
identity
|
|
1059
1306
|
.get("tmux_endpoint")
|
|
1060
1307
|
.and_then(serde_json::Value::as_str)
|
|
1308
|
+
.or_else(|| {
|
|
1309
|
+
identity.get("tmux_socket").and_then(serde_json::Value::as_str)
|
|
1310
|
+
})
|
|
1311
|
+
.or_else(|| {
|
|
1312
|
+
state
|
|
1313
|
+
.get("tmux_endpoint")
|
|
1314
|
+
.and_then(serde_json::Value::as_str)
|
|
1315
|
+
})
|
|
1061
1316
|
.or_else(|| {
|
|
1062
1317
|
state.get("tmux_socket").and_then(serde_json::Value::as_str)
|
|
1063
1318
|
}),
|
|
@@ -1092,12 +1347,18 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
|
|
|
1092
1347
|
}
|
|
1093
1348
|
next_actions.extend(attach_commands.iter().cloned());
|
|
1094
1349
|
}
|
|
1350
|
+
let agent_ids = identity
|
|
1351
|
+
.get("agents")
|
|
1352
|
+
.and_then(serde_json::Value::as_object)
|
|
1353
|
+
.map(|agents| agents.keys().cloned().collect::<Vec<_>>())
|
|
1354
|
+
.unwrap_or_default();
|
|
1095
1355
|
return Ok(QuickStartReport::ExistingRuntime {
|
|
1096
1356
|
team: requested_team.clone(),
|
|
1097
1357
|
session_name,
|
|
1098
1358
|
state_path: Some(state_path),
|
|
1099
1359
|
next_actions,
|
|
1100
1360
|
attach_commands,
|
|
1361
|
+
agent_ids,
|
|
1101
1362
|
});
|
|
1102
1363
|
}
|
|
1103
1364
|
}
|
|
@@ -1113,6 +1374,14 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
|
|
|
1113
1374
|
// team_key 已在入口由显式 id/name 或 compiled spec identity 单次确定。
|
|
1114
1375
|
let state_team_key = canonical_team_key;
|
|
1115
1376
|
warn_ignored_owner_team_id(workspace.as_path(), agents_dir, &state_team_key);
|
|
1377
|
+
if let Some((summary, blockers, next_actions)) = preflight_fresh_leader_identity(transport) {
|
|
1378
|
+
return Ok(QuickStartReport::PreflightBlocked {
|
|
1379
|
+
summary,
|
|
1380
|
+
blockers,
|
|
1381
|
+
next_actions,
|
|
1382
|
+
attach_commands: Vec::new(),
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1116
1385
|
// E5 spec 迁移:spec 写到 .team/runtime/<team_key>/(中间产物,绝不落用户目录 agents_dir)。
|
|
1117
1386
|
// Bug2:原子写(tmp+rename),避免半截 spec。
|
|
1118
1387
|
let spec_path = crate::model::paths::runtime_spec_path(&workspace, &state_team_key);
|
|
@@ -1144,6 +1413,13 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
|
|
|
1144
1413
|
== Some("quick-start")
|
|
1145
1414
|
})
|
|
1146
1415
|
.cloned();
|
|
1416
|
+
let seeded_receiver = state
|
|
1417
|
+
.get("teams")
|
|
1418
|
+
.and_then(serde_json::Value::as_object)
|
|
1419
|
+
.and_then(|teams| teams.get(&state_team_key))
|
|
1420
|
+
.and_then(|team| team.get("leader_receiver"))
|
|
1421
|
+
.cloned()
|
|
1422
|
+
.or_else(|| state.get("leader_receiver").cloned());
|
|
1147
1423
|
// 0.5.x Phase 1d hot-path 接线(裁决1 msg_76e1d98202b8): use the
|
|
1148
1424
|
// generic annotator that writes `state.transport = { kind, source }`
|
|
1149
1425
|
// for every backend AND (for tmux) preserves the existing
|
|
@@ -1180,12 +1456,20 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
|
|
|
1180
1456
|
// Fresh initialization owns this one fail-closed bind attempt. It is
|
|
1181
1457
|
// independent of display layout, so --no-display never suppresses receiver
|
|
1182
1458
|
// binding. Readiness receives true only after canonical registry readback.
|
|
1459
|
+
let _ = take_fresh_bind_refusal();
|
|
1183
1460
|
launch.leader_receiver_attached = bind_fresh_quick_start_leader(
|
|
1184
1461
|
&workspace,
|
|
1185
1462
|
&state_team_key,
|
|
1186
1463
|
seeded_owner.as_ref(),
|
|
1464
|
+
seeded_receiver.as_ref(),
|
|
1187
1465
|
transport,
|
|
1188
1466
|
)?;
|
|
1467
|
+
if !launch.leader_receiver_attached {
|
|
1468
|
+
if let Some((stage, reason)) = take_fresh_bind_refusal() {
|
|
1469
|
+
launch.leader_bind_stage = Some(stage.to_string());
|
|
1470
|
+
launch.leader_bind_reason = Some(reason.to_string());
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1189
1473
|
launch.session_capture_incomplete_agents =
|
|
1190
1474
|
quick_start_session_capture_incomplete_agents(&workspace, &state_team_key);
|
|
1191
1475
|
let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.clone());
|
|
@@ -1258,6 +1542,7 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
|
|
|
1258
1542
|
attach_commands,
|
|
1259
1543
|
display_backend,
|
|
1260
1544
|
worker_readiness,
|
|
1545
|
+
team: state_team_key,
|
|
1261
1546
|
})
|
|
1262
1547
|
}
|
|
1263
1548
|
|
|
@@ -1266,7 +1551,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1266
1551
|
use super::*;
|
|
1267
1552
|
use crate::layout::worker_env::{CALLER_ENDPOINT_ENV, CALLER_PANE_ENV, CALLER_PROVIDER_ENV};
|
|
1268
1553
|
use crate::transport::test_support::OfflineTransport;
|
|
1269
|
-
use crate::transport::{PaneInfo, SessionName, WindowName};
|
|
1554
|
+
use crate::transport::{PaneId, PaneInfo, SessionName, WindowName};
|
|
1270
1555
|
use serde_json::json;
|
|
1271
1556
|
use std::collections::BTreeMap;
|
|
1272
1557
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
@@ -1275,7 +1560,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1275
1560
|
|
|
1276
1561
|
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
|
1277
1562
|
|
|
1278
|
-
fn
|
|
1563
|
+
fn workspace_with_provider(tag: &str, provider: &str) -> PathBuf {
|
|
1279
1564
|
let path = std::env::temp_dir().join(format!(
|
|
1280
1565
|
"ta-quick-start-bind-{tag}-{}-{}",
|
|
1281
1566
|
std::process::id(),
|
|
@@ -1288,14 +1573,19 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1288
1573
|
"active_team_key": "fresh",
|
|
1289
1574
|
"team_key": "fresh",
|
|
1290
1575
|
"session_name": "team-fresh",
|
|
1291
|
-
"agents": {"sol": {"status": "running", "provider":
|
|
1576
|
+
"agents": {"sol": {"status": "running", "provider": provider}}
|
|
1292
1577
|
}),
|
|
1293
1578
|
)
|
|
1294
1579
|
.unwrap();
|
|
1295
1580
|
path
|
|
1296
1581
|
}
|
|
1297
1582
|
|
|
1583
|
+
fn workspace(tag: &str) -> PathBuf {
|
|
1584
|
+
workspace_with_provider(tag, "codex")
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1298
1587
|
struct MockOps {
|
|
1588
|
+
provider: String,
|
|
1299
1589
|
pane: Option<String>,
|
|
1300
1590
|
explicit_provider: Option<String>,
|
|
1301
1591
|
endpoint: Option<String>,
|
|
@@ -1308,15 +1598,20 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1308
1598
|
readback_calls: usize,
|
|
1309
1599
|
attached_provider: Option<crate::provider::Provider>,
|
|
1310
1600
|
applied_grant: Option<(serde_json::Value, serde_json::Value)>,
|
|
1601
|
+
frozen_owner: Option<serde_json::Value>,
|
|
1602
|
+
frozen_receiver: Option<serde_json::Value>,
|
|
1603
|
+
last_expected_receiver: Option<serde_json::Value>,
|
|
1604
|
+
extra_state_keys: Vec<&'static str>,
|
|
1311
1605
|
}
|
|
1312
1606
|
|
|
1313
1607
|
impl Default for MockOps {
|
|
1314
1608
|
fn default() -> Self {
|
|
1315
1609
|
Self {
|
|
1610
|
+
provider: "codex".to_string(),
|
|
1316
1611
|
pane: Some("%42".to_string()),
|
|
1317
|
-
explicit_provider: Some("
|
|
1612
|
+
explicit_provider: Some("codex".to_string()),
|
|
1318
1613
|
endpoint: Some("/private/tmp/tmux-test/default".to_string()),
|
|
1319
|
-
command: Some("
|
|
1614
|
+
command: Some("codex".to_string()),
|
|
1320
1615
|
attach_ok: true,
|
|
1321
1616
|
register_ok: true,
|
|
1322
1617
|
readback_ok: true,
|
|
@@ -1325,55 +1620,22 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1325
1620
|
readback_calls: 0,
|
|
1326
1621
|
attached_provider: None,
|
|
1327
1622
|
applied_grant: None,
|
|
1623
|
+
frozen_owner: None,
|
|
1624
|
+
frozen_receiver: None,
|
|
1625
|
+
last_expected_receiver: None,
|
|
1626
|
+
extra_state_keys: Vec::new(),
|
|
1328
1627
|
}
|
|
1329
1628
|
}
|
|
1330
1629
|
}
|
|
1331
1630
|
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
1341
|
-
std::fs::write(
|
|
1342
|
-
team.join("TEAM.md"),
|
|
1343
|
-
"---\nname: pi-tools\nprovider: pi\n---\n\nPi team.\n",
|
|
1344
|
-
)
|
|
1345
|
-
.unwrap();
|
|
1346
|
-
std::fs::write(
|
|
1347
|
-
team.join("agents/worker.md"),
|
|
1348
|
-
"---\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",
|
|
1349
|
-
)
|
|
1350
|
-
.unwrap();
|
|
1351
|
-
|
|
1352
|
-
let transport = crate::transport::test_support::OfflineTransport::new();
|
|
1353
|
-
let mut discover = |_requested: &str| -> Result<Vec<String>, ()> { Err(()) };
|
|
1354
|
-
let error = quick_start_with_transport_in_workspace_with_display_pi_preflight(
|
|
1355
|
-
&root,
|
|
1356
|
-
&team,
|
|
1357
|
-
None,
|
|
1358
|
-
true,
|
|
1359
|
-
None,
|
|
1360
|
-
&transport,
|
|
1361
|
-
false,
|
|
1362
|
-
&mut discover,
|
|
1363
|
-
)
|
|
1364
|
-
.expect_err("invalid Pi category must fail before quick-start persistence");
|
|
1365
|
-
let error = error.to_string();
|
|
1366
|
-
assert!(error.contains("Pi does not support Team Agent tool category \"provider_builtin\""));
|
|
1367
|
-
assert!(error.contains("remove it from the role's tools"));
|
|
1368
|
-
assert!(
|
|
1369
|
-
!crate::state::persist::runtime_state_path(&root).exists(),
|
|
1370
|
-
"quick-start validation failure must not persist runtime state"
|
|
1371
|
-
);
|
|
1372
|
-
assert!(
|
|
1373
|
-
!root.join(".team/runtime").exists(),
|
|
1374
|
-
"quick-start validation failure must not create runtime artifacts"
|
|
1375
|
-
);
|
|
1376
|
-
let _ = std::fs::remove_dir_all(root);
|
|
1631
|
+
impl MockOps {
|
|
1632
|
+
fn with_provider(provider: &str) -> Self {
|
|
1633
|
+
let mut ops = Self::default();
|
|
1634
|
+
ops.provider = provider.to_string();
|
|
1635
|
+
ops.explicit_provider = Some(provider.to_string());
|
|
1636
|
+
ops.command = Some(provider.to_string());
|
|
1637
|
+
ops
|
|
1638
|
+
}
|
|
1377
1639
|
}
|
|
1378
1640
|
|
|
1379
1641
|
impl FreshQuickStartLeaderBindingOps for MockOps {
|
|
@@ -1406,6 +1668,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1406
1668
|
if !self.attach_ok {
|
|
1407
1669
|
state["failed_attach_mutation"] = json!(true);
|
|
1408
1670
|
let _ = crate::state::persist::save_runtime_state(workspace, state);
|
|
1671
|
+
self.extra_state_keys.push("failed_attach_mutation");
|
|
1409
1672
|
return false;
|
|
1410
1673
|
}
|
|
1411
1674
|
let team_key = state
|
|
@@ -1413,12 +1676,19 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1413
1676
|
.and_then(serde_json::Value::as_str)
|
|
1414
1677
|
.unwrap_or("fresh")
|
|
1415
1678
|
.to_string();
|
|
1416
|
-
let expected_owner =
|
|
1417
|
-
|
|
1679
|
+
let expected_owner = self
|
|
1680
|
+
.frozen_owner
|
|
1681
|
+
.clone()
|
|
1682
|
+
.or_else(|| state.get("team_owner").cloned());
|
|
1683
|
+
let expected_receiver = self
|
|
1684
|
+
.frozen_receiver
|
|
1685
|
+
.clone()
|
|
1686
|
+
.or_else(|| state.get("leader_receiver").cloned());
|
|
1687
|
+
self.last_expected_receiver = expected_receiver.clone();
|
|
1418
1688
|
let owner = expected_owner.clone().unwrap_or_else(|| {
|
|
1419
1689
|
json!({
|
|
1420
1690
|
"pane_id": pane.as_str(),
|
|
1421
|
-
"provider":
|
|
1691
|
+
"provider": self.provider.as_str(),
|
|
1422
1692
|
"leader_session_uuid": "uuid-fresh",
|
|
1423
1693
|
"owner_epoch": 1
|
|
1424
1694
|
})
|
|
@@ -1431,7 +1701,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1431
1701
|
"mode": "direct_tmux",
|
|
1432
1702
|
"pane_id": pane.as_str(),
|
|
1433
1703
|
"status": "attached",
|
|
1434
|
-
"provider":
|
|
1704
|
+
"provider": self.provider.as_str(),
|
|
1435
1705
|
"leader_session_uuid": "uuid-fresh",
|
|
1436
1706
|
"owner_epoch": owner_epoch,
|
|
1437
1707
|
"tmux_socket": self.endpoint
|
|
@@ -1471,6 +1741,16 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1471
1741
|
self.applied_grant.clone()
|
|
1472
1742
|
}
|
|
1473
1743
|
|
|
1744
|
+
fn frozen_seed_receipt(&self) -> Option<(serde_json::Value, serde_json::Value)> {
|
|
1745
|
+
self.frozen_owner
|
|
1746
|
+
.clone()
|
|
1747
|
+
.zip(self.frozen_receiver.clone())
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
fn this_attempt_extra_state_keys(&self) -> Vec<&'static str> {
|
|
1751
|
+
self.extra_state_keys.clone()
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1474
1754
|
fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
1475
1755
|
self.register_calls += 1;
|
|
1476
1756
|
let mut persisted = crate::state::persist::load_runtime_state(workspace).unwrap();
|
|
@@ -1487,6 +1767,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1487
1767
|
if !self.register_ok {
|
|
1488
1768
|
persisted["failed_registry_mutation"] = json!(true);
|
|
1489
1769
|
crate::state::persist::save_runtime_state(workspace, &persisted).unwrap();
|
|
1770
|
+
self.extra_state_keys.push("failed_registry_mutation");
|
|
1490
1771
|
}
|
|
1491
1772
|
self.register_ok
|
|
1492
1773
|
}
|
|
@@ -1497,6 +1778,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1497
1778
|
if !self.readback_ok {
|
|
1498
1779
|
persisted["failed_readback_mutation"] = json!(true);
|
|
1499
1780
|
crate::state::persist::save_runtime_state(workspace, &persisted).unwrap();
|
|
1781
|
+
self.extra_state_keys.push("failed_readback_mutation");
|
|
1500
1782
|
}
|
|
1501
1783
|
self.readback_ok
|
|
1502
1784
|
&& persisted
|
|
@@ -1509,12 +1791,12 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1509
1791
|
}
|
|
1510
1792
|
|
|
1511
1793
|
#[test]
|
|
1512
|
-
fn
|
|
1794
|
+
fn fresh_binding_persists_then_registers_then_requires_canonical_readback_provider_independent() {
|
|
1513
1795
|
let workspace = workspace("positive");
|
|
1514
1796
|
let mut ops = MockOps::default();
|
|
1515
1797
|
assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
1516
1798
|
.unwrap());
|
|
1517
|
-
assert_eq!(ops.attached_provider, Some(crate::provider::Provider::
|
|
1799
|
+
assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Codex));
|
|
1518
1800
|
assert_eq!(ops.attach_calls, 1);
|
|
1519
1801
|
assert_eq!(ops.register_calls, 1);
|
|
1520
1802
|
assert_eq!(ops.readback_calls, 1);
|
|
@@ -1538,6 +1820,146 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1538
1820
|
);
|
|
1539
1821
|
}
|
|
1540
1822
|
|
|
1823
|
+
fn seed_fresh_receipts(
|
|
1824
|
+
workspace: &std::path::Path,
|
|
1825
|
+
) -> (serde_json::Value, serde_json::Value, serde_json::Value) {
|
|
1826
|
+
let mut state = crate::state::persist::load_runtime_state(workspace).unwrap();
|
|
1827
|
+
let caller = crate::state::owner_gate::CallerIdentity {
|
|
1828
|
+
pane_id: "%42".to_string(),
|
|
1829
|
+
provider: "codex".to_string(),
|
|
1830
|
+
machine_fingerprint: "fp".to_string(),
|
|
1831
|
+
leader_session_uuid: "uuid-fresh".to_string(),
|
|
1832
|
+
leader_session_uuid_source: "env".to_string(),
|
|
1833
|
+
};
|
|
1834
|
+
assert!(super::spec_state::seed_launched_owner_from_caller_with_provider_lookup(
|
|
1835
|
+
&mut state,
|
|
1836
|
+
caller,
|
|
1837
|
+
|_| None
|
|
1838
|
+
));
|
|
1839
|
+
let owner = state
|
|
1840
|
+
.pointer("/teams/fresh/team_owner")
|
|
1841
|
+
.cloned()
|
|
1842
|
+
.expect("seed owner");
|
|
1843
|
+
let receiver = state
|
|
1844
|
+
.pointer("/teams/fresh/leader_receiver")
|
|
1845
|
+
.cloned()
|
|
1846
|
+
.expect("seed receiver");
|
|
1847
|
+
assert_eq!(
|
|
1848
|
+
receiver.get("status").and_then(serde_json::Value::as_str),
|
|
1849
|
+
Some("pending")
|
|
1850
|
+
);
|
|
1851
|
+
crate::state::persist::save_runtime_state(workspace, &state).unwrap();
|
|
1852
|
+
(state, owner, receiver)
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
#[test]
|
|
1856
|
+
#[serial_test::serial(env)]
|
|
1857
|
+
fn fresh_seed_exemption_requires_frozen_receiver_receipt() {
|
|
1858
|
+
let workspace = workspace("frozen-seed");
|
|
1859
|
+
let (_state, owner, frozen_receiver) = seed_fresh_receipts(&workspace);
|
|
1860
|
+
let mut ops = MockOps {
|
|
1861
|
+
frozen_owner: Some(owner.clone()),
|
|
1862
|
+
frozen_receiver: Some(frozen_receiver.clone()),
|
|
1863
|
+
..MockOps::default()
|
|
1864
|
+
};
|
|
1865
|
+
assert!(bind_fresh_quick_start_leader_with(
|
|
1866
|
+
&workspace,
|
|
1867
|
+
"fresh",
|
|
1868
|
+
Some(&owner),
|
|
1869
|
+
&mut ops
|
|
1870
|
+
)
|
|
1871
|
+
.unwrap());
|
|
1872
|
+
assert_eq!(ops.attach_calls, 1);
|
|
1873
|
+
assert_eq!(ops.last_expected_receiver, Some(frozen_receiver));
|
|
1874
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
#[test]
|
|
1878
|
+
#[serial_test::serial(env)]
|
|
1879
|
+
fn newer_pending_receiver_is_not_cas_expected() {
|
|
1880
|
+
let workspace = workspace("newer-pending");
|
|
1881
|
+
let (mut state, owner, frozen_receiver) = seed_fresh_receipts(&workspace);
|
|
1882
|
+
let mut newer = frozen_receiver.clone();
|
|
1883
|
+
newer["pane_id"] = json!("%99");
|
|
1884
|
+
newer["nonce"] = json!("winner");
|
|
1885
|
+
state["leader_receiver"] = newer.clone();
|
|
1886
|
+
state["teams"]["fresh"]["leader_receiver"] = newer.clone();
|
|
1887
|
+
crate::state::persist::save_runtime_state_with_receiver_authority(
|
|
1888
|
+
&workspace,
|
|
1889
|
+
&state,
|
|
1890
|
+
"fresh",
|
|
1891
|
+
None,
|
|
1892
|
+
)
|
|
1893
|
+
.unwrap();
|
|
1894
|
+
let on_disk = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
1895
|
+
assert_eq!(
|
|
1896
|
+
on_disk
|
|
1897
|
+
.pointer("/teams/fresh/leader_receiver")
|
|
1898
|
+
.cloned()
|
|
1899
|
+
.as_ref(),
|
|
1900
|
+
Some(&newer),
|
|
1901
|
+
"pending winner must be on disk before bind"
|
|
1902
|
+
);
|
|
1903
|
+
let mut ops = MockOps {
|
|
1904
|
+
frozen_owner: Some(owner.clone()),
|
|
1905
|
+
frozen_receiver: Some(frozen_receiver),
|
|
1906
|
+
..MockOps::default()
|
|
1907
|
+
};
|
|
1908
|
+
assert!(!bind_fresh_quick_start_leader_with(
|
|
1909
|
+
&workspace,
|
|
1910
|
+
"fresh",
|
|
1911
|
+
Some(&owner),
|
|
1912
|
+
&mut ops
|
|
1913
|
+
)
|
|
1914
|
+
.unwrap());
|
|
1915
|
+
assert_eq!(ops.attach_calls, 0);
|
|
1916
|
+
assert_eq!(ops.last_expected_receiver, None);
|
|
1917
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
#[test]
|
|
1921
|
+
#[serial_test::serial(env)]
|
|
1922
|
+
fn newer_attached_receiver_is_kept() {
|
|
1923
|
+
let workspace = workspace("newer-attached");
|
|
1924
|
+
let (mut state, owner, frozen_receiver) = seed_fresh_receipts(&workspace);
|
|
1925
|
+
let mut winner = frozen_receiver.clone();
|
|
1926
|
+
winner["status"] = json!("attached");
|
|
1927
|
+
winner["pane_id"] = json!("%99");
|
|
1928
|
+
state["leader_receiver"] = winner.clone();
|
|
1929
|
+
state["teams"]["fresh"]["leader_receiver"] = winner.clone();
|
|
1930
|
+
crate::state::persist::save_runtime_state_with_receiver_authority(
|
|
1931
|
+
&workspace,
|
|
1932
|
+
&state,
|
|
1933
|
+
"fresh",
|
|
1934
|
+
None,
|
|
1935
|
+
)
|
|
1936
|
+
.unwrap();
|
|
1937
|
+
let on_disk = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
1938
|
+
assert_eq!(
|
|
1939
|
+
on_disk
|
|
1940
|
+
.pointer("/teams/fresh/leader_receiver")
|
|
1941
|
+
.cloned()
|
|
1942
|
+
.as_ref(),
|
|
1943
|
+
Some(&winner),
|
|
1944
|
+
"attached winner must be on disk before bind"
|
|
1945
|
+
);
|
|
1946
|
+
let mut ops = MockOps {
|
|
1947
|
+
frozen_owner: Some(owner.clone()),
|
|
1948
|
+
frozen_receiver: Some(frozen_receiver),
|
|
1949
|
+
..MockOps::default()
|
|
1950
|
+
};
|
|
1951
|
+
assert!(!bind_fresh_quick_start_leader_with(
|
|
1952
|
+
&workspace,
|
|
1953
|
+
"fresh",
|
|
1954
|
+
Some(&owner),
|
|
1955
|
+
&mut ops
|
|
1956
|
+
)
|
|
1957
|
+
.unwrap());
|
|
1958
|
+
assert_eq!(ops.attach_calls, 0);
|
|
1959
|
+
assert_eq!(ops.last_expected_receiver, None);
|
|
1960
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1541
1963
|
#[test]
|
|
1542
1964
|
fn fresh_bind_refusal_event_keeps_first_safe_stage_and_reason() {
|
|
1543
1965
|
for (case, expected_stage, expected_reason) in [
|
|
@@ -1671,16 +2093,18 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1671
2093
|
| "dual_state_mismatch" => {
|
|
1672
2094
|
state["workspace"] = json!(workspace);
|
|
1673
2095
|
state["team_owner"] = json!({
|
|
1674
|
-
"pane_id": "%42", "provider": "
|
|
2096
|
+
"pane_id": "%42", "provider": "codex",
|
|
1675
2097
|
"leader_session_uuid": "uuid-fresh", "owner_epoch": 1
|
|
1676
2098
|
});
|
|
1677
2099
|
state["leader_receiver"] = json!({
|
|
1678
2100
|
"mode": "direct_tmux", "status": "attached", "pane_id": "%42",
|
|
1679
|
-
"provider": "
|
|
2101
|
+
"provider": "codex", "leader_session_uuid": "uuid-fresh", "owner_epoch": 1,
|
|
1680
2102
|
"tmux_socket": "/private/tmp/tmux-test/default"
|
|
1681
2103
|
});
|
|
1682
2104
|
match case {
|
|
1683
|
-
"provider_mismatch" =>
|
|
2105
|
+
"provider_mismatch" => {
|
|
2106
|
+
state["leader_receiver"]["provider"] = json!("claude")
|
|
2107
|
+
},
|
|
1684
2108
|
"socket_mismatch" => state["leader_receiver"]["tmux_socket"] = json!("/tmp/other"),
|
|
1685
2109
|
"workspace_mismatch" => state["workspace"] = json!(workspace.join("other")),
|
|
1686
2110
|
"dual_state_mismatch" => state["team_owner"]["owner_epoch"] = json!(2),
|
|
@@ -1739,7 +2163,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1739
2163
|
"active_team_key": "fresh",
|
|
1740
2164
|
"team_key": "fresh",
|
|
1741
2165
|
"session_name": "team-fresh",
|
|
1742
|
-
"agents": {"sol": {"status": "running", "provider": "
|
|
2166
|
+
"agents": {"sol": {"status": "running", "provider": "codex"}},
|
|
1743
2167
|
"teams": {
|
|
1744
2168
|
"current": {
|
|
1745
2169
|
"team_key": "current",
|
|
@@ -1750,10 +2174,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1750
2174
|
"workspace": workspace,
|
|
1751
2175
|
"team_key": "fresh",
|
|
1752
2176
|
"session_name": "team-fresh",
|
|
1753
|
-
"agents": {"sol": {"status": "running", "provider": "
|
|
2177
|
+
"agents": {"sol": {"status": "running", "provider": "codex"}},
|
|
1754
2178
|
"team_owner": {
|
|
1755
2179
|
"pane_id": "%42",
|
|
1756
|
-
"provider": "
|
|
2180
|
+
"provider": "codex",
|
|
1757
2181
|
"leader_session_uuid": "uuid-fresh",
|
|
1758
2182
|
"owner_epoch": 1
|
|
1759
2183
|
},
|
|
@@ -1761,7 +2185,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1761
2185
|
"mode": "direct_tmux",
|
|
1762
2186
|
"status": "attached",
|
|
1763
2187
|
"pane_id": "%42",
|
|
1764
|
-
"provider": "
|
|
2188
|
+
"provider": "codex",
|
|
1765
2189
|
"leader_session_uuid": "uuid-fresh",
|
|
1766
2190
|
"owner_epoch": 1,
|
|
1767
2191
|
"tmux_socket": ambient
|
|
@@ -1787,7 +2211,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1787
2211
|
}
|
|
1788
2212
|
|
|
1789
2213
|
fn explicit_provider(&mut self) -> Option<String> {
|
|
1790
|
-
Some("
|
|
2214
|
+
Some("codex".to_string())
|
|
1791
2215
|
}
|
|
1792
2216
|
|
|
1793
2217
|
fn tmux_endpoint(&mut self) -> Option<String> {
|
|
@@ -1795,7 +2219,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1795
2219
|
}
|
|
1796
2220
|
|
|
1797
2221
|
fn observe_command(&mut self, _pane: &PaneId) -> Option<String> {
|
|
1798
|
-
Some("
|
|
2222
|
+
Some("codex".to_string())
|
|
1799
2223
|
}
|
|
1800
2224
|
|
|
1801
2225
|
fn attach(
|
|
@@ -1845,8 +2269,28 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1845
2269
|
attach_calls: 0,
|
|
1846
2270
|
registry_receipt: None,
|
|
1847
2271
|
};
|
|
1848
|
-
|
|
1849
|
-
|
|
2272
|
+
let endpoint = "/private/tmp/tmux-test/default";
|
|
2273
|
+
let transport = OfflineTransport::default()
|
|
2274
|
+
.with_tmux_endpoint(endpoint)
|
|
2275
|
+
.with_targets(vec![PaneInfo {
|
|
2276
|
+
pane_id: PaneId::new("%42"),
|
|
2277
|
+
session: SessionName::new("s"),
|
|
2278
|
+
window_index: None,
|
|
2279
|
+
window_name: None,
|
|
2280
|
+
pane_index: None,
|
|
2281
|
+
tty: None,
|
|
2282
|
+
current_command: Some("codex".to_string()),
|
|
2283
|
+
current_path: Some(workspace.clone()),
|
|
2284
|
+
active: true,
|
|
2285
|
+
pane_pid: None,
|
|
2286
|
+
leader_env: Default::default(),
|
|
2287
|
+
}]);
|
|
2288
|
+
assert!(crate::transport_factory::with_leader_endpoint_transport(
|
|
2289
|
+
endpoint,
|
|
2290
|
+
transport,
|
|
2291
|
+
|| bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
2292
|
+
)
|
|
2293
|
+
.unwrap());
|
|
1850
2294
|
assert_eq!(ops.attach_calls, 0, "preseeded dual state must not reattach");
|
|
1851
2295
|
let path = crate::leader::registry::registry_dir().unwrap().join(format!(
|
|
1852
2296
|
"{}__fresh.json",
|
|
@@ -1867,7 +2311,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1867
2311
|
state["workspace"] = json!(workspace);
|
|
1868
2312
|
state["team_owner"] = json!({
|
|
1869
2313
|
"pane_id": "%old",
|
|
1870
|
-
"provider": "
|
|
2314
|
+
"provider": "codex",
|
|
1871
2315
|
"leader_session_uuid": "uuid-old",
|
|
1872
2316
|
"owner_epoch": 4,
|
|
1873
2317
|
"claimed_via": "quick-start"
|
|
@@ -1876,7 +2320,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1876
2320
|
"mode": "direct_tmux",
|
|
1877
2321
|
"status": "attached",
|
|
1878
2322
|
"pane_id": "%old",
|
|
1879
|
-
"provider": "
|
|
2323
|
+
"provider": "codex",
|
|
1880
2324
|
"leader_session_uuid": "uuid-old",
|
|
1881
2325
|
"owner_epoch": 4,
|
|
1882
2326
|
"tmux_socket": "/private/tmp/tmux-test/old"
|
|
@@ -1897,7 +2341,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1897
2341
|
fn matching_seed() -> serde_json::Value {
|
|
1898
2342
|
json!({
|
|
1899
2343
|
"pane_id": "%42",
|
|
1900
|
-
"provider": "
|
|
2344
|
+
"provider": "codex",
|
|
1901
2345
|
"leader_session_uuid": "uuid-fresh",
|
|
1902
2346
|
"owner_epoch": 1,
|
|
1903
2347
|
"claimed_via": "quick-start"
|
|
@@ -1909,16 +2353,29 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1909
2353
|
"mode": "direct_tmux",
|
|
1910
2354
|
"status": "attached",
|
|
1911
2355
|
"pane_id": "%42",
|
|
1912
|
-
"provider": "
|
|
2356
|
+
"provider": "codex",
|
|
1913
2357
|
"leader_session_uuid": "uuid-fresh",
|
|
1914
2358
|
"owner_epoch": 1,
|
|
1915
2359
|
"tmux_socket": "/private/tmp/tmux-test/default"
|
|
1916
2360
|
})
|
|
1917
2361
|
}
|
|
1918
2362
|
|
|
1919
|
-
fn
|
|
1920
|
-
|
|
1921
|
-
|
|
2363
|
+
fn matching_pending_receiver() -> serde_json::Value {
|
|
2364
|
+
json!({
|
|
2365
|
+
"mode": "direct_tmux",
|
|
2366
|
+
"status": "pending",
|
|
2367
|
+
"discovery": "quick_start_seed",
|
|
2368
|
+
"pane_id": "%42",
|
|
2369
|
+
"provider": "codex",
|
|
2370
|
+
"leader_session_uuid": "uuid-fresh",
|
|
2371
|
+
"owner_epoch": 1,
|
|
2372
|
+
"tmux_socket": "/private/tmp/tmux-test/default"
|
|
2373
|
+
})
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
fn workspace_with_fresh_seed(tag: &str) -> (PathBuf, serde_json::Value) {
|
|
2377
|
+
let workspace = workspace(tag);
|
|
2378
|
+
let seed = matching_seed();
|
|
1922
2379
|
crate::state::persist::save_runtime_state(
|
|
1923
2380
|
&workspace,
|
|
1924
2381
|
&json!({
|
|
@@ -1926,13 +2383,46 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1926
2383
|
"active_team_key": "fresh",
|
|
1927
2384
|
"team_key": "fresh",
|
|
1928
2385
|
"session_name": "team-fresh",
|
|
1929
|
-
"agents": {"sol": {"status": "running", "provider": "
|
|
2386
|
+
"agents": {"sol": {"status": "running", "provider": "codex"}},
|
|
2387
|
+
"team_owner": seed,
|
|
2388
|
+
"leader_receiver": matching_pending_receiver(),
|
|
1930
2389
|
"teams": {
|
|
1931
2390
|
"fresh": {
|
|
1932
2391
|
"workspace": workspace,
|
|
1933
2392
|
"team_key": "fresh",
|
|
1934
2393
|
"session_name": "team-fresh",
|
|
1935
|
-
"agents": {"sol": {"status": "running", "provider": "
|
|
2394
|
+
"agents": {"sol": {"status": "running", "provider": "codex"}},
|
|
2395
|
+
"team_owner": seed,
|
|
2396
|
+
"leader_receiver": matching_pending_receiver(),
|
|
2397
|
+
"owner_epoch": 1
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
}),
|
|
2401
|
+
)
|
|
2402
|
+
.unwrap();
|
|
2403
|
+
let seed = crate::state::persist::load_runtime_state(&workspace).unwrap()["teams"]["fresh"]
|
|
2404
|
+
["team_owner"]
|
|
2405
|
+
.clone();
|
|
2406
|
+
(workspace, seed)
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
fn workspace_with_matching_seed(tag: &str) -> (PathBuf, serde_json::Value) {
|
|
2410
|
+
let workspace = workspace(tag);
|
|
2411
|
+
let seed = matching_seed();
|
|
2412
|
+
crate::state::persist::save_runtime_state(
|
|
2413
|
+
&workspace,
|
|
2414
|
+
&json!({
|
|
2415
|
+
"workspace": workspace,
|
|
2416
|
+
"active_team_key": "fresh",
|
|
2417
|
+
"team_key": "fresh",
|
|
2418
|
+
"session_name": "team-fresh",
|
|
2419
|
+
"agents": {"sol": {"status": "running", "provider": "codex"}},
|
|
2420
|
+
"teams": {
|
|
2421
|
+
"fresh": {
|
|
2422
|
+
"workspace": workspace,
|
|
2423
|
+
"team_key": "fresh",
|
|
2424
|
+
"session_name": "team-fresh",
|
|
2425
|
+
"agents": {"sol": {"status": "running", "provider": "codex"}},
|
|
1936
2426
|
"team_owner": seed,
|
|
1937
2427
|
"leader_receiver": matching_receiver(),
|
|
1938
2428
|
"owner_epoch": 1
|
|
@@ -2078,7 +2568,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2078
2568
|
|
|
2079
2569
|
#[test]
|
|
2080
2570
|
fn seeded_cleanup_clears_exact_seed_through_bind_repository_entry() {
|
|
2081
|
-
let (workspace, seed) =
|
|
2571
|
+
let (workspace, seed) = workspace_with_fresh_seed("seed-cleanup-success");
|
|
2082
2572
|
let mut ops = failed_register_ops(None, false);
|
|
2083
2573
|
assert!(!bind_fresh_quick_start_leader_with(
|
|
2084
2574
|
&workspace,
|
|
@@ -2094,7 +2584,8 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2094
2584
|
.applied_grant
|
|
2095
2585
|
.as_ref()
|
|
2096
2586
|
.is_some_and(|(owner, receiver)| {
|
|
2097
|
-
owner == &seed
|
|
2587
|
+
owner == &seed
|
|
2588
|
+
&& receiver.get("pane_id").and_then(serde_json::Value::as_str) == Some("%42")
|
|
2098
2589
|
}));
|
|
2099
2590
|
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
2100
2591
|
assert!(
|
|
@@ -2154,7 +2645,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2154
2645
|
|
|
2155
2646
|
#[test]
|
|
2156
2647
|
fn seeded_cleanup_propagates_persist_error() {
|
|
2157
|
-
let (workspace, seed) =
|
|
2648
|
+
let (workspace, seed) = workspace_with_fresh_seed("seed-cleanup-persist-err");
|
|
2158
2649
|
let mut ops = failed_register_ops(None, true);
|
|
2159
2650
|
let error = bind_fresh_quick_start_leader_with(
|
|
2160
2651
|
&workspace,
|
|
@@ -2170,7 +2661,8 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2170
2661
|
.applied_grant
|
|
2171
2662
|
.as_ref()
|
|
2172
2663
|
.is_some_and(|(owner, receiver)| {
|
|
2173
|
-
owner == &seed
|
|
2664
|
+
owner == &seed
|
|
2665
|
+
&& receiver.get("pane_id").and_then(serde_json::Value::as_str) == Some("%42")
|
|
2174
2666
|
}));
|
|
2175
2667
|
match error {
|
|
2176
2668
|
LifecycleError::StatePersist(text) => {
|
|
@@ -2184,60 +2676,375 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2184
2676
|
let _ = std::fs::remove_dir_all(crate::state::persist::runtime_state_path(&workspace));
|
|
2185
2677
|
}
|
|
2186
2678
|
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
}
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2679
|
+
fn complete_normalized_state() -> serde_json::Value {
|
|
2680
|
+
json!({
|
|
2681
|
+
"active_team_key": "fresh",
|
|
2682
|
+
"team_key": "fresh",
|
|
2683
|
+
"session_name": "team-fresh",
|
|
2684
|
+
"agents": {
|
|
2685
|
+
"sol": {
|
|
2686
|
+
"status": "running",
|
|
2687
|
+
"provider": "codex",
|
|
2688
|
+
"session_id": null,
|
|
2689
|
+
"rollout_path": null,
|
|
2690
|
+
"captured_at": null,
|
|
2691
|
+
"captured_via": null,
|
|
2692
|
+
"attribution_confidence": null,
|
|
2693
|
+
"spawn_cwd": null
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
})
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
fn persist_complete_normalized_baseline(workspace: &Path) -> (Vec<u8>, serde_json::Value) {
|
|
2700
|
+
let state = complete_normalized_state();
|
|
2701
|
+
let path = crate::state::persist::runtime_state_path(workspace);
|
|
2702
|
+
if let Some(parent) = path.parent() {
|
|
2703
|
+
std::fs::create_dir_all(parent).unwrap();
|
|
2704
|
+
}
|
|
2705
|
+
std::fs::write(&path, serde_json::to_string_pretty(&state).unwrap()).unwrap();
|
|
2706
|
+
let _ = crate::state::persist::load_runtime_state(workspace).unwrap();
|
|
2707
|
+
let raw = std::fs::read(&path).unwrap();
|
|
2708
|
+
let on_disk: serde_json::Value = serde_json::from_slice(&raw).unwrap();
|
|
2709
|
+
for field in [
|
|
2710
|
+
"session_id",
|
|
2711
|
+
"rollout_path",
|
|
2712
|
+
"captured_at",
|
|
2713
|
+
"captured_via",
|
|
2714
|
+
"attribution_confidence",
|
|
2715
|
+
"spawn_cwd",
|
|
2211
2716
|
] {
|
|
2212
|
-
let workspace = workspace(case);
|
|
2213
|
-
let state_path = crate::state::persist::runtime_state_path(&workspace);
|
|
2214
|
-
let before = std::fs::read(&state_path).unwrap();
|
|
2215
|
-
assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
2216
|
-
.unwrap());
|
|
2217
2717
|
assert_eq!(
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
"
|
|
2718
|
+
on_disk.pointer(&format!("/agents/sol/{field}")),
|
|
2719
|
+
Some(&json!(null)),
|
|
2720
|
+
"normalized baseline missing {field}"
|
|
2221
2721
|
);
|
|
2222
|
-
match case {
|
|
2223
|
-
"attach-failure" => {
|
|
2224
|
-
assert_eq!(ops.attach_calls, 1);
|
|
2225
|
-
assert_eq!(ops.register_calls, 0);
|
|
2226
|
-
assert_eq!(ops.readback_calls, 0);
|
|
2227
|
-
}
|
|
2228
|
-
"registry-failure" => {
|
|
2229
|
-
assert_eq!(ops.attach_calls, 1);
|
|
2230
|
-
assert_eq!(ops.register_calls, 1);
|
|
2231
|
-
assert_eq!(ops.readback_calls, 0);
|
|
2232
|
-
}
|
|
2233
|
-
"readback-failure" => {
|
|
2234
|
-
assert_eq!(ops.attach_calls, 1);
|
|
2235
|
-
assert_eq!(ops.register_calls, 1);
|
|
2236
|
-
assert_eq!(ops.readback_calls, 1);
|
|
2237
|
-
}
|
|
2238
|
-
_ => unreachable!(),
|
|
2239
|
-
}
|
|
2240
2722
|
}
|
|
2723
|
+
assert_eq!(on_disk.get("team_key"), Some(&json!("fresh")));
|
|
2724
|
+
(raw, on_disk)
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
fn clear_exact_expected_bytes(baseline: &serde_json::Value) -> Vec<u8> {
|
|
2728
|
+
let mut expected = baseline.clone();
|
|
2729
|
+
expected["teams"] = json!({
|
|
2730
|
+
"fresh": {
|
|
2731
|
+
"leader_receiver": null,
|
|
2732
|
+
"team_owner": null,
|
|
2733
|
+
"owner_epoch": 1
|
|
2734
|
+
}
|
|
2735
|
+
});
|
|
2736
|
+
serde_json::to_string_pretty(&expected).unwrap().into_bytes()
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
fn assert_failure_rolls_back_this_attempt(
|
|
2740
|
+
case: &str,
|
|
2741
|
+
mut ops: MockOps,
|
|
2742
|
+
attach_calls: usize,
|
|
2743
|
+
register_calls: usize,
|
|
2744
|
+
readback_calls: usize,
|
|
2745
|
+
clear_exact: bool,
|
|
2746
|
+
) {
|
|
2747
|
+
let workspace = workspace(case);
|
|
2748
|
+
let state_path = crate::state::persist::runtime_state_path(&workspace);
|
|
2749
|
+
let (before, baseline) = persist_complete_normalized_baseline(&workspace);
|
|
2750
|
+
let expected = if clear_exact {
|
|
2751
|
+
clear_exact_expected_bytes(&baseline)
|
|
2752
|
+
} else {
|
|
2753
|
+
before
|
|
2754
|
+
};
|
|
2755
|
+
assert!(
|
|
2756
|
+
!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap(),
|
|
2757
|
+
"{case} must refuse"
|
|
2758
|
+
);
|
|
2759
|
+
assert_eq!(ops.attach_calls, attach_calls, "{case} attach_calls");
|
|
2760
|
+
assert_eq!(ops.register_calls, register_calls, "{case} register_calls");
|
|
2761
|
+
assert_eq!(ops.readback_calls, readback_calls, "{case} readback_calls");
|
|
2762
|
+
let after = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
2763
|
+
assert!(
|
|
2764
|
+
after.get("failed_attach_mutation").is_none(),
|
|
2765
|
+
"{case} left failed_attach_mutation"
|
|
2766
|
+
);
|
|
2767
|
+
assert!(
|
|
2768
|
+
after.get("failed_registry_mutation").is_none(),
|
|
2769
|
+
"{case} left failed_registry_mutation"
|
|
2770
|
+
);
|
|
2771
|
+
assert!(
|
|
2772
|
+
after.get("failed_readback_mutation").is_none(),
|
|
2773
|
+
"{case} left failed_readback_mutation"
|
|
2774
|
+
);
|
|
2775
|
+
assert!(
|
|
2776
|
+
after
|
|
2777
|
+
.pointer("/teams/fresh/leader_receiver")
|
|
2778
|
+
.is_none_or(serde_json::Value::is_null),
|
|
2779
|
+
"{case} left a live leader_receiver"
|
|
2780
|
+
);
|
|
2781
|
+
assert!(
|
|
2782
|
+
after
|
|
2783
|
+
.pointer("/teams/fresh/team_owner")
|
|
2784
|
+
.is_none_or(serde_json::Value::is_null),
|
|
2785
|
+
"{case} left a live team_owner"
|
|
2786
|
+
);
|
|
2787
|
+
assert_eq!(
|
|
2788
|
+
std::fs::read(&state_path).unwrap(),
|
|
2789
|
+
expected,
|
|
2790
|
+
"{case} left partial state bytes"
|
|
2791
|
+
);
|
|
2792
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
#[test]
|
|
2796
|
+
fn attach_registry_or_readback_failure_rolls_back_exact_state_bytes() {
|
|
2797
|
+
assert_failure_rolls_back_this_attempt(
|
|
2798
|
+
"attach-failure",
|
|
2799
|
+
MockOps {
|
|
2800
|
+
attach_ok: false,
|
|
2801
|
+
..MockOps::default()
|
|
2802
|
+
},
|
|
2803
|
+
1,
|
|
2804
|
+
0,
|
|
2805
|
+
0,
|
|
2806
|
+
false,
|
|
2807
|
+
);
|
|
2808
|
+
assert_failure_rolls_back_this_attempt(
|
|
2809
|
+
"registry-failure",
|
|
2810
|
+
MockOps {
|
|
2811
|
+
register_ok: false,
|
|
2812
|
+
..MockOps::default()
|
|
2813
|
+
},
|
|
2814
|
+
1,
|
|
2815
|
+
1,
|
|
2816
|
+
0,
|
|
2817
|
+
true,
|
|
2818
|
+
);
|
|
2819
|
+
assert_failure_rolls_back_this_attempt(
|
|
2820
|
+
"readback-failure",
|
|
2821
|
+
MockOps {
|
|
2822
|
+
readback_ok: false,
|
|
2823
|
+
..MockOps::default()
|
|
2824
|
+
},
|
|
2825
|
+
1,
|
|
2826
|
+
1,
|
|
2827
|
+
1,
|
|
2828
|
+
true,
|
|
2829
|
+
);
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
#[test]
|
|
2833
|
+
fn attach_failure_rolls_back_exact_state_bytes() {
|
|
2834
|
+
assert_failure_rolls_back_this_attempt(
|
|
2835
|
+
"attach-failure-only",
|
|
2836
|
+
MockOps {
|
|
2837
|
+
attach_ok: false,
|
|
2838
|
+
..MockOps::default()
|
|
2839
|
+
},
|
|
2840
|
+
1,
|
|
2841
|
+
0,
|
|
2842
|
+
0,
|
|
2843
|
+
false,
|
|
2844
|
+
);
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
#[test]
|
|
2848
|
+
fn registry_failure_rolls_back_exact_state_bytes() {
|
|
2849
|
+
assert_failure_rolls_back_this_attempt(
|
|
2850
|
+
"registry-failure-only",
|
|
2851
|
+
MockOps {
|
|
2852
|
+
register_ok: false,
|
|
2853
|
+
..MockOps::default()
|
|
2854
|
+
},
|
|
2855
|
+
1,
|
|
2856
|
+
1,
|
|
2857
|
+
0,
|
|
2858
|
+
true,
|
|
2859
|
+
);
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
#[test]
|
|
2863
|
+
fn attach_failure_keeps_unrelated_fields() {
|
|
2864
|
+
let workspace = workspace("unrelated-fields");
|
|
2865
|
+
let mut state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
2866
|
+
state["tasks"] = json!({"keep": true});
|
|
2867
|
+
crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
|
|
2868
|
+
let mut ops = MockOps {
|
|
2869
|
+
attach_ok: false,
|
|
2870
|
+
..MockOps::default()
|
|
2871
|
+
};
|
|
2872
|
+
assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
2873
|
+
.unwrap());
|
|
2874
|
+
let after = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
2875
|
+
assert_eq!(after.get("tasks"), Some(&json!({"keep": true})));
|
|
2876
|
+
assert!(after.get("failed_attach_mutation").is_none());
|
|
2877
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
fn complete_owner(pane: &str, provider: &str, uuid: &str, epoch: u64) -> serde_json::Value {
|
|
2881
|
+
json!({
|
|
2882
|
+
"pane_id": pane,
|
|
2883
|
+
"provider": provider,
|
|
2884
|
+
"leader_session_uuid": uuid,
|
|
2885
|
+
"owner_epoch": epoch
|
|
2886
|
+
})
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
fn complete_receiver(
|
|
2890
|
+
pane: &str,
|
|
2891
|
+
provider: &str,
|
|
2892
|
+
uuid: &str,
|
|
2893
|
+
epoch: u64,
|
|
2894
|
+
socket: &str,
|
|
2895
|
+
) -> serde_json::Value {
|
|
2896
|
+
json!({
|
|
2897
|
+
"mode": "direct_tmux",
|
|
2898
|
+
"status": "attached",
|
|
2899
|
+
"pane_id": pane,
|
|
2900
|
+
"provider": provider,
|
|
2901
|
+
"leader_session_uuid": uuid,
|
|
2902
|
+
"owner_epoch": epoch,
|
|
2903
|
+
"tmux_socket": socket
|
|
2904
|
+
})
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
fn commit_fresh_binding(
|
|
2908
|
+
workspace: &Path,
|
|
2909
|
+
owner: &serde_json::Value,
|
|
2910
|
+
receiver: &serde_json::Value,
|
|
2911
|
+
epoch: u64,
|
|
2912
|
+
) {
|
|
2913
|
+
let mut state = crate::state::persist::load_runtime_state(workspace).unwrap();
|
|
2914
|
+
state["teams"]["fresh"]["team_owner"] = owner.clone();
|
|
2915
|
+
state["teams"]["fresh"]["leader_receiver"] = receiver.clone();
|
|
2916
|
+
state["teams"]["fresh"]["owner_epoch"] = json!(epoch);
|
|
2917
|
+
crate::state::persist::save_runtime_state_with_receiver_authority(
|
|
2918
|
+
workspace,
|
|
2919
|
+
&state,
|
|
2920
|
+
"fresh",
|
|
2921
|
+
None,
|
|
2922
|
+
)
|
|
2923
|
+
.unwrap();
|
|
2924
|
+
let raw = std::fs::read(crate::state::persist::runtime_state_path(workspace)).unwrap();
|
|
2925
|
+
let on_disk: serde_json::Value = serde_json::from_slice(&raw).unwrap();
|
|
2926
|
+
assert_eq!(
|
|
2927
|
+
on_disk.pointer("/teams/fresh/team_owner"),
|
|
2928
|
+
Some(owner),
|
|
2929
|
+
"committed owner must equal predetermined pair"
|
|
2930
|
+
);
|
|
2931
|
+
assert_eq!(
|
|
2932
|
+
on_disk.pointer("/teams/fresh/leader_receiver"),
|
|
2933
|
+
Some(receiver),
|
|
2934
|
+
"committed receiver must equal predetermined pair"
|
|
2935
|
+
);
|
|
2936
|
+
assert_eq!(
|
|
2937
|
+
on_disk.pointer("/teams/fresh/owner_epoch"),
|
|
2938
|
+
Some(&json!(epoch)),
|
|
2939
|
+
"canonical epoch must match the pair"
|
|
2940
|
+
);
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
#[test]
|
|
2944
|
+
fn restore_exact_restores_previous_owner_receiver() {
|
|
2945
|
+
let workspace = workspace("restore-exact-prev");
|
|
2946
|
+
let _ = persist_complete_normalized_baseline(&workspace);
|
|
2947
|
+
let mut state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
2948
|
+
state["tasks"] = json!({"keep": true});
|
|
2949
|
+
crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
|
|
2950
|
+
let previous_uuid = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
2951
|
+
let previous_owner = complete_owner("%99", "codex", previous_uuid, 1);
|
|
2952
|
+
let previous_receiver =
|
|
2953
|
+
complete_receiver("%99", "codex", previous_uuid, 1, "/tmp/prev.sock");
|
|
2954
|
+
commit_fresh_binding(&workspace, &previous_owner, &previous_receiver, 1);
|
|
2955
|
+
let snapshot = super::BindingFileSnapshot::capture(
|
|
2956
|
+
crate::state::persist::runtime_state_path(&workspace),
|
|
2957
|
+
);
|
|
2958
|
+
let this_uuid = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
2959
|
+
let this_owner = complete_owner("%42", "codex", this_uuid, 1);
|
|
2960
|
+
let this_receiver = complete_receiver(
|
|
2961
|
+
"%42",
|
|
2962
|
+
"codex",
|
|
2963
|
+
this_uuid,
|
|
2964
|
+
1,
|
|
2965
|
+
"/private/tmp/tmux-test/default",
|
|
2966
|
+
);
|
|
2967
|
+
commit_fresh_binding(&workspace, &this_owner, &this_receiver, 1);
|
|
2968
|
+
let this_receipt = (this_owner.clone(), this_receiver.clone());
|
|
2969
|
+
assert_ne!(this_owner, previous_owner);
|
|
2970
|
+
assert_ne!(this_receiver, previous_receiver);
|
|
2971
|
+
super::restore_this_attempt_state(
|
|
2972
|
+
&workspace,
|
|
2973
|
+
"fresh",
|
|
2974
|
+
Some(&snapshot),
|
|
2975
|
+
Some(&this_receipt),
|
|
2976
|
+
&[],
|
|
2977
|
+
)
|
|
2978
|
+
.unwrap();
|
|
2979
|
+
let after = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
2980
|
+
assert_eq!(after["teams"]["fresh"]["team_owner"], previous_owner);
|
|
2981
|
+
assert_eq!(after["teams"]["fresh"]["leader_receiver"], previous_receiver);
|
|
2982
|
+
assert_eq!(after["teams"]["fresh"]["owner_epoch"], json!(1));
|
|
2983
|
+
assert_eq!(after.get("tasks"), Some(&json!({"keep": true})));
|
|
2984
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
#[test]
|
|
2988
|
+
fn restore_exact_keeps_unmatched_winner() {
|
|
2989
|
+
let workspace = workspace("restore-exact-winner");
|
|
2990
|
+
let _ = persist_complete_normalized_baseline(&workspace);
|
|
2991
|
+
let previous_uuid = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
2992
|
+
let previous_owner = complete_owner("%99", "codex", previous_uuid, 1);
|
|
2993
|
+
let previous_receiver =
|
|
2994
|
+
complete_receiver("%99", "codex", previous_uuid, 1, "/tmp/prev.sock");
|
|
2995
|
+
commit_fresh_binding(&workspace, &previous_owner, &previous_receiver, 1);
|
|
2996
|
+
let snapshot = super::BindingFileSnapshot::capture(
|
|
2997
|
+
crate::state::persist::runtime_state_path(&workspace),
|
|
2998
|
+
);
|
|
2999
|
+
let this_uuid = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
3000
|
+
let this_owner = complete_owner("%42", "codex", this_uuid, 1);
|
|
3001
|
+
let this_receiver = complete_receiver(
|
|
3002
|
+
"%42",
|
|
3003
|
+
"codex",
|
|
3004
|
+
this_uuid,
|
|
3005
|
+
1,
|
|
3006
|
+
"/private/tmp/tmux-test/default",
|
|
3007
|
+
);
|
|
3008
|
+
commit_fresh_binding(&workspace, &this_owner, &this_receiver, 1);
|
|
3009
|
+
let this_receipt = (this_owner.clone(), this_receiver.clone());
|
|
3010
|
+
let winner_uuid = "cccccccccccccccccccccccccccccccc";
|
|
3011
|
+
let winner_owner = complete_owner("%7", "codex", winner_uuid, 2);
|
|
3012
|
+
let winner_receiver = complete_receiver("%7", "codex", winner_uuid, 2, "/tmp/win.sock");
|
|
3013
|
+
commit_fresh_binding(&workspace, &winner_owner, &winner_receiver, 2);
|
|
3014
|
+
assert_ne!(
|
|
3015
|
+
(winner_owner.clone(), winner_receiver.clone()),
|
|
3016
|
+
this_receipt
|
|
3017
|
+
);
|
|
3018
|
+
super::restore_this_attempt_state(
|
|
3019
|
+
&workspace,
|
|
3020
|
+
"fresh",
|
|
3021
|
+
Some(&snapshot),
|
|
3022
|
+
Some(&this_receipt),
|
|
3023
|
+
&[],
|
|
3024
|
+
)
|
|
3025
|
+
.unwrap();
|
|
3026
|
+
let after = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
3027
|
+
assert_eq!(after["teams"]["fresh"]["team_owner"], winner_owner);
|
|
3028
|
+
assert_eq!(after["teams"]["fresh"]["leader_receiver"], winner_receiver);
|
|
3029
|
+
assert_eq!(after["teams"]["fresh"]["owner_epoch"], json!(2));
|
|
3030
|
+
assert_ne!(after["teams"]["fresh"]["team_owner"], previous_owner);
|
|
3031
|
+
assert_ne!(after["teams"]["fresh"]["leader_receiver"], previous_receiver);
|
|
3032
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
#[test]
|
|
3036
|
+
fn readback_failure_rolls_back_exact_state_bytes() {
|
|
3037
|
+
assert_failure_rolls_back_this_attempt(
|
|
3038
|
+
"readback-failure-only",
|
|
3039
|
+
MockOps {
|
|
3040
|
+
readback_ok: false,
|
|
3041
|
+
..MockOps::default()
|
|
3042
|
+
},
|
|
3043
|
+
1,
|
|
3044
|
+
1,
|
|
3045
|
+
1,
|
|
3046
|
+
true,
|
|
3047
|
+
);
|
|
2241
3048
|
}
|
|
2242
3049
|
|
|
2243
3050
|
#[test]
|
|
@@ -2296,6 +3103,8 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2296
3103
|
cas_conflict: false,
|
|
2297
3104
|
applied_grant: None,
|
|
2298
3105
|
registry_receipt: None,
|
|
3106
|
+
frozen_owner: None,
|
|
3107
|
+
frozen_receiver: None,
|
|
2299
3108
|
nonce_writer: None,
|
|
2300
3109
|
},
|
|
2301
3110
|
attached_provider: None,
|
|
@@ -2362,7 +3171,11 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2362
3171
|
}
|
|
2363
3172
|
}
|
|
2364
3173
|
|
|
2365
|
-
fn
|
|
3174
|
+
fn runtime_workspace_with_provider(
|
|
3175
|
+
hermetic: &HermeticTestEnv,
|
|
3176
|
+
tag: &str,
|
|
3177
|
+
provider: &str,
|
|
3178
|
+
) -> PathBuf {
|
|
2366
3179
|
let path = hermetic.workspace(tag);
|
|
2367
3180
|
crate::state::persist::save_runtime_state(
|
|
2368
3181
|
&path,
|
|
@@ -2370,13 +3183,17 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2370
3183
|
"active_team_key": "fresh",
|
|
2371
3184
|
"team_key": "fresh",
|
|
2372
3185
|
"session_name": "team-fresh",
|
|
2373
|
-
"agents": {"sol": {"status": "running", "provider":
|
|
3186
|
+
"agents": {"sol": {"status": "running", "provider": provider}}
|
|
2374
3187
|
}),
|
|
2375
3188
|
)
|
|
2376
3189
|
.unwrap();
|
|
2377
3190
|
path
|
|
2378
3191
|
}
|
|
2379
3192
|
|
|
3193
|
+
fn runtime_workspace(hermetic: &HermeticTestEnv, tag: &str) -> PathBuf {
|
|
3194
|
+
runtime_workspace_with_provider(hermetic, tag, "codex")
|
|
3195
|
+
}
|
|
3196
|
+
|
|
2380
3197
|
fn refusal_reason(workspace: &Path) -> Option<(String, String)> {
|
|
2381
3198
|
let events = crate::event_log::EventLog::new(workspace).tail(0).unwrap();
|
|
2382
3199
|
let event = events.last()?;
|
|
@@ -2429,23 +3246,169 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2429
3246
|
}
|
|
2430
3247
|
}
|
|
2431
3248
|
|
|
3249
|
+
fn bind_seeded_caller(target: PaneInfo, hermetic_name: &str) -> (bool, Option<&'static str>) {
|
|
3250
|
+
let hermetic = HermeticTestEnv::enter(hermetic_name);
|
|
3251
|
+
let workspace = runtime_workspace(&hermetic, "fresh");
|
|
3252
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
3253
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
3254
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
|
|
3255
|
+
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
3256
|
+
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
3257
|
+
let seed = seeded_runtime_owner(&workspace);
|
|
3258
|
+
let transport = OfflineTransport::new()
|
|
3259
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
3260
|
+
.with_pane_current_command("%1", "bash")
|
|
3261
|
+
.with_targets(vec![target]);
|
|
3262
|
+
let mut ops = RuntimeFreshQuickStartLeaderBindingOps {
|
|
3263
|
+
transport: &transport,
|
|
3264
|
+
last_failure_reason: None,
|
|
3265
|
+
cas_conflict: false,
|
|
3266
|
+
applied_grant: None,
|
|
3267
|
+
registry_receipt: None,
|
|
3268
|
+
frozen_owner: None,
|
|
3269
|
+
frozen_receiver: None,
|
|
3270
|
+
nonce_writer: None,
|
|
3271
|
+
};
|
|
3272
|
+
let ok = crate::transport_factory::with_leader_endpoint_transport(
|
|
3273
|
+
"/tmp/tmux.sock",
|
|
3274
|
+
transport.clone(),
|
|
3275
|
+
|| bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops),
|
|
3276
|
+
)
|
|
3277
|
+
.unwrap();
|
|
3278
|
+
(ok, ops.last_failure_reason)
|
|
3279
|
+
}
|
|
3280
|
+
|
|
2432
3281
|
#[test]
|
|
2433
3282
|
#[serial_test::serial(env)]
|
|
2434
|
-
fn
|
|
2435
|
-
let
|
|
3283
|
+
fn seeded_runtime_bind_ignores_pane_focus_for_same_live_caller() {
|
|
3284
|
+
let parent = std::env::temp_dir().join(format!(
|
|
3285
|
+
"ta-inactive-cwd-{}-{}",
|
|
3286
|
+
std::process::id(),
|
|
3287
|
+
std::time::SystemTime::now()
|
|
3288
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
3289
|
+
.unwrap()
|
|
3290
|
+
.as_nanos()
|
|
3291
|
+
));
|
|
3292
|
+
std::fs::create_dir_all(&parent).unwrap();
|
|
3293
|
+
let mut focused = caller_target(parent.clone(), Some("shared-live-nonce"));
|
|
3294
|
+
focused.active = true;
|
|
3295
|
+
let (active_ok, active_reason) = bind_seeded_caller(focused, "runtime-focus-active");
|
|
3296
|
+
assert!(active_ok, "focused live caller must bind: {active_reason:?}");
|
|
3297
|
+
|
|
3298
|
+
let mut background = caller_target(parent, Some("shared-live-nonce"));
|
|
3299
|
+
background.active = false;
|
|
3300
|
+
let (inactive_ok, inactive_reason) =
|
|
3301
|
+
bind_seeded_caller(background, "runtime-focus-inactive");
|
|
3302
|
+
assert!(
|
|
3303
|
+
inactive_ok,
|
|
3304
|
+
"same live caller with pane_active=0 must still bind: {inactive_reason:?}"
|
|
3305
|
+
);
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
#[test]
|
|
3309
|
+
#[serial_test::serial(env)]
|
|
3310
|
+
fn seeded_runtime_bind_dead_pane_still_refuses() {
|
|
3311
|
+
let hermetic = HermeticTestEnv::enter("runtime-dead-pane");
|
|
2436
3312
|
let workspace = runtime_workspace(&hermetic, "fresh");
|
|
2437
3313
|
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
2438
3314
|
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
2439
|
-
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "
|
|
3315
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
|
|
2440
3316
|
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
2441
3317
|
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
3318
|
+
let seed = seeded_runtime_owner(&workspace);
|
|
2442
3319
|
let transport = OfflineTransport::new()
|
|
2443
3320
|
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
2444
3321
|
.with_pane_current_command("%1", "bash");
|
|
2445
|
-
let mut ops =
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
3322
|
+
let mut ops = RuntimeFreshQuickStartLeaderBindingOps {
|
|
3323
|
+
transport: &transport,
|
|
3324
|
+
last_failure_reason: None,
|
|
3325
|
+
cas_conflict: false,
|
|
3326
|
+
applied_grant: None,
|
|
3327
|
+
registry_receipt: None,
|
|
3328
|
+
frozen_owner: None,
|
|
3329
|
+
frozen_receiver: None,
|
|
3330
|
+
nonce_writer: None,
|
|
3331
|
+
};
|
|
3332
|
+
assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops)
|
|
3333
|
+
.unwrap());
|
|
3334
|
+
assert_eq!(ops.last_failure_reason, Some("caller_pane_not_live"));
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
#[test]
|
|
3338
|
+
#[serial_test::serial(env)]
|
|
3339
|
+
fn seeded_runtime_bind_identity_mismatch_still_refuses() {
|
|
3340
|
+
let parent = std::env::temp_dir().join(format!(
|
|
3341
|
+
"ta-mismatch-cwd-{}-{}",
|
|
3342
|
+
std::process::id(),
|
|
3343
|
+
std::time::SystemTime::now()
|
|
3344
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
3345
|
+
.unwrap()
|
|
3346
|
+
.as_nanos()
|
|
3347
|
+
));
|
|
3348
|
+
std::fs::create_dir_all(&parent).unwrap();
|
|
3349
|
+
let mut other_pane = caller_target(parent, Some("shared-live-nonce"));
|
|
3350
|
+
other_pane.pane_id = PaneId::new("%9");
|
|
3351
|
+
let (ok, reason) = bind_seeded_caller(other_pane, "runtime-identity-mismatch");
|
|
3352
|
+
assert!(!ok);
|
|
3353
|
+
assert_eq!(reason, Some("caller_pane_not_live"));
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
#[test]
|
|
3357
|
+
fn existing_runtime_view_uses_requested_bob_not_top_level_alice() {
|
|
3358
|
+
let state = json!({
|
|
3359
|
+
"agents": { "alice": { "window": "alice" } },
|
|
3360
|
+
"session_name": "team-alice",
|
|
3361
|
+
"tmux_endpoint": "/tmp/shared.sock",
|
|
3362
|
+
"teams": {
|
|
3363
|
+
"alice": {
|
|
3364
|
+
"agents": { "alice": { "window": "alice" } },
|
|
3365
|
+
"session_name": "team-alice"
|
|
3366
|
+
},
|
|
3367
|
+
"bob": {
|
|
3368
|
+
"agents": { "bob": { "window": "bob" } },
|
|
3369
|
+
"session_name": "team-bob"
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
});
|
|
3373
|
+
let view = existing_runtime_identity_view(&state, Some("bob"));
|
|
3374
|
+
let ids = view
|
|
3375
|
+
.get("agents")
|
|
3376
|
+
.and_then(serde_json::Value::as_object)
|
|
3377
|
+
.map(|agents| agents.keys().cloned().collect::<Vec<_>>())
|
|
3378
|
+
.unwrap_or_default();
|
|
3379
|
+
assert_eq!(ids, vec!["bob".to_string()]);
|
|
3380
|
+
assert_eq!(view.get("session_name").and_then(serde_json::Value::as_str), Some("team-bob"));
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
#[test]
|
|
3384
|
+
fn existing_runtime_view_empty_top_level_agents_still_emits_bob() {
|
|
3385
|
+
let state = json!({
|
|
3386
|
+
"agents": {},
|
|
3387
|
+
"teams": {
|
|
3388
|
+
"bob": {
|
|
3389
|
+
"agents": { "bob": { "window": "bob" } },
|
|
3390
|
+
"session_name": "team-bob"
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
});
|
|
3394
|
+
let view = existing_runtime_identity_view(&state, Some("bob"));
|
|
3395
|
+
assert!(view
|
|
3396
|
+
.get("agents")
|
|
3397
|
+
.and_then(serde_json::Value::as_object)
|
|
3398
|
+
.is_some_and(|agents| agents.contains_key("bob")));
|
|
3399
|
+
}
|
|
3400
|
+
|
|
3401
|
+
#[test]
|
|
3402
|
+
fn existing_runtime_view_legacy_root_without_teams_map() {
|
|
3403
|
+
let state = json!({
|
|
3404
|
+
"agents": { "alice": { "window": "alice" } },
|
|
3405
|
+
"session_name": "team-alice"
|
|
3406
|
+
});
|
|
3407
|
+
let view = existing_runtime_identity_view(&state, Some("alice"));
|
|
3408
|
+
assert!(view
|
|
3409
|
+
.get("agents")
|
|
3410
|
+
.and_then(serde_json::Value::as_object)
|
|
3411
|
+
.is_some_and(|agents| agents.contains_key("alice")));
|
|
2449
3412
|
}
|
|
2450
3413
|
|
|
2451
3414
|
#[test]
|
|
@@ -2457,7 +3420,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2457
3420
|
std::fs::create_dir_all(&parent).unwrap();
|
|
2458
3421
|
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
2459
3422
|
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
2460
|
-
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "
|
|
3423
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
|
|
2461
3424
|
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
2462
3425
|
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
2463
3426
|
let seed = seeded_runtime_owner(&workspace);
|
|
@@ -2471,14 +3434,15 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2471
3434
|
last_failure_reason: None,
|
|
2472
3435
|
cas_conflict: false,
|
|
2473
3436
|
applied_grant: None,
|
|
2474
|
-
registry_receipt: None,
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
3437
|
+
registry_receipt: None,
|
|
3438
|
+
frozen_owner: None,
|
|
3439
|
+
frozen_receiver: None,
|
|
3440
|
+
nonce_writer: None,
|
|
3441
|
+
};
|
|
3442
|
+
assert!(crate::transport_factory::with_leader_endpoint_transport(
|
|
3443
|
+
"/tmp/tmux.sock",
|
|
3444
|
+
transport.clone(),
|
|
3445
|
+
|| bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops)
|
|
2482
3446
|
)
|
|
2483
3447
|
.unwrap());
|
|
2484
3448
|
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
@@ -2588,7 +3552,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2588
3552
|
let hermetic = HermeticTestEnv::enter("runtime-seeded-shared-nonce");
|
|
2589
3553
|
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
2590
3554
|
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
2591
|
-
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "
|
|
3555
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
|
|
2592
3556
|
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
2593
3557
|
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
2594
3558
|
let parent = hermetic.root().join("parent");
|
|
@@ -2599,12 +3563,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2599
3563
|
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
2600
3564
|
.with_pane_current_command("%1", "bash")
|
|
2601
3565
|
.with_targets(vec![target_with_winner.clone()])
|
|
2602
|
-
.with_target_snapshots(vec![
|
|
2603
|
-
vec![target_without_nonce.clone()],
|
|
2604
|
-
vec![target_with_winner.clone()],
|
|
2605
|
-
vec![target_without_nonce],
|
|
2606
|
-
vec![target_with_winner],
|
|
2607
|
-
]);
|
|
3566
|
+
.with_target_snapshots(vec![vec![target_without_nonce]]);
|
|
2608
3567
|
let first_workspace = runtime_workspace(&hermetic, "child-a");
|
|
2609
3568
|
let second_workspace = runtime_workspace(&hermetic, "child-b");
|
|
2610
3569
|
let first_seed = seeded_runtime_owner(&first_workspace);
|
|
@@ -2615,13 +3574,19 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2615
3574
|
cas_conflict: false,
|
|
2616
3575
|
applied_grant: None,
|
|
2617
3576
|
registry_receipt: None,
|
|
3577
|
+
frozen_owner: None,
|
|
3578
|
+
frozen_receiver: None,
|
|
2618
3579
|
nonce_writer: Some(&nonce_writer_winner),
|
|
2619
3580
|
};
|
|
2620
|
-
assert!(
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
3581
|
+
assert!(crate::transport_factory::with_leader_endpoint_transport(
|
|
3582
|
+
"/tmp/tmux.sock",
|
|
3583
|
+
transport.clone(),
|
|
3584
|
+
|| bind_fresh_quick_start_leader_with(
|
|
3585
|
+
&first_workspace,
|
|
3586
|
+
"fresh",
|
|
3587
|
+
Some(&first_seed),
|
|
3588
|
+
&mut first_ops
|
|
3589
|
+
)
|
|
2625
3590
|
)
|
|
2626
3591
|
.unwrap());
|
|
2627
3592
|
let mut second_ops = RuntimeFreshQuickStartLeaderBindingOps {
|
|
@@ -2630,13 +3595,19 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2630
3595
|
cas_conflict: false,
|
|
2631
3596
|
applied_grant: None,
|
|
2632
3597
|
registry_receipt: None,
|
|
3598
|
+
frozen_owner: None,
|
|
3599
|
+
frozen_receiver: None,
|
|
2633
3600
|
nonce_writer: Some(&nonce_writer_winner),
|
|
2634
3601
|
};
|
|
2635
|
-
assert!(
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
3602
|
+
assert!(crate::transport_factory::with_leader_endpoint_transport(
|
|
3603
|
+
"/tmp/tmux.sock",
|
|
3604
|
+
transport.clone(),
|
|
3605
|
+
|| bind_fresh_quick_start_leader_with(
|
|
3606
|
+
&second_workspace,
|
|
3607
|
+
"fresh",
|
|
3608
|
+
Some(&second_seed),
|
|
3609
|
+
&mut second_ops
|
|
3610
|
+
)
|
|
2640
3611
|
)
|
|
2641
3612
|
.unwrap());
|
|
2642
3613
|
let first_receiver = crate::state::persist::load_runtime_state(&first_workspace).unwrap();
|
|
@@ -2786,7 +3757,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2786
3757
|
std::fs::create_dir_all(&parent).unwrap();
|
|
2787
3758
|
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
2788
3759
|
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
2789
|
-
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "
|
|
3760
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
|
|
2790
3761
|
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
2791
3762
|
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
2792
3763
|
let seed = seeded_runtime_owner(&workspace);
|
|
@@ -2807,6 +3778,8 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2807
3778
|
cas_conflict: false,
|
|
2808
3779
|
applied_grant: None,
|
|
2809
3780
|
registry_receipt: None,
|
|
3781
|
+
frozen_owner: None,
|
|
3782
|
+
frozen_receiver: None,
|
|
2810
3783
|
nonce_writer: Some(&nonce_writer_winner),
|
|
2811
3784
|
},
|
|
2812
3785
|
register_ok: false,
|
|
@@ -2858,7 +3831,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2858
3831
|
std::fs::create_dir_all(&parent).unwrap();
|
|
2859
3832
|
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
2860
3833
|
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
2861
|
-
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "
|
|
3834
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
|
|
2862
3835
|
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
2863
3836
|
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
2864
3837
|
let seed = seeded_runtime_owner(&workspace);
|
|
@@ -2874,6 +3847,8 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2874
3847
|
cas_conflict: false,
|
|
2875
3848
|
applied_grant: None,
|
|
2876
3849
|
registry_receipt: None,
|
|
3850
|
+
frozen_owner: None,
|
|
3851
|
+
frozen_receiver: None,
|
|
2877
3852
|
nonce_writer: None,
|
|
2878
3853
|
},
|
|
2879
3854
|
register_ok: true,
|
|
@@ -2917,7 +3892,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2917
3892
|
std::fs::create_dir_all(&parent).unwrap();
|
|
2918
3893
|
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
2919
3894
|
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
2920
|
-
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "
|
|
3895
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
|
|
2921
3896
|
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
2922
3897
|
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
2923
3898
|
let seed = seeded_runtime_owner(&workspace);
|
|
@@ -2937,13 +3912,14 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2937
3912
|
cas_conflict: false,
|
|
2938
3913
|
applied_grant: None,
|
|
2939
3914
|
registry_receipt: None,
|
|
3915
|
+
frozen_owner: None,
|
|
3916
|
+
frozen_receiver: None,
|
|
2940
3917
|
nonce_writer: None,
|
|
2941
3918
|
};
|
|
2942
|
-
assert!(!
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
Some(&seed),
|
|
2946
|
-
&mut ops
|
|
3919
|
+
assert!(!crate::transport_factory::with_leader_endpoint_transport(
|
|
3920
|
+
"/tmp/tmux.sock",
|
|
3921
|
+
transport.clone(),
|
|
3922
|
+
|| bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops)
|
|
2947
3923
|
)
|
|
2948
3924
|
.unwrap());
|
|
2949
3925
|
assert_eq!(ops.last_failure_reason, Some("receiver_live_channel_unavailable"));
|
|
@@ -2972,7 +3948,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2972
3948
|
std::fs::create_dir_all(&parent).unwrap();
|
|
2973
3949
|
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
2974
3950
|
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
2975
|
-
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "
|
|
3951
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
|
|
2976
3952
|
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
2977
3953
|
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
2978
3954
|
let seed = seeded_runtime_owner(&workspace);
|
|
@@ -2988,6 +3964,8 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
2988
3964
|
cas_conflict: false,
|
|
2989
3965
|
applied_grant: None,
|
|
2990
3966
|
registry_receipt: None,
|
|
3967
|
+
frozen_owner: None,
|
|
3968
|
+
frozen_receiver: None,
|
|
2991
3969
|
nonce_writer: None,
|
|
2992
3970
|
},
|
|
2993
3971
|
register_ok: true,
|
|
@@ -3029,7 +4007,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3029
4007
|
std::fs::create_dir_all(&parent).unwrap();
|
|
3030
4008
|
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
3031
4009
|
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
3032
|
-
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "
|
|
4010
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
|
|
3033
4011
|
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
3034
4012
|
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
3035
4013
|
let seed = seeded_runtime_owner(&workspace);
|
|
@@ -3045,6 +4023,8 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3045
4023
|
cas_conflict: false,
|
|
3046
4024
|
applied_grant: None,
|
|
3047
4025
|
registry_receipt: None,
|
|
4026
|
+
frozen_owner: None,
|
|
4027
|
+
frozen_receiver: None,
|
|
3048
4028
|
nonce_writer: None,
|
|
3049
4029
|
},
|
|
3050
4030
|
register_ok: true,
|
|
@@ -3110,7 +4090,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3110
4090
|
std::fs::create_dir_all(&parent).unwrap();
|
|
3111
4091
|
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
3112
4092
|
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
3113
|
-
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "
|
|
4093
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
|
|
3114
4094
|
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
3115
4095
|
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
3116
4096
|
let seed = seeded_runtime_owner(&workspace);
|
|
@@ -3126,6 +4106,8 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3126
4106
|
cas_conflict: false,
|
|
3127
4107
|
applied_grant: None,
|
|
3128
4108
|
registry_receipt: None,
|
|
4109
|
+
frozen_owner: None,
|
|
4110
|
+
frozen_receiver: None,
|
|
3129
4111
|
nonce_writer: None,
|
|
3130
4112
|
},
|
|
3131
4113
|
register_ok: true,
|
|
@@ -3188,7 +4170,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3188
4170
|
std::fs::create_dir_all(&parent).unwrap();
|
|
3189
4171
|
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
3190
4172
|
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
3191
|
-
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "
|
|
4173
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
|
|
3192
4174
|
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
3193
4175
|
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
3194
4176
|
let seed = seeded_runtime_owner(&workspace);
|
|
@@ -3213,6 +4195,8 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3213
4195
|
cas_conflict: false,
|
|
3214
4196
|
applied_grant: None,
|
|
3215
4197
|
registry_receipt: None,
|
|
4198
|
+
frozen_owner: None,
|
|
4199
|
+
frozen_receiver: None,
|
|
3216
4200
|
nonce_writer: None,
|
|
3217
4201
|
};
|
|
3218
4202
|
assert!(!bind_fresh_quick_start_leader_with(
|
|
@@ -3236,14 +4220,14 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3236
4220
|
|
|
3237
4221
|
#[test]
|
|
3238
4222
|
#[serial_test::serial(env)]
|
|
3239
|
-
fn
|
|
4223
|
+
fn first_nonce_writer_is_product_generated_and_consumer_live_provider_independent() {
|
|
3240
4224
|
let hermetic = HermeticTestEnv::enter("runtime-first-nonce-writer");
|
|
3241
4225
|
let workspace = runtime_workspace(&hermetic, "fresh");
|
|
3242
4226
|
let parent = hermetic.root().join("parent");
|
|
3243
4227
|
std::fs::create_dir_all(&parent).unwrap();
|
|
3244
4228
|
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
3245
4229
|
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
3246
|
-
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "
|
|
4230
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
|
|
3247
4231
|
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
3248
4232
|
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
3249
4233
|
let seed = seeded_runtime_owner(&workspace);
|
|
@@ -3261,7 +4245,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3261
4245
|
&workspace,
|
|
3262
4246
|
&mut state,
|
|
3263
4247
|
Some(&PaneId::new("%1")),
|
|
3264
|
-
crate::provider::Provider::
|
|
4248
|
+
crate::provider::Provider::Codex,
|
|
3265
4249
|
&event_log,
|
|
3266
4250
|
crate::leader::LeaseSource::QuickStart,
|
|
3267
4251
|
true,
|
|
@@ -3297,7 +4281,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3297
4281
|
&workspace,
|
|
3298
4282
|
&mut state,
|
|
3299
4283
|
Some(&PaneId::new("%1")),
|
|
3300
|
-
crate::provider::Provider::
|
|
4284
|
+
crate::provider::Provider::Codex,
|
|
3301
4285
|
&crate::event_log::EventLog::new(&workspace),
|
|
3302
4286
|
crate::leader::LeaseSource::QuickStart,
|
|
3303
4287
|
true,
|
|
@@ -3325,7 +4309,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3325
4309
|
std::fs::create_dir_all(&parent).unwrap();
|
|
3326
4310
|
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
3327
4311
|
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
3328
|
-
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "
|
|
4312
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
|
|
3329
4313
|
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
3330
4314
|
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
3331
4315
|
let seed = seeded_runtime_owner(&workspace);
|
|
@@ -3361,6 +4345,8 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3361
4345
|
cas_conflict: false,
|
|
3362
4346
|
applied_grant: None,
|
|
3363
4347
|
registry_receipt: None,
|
|
4348
|
+
frozen_owner: None,
|
|
4349
|
+
frozen_receiver: None,
|
|
3364
4350
|
nonce_writer: Some(&concurrent_writer),
|
|
3365
4351
|
};
|
|
3366
4352
|
assert!(!bind_fresh_quick_start_leader_with(
|
|
@@ -3401,32 +4387,6 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3401
4387
|
assert_eq!(winner_entry.channel["pane_id"], json!("%winner"));
|
|
3402
4388
|
}
|
|
3403
4389
|
|
|
3404
|
-
#[test]
|
|
3405
|
-
#[serial_test::serial(env)]
|
|
3406
|
-
fn runtime_invalid_caller_refuses_even_when_command_is_pi() {
|
|
3407
|
-
let hermetic = HermeticTestEnv::enter("runtime-invalid-command-pi");
|
|
3408
|
-
let workspace = runtime_workspace(&hermetic, "fresh");
|
|
3409
|
-
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
3410
|
-
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
3411
|
-
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
|
|
3412
|
-
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%9");
|
|
3413
|
-
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
3414
|
-
let transport = OfflineTransport::new()
|
|
3415
|
-
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
3416
|
-
.with_pane_current_command("%1", "pi");
|
|
3417
|
-
let mut ops = RecordingRuntimeOps::new(&transport);
|
|
3418
|
-
assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
|
|
3419
|
-
assert_eq!(ops.attach_calls, 0);
|
|
3420
|
-
assert_eq!(ops.attached_provider, None);
|
|
3421
|
-
assert_eq!(
|
|
3422
|
-
refusal_reason(&workspace),
|
|
3423
|
-
Some((
|
|
3424
|
-
"strict_provider".to_string(),
|
|
3425
|
-
"provider_unresolved".to_string()
|
|
3426
|
-
))
|
|
3427
|
-
);
|
|
3428
|
-
}
|
|
3429
|
-
|
|
3430
4390
|
#[test]
|
|
3431
4391
|
#[serial_test::serial(env)]
|
|
3432
4392
|
fn runtime_socket_and_explicit_conflicts_refuse_without_command_fallback() {
|
|
@@ -3444,9 +4404,11 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3444
4404
|
"/tmp/product.sock",
|
|
3445
4405
|
),
|
|
3446
4406
|
(
|
|
4407
|
+
// The explicit leader provider intentionally conflicts with the
|
|
4408
|
+
// caller provider; worker_env must reject this before command fallback.
|
|
3447
4409
|
"leader",
|
|
3448
4410
|
"/tmp/tmux.sock",
|
|
3449
|
-
Some("
|
|
4411
|
+
Some("claude"),
|
|
3450
4412
|
"/tmp/tmux.sock",
|
|
3451
4413
|
),
|
|
3452
4414
|
] {
|
|
@@ -3454,13 +4416,13 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3454
4416
|
let workspace = runtime_workspace(&hermetic, tag);
|
|
3455
4417
|
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
3456
4418
|
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
3457
|
-
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "
|
|
4419
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
|
|
3458
4420
|
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
3459
4421
|
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, endpoint);
|
|
3460
4422
|
let _leader = leader.map(|value| hermetic.with_env("TEAM_AGENT_LEADER_PROVIDER", value));
|
|
3461
4423
|
let transport = OfflineTransport::new()
|
|
3462
4424
|
.with_tmux_endpoint(scoped)
|
|
3463
|
-
.with_pane_current_command("%1", "
|
|
4425
|
+
.with_pane_current_command("%1", "codex");
|
|
3464
4426
|
let mut ops = RecordingRuntimeOps::new(&transport);
|
|
3465
4427
|
assert!(
|
|
3466
4428
|
!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap(),
|
|
@@ -3470,11 +4432,216 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3470
4432
|
}
|
|
3471
4433
|
}
|
|
3472
4434
|
|
|
4435
|
+
|
|
4436
|
+
#[test]
|
|
4437
|
+
fn invalid_pi_tool_category_is_rejected_before_runtime_persistence() {
|
|
4438
|
+
let root = std::env::temp_dir().join(format!(
|
|
4439
|
+
"ta-quick-start-pi-tools-{}-{}",
|
|
4440
|
+
std::process::id(),
|
|
4441
|
+
SEQUENCE.fetch_add(1, Ordering::Relaxed)
|
|
4442
|
+
));
|
|
4443
|
+
let team = root.join(".team/current");
|
|
4444
|
+
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
4445
|
+
std::fs::write(
|
|
4446
|
+
team.join("TEAM.md"),
|
|
4447
|
+
"---\nname: pi-tools\nprovider: pi\n---\n\nPi team.\n",
|
|
4448
|
+
)
|
|
4449
|
+
.unwrap();
|
|
4450
|
+
std::fs::write(
|
|
4451
|
+
team.join("agents/worker.md"),
|
|
4452
|
+
"---\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",
|
|
4453
|
+
)
|
|
4454
|
+
.unwrap();
|
|
4455
|
+
|
|
4456
|
+
let transport = crate::transport::test_support::OfflineTransport::new();
|
|
4457
|
+
let mut discover = |_requested: &str| -> Result<Vec<String>, ()> { Err(()) };
|
|
4458
|
+
let error = quick_start_with_transport_in_workspace_with_display_pi_preflight(
|
|
4459
|
+
&root,
|
|
4460
|
+
&team,
|
|
4461
|
+
None,
|
|
4462
|
+
true,
|
|
4463
|
+
None,
|
|
4464
|
+
&transport,
|
|
4465
|
+
false,
|
|
4466
|
+
&mut discover,
|
|
4467
|
+
)
|
|
4468
|
+
.expect_err("invalid Pi category must fail before quick-start persistence");
|
|
4469
|
+
let error = error.to_string();
|
|
4470
|
+
assert!(error.contains("Pi does not support Team Agent tool category \"provider_builtin\""));
|
|
4471
|
+
assert!(error.contains("remove it from the role's tools"));
|
|
4472
|
+
assert!(
|
|
4473
|
+
!crate::state::persist::runtime_state_path(&root).exists(),
|
|
4474
|
+
"quick-start validation failure must not persist runtime state"
|
|
4475
|
+
);
|
|
4476
|
+
assert!(
|
|
4477
|
+
!root.join(".team/runtime").exists(),
|
|
4478
|
+
"quick-start validation failure must not create runtime artifacts"
|
|
4479
|
+
);
|
|
4480
|
+
let _ = std::fs::remove_dir_all(root);
|
|
4481
|
+
}
|
|
4482
|
+
|
|
4483
|
+
#[test]
|
|
4484
|
+
fn fresh_binding_persists_then_registers_then_requires_canonical_readback() {
|
|
4485
|
+
let workspace = workspace_with_provider("positive-pi", "pi");
|
|
4486
|
+
let mut ops = MockOps::with_provider("pi");
|
|
4487
|
+
assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
4488
|
+
.unwrap());
|
|
4489
|
+
assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
|
|
4490
|
+
assert_eq!(ops.attach_calls, 1);
|
|
4491
|
+
assert_eq!(ops.register_calls, 1);
|
|
4492
|
+
assert_eq!(ops.readback_calls, 1);
|
|
4493
|
+
let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
4494
|
+
assert_eq!(
|
|
4495
|
+
state
|
|
4496
|
+
.get("teams")
|
|
4497
|
+
.and_then(serde_json::Value::as_object)
|
|
4498
|
+
.and_then(|teams| teams.get("fresh"))
|
|
4499
|
+
.and_then(|team| team.get("leader_receiver"))
|
|
4500
|
+
.and_then(|receiver| receiver.get("pane_id"))
|
|
4501
|
+
.and_then(serde_json::Value::as_str),
|
|
4502
|
+
Some("%42")
|
|
4503
|
+
);
|
|
4504
|
+
assert!(
|
|
4505
|
+
crate::event_log::EventLog::new(&workspace)
|
|
4506
|
+
.tail(0)
|
|
4507
|
+
.unwrap()
|
|
4508
|
+
.is_empty(),
|
|
4509
|
+
"successful bind must not emit a refusal event"
|
|
4510
|
+
);
|
|
4511
|
+
}
|
|
4512
|
+
|
|
4513
|
+
#[test]
|
|
4514
|
+
#[serial_test::serial(env)]
|
|
4515
|
+
fn runtime_valid_caller_binds_typed_pi_through_bash() {
|
|
4516
|
+
let hermetic = HermeticTestEnv::enter("runtime-valid-pi-bash");
|
|
4517
|
+
let workspace = runtime_workspace_with_provider(&hermetic, "fresh", "pi");
|
|
4518
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
4519
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
4520
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
|
|
4521
|
+
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
4522
|
+
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
4523
|
+
let transport = OfflineTransport::new()
|
|
4524
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
4525
|
+
.with_pane_current_command("%1", "bash");
|
|
4526
|
+
let mut ops = RecordingRuntimeOps::new(&transport);
|
|
4527
|
+
assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
|
|
4528
|
+
assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
|
|
4529
|
+
assert_eq!(ops.attach_calls, 1);
|
|
4530
|
+
}
|
|
4531
|
+
|
|
4532
|
+
#[test]
|
|
4533
|
+
#[serial_test::serial(env)]
|
|
4534
|
+
fn first_nonce_writer_is_product_generated_and_consumer_live() {
|
|
4535
|
+
let hermetic = HermeticTestEnv::enter("runtime-first-nonce-writer");
|
|
4536
|
+
let workspace = runtime_workspace_with_provider(&hermetic, "fresh", "pi");
|
|
4537
|
+
let parent = hermetic.root().join("parent");
|
|
4538
|
+
std::fs::create_dir_all(&parent).unwrap();
|
|
4539
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
4540
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
4541
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
|
|
4542
|
+
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
4543
|
+
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
4544
|
+
let seed = seeded_runtime_owner(&workspace);
|
|
4545
|
+
let mut state = crate::state::projection::resolve_runtime_team_scope(
|
|
4546
|
+
&workspace,
|
|
4547
|
+
Some("fresh"),
|
|
4548
|
+
)
|
|
4549
|
+
.unwrap()
|
|
4550
|
+
.state;
|
|
4551
|
+
let expected_owner = state["team_owner"].clone();
|
|
4552
|
+
let expected_receiver = state["leader_receiver"].clone();
|
|
4553
|
+
let target = caller_target(parent.clone(), None);
|
|
4554
|
+
let event_log = crate::event_log::EventLog::new(&workspace);
|
|
4555
|
+
let (receiver, _) = crate::leader::attach_leader_to_state_with_target_and_controls(
|
|
4556
|
+
&workspace,
|
|
4557
|
+
&mut state,
|
|
4558
|
+
Some(&PaneId::new("%1")),
|
|
4559
|
+
crate::provider::Provider::Pi,
|
|
4560
|
+
&event_log,
|
|
4561
|
+
crate::leader::LeaseSource::QuickStart,
|
|
4562
|
+
true,
|
|
4563
|
+
Some(&target),
|
|
4564
|
+
Some(&expected_owner),
|
|
4565
|
+
Some(&expected_receiver),
|
|
4566
|
+
Some(&nonce_writer_winner),
|
|
4567
|
+
)
|
|
4568
|
+
.unwrap();
|
|
4569
|
+
let receiver = serde_json::to_value(receiver).unwrap();
|
|
4570
|
+
assert_eq!(receiver["binding_nonce"], json!("writer-winner"));
|
|
4571
|
+
let observed = caller_target(parent, Some("writer-winner"));
|
|
4572
|
+
let transport = OfflineTransport::new()
|
|
4573
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
4574
|
+
.with_targets(vec![observed]);
|
|
4575
|
+
assert!(matches!(
|
|
4576
|
+
crate::messaging::resolve_live_leader_channel(&workspace, &receiver, &transport),
|
|
4577
|
+
crate::messaging::LeaderChannelResolution::Live(_)
|
|
4578
|
+
));
|
|
4579
|
+
|
|
4580
|
+
let workspace = runtime_workspace_with_provider(&hermetic, "writer-failure", "pi");
|
|
4581
|
+
let seed = seeded_runtime_owner(&workspace);
|
|
4582
|
+
let mut state = crate::state::projection::resolve_runtime_team_scope(
|
|
4583
|
+
&workspace,
|
|
4584
|
+
Some("fresh"),
|
|
4585
|
+
)
|
|
4586
|
+
.unwrap()
|
|
4587
|
+
.state;
|
|
4588
|
+
let expected_owner = state["team_owner"].clone();
|
|
4589
|
+
let expected_receiver = state["leader_receiver"].clone();
|
|
4590
|
+
let target = caller_target(hermetic.root().join("parent"), None);
|
|
4591
|
+
let result = crate::leader::attach_leader_to_state_with_target_and_controls(
|
|
4592
|
+
&workspace,
|
|
4593
|
+
&mut state,
|
|
4594
|
+
Some(&PaneId::new("%1")),
|
|
4595
|
+
crate::provider::Provider::Pi,
|
|
4596
|
+
&crate::event_log::EventLog::new(&workspace),
|
|
4597
|
+
crate::leader::LeaseSource::QuickStart,
|
|
4598
|
+
true,
|
|
4599
|
+
Some(&target),
|
|
4600
|
+
Some(&expected_owner),
|
|
4601
|
+
Some(&expected_receiver),
|
|
4602
|
+
Some(&nonce_writer_failure),
|
|
4603
|
+
);
|
|
4604
|
+
assert!(result.is_err());
|
|
4605
|
+
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
4606
|
+
assert_eq!(
|
|
4607
|
+
persisted.pointer("/teams/fresh/leader_receiver/binding_nonce"),
|
|
4608
|
+
None
|
|
4609
|
+
);
|
|
4610
|
+
assert!(!persisted.to_string().contains("fresh_caller"));
|
|
4611
|
+
let _ = seed;
|
|
4612
|
+
}
|
|
4613
|
+
|
|
4614
|
+
#[test]
|
|
4615
|
+
#[serial_test::serial(env)]
|
|
4616
|
+
fn runtime_invalid_caller_refuses_even_when_command_is_pi() {
|
|
4617
|
+
let hermetic = HermeticTestEnv::enter("runtime-invalid-command-pi");
|
|
4618
|
+
let workspace = runtime_workspace_with_provider(&hermetic, "fresh", "pi");
|
|
4619
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
4620
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
4621
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
|
|
4622
|
+
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%9");
|
|
4623
|
+
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
4624
|
+
let transport = OfflineTransport::new()
|
|
4625
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
4626
|
+
.with_pane_current_command("%1", "pi");
|
|
4627
|
+
let mut ops = RecordingRuntimeOps::new(&transport);
|
|
4628
|
+
assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
|
|
4629
|
+
assert_eq!(ops.attach_calls, 0);
|
|
4630
|
+
assert_eq!(ops.attached_provider, None);
|
|
4631
|
+
assert_eq!(
|
|
4632
|
+
refusal_reason(&workspace),
|
|
4633
|
+
Some((
|
|
4634
|
+
"strict_provider".to_string(),
|
|
4635
|
+
"invalid_caller_tuple".to_string()
|
|
4636
|
+
))
|
|
4637
|
+
);
|
|
4638
|
+
}
|
|
4639
|
+
|
|
3473
4640
|
#[test]
|
|
3474
4641
|
#[serial_test::serial(env)]
|
|
3475
4642
|
fn runtime_absent_keeps_explicit_and_direct_paths() {
|
|
3476
4643
|
let hermetic = HermeticTestEnv::enter("runtime-absent-compat");
|
|
3477
|
-
let workspace =
|
|
4644
|
+
let workspace = runtime_workspace_with_provider(&hermetic, "explicit", "pi");
|
|
3478
4645
|
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
3479
4646
|
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
3480
4647
|
let _leader = hermetic.with_env("TEAM_AGENT_LEADER_PROVIDER", "pi");
|
|
@@ -3485,7 +4652,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3485
4652
|
assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
|
|
3486
4653
|
assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
|
|
3487
4654
|
|
|
3488
|
-
let workspace =
|
|
4655
|
+
let workspace = runtime_workspace_with_provider(&hermetic, "direct", "pi");
|
|
3489
4656
|
drop(_leader);
|
|
3490
4657
|
let transport = OfflineTransport::new()
|
|
3491
4658
|
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
@@ -3494,7 +4661,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3494
4661
|
assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
|
|
3495
4662
|
assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
|
|
3496
4663
|
|
|
3497
|
-
let workspace =
|
|
4664
|
+
let workspace = runtime_workspace_with_provider(&hermetic, "unknown-shell", "pi");
|
|
3498
4665
|
let transport = OfflineTransport::new()
|
|
3499
4666
|
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
3500
4667
|
.with_pane_current_command("%1", "bash");
|