@team-agent/installer 0.3.21 → 0.3.22

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 (29) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +9 -1
  4. package/crates/team-agent/src/cli/mod.rs +158 -2
  5. package/crates/team-agent/src/cli/status.rs +61 -0
  6. package/crates/team-agent/src/cli/status_port.rs +2 -2
  7. package/crates/team-agent/src/cli/tests/base.rs +26 -0
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +233 -9
  9. package/crates/team-agent/src/coordinator/tests/energy.rs +548 -0
  10. package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/coordinator/tick.rs +452 -17
  12. package/crates/team-agent/src/lifecycle/display.rs +23 -302
  13. package/crates/team-agent/src/lifecycle/launch.rs +38 -0
  14. package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -6
  15. package/crates/team-agent/src/lifecycle/restart/common.rs +76 -0
  16. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +295 -13
  17. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +30 -0
  18. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +89 -5
  19. package/crates/team-agent/src/messaging/delivery.rs +324 -95
  20. package/crates/team-agent/src/messaging/scheduler.rs +95 -73
  21. package/crates/team-agent/src/messaging/send.rs +10 -0
  22. package/crates/team-agent/src/messaging/tests/runtime.rs +460 -0
  23. package/crates/team-agent/src/messaging/tests/spine.rs +51 -2
  24. package/crates/team-agent/src/session_capture.rs +261 -1
  25. package/crates/team-agent/src/tmux_backend/tests.rs +97 -1
  26. package/crates/team-agent/src/tmux_backend.rs +138 -2
  27. package/crates/team-agent/src/transport/test_support.rs +25 -0
  28. package/crates/team-agent/src/transport.rs +11 -0
  29. package/package.json +4 -4
package/Cargo.lock CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.3.21"
569
+ version = "0.3.22"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.3.21"
12
+ version = "0.3.22"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -218,7 +218,7 @@ fn command_help(command: Option<&str>) -> String {
218
218
  Some("fallback-send-leader") => "usage: team-agent fallback-send-leader --workspace WORKSPACE --team TEAM --sender AGENT --message-id ID --content TEXT --primary-error ERROR [--task TASK] [--json]\n\nEmergency one-shot fallback only after a leader-bound MCP send transport failure; restart-agent after use.".to_string(),
219
219
  Some("fallback-report-result") => "usage: team-agent fallback-report-result --workspace WORKSPACE --team TEAM --agent-id AGENT --task-id TASK --result-json JSON --primary-error ERROR [--json]\n\nEmergency one-shot fallback only after a report_result MCP transport failure; persists the result DB row, then restart-agent after use.".to_string(),
220
220
  Some("allow-peer-talk") => "usage: team-agent allow-peer-talk A B [--workspace WORKSPACE] [--json]".to_string(),
221
- Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]".to_string(),
221
+ Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]\n\n默认输出: worker,空闲|工作|错误;错误细分走 status --summary".to_string(),
222
222
  Some("stop") => "usage: team-agent stop [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]".to_string(),
223
223
  Some("shutdown") => "usage: team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]".to_string(),
224
224
  Some("restart") => "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json]".to_string(),
@@ -1663,6 +1663,14 @@ mod tests {
1663
1663
  }
1664
1664
  }
1665
1665
 
1666
+ #[test]
1667
+ fn status_help_mentions_summary_for_error_details() {
1668
+ assert!(
1669
+ command_help(Some("status")).contains("错误细分走 status --summary"),
1670
+ "status help must tell users where detailed error classes live"
1671
+ );
1672
+ }
1673
+
1666
1674
  #[test]
