@team-agent/installer 0.5.75 → 0.5.78

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.
Files changed (98) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +9 -0
  4. package/README.zh.md +8 -0
  5. package/crates/team-agent/src/cli/adapters.rs +184 -125
  6. package/crates/team-agent/src/cli/diagnose.rs +333 -68
  7. package/crates/team-agent/src/cli/emit.rs +59 -15
  8. package/crates/team-agent/src/cli/mod.rs +600 -134
  9. package/crates/team-agent/src/cli/models.rs +485 -44
  10. package/crates/team-agent/src/cli/named_address.rs +19 -5
  11. package/crates/team-agent/src/cli/profile.rs +35 -4
  12. package/crates/team-agent/src/cli/spec.rs +7 -2
  13. package/crates/team-agent/src/cli/status_port/brief.rs +1137 -0
  14. package/crates/team-agent/src/cli/status_port/compact.rs +12 -8
  15. package/crates/team-agent/src/cli/status_port/snapshot.rs +15 -12
  16. package/crates/team-agent/src/cli/status_port/store.rs +1 -1
  17. package/crates/team-agent/src/cli/status_port.rs +8 -0
  18. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
  19. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  20. package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
  21. package/crates/team-agent/src/cli/tests/run_delegation.rs +29 -24
  22. package/crates/team-agent/src/cli/tests/status_brief.rs +691 -0
  23. package/crates/team-agent/src/cli/tests/status_send.rs +8 -2
  24. package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
  25. package/crates/team-agent/src/cli/types.rs +1 -0
  26. package/crates/team-agent/src/leader/lease.rs +538 -41
  27. package/crates/team-agent/src/leader/owner_bind.rs +40 -12
  28. package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
  29. package/crates/team-agent/src/leader/rediscover.rs +36 -6
  30. package/crates/team-agent/src/leader/start.rs +151 -14
  31. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
  32. package/crates/team-agent/src/leader/types.rs +4 -0
  33. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
  34. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
  35. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
  36. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
  37. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +138 -14
  38. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
  39. package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
  40. package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
  41. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
  42. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
  43. package/crates/team-agent/src/lifecycle/launch.rs +30 -7
  44. package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
  45. package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
  46. package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
  47. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +1002 -65
  48. package/crates/team-agent/src/lifecycle/restart.rs +2 -2
  49. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
  50. package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +45 -14
  51. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
  52. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
  53. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
  54. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -3
  55. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
  56. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
  57. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
  58. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
  59. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
  60. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
  61. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
  62. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
  63. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
  64. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
  65. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
  66. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +4 -3
  67. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
  68. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
  69. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
  70. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +69 -28
  71. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
  72. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
  73. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
  74. package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +22 -10
  75. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
  76. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
  77. package/crates/team-agent/src/lifecycle/types.rs +12 -0
  78. package/crates/team-agent/src/mcp_server/wire.rs +140 -11
  79. package/crates/team-agent/src/messaging/delivery.rs +67 -8
  80. package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
  81. package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
  82. package/crates/team-agent/src/messaging/watchers.rs +48 -4
  83. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
  84. package/crates/team-agent/src/state/paths.rs +17 -2
  85. package/crates/team-agent/src/state/persist.rs +97 -1
  86. package/crates/team-agent/src/state/projection.rs +18 -2
  87. package/crates/team-agent/src/state/repository.rs +29 -0
  88. package/crates/team-agent/src/state/selector.rs +23 -3
  89. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +144 -0
  90. package/crates/team-agent/src/tmux_backend/tests.rs +121 -4
  91. package/crates/team-agent/src/tmux_backend.rs +208 -118
  92. package/crates/team-agent/src/transport.rs +72 -0
  93. package/crates/team-agent/src/transport_factory.rs +53 -0
  94. package/package.json +4 -5
  95. package/skills/team-agent/SKILL.md +45 -17
  96. package/skills/team-agent/command-coverage.json +4 -29
  97. package/npm/check-skill-links.mjs +0 -88
  98. package/skills/team-agent/references/team-agent-operator.md +0 -672
@@ -377,6 +377,7 @@ fn save_runtime_state_with_merge_options(
377
377
  exact_owner_seed_to_clear,
378
378
  None,
379
379
  None,
380
+ None,
380
381
  )
381
382
  }
382
383
 
@@ -399,6 +400,7 @@ pub(crate) fn save_runtime_state_with_receiver_authority_and_expected(
399
400
  None,
400
401
  Some((expected_owner, expected_receiver)),
401
402
  None,
403
+ None,
402
404
  )
