@team-agent/installer 0.5.6 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.lock CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.6"
578
+ version = "0.5.8"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.6"
12
+ version = "0.5.8"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -18,28 +18,34 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
18
18
  Ok(true) => {}
19
19
  Ok(false) => {
20
20
  issues.push(json!("tmux_session_missing"));
21
- repairs.push(json!({
22
- "issue": "tmux_session_missing",
23
- "action": format!("restart or relaunch tmux session `{session_name}`"),
24
- }));
21
+ repairs.push(recovery_hint(
22
+ session_name,
23
+ "tmux_session_missing",
24
+ "team-agent restart",
25
+ ));
25
26
  }
26
27
  Err(error) => {
27
28
  issues.push(json!("tmux_session_missing"));
28
- repairs.push(json!({
29
- "issue": "tmux_session_missing",
30
- "action": format!("restart or relaunch tmux session `{session_name}`"),
31
- "reason": error.to_string(),
32
- }));
29
+ let mut hint =
30
+ recovery_hint(session_name, "tmux_session_missing", "team-agent restart");
31
+ if let Some(obj) = hint.as_object_mut() {
32
+ obj.insert("reason".to_string(), Value::String(error.to_string()));
33
+ }
34
+ repairs.push(hint);
33
35
  }
34
36
  }
35
37
  }
36
38
 
