@team-agent/installer 0.5.81 → 0.5.83
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/emit.rs +155 -5
- package/crates/team-agent/src/cli/leaders.rs +304 -167
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/types.rs +11 -0
- package/crates/team-agent/src/communication_mode/mod.rs +3 -3
- package/crates/team-agent/src/leader/registry.rs +295 -64
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +0 -2
- package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +15 -4
- package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +0 -1
- package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +3 -4
- package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +0 -1
- package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +5 -0
- package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +0 -1
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +47 -13
- package/crates/team-agent/src/provider/adapters/pi.rs +6 -6
- package/crates/team-agent/src/state/persist.rs +50 -1
- package/package.json +4 -4
|
@@ -367,8 +367,8 @@ pub fn restore_entry_if_current_matches(
|
|
|
367
367
|
if receipt.path.parent() != Some(dir) {
|
|
368
368
|
return Err(RegistryRollbackError::InvalidReceipt);
|
|
369
369
|
}
|
|
370
|
-
let current =
|
|
371
|
-
.map_err(|_| RegistryRollbackError::ReadFailed)?;
|
|
370
|
+
let current =
|
|
371
|
+
std::fs::read(&receipt.path).map_err(|_| RegistryRollbackError::ReadFailed)?;
|
|
372
372
|
if current != receipt.bytes {
|
|
373
373
|
return Ok(RegistryRollback::Superseded);
|
|
374
374
|
}
|
|
@@ -377,33 +377,117 @@ pub fn restore_entry_if_current_matches(
|
|
|
377
377
|
Ok(RegistryRollback::Restored)
|
|
378
378
|
}
|
|
379
379
|
Some(_) => Err(RegistryRollbackError::RestoreFailed),
|
|
380
|
-
None if std::fs::remove_file(&receipt.path).is_ok() =>
|
|
381
|
-
Ok(RegistryRollback::Restored)
|
|
382
|
-
}
|
|
380
|
+
None if std::fs::remove_file(&receipt.path).is_ok() => Ok(RegistryRollback::Restored),
|
|
383
381
|
None => Err(RegistryRollbackError::DeleteFailed),
|
|
384
382
|
}
|
|
385
383
|
})
|
|
386
384
|
}
|
|
387
385
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
386
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
387
|
+
pub struct RegistryPruneItem {
|
|
388
|
+
pub workspace: PathBuf,
|
|
389
|
+
pub team_key: String,
|
|
390
|
+
pub reason: String,
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
394
|
+
pub struct RegistryPruneReport {
|
|
395
|
+
pub dry_run: bool,
|
|
396
|
+
pub candidates: Vec<RegistryPruneItem>,
|
|
397
|
+
pub removed: Vec<RegistryPruneItem>,
|
|
398
|
+
pub kept: Vec<RegistryPruneItem>,
|
|
399
|
+
pub skipped: Vec<RegistryPruneItem>,
|
|
400
|
+
pub errors: Vec<RegistryPruneItem>,
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
404
|
+
enum PruneAttempt {
|
|
405
|
+
Removed,
|
|
406
|
+
Skipped(&'static str),
|
|
407
|
+
Error,
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
fn prune_item(entry: &LeaderRegistryEntry, reason: impl Into<String>) -> RegistryPruneItem {
|
|
411
|
+
RegistryPruneItem {
|
|
412
|
+
workspace: entry.workspace.clone(),
|
|
413
|
+
team_key: entry.team_key.clone(),
|
|
414
|
+
reason: reason.into(),
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
fn prune_reason_from_state(entry: &LeaderRegistryEntry, state: &Value) -> Option<&'static str> {
|
|
419
|
+
let (status, reason) = classify_loaded(entry, state);
|
|
420
|
+
if status != "STALE" {
|
|
421
|
+
return None;
|
|
422
|
+
}
|
|
423
|
+
match reason.as_deref() {
|
|
424
|
+
Some("team_key_not_found") => Some("team_key_not_found"),
|
|
425
|
+
Some("team_not_alive") => Some("team_not_alive"),
|
|
426
|
+
_ => None,
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
fn prune_reason(
|
|
431
|
+
entry: &LeaderRegistryEntry,
|
|
432
|
+
status: &str,
|
|
433
|
+
reason: Option<&str>,
|
|
434
|
+
) -> Option<&'static str> {
|
|
435
|
+
if status != "STALE" || !crate::state::persist::runtime_state_path(&entry.workspace).is_file() {
|
|
436
|
+
// A missing state file is unavailable rather than proof that the
|
|
437
|
+
// workspace was retired. Only an existing, readable canonical state
|
|
438
|
+
// can authorize the team-key cleanup below.
|
|
439
|
+
return None;
|
|
440
|
+
}
|
|
441
|
+
match reason {
|
|
442
|
+
Some("team_key_not_found") => Some("team_key_not_found"),
|
|
443
|
+
Some("team_not_alive") => Some("team_not_alive"),
|
|
444
|
+
_ => None,
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
fn prune_entry_if_current(
|
|
449
|
+
path: &Path,
|
|
450
|
+
expected: &LeaderRegistryEntry,
|
|
451
|
+
snapshot: &[u8],
|
|
452
|
+
) -> PruneAttempt {
|
|
453
|
+
let result = crate::state::persist::with_runtime_state_lock_without_migrations(
|
|
454
|
+
&expected.workspace,
|
|
455
|
+
|state| {
|
|
456
|
+
let Some(state) = state.as_ref() else {
|
|
457
|
+
return PruneAttempt::Skipped("canonical_state_unavailable");
|
|
458
|
+
};
|
|
459
|
+
// Lock ordering is canonical state -> host registry. The state
|
|
460
|
+
// remains unchanged while the exact registry bytes are checked.
|
|
461
|
+
with_registry_lock(|_| {
|
|
462
|
+
let Ok(bytes) = std::fs::read(path) else {
|
|
463
|
+
return PruneAttempt::Skipped("registry_entry_missing");
|
|
464
|
+
};
|
|
465
|
+
if bytes != snapshot {
|
|
466
|
+
return PruneAttempt::Skipped("registry_entry_changed");
|
|
467
|
+
}
|
|
468
|
+
let Ok(current) = serde_json::from_slice::<LeaderRegistryEntry>(&bytes) else {
|
|
469
|
+
return PruneAttempt::Skipped("registry_entry_changed");
|
|
470
|
+
};
|
|
471
|
+
if current != *expected {
|
|
472
|
+
return PruneAttempt::Skipped("registry_entry_changed");
|
|
473
|
+
}
|
|
474
|
+
if prune_reason_from_state(¤t, state).is_none() {
|
|
475
|
+
return PruneAttempt::Skipped("canonical_state_changed");
|
|
476
|
+
}
|
|
477
|
+
if std::fs::remove_file(path).is_ok() {
|
|
478
|
+
PruneAttempt::Removed
|
|
479
|
+
} else {
|
|
480
|
+
PruneAttempt::Error
|
|
481
|
+
}
|
|
482
|
+
})
|
|
483
|
+
.unwrap_or(PruneAttempt::Skipped("registry_lock_unavailable"))
|
|
484
|
+
},
|
|
485
|
+
);
|
|
486
|
+
result.unwrap_or(PruneAttempt::Skipped("canonical_state_unavailable"))
|
|
399
487
|
}
|
|
400
488
|
|
|
401
489
|
fn replace_entry_bytes_locked(path: &Path, bytes: &[u8]) -> bool {
|
|
402
|
-
let tmp = path.with_extension(format!(
|
|
403
|
-
"rollback-{}-{}",
|
|
404
|
-
std::process::id(),
|
|
405
|
-
rand_suffix()
|
|
406
|
-
));
|
|
490
|
+
let tmp = path.with_extension(format!("rollback-{}-{}", std::process::id(), rand_suffix()));
|
|
407
491
|
if std::fs::write(&tmp, bytes).is_err() {
|
|
408
492
|
let _ = std::fs::remove_file(&tmp);
|
|
409
493
|
return false;
|
|
@@ -497,7 +581,7 @@ pub fn unregister_entry(workspace: &Path, team_key: &str) -> Option<PathBuf> {
|
|
|
497
581
|
/// registry is a derived discovery index and unreadable files are the
|
|
498
582
|
/// "STALE / UNREADABLE" dirty class rather than an error.
|
|
499
583
|
#[must_use]
|
|
500
|
-
fn
|
|
584
|
+
fn read_all_entries_with_bytes() -> Vec<(PathBuf, LeaderRegistryEntry, Vec<u8>)> {
|
|
501
585
|
let Some(dir) = registry_dir() else {
|
|
502
586
|
return Vec::new();
|
|
503
587
|
};
|
|
@@ -517,18 +601,26 @@ fn read_all_entries() -> Vec<(PathBuf, LeaderRegistryEntry)> {
|
|
|
517
601
|
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
|
518
602
|
continue;
|
|
519
603
|
}
|
|
520
|
-
let Ok(
|
|
604
|
+
let Ok(bytes) = std::fs::read(&path) else {
|
|
521
605
|
continue;
|
|
522
606
|
};
|
|
523
|
-
let Ok(entry) = serde_json::
|
|
607
|
+
let Ok(entry) = serde_json::from_slice::<LeaderRegistryEntry>(&bytes) else {
|
|
524
608
|
continue;
|
|
525
609
|
};
|
|
526
|
-
out.push((path, entry));
|
|
610
|
+
out.push((path, entry, bytes));
|
|
527
611
|
}
|
|
528
612
|
out.sort_by(|a, b| a.0.cmp(&b.0));
|
|
529
613
|
out
|
|
530
614
|
}
|
|
531
615
|
|
|
616
|
+
#[must_use]
|
|
617
|
+
fn read_all_entries() -> Vec<(PathBuf, LeaderRegistryEntry)> {
|
|
618
|
+
read_all_entries_with_bytes()
|
|
619
|
+
.into_iter()
|
|
620
|
+
.map(|(path, entry, _bytes)| (path, entry))
|
|
621
|
+
.collect()
|
|
622
|
+
}
|
|
623
|
+
|
|
532
624
|
/// Validate a registry entry against its canonical workspace state.
|
|
533
625
|
/// Returns the wire status label — LIVE when canonical `leader_receiver`
|
|
534
626
|
/// matches, STALE otherwise. This is the pruning gate for GC and the
|
|
@@ -538,11 +630,17 @@ pub fn classify(entry: &LeaderRegistryEntry) -> (&'static str, Option<String>) {
|
|
|
538
630
|
let Ok(state) = crate::state::persist::load_runtime_state(&entry.workspace) else {
|
|
539
631
|
return ("STALE", Some("workspace_no_state".to_string()));
|
|
540
632
|
};
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
633
|
+
classify_loaded(entry, &state)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
fn classify_loaded(entry: &LeaderRegistryEntry, state: &Value) -> (&'static str, Option<String>) {
|
|
637
|
+
// A missing or malformed `teams` root is an unavailable/legacy state,
|
|
638
|
+
// not proof that this registry entry's team was retired. Keep it out of
|
|
639
|
+
// the prune candidate set unless a valid map was independently found.
|
|
640
|
+
let Some(teams) = state.get("teams").and_then(Value::as_object) else {
|
|
641
|
+
return ("STALE", Some("teams_invalid".to_string()));
|
|
642
|
+
};
|
|
643
|
+
let team = match teams.get(&entry.team_key) {
|
|
546
644
|
Some(t) => t,
|
|
547
645
|
None => return ("STALE", Some("team_key_not_found".to_string())),
|
|
548
646
|
};
|
|
@@ -599,47 +697,57 @@ fn tmux_pane_live(socket: &str, pane_id: &str) -> bool {
|
|
|
599
697
|
}
|
|
600
698
|
}
|
|
601
699
|
|
|
602
|
-
///
|
|
603
|
-
///
|
|
604
|
-
///
|
|
605
|
-
/// classified for the `leaders` command output.
|
|
606
|
-
///
|
|
607
|
-
/// Design §14 step 6: "Successful scoped shutdown removes matching registry
|
|
608
|
-
/// entry. Failed/degraded shutdown leaves entry STALE, not deleted."
|
|
609
|
-
/// The pruning here is the GC arm — it only removes entries whose
|
|
610
|
-
/// canonical workspace/team has no state at all, i.e. the target has been
|
|
611
|
-
/// permanently removed. Leader-detached-but-team-alive stays STALE and
|
|
612
|
-
/// visible so the operator can decide.
|
|
700
|
+
/// Explicitly prune only entries whose readable canonical state proves that
|
|
701
|
+
/// the team is gone or terminal. Dead panes, old epochs, unattached leaders,
|
|
702
|
+
/// missing/unreadable state, and probe failures remain untouched.
|
|
613
703
|
#[must_use]
|
|
614
|
-
pub fn
|
|
615
|
-
let mut
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
704
|
+
pub fn prune_registry(dry_run: bool) -> RegistryPruneReport {
|
|
705
|
+
let mut report = RegistryPruneReport {
|
|
706
|
+
dry_run,
|
|
707
|
+
candidates: Vec::new(),
|
|
708
|
+
removed: Vec::new(),
|
|
709
|
+
kept: Vec::new(),
|
|
710
|
+
skipped: Vec::new(),
|
|
711
|
+
errors: Vec::new(),
|
|
712
|
+
};
|
|
713
|
+
let mut registry_lock_unavailable = false;
|
|
714
|
+
for (path, entry, snapshot) in read_all_entries_with_bytes() {
|
|
715
|
+
let (status, classified_reason) = classify(&entry);
|
|
716
|
+
let reason = prune_reason(&entry, status, classified_reason.as_deref());
|
|
717
|
+
let kept_reason = classified_reason.as_deref().unwrap_or("LIVE");
|
|
718
|
+
let Some(reason) = reason else {
|
|
719
|
+
report.kept.push(prune_item(&entry, kept_reason));
|
|
720
|
+
continue;
|
|
721
|
+
};
|
|
722
|
+
let candidate = prune_item(&entry, reason);
|
|
723
|
+
report.candidates.push(candidate.clone());
|
|
724
|
+
if dry_run {
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
if registry_lock_unavailable {
|
|
728
|
+
report
|
|
729
|
+
.skipped
|
|
730
|
+
.push(prune_item(&entry, "registry_lock_unavailable"));
|
|
632
731
|
continue;
|
|
633
732
|
}
|
|
634
|
-
|
|
733
|
+
match prune_entry_if_current(&path, &entry, &snapshot) {
|
|
734
|
+
PruneAttempt::Removed => report.removed.push(candidate),
|
|
735
|
+
PruneAttempt::Skipped(reason) => {
|
|
736
|
+
if reason == "registry_lock_unavailable" {
|
|
737
|
+
registry_lock_unavailable = true;
|
|
738
|
+
}
|
|
739
|
+
report.skipped.push(prune_item(&entry, reason));
|
|
740
|
+
}
|
|
741
|
+
PruneAttempt::Error => report.errors.push(prune_item(&entry, "remove_failed")),
|
|
742
|
+
}
|
|
635
743
|
}
|
|
636
|
-
|
|
744
|
+
report
|
|
637
745
|
}
|
|
638
746
|
|
|
639
|
-
///
|
|
640
|
-
/// --to-leader` needs to see stale entries so it can refuse with
|
|
747
|
+
/// Read all entries and classify each without mutating the derived index.
|
|
748
|
+
/// `send --to-leader` needs to see stale entries so it can refuse with
|
|
641
749
|
/// `registry_stale` (and never silently fall through to
|
|
642
|
-
/// `leader_name_not_found` just because
|
|
750
|
+
/// `leader_name_not_found` just because a listing ran first).
|
|
643
751
|
#[must_use]
|
|
644
752
|
pub fn list_validated_no_gc() -> Vec<(LeaderRegistryEntry, &'static str, Option<String>)> {
|
|
645
753
|
read_all_entries()
|
|
@@ -650,3 +758,126 @@ pub fn list_validated_no_gc() -> Vec<(LeaderRegistryEntry, &'static str, Option<
|
|
|
650
758
|
})
|
|
651
759
|
.collect()
|
|
652
760
|
}
|
|
761
|
+
|
|
762
|
+
#[cfg(test)]
|
|
763
|
+
mod tests {
|
|
764
|
+
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
|
765
|
+
|
|
766
|
+
use super::*;
|
|
767
|
+
use serde_json::json;
|
|
768
|
+
use serial_test::serial;
|
|
769
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
770
|
+
|
|
771
|
+
static TEST_SEQ: AtomicU64 = AtomicU64::new(0);
|
|
772
|
+
|
|
773
|
+
struct HomeGuard(Option<std::ffi::OsString>);
|
|
774
|
+
|
|
775
|
+
impl Drop for HomeGuard {
|
|
776
|
+
fn drop(&mut self) {
|
|
777
|
+
if let Some(home) = self.0.take() {
|
|
778
|
+
std::env::set_var("HOME", home);
|
|
779
|
+
} else {
|
|
780
|
+
std::env::remove_var("HOME");
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
fn test_home(label: &str) -> (HomeGuard, PathBuf, PathBuf) {
|
|
786
|
+
let n = TEST_SEQ.fetch_add(1, Ordering::Relaxed);
|
|
787
|
+
let home =
|
|
788
|
+
std::env::temp_dir().join(format!("ta-registry-{label}-{}-{n}", std::process::id()));
|
|
789
|
+
let workspace = home.join("workspace");
|
|
790
|
+
std::fs::create_dir_all(&workspace).expect("create workspace");
|
|
791
|
+
let previous = std::env::var_os("HOME");
|
|
792
|
+
std::env::set_var("HOME", &home);
|
|
793
|
+
(HomeGuard(previous), home, workspace)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
fn write_state(workspace: &Path, state: Value) {
|
|
797
|
+
let path = crate::state::persist::runtime_state_path(workspace);
|
|
798
|
+
std::fs::create_dir_all(path.parent().expect("runtime parent")).expect("create runtime");
|
|
799
|
+
std::fs::write(
|
|
800
|
+
path,
|
|
801
|
+
serde_json::to_vec_pretty(&state).expect("serialize state"),
|
|
802
|
+
)
|
|
803
|
+
.expect("write state");
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
fn entry(workspace: &Path, team_key: &str) -> LeaderRegistryEntry {
|
|
807
|
+
build_entry(
|
|
808
|
+
workspace,
|
|
809
|
+
team_key,
|
|
810
|
+
"direct_tmux",
|
|
811
|
+
json!({"pane_id": "%fixture"}),
|
|
812
|
+
1,
|
|
813
|
+
"test",
|
|
814
|
+
"2026-09-16T00:00:00Z".to_string(),
|
|
815
|
+
)
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
#[test]
|
|
819
|
+
#[serial]
|
|
820
|
+
fn prune_removes_team_proven_terminal() {
|
|
821
|
+
let (_home_guard, _home, workspace) = test_home("terminal");
|
|
822
|
+
write_state(
|
|
823
|
+
&workspace,
|
|
824
|
+
json!({"teams": {"retired": {"status": "stopped"}}}),
|
|
825
|
+
);
|
|
826
|
+
let registered = entry(&workspace, "retired");
|
|
827
|
+
let path = write_entry_best_effort(®istered).expect("register terminal team");
|
|
828
|
+
|
|
829
|
+
let report = prune_registry(false);
|
|
830
|
+
assert_eq!(report.candidates.len(), 1);
|
|
831
|
+
assert_eq!(report.removed.len(), 1);
|
|
832
|
+
assert!(report.skipped.is_empty());
|
|
833
|
+
assert!(!path.exists(), "terminal registry entry must be removed");
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
#[test]
|
|
837
|
+
#[serial]
|
|
838
|
+
fn prune_removes_missing_key_only_from_valid_teams_map() {
|
|
839
|
+
let (_home_guard, _home, workspace) = test_home("missing-key");
|
|
840
|
+
write_state(&workspace, json!({"teams": {"other": {"status": "alive"}}}));
|
|
841
|
+
let registered = entry(&workspace, "gone");
|
|
842
|
+
let path = write_entry_best_effort(®istered).expect("register missing team");
|
|
843
|
+
|
|
844
|
+
let report = prune_registry(false);
|
|
845
|
+
assert_eq!(report.removed.len(), 1);
|
|
846
|
+
assert!(
|
|
847
|
+
!path.exists(),
|
|
848
|
+
"missing key in valid teams map is removable"
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
#[test]
|
|
853
|
+
#[serial]
|
|
854
|
+
fn prune_keeps_null_nonobject_and_legacy_teams_shapes() {
|
|
855
|
+
for (label, state) in [
|
|
856
|
+
("null", json!({"teams": null})),
|
|
857
|
+
("array", json!({"teams": []})),
|
|
858
|
+
("string", json!({"teams": "legacy"})),
|
|
859
|
+
(
|
|
860
|
+
"single",
|
|
861
|
+
json!({"team_key": "retired", "status": "stopped"}),
|
|
862
|
+
),
|
|
863
|
+
] {
|
|
864
|
+
let (_home_guard, _home, workspace) = test_home(label);
|
|
865
|
+
write_state(&workspace, state);
|
|
866
|
+
let registered = entry(&workspace, "retired");
|
|
867
|
+
let path = write_entry_best_effort(®istered).expect("register malformed state");
|
|
868
|
+
|
|
869
|
+
let report = prune_registry(false);
|
|
870
|
+
assert!(
|
|
871
|
+
report.candidates.is_empty(),
|
|
872
|
+
"malformed shape became candidate: {label}"
|
|
873
|
+
);
|
|
874
|
+
assert_eq!(
|
|
875
|
+
report.kept.len(),
|
|
876
|
+
1,
|
|
877
|
+
"malformed shape must be retained: {label}"
|
|
878
|
+
);
|
|
879
|
+
assert_eq!(report.kept[0].reason, "teams_invalid");
|
|
880
|
+
assert!(path.exists(), "malformed state entry must remain: {label}");
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
@@ -1089,7 +1089,6 @@ fn materialize_pi_plan_with_session(
|
|
|
1089
1089
|
})?;
|
|
1090
1090
|
let (argv, expected_session_id) = if let Some((_session_id, exact_path)) = resume {
|
|
1091
1091
|
let argv = build_pi_command_argv(PiCommandRequest {
|
|
1092
|
-
executable: &chain.launch_executable,
|
|
1093
1092
|
extension: &wrapper,
|
|
1094
1093
|
model: model.as_deref(),
|
|
1095
1094
|
effort: request.effort,
|
|
@@ -1103,7 +1102,6 @@ fn materialize_pi_plan_with_session(
|
|
|
1103
1102
|
} else {
|
|
1104
1103
|
let session_id = new_pi_session_id();
|
|
1105
1104
|
let argv = build_pi_command_argv(PiCommandRequest {
|
|
1106
|
-
executable: &chain.launch_executable,
|
|
1107
1105
|
extension: &wrapper,
|
|
1108
1106
|
model: model.as_deref(),
|
|
1109
1107
|
effort: request.effort,
|
|
@@ -112,7 +112,13 @@ fn t06_selected_templates_project_the_two_signed_communication_boundaries() {
|
|
|
112
112
|
};
|
|
113
113
|
|
|
114
114
|
let leader = prompt(CommunicationMode::LeaderCentric);
|
|
115
|
-
for marker in [
|
|
115
|
+
for marker in [
|
|
116
|
+
"Progress",
|
|
117
|
+
"blocker",
|
|
118
|
+
"question",
|
|
119
|
+
"actionable requests or questions",
|
|
120
|
+
"Do not reply to pure ACKs, greetings, or unchanged status notices",
|
|
121
|
+
] {
|
|
116
122
|
assert!(
|
|
117
123
|
leader.contains(marker),
|
|
118
124
|
"T06/leader_centric: official template must retain {marker:?} guidance; prompt={leader:?}"
|
|
@@ -120,7 +126,11 @@ fn t06_selected_templates_project_the_two_signed_communication_boundaries() {
|
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
let orchestrated = prompt(CommunicationMode::Orchestrated);
|
|
123
|
-
for marker in [
|
|
129
|
+
for marker in [
|
|
130
|
+
"declared channel",
|
|
131
|
+
"Respond to task-related messages through Team Agent MCP tools.",
|
|
132
|
+
"A pure ACK, unrelated status, or non-task message does not require a response.",
|
|
133
|
+
] {
|
|
124
134
|
assert!(
|
|
125
135
|
orchestrated.contains(marker),
|
|
126
136
|
"T06/orchestrated: official template must state the on-demand channel/response boundary via {marker:?}; prompt={orchestrated:?}"
|
|
@@ -159,7 +169,7 @@ fn t07_final_spawn_prompt_does_not_leak_leader_centric_obligations_into_orchestr
|
|
|
159
169
|
|
|
160
170
|
for required in [
|
|
161
171
|
"All communication must go through Team Agent MCP tools.",
|
|
162
|
-
"
|
|
172
|
+
"Do not reply to pure ACKs, greetings, or unchanged status notices",
|
|
163
173
|
"report_result exactly once",
|
|
164
174
|
] {
|
|
165
175
|
assert!(
|
|
@@ -186,7 +196,8 @@ fn t07_final_spawn_prompt_does_not_leak_leader_centric_obligations_into_orchestr
|
|
|
186
196
|
.expect("T07/leader_centric positive control: missing recorded default spawn");
|
|
187
197
|
for required in [
|
|
188
198
|
"Progress, blockers, questions:",
|
|
189
|
-
"
|
|
199
|
+
"actionable requests or questions",
|
|
200
|
+
"Do not reply to pure ACKs, greetings, or unchanged status notices",
|
|
190
201
|
] {
|
|
191
202
|
assert!(
|
|
192
203
|
default_prompt.contains(required),
|
|
@@ -87,7 +87,6 @@ fn argv_has_pair(argv: &[String], flag: &str, value: &str) -> bool {
|
|
|
87
87
|
|
|
88
88
|
fn command_for_model(model: &str) -> Vec<String> {
|
|
89
89
|
build_pi_command_argv(PiCommandRequest {
|
|
90
|
-
executable: Path::new("/verified/pi"),
|
|
91
90
|
extension: Path::new("/workspace/.team/runtime/pi/t1/w1/team-mcp.ts"),
|
|
92
91
|
model: Some(model),
|
|
93
92
|
effort: Some(ProviderEffort::Medium),
|
|
@@ -6,7 +6,6 @@ use crate::provider::adapters::pi::{build_pi_command_argv, PiCommandRequest, PiS
|
|
|
6
6
|
|
|
7
7
|
fn request(session: PiSessionSelector<'static>) -> PiCommandRequest<'static> {
|
|
8
8
|
PiCommandRequest {
|
|
9
|
-
executable: Path::new("/verified/pi"),
|
|
10
9
|
extension: Path::new("/workspace/.team/runtime/pi/team-a/worker-a/team-mcp.ts"),
|
|
11
10
|
model: Some("team-agent/qwen3.8-27b"),
|
|
12
11
|
effort: Some(ProviderEffort::High),
|
|
@@ -35,7 +34,6 @@ fn pi_command_omits_unset_model_and_thinking_flags() {
|
|
|
35
34
|
|
|
36
35
|
fn leader_request() -> PiCommandRequest<'static> {
|
|
37
36
|
PiCommandRequest {
|
|
38
|
-
executable: Path::new("/verified/pi"),
|
|
39
37
|
extension: Path::new("/workspace/.team/runtime/pi/current/leader/team-mcp.ts"),
|
|
40
38
|
model: None,
|
|
41
39
|
effort: None,
|
|
@@ -56,7 +54,7 @@ fn pi_interactive_leader_argv_preserves_native_session_discovery() {
|
|
|
56
54
|
assert_eq!(
|
|
57
55
|
argv,
|
|
58
56
|
[
|
|
59
|
-
"
|
|
57
|
+
"pi",
|
|
60
58
|
"-e",
|
|
61
59
|
"/workspace/.team/runtime/pi/current/leader/team-mcp.ts",
|
|
62
60
|
"--append-system-prompt",
|
|
@@ -109,7 +107,7 @@ fn pi_command_preserves_direct_pi_defaults_with_only_team_runtime_additions() {
|
|
|
109
107
|
assert_eq!(
|
|
110
108
|
fresh,
|
|
111
109
|
[
|
|
112
|
-
"
|
|
110
|
+
"pi",
|
|
113
111
|
"-e",
|
|
114
112
|
"/workspace/.team/runtime/pi/team-a/worker-a/team-mcp.ts",
|
|
115
113
|
"--model",
|
|
@@ -143,6 +141,7 @@ fn pi_command_preserves_direct_pi_defaults_with_only_team_runtime_additions() {
|
|
|
143
141
|
assert!(!resume.iter().any(|arg| arg == "--session-id"));
|
|
144
142
|
|
|
145
143
|
for argv in [&fresh, &resume] {
|
|
144
|
+
assert_eq!(argv.first().map(String::as_str), Some("pi"));
|
|
146
145
|
for forbidden in [
|
|
147
146
|
"rpc",
|
|
148
147
|
"--mode",
|
|
@@ -53,7 +53,6 @@ fn compile_error(result: Result<CompiledRole, ModelError>) -> ModelError {
|
|
|
53
53
|
|
|
54
54
|
fn command(tools: &[&str], effort: ProviderEffort, model: &str) -> Result<Vec<String>, String> {
|
|
55
55
|
build_pi_command_argv(PiCommandRequest {
|
|
56
|
-
executable: Path::new("/verified/pi"),
|
|
57
56
|
extension: Path::new("/workspace/.team/runtime/pi/t1/pi-worker/team-mcp.ts"),
|
|
58
57
|
model: Some(model),
|
|
59
58
|
effort: Some(effort),
|
|
@@ -586,6 +586,7 @@ fn pi_materializer_and_worker_routes_body(hermetic: &HermeticTestEnv) {
|
|
|
586
586
|
!native_paths.sessions.exists(),
|
|
587
587
|
"NativeDefault must not create the Team Agent seat session root"
|
|
588
588
|
);
|
|
589
|
+
assert_eq!(native.argv.first().map(String::as_str), Some("pi"));
|
|
589
590
|
for forbidden in ["--session-dir", "--cwd", "--workspace", "--session"] {
|
|
590
591
|
assert!(
|
|
591
592
|
!native.argv.iter().any(|arg| arg == forbidden),
|
|
@@ -614,6 +615,7 @@ fn pi_materializer_and_worker_routes_body(hermetic: &HermeticTestEnv) {
|
|
|
614
615
|
isolated.provider_projects_root.as_deref(),
|
|
615
616
|
Some(isolated_paths.sessions.as_path())
|
|
616
617
|
);
|
|
618
|
+
assert_eq!(isolated.argv.first().map(String::as_str), Some("pi"));
|
|
617
619
|
assert_session_pair(&isolated.argv, &isolated_paths.sessions);
|
|
618
620
|
assert!(!isolated.argv.iter().any(|arg| arg == "--cwd"));
|
|
619
621
|
assert!(!isolated.argv.iter().any(|arg| arg == "--workspace"));
|
|
@@ -645,6 +647,7 @@ fn pi_materializer_and_worker_routes_body(hermetic: &HermeticTestEnv) {
|
|
|
645
647
|
resumed.provider_projects_root.as_deref(),
|
|
646
648
|
Some(isolated_paths.sessions.as_path())
|
|
647
649
|
);
|
|
650
|
+
assert_eq!(resumed.argv.first().map(String::as_str), Some("pi"));
|
|
648
651
|
assert_session_pair(&resumed.argv, &isolated_paths.sessions);
|
|
649
652
|
assert!(
|
|
650
653
|
resumed
|
|
@@ -757,6 +760,7 @@ fn pi_materializer_and_worker_routes_body(hermetic: &HermeticTestEnv) {
|
|
|
757
760
|
.any(|arg| arg.as_str() == paths.wrapper.to_string_lossy().as_ref())
|
|
758
761
|
})
|
|
759
762
|
.expect("transport spawn record for worker seat");
|
|
763
|
+
assert_eq!(record.1.first().map(String::as_str), Some("pi"));
|
|
760
764
|
assert_session_pair(&record.1, &paths.sessions);
|
|
761
765
|
assert!(
|
|
762
766
|
!record
|
|
@@ -861,6 +865,7 @@ fn pi_materializer_and_worker_routes_body(hermetic: &HermeticTestEnv) {
|
|
|
861
865
|
.into_iter()
|
|
862
866
|
.next()
|
|
863
867
|
.expect("restart must reach the worker spawn construction");
|
|
868
|
+
assert_eq!(restart_record.1.first().map(String::as_str), Some("pi"));
|
|
864
869
|
assert_session_pair(&restart_record.1, &restart_paths.sessions);
|
|
865
870
|
assert!(
|
|
866
871
|
restart_record
|
|
@@ -54,7 +54,6 @@ fn scan_context(cwd: &Path, root: &Path, id: &str, generation: &str) -> CaptureS
|
|
|
54
54
|
|
|
55
55
|
fn resume_argv(path: &Path) -> Result<Vec<String>, ProviderError> {
|
|
56
56
|
build_pi_command_argv(PiCommandRequest {
|
|
57
|
-
executable: Path::new("/verified/pi"),
|
|
58
57
|
extension: Path::new("/workspace/.team/runtime/pi/team-a/worker-a/team-mcp.ts"),
|
|
59
58
|
model: Some("team-agent/qwen3.8-27b"),
|
|
60
59
|
effort: Some(ProviderEffort::Medium),
|