@team-agent/installer 0.3.39 → 0.4.0

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 (61) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +7 -0
  4. package/crates/team-agent/src/cli/adapters.rs +8 -3
  5. package/crates/team-agent/src/cli/diagnose.rs +12 -3
  6. package/crates/team-agent/src/cli/emit.rs +1 -0
  7. package/crates/team-agent/src/cli/mod.rs +250 -9
  8. package/crates/team-agent/src/cli/send.rs +11 -2
  9. package/crates/team-agent/src/cli/status_port.rs +39 -4
  10. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +189 -6
  11. package/crates/team-agent/src/cli/tests/status_send.rs +189 -0
  12. package/crates/team-agent/src/cli/tests/verb_settle.rs +2 -0
  13. package/crates/team-agent/src/cli/types.rs +5 -0
  14. package/crates/team-agent/src/coordinator/mod.rs +1 -0
  15. package/crates/team-agent/src/coordinator/steps/abnormal.rs +4 -0
  16. package/crates/team-agent/src/coordinator/steps/delivery.rs +4 -0
  17. package/crates/team-agent/src/coordinator/steps/health_sync.rs +4 -0
  18. package/crates/team-agent/src/coordinator/steps/mod.rs +83 -0
  19. package/crates/team-agent/src/coordinator/steps/persist.rs +4 -0
  20. package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +4 -0
  21. package/crates/team-agent/src/coordinator/steps/session_gate.rs +5 -0
  22. package/crates/team-agent/src/coordinator/tests/health_sync.rs +156 -0
  23. package/crates/team-agent/src/coordinator/tick.rs +126 -30
  24. package/crates/team-agent/src/{message_store.rs → db/message_store.rs} +6 -0
  25. package/crates/team-agent/src/db/mod.rs +1 -0
  26. package/crates/team-agent/src/layout/mod.rs +7 -0
  27. package/crates/team-agent/src/layout/runtime_sessions.rs +312 -0
  28. package/crates/team-agent/src/layout/tmux_endpoint.rs +162 -0
  29. package/crates/team-agent/src/leader/start.rs +27 -1
  30. package/crates/team-agent/src/leader/tests/identity.rs +50 -0
  31. package/crates/team-agent/src/lib.rs +6 -2
  32. package/crates/team-agent/src/lifecycle/launch/readiness.rs +32 -0
  33. package/crates/team-agent/src/lifecycle/launch/spawn.rs +32 -0
  34. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +51 -0
  35. package/crates/team-agent/src/lifecycle/launch.rs +34 -0
  36. package/crates/team-agent/src/lifecycle/restart/agent.rs +17 -0
  37. package/crates/team-agent/src/lifecycle/restart/common.rs +143 -23
  38. package/crates/team-agent/src/lifecycle/restart/preflight.rs +87 -0
  39. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +308 -0
  40. package/crates/team-agent/src/lifecycle/restart/selection.rs +87 -22
  41. package/crates/team-agent/src/lifecycle/restart.rs +6 -0
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +6 -1
  43. package/crates/team-agent/src/lifecycle/tests/restart.rs +187 -0
  44. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  45. package/crates/team-agent/src/lifecycle/types.rs +20 -1
  46. package/crates/team-agent/src/mcp_server/helpers.rs +72 -1
  47. package/crates/team-agent/src/mcp_server/tools.rs +29 -4
  48. package/crates/team-agent/src/provider/adapter.rs +31 -1
  49. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +34 -0
  50. package/crates/team-agent/src/provider/classify.rs +138 -0
  51. package/crates/team-agent/src/provider/command.rs +71 -0
  52. package/crates/team-agent/src/provider/mod.rs +3 -0
  53. package/crates/team-agent/src/{session_capture.rs → provider/session/capture.rs} +139 -6
  54. package/crates/team-agent/src/provider/session/mod.rs +13 -0
  55. package/crates/team-agent/src/provider/session/resume.rs +417 -0
  56. package/crates/team-agent/src/provider/session_scan.rs +63 -0
  57. package/crates/team-agent/src/provider/types.rs +5 -0
  58. package/crates/team-agent/src/state/persist.rs +238 -0
  59. package/crates/team-agent/src/tmux_backend.rs +25 -0
  60. package/npm/install.mjs +27 -1
  61. 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.39"