1667
1675
  fn ux_quick_start_workspace_resolves_relative_agents_dir_inside_workspace() {
1668
1676
  let cwd = tmp_workspace();
@@ -269,6 +269,21 @@ pub mod lifecycle_port {
269
269
  error: Option<String>,
270
270
  }
271
271
 
272
+ #[derive(Debug)]
273
+ struct OwnedShutdownEndpoint {
274
+ endpoint: String,
275
+ socket_file: Option<PathBuf>,
276
+ }
277
+
278
+ #[derive(Debug, Default)]
279
+ struct OwnedEndpointCleanup {
280
+ residual_files: Vec<String>,
281
+ removed_files: Vec<String>,
282
+ skipped_files: Vec<String>,
283
+ remaining_sessions: Vec<String>,
284
+ error: Option<String>,
285
+ }
286
+
272
287
  fn push_unique_session(
273
288
  sessions: &mut Vec<crate::transport::SessionName>,
274
289
  session: crate::transport::SessionName,
@@ -419,6 +434,119 @@ pub mod lifecycle_port {
419
434
  }
420
435
  }
421
436
 
437
+ fn owned_shutdown_endpoint(
438
+ workspace: &Path,
439
+ state: &Value,
440
+ transport: &dyn crate::transport::Transport,
441
+ ) -> Option<OwnedShutdownEndpoint> {
442
+ if state_uses_external_leader(state) {
443
+ return None;
444
+ }
445
+ if state.get("tmux_socket_source").and_then(Value::as_str) == Some("leader_env") {
446
+ return None;
447
+ }
448
+ let endpoint = legacy_worker_tmux_endpoint(state)
449
+ .map(str::to_string)
450
+ .or_else(|| transport.tmux_endpoint())
451
+ .unwrap_or_else(|| crate::tmux_backend::socket_name_for_workspace(workspace));
452
+ if endpoint.is_empty() || endpoint == "default" {
453
+ return None;
454
+ }
455
+ let workspace_socket = crate::tmux_backend::socket_name_for_workspace(workspace);
456
+ let source = state.get("tmux_socket_source").and_then(Value::as_str);
457
+ let owned = source == Some("workspace")
458
+ || source.is_none()
459
+ && endpoint_matches_workspace_socket(&endpoint, workspace, &workspace_socket);
460
+ if !owned {
461
+ return None;
462
+ }
463
+ let socket_file = socket_file_for_endpoint(&endpoint);
464
+ Some(OwnedShutdownEndpoint {
465
+ endpoint,
466
+ socket_file,
467
+ })
468
+ }
469
+
470
+ fn endpoint_matches_workspace_socket(
471
+ endpoint: &str,
472
+ workspace: &Path,
473
+ workspace_socket: &str,
474
+ ) -> bool {
475
+ if endpoint == workspace_socket {
476
+ return true;
477
+ }
478
+ let Some(workspace_path) = crate::tmux_backend::socket_path_for_workspace(workspace) else {
479
+ return false;
480
+ };
481
+ Path::new(endpoint) == workspace_path
482
+ }
483
+
484
+ fn socket_file_for_endpoint(endpoint: &str) -> Option<PathBuf> {
485
+ if Path::new(endpoint).is_absolute() {
486
+ Some(PathBuf::from(endpoint))
487
+ } else {
488
+ crate::tmux_backend::socket_path_for_name(endpoint)
489
+ }
490
+ }
491
+
492
+ fn cleanup_owned_empty_endpoint(
493
+ transport: &dyn crate::transport::Transport,
494
+ endpoint: &OwnedShutdownEndpoint,
495
+ event_log: &crate::event_log::EventLog,
496
+ ) -> OwnedEndpointCleanup {
497
+ let mut cleanup = OwnedEndpointCleanup::default();
498
+ cleanup.remaining_sessions = transport
499
+ .list_targets()
500
+ .unwrap_or_default()
501
+ .into_iter()
502
+ .map(|pane| pane.session.as_str().to_string())
503
+ .collect::<std::collections::BTreeSet<_>>()
504
+ .into_iter()
505
+ .collect::<Vec<_>>();
506
+ if !cleanup.remaining_sessions.is_empty() {
507
+ if let Some(path) = endpoint.socket_file.as_ref() {
508
+ cleanup.skipped_files.push(path.display().to_string());
509
+ }
510
+ let _ = event_log.write(
511
+ "shutdown.owned_endpoint_cleanup_skipped",
512
+ json!({
513
+ "endpoint": endpoint.endpoint,
514
+ "reason": "sessions_remain",
515
+ "remaining_sessions": cleanup.remaining_sessions.clone(),
516
+ "skipped_files": cleanup.skipped_files.clone(),
517
+ }),
518
+ );
519
+ return cleanup;
520
+ }
521
+ if let Err(error) = transport.kill_server() {
522
+ cleanup.error = Some(error.to_string());
523
+ }
524
+ if let Some(path) = endpoint.socket_file.as_ref() {
525
+ if path.exists() {
526
+ match std::fs::remove_file(path) {
527
+ Ok(()) => cleanup.removed_files.push(path.display().to_string()),
528
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
529
+ Err(error) => {
530
+ cleanup.error.get_or_insert_with(|| error.to_string());
531
+ }
532
+ }
533
+ }
534
+ if path.exists() {
535
+ cleanup.residual_files.push(path.display().to_string());
536
+ }
537
+ }
538
+ let _ = event_log.write(
539
+ "shutdown.owned_endpoint_cleanup",
540
+ json!({
541
+ "endpoint": endpoint.endpoint,
542
+ "removed_files": cleanup.removed_files.clone(),
543
+ "residual_files": cleanup.residual_files.clone(),
544
+ "error": cleanup.error.clone(),
545
+ }),
546
+ );
547
+ cleanup
548
+ }
549
+
422
550
  pub fn shutdown_with_transport(
423
551
  workspace: &Path,
424
552
  keep_logs: bool,
@@ -506,6 +634,7 @@ pub mod lifecycle_port {
506
634
  kill_error = Some(error.to_string());
507
635
  }
508
636
  }
637
+ let _ = crate::lifecycle::display::close_team_display_backends(&run_workspace, session);
509
638
  }