37
39
  if !leader_receiver_attached(state) {
38
40
  issues.push(json!("leader_not_attached"));
39
- repairs.push(json!({
40
- "issue": "leader_not_attached",
41
- "action": "attach or claim a leader receiver before sending work",
42
- }));
41
+ repairs.push(recovery_hint(
42
+ state
43
+ .get("session_name")
44
+ .and_then(Value::as_str)
45
+ .unwrap_or("unknown"),
46
+ "leader_not_attached",
47
+ "team-agent attach-leader",
48
+ ));
43
49
  } else {
44
50
  // 0.4.x (CR R2 P0): leader provider health reconciliation. The
45
51
  // leader_receiver may be marked `attached` (pane addressable) but the
@@ -57,6 +63,11 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
57
63
  issues.push(json!("leader_provider_exited"));
58
64
  repairs.push(json!({
59
65
  "issue": "leader_provider_exited",
66
+ "action_required": true,
67
+ "advisory": true,
68
+ "broken_class": "leader_provider_exited",
69
+ "hint_action": "team-agent restart",
70
+ "dedupe_key": "leader_provider_exited",
60
71
  "action": format!(
61
72
  "leader pane fell back to shell — provider `{provider_label}` exited; \
62
73
  relaunch with `team-agent {provider_label}` to restart the provider"
@@ -65,10 +76,14 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
65
76
  }
66
77
  crate::leader::LeaderProviderHealth::Unreachable => {
67
78
  issues.push(json!("leader_provider_unreachable"));
68
- repairs.push(json!({
69
- "issue": "leader_provider_unreachable",
70
- "action": "leader pane is dead — relaunch the leader",
71
- }));
79
+ repairs.push(recovery_hint(
80
+ state
81
+ .get("session_name")
82
+ .and_then(Value::as_str)
83
+ .unwrap_or("unknown"),
84
+ "leader_provider_unreachable",
85
+ "team-agent claim-leader",
86
+ ));
72
87
  }
73
88
  crate::leader::LeaderProviderHealth::Alive => {}
74
89
  }
@@ -179,6 +194,20 @@ fn leader_receiver_attached(state: &Value) -> bool {
179
194
  mode_direct && status_attached && pane_present
180
195
  }
181
196
 
197
+ fn recovery_hint(team: &str, broken_class: &str, hint_action: &str) -> Value {
198
+ json!({
199
+ "issue": broken_class,
200
+ "action_required": true,
201
+ "advisory": true,
202
+ "broken_class": broken_class,
203
+ "hint_action": hint_action,
204
+ "dedupe_key": format!("{team}:{broken_class}"),
205
+ "action": format!(
206
+ "{hint_action} # alternatives: team-agent restart; team-agent claim-leader; team-agent quick-start; team-agent attach-leader"
207
+ ),
208
+ })
209
+ }
210
+
182
211
  #[cfg(test)]
183
212
  mod tests {
184
213
  use super::*;
@@ -2314,12 +2314,29 @@ pub mod lifecycle_port {
2314
2314
  force: bool,
2315
2315
  team: Option<&str>,
2316
2316
  ) -> Result<Value, CliError> {
2317
+ let agent_id = crate::model::ids::AgentId::new(agent);
2318
+ match crate::lifecycle::remove_agent_flag_requirements(workspace, &agent_id, team) {
2319
+ Ok(requirements) => {
2320
+ if !remove_agent_missing_flags(from_spec, confirm, force, &requirements).is_empty() {
2321
+ return Ok(remove_agent_flag_refusal(
2322
+ workspace,
2323
+ agent,
2324
+ team,
2325
+ from_spec,
2326
+ confirm,
2327
+ force,
2328
+ &requirements,
2329
+ ));
2330
+ }
2331
+ }
2332
+ Err(error) if confirm => return Ok(error_value(error)),
2333
+ Err(_) => {}
2334
+ }
2317
2335
  if !confirm {
2318
2336
  return Ok(
2319
2337
  json!({"ok": false, "agent_id": agent, "error": "remove-agent requires --confirm"}),
2320
2338
  );
2321
2339
  }
2322
- let agent_id = crate::model::ids::AgentId::new(agent);
2323
2340
  match crate::lifecycle::remove_agent(workspace, &agent_id, from_spec, force, team) {
2324
2341
  Ok(report @ crate::lifecycle::RemoveAgentOutcome::Removed { .. }) => Ok(json!({
2325
2342
  "ok": true,
@@ -2343,9 +2360,121 @@ pub mod lifecycle_port {
2343
2360
  "error": "agent is running; remove-agent requires --force",
2344
2361
  "action": "rerun with --force to stop and remove the running agent",
2345
2362
  })),
2363
+ Ok(crate::lifecycle::RemoveAgentOutcome::RefusedRequiredFlags { .. }) => Ok(json!({
2364
+ "ok": false,
2365
+ "agent_id": agent,
2366
+ "status": "refused",
2367
+ "reason": "remove_agent_flags_required",
2368
+ "error": "remove-agent required flags changed; rerun the command from the latest refusal",
2369
+ })),
2346
2370
  Err(e) => Ok(error_value(e)),
2347
2371
  }
2348
2372
  }
2373
+
2374
+ fn remove_agent_flag_refusal(
2375
+ workspace: &Path,
2376
+ agent: &str,
2377
+ team: Option<&str>,
2378
+ from_spec: bool,
2379
+ confirm: bool,
2380
+ force: bool,
2381
+ requirements: &crate::lifecycle::RemoveAgentFlagRequirements,
2382
+ ) -> Value {
2383
+ let required_flags = remove_agent_required_flags(requirements);
2384
+ let missing_flags = remove_agent_missing_flags(from_spec, confirm, force, requirements);
2385
+ let reason = if missing_flags.len() == 1 {
2386
+ match missing_flags[0] {
2387
+ "--confirm" => "confirm_required",
2388
+ "--from-spec" => "from_spec_confirm_required",
2389
+ "--force" => "force_required",
2390
+ _ => "remove_agent_flags_required",
2391
+ }
2392
+ } else {
2393
+ "remove_agent_flags_required"
2394
+ };
2395
+ let command = remove_agent_command(workspace, agent, team, &required_flags);
2396
+ let required = required_flags.join(" ");
2397
+ json!({
2398
+ "ok": false,
2399
+ "agent_id": agent,
2400
+ "status": "refused",
2401
+ "reason": reason,
2402
+ "error": format!("remove-agent requires {required} for this agent"),
2403
+ "action": format!("rerun: {command}"),
2404
+ "command": command,
2405
+ "missing_flags": missing_flags,
2406
+ "required_flags": required_flags,
2407
+ "state": {
2408
+ "from_spec_required": requirements.from_spec_required,
2409
+ "running": requirements.force_required,
2410
+ "has_session": requirements.has_session,
2411
+ },
2412
+ })
2413
+ }
2414
+
2415
+ fn remove_agent_missing_flags(
2416
+ from_spec: bool,
2417
+ confirm: bool,
2418
+ force: bool,
2419
+ requirements: &crate::lifecycle::RemoveAgentFlagRequirements,
2420
+ ) -> Vec<&'static str> {
2421
+ remove_agent_required_flags(requirements)
2422
+ .into_iter()
2423
+ .filter(|flag| match *flag {
2424
+ "--from-spec" => !from_spec,
2425
+ "--confirm" => !confirm,
2426
+ "--force" => !force,
2427
+ _ => false,
2428
+ })
2429
+ .collect()
2430
+ }
2431
+
2432
+ fn remove_agent_required_flags(
2433
+ requirements: &crate::lifecycle::RemoveAgentFlagRequirements,
2434
+ ) -> Vec<&'static str> {
2435
+ let mut flags = Vec::new();
2436
+ if requirements.from_spec_required {
2437
+ flags.push("--from-spec");
2438
+ }
2439
+ flags.push("--confirm");
2440
+ if requirements.force_required {
2441
+ flags.push("--force");
2442
+ }
2443
+ flags
2444
+ }
2445
+
2446
+ fn remove_agent_command(
2447
+ workspace: &Path,
2448
+ agent: &str,
2449
+ team: Option<&str>,
2450
+ required_flags: &[&str],
2451
+ ) -> String {
2452
+ let mut parts = vec![
2453
+ "team-agent".to_string(),
2454
+ "remove-agent".to_string(),
2455
+ shell_arg(agent),
2456
+ "--workspace".to_string(),
2457
+ shell_arg(&workspace.to_string_lossy()),
2458
+ ];
2459
+ if let Some(team) = team {
2460
+ parts.push("--team".to_string());
2461
+ parts.push(shell_arg(team));
2462
+ }
2463
+ parts.extend(required_flags.iter().map(|flag| (*flag).to_string()));
2464
+ parts.join(" ")
2465
+ }
2466
+
2467
+ fn shell_arg(raw: &str) -> String {
2468
+ if !raw.is_empty()
2469
+ && raw
2470
+ .bytes()
2471
+ .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'.' | b'_' | b'-' | b':'))
2472
+ {
2473
+ raw.to_string()
2474
+ } else {
2475
+ format!("'{}'", raw.replace('\'', "'\\''"))
2476
+ }
2477
+ }
2349
2478
  /// `runtime.acknowledge_idle`(`cmd_acknowledge_idle`)。
2350
2479
  pub fn acknowledge_idle(workspace: &Path, team: Option<&str>) -> Result<Value, CliError> {
2351
2480
  let mut state = crate::state::persist::load_runtime_state(workspace)
@@ -531,6 +531,55 @@ fn resolve_bare_agent(
531
531
  }
532
532
  }
533
533
 
534
+ /// Phase-DX E3 (plan §4 / CR supplements A/B/C, P0 red lines #1 & #6):
535
+ /// build advisory diagnostic candidates for a failed `--to-name leader`
536
+ /// resolution. The candidate list is deliberately shaped to be **read by
537
+ /// humans**, not consumed as route authority: every entry carries
538
+ /// `advisory: true` and a `source` tag identifying where it was observed.
539
+ ///
540
+ /// Candidate source rules:
541
+ /// - The transport passed in is already scoped by [`transport_for_cli_target`]
542
+ /// to the team's recorded `leader_receiver.tmux_socket` (or the workspace
543
+ /// default when no socket was recorded), so `transport.list_targets()`
544
+ /// enumerates only that endpoint. We do NOT scan other tmux sockets or
545
+ /// fall back to reverse-authority tmux-session discovery.
546
+ /// - `pane_id` fields are copied verbatim from `list_targets`. `session` is
547
+ /// the tmux session, and `socket` records the tmux endpoint the candidate
548
+ /// was seen on (whichever the scoped transport reports).
549
+ ///
550
+ /// Callers must never treat any candidate as authoritative; MUST-NOT-12 stays
551
+ /// intact — resolver refusal is still the outcome even when candidates fire.
552
+ fn leader_advisory_candidates(
553
+ state: &Value,
554
+ team: &str,
555
+ transport: &dyn Transport,
556
+ source: &str,
557
+ ) -> Vec<Value> {
558
+ let socket = team_entry(state, team)
559
+ .and_then(|entry| entry.get("leader_receiver"))
560
+ .or_else(|| state.get("leader_receiver"))
561
+ .and_then(|receiver| string_field(receiver, "tmux_socket"))
562
+ .map(str::to_string)
563
+ .or_else(|| transport.tmux_endpoint());
564
+ let targets = match transport.list_targets() {
565
+ Ok(targets) => targets,
566
+ Err(_) => return Vec::new(),
567
+ };
568
+ targets
569
+ .into_iter()
570
+ .map(|pane| {
571
+ json!({
572
+ "pane_id": pane.pane_id.as_str(),
573
+ "session": pane.session.as_str(),
574
+ "window": pane.window_name.as_ref().map(|w| w.as_str()),
575
+ "socket": socket,
576
+ "source": source,
577
+ "advisory": json!(true),
578
+ })
579
+ })
580
+ .collect()
581
+ }
582
+
534
583
  fn resolve_leader(
535
584
  sender_workspace: &Path,
536
585
  target_workspace: &Path,
@@ -544,7 +593,12 @@ fn resolve_leader(
544
593
  } else {
545
594
  state.get("leader_receiver")
546
595
  }
547
- .ok_or_else(|| name_not_live_leader(team, None, None, None, transport.tmux_endpoint()))?;
596
+ .ok_or_else(|| {
597
+ let mut err = name_not_live_leader(team, None, None, None, transport.tmux_endpoint());
598
+ err.candidates =
599
+ leader_advisory_candidates(state, team, transport, "state_recorded_socket");
600
+ err
601
+ })?;
548
602
  if let Some((mode, transport_kind)) = receiver_transport_conflict(receiver) {
549
603
  let mut err = NamedAddressError::new(
550
604
  NamedAddressErrorKind::NameNotLive,
@@ -569,7 +623,13 @@ fn resolve_leader(
569
623
  }
570
624
  let pane_id = string_field(receiver, "pane_id")
571
625
  .filter(|pane| !pane.is_empty())
572
- .ok_or_else(|| name_not_live_leader(team, None, None, None, transport.tmux_endpoint()))?;
626
+ .ok_or_else(|| {
627
+ let mut err =
628
+ name_not_live_leader(team, None, None, None, transport.tmux_endpoint());
629
+ err.candidates =
630
+ leader_advisory_candidates(state, team, transport, "state_recorded_socket");
631
+ err
632
+ })?;
573
633
  let session = string_field(receiver, "session_name");
574
634
  let window = string_field(receiver, "window_name");
575
635
  let socket = string_field(receiver, "tmux_socket")
@@ -599,13 +659,13 @@ fn resolve_leader(
599
659
  agent_status: None,
600
660
  warning: None,
601
661
  }),
602
- 0 => Err(name_not_live_leader(
603
- team,
604
- Some(pane_id),
605
- session,
606
- window,
607
- socket,
608
- )),
662
+ 0 => {
663
+ let mut err =
664
+ name_not_live_leader(team, Some(pane_id), session, window, socket);
665
+ err.candidates =
666
+ leader_advisory_candidates(state, team, transport, "state_recorded_socket");
667
+ Err(err)
668
+ }
609
669
  _ => Err(name_ambiguous(
610
670
  "multiple live panes matched leader_receiver pane id",
611
671
  matches
@@ -39,7 +39,7 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
39
39
  args.json,
40
40
  )?;
41
41
  add_send_reminder_if_ok(&mut value);
42
- return Ok(CmdResult::from_json(value, args.json));
42
+ return Ok(cmd_send_result(value, args.json));
43
43
  }
44
44
  // F1 (0.3.26, cross-team send): --pane <pane_id> direct targeting.
45
45
  // Mutually exclusive with target / --to (agent-name routing).
@@ -68,7 +68,7 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
68
68
  args.json,
69
69
  )?;
70
70
  add_send_reminder_if_ok(&mut value);
71
- return Ok(CmdResult::from_json(value, args.json));
71
+ return Ok(cmd_send_result(value, args.json));
72
72
  }
73
73
  let selected = crate::state::selector::resolve_active_team(
74
74
  &args.workspace,
@@ -88,7 +88,7 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
88
88
  if let Some(amb) =
89
89
  routing_ambiguous_value(&selected.run_workspace, args, &target, &content, &opts)
90
90
  {
91
- return Ok(CmdResult::from_json(amb, args.json));
91
+ return Ok(cmd_send_result(amb, args.json));
92
92
  }
93
93
  let mut outcome = messaging::send_message(&selected.run_workspace, &target, &content, &opts)?;
94
94
  if opts.watch_result {
@@ -101,7 +101,7 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
101
101
  }
102
102
  }
103
103
  add_send_reminder_if_ok(&mut value);
104
- Ok(CmdResult::from_json(value, args.json))
104
+ Ok(cmd_send_result(value, args.json))
105
105
  }
