@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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/diagnose.rs +46 -17
- package/crates/team-agent/src/cli/mod.rs +130 -1
- package/crates/team-agent/src/cli/named_address.rs +69 -9
- package/crates/team-agent/src/cli/send.rs +95 -5
- package/crates/team-agent/src/cli/status_port.rs +97 -8
- package/crates/team-agent/src/cli/tests/main_preserved.rs +54 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +109 -2
- package/crates/team-agent/src/coordinator/tick.rs +7 -1
- package/crates/team-agent/src/db/agent_health_capture.rs +96 -0
- package/crates/team-agent/src/db/mod.rs +1 -0
- package/crates/team-agent/src/lifecycle/restart/remove.rs +107 -76
- package/crates/team-agent/src/lifecycle/restart.rs +1 -1
- package/crates/team-agent/src/lifecycle/types.rs +15 -0
- package/crates/team-agent/src/mcp_server/helpers.rs +18 -1
- package/crates/team-agent/src/mcp_server/tests/send.rs +125 -13
- package/crates/team-agent/src/mcp_server/tools.rs +16 -1
- package/crates/team-agent/src/messaging/delivery.rs +12 -5
- package/crates/team-agent/src/messaging/tests/runtime.rs +7 -2
- package/package.json +4 -4
|
@@ -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
|
|
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
|
|
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("
|
|
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
|
+
}
|
|
@@ -60,15 +60,37 @@ pub fn remove_agent_with_transport(
|
|
|
60
60
|
)
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
fn
|
|
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<
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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(
|
|
98
|
-
|
|
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
|
-
|
|
595
|
-
|
|
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
|
-
|
|
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
|
-
|
|
631
|
-
|
|
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|
|
|
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)
|