510
639
  deadline.check("reap_workspace_residuals")?;
511
640
  reap_workspace_process_residuals(
@@ -539,6 +668,20 @@ pub mod lifecycle_port {
539
668
  if let Some(error) = session_residual_error {
540
669
  kill_error.get_or_insert(error);
541
670
  }
671
+ let event_log = crate::event_log::EventLog::new(&run_workspace);
672
+ let owned_endpoint = owned_shutdown_endpoint(&run_workspace, &state, transport);
673
+ let owned_cleanup = owned_endpoint
674
+ .as_ref()
675
+ .map(|endpoint| cleanup_owned_empty_endpoint(transport, endpoint, &event_log))
676
+ .unwrap_or_default();
677
+ if let Some(error) = owned_cleanup.error.as_ref() {
678
+ kill_error.get_or_insert_with(|| error.clone());
679
+ }
680
+ let owned_file_residuals = owned_cleanup
681
+ .residual_files
682
+ .iter()
683
+ .map(|path| json!({ "path": path }))
684
+ .collect::<Vec<_>>();
542
685
  deadline.check("process_residuals")?;
543
686
  // C-①: the post-verify gets ONE fresh verification snapshot (reaps changed
544
687
  // the world; #248 post-verify facts must be current, not the entry view).
@@ -597,7 +740,8 @@ pub mod lifecycle_port {
597
740
  let session_killed = !killed_sessions.is_empty()
598
741
  && kill_error.is_none()
599
742
  && session_residuals.is_empty()
600
- && process_residuals.is_empty();
743
+ && process_residuals.is_empty()
744
+ && owned_file_residuals.is_empty();
601
745
  mark_agents_stopped(&mut state);
602
746
  deadline.check("save_state")?;
603
747
  if team.is_some() {
@@ -624,6 +768,7 @@ pub mod lifecycle_port {
624
768
  kill_error: kill_error.is_some(),
625
769
  session_residuals: !session_residuals.is_empty(),
626
770
  process_residuals: !process_residuals.is_empty(),
771
+ owned_file_residuals: !owned_file_residuals.is_empty(),
627
772
  cleanup_truth_degraded,
628
773
  coordinator_timeout,
629
774
  coordinator_stop_ok: stopped.as_ref().map(|stopped| stopped.ok),
@@ -655,6 +800,10 @@ pub mod lifecycle_port {
655
800
  "verification_degraded": verification_degraded,
656
801
  "probe_timeout_kind": probe_timeout_kind,
657
802
  "probe_timeout": probe_timeout_value,
803
+ "owned_endpoint": owned_endpoint.as_ref().map(|endpoint| endpoint.endpoint.clone()),
804
+ "owned_files_removed": owned_cleanup.removed_files.clone(),
805
+ "owned_files_skipped": owned_cleanup.skipped_files.clone(),
806
+ "owned_file_residuals": owned_file_residuals.clone(),
658
807
  }),
659
808
  )
660
809
  .map_err(|e| CliError::Runtime(e.to_string()))?;
@@ -675,6 +824,7 @@ pub mod lifecycle_port {
675
824
  "residuals": {
676
825
  "sessions": session_residuals,
677
826
  "processes": process_residuals,
827
+ "owned_files": owned_file_residuals,
678
828
  },
679
829
  "error": kill_error,
680
830
  "coordinator": {
@@ -706,6 +856,7 @@ pub mod lifecycle_port {
706
856
  pub(crate) kill_error: bool,
707
857
  pub(crate) session_residuals: bool,
708
858
  pub(crate) process_residuals: bool,
859
+ pub(crate) owned_file_residuals: bool,
709
860
  pub(crate) cleanup_truth_degraded: bool,
710
861
  pub(crate) coordinator_timeout: bool,
711
862
  pub(crate) coordinator_stop_ok: Option<bool>,
@@ -724,6 +875,7 @@ pub mod lifecycle_port {
724
875
  && !input.kill_error
725
876
  && !input.session_residuals
726
877
  && !input.process_residuals
878
+ && !input.owned_file_residuals
727
879
  && !input.cleanup_truth_degraded;
728
880
  if ok {
729
881
  return ShutdownOutcome {
@@ -736,7 +888,11 @@ pub mod lifecycle_port {
736
888
  ("timeout", Some("stop_coordinator"))
737
889
  } else if input.cleanup_truth_degraded {
738
890
  ("partial", Some("os_probe"))
739
- } else if input.kill_error {
891
+ } else if input.kill_error
892
+ || input.session_residuals
893
+ || input.process_residuals
894
+ || input.owned_file_residuals
895
+ {
740
896
  ("failed", None)
741
897
  } else {
742
898
  ("partial", None)
@@ -145,6 +145,67 @@ pub fn format_status_summary(data: &Value) -> String {
145
145
  )
146
146
  }
147
147
 
148
+ pub fn format_status_csv(data: &Value) -> String {
149
+ let Some(agents) = data.get("agents").and_then(Value::as_object) else {
150
+ return String::new();
151
+ };
152
+ let health = data.get("agent_health").and_then(Value::as_object);
153
+ agents
154
+ .iter()
155
+ .map(|(agent_id, agent)| {
156
+ let status = csv_agent_status(agent_id, agent, health);
157
+ format!("{agent_id},{status}")
158
+ })
159
+ .collect::<Vec<_>>()
160
+ .join("\n")
161
+ }
162
+
163
+ fn csv_agent_status(
164
+ agent_id: &str,
165
+ agent: &Value,
166
+ health: Option<&serde_json::Map<String, Value>>,
167
+ ) -> &'static str {
168
+ let raw = agent
169
+ .get("status")
170
+ .and_then(Value::as_str)
171
+ .unwrap_or("")
172
+ .to_ascii_lowercase();
173
+ let hstatus = health
174
+ .and_then(|map| map.get(agent_id))
175
+ .and_then(|v| v.get("status"))
176
+ .and_then(Value::as_str)
177
+ .unwrap_or("")
178
+ .to_ascii_lowercase();
179
+ if matches!(raw.as_str(), "failed" | "error" | "spawn_failed")
180
+ || matches!(hstatus.as_str(), "failed" | "error" | "spawn_failed")
181
+ || matches!(raw.as_str(), "stopped" | "done")
182
+ {
183
+ return "错误";
184
+ }
185
+ let pane_present = agent
186
+ .get("pane_id")
187
+ .and_then(Value::as_str)
188
+ .filter(|pane| !pane.is_empty())
189
+ .is_some()
190
+ || agent
191
+ .pointer("/display/pane_id")
192
+ .and_then(Value::as_str)
193
+ .filter(|pane| !pane.is_empty())
194
+ .is_some();
195
+ if matches!(raw.as_str(), "running" | "busy" | "working") && !pane_present {
196
+ return "错误";
197
+ }
198
+ if raw == "idle" || hstatus == "idle" {
199
+ return "空闲";
200
+ }
201
+ if matches!(raw.as_str(), "running" | "busy" | "working")
202
+ || matches!(hstatus.as_str(), "running" | "working" | "busy")
203
+ {
204
+ return "工作";
205
+ }
206
+ "错误"
207
+ }
208
+
148
209
  /// `_latest_result_line`(`commands.py:333-337`):agent_id/summary/created_at 渲染单行。
149
210
  /// summary `\n`→` ` 后 [:80] 截断、空 → `-`;agent_id 空 → `-`;created_at 经 age_text。
150
211
  pub(crate) fn format_latest_result(value: &Value) -> String {
@@ -127,8 +127,8 @@ use rusqlite::params;
127
127
  format_agent_status(workspace, &status, agent)
128
128
  }
129
129
  None => {
130
- let status = status_scoped(workspace, state, owner_team_id, true, false)?;
131
- Ok(crate::cli::format_status_summary(&status))
130
+ let status = status_scoped(workspace, state, owner_team_id, false, false)?;
131
+ Ok(crate::cli::format_status_csv(&status))
132
132
  }
133
133
  }
134
134
  }
@@ -363,6 +363,32 @@ latest result: none";
363
363
  assert!(!line2.contains("interacted"), "no marker when interacted==0");
364
364
  }
365
365
 
366
+ #[test]
367
+ fn format_status_csv_preserves_agent_order_and_collapses_errors() {
368
+ let data = json!({
369
+ "agents": {
370
+ "zeta": {"status": "idle", "pane_id": "%1"},
371
+ "alpha": {"status": "running", "pane_id": "%2"},
372
+ "err_failed": {"status": "failed", "pane_id": "%3"},
373
+ "err_missing_pane": {"status": "running"},
374
+ "err_unknown": {"status": "mystery", "pane_id": "%4"},
375
+ "err_stopped": {"status": "stopped", "pane_id": "%5"}
376
+ },
377
+ "agent_health": {
378
+ "alpha": {"status": "working"}
379
+ }
380
+ });
381
+ assert_eq!(
382
+ format_status_csv(&data),
383
+ "zeta,空闲\nalpha,工作\nerr_failed,错误\nerr_missing_pane,错误\nerr_unknown,错误\nerr_stopped,错误"
384
+ );
385
+ }
386
+
387
+ #[test]
388
+ fn format_status_csv_zero_workers_is_empty() {
389
+ assert_eq!(format_status_csv(&json!({"agents": {}, "agent_health": {}})), "");
390
+ }
391
+
366
392
  // =========================================================================
367
393
  // emit(helpers.py:12-23):--json sort_keys+indent=2 | dict 逐键 | 非 dict
368
394
  // =========================================================================