106
106
 
107
107
  /// F1 (0.3.26): direct pane-id send — bypasses agent-name routing + team
@@ -752,8 +752,98 @@ fn add_send_reminder_if_ok(value: &mut Value) {
752
752
  if value.get("ok").and_then(Value::as_bool) != Some(true) {
753
753
  return;
754
754
  }
755
+ let reminder = send_reminder_for_value(value);
755
756
  if let Some(obj) = value.as_object_mut() {
756
- obj.insert("reminder".to_string(), json!(crate::cli::SEND_REMINDER));
757
+ obj.insert("reminder".to_string(), json!(reminder));
758
+ }
759
+ }
760
+
761
+ fn cmd_send_result(value: Value, as_json: bool) -> CmdResult {
762
+ let exit = if value.get("ok").and_then(Value::as_bool) == Some(false) {
763
+ ExitCode::Error
764
+ } else {
765
+ ExitCode::Ok
766
+ };
767
+ if as_json {
768
+ CmdResult::from_json(value, true)
769
+ } else {
770
+ CmdResult {
771
+ output: CmdOutput::Human(send_human_output(&value)),
772
+ exit,
773
+ as_json: false,
774
+ }
775
+ }
776
+ }
777
+
778
+ fn send_human_output(value: &Value) -> String {
779
+ let mut parts = vec![
780
+ send_human_field(value, "ok"),
781
+ format!("status: {}", send_human_status(value)),
782
+ send_human_field(value, "message_id"),
783
+ format!("target: {}", send_human_target(value)),
784
+ ];
785
+ for key in ["verification", "stage", "reason", "channel"] {
786
+ if !value.get(key).is_none_or(Value::is_null) {
787
+ parts.push(send_human_field(value, key));
788
+ }
789
+ }
790
+ parts.join(" ")
791
+ }
792
+
793
+ fn send_human_field(value: &Value, key: &str) -> String {
794
+ let rendered = value
795
+ .get(key)
796
+ .map(send_human_value)
797
+ .unwrap_or_else(|| "None".to_string());
798
+ format!("{key}: {rendered}")
799
+ }
800
+
801
+ fn send_human_target(value: &Value) -> String {
802
+ ["target", "agent_id", "pane_id", "to_name"]
803
+ .iter()
804
+ .find_map(|key| value.get(*key).filter(|v| !v.is_null()))
805
+ .map(send_human_value)
806
+ .unwrap_or_else(|| "None".to_string())
807
+ }
808
+
809
+ fn send_human_status(value: &Value) -> String {
810
+ value
811
+ .get("status")
812
+ .map(send_human_value)
813
+ .unwrap_or_else(|| {
814
+ if value.get("ok").and_then(Value::as_bool) == Some(true) {
815
+ "delivered".to_string()
816
+ } else {
817
+ "failed".to_string()
818
+ }
819
+ })
820
+ }
821
+
822
+ fn send_human_value(value: &Value) -> String {
823
+ let text = match value {
824
+ Value::Null => "None".to_string(),
825
+ Value::Bool(true) => "True".to_string(),
826
+ Value::Bool(false) => "False".to_string(),
827
+ Value::Number(n) => n.to_string(),
828
+ Value::String(s) => s.clone(),
829
+ Value::Array(_) | Value::Object(_) => {
830
+ serde_json::to_string(value).unwrap_or_else(|_| "None".to_string())
831
+ }
832
+ };
833
+ text.replace(['\r', '\n'], " ")
834
+ }
835
+
836
+ fn send_reminder_for_value(value: &Value) -> &'static str {
837
+ let delivered = value.get("delivered").and_then(Value::as_bool);
838
+ let status = value.get("status").and_then(Value::as_str);
839
+ let delivery_status = value.get("delivery_status").and_then(Value::as_str);
840
+ if delivered == Some(false)
841
+ || matches!(status, Some("queued"))
842
+ || matches!(delivery_status, Some("pending"))
843
+ {
844
+ "Message queued; coordinator will notify when the worker receives it. Do not poll the worker terminal with capture-pane."
845
+ } else {
846
+ crate::cli::SEND_REMINDER
757
847
  }