403
405
  }
404
406
 
@@ -421,6 +423,38 @@ pub(crate) fn save_runtime_state_with_exact_owner_receiver_cleanup(
421
423
  None,
422
424
  None,
423
425
  Some((team_key, expected_owner, expected_receiver)),
426
+ None,
427
+ )
428
+ }
429
+
430
+ pub(crate) fn save_runtime_state_with_exact_owner_receiver_restore(
431
+ workspace: &Path,
432
+ state: &Value,
433
+ team_key: &str,
434
+ expected_owner: &Value,
435
+ expected_receiver: &Value,
436
+ previous_owner: &Value,
437
+ previous_receiver: &Value,
438
+ ) -> Result<(), StateError> {
439
+ persist_runtime_state_with_merge_options_and_expected(
440
+ workspace,
441
+ state,
442
+ &[],
443
+ None,
444
+ &[],
445
+ None,
446
+ &[],
447
+ Some(team_key),
448
+ None,
449
+ None,
450
+ None,
451
+ Some((
452
+ team_key,
453
+ expected_owner,
454
+ expected_receiver,
455
+ previous_owner,
456
+ previous_receiver,
457
+ )),
424
458
  )
425
459
  }
426
460
 
@@ -436,6 +470,7 @@ fn persist_runtime_state_with_merge_options_and_expected(
436
470
  exact_owner_seed_to_clear: Option<&Value>,
437
471
  expected_owner_receiver: Option<(&Value, &Value)>,
438
472
  exact_owner_receiver_to_clear: Option<(&str, &Value, &Value)>,
473
+ exact_owner_receiver_to_restore: Option<(&str, &Value, &Value, &Value, &Value)>,
439
474
  ) -> Result<(), StateError> {
440
475
  let path = runtime_state_path(workspace);
441
476
  // Python `state.py:497`:先对入参 state 跑 `_migrate_state_identity`(就地填缺失 leader uuid)。
@@ -459,7 +494,8 @@ fn persist_runtime_state_with_merge_options_and_expected(
459
494
  // and leave the seed, or skip copying a concurrent owner.
460
495
  let skip_pre_lock_fast_path = exact_owner_seed_to_clear.is_some()
461
496
  || expected_owner_receiver.is_some()
462
- || exact_owner_receiver_to_clear.is_some();
497
+ || exact_owner_receiver_to_clear.is_some()
498
+ || exact_owner_receiver_to_restore.is_some();
463
499
  if !skip_pre_lock_fast_path && cache_equals(&path, &migrated) {
464
500
  return Ok(());
465
501
  }
@@ -544,6 +580,7 @@ fn persist_runtime_state_with_merge_options_and_expected(
544
580
  &receiver_updates,
545
581
  receiver_update_team_key.zip(exact_owner_seed_to_clear),
546
582
  exact_owner_receiver_to_clear,
583
+ exact_owner_receiver_to_restore,
547
584
  )?;
548
585
  }
549
586
  // Stage 3 save-output strip second pass (defence-in-depth): after the
@@ -613,6 +650,7 @@ fn apply_persist_merge_contract(
613
650
  receiver_updates: &BTreeSet<String>,
614
651
  exact_owner_seed_to_clear: Option<(&str, &Value)>,
615
652
  exact_owner_receiver_to_clear: Option<(&str, &Value, &Value)>,
653
+ exact_owner_receiver_to_restore: Option<(&str, &Value, &Value, &Value, &Value)>,
616
654
  ) -> Result<(), StateError> {
617
655
  // A0/R1: the projection gate only guards the TOP-LEVEL passes (top-level agents and
618
656
  // the top-level<->active-team cross projections depend on which team is active); the
@@ -745,6 +783,25 @@ fn apply_persist_merge_contract(
745
783
  );
746
784
  }
747
785
  }
786
+ if let Some((
787
+ restore_team,
788
+ expected_owner,
789
+ expected_receiver,
790
+ previous_owner,
791
+ previous_receiver,
792
+ )) = exact_owner_receiver_to_restore
793
+ {
794
+ if team == restore_team {
795
+ apply_lock_held_exact_owner_receiver_restore(
796
+ incoming_entry,
797
+ latest_entry,
798
+ expected_owner,
799
+ expected_receiver,
800
+ previous_owner,
801
+ previous_receiver,
802
+ );
803
+ }
804
+ }
748
805
  preserve_latest_endpoint_convergence_fields(incoming_entry, latest_entry);
749
806
  }
750
807
  }
@@ -919,6 +976,43 @@ fn apply_lock_held_exact_owner_receiver_cleanup(
919
976
  }
