@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.
@@ -493,6 +493,60 @@ fn seed_team_spec(ws: &std::path::Path) {
493
493
  .unwrap();
494
494
  }
495
495
  #[test]
496
+ fn remove_agent_spec_running_refusal_lists_all_required_flags_once() {
497
+ let ws = tmp_workspace();
498
+ seed_remove_agent_workspace(&ws, "running");
499
+ let out = json_output(
500
+ cmd_remove_agent(&RemoveAgentArgs {
501
+ agent: "fake_impl".to_string(),
502
+ workspace: ws.clone(),
503
+ team: None,
504
+ from_spec: false,
505
+ confirm: false,
506
+ force: false,
507
+ json: true,
508
+ })
509
+ .unwrap(),
510
+ );
511
+ assert_eq!(out["ok"], json!(false));
512
+ assert_eq!(out["status"], json!("refused"));
513
+ assert_eq!(out["reason"], json!("remove_agent_flags_required"));
514
+ for flag in ["--from-spec", "--confirm", "--force"] {
515
+ assert!(
516
+ out["error"].as_str().is_some_and(|s| s.contains(flag))
517
+ && out["action"].as_str().is_some_and(|s| s.contains(flag))
518
+ && out["command"].as_str().is_some_and(|s| s.contains(flag)),
519
+ "refusal must mention {flag} in error/action/copyable command; got {out}"
520
+ );
521
+ }
522
+ let with_confirm = json_output(
523
+ cmd_remove_agent(&RemoveAgentArgs {
524
+ agent: "fake_impl".to_string(),
525
+ workspace: ws.clone(),
526
+ team: None,
527
+ from_spec: false,
528
+ confirm: true,
529
+ force: false,
530
+ json: true,
531
+ })
532
+ .unwrap(),
533
+ );
534
+ assert_eq!(with_confirm["reason"], json!("remove_agent_flags_required"));
535
+ for flag in ["--from-spec", "--confirm", "--force"] {
536
+ assert!(
537
+ with_confirm["error"].as_str().is_some_and(|s| s.contains(flag))
538
+ && with_confirm["action"].as_str().is_some_and(|s| s.contains(flag))
539
+ && with_confirm["command"].as_str().is_some_and(|s| s.contains(flag)),
540
+ "refusal with --confirm must still give the full command including {flag}; got {with_confirm}"
541
+ );
542
+ }
543
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
544
+ assert!(
545
+ state["agents"].get("fake_impl").is_some(),
546
+ "refused remove-agent must not delete the spec-defined running agent"
547
+ );
548
+ }
549
+ #[test]
496
550
  fn remove_agent_running_refusal_is_not_success_envelope() {
497
551
  let ws = tmp_workspace();
498
552
  seed_remove_agent_workspace(&ws, "running");
@@ -451,6 +451,28 @@ use super::*;
451
451
  }
452
452
  }
453
453
 
454
+ fn queued_send_args_fixture(json: bool) -> SendArgs {
455
+ let ws = deleg_uniq_dir("send-human");
456
+ let _ = crate::message_store::MessageStore::open(&ws).unwrap();
457
+ crate::state::persist::save_runtime_state(
458
+ &ws,
459
+ &json!({
460
+ "active_team_key": "current",
461
+ "teams": {"current": {"agents": {"w1": {"provider": "codex"}}}}
462
+ }),
463
+ )
464
+ .unwrap();
465
+ SendArgs {
466
+ workspace: ws,
467
+ target: Some("w1".into()),
468
+ team: None,
469
+ task: None,
470
+ watch_result: false,
471
+ json,
472
+ ..send_args_fixture()
473
+ }
474
+ }
475
+
454
476
  #[test]