758
848
  }
759
849
 
@@ -38,7 +38,9 @@ use rusqlite::params;
38
38
  coordinator_running,
39
39
  undelivered_backlog,
40
40
  );
41
- let agents = enrich_agents(state.get("agents"));
41
+ let session_name = state.get("session_name").cloned().unwrap_or(Value::Null);
42
+ let tmux_present = tmux_session_present(workspace, state, session_name.as_str());
43
+ let agents = enrich_agents(state.get("agents"), tmux_present);
42
44
  let tasks = state
43
45
  .get("tasks")
44
46
  .cloned()
@@ -47,7 +49,6 @@ use rusqlite::params;
47
49
  .get("leader_receiver")
48
50
  .cloned()
49
51
  .unwrap_or_else(|| json!({}));
50
- let session_name = state.get("session_name").cloned().unwrap_or(Value::Null);
51
52
  let is_external_leader = crate::state::projection::state_is_external_leader(state);
52
53
  let leader_topology = if is_external_leader { "external" } else { "managed" };
53
54
  let leader_attach_command = if is_external_leader {
@@ -70,7 +71,6 @@ use rusqlite::params;
70
71
  )
71
72
  })
72
73
  };
73
- let tmux_present = tmux_session_present(workspace, state, session_name.as_str());
74
74
  let mut readiness_state = state.clone();