920
977
  }
921
978
 
979
+ fn apply_lock_held_exact_owner_receiver_restore(
980
+ incoming: &mut Value,
981
+ latest: &Value,
982
+ expected_owner: &Value,
983
+ expected_receiver: &Value,
984
+ previous_owner: &Value,
985
+ previous_receiver: &Value,
986
+ ) {
987
+ let latest_owner = latest.get("team_owner");
988
+ let latest_receiver = latest.get("leader_receiver");
989
+ let Some(incoming_obj) = incoming.as_object_mut() else {
990
+ return;
991
+ };
992
+ if latest_owner == Some(expected_owner) && latest_receiver == Some(expected_receiver) {
993
+ incoming_obj.insert("team_owner".to_string(), previous_owner.clone());
994
+ incoming_obj.insert("leader_receiver".to_string(), previous_receiver.clone());
995
+ if let Some(epoch) = previous_owner
996
+ .get("owner_epoch")
997
+ .cloned()
998
+ .or_else(|| latest.get("owner_epoch").cloned())
999
+ {
1000
+ incoming_obj.insert("owner_epoch".to_string(), epoch);
1001
+ }
1002
+ return;
1003
+ }
1004
+ for key in ["team_owner", "leader_receiver", "owner_epoch"] {
1005
+ match latest.get(key) {
1006
+ Some(value) => {
1007
+ incoming_obj.insert(key.to_string(), value.clone());
1008
+ }
1009
+ None => {
1010
+ incoming_obj.remove(key);
1011
+ }
1012
+ }
1013
+ }
1014
+ }
1015
+
922
1016
  fn preserve_latest_endpoint_convergence_fields(incoming: &mut Value, latest: &Value) {
923
1017
  if !latest_has_preferable_endpoint_convergence(incoming, latest) {
924
1018
  return;
@@ -3113,6 +3207,7 @@ mod tests {
3113
3207
  &BTreeSet::new(),
3114
3208
  None,
3115
3209
  None,
3210
+ None,
3116
3211
  )
3117
3212
  .unwrap();
3118
3213
 
@@ -3149,6 +3244,7 @@ mod tests {
3149
3244
  &receiver_updates,
3150
3245
  None,
3151
3246
  None,
3247
+ None,
3152
3248
  )
3153
3249
  .unwrap();
3154
3250
 
@@ -16,7 +16,8 @@ use serde_json::{json, Map, Value};
16
16
 
17
17
  use super::StateError;
18
18
  use crate::state::persist::{
19
- load_runtime_state, save_runtime_state_with_deleted_agents,
19
+ load_runtime_state, load_runtime_state_without_migrations,
20
+ save_runtime_state_with_deleted_agents,
20
21
  save_runtime_state_with_lifecycle_topology_authority,
21
22
  save_runtime_state_with_lifecycle_topology_authority_and_capture_backfill_skip,
22
23
  save_runtime_state_with_team_tombstone_lifecycle_topology_authority,
@@ -504,7 +505,22 @@ pub fn resolve_runtime_team_scope(
504
505
  workspace: &Path,
505
506
  team: Option<&str>,
506
507
  ) -> Result<TeamScopeResolution, StateError> {
507
- let state = load_runtime_state(workspace)?;
508
+ resolve_runtime_team_scope_loaded(load_runtime_state(workspace)?, team)
509
+ }
510
+
511
+ /// Same team selection as [`resolve_runtime_team_scope`], but never migrates or
512
+ /// writes runtime state. Status and other read-only CLI surfaces must use this.
513
+ pub fn resolve_runtime_team_scope_readonly(
514
+ workspace: &Path,
515
+ team: Option<&str>,
516
+ ) -> Result<TeamScopeResolution, StateError> {
517
+ resolve_runtime_team_scope_loaded(load_runtime_state_without_migrations(workspace)?, team)
518
+ }
519
+
520
+ fn resolve_runtime_team_scope_loaded(
521
+ state: Value,
522
+ team: Option<&str>,
523
+ ) -> Result<TeamScopeResolution, StateError> {
508
524
  let alive = team_state_candidates(&state);
509
525
  // Python `if team:` —— 空串 falsy,等同无 team(对抗 P1:此前 Some("") 误入 team 分支,空
510
526
  // team_dir 匹配致歧义/未找到错误串漂移)。
@@ -52,6 +52,8 @@ use super::persist::{
52
52
  as helper_write_root_with_receiver_authority_and_expected,
53
53
  save_runtime_state_with_exact_owner_receiver_cleanup
54
54
  as helper_write_root_with_exact_owner_receiver_cleanup,
55
+ save_runtime_state_with_exact_owner_receiver_restore
56
+ as helper_write_root_with_exact_owner_receiver_restore,
55
57
  save_runtime_state_with_team_tombstone_lifecycle_topology_authority as helper_write_root_with_team_tombstone_lifecycle_topology_authority,
56
58
  save_runtime_state_with_team_tombstoned_agents as helper_write_root_with_team_tombstoned_agents,
57
59
  save_runtime_state_without_migrations as helper_write_root_without_migrations,
@@ -224,9 +226,20 @@ pub enum StateWriteIntent<'a> {
224
226
  seed: &'a Value,
225
227
  expected_receiver: &'a Value,
226
228
  },
229
+ RestoreExactTeamOwnerAndReceiver {
230
+ team_key: &'a str,
231
+ expected_owner: &'a Value,
232
+ expected_receiver: &'a Value,
233
+ previous_owner: &'a Value,
234
+ previous_receiver: &'a Value,
235
+ },
227
236
  LeaderBindingRestoreNonTargetTeams {
228
237
  target_team_key: &'a str,
229
238
  },
239
+ /// Strip extra keys written by a failed or degraded quick-start bind attempt.
240
+ QuickStartBindingCleanup {
241
+ team_key: &'a str,
242
+ },
230
243
  LeaderStartBinding {
231
244
  team_key: &'a str,
232
245
  transport_kind: &'a str,
@@ -428,6 +441,22 @@ fn route_direct(
428
441
  seed,
429
442
  expected_receiver,
430
443
  ),
444
+ StateWriteIntent::RestoreExactTeamOwnerAndReceiver {
445
+ team_key,
446
+ expected_owner,
447
+ expected_receiver,
448
+ previous_owner,
449
+ previous_receiver,
450
+ } => helper_write_root_with_exact_owner_receiver_restore(
451
+ workspace,
452
+ state,
453
+ team_key,
454
+ expected_owner,
455
+ expected_receiver,
456
+ previous_owner,
457
+ previous_receiver,
458
+ ),
459
+ StateWriteIntent::QuickStartBindingCleanup { .. } => helper_write_root(workspace, state),
431
460
  StateWriteIntent::LeaderBindingRestoreNonTargetTeams { .. } => {
432
461
  helper_write_root_without_migrations(workspace, state)
433
462
  }
@@ -7,7 +7,9 @@ use serde_json::Value;
7
7
 
8
8
  use crate::model::paths::{canonical_run_workspace, runtime_spec_path, team_workspace};
9
9
  use crate::state::persist::runtime_state_path;
10
- use crate::state::projection::resolve_runtime_team_scope;
10
+ use crate::state::projection::{
11
+ resolve_runtime_team_scope, resolve_runtime_team_scope_readonly, TeamScopeResolution,
12
+ };
11
13
  use crate::state::StateError;
12
14
 
13
15
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -35,6 +37,24 @@ pub fn resolve_active_team(
35
37
  input: &Path,
36
38
  team: Option<&str>,
37
39
  mode: SelectorMode,
40
+ ) -> Result<SelectedTeam, StateError> {
41
+ resolve_active_team_with(input, team, mode, resolve_runtime_team_scope)
42
+ }
43
+
44
+ /// Status-path selector: in-memory projection only, no runtime-state writeback.
45
+ pub fn resolve_active_team_readonly(
46
+ input: &Path,
47
+ team: Option<&str>,
48
+ mode: SelectorMode,
49
+ ) -> Result<SelectedTeam, StateError> {
50
+ resolve_active_team_with(input, team, mode, resolve_runtime_team_scope_readonly)
51
+ }
52
+
53
+ fn resolve_active_team_with(
54
+ input: &Path,
55
+ team: Option<&str>,
56
+ mode: SelectorMode,
57
+ resolve_scope: fn(&Path, Option<&str>) -> Result<TeamScopeResolution, StateError>,
38
58
  ) -> Result<SelectedTeam, StateError> {
39
59
  let explicit_spec = input.join("team.spec.yaml");
40
60
  let (run_workspace, resolved) = if explicit_spec.exists() {
@@ -44,7 +64,7 @@ pub fn resolve_active_team(
44
64
  } else {
45
65
  team_run
46
66
  };
47
- let resolved = resolve_runtime_team_scope(&run, team)?;
67
+ let resolved = resolve_scope(&run, team)?;
48
68
  (run, resolved)
49
69
  } else {
50
70
  let run =
@@ -59,7 +79,7 @@ pub fn resolve_active_team(
59
79
  input.display()
60
80
  )));
61
81
  }
62
- let resolved = resolve_runtime_team_scope(&run, team)?;
82
+ let resolved = resolve_scope(&run, team)?;
63
83
  (run, resolved)
64
84
  };
65
85
  let state = resolved.state;
@@ -0,0 +1,144 @@
1
+ use std::fs::OpenOptions;
2
+ use std::io::Write;
3
+ use std::path::{Path, PathBuf};
4
+ use std::process::{Command, Stdio};
5
+ use std::time::{Duration, Instant};
6
+
7
+ const CHILD_FLAG: &str = "--team-agent-models-timeout-child";
8
+ const LIST_MODELS_FLAG: &str = "--list-models";
9
+ const RECEIPT_ENV: &str = "TEAM_AGENT_MODELS_TIMEOUT_RECEIPT";
10
+ const STAGE_RECEIPT_ENV: &str = "TEAM_AGENT_MODELS_TIMEOUT_STAGE_RECEIPT";
11
+ const MODE_ENV: &str = "TEAM_AGENT_MODELS_TIMEOUT_MODE";
12
+ const STDOUT_MARKER: &[u8] = b"__team_agent_models_timeout_descendant_stdout_v1__\n";
13
+ const PARENT_HANDOFF_TIMEOUT: Duration = Duration::from_secs(1);
14
+
15
+ // Stage values are UNIX epoch milliseconds so the test process and helper use
16
+ // one directly comparable clock. Stage writes are best-effort diagnostics and
17
+ // never change the fixture's production-facing exit semantics.
18
+ fn append_stage(key: &str) {
19
+ let Some(path) = std::env::var_os(STAGE_RECEIPT_ENV) else {
20
+ return;
21
+ };
22
+ let Some(milliseconds) = std::time::SystemTime::now()
23
+ .duration_since(std::time::UNIX_EPOCH)
24
+ .ok()
25
+ .map(|duration| duration.as_millis())
26
+ else {
27
+ return;
28
+ };
29
+ let _ = append_receipt(Path::new(&path), key, milliseconds);
30
+ }
31
+
32
+ fn receipt_path() -> PathBuf {
33
+ std::env::var_os(RECEIPT_ENV)
34
+ .map(PathBuf::from)
35
+ .unwrap_or_else(|| std::process::exit(4))
36
+ }
37
+
38
+ fn append_receipt(path: &Path, key: &str, value: impl std::fmt::Display) -> Result<(), ()> {
39
+ let mut receipt = OpenOptions::new()
40
+ .create(true)
41
+ .append(true)
42
+ .open(path)
43
+ .map_err(|_| ())?;
44
+ writeln!(receipt, "{key}={value}").map_err(|_| ())
45
+ }
46
+
47
+ fn wait_for_parent_handoff(receipt: &Path) {
48
+ let expected = format!("child_pid={}", std::process::id());
49
+ let deadline = Instant::now() + PARENT_HANDOFF_TIMEOUT;
50
+ loop {
51
+ if std::fs::read_to_string(receipt)
52
+ .ok()
53
+ .is_some_and(|text| text.lines().any(|line| line == expected))
54
+ {
55
+ return;
56
+ }
57
+ if Instant::now() >= deadline {
58
+ std::process::exit(5);
59
+ }
60
+ std::thread::sleep(Duration::from_millis(1));
61
+ }
62
+ }
63
+
64
+ fn child_main(args: &[String]) {
65
+ if args.len() != 1 || args[0] != CHILD_FLAG {
66
+ std::process::exit(5);
67
+ }
68
+ let receipt = receipt_path();
69
+ wait_for_parent_handoff(&receipt);
70
+
71
+ let mut stdout = std::io::stdout();
72
+ if stdout.write_all(STDOUT_MARKER).is_err() || stdout.flush().is_err() {
73
+ std::process::exit(5);
74
+ }
75
+ if append_receipt(&receipt, "child_argv_exact", 1).is_err()
76
+ || append_receipt(&receipt, "descendant_pid", std::process::id()).is_err()
77
+ || append_receipt(&receipt, "descendant_started", 1).is_err()
78
+ || append_receipt(&receipt, "descendant_stdout_inherited", 1).is_err()
79
+ {
80
+ std::process::exit(5);
81
+ }
82
+
83
+ std::thread::sleep(Duration::from_secs(2));
84
+ }
85
+
86
+ fn main() {
87
+ let args: Vec<String> = std::env::args().skip(1).collect();
88
+ if args.first().is_some_and(|arg| arg == CHILD_FLAG) {
89
+ child_main(&args);
90
+ return;
91
+ }
92
+ if args.len() != 1 || args[0] != LIST_MODELS_FLAG {
93
+ std::process::exit(2);
94
+ }
95
+
96
+ match std::env::var(MODE_ENV) {
97
+ Ok(mode) if mode == "exit7" => {
98
+ eprintln!("sensitive-token");
99
+ std::process::exit(7);
100
+ }
101
+ Ok(mode) if mode == "oversize" => {
102
+ let mut stdout = std::io::stdout();
103
+ let bytes = [b'x'; 64];
104
+ let _ = stdout.write_all(&bytes);
105
+ let _ = stdout.flush();
106
+ return;
107
+ }
108
+ Ok(mode) if mode == "sleep" => {
109
+ std::thread::sleep(Duration::from_secs(2));
110
+ return;
111
+ }
112
+ Ok(mode) if mode == "descendant" => {}
113
+ Err(std::env::VarError::NotPresent) => {}
114
+ Ok(_) | Err(std::env::VarError::NotUnicode(_)) => std::process::exit(2),
115
+ }
116
+
117
+ append_stage("parent_entry_ms");
118
+ let receipt = receipt_path();
119
+ if append_receipt(&receipt, "parent_started_once", 1).is_err()
120
+ || append_receipt(&receipt, "parent_argv_exact", 1).is_err()
121
+ || append_receipt(&receipt, "parent_pid", std::process::id()).is_err()
122
+ || append_receipt(&receipt, "child_spawn_attempts", 1).is_err()
123
+ {
124
+ std::process::exit(4);
125
+ }
126
+
127
+ let executable = std::env::current_exe().unwrap_or_else(|_| std::process::exit(4));
128
+ append_stage("parent_before_child_spawn_ms");
129
+ let child = Command::new(executable)
130
+ .arg(CHILD_FLAG)
131
+ .env(RECEIPT_ENV, &receipt)
132
+ .stdin(Stdio::null())
133
+ .stdout(Stdio::inherit())
134
+ .stderr(Stdio::null())
135
+ .spawn()
136
+ .unwrap_or_else(|_| std::process::exit(4));
137
+ append_stage("parent_after_child_spawn_ms");
138
+ if append_receipt(&receipt, "child_pid", child.id()).is_err() {
139
+ std::process::exit(4);
140
+ }
141
+ append_stage("parent_before_exit_ms");
142
+ // The parent exits successfully without waiting; the child retains the
143
+ // inherited stdout pipe and self-exits after the real reader deadline.
144
+ }
@@ -28,10 +28,10 @@ use crate::layout::worker_env::{
28
28
  use crate::model::enums::{PaneLiveness, Provider};
29
29
  use crate::transport::{
30
30
  command_basename, normalize_capture, tmux_capture_argv, tmux_query_argv, tmux_send_keys_argv,
31
- tmux_spawn_argv, tmux_submit_key_name, AttachOutcome, CaptureRange, CaptureSampleOutcome,
32
- InjectPayload, InjectStage, InjectVerification, InputSurfaceProbe, Key, PaneField, PaneId,
33
- QueryOutcome, SessionName, SetEnvOutcome, SubmitVerification, Target, Transport,
34
- TransportError, TurnVerification, WindowName,
31
+ tmux_spawn_argv, tmux_submit_key_name, AttachOutcome, BusySignalKind, CaptureRange,
32
+ CaptureSampleOutcome, InjectPayload, InjectStage, InjectVerification, InputSurfaceProbe, Key,
33
+ PaneField, PaneId, QueryOutcome, SessionName, SetEnvOutcome, SubmitConsumptionReason,
34
+ SubmitVerification, Target, Transport, TransportError, TurnVerification, WindowName,
35
35
  };
36
36
 
37
37
  type RecordedArgv = Arc<Mutex<Vec<Vec<String>>>>;
@@ -1051,6 +1051,15 @@ fn inject_text_runs_buffer_paste_submit_sequence_and_reports_submit() {
1051
1051
  SubmitVerification::EnterSentWithoutPlaceholderCheck
1052
1052
  );
1053
1053
  assert_eq!(report.turn_verification, TurnVerification::NotYetObserved);
1054
+ let diagnostics = report.submit_diagnostics.expect("diagnostics");
1055
+ assert_eq!(
1056
+ diagnostics.consumption_reason,
1057
+ SubmitConsumptionReason::NoMarker
1058
+ );
1059
+ assert_eq!(diagnostics.busy_signal_kind, None);
1060
+ assert_eq!(diagnostics.current_marker_in_bottom_15, None);
1061
+ assert_eq!(diagnostics.paste_identity_in_composer, None);
1062
+ assert_eq!(diagnostics.consumption_from_capture_result, None);
1054
1063
  }
1055
1064
 
1056
1065
  #[test]
@@ -1409,10 +1418,18 @@ fn e46_post_submit_matched_token_without_scroll_is_unverified() {
1409
1418
  "token still in composer and no busy must report 没开跑, not unknown/started"
1410
1419
  );
1411
1420
  let diagnostics = report.submit_diagnostics.expect("diagnostics");
1421
+ assert_eq!(diagnostics.busy_signal_kind, None);
1422
+ assert_eq!(diagnostics.current_marker_in_bottom_15, Some(true));
1423
+ assert_eq!(diagnostics.paste_identity_in_composer, None);
1424
+ assert_eq!(diagnostics.consumption_from_capture_result, Some(false));
1412
1425
  assert!(
1413
1426
  diagnostics.attempts_detail.iter().any(|obs| obs.matched),
1414
1427
  "the unverified verdict must still record that paste landed"
1415
1428
  );
1429
+ assert_eq!(
1430
+ diagnostics.consumption_reason,
1431
+ SubmitConsumptionReason::Unverified
1432
+ );
1416
1433
  }
1417
1434
 
1418
1435
  #[test]
@@ -1452,6 +1469,56 @@ fn e46_unconsumed_token_with_live_busy_state_is_treated_as_processing() {
1452
1469
  "busy-state capture should be recorded in attempts_detail: {:?}",
1453
1470
  diagnostics.attempts_detail
1454
1471
  );
1472
+ assert_eq!(
1473
+ diagnostics.consumption_reason,
1474
+ SubmitConsumptionReason::FallbackBusy
1475
+ );
1476
+ assert_eq!(diagnostics.before_busy_signal_kind, None);
1477
+ assert_eq!(diagnostics.busy_signal_kind, Some(BusySignalKind::Working));
1478
+ assert_eq!(diagnostics.busy_line_from_bottom, Some(0));
1479
+ assert_eq!(diagnostics.current_marker_in_bottom_15, Some(true));
1480
+ assert_eq!(diagnostics.paste_identity_in_composer, None);
1481
+ assert_eq!(diagnostics.consumption_from_capture_result, Some(false));
1482
+ }
1483
+
1484
+ #[test]
1485
+ fn e46_capture_error_clears_previous_consumption_observation() {
1486
+ let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_capture_error]";
1487
+ let busy_tail = format!("{token_text}\n● Working (1s · esc to interrupt)\n");
1488
+ let visible = ok(token_text);
1489
+ let queued = vec![
1490
+ MockResp::Out(ok("")), // set-buffer
1491
+ MockResp::Out(ok("")), // paste-buffer
1492
+ MockResp::Out(ok("")), // delete-buffer
1493
+ MockResp::Out(visible.clone()), // pasted-content prompt: continue
1494
+ MockResp::Out(visible), // pre-submit token visibility
1495
+ MockResp::Out(ok("")), // Escape
1496
+ MockResp::Out(ok("")), // Enter
1497
+ MockResp::Out(ok(&busy_tail)), // first post-Enter capture: busy
1498
+ // Default capture error forces the next poll/fallback/resend reads to fail.
1499
+ ];
1500
+ let (be, _rec) = backend_with(MockResp::Io(std::io::ErrorKind::Other), queued);
1501
+ let report = be
1502
+ .inject(
1503
+ &Target::Pane(PaneId::new("%7")),
1504
+ &InjectPayload::Text(token_text.to_string()),
1505
+ Key::Enter,
1506
+ true,
1507
+ )
1508
+ .expect("inject runs through capture failure");
1509
+ let diagnostics = report.submit_diagnostics.expect("diagnostics");
1510
+ assert_eq!(
1511
+ report.submit_verification,
1512
+ SubmitVerification::SubmitConsumptionUnverified
1513
+ );
1514
+ assert_eq!(diagnostics.consumption_reason, SubmitConsumptionReason::Unverified);
1515
+ assert!(diagnostics.capture_err_count > 0);
1516
+ assert_eq!(diagnostics.before_busy_signal_kind, None);
1517
+ assert_eq!(diagnostics.busy_signal_kind, None);
1518
+ assert_eq!(diagnostics.busy_line_from_bottom, None);
1519
+ assert_eq!(diagnostics.current_marker_in_bottom_15, None);
1520
+ assert_eq!(diagnostics.paste_identity_in_composer, None);
1521
+ assert_eq!(diagnostics.consumption_from_capture_result, None);
1455
1522
  }
