@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
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -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(
|
|
22
|
-
|
|
23
|
-
"
|
|
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
|
-
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
"reason"
|
|
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(
|
|
40
|
-
|
|
41
|
-
|
|
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(
|
|
69
|
-
|
|
70
|
-
|
|
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(||
|
|
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(||
|
|
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 =>
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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!(
|
|
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
|
|
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
|
|
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 [
|
|
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
|
-
|
|
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
|
-
"
|
|
970
|
-
json!(
|
|
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));
|