75
75
  if let Some(obj) = readiness_state.as_object_mut() {
76
76
  obj.insert("tmux_session_present".to_string(), serde_json::json!(tmux_present));
@@ -437,7 +437,7 @@ use rusqlite::params;
437
437
  .to_string()
438
438
  }
439
439
 
440
- fn enrich_agents(agents: Option<&Value>) -> Value {
440
+ fn enrich_agents(agents: Option<&Value>, tmux_session_present: bool) -> Value {
441
441
  let Some(Value::Object(input)) = agents else {
442
442
  return json!({});
443
443
  };
@@ -450,6 +450,15 @@ use rusqlite::params;
450
450
  "interacted".to_string(),
451
451
  Value::String(interacted_marker(obj.get("first_send_at"))),
452
452
  );
453
+ if let Some(reason) =
454
+ stale_reason_for_agent(&Value::Object(obj.clone()), tmux_session_present)
455
+ {
456
+ enriched.insert("stale".to_string(), Value::Bool(true));
457
+ enriched.insert(
458
+ "stale_reason".to_string(),
459
+ Value::String(reason.to_string()),
460
+ );
461
+ }
453
462
  out.insert(agent_id.clone(), Value::Object(enriched));
454
463
  }
455
464
  _ => {
@@ -460,6 +469,53 @@ use rusqlite::params;
460
469
  Value::Object(out)
461
470
  }