1456
1523
 
1457
1524
  /// NeverSeen + 无占位符:空 pane 不得再把「token 不在底部」写成 consumed。
@@ -1521,6 +1588,14 @@ fn e46_inject_text_with_token_consumed_after_enter_keeps_enter_sent_without_plac
1521
1588
  hold (provider_submit_verification_red.rs:113-159). Got {:?}",
1522
1589
  report.submit_verification
1523
1590
  );
1591
+ let diagnostics = report.submit_diagnostics.expect("diagnostics");
1592
+ assert_eq!(
1593
+ diagnostics.consumption_reason,
1594
+ SubmitConsumptionReason::ConsumptionFromCapture
1595
+ );
1596
+ assert_eq!(diagnostics.busy_signal_kind, None);
1597
+ assert_eq!(diagnostics.current_marker_in_bottom_15, Some(false));
1598
+ assert_eq!(diagnostics.consumption_from_capture_result, Some(true));
1524
1599
  }
1525
1600
 
1526
1601
  /// **E46 RED-3 (resend-to-cap, no double-submit)**: when the FIRST Enter
@@ -3289,6 +3364,15 @@ fn cursor_single_enter_busy_transcript_placeholder_does_not_retry() {
3289
3364
  "busy after first Enter is consumption for cursor; got {:?}",
3290
3365
  report.submit_verification
3291
3366
  );