455
477
  fn send_options_negates_no_ack_and_no_wait_and_carries_watch() {
456
478
  // golden (commands.py:172,174,176): requires_ack=not no_ack; wait_visible=not no_wait;
@@ -494,7 +516,11 @@ use super::*;
494
516
  fn cmd_send_joins_message_with_single_space() {
495
517
  // golden (commands.py:169): " ".join(["hello","world","foo"]) == "hello world foo".
496
518
  // Drive cmd_send; the joined content must reach send_message (RED until ported).
497
- let r = cmd_send(&send_args_fixture()).expect("cmd_send returns CmdResult");
519
+ let args = SendArgs {
520
+ json: true,
521
+ ..send_args_fixture()
522
+ };
523
+ let r = cmd_send(&args).expect("cmd_send returns CmdResult");
498
524
  // The delegate's DeliveryOutcome -> Json must carry an `ok` key feeding exit-code.
499
525
  match r.output {
500
526
  CmdOutput::Json(ref v) => {
@@ -510,11 +536,92 @@ use super::*;
510
536
  }
511
537
  }
512
538
 
539
+ #[test]
540
+ fn cmd_send_default_human_output_is_one_line_without_false_delivered() {
541
+ let r = cmd_send(&queued_send_args_fixture(false)).expect("cmd_send returns CmdResult");
542
+ assert!(!r.as_json);
543
+ let text = emit(&r.output, r.as_json).expect("send should render human text");
544
+ let lines: Vec<_> = text.lines().collect();
545
+ assert_eq!(lines.len(), 1, "default send output must be one line: {text}");
546
+ assert!(
547
+ lines[0].contains("ok:")
548
+ && lines[0].contains("status:")
549
+ && lines[0].contains("message_id:")
550
+ && lines[0].contains("target:"),
551
+ "default send output must keep only the core fields; got {text}"
552
+ );
553
+ assert!(
554
+ !text.contains("delivered"),
555
+ "queued send output must not claim or mention delivered; got {text}"
556
+ );
557
+ for hidden in [
558
+ "agent_id:",
559
+ "sender:",
560
+ "message_status:",
561
+ "verification:",
562
+ "stage:",
563
+ "reason:",
564
+ "channel:",
565
+ "reminder:",
566
+ ] {
567
+ assert!(
568
+ !text.contains(hidden),
569
+ "default send output should hide {hidden} unless needed; got {text}"
570
+ );
571
+ }
572
+ }
573
+
574
+ #[test]
575
+ fn cmd_send_json_shape_keeps_056_fields() {
576
+ let args = queued_send_args_fixture(true);
577
+ let r = cmd_send(&args).expect("cmd_send returns CmdResult");
578
+ let v = match r.output {
579
+ CmdOutput::Json(v) => v,
580
+ other => panic!("--json send must emit Json, got {other:?}"),
581
+ };
582
+ let obj = v.as_object().expect("--json send output must be object");
583
+ for key in [
584
+ "ok",
585
+ "status",
586
+ "delivery_status",
587
+ "delivered",
588
+ "target",
589
+ "agent_id",
590
+ "content_length_bytes",
591
+ "sender",
592
+ "message_id",
593
+ "message_status",
594
+ "verification",
595
+ "stage",
596
+ "reason",
597
+ "channel",
598
+ "reminder",
599
+ ] {
600
+ assert!(obj.contains_key(key), "--json send shape lost {key}: {v}");
601
+ }
602
+ assert_eq!(v.get("verification"), Some(&serde_json::Value::Null));
603
+ assert_eq!(v.get("stage"), Some(&serde_json::Value::Null));
604
+ assert_eq!(v.get("reason"), Some(&serde_json::Value::Null));
605
+ assert_eq!(v.get("channel"), Some(&serde_json::Value::Null));
606
+ assert_eq!(v.get("delivered").and_then(|d| d.as_bool()), Some(false));
607
+ assert!(
608
+ !v.get("reminder")
609
+ .and_then(|reminder| reminder.as_str())
610
+ .unwrap_or_default()
611
+ .contains("Message delivered."),
612
+ "queued JSON reminder must not contradict delivered:false: {v}"
613
+ );
614
+ }
615
+
513
616
  #[test]
514
617
  fn cmd_send_watch_result_does_not_register_before_delivery() {
515
618
  // 0.5.x send contract: --watch-result may only advertise a watcher after
516
619
  // initial worker delivery is physically proven.
517
- let r = cmd_send(&send_args_fixture()).expect("cmd_send returns CmdResult");
620
+ let args = SendArgs {
621
+ json: true,
622
+ ..send_args_fixture()
623
+ };
624
+ let r = cmd_send(&args).expect("cmd_send returns CmdResult");
518
625
  let v = match r.output {
519
626
  CmdOutput::Json(v) => v,
520
627
  other => panic!("expected Json, got {other:?}"),
@@ -2366,8 +2366,14 @@ fn write_agent_health(
2366
2366
  .get("context_usage_pct")
2367
2367
  .or_else(|| agent.get("context_usage_percent"))
2368
2368
  .and_then(Value::as_i64);
2369
+ // Phase-DX E2: read the renamed `current_turn_message_id` (leader→worker turn
2370
+ // proxy, written by delivery::arm_turn_open) with fallbacks to the legacy field
2371
+ // names for backwards state compatibility. The SQL column stays `current_task_id`
2372
+ // (agent_health schema) — a rename would require a DB migration, which Phase-DX
2373
+ // forbids.
2369
2374
  let current_task_id = agent
2370
- .get("current_task_id")
2375
+ .get("current_turn_message_id")
2376
+ .or_else(|| agent.get("current_task_id"))
2371
2377
  .or_else(|| agent.get("task_id"))
2372
2378
  .and_then(Value::as_str);
2373
2379
  conn.execute(
@@ -0,0 +1,96 @@
1
+ //! Phase-DX E2: `agent_health` row capture/restore for the remove-agent flow.
2
+ //!
3
+ //! Extracted from `lifecycle/restart/remove.rs` so the SQL column reference to
4
+ //! `current_task_id` sits in the persistence layer (whitelisted by the E2 grep
5
+ //! guard) rather than in lifecycle policy code. Semantics are the golden Python
6
+ //! `_capture_agent_health` / `_restore_agent_health` — a plain
7
+ //! backup-across-delete that never treats the stored column as authoritative
8
+ //! task state.
9
+
10
+ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
11
+
12
+ use std::path::Path;
13
+
14
+ use crate::model::ids::AgentId;
15
+
16
+ #[derive(Clone, Debug)]
17
+ pub struct CapturedHealth {
18
+ pub owner_team_id: Option<String>,
19
+ pub status: Option<String>,
20
+ pub last_output_at: Option<String>,
21
+ pub context_usage_pct: Option<i64>,
22
+ pub current_task_id: Option<String>,
23
+ }
24
+
25
+ /// golden agents.py:185 `copy.deepcopy(store.agent_health().get(agent_id))` — read the row BEFORE
26
+ /// delete so the rollback can re-upsert it. Returns the captured columns, or `None` if the row is
27
+ /// absent.
28
+ pub fn select_agent_health(
29
+ workspace: &Path,
30
+ agent_id: &AgentId,
31
+ ) -> Result<Option<CapturedHealth>, crate::db::DbError> {
32
+ let store = crate::message_store::MessageStore::open(workspace)
33
+ .map_err(|e| crate::db::DbError::Schema(e.to_string()))?;
34
+ let conn = crate::db::schema::open_db(store.db_path())?;
35
+ let row = conn
36
+ .query_row(
37
+ "select owner_team_id, status, last_output_at, context_usage_pct, current_task_id \
38
+ from agent_health where agent_id = ?1",
39
+ [agent_id.as_str()],
40
+ |r| {
41
+ Ok(CapturedHealth {
42
+ owner_team_id: r.get::<_, Option<String>>(0)?,
43
+ status: r.get::<_, Option<String>>(1)?,
44
+ last_output_at: r.get::<_, Option<String>>(2)?,
45
+ context_usage_pct: r.get::<_, Option<i64>>(3)?,
46
+ current_task_id: r.get::<_, Option<String>>(4)?,
47
+ })
48
+ },
49
+ )
50
+ .ok();
51
+ Ok(row)
52
+ }
53
+
54
+ /// golden agents.py:268-278 `_restore_agent_health`: re-upsert the captured row (status||"IDLE"),
55
+ /// or delete the row when there was nothing to restore.
56
+ pub fn restore_agent_health(
57
+ workspace: &Path,
58
+ agent_id: &AgentId,
59
+ row: &Option<CapturedHealth>,
60
+ ) -> Result<(), crate::db::DbError> {
61
+ let Some(row) = row else {
62
+ return delete_agent_health(workspace, agent_id);
63
+ };
64
+ let store = crate::message_store::MessageStore::open(workspace)
65
+ .map_err(|e| crate::db::DbError::Schema(e.to_string()))?;
66
+ let conn = crate::db::schema::open_db(store.db_path())?;
67
+ let status = row.status.clone().unwrap_or_else(|| "IDLE".to_string());
68
+ let now = chrono::Utc::now()
69
+ .format("%Y-%m-%dT%H:%M:%S%.6f+00:00")
70
+ .to_string();
71
+ conn.execute(
72
+ "insert into agent_health (owner_team_id, agent_id, status, last_output_at, context_usage_pct, current_task_id, updated_at) \
73
+ values (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
74
+ rusqlite::params![
75
+ row.owner_team_id,
76
+ agent_id.as_str(),
77
+ status,
78
+ row.last_output_at,
79
+ row.context_usage_pct,
80
+ row.current_task_id,
81
+ now,
82
+ ],
83
+ )?;
84
+ Ok(())
85
+ }
86
+
87
+ fn delete_agent_health(workspace: &Path, agent_id: &AgentId) -> Result<(), crate::db::DbError> {
88
+ let store = crate::message_store::MessageStore::open(workspace)
89
+ .map_err(|e| crate::db::DbError::Schema(e.to_string()))?;
90
+ let conn = crate::db::schema::open_db(store.db_path())?;
91
+ conn.execute(
92
+ "delete from agent_health where agent_id = ?1",
93
+ [agent_id.as_str()],
94
+ )?;
95
+ Ok(())
96
+ }
@@ -9,6 +9,7 @@
9
9
  //! §10:db 层无 unwrap/expect/panic;rusqlite 错误经 [`DbError`] 传播。
10
10
  #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
11
11
 
12
+ pub mod agent_health_capture;
12
13
  pub mod message_store;
13
14
  pub mod migration;
14
15
  pub mod schema;
@@ -60,15 +60,37 @@ pub fn remove_agent_with_transport(
60
60
  )
61
61
  }
62
62
 
63
- fn remove_agent_at_paths(
63
+ pub fn remove_agent_flag_requirements(
64
+ workspace: &Path,
65
+ agent_id: &AgentId,
66
+ team: Option<&str>,
67
+ ) -> Result<RemoveAgentFlagRequirements, LifecycleError> {
68
+ let paths = lifecycle_paths(workspace, team)?;
69
+ let transport =
70
+ lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
71
+ Ok(remove_agent_preflight(
72
+ &paths.run_workspace,
73
+ &paths.spec_workspace,
74
+ agent_id,
75
+ team,
76
+ &transport,
77
+ )?
78
+ .requirements)
79
+ }
80
+
81
+ struct RemoveAgentPreflight {
82
+ state: serde_json::Value,
83
+ spec: YamlValue,
84
+ requirements: RemoveAgentFlagRequirements,
85
+ }
86
+
87
+ fn remove_agent_preflight(
64
88
  workspace: &Path,
65
89
  spec_workspace: &Path,
66
90
  agent_id: &AgentId,
67
- from_spec: bool,
68
- force: bool,
69
91
  team: Option<&str>,
70
92
  transport: &dyn crate::transport::Transport,
71
- ) -> Result<RemoveAgentOutcome, LifecycleError> {
93
+ ) -> Result<RemoveAgentPreflight, LifecycleError> {
72
94
  // golden agents.py:34-41: resolve_team_scoped_state FIRST (surfaces the team_target_ambiguous /
73
95
  // team_target_unresolved refusal before the owner gate), THEN the owner gate, THEN load_spec +
74
96
  // _find_worker (unknown-worker raise). Mirror the stop/reset wiring so remove is byte-symmetric:
@@ -80,27 +102,84 @@ fn remove_agent_at_paths(
80
102
  return Err(unknown_worker(agent_id));
81
103
  };
82
104
  let dynamic_agent = is_dynamic_agent(&state, spec_agent, agent_id);
83
- if !dynamic_agent && !from_spec {
84
- return Ok(RemoveAgentOutcome::RefusedFromSpecConfirm {
85
- agent_id: agent_id.clone(),
86
- });
87
- }
88
- if agent_is_running(&state, agent_id, transport) && !force {
89
- return Ok(RemoveAgentOutcome::RefusedForceRequired {
105
+ let force_required = agent_is_running(&state, agent_id, transport);
106
+ let has_session = state
107
+ .get("session_name")
108
+ .and_then(|v| v.as_str())
109
+ .is_some_and(|s| !s.is_empty())
110
+ || state
111
+ .get("agents")
112
+ .and_then(|v| v.get(agent_id.as_str()))
113
+ .is_some_and(agent_has_session);
114
+ Ok(RemoveAgentPreflight {
115
+ state,
116
+ spec,
117
+ requirements: RemoveAgentFlagRequirements {
90
118
  agent_id: agent_id.clone(),
119
+ from_spec_required: !dynamic_agent,
120
+ force_required,
121
+ has_session,
122
+ },
123
+ })
124
+ }
125
+
126
+ fn agent_has_session(agent: &serde_json::Value) -> bool {
127
+ ["session_id", "_pending_session_id", "rollout_path"]
128
+ .iter()
129
+ .any(|key| {
130
+ agent
131
+ .get(key)
132
+ .and_then(|v| v.as_str())
133
+ .is_some_and(|s| !s.is_empty())
134
+ })
135
+ }
136
+
137
+ fn remove_agent_at_paths(
138
+ workspace: &Path,
139
+ spec_workspace: &Path,
140
+ agent_id: &AgentId,
141
+ from_spec: bool,
142
+ force: bool,
143
+ team: Option<&str>,
144
+ transport: &dyn crate::transport::Transport,
145
+ ) -> Result<RemoveAgentOutcome, LifecycleError> {
146
+ let preflight = remove_agent_preflight(workspace, spec_workspace, agent_id, team, transport)?;
147
+ let missing_from_spec = preflight.requirements.from_spec_required && !from_spec;
148
+ let missing_force = preflight.requirements.force_required && !force;
149
+ if missing_from_spec || missing_force {
150
+ return Ok(if missing_from_spec && missing_force {
151
+ RemoveAgentOutcome::RefusedRequiredFlags {
152
+ agent_id: agent_id.clone(),
153
+ from_spec_required: true,
154
+ force_required: true,
155
+ }
156
+ } else if missing_from_spec {
157
+ RemoveAgentOutcome::RefusedFromSpecConfirm {
158
+ agent_id: agent_id.clone(),
159
+ }
160
+ } else {
161
+ RemoveAgentOutcome::RefusedForceRequired {
162
+ agent_id: agent_id.clone(),
163
+ }
91
164
  });
92
165
  }
93
166
  let paths = LifecyclePathRefs {
94
167
  run_workspace: workspace,
95
168
  spec_workspace,
96
169
  };
97
- let mut rollback = RemoveRollback::capture(paths.run_workspace, paths.spec_workspace, &spec, &state, agent_id)?;
98
- rollback.restore_running = force && agent_is_running(&state, agent_id, transport);
170
+ let mut rollback = RemoveRollback::capture(
171
+ paths.run_workspace,
172
+ paths.spec_workspace,
173
+ &preflight.spec,
174
+ &preflight.state,
175
+ agent_id,
176
+ )?;
177
+ rollback.restore_running = force && preflight.requirements.force_required;
99
178
  let result = remove_agent_inner(
100
179
  &paths,
101
180
  agent_id,
102
- &spec,
103
- state,
181
+ &preflight.spec,
182
+ preflight.state,
104
183
  force,
105
184
  team,
106
185
  transport,
@@ -591,78 +670,30 @@ fn delete_agent_health(workspace: &Path, agent_id: &AgentId) -> Result<bool, Lif
591
670
  Ok(changed > 0)
592
671
  }
593
672
 
594
- /// golden agents.py:185 `copy.deepcopy(store.agent_health().get(agent_id))` read the row BEFORE delete
595
- /// so the rollback can re-upsert it. Returns the captured health columns, or None if absent.
673
+ // Phase-DX E2: `select_agent_health` / `restore_agent_health` / `CapturedHealth` moved to
674
+ // `db::agent_health_capture` so the SQL column references (agent_health backup columns)
675
+ // live in the persistence layer (whitelisted by the E2 grep guard) rather than lifecycle
676
+ // policy code. The wrappers below preserve the existing `LifecycleError` surface.
677
+ use crate::db::agent_health_capture::{
678
+ restore_agent_health as capture_restore_agent_health,
679
+ select_agent_health as capture_select_agent_health, CapturedHealth,
680
+ };
681
+
596
682
  fn select_agent_health(
597
683
  workspace: &Path,
598
684
  agent_id: &AgentId,
599
685
  ) -> Result<Option<CapturedHealth>, LifecycleError> {
600
- let store = crate::message_store::MessageStore::open(workspace)
601
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
602
- let conn = crate::db::schema::open_db(store.db_path())
603
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
604
- let row = conn
605
- .query_row(
606
- "select owner_team_id, status, last_output_at, context_usage_pct, current_task_id \
607
- from agent_health where agent_id = ?1",
608
- [agent_id.as_str()],
609
- |r| {
610
- Ok(CapturedHealth {
611
- owner_team_id: r.get::<_, Option<String>>(0)?,
612
- status: r.get::<_, Option<String>>(1)?,
613
- last_output_at: r.get::<_, Option<String>>(2)?,
614
- context_usage_pct: r.get::<_, Option<i64>>(3)?,
615
- current_task_id: r.get::<_, Option<String>>(4)?,
616
- })
617
- },
618
- )
619
- .ok();
620
- Ok(row)
686
+ capture_select_agent_health(workspace, agent_id)
687
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))
621
688
  }
622
689
 
623
- /// golden agents.py:268-278 `_restore_agent_health`: re-upsert the captured row (status||"IDLE"), or
624
- /// delete the row when there was nothing to restore.
625
690
  fn restore_agent_health(
626
691
  workspace: &Path,
627
692
  agent_id: &AgentId,
628
693
  row: &Option<CapturedHealth>,
629
694
  ) -> Result<(), LifecycleError> {
630
- let Some(row) = row else {
631
- delete_agent_health(workspace, agent_id)?;
632
- return Ok(());
633
- };
634
- let store = crate::message_store::MessageStore::open(workspace)
635
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
636
- let conn = crate::db::schema::open_db(store.db_path())
637
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
638
- let status = row.status.clone().unwrap_or_else(|| "IDLE".to_string());
639
- let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6f+00:00").to_string();
640
- // The restore always follows a delete of this row, so a plain insert re-materializes the captured
641
- // health (golden _restore_agent_health re-upserts status||"IDLE" + the captured columns).
642
- conn.execute(
643
- "insert into agent_health (owner_team_id, agent_id, status, last_output_at, context_usage_pct, current_task_id, updated_at) \
644
- values (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
645
- rusqlite::params![
646
- row.owner_team_id,
647
- agent_id.as_str(),
648
- status,
649
- row.last_output_at,
650
- row.context_usage_pct,
651
- row.current_task_id,
652
- now,
653
- ],
654
- )
655
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
656
- Ok(())
657
- }
658
-
659
- #[derive(Clone)]
660
- struct CapturedHealth {
661
- owner_team_id: Option<String>,
662
- status: Option<String>,
663
- last_output_at: Option<String>,
664
- context_usage_pct: Option<i64>,
665
- current_task_id: Option<String>,
695
+ capture_restore_agent_health(workspace, agent_id, row)
696
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))
666
697
  }
667
698
 
668
699
  fn maybe_fail_remove_after_agent_health_delete() -> Result<(), LifecycleError> {
@@ -50,7 +50,7 @@ pub use rebuild::{
50
50
  restart, restart_candidates, restart_with_session_convergence_deadline, restart_with_transport,
51
51
  restart_with_transport_with_readiness_deadline, select_restart_state,
52
52
  };
53
- pub use remove::{remove_agent, remove_agent_with_transport};
53
+ pub use remove::{remove_agent, remove_agent_flag_requirements, remove_agent_with_transport};
54
54
  pub use selection::{
55
55
  classify_first_send_at, classify_restart_plan, decide_start_mode, python_type_name,
56
56
  };
@@ -609,6 +609,15 @@ pub struct ForkAgentReport {
609
609
  pub session_id: Option<SessionId>,
610
610
  }
611
611
 
612
+ /// Read-only remove-agent flag requirements for the target's current state.
613
+ #[derive(Debug, Clone, PartialEq, Eq)]
614
+ pub struct RemoveAgentFlagRequirements {
615
+ pub agent_id: AgentId,
616
+ pub from_spec_required: bool,
617
+ pub force_required: bool,
618
+ pub has_session: bool,
619
+ }
620
+
612
621
  /// `remove_agent(...)` 结果(`agents.py:54/56/150`)。`_RemoveRollback` 快照
613
622
  /// spec/state/team_state/role_file/agent_health 字节级回滚(Gap 16)。
614
623
  #[derive(Debug, Clone, PartialEq, Eq)]
@@ -624,6 +633,12 @@ pub enum RemoveAgentOutcome {
624
633
  RefusedFromSpecConfirm { agent_id: AgentId },
625
634
  /// 运行中未传 force(`agents.py:56`)。
626
635
  RefusedForceRequired { agent_id: AgentId },
636
+ /// Multiple required destructive flags are absent; report them together.
637
+ RefusedRequiredFlags {
638
+ agent_id: AgentId,
639
+ from_spec_required: bool,
640
+ force_required: bool,
641
+ },
627
642
  }
628
643
 
629
644
  /// `restart(...)` 结果(`orchestration.py:114/142/387`)。Route B:**先全量验证**
@@ -336,10 +336,27 @@ fn current_turn_id_from_state(state: &Value, agent_id: &str) -> Option<String> {
336
336
  }
337
337
  }
338
338
  }
339
+ // Phase-DX E2: read the renamed `current_turn_message_id` (leader→worker turn
340
+ // proxy from `delivery::arm_turn_open`). Legacy state written by 0.5.x may
341
+ // still carry the pre-rename JSON key; the fallback keeps current-turn
342
+ // attribution stable during the transition. Neither field is treated as
343
+ // authoritative task state — that stays A1 territory (task FSM).
344
+ //
345
+ // The next line reads the pre-rename JSON key verbatim; that is deliberate
346
+ // and marked so the E2 grep guard admits it as a documented exception. The
347
+ // marker distinguishes a legitimate read-only backwards-compat bridge from
348
+ // an authority-consuming read (which the guard still forbids). Delete this
349
+ // fallback and its marker when the A1 task FSM lands (task attribution
350
+ // becomes authoritative and legacy state layouts are no longer produced).
351
+ let legacy_field = "current_task_id"; // ALLOWED-LEGACY-READ: backward-compat bridge for pre-rename key
339
352
  state
340
353
  .get("agents")
341
354
  .and_then(|agents| agents.get(agent_id))
342
- .and_then(|agent| agent.get("current_task_id"))
355
+ .and_then(|agent| {
356
+ agent
357
+ .get("current_turn_message_id")
358
+ .or_else(|| agent.get(legacy_field))
359
+ })
343
360
  .and_then(Value::as_str)
344
361
  .and_then(non_empty_string)
345
362
  .map(ToString::to_string)