@team-agent/installer 0.5.7 → 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/named_address.rs +69 -9
- package/crates/team-agent/src/cli/status_port.rs +97 -8
- 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 +13 -61
- 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::*;
|
|
@@ -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
|
|
@@ -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));
|
|
@@ -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
|
+
}
|
|
@@ -670,78 +670,30 @@ fn delete_agent_health(workspace: &Path, agent_id: &AgentId) -> Result<bool, Lif
|
|
|
670
670
|
Ok(changed > 0)
|
|
671
671
|
}
|
|
672
672
|
|
|
673
|
-
|
|
674
|
-
|
|
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
|
+
|
|
675
682
|
fn select_agent_health(
|
|
676
683
|
workspace: &Path,
|
|
677
684
|
agent_id: &AgentId,
|
|
678
685
|
) -> Result<Option<CapturedHealth>, LifecycleError> {
|
|
679
|
-
|
|
680
|
-
.map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
681
|
-
let conn = crate::db::schema::open_db(store.db_path())
|
|
682
|
-
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
683
|
-
let row = conn
|
|
684
|
-
.query_row(
|
|
685
|
-
"select owner_team_id, status, last_output_at, context_usage_pct, current_task_id \
|
|
686
|
-
from agent_health where agent_id = ?1",
|
|
687
|
-
[agent_id.as_str()],
|
|
688
|
-
|r| {
|
|
689
|
-
Ok(CapturedHealth {
|
|
690
|
-
owner_team_id: r.get::<_, Option<String>>(0)?,
|
|
691
|
-
status: r.get::<_, Option<String>>(1)?,
|
|
692
|
-
last_output_at: r.get::<_, Option<String>>(2)?,
|
|
693
|
-
context_usage_pct: r.get::<_, Option<i64>>(3)?,
|
|
694
|
-
current_task_id: r.get::<_, Option<String>>(4)?,
|
|
695
|
-
})
|
|
696
|
-
},
|
|
697
|
-
)
|
|
698
|
-
.ok();
|
|
699
|
-
Ok(row)
|
|
686
|
+
capture_select_agent_health(workspace, agent_id)
|
|
687
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
700
688
|
}
|
|
701
689
|
|
|
702
|
-
/// golden agents.py:268-278 `_restore_agent_health`: re-upsert the captured row (status||"IDLE"), or
|
|
703
|
-
/// delete the row when there was nothing to restore.
|
|
704
690
|
fn restore_agent_health(
|
|
705
691
|
workspace: &Path,
|
|
706
692
|
agent_id: &AgentId,
|
|
707
693
|
row: &Option<CapturedHealth>,
|
|
708
694
|
) -> Result<(), LifecycleError> {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
return Ok(());
|
|
712
|
-
};
|
|
713
|
-
let store = crate::message_store::MessageStore::open(workspace)
|
|
714
|
-
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
715
|
-
let conn = crate::db::schema::open_db(store.db_path())
|
|
716
|
-
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
717
|
-
let status = row.status.clone().unwrap_or_else(|| "IDLE".to_string());
|
|
718
|
-
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6f+00:00").to_string();
|
|
719
|
-
// The restore always follows a delete of this row, so a plain insert re-materializes the captured
|
|
720
|
-
// health (golden _restore_agent_health re-upserts status||"IDLE" + the captured columns).
|
|
721
|
-
conn.execute(
|
|
722
|
-
"insert into agent_health (owner_team_id, agent_id, status, last_output_at, context_usage_pct, current_task_id, updated_at) \
|
|
723
|
-
values (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
724
|
-
rusqlite::params![
|
|
725
|
-
row.owner_team_id,
|
|
726
|
-
agent_id.as_str(),
|
|
727
|
-
status,
|
|
728
|
-
row.last_output_at,
|
|
729
|
-
row.context_usage_pct,
|
|
730
|
-
row.current_task_id,
|
|
731
|
-
now,
|
|
732
|
-
],
|
|
733
|
-
)
|
|
734
|
-
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
735
|
-
Ok(())
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
#[derive(Clone)]
|
|
739
|
-
struct CapturedHealth {
|
|
740
|
-
owner_team_id: Option<String>,
|
|
741
|
-
status: Option<String>,
|
|
742
|
-
last_output_at: Option<String>,
|
|
743
|
-
context_usage_pct: Option<i64>,
|
|
744
|
-
current_task_id: Option<String>,
|
|
695
|
+
capture_restore_agent_health(workspace, agent_id, row)
|
|
696
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
745
697
|
}
|
|
746
698
|
|
|
747
699
|
fn maybe_fail_remove_after_agent_health_delete() -> Result<(), LifecycleError> {
|
|
@@ -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)
|
|
@@ -112,29 +112,43 @@ fn send_outcome_direct_renders_compact_body() {
|
|
|
112
112
|
// ════════════════════════════════════════════════════════════════════════
|
|
113
113
|
// CONTROL-PLANE: send_message worker recipient → WorkerAccepted (tools.py:135-183)
|
|
114
114
|
// ════════════════════════════════════════════════════════════════════════
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
// A worker recipient w/ a delivered message_id → async accepted carrying the
|
|
118
|
-
// byte-stable poll hint. Identity anchored on injected env (no candidate scan).
|
|
119
|
-
// golden: a leader WITH owner_team_id on an unseeded ws would hit the C23 cross-team
|
|
120
|
-
// refusal first (worker-1 not in visible peers) -> PeerNotInScope. owner_team_id=None
|
|
121
|
-
// (legacy single-team) bypasses that, isolating the worker-recipient accepted path.
|
|
122
|
-
// The cross-team refusal has its own tests (refuse_cross_team_peer_* / send_message_cross_team_*).
|
|
123
|
-
// A-7: the accepted path needs a REAL stored message_id (tools.py:175-181 —
|
|
124
|
-
// fabricated ids are gone), so the workspace seeds a running worker-1 the
|
|
125
|
-
// delivery layer can actually queue for.
|
|
126
|
-
let ws = unique_ws("send-worker");
|
|
115
|
+
fn seed_current_worker_state(tag: &str) -> std::path::PathBuf {
|
|
116
|
+
let ws = unique_ws(tag);
|
|
127
117
|
crate::state::persist::save_runtime_state(
|
|
128
118
|
&ws,
|
|
129
119
|
&serde_json::json!({
|
|
130
120
|
"session_name": "team-x",
|
|
121
|
+
"active_team_key": "current",
|
|
131
122
|
"agents": {
|
|
132
123
|
"worker-1": {"status": "running", "agent_id": "worker-1", "window": "worker-1"},
|
|
133
124
|
},
|
|
125
|
+
"teams": {
|
|
126
|
+
"current": {
|
|
127
|
+
"status": "alive",
|
|
128
|
+
"agents": {
|
|
129
|
+
"worker-1": {"status": "running", "agent_id": "worker-1", "window": "worker-1"},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
134
133
|
}),
|
|
135
134
|
)
|
|
136
135
|
.unwrap();
|
|
137
|
-
|
|
136
|
+
ws
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#[test]
|
|
140
|
+
fn send_message_worker_recipient_returns_accepted_with_poll_hint() {
|
|
141
|
+
// A worker recipient w/ a delivered message_id → async accepted carrying the
|
|
142
|
+
// byte-stable poll hint. Identity anchored on injected env (no candidate scan).
|
|
143
|
+
// A-7: the accepted path needs a REAL stored message_id (tools.py:175-181 —
|
|
144
|
+
// fabricated ids are gone), so the workspace seeds a running worker-1 the
|
|
145
|
+
// delivery layer can actually queue for.
|
|
146
|
+
let ws = seed_current_worker_state("send-worker");
|
|
147
|
+
let tools = TeamOrchestratorTools::with_identity(
|
|
148
|
+
&ws,
|
|
149
|
+
Some(AgentId::new("leader")),
|
|
150
|
+
Some(TeamKey::new("current")),
|
|
151
|
+
);
|
|
138
152
|
let outcome = tools.send_message(
|
|
139
153
|
&MessageTarget::Single("worker-1".to_string()),
|
|
140
154
|
"do the thing",
|
|
@@ -155,6 +169,104 @@ fn send_message_worker_recipient_returns_accepted_with_poll_hint() {
|
|
|
155
169
|
}
|
|
156
170
|
}
|
|
157
171
|
|
|
172
|
+
#[test]
|
|
173
|
+
fn ordinary_send_assign_shape_has_no_recovery_marker() {
|
|
174
|
+
let ws = seed_current_worker_state("ordinary-no-recovery");
|
|
175
|
+
let tools = TeamOrchestratorTools::with_identity(
|
|
176
|
+
&ws,
|
|
177
|
+
Some(AgentId::new("leader")),
|
|
178
|
+
Some(TeamKey::new("current")),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
let sent = tools
|
|
182
|
+
.send_message(
|
|
183
|
+
&MessageTarget::Single("worker-1".to_string()),
|
|
184
|
+
"ordinary send",
|
|
185
|
+
None,
|
|
186
|
+
None,
|
|
187
|
+
None,
|
|
188
|
+
None,
|
|
189
|
+
)
|
|
190
|
+
.expect("ordinary send ok")
|
|
191
|
+
.to_value();
|
|
192
|
+
assert!(
|
|
193
|
+
!sent.as_object().unwrap().contains_key("recovery"),
|
|
194
|
+
"ordinary send must not carry recovery marker: {sent}"
|
|
195
|
+
);
|
|
196
|
+
assert!(
|
|
197
|
+
sent.get("acceptance_marker").is_none(),
|
|
198
|
+
"ordinary send must not carry acceptance marker: {sent}"
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
let assigned = tools
|
|
202
|
+
.assign_task(
|
|
203
|
+
&json!({"id": "ordinary-task", "assignee": "worker-1", "title": "ordinary"}),
|
|
204
|
+
None,
|
|
205
|
+
)
|
|
206
|
+
.expect("ordinary assign ok");
|
|
207
|
+
let assigned_value = serde_json::to_value(&assigned).expect("serialize ordinary assign");
|
|
208
|
+
assert!(
|
|
209
|
+
!assigned_value.as_object().unwrap().contains_key("recovery"),
|
|
210
|
+
"ordinary assign must not carry recovery marker: {assigned_value}"
|
|
211
|
+
);
|
|
212
|
+
assert!(
|
|
213
|
+
assigned_value.get("acceptance_marker").is_none(),
|
|
214
|
+
"ordinary assign must not carry acceptance marker: {assigned_value}"
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
let recovery_false_assigned = tools
|
|
218
|
+
.assign_task(
|
|
219
|
+
&json!({
|
|
220
|
+
"id": "ordinary-recovery-false-task",
|
|
221
|
+
"assignee": "worker-1",
|
|
222
|
+
"title": "ordinary recovery false",
|
|
223
|
+
"recovery": false,
|
|
224
|
+
}),
|
|
225
|
+
None,
|
|
226
|
+
)
|
|
227
|
+
.expect("recovery=false assign ok");
|
|
228
|
+
let recovery_false_value =
|
|
229
|
+
serde_json::to_value(&recovery_false_assigned).expect("serialize recovery=false assign");
|
|
230
|
+
assert!(
|
|
231
|
+
!recovery_false_value
|
|
232
|
+
.as_object()
|
|
233
|
+
.unwrap()
|
|
234
|
+
.contains_key("recovery"),
|
|
235
|
+
"recovery=false assign must not carry recovery marker: {recovery_false_value}"
|
|
236
|
+
);
|
|
237
|
+
assert!(
|
|
238
|
+
recovery_false_value.get("acceptance_marker").is_none(),
|
|
239
|
+
"recovery=false assign must not carry acceptance marker: {recovery_false_value}"
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#[test]
|
|
244
|
+
fn recovery_assign_shape_has_structured_marker() {
|
|
245
|
+
let ws = seed_current_worker_state("recovery-marker");
|
|
246
|
+
let tools = TeamOrchestratorTools::with_identity(
|
|
247
|
+
&ws,
|
|
248
|
+
Some(AgentId::new("leader")),
|
|
249
|
+
Some(TeamKey::new("current")),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
let assigned = tools
|
|
253
|
+
.assign_task(
|
|
254
|
+
&json!({
|
|
255
|
+
"id": "recovery-task",
|
|
256
|
+
"assignee": "worker-1",
|
|
257
|
+
"title": "recover",
|
|
258
|
+
"recovery": true,
|
|
259
|
+
}),
|
|
260
|
+
None,
|
|
261
|
+
)
|
|
262
|
+
.expect("recovery assign ok");
|
|
263
|
+
assert_eq!(assigned.fields.get("recovery"), Some(&json!(true)));
|
|
264
|
+
assert_eq!(
|
|
265
|
+
assigned.fields.get("acceptance_marker"),
|
|
266
|
+
Some(&json!("recovery"))
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
158
270
|
#[test]
|
|
159
271
|
fn send_message_worker_recipient_surfaces_dead_coordinator_warning() {
|
|
160
272
|
let ws = unique_ws("send-worker-dead-coord");
|
|
@@ -116,6 +116,7 @@ impl TeamOrchestratorTools {
|
|
|
116
116
|
};
|
|
117
117
|
|
|
118
118
|
let task_value = Value::Object(task_obj.clone());
|
|
119
|
+
let recovery = task_recovery_marker(&task_value);
|
|
119
120
|
let mut state = load_runtime_state(&self.workspace).map_err(tool_runtime_error)?;
|
|
120
121
|
ensure_object(&mut state);
|
|
121
122
|
let team_key = self
|
|
@@ -139,7 +140,16 @@ impl TeamOrchestratorTools {
|
|
|
139
140
|
None,
|
|
140
141
|
None,
|
|
141
142
|
)?;
|
|
142
|
-
compact_tool_result(&out.to_value())
|
|
143
|
+
let mut ok = compact_tool_result(&out.to_value())?;
|
|
144
|
+
if recovery {
|
|
145
|
+
ok.fields
|
|
146
|
+
.insert("recovery".to_string(), serde_json::json!(true));
|
|
147
|
+
ok.fields.insert(
|
|
148
|
+
"acceptance_marker".to_string(),
|
|
149
|
+
Value::String("recovery".to_string()),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
Ok(ok)
|
|
143
153
|
}
|
|
144
154
|
|
|
145
155
|
/// `send_message` (`tools.py:135-183`): C14/C15/C17 scope resolution.
|
|
@@ -989,6 +999,11 @@ fn assignment_message(task: &Value, explicit: Option<&str>) -> String {
|
|
|
989
999
|
json_dumps_default(task)
|
|
990
1000
|
}
|
|
991
1001
|
|
|
1002
|
+
fn task_recovery_marker(task: &Value) -> bool {
|
|
1003
|
+
task.get("recovery").and_then(Value::as_bool) == Some(true)
|
|
1004
|
+
|| task.get("acceptance_marker").and_then(Value::as_str) == Some("recovery")
|
|
1005
|
+
}
|
|
1006
|
+
|
|
992
1007
|
fn scope_override_name(scope: Scope) -> Option<&'static str> {
|
|
993
1008
|
match scope {
|
|
994
1009
|
Scope::Team => Some("team"),
|
|
@@ -1981,12 +1981,19 @@ fn arm_turn_open(state: &mut serde_json::Value, recipient: &str, message_id: &Op
|
|
|
1981
1981
|
.and_then(|agents| agents.get_mut(recipient))
|
|
1982
1982
|
.and_then(serde_json::Value::as_object_mut)
|
|
1983
1983
|
{
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1984
|
+
// Phase-DX E2 (CR P0 red line #5): the leader→worker turn message id.
|
|
1985
|
+
// Renamed from a misleading legacy field name (which was never a task
|
|
1986
|
+
// FSM id — task FSM is A1) to `current_turn_message_id`, matching its
|
|
1987
|
+
// actual semantic. Reader compatibility lives in `coordinator/tick.rs`
|
|
1988
|
+
// (whitelisted for the transition) and `mcp_server/helpers.rs`; other
|
|
1989
|
+
// messaging/lifecycle/mcp_server code MUST NOT treat this as
|
|
1990
|
+
// authoritative task state.
|
|
1991
|
+
let field = message_id
|
|
1992
|
+
.as_ref()
|
|
1993
|
+
.map_or(serde_json::Value::Null, |id| {
|
|
1987
1994
|
serde_json::Value::String(id.clone())
|
|
1988
|
-
})
|
|
1989
|
-
);
|
|
1995
|
+
});
|
|
1996
|
+
agent.insert("current_turn_message_id".to_string(), field);
|
|
1990
1997
|
}
|
|
1991
1998
|
}
|
|
1992
1999
|
|
|
@@ -1581,10 +1581,15 @@ fn deliver_pending_submit_unverified_is_not_saved_by_readback() {
|
|
|
1581
1581
|
Some(&serde_json::json!(message_id)),
|
|
1582
1582
|
"current-turn fact is armed immediately after physical inject succeeds"
|
|
1583
1583
|
);
|
|
1584
|
+
// Phase-DX E2 rename: the leader→worker turn message id lives under
|
|
1585
|
+
// `current_turn_message_id` (the previous name misleadingly implied a task
|
|
1586
|
+
// FSM id — that is A1 territory). Same value written by
|
|
1587
|
+
// `delivery::arm_turn_open`; the invariant of arming-without-delivered is
|
|
1588
|
+
// unchanged.
|
|
1584
1589
|
assert_eq!(
|
|
1585
|
-
updated.pointer("/agents/w1/
|
|
1590
|
+
updated.pointer("/agents/w1/current_turn_message_id"),
|
|
1586
1591
|
Some(&serde_json::json!(message_id)),
|
|
1587
|
-
"agent
|
|
1592
|
+
"agent current-turn message id is armed without marking the message delivered"
|
|
1588
1593
|
);
|
|
1589
1594
|
}
|
|
1590
1595
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.8",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.5.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.5.8",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.8",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.8"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|