462
471
 
472
+ fn stale_reason_for_agent(agent: &Value, tmux_session_present: bool) -> Option<&'static str> {
473
+ let pane_dead = !tmux_session_present && agent_has_pane_fact(agent);
474
+ let process_dead =
475
+ agent_process_dead(agent) || (!tmux_session_present && agent_has_process_fact(agent));
476
+ match (pane_dead, process_dead) {
477
+ (true, true) => Some("both"),
478
+ (false, true) => Some("process_dead"),
479
+ (true, false) => Some("pane_dead"),
480
+ (false, false) => None,
481
+ }
482
+ }
483
+
484
+ fn agent_has_pane_fact(agent: &Value) -> bool {
485
+ ["pane_id", "window", "window_name"].iter().any(|key| {
486
+ agent
487
+ .get(*key)
488
+ .and_then(Value::as_str)
489
+ .is_some_and(|value| !value.is_empty())
490
+ })
491
+ }
492
+
493
+ fn agent_has_process_fact(agent: &Value) -> bool {
494
+ agent.get("pid").and_then(Value::as_i64).is_some()
495
+ || agent.get("process_started").and_then(Value::as_bool) == Some(true)
496
+ || agent.get("provider_process_dead").and_then(Value::as_bool).is_some()
497
+ || agent.get("process_liveness").and_then(Value::as_str).is_some()
498
+ }
499
+
500
+ fn agent_process_dead(agent: &Value) -> bool {
501
+ if agent.get("provider_process_dead").and_then(Value::as_bool) == Some(true) {
502
+ return true;
503
+ }
504
+ ["process_liveness", "worker_state"].iter().any(|key| {
505
+ agent
506
+ .get(*key)
507
+ .and_then(Value::as_str)
508
+ .is_some_and(is_dead_process_state)
509
+ })
510
+ }
511
+
512
+ fn is_dead_process_state(value: &str) -> bool {
513
+ matches!(
514
+ value,
515
+ "dead" | "missing" | "stopped" | "exited" | "terminated"
516
+ )
517
+ }
518
+
463
519
  fn interacted_marker(value: Option<&Value>) -> String {
464
520
  let Some(raw) = value.and_then(Value::as_str) else {
465
521
  return "never".to_string();
@@ -846,7 +902,15 @@ use rusqlite::params;
846
902
  // 0.4.x Phase 1: add `worker_state` (canonical 5-state product
847
903
  // surface). `activity` is preserved alongside as the deprecated
848
904
  // legacy classifier output (CR R3 same-source contract).
849
- for key in ["status", "provider", "worker_state", "activity", "last_output_at"] {
905
+ for key in [
906
+ "status",
907
+ "provider",
908
+ "worker_state",
909
+ "activity",
910
+ "last_output_at",
911
+ "stale",
912
+ "stale_reason",
913
+ ] {
850
914
  if let Some(value) = input.get(key) {
851
915
  out.insert(key.to_string(), value.clone());
852
916
  }
@@ -964,10 +1028,35 @@ use rusqlite::params;
964
1028
  );
965
1029
  insert_optional_string(&mut item, "last_output_at", row.get(2).map_err(|e| CliError::Runtime(e.to_string()))?);
966
1030
  insert_optional_i64(&mut item, "context_usage_pct", row.get(3).map_err(|e| CliError::Runtime(e.to_string()))?);
967
- insert_optional_string(&mut item, "current_task_id", row.get(4).map_err(|e| CliError::Runtime(e.to_string()))?);
1031
+ let current_task_id: Option<String> =
1032
+ row.get(4).map_err(|e| CliError::Runtime(e.to_string()))?;
1033
+ let has_current_task = current_task_id.is_some();
1034
+ insert_optional_string(&mut item, "current_task_id", current_task_id);
1035
+ let updated_at: String =
1036
+ row.get(5).map_err(|e| CliError::Runtime(e.to_string()))?;
1037
+ // Phase-DX E2 (plan §4 / CR supplement A): expose the last agent_health
1038
+ // observation timestamp as `health_updated_at` alongside the legacy
1039
+ // `updated_at` alias. Two names for one column keep old scrapers working
1040
+ // while surfacing the semantic (heartbeat, not row bookkeeping).
1041
+ item.insert("updated_at".to_string(), json!(updated_at.clone()));
1042
+ item.insert("health_updated_at".to_string(), json!(updated_at));
1043
+ // Phase-DX E2 (CR P0 red line #6, supplements A/B): current_task is a
1044
+ // best-effort *display* field until A1 makes task FSM authoritative.
1045
+ // The structured source/confidence markers stop downstream code from
1046
+ // treating agent_health.current_task_id as authority. `current_task_source`
1047
+ // records where the display value came from (only "health" today —
1048
+ // Phase-DX never merges state tasks into this projection); the
1049
+ // `current_task_confidence` enum stays "best_effort" for the whole
1050
+ // Phase-DX slice (A1 will later flip it to "authoritative" when the
1051
+ // task FSM lands). Field is written unconditionally so consumers can
1052
+ // switch on it even when `current_task_id` is null.
1053
+ item.insert(
1054
+ "current_task_source".to_string(),
1055
+ json!(if has_current_task { "health" } else { "none" }),
1056
+ );
968
1057
  item.insert(
969
- "updated_at".to_string(),
970
- json!(row.get::<_, String>(5).map_err(|e| CliError::Runtime(e.to_string()))?),
1058
+ "current_task_confidence".to_string(),
1059
+ json!("best_effort"),
971
1060
  );
972
1061
  insert_optional_string(&mut item, "owner_team_id", row.get(6).map_err(|e| CliError::Runtime(e.to_string()))?);
973
1062
  out.insert(agent_id, Value::Object(item));