569
+ version = "0.4.0"
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.39"
12
+ version = "0.4.0"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -29,5 +29,12 @@ rusqlite.workspace = true # step 3 db(bundled SQLite,静态链接)
29
29
  [dev-dependencies]
30
30
  serial_test = { version = "3", features = ["file_locks"] }
31
31
 
32
+ # `tests/e2e/` 黑盒 E2E 框架专用 — 不需要 assert_cmd / predicates,
33
+ # 框架内部直接用 std::process::Command + serde_json + 自写 assert_* helpers,
34
+ # 保持 zero new external test-CLI deps(避开 crates.io 代理限制)。
35
+ [[test]]
36
+ name = "e2e"
37
+ path = "tests/e2e/main.rs"
38
+
32
39
  [lints]
33
40
  workspace = true
@@ -443,7 +443,7 @@ pub fn cmd_collect_for_team(args: &CollectArgs, team: Option<&str>) -> Result<Cm
443
443
 
444
444
  /// `cmd_settle`(`commands.py:86`)。
445
445
  pub fn cmd_settle(args: &SettleArgs) -> Result<CmdResult, CliError> {
446
- match settle_value(&args.workspace) {
446
+ match settle_value(&args.workspace, args.team.as_deref()) {
447
447
  Ok(value) => Ok(CmdResult::from_json(value, args.json)),
448
448
  Err(error) => Ok(CmdResult::from_json(
449
449
  json!({
@@ -456,10 +456,15 @@ pub fn cmd_settle(args: &SettleArgs) -> Result<CmdResult, CliError> {
456
456
  }
457
457
  }
458
458
 
459
- fn settle_value(workspace: &Path) -> Result<Value, CliError> {
459
+ fn settle_value(workspace: &Path, team: Option<&str>) -> Result<Value, CliError> {
460
+ // Bug #6 (prerelease 0.4.0 gate review §6): pass explicit team through to
461
+ // resolve_active_team so settle scopes collect/status/team-state to the
462
+ // requested team. Without this, the selector falls back to top-level
463
+ // active_team_key and settle operates on the wrong team. resolve_active_team
464
+ // logic unchanged per 不可改项.
460
465
  let selected = crate::state::selector::resolve_active_team(
461
466
  workspace,
462
- None,
467
+ team,
463
468
  crate::state::selector::SelectorMode::RuntimeOnly,
464
469
  )
465
470
  .map_err(|e| CliError::Runtime(e.to_string()))?;
@@ -418,12 +418,21 @@ pub(crate) fn build_wait_ready_report(workspace: &std::path::Path, timeout: f64)
418
418
  }
419
419
 
420
420
  fn inject_tmux_session_present(workspace: &std::path::Path, state: &mut Value) {
421
+ // Bug #7 (prerelease 0.4.0 gate review §6): probe the SAME endpoint the
422
+ // runtime actually uses (state.tmux_endpoint / tmux_socket), not the
423
+ // workspace-hash socket. Otherwise readiness reports `process_started=false`
424
+ // even though the session is alive on the persisted socket.
421
425
  let Some(session_name) = state.get("session_name").and_then(Value::as_str).filter(|s| !s.is_empty()) else {
422
426
  return;
423
427
  };
424
- let backend = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
425
- let present = backend
426
- .has_session(&crate::transport::SessionName::new(session_name.to_string()))
428
+ let session_name_owned = session_name.to_string();
429
+ let selection = crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
430
+ workspace,
431
+ Some(state),
432
+ );
433
+ let present = selection
434
+ .backend
435
+ .has_session(&crate::transport::SessionName::new(session_name_owned))
427
436
  .unwrap_or(false);
428
437
  if let Value::Object(map) = state {
429
438
  map.insert("tmux_session_present".to_string(), Value::Bool(present));
@@ -1263,6 +1263,7 @@ fn settle_args(args: &[String], cwd: &Path) -> SettleArgs {
1263
1263
  let parsed = parse_args(args);
1264
1264
  SettleArgs {
1265
1265
  workspace: workspace(&parsed, cwd),
1266
+ team: parsed.team.clone(),
1266
1267
  json: parsed.json,
1267
1268
  }
1268
1269
  }
@@ -859,6 +859,18 @@ pub mod lifecycle_port {
859
859
  .as_ref()
860
860
  .and_then(|stopped| stopped.pid.map(|p| p.get()));
861
861
  let coordinator_pid = coordinator_pid.or(coordinator_pid_for_report);
862
+ // unit-2 (Stage 1) false-green guard fact:
863
+ // target_session_spared = "we had a target worker session name AND
864
+ // that exact name appears in spared_sessions"
865
+ // Mirrors the "we asked to kill X but X is still alive" check the
866
+ // 0.3.39 false-green bug missed. session_name is the configured
867
+ // target; spared_sessions is the cleanup's "kept alive" list.
868
+ let target_session_spared = match session_name.as_ref() {
869
+ Some(target) => spared_sessions
870
+ .iter()
871
+ .any(|s| s.as_str() == target.as_str()),
872
+ None => false,
873
+ };
862
874
  let outcome = classify_shutdown_outcome(ShutdownOutcomeInput {
863
875
  kill_error: kill_error.is_some(),
864
876
  session_residuals: !session_residuals.is_empty(),
@@ -868,6 +880,7 @@ pub mod lifecycle_port {
868
880
  coordinator_timeout,
869
881
  coordinator_stop_ok: stopped.as_ref().map(|stopped| stopped.ok),
870
882
  coordinator_post_stop,
883
+ target_session_spared,
871
884
  });
872
885
  let ok = outcome.ok;
873
886
  let status = outcome.status;
@@ -956,6 +969,16 @@ pub mod lifecycle_port {
956
969
  pub(crate) coordinator_timeout: bool,
957
970
  pub(crate) coordinator_stop_ok: Option<bool>,
958
971
  pub(crate) coordinator_post_stop: CoordinatorStopObservation,
972
+ /// unit-2 (Stage 1) false-green guard:
973
+ /// `true` when the workspace had a target worker session name AND
974
+ /// the cleanup did NOT confirm kill of that name (i.e. the target
975
+ /// was spared, leaving the worker alive while ok/status:"ok" would
976
+ /// otherwise be reported — the 0.3.39 shutdown false-green shape).
977
+ ///
978
+ /// Default `false` for callers that have no target (legacy bare
979
+ /// shutdown on a not-yet-bound workspace) so the existing
980
+ /// well-defined OK branch keeps working.
981
+ pub(crate) target_session_spared: bool,
959
982
  }
960
983
 
961
984
  pub(crate) fn classify_shutdown_outcome(input: ShutdownOutcomeInput) -> ShutdownOutcome {
@@ -971,7 +994,12 @@ pub mod lifecycle_port {
971
994
  && !input.session_residuals
972
995
  && !input.process_residuals
973
996
  && !input.owned_file_residuals
974
- && !input.cleanup_truth_degraded;
997
+ && !input.cleanup_truth_degraded
998
+ // unit-2 false-green guard: refuse OK when the configured worker
999
+ // session was spared (still alive). The user asked for shutdown,
1000
+ // not "shutdown but maybe keep the session" — make the disagreement
1001
+ // explicit instead of paint-it-green.
1002
+ && !input.target_session_spared;
975
1003
  if ok {
976
1004
  return ShutdownOutcome {
977
1005
  ok,
@@ -989,6 +1017,12 @@ pub mod lifecycle_port {
989
1017
  || input.owned_file_residuals
990
1018
  {
991
1019
  ("failed", None)
1020
+ } else if input.target_session_spared {
1021
+ // The worker session that was supposed to be killed survived
1022
+ // because the cleanup decision spared it (dirty topology — e.g.
1023
+ // 0.3.39 leader_receiver.session_name == worker session). Surface
1024
+ // a status that names the cause instead of fall-through "partial".
1025
+ ("dirty_state", Some("target_session_spared"))
992
1026
  } else {
993
1027
  ("partial", None)
994
1028
  };
@@ -2713,14 +2747,209 @@ pub mod lifecycle_port {
2713
2747
  unresumable,
2714
2748
  allow_fresh,
2715
2749
  error,
2716
- } => json!({
2717
- "ok": false,
2718
- "status": "refused_resume_atomicity",
2719
- "allow_fresh": allow_fresh,
2720
- "error": error,
2721
- "unresumable": unresumable.iter().map(|w| w.agent_id.as_str()).collect::<Vec<_>>(),
2722
- "reminder": crate::cli::QUICK_START_REMINDER,
2723
- }),
2750
+ } => {
2751
+ // Unit 5 + Layer 2 wire-through (leader directive 2026-06-22):
2752
+ // `unresumable` JSON shape is now `[{agent_id, reason,
2753
+ // session_id?, checked_paths?, recovery_hint?}, ...]` so the
2754
+ // structured refusal class is visible to CLI consumers. The
2755
+ // legacy string-array shape (single agent_id per entry) is
2756
+ // available under `unresumable_ids` for tooling that still
2757
+ // wants the cheap list.
2758
+ let unresumable_detail: Vec<Value> = unresumable
2759
+ .iter()
2760
+ .map(|w| {
2761
+ let mut entry = serde_json::Map::new();
2762
+ entry.insert(
2763
+ "agent_id".to_string(),
2764
+ json!(w.agent_id.as_str()),
2765
+ );
2766
+ // Prefer the structured wire string when the
2767
+ // ResumeRefusalReason enum is populated; fall back
2768
+ // to the legacy free-form `reason` otherwise.
2769
+ let reason_wire = w
2770
+ .refusal_reason
2771
+ .as_ref()
2772
+ .map(|r| r.wire().to_string())
2773
+ .unwrap_or_else(|| w.reason.clone());
2774
+ entry.insert("reason".to_string(), json!(reason_wire));
2775
+ if let Some(sid) = &w.session_id {
2776
+ entry.insert(
2777
+ "session_id".to_string(),
2778
+ json!(sid.as_str()),
2779
+ );
2780
+ }
2781
+ if let Some(reason) = &w.refusal_reason {
2782
+ if let crate::provider::session::ResumeRefusalReason::SessionBackingStoreMissing {
2783
+ checked_paths,
2784
+ recovery_hint,
2785
+ } = reason
2786
+ {
2787
+ if !checked_paths.is_empty() {
2788
+ entry.insert(
2789
+ "checked_paths".to_string(),
2790
+ json!(checked_paths
2791
+ .iter()
2792
+ .map(|p| p.to_string_lossy().into_owned())
2793
+ .collect::<Vec<_>>()),
2794
+ );
2795
+ }
2796
+ if let Some(hint) = recovery_hint {
2797
+ let mut h = serde_json::Map::new();
2798
+ h.insert(
2799
+ "provider".to_string(),
2800
+ json!(hint.provider),
2801
+ );
2802
+ if let Some(name) = &hint.provider_session_name_hint {
2803
+ h.insert("name".to_string(), json!(name));
2804
+ }
2805
+ if let Some(cwd) = &hint.spawn_cwd {
2806
+ h.insert(
2807
+ "spawn_cwd".to_string(),
2808
+ json!(cwd.to_string_lossy()),
2809
+ );
2810
+ }
2811
+ h.insert(
2812
+ "picker_hint".to_string(),
2813
+ json!(hint.picker_hint()),
2814
+ );
2815
+ entry.insert(
2816
+ "recovery_hint".to_string(),
2817
+ Value::Object(h),
2818
+ );
2819
+ }
2820
+ }
2821
+ }
2822
+ Value::Object(entry)
2823
+ })
2824
+ .collect();
2825
+ let unresumable_ids: Vec<&str> = unresumable
2826
+ .iter()
2827
+ .map(|w| w.agent_id.as_str())
2828
+ .collect();
2829
+ // Layer 2 self-healing (leader follow-up 2026-06-22): when
2830
+ // EVERY unresumable worker shares the same structured
2831
+ // refusal_reason wire string, refine the top-level `status`
2832
+ // from the coarse `refused_resume_atomicity` to a class-
2833
+ // specific label and overlay an `error` that names the
2834
+ // specific cause + concrete recovery moves. The original
2835
+ // `refused_resume_atomicity` status is kept as
2836
+ // `status_class` so anything matching on the coarse label
2837
+ // still works.
2838
+ let distinct_wires: std::collections::BTreeSet<&'static str> = unresumable
2839
+ .iter()
2840
+ .filter_map(|w| w.refusal_reason.as_ref().map(|r| r.wire()))
2841
+ .collect();
2842
+ let (status_str, error_str): (String, String) = if distinct_wires.len() == 1
2843
+ && distinct_wires.len() == unresumable.len()
2844
+ {
2845
+ // Every entry had a structured reason and they all
2846
+ // agreed. Refine.
2847
+ let wire = *distinct_wires.iter().next().unwrap_or(&"");
2848
+ match wire {
2849
+ "session_backing_store_missing" => {
2850
+ // Collect a flat list of every checked path so
2851
+ // operators can copy/paste-grep for files that
2852
+ // moved. picker_hint per worker gives the
2853
+ // alternate-recovery one-liner.
2854
+ let mut probed: Vec<String> = Vec::new();
2855
+ let mut picker_lines: Vec<String> = Vec::new();
2856
+ for w in unresumable.iter() {
2857
+ if let Some(
2858
+ crate::provider::session::ResumeRefusalReason::SessionBackingStoreMissing {
2859
+ checked_paths,
2860
+ recovery_hint,
2861
+ },
2862
+ ) = w.refusal_reason.as_ref()
2863
+ {
2864
+ for p in checked_paths {
2865
+ probed.push(p.to_string_lossy().into_owned());
2866
+ }
2867
+ if let Some(h) = recovery_hint {
2868
+ picker_lines.push(format!(
2869
+ " {}: try `{}`",
2870
+ w.agent_id.as_str(),
2871
+ h.picker_hint(),
2872
+ ));
2873
+ }
2874
+ }
2875
+ }
2876
+ probed.sort();
2877
+ probed.dedup();
2878
+ let mut msg = format!(
2879
+ "restart refused: provider session backing store missing for {} worker(s) ({}). \
2880
+ Pass --allow-fresh to start a new session, or restore the backing files listed below.",
2881
+ unresumable.len(),
2882
+ unresumable_ids.join(", "),
2883
+ );
2884
+ if !probed.is_empty() {
2885
+ msg.push_str("\nProbed paths (none contained the expected session):");
2886
+ for p in &probed {
2887
+ msg.push_str("\n ");
2888
+ msg.push_str(p);
2889
+ }
2890
+ }
2891
+ if !picker_lines.is_empty() {
2892
+ msg.push_str("\nProvider picker recovery hints:");
2893
+ for line in &picker_lines {
2894
+ msg.push_str("\n");
2895
+ msg.push_str(line);
2896
+ }
2897
+ }
2898
+ (
2899
+ "refused_session_backing_missing".to_string(),
2900
+ msg,
2901
+ )
2902
+ }
2903
+ "no_persisted_session_id" => (
2904
+ "refused_no_session_id".to_string(),
2905
+ format!(
2906
+ "restart refused: no persisted session_id for {} worker(s) ({}). Pass --allow-fresh to start fresh.",
2907
+ unresumable.len(),
2908
+ unresumable_ids.join(", "),
2909
+ ),
2910
+ ),
2911
+ "provider_resume_unsupported" => (
2912
+ "refused_provider_resume_unsupported".to_string(),
2913
+ format!(
2914
+ "restart refused: provider does not support resume for {} worker(s) ({}). Pass --allow-fresh to start fresh.",
2915
+ unresumable.len(),
2916
+ unresumable_ids.join(", "),
2917
+ ),
2918
+ ),
2919
+ _ => ("refused_resume_atomicity".to_string(), error.clone()),
2920
+ }
2921
+ } else {
2922
+ // Mixed or unstructured reasons — keep the coarse
2923
+ // label, but augment the error so it lists each
2924
+ // worker's specific reason.
2925
+ let per_worker = unresumable
2926
+ .iter()
2927
+ .map(|w| {
2928
+ let reason = w
2929
+ .refusal_reason
2930
+ .as_ref()
2931
+ .map(|r| r.wire().to_string())
2932
+ .unwrap_or_else(|| w.reason.clone());
2933
+ format!("{} ({})", w.agent_id.as_str(), reason)
2934
+ })
2935
+ .collect::<Vec<_>>()
2936
+ .join(", ");
2937
+ (
2938
+ "refused_resume_atomicity".to_string(),
2939
+ format!("{error} Per-worker: {per_worker}."),
2940
+ )
2941
+ };
2942
+ json!({
2943
+ "ok": false,
2944
+ "status": status_str,
2945
+ "status_class": "refused_resume_atomicity",
2946
+ "allow_fresh": allow_fresh,
2947
+ "error": error_str,
2948
+ "unresumable": unresumable_detail,
2949
+ "unresumable_ids": unresumable_ids,
2950
+ "reminder": crate::cli::QUICK_START_REMINDER,
2951
+ })
2952
+ }
2724
2953
  crate::lifecycle::RestartReport::RefusedResumeNotReady {
2725
2954
  missing,
2726
2955
  allow_fresh,
@@ -2758,6 +2987,18 @@ pub mod lifecycle_port {
2758
2987
  "invalid": invalid.iter().map(|w| w.worker_id.as_str()).collect::<Vec<_>>(),
2759
2988
  "reminder": crate::cli::QUICK_START_REMINDER,
2760
2989
  }),
2990
+ crate::lifecycle::RestartReport::RefusedDirtyTopology {
2991
+ session_name,
2992
+ reason,
2993
+ error,
2994
+ } => json!({
2995
+ "ok": false,
2996
+ "status": "refused_dirty_topology",
2997
+ "reason": reason,
2998
+ "session_name": session_name,
2999
+ "error": error,
3000
+ "reminder": crate::cli::QUICK_START_REMINDER,
3001
+ }),
2761
3002
  }
2762
3003
  }
2763
3004
 
@@ -283,12 +283,15 @@ fn routing_ambiguous_value(
283
283
  if in_team {
284
284
  return None;
285
285
  }
286
+ // aeab1c7 follow-up: `content` is no longer emitted anywhere from `send`
287
+ // responses (including this refusal). Replace with `content_length_bytes`
288
+ // to keep the size-sanity field consistent with the normal-send shape.
286
289
  Some(json!({
287
290
  "ok": false,
288
291
  "status": "refused",
289
292
  "target": null,
290
293
  "agent_id": null,
291
- "content": name,
294
+ "content_length_bytes": name.len(),
292
295
  "sender": opts.sender,
293
296
  "message_id": null,
294
297
  "message_status": "refused",
@@ -456,13 +459,19 @@ fn delivery_outcome_json(
456
459
  content: &str,
457
460
  opts: &SendOptions,
458
461
  ) -> Value {
462
+ // Pre-release 0.4.0 user directive: send result MUST NOT carry the
463
+ // message body — neither in human form (cli/emit.rs) NOR in --json.
464
+ // External consumers who need the message content read it via `inbox`,
465
+ // not from the send response. We surface `content_length_bytes` as a
466
+ // size sanity field so callers can verify the body size they intended
467
+ // to send arrived intact without exposing the body itself.
459
468
  let target_wire = target_json(target);
460
469
  json!({
461
470
  "ok": outcome.ok,
462
471
  "status": delivery_status_wire(outcome.status),
463
472
  "target": target_wire,
464
473
  "agent_id": first_target(target),
465
- "content": content,
474
+ "content_length_bytes": content.len(),
466
475
  "sender": opts.sender,
467
476
  "message_id": outcome.message_id,
468
477
  "message_status": outcome.message_status.0,
@@ -58,14 +58,19 @@ use rusqlite::params;
58
58
  .and_then(Value::as_str)
59
59
  .unwrap_or("leader");
60
60
  session_name.as_str().and_then(|session| {
61
- crate::tmux_backend::attach_command_for_workspace(
61
+ // Bug #7 (gate review §6): build the attach command from the
62
+ // SAME endpoint the readiness probe uses (state's persisted
63
+ // tmux_endpoint/tmux_socket), so the printed command matches
64
+ // where the session actually lives.
65
+ crate::tmux_backend::attach_command_for_runtime_state_or_workspace(
62
66
  workspace,
67
+ Some(state),
63
68
  &crate::transport::SessionName::new(session.to_string()),
64
69
  window_name,
65
70
  )
66
71
  })
67
72
  };
68
- let tmux_present = tmux_session_present(workspace, session_name.as_str());
73
+ let tmux_present = tmux_session_present(workspace, state, session_name.as_str());
69
74
  let mut readiness_state = state.clone();
70
75
  if let Some(obj) = readiness_state.as_object_mut() {
71
76
  obj.insert("tmux_session_present".to_string(), serde_json::json!(tmux_present));
@@ -435,7 +440,16 @@ use rusqlite::params;
435
440
  }
436
441
  }
437
442
 
438
- fn tmux_session_present(workspace: &Path, session_name: Option<&str>) -> bool {
443
+ fn tmux_session_present(
444
+ workspace: &Path,
445
+ state: &Value,
446
+ session_name: Option<&str>,
447
+ ) -> bool {
448
+ // Bug #7 (prerelease 0.4.0 gate review §6): probe the SAME endpoint
449
+ // the runtime actually uses (state.tmux_endpoint / tmux_socket), not
450
+ // the workspace-hash socket. When state has no persisted endpoint,
451
+ // fall back to workspace — preserves legacy behavior. wait_readiness
452
+ // formula unchanged per 不可改项; only the input signal is fixed.
439
453
  let Some(name) = session_name else {
440
454
  return false;
441
455
  };
@@ -444,7 +458,12 @@ use rusqlite::params;
444
458
  }
445
459
  let run_ws = crate::model::paths::canonical_run_workspace(workspace)
446
460
  .unwrap_or_else(|_| workspace.to_path_buf());
447
- crate::tmux_backend::TmuxBackend::for_workspace(&run_ws)
461
+ let selection = crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
462
+ &run_ws,
463
+ Some(state),
464
+ );
465
+ selection
466
+ .backend
448
467
  .has_session(&crate::transport::SessionName::new(name))
449
468
  .unwrap_or(false)
450
469
  }
@@ -635,6 +654,22 @@ use rusqlite::params;
635
654
  "session_id",
636
655
  "captured_via",
637
656
  "attribution_confidence",
657
+ // RM-039-STAT-001 fix (real-machine evidence 2026-06-22): the
658
+ // coordinator-tick activity classifier writes activity (status,
659
+ // confidence, rationale) to the top-level agent state, but the
660
+ // status --json compact projection dropped it. Operators saw
661
+ // "status: running" with no working/idle signal even when the
662
+ // pane was clearly producing output. Add the classifier output
663
+ // here so the compact form surfaces it.
664
+ // last_output_at is the timestamp the classifier advanced when
665
+ // the scrollback digest changed; keeping it adjacent to activity
666
+ // gives the operator a one-glance "is something moving" view.
667
+ // interacted is enriched in enrich_agents (true|false|never)
668
+ // and is the canonical "leader has ever sent this worker a
669
+ // message" signal — also needed for status sanity at a glance.
670
+ "activity",
671
+ "last_output_at",
672
+ "interacted",
638
673
  ] {
639
674
  if let Some(value) = input.get(key) {
640
675
  out.insert(key.to_string(), value.clone());