@team-agent/installer 0.5.70 → 0.5.72

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.
@@ -2522,10 +2522,10 @@ fn write_restart_agent_failed_event(
2522
2522
  "session_id": decision.session_id.as_ref().map(|session| session.as_str()),
2523
2523
  "phase": phase,
2524
2524
  "error": error,
2525
- "action": format!(
2526
- "inspect worker {} output, then restart that worker with `team-agent restart-agent {}` or rerun `team-agent restart --allow-fresh`",
2527
- decision.agent_id,
2528
- decision.agent_id
2525
+ "action": restart_failure_action(
2526
+ &decision.agent_id,
2527
+ phase,
2528
+ decision.session_id.as_ref(),
2529
2529
  ),
2530
2530
  "log": format!(
2531
2531
  ".team/logs/coordinator.log and .team/runtime/state.json agent={}",
@@ -3008,14 +3008,30 @@ fn restart_failure_next_actions(failed_agents: &[RestartFailedAgent]) -> Vec<Str
3008
3008
  failed_agents
3009
3009
  .iter()
3010
3010
  .map(|failure| {
3011
- format!(
3012
- "inspect worker {} output, then restart that worker with `team-agent restart-agent {}` or rerun `team-agent restart --allow-fresh`",
3013
- failure.agent_id, failure.agent_id
3011
+ restart_failure_action(
3012
+ &failure.agent_id,
3013
+ &failure.phase,
3014
+ failure.session_id.as_ref(),
3014
3015
  )
3015
3016
  })
3016
3017
  .collect()
3017
3018
  }
3018
3019
 
3020
+ pub(crate) fn restart_failure_action(
3021
+ agent_id: &AgentId,
3022
+ phase: &str,
3023
+ session_id: Option<&SessionId>,
3024
+ ) -> String {
3025
+ if phase == "spawn" && session_id.is_none() {
3026
+ return format!(
3027
+ "worker {agent_id} failed before a pane was created; inspect the reported provider error, then rerun `team-agent restart --allow-fresh`"
3028
+ );
3029
+ }
3030
+ format!(
3031
+ "inspect worker {agent_id} output, then reset that worker with `team-agent reset-agent {agent_id}` or rerun `team-agent restart --allow-fresh`"
3032
+ )
3033
+ }
3034
+
3019
3035
  fn start_mode_wire(mode: StartMode) -> &'static str {
3020
3036
  match mode {
3021
3037
  StartMode::Resumed => "resumed",
@@ -92,7 +92,8 @@ pub(crate) use rebuild::restart_with_transport_with_session_convergence_deadline
92
92
  // timer + worker timing writer so `lifecycle::launch::launch_with_transport_in_workspace`
93
93
  // can emit the same instrumentation event family with `source="launch"`.
94
94
  pub(crate) use rebuild::{
95
- provider_wire_from_state, write_worker_spawn_timing_event, RestartPhaseTimer,
95
+ provider_wire_from_state, restart_failure_action, write_worker_spawn_timing_event,
96
+ RestartPhaseTimer,
96
97
  };
97
98
  pub use rebuild::{restart, restart_with_session_convergence_deadline};
98
99
  pub(crate) use rebuild::{
@@ -1846,3 +1846,171 @@ fn lanea_fork_rollback_complete_on_post_spawn_failure() {
1846
1846
  // Golden contract (operations.py:384-394): a post-spawn failure kills the spawned window, restores
1847
1847
  // the old spec text + prior state, and runs cleanup_mcp before re-raising.
1848
1848
  }
1849
+
1850
+ #[test]
1851
+ fn schema_invalid_pi_role_is_rejected_before_normal_and_force_mutation() {
1852
+ let ws = lanea_ws_agents(json!({
1853
+ "alpha": { "status": "stopped", "provider": "codex", "window": "alpha" },
1854
+ "bravo": { "status": "running", "provider": "codex", "window": "bravo" }
1855
+ }));
1856
+ let tx = LaneTransport::new("team-laneateam", &[]);
1857
+ seed_healthy_coordinator(&ws);
1858
+ let role_dir = ws.join("role-masters");
1859
+ std::fs::create_dir_all(&role_dir).unwrap();
1860
+ let role = role_dir.join("invalid-pi.md");
1861
+ std::fs::write(
1862
+ &role,
1863
+ CHARLIE_ROLE
1864
+ .replace(
1865
+ "provider: codex\nmodel: gpt-5.5",
1866
+ "provider: pi\nmodel: openai-codex/gpt-5.6-sol",
1867
+ )
1868
+ .replace(" - mcp_team", " - fs_read"),
1869
+ )
1870
+ .unwrap();
1871
+
1872
+ let spec_path = selected_spec_path(&ws);
1873
+ let state_path = crate::state::persist::runtime_state_path(&ws);
1874
+ let event_path = ws.join(".team/logs/events.jsonl");
1875
+ let copied_role = ws.join("agents/charlie.md");
1876
+ let team_state_path = ws.join("team_state.md");
1877
+ let charlie_paths = crate::lifecycle::launch::pi_mcp::pi_seat_paths(
1878
+ &ws,
1879
+ "laneateam",
1880
+ "charlie",
1881
+ );
1882
+ let alpha_paths =
1883
+ crate::lifecycle::launch::pi_mcp::pi_seat_paths(&ws, "laneateam", "alpha");
1884
+ let spec_before = std::fs::read(&spec_path).unwrap();
1885
+ let state_before = std::fs::read(&state_path).unwrap();
1886
+ let role_before = std::fs::read(&role).unwrap();
1887
+ let alpha_before = serde_json::from_slice::<serde_json::Value>(&state_before).unwrap()
1888
+ ["agents"]["alpha"]
1889
+ .clone();
1890
+ for path in [
1891
+ &event_path,
1892
+ &copied_role,
1893
+ &team_state_path,
1894
+ &charlie_paths.wrapper,
1895
+ &charlie_paths.sessions,
1896
+ &alpha_paths.wrapper,
1897
+ &alpha_paths.sessions,
1898
+ ] {
1899
+ assert!(!path.exists(), "precondition: {} must be absent", path.display());
1900
+ }
1901
+
1902
+ let assert_schema_error = |error: crate::lifecycle::LifecycleError| match error {
1903
+ crate::lifecycle::LifecycleError::Compile(message) => {
1904
+ assert!(message.contains("Pi roles require mcp_team"), "{message}");
1905
+ }
1906
+ other => panic!("expected Pi role schema error, got {other:?}"),
1907
+ };
1908
+ let assert_unchanged = |expected_role: &[u8]| {
1909
+ assert_eq!(spec_before, std::fs::read(&spec_path).unwrap());
1910
+ assert_eq!(state_before, std::fs::read(&state_path).unwrap());
1911
+ assert_eq!(expected_role, std::fs::read(&role).unwrap());
1912
+ for path in [
1913
+ &event_path,
1914
+ &copied_role,
1915
+ &team_state_path,
1916
+ &charlie_paths.wrapper,
1917
+ &charlie_paths.sessions,
1918
+ &alpha_paths.wrapper,
1919
+ &alpha_paths.sessions,
1920
+ ] {
1921
+ assert!(!path.exists(), "preflight created {}", path.display());
1922
+ }
1923
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
1924
+ assert_eq!(state["agents"]["alpha"], alpha_before);
1925
+ assert!(state["agents"].get("charlie").is_none(), "no reservation row");
1926
+ assert!(tx.spawns().is_empty());
1927
+ assert!(tx.killed().is_empty());
1928
+ };
1929
+
1930
+ let mut normal_calls = 0;
1931
+ let mut normal_discover = |_requested: &str| {
1932
+ normal_calls += 1;
1933
+ Ok(Vec::new())
1934
+ };
1935
+ let normal = crate::lifecycle::add_agent_with_transport_pi_preflight(
1936
+ &ws,
1937
+ &aid("charlie"),
1938
+ &role,
1939
+ false,
1940
+ None,
1941
+ &tx,
1942
+ &mut normal_discover,
1943
+ )
1944
+ .expect_err("invalid Pi schema must fail before reservation");
1945
+ drop(normal_discover);
1946
+ assert_eq!(normal_calls, 0);
1947
+ assert_schema_error(normal);
1948
+ assert_unchanged(&role_before);
1949
+
1950
+ let mut force_calls = 0;
1951
+ let mut force_discover = |_requested: &str| {
1952
+ force_calls += 1;
1953
+ Ok(Vec::new())
1954
+ };
1955
+ let force = crate::lifecycle::add_agent_with_transport_force_pi_preflight(
1956
+ &ws,
1957
+ &aid("alpha"),
1958
+ &role,
1959
+ false,
1960
+ None,
1961
+ true,
1962
+ &tx,
1963
+ &mut force_discover,
1964
+ )
1965
+ .expect_err("invalid Pi replacement schema must fail before old-seat consumption");
1966
+ drop(force_discover);
1967
+ assert_eq!(force_calls, 0);
1968
+ assert_schema_error(force);
1969
+ assert_unchanged(&role_before);
1970
+
1971
+ std::fs::write(
1972
+ &role,
1973
+ CHARLIE_ROLE.replace(
1974
+ "provider: codex\nmodel: gpt-5.5",
1975
+ "provider: pi\nmodel: gpt-5.6-sol",
1976
+ ),
1977
+ )
1978
+ .unwrap();
1979
+ let model_role_before = std::fs::read(&role).unwrap();
1980
+ let assert_model_diagnostic = |error: crate::lifecycle::LifecycleError| match error {
1981
+ crate::lifecycle::LifecycleError::PiModelPreflight {
1982
+ requested,
1983
+ candidates,
1984
+ not_ready,
1985
+ ..
1986
+ } => {
1987
+ assert_eq!(requested, "gpt-5.6-sol");
1988
+ assert_eq!(candidates, vec!["openai-codex/gpt-5.6-sol"]);
1989
+ assert!(!not_ready);
1990
+ }
1991
+ other => panic!("expected typed Pi preflight error, got {other:?}"),
1992
+ };
1993
+ for (force, agent_id) in [(false, "charlie"), (true, "alpha")] {
1994
+ let mut calls = 0;
1995
+ let mut discover = |requested: &str| {
1996
+ calls += 1;
1997
+ assert_eq!(requested, "gpt-5.6-sol");
1998
+ Ok(vec!["openai-codex/gpt-5.6-sol".into()])
1999
+ };
2000
+ let error = crate::lifecycle::add_agent_with_transport_force_pi_preflight(
2001
+ &ws,
2002
+ &aid(agent_id),
2003
+ &role,
2004
+ false,
2005
+ None,
2006
+ force,
2007
+ &tx,
2008
+ &mut discover,
2009
+ )
2010
+ .expect_err("unqualified Pi model must fail before mutation");
2011
+ drop(discover);
2012
+ assert_eq!(calls, 1);
2013
+ assert_model_diagnostic(error);
2014
+ assert_unchanged(&model_role_before);
2015
+ }
2016
+ }
@@ -375,6 +375,215 @@ fn quick_start_invalid_role_doc_surfaces_real_compile_error() {
375
375
  );
376
376
  }
377
377
 
378
+ #[test]
379
+ fn quick_start_absent_or_empty_roles_preserves_compile_outcome_without_pi_preflight_mutation() {
380
+ for (label, create_agents, expected) in [
381
+ ("absent", false, "missing agents directory"),
382
+ ("empty", true, "no role docs found"),
383
+ ] {
384
+ let team = temp_ws().join(format!("teamdir-{label}"));
385
+ std::fs::create_dir_all(&team).unwrap();
386
+ std::fs::write(team.join("TEAM.md"), QS_TEAM_MD).unwrap();
387
+ if create_agents {
388
+ std::fs::create_dir(team.join("agents")).unwrap();
389
+ }
390
+ let workspace = crate::model::paths::team_workspace(&team).unwrap();
391
+ let team_md_before = std::fs::read(team.join("TEAM.md")).unwrap();
392
+ let absent_paths = [
393
+ team.join("team.spec.yaml"),
394
+ crate::state::persist::runtime_state_path(&workspace),
395
+ crate::model::paths::runtime_spec_path(&workspace, "quickteam"),
396
+ workspace.join("team_state.md"),
397
+ workspace.join(".team/logs/events.jsonl"),
398
+ workspace.join(".team/runtime/team.db"),
399
+ ];
400
+ for path in &absent_paths {
401
+ assert!(!path.exists(), "precondition: {} must be absent", path.display());
402
+ }
403
+
404
+ let transport = OfflineTransport::new();
405
+ let mut calls = 0;
406
+ let mut discover = |_requested: &str| {
407
+ calls += 1;
408
+ Ok(Vec::new())
409
+ };
410
+ let error = quick_start_with_transport_in_workspace_with_display_pi_preflight(
411
+ &workspace,
412
+ &team,
413
+ None,
414
+ true,
415
+ None,
416
+ &transport,
417
+ true,
418
+ &mut discover,
419
+ )
420
+ .expect_err("an empty role input must preserve the existing compile outcome");
421
+ drop(discover);
422
+ assert_eq!(calls, 0, "{label} role input must not invoke Pi discovery");
423
+ match error {
424
+ LifecycleError::Compile(message) => {
425
+ assert!(message.contains(expected), "{label}: {message}");
426
+ }
427
+ other => panic!("{label}: expected existing compile outcome, got {other:?}"),
428
+ }
429
+ assert_eq!(team_md_before, std::fs::read(team.join("TEAM.md")).unwrap());
430
+ for path in &absent_paths {
431
+ assert!(!path.exists(), "{label} role input created {}", path.display());
432
+ }
433
+ assert!(transport.calls().is_empty(), "{label}: no transport mutation");
434
+ assert!(transport.spawn_records().is_empty(), "{label}: no spawn");
435
+ }
436
+ }
437
+
438
+ #[test]
439
+ fn quick_start_injected_pi_preflight_is_typed_and_mutation_free() {
440
+ let role_doc = QS_VALID_ROLE.replace(
441
+ "provider: codex\nmodel: gpt-5.5",
442
+ "provider: pi\nmodel: gpt-5.6-sol",
443
+ );
444
+ let team = quick_start_team_dir(&role_doc);
445
+ let workspace = crate::model::paths::team_workspace(&team).unwrap();
446
+ let role_path = team.join("agents/implementer.md");
447
+ let team_md_path = team.join("TEAM.md");
448
+ let local_spec_path = team.join("team.spec.yaml");
449
+ let runtime_state_path = crate::state::persist::runtime_state_path(&workspace);
450
+ let runtime_spec_path = crate::model::paths::runtime_spec_path(&workspace, "quickteam");
451
+ let pi_paths = crate::lifecycle::launch::pi_mcp::pi_seat_paths(
452
+ &workspace,
453
+ "quickteam",
454
+ "implementer",
455
+ );
456
+ let role_before = std::fs::read(&role_path).unwrap();
457
+ let team_md_before = std::fs::read(&team_md_path).unwrap();
458
+ for path in [
459
+ &local_spec_path,
460
+ &runtime_state_path,
461
+ &runtime_spec_path,
462
+ &pi_paths.wrapper,
463
+ &pi_paths.sessions,
464
+ ] {
465
+ assert!(!path.exists(), "precondition: {} must be absent", path.display());
466
+ }
467
+
468
+ let transport = OfflineTransport::new();
469
+ let mut calls = 0;
470
+ let mut discover = |requested: &str| {
471
+ calls += 1;
472
+ assert_eq!(requested, "gpt-5.6-sol");
473
+ Ok(vec!["openai-codex/gpt-5.6-sol".into()])
474
+ };
475
+ let error = quick_start_with_transport_in_workspace_with_display_pi_preflight(
476
+ &workspace,
477
+ &team,
478
+ None,
479
+ true,
480
+ None,
481
+ &transport,
482
+ true,
483
+ &mut discover,
484
+ )
485
+ .expect_err("unqualified Pi role must fail before quick-start mutation");
486
+ drop(discover);
487
+ assert_eq!(calls, 1);
488
+ match error {
489
+ LifecycleError::PiModelPreflight {
490
+ requested,
491
+ candidates,
492
+ action,
493
+ not_ready,
494
+ } => {
495
+ assert_eq!(requested, "gpt-5.6-sol");
496
+ assert_eq!(candidates, vec!["openai-codex/gpt-5.6-sol"]);
497
+ assert_eq!(
498
+ action,
499
+ "copy a candidate into the role, or run `team-agent models --provider pi --search gpt-5.6-sol`"
500
+ );
501
+ assert!(!not_ready);
502
+ }
503
+ other => panic!("expected typed Pi preflight error, got {other:?}"),
504
+ }
505
+ assert_eq!(role_before, std::fs::read(&role_path).unwrap());
506
+ assert_eq!(team_md_before, std::fs::read(&team_md_path).unwrap());
507
+ for path in [
508
+ &local_spec_path,
509
+ &runtime_state_path,
510
+ &runtime_spec_path,
511
+ &pi_paths.wrapper,
512
+ &pi_paths.sessions,
513
+ ] {
514
+ assert!(!path.exists(), "quick-start preflight created {}", path.display());
515
+ }
516
+ assert!(transport.calls().is_empty(), "no transport calls before preflight rejection");
517
+ assert!(transport.spawn_records().is_empty());
518
+ }
519
+
520
+ #[test]
521
+ fn quick_start_schema_invalid_pi_role_is_mutation_free() {
522
+ let role_doc = QS_VALID_ROLE
523
+ .replace(
524
+ "provider: codex\nmodel: gpt-5.5",
525
+ "provider: pi\nmodel: openai-codex/gpt-5.6-sol",
526
+ )
527
+ .replace(" - mcp_team", " - fs_read");
528
+ let team = quick_start_team_dir(&role_doc);
529
+ let workspace = crate::model::paths::team_workspace(&team).unwrap();
530
+ let role_path = team.join("agents/implementer.md");
531
+ let team_md_path = team.join("TEAM.md");
532
+ let role_before = std::fs::read(&role_path).unwrap();
533
+ let team_md_before = std::fs::read(&team_md_path).unwrap();
534
+ let pi_paths = crate::lifecycle::launch::pi_mcp::pi_seat_paths(
535
+ &workspace,
536
+ "quickteam",
537
+ "implementer",
538
+ );
539
+ let absent_paths = [
540
+ team.join("team.spec.yaml"),
541
+ crate::state::persist::runtime_state_path(&workspace),
542
+ crate::model::paths::runtime_spec_path(&workspace, "quickteam"),
543
+ workspace.join("team_state.md"),
544
+ workspace.join(".team/logs/events.jsonl"),
545
+ workspace.join(".team/runtime/team.db"),
546
+ pi_paths.wrapper,
547
+ pi_paths.sessions,
548
+ ];
549
+ for path in &absent_paths {
550
+ assert!(!path.exists(), "precondition: {} must be absent", path.display());
551
+ }
552
+
553
+ let transport = OfflineTransport::new();
554
+ let mut calls = 0;
555
+ let mut discover = |_requested: &str| {
556
+ calls += 1;
557
+ Ok(Vec::new())
558
+ };
559
+ let error = quick_start_with_transport_in_workspace_with_display_pi_preflight(
560
+ &workspace,
561
+ &team,
562
+ None,
563
+ true,
564
+ None,
565
+ &transport,
566
+ true,
567
+ &mut discover,
568
+ )
569
+ .expect_err("invalid Pi schema must fail before quick-start mutation");
570
+ drop(discover);
571
+ assert_eq!(calls, 0, "qualified models must not invoke discovery");
572
+ match error {
573
+ LifecycleError::Compile(message) => {
574
+ assert!(message.contains("Pi roles require mcp_team"), "{message}");
575
+ }
576
+ other => panic!("expected Pi role schema error, got {other:?}"),
577
+ }
578
+ assert_eq!(role_before, std::fs::read(&role_path).unwrap());
579
+ assert_eq!(team_md_before, std::fs::read(&team_md_path).unwrap());
580
+ for path in &absent_paths {
581
+ assert!(!path.exists(), "schema preflight created {}", path.display());
582
+ }
583
+ assert!(transport.calls().is_empty(), "no transport calls before schema rejection");
584
+ assert!(transport.spawn_records().is_empty());
585
+ }
586
+
378
587
  // P0 — launch (dry_run) over a real compiled spec must resolve the REAL route/permission plan
379
588
  // (no spawn) — NOT the hardcoded RequirementUnmet stub. Golden: launch/core.py dry_run resolves
380
589
  // routing + permissions without starting any process.
@@ -3,9 +3,10 @@ use std::path::{Path, PathBuf};
3
3
  use std::sync::atomic::{AtomicU32, Ordering};
4
4
 
5
5
  use crate::lifecycle::launch::pi_mcp::{
6
- parse_pi_list_models_table, resolve_pi_executable_chain, validate_pi_adapter_identity,
7
- validate_pi_executable_chain, validate_pi_wrapper_source, write_pi_wrapper, PiAdapterIdentity,
8
- PiExecutableChain, PiExecutableFileType, PiWrapperRequest,
6
+ parse_pi_list_models_table, pi_seat_paths, resolve_pi_executable_chain,
7
+ validate_pi_adapter_identity, validate_pi_executable_chain, validate_pi_wrapper_source,
8
+ write_pi_wrapper, write_pi_wrapper_with_publish, PiAdapterIdentity, PiExecutableChain,
9
+ PiExecutableFileType, PiWrapperRequest,
9
10
  };
10
11
  use crate::provider::McpConfig;
11
12
 
@@ -336,6 +337,124 @@ fn pi_wrapper_is_atomic_body(hermetic: &HermeticTestEnv) {
336
337
  std::fs::remove_dir_all(root).expect("remove wrapper fixture");
337
338
  }
338
339
 
340
+ #[test]
341
+ fn pi_leader_wrapper_materializes_from_empty_unicode_wsl_workspace() {
342
+ let root = temp_root("empty-unicode-leader");
343
+ let workspace = root.join("mnt/f/测试基础设施");
344
+ let candidate = root.join("candidate/team-agent");
345
+ std::fs::create_dir_all(&workspace).expect("create empty Unicode workspace");
346
+ std::fs::create_dir_all(candidate.parent().expect("candidate parent"))
347
+ .expect("create candidate parent");
348
+ std::fs::write(&candidate, b"candidate").expect("write candidate fixture");
349
+ assert!(
350
+ !workspace.join(".team").exists(),
351
+ "fixture must begin without a .team directory"
352
+ );
353
+
354
+ let paths = pi_seat_paths(&workspace, "current", "leader");
355
+ let adapter = adapter_identity(&root);
356
+ let written = write_pi_wrapper(PiWrapperRequest {
357
+ destination: &paths.wrapper,
358
+ adapter: &adapter,
359
+ candidate_executable: &candidate,
360
+ mcp_config: &mcp_config(&candidate, &workspace, "leader"),
361
+ team_id: "current",
362
+ agent_id: "leader",
363
+ workspace: &workspace,
364
+ include_tools: &["assign_task", "send_message"],
365
+ })
366
+ .expect("materialize Pi leader wrapper from an empty Unicode workspace");
367
+
368
+ assert_eq!(written, paths.wrapper);
369
+ assert!(written.is_file(), "published leader wrapper must exist");
370
+ assert!(
371
+ paths.runtime_root.is_dir(),
372
+ "leader runtime root must be created recursively"
373
+ );
374
+ let source = std::fs::read_to_string(&written).expect("read complete leader wrapper");
375
+ validate_pi_wrapper_source(&source, &adapter, &candidate)
376
+ .expect("published leader wrapper must be complete and valid");
377
+ assert!(source.contains("TEAM_AGENT_ID") && source.contains("leader"));
378
+ assert!(source.contains("TEAM_AGENT_OWNER_TEAM_ID") && source.contains("current"));
379
+ std::fs::remove_dir_all(root).expect("remove Unicode leader fixture");
380
+ }
381
+
382
+ #[test]
383
+ fn pi_wrapper_permissions_are_finalized_before_atomic_publish() {
384
+ let source = include_str!("../launch/pi_mcp.rs");
385
+ let write_start = source
386
+ .find("pub(crate) fn write_pi_wrapper")
387
+ .expect("write_pi_wrapper source");
388
+ let write_source = &source[write_start..];
389
+ let permissions = write_source
390
+ .find("file.set_permissions")
391
+ .expect("wrapper permissions must use the open temp-file handle");
392
+ let publish = write_source
393
+ .find("publish(&temp, request.destination)")
394
+ .expect("atomic wrapper publish");
395
+
396
+ assert!(
397
+ permissions < publish,
398
+ "DrvFS may not resolve the destination immediately after rename; finalize mode through the open temp-file handle before publishing"
399
+ );
400
+ assert!(
401
+ !write_source.contains("std::fs::set_permissions(request.destination"),
402
+ "atomic publish must not require a second destination-path lookup"
403
+ );
404
+ }
405
+
406
+ #[test]
407
+ fn pi_wrapper_publish_needs_no_final_path_metadata_lookup() {
408
+ let root = temp_root("publish-path-enoent");
409
+ let workspace = root.join("mnt/f/测试基础设施");
410
+ let candidate = root.join("candidate/team-agent");
411
+ std::fs::create_dir_all(&workspace).expect("create Unicode workspace");
412
+ std::fs::create_dir_all(candidate.parent().expect("candidate parent"))
413
+ .expect("create candidate parent");
414
+ std::fs::write(&candidate, b"candidate").expect("write candidate fixture");
415
+
416
+ let paths = pi_seat_paths(&workspace, "current", "leader");
417
+ let published_parent = paths.runtime_root.clone();
418
+ let hidden_parent = published_parent.with_file_name("leader-after-rename");
419
+ let adapter = adapter_identity(&root);
420
+ write_pi_wrapper_with_publish(
421
+ PiWrapperRequest {
422
+ destination: &paths.wrapper,
423
+ adapter: &adapter,
424
+ candidate_executable: &candidate,
425
+ mcp_config: &mcp_config(&candidate, &workspace, "leader"),
426
+ team_id: "current",
427
+ agent_id: "leader",
428
+ workspace: &workspace,
429
+ include_tools: &["assign_task", "send_message"],
430
+ },
431
+ |temp, destination| {
432
+ std::fs::rename(temp, destination)?;
433
+ std::fs::rename(&published_parent, &hidden_parent)?;
434
+ assert_eq!(
435
+ std::fs::metadata(destination)
436
+ .expect_err("simulate DrvFS final-path metadata ENOENT after rename")
437
+ .kind(),
438
+ std::io::ErrorKind::NotFound
439
+ );
440
+ Ok(())
441
+ },
442
+ )
443
+ .expect("successful publish must not perform final-path metadata operations");
444
+
445
+ let published = hidden_parent.join("team-mcp.ts");
446
+ assert!(published.is_file(), "rename published a complete wrapper");
447
+ assert_eq!(
448
+ std::fs::metadata(&published)
449
+ .expect("published wrapper metadata through converged path")
450
+ .permissions()
451
+ .mode()
452
+ & 0o777,
453
+ 0o600
454
+ );
455
+ std::fs::remove_dir_all(root).expect("remove publish-path fixture");
456
+ }
457
+
339
458
  #[test]
340
459
  fn pi_wrapper_runtime_registration_does_not_copy_ambient_config() {
341
460
  run_process_isolated(PI_WRAPPER_AMBIENT_CHILD, PI_WRAPPER_AMBIENT_TEST, || {
@@ -495,6 +495,14 @@ pub enum LifecycleError {
495
495
  /// 编译 spec / role doc 失败(`compile_team`/`compile_role_doc_agent`)。
496
496
  #[error("spec compile failed: {0}")]
497
497
  Compile(String),
498
+ /// Pi model/schema admission failed before lifecycle reservation or writes.
499
+ #[error("pi model preflight failed: {requested}")]
500
+ PiModelPreflight {
501
+ requested: String,
502
+ candidates: Vec<String>,
503
+ action: String,
504
+ not_ready: bool,
505
+ },
498
506
  /// provider 命令构造 / resume 不可用(`ResumeUnavailable`)。
499
507
  #[error("provider error: {0}")]
500
508
  Provider(String),