3367
+ let diagnostics = report.submit_diagnostics.expect("diagnostics");
3368
+ assert_eq!(
3369
+ diagnostics.consumption_reason,
3370
+ SubmitConsumptionReason::CursorPollingBusy
3371
+ );
3372
+ assert_eq!(diagnostics.busy_signal_kind, Some(BusySignalKind::Working));
3373
+ assert!(diagnostics.busy_line_from_bottom.is_some());
3374
+ assert!(diagnostics.current_marker_in_bottom_15.is_some());
3375
+ assert!(diagnostics.consumption_from_capture_result.is_some());
3292
3376
  let calls = rec.lock().unwrap().clone();
3293
3377
  assert_eq!(
3294
3378
  count_submit_enters(&calls),
@@ -3570,6 +3654,39 @@ fn grok_fold_worker_text_payload_polls_consumption() {
3570
3654
  );
3571
3655
  }
3572
3656
 
3657
+ #[test]
3658
+ fn provider_busy_signal_match_preserves_category_priority_and_tail_boundary() {
3659
+ let cases = [
3660
+ ("working", BusySignalKind::Working),
3661
+ ("thinking", BusySignalKind::Thinking),
3662
+ ("processing", BusySignalKind::Processing),
3663
+ ("esc to interrupt", BusySignalKind::EscToInterrupt),
3664
+ ("⠋", BusySignalKind::SpinnerGlyph),
3665
+ ("working ⠋", BusySignalKind::Working),
3666
+ ];
3667
+ for (text, expected) in cases {
3668
+ let matched = super::provider_busy_signal_match(text).expect("busy match");
3669
+ assert_eq!(matched.kind, expected, "text={text:?}");
3670
+ assert_eq!(matched.line_from_bottom, 0, "text={text:?}");
3671
+ assert!(super::provider_busy_signal_in_tail(text));
3672
+ }
3673
+
3674
+ let inside_bottom_15 = std::iter::once("working".to_string())
3675
+ .chain((0..14).map(|index| format!("tail-{index}")))
3676
+ .collect::<Vec<_>>()
3677
+ .join("\n");
3678
+ let matched = super::provider_busy_signal_match(&inside_bottom_15).expect("15th line");
3679
+ assert_eq!(matched.kind, BusySignalKind::Working);
3680
+ assert_eq!(matched.line_from_bottom, 14);
3681
+
3682
+ let outside_bottom_15 = std::iter::once("working".to_string())
3683
+ .chain((0..15).map(|index| format!("tail-{index}")))
3684
+ .collect::<Vec<_>>()
3685
+ .join("\n");
3686
+ assert!(super::provider_busy_signal_match(&outside_bottom_15).is_none());
3687
+ assert!(!super::provider_busy_signal_in_tail(&outside_bottom_15));
3688
+ }
3689
+
3573
3690
  const G4_TOKEN: &str = "[team-agent-token:msg_g4]";
3574
3691
 
3575
3692
  #[test]