@team-agent/installer 0.5.4 → 0.5.6
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/adapters.rs +9 -1
- package/crates/team-agent/src/cli/send.rs +69 -2
- package/crates/team-agent/src/cli/status_port.rs +62 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +12 -19
- package/crates/team-agent/src/lifecycle/restart/agent.rs +29 -0
- package/crates/team-agent/src/mcp_server/helpers.rs +144 -34
- package/crates/team-agent/src/mcp_server/mod.rs +3 -3
- package/crates/team-agent/src/mcp_server/tests/tools.rs +128 -0
- package/crates/team-agent/src/mcp_server/tools.rs +6 -7
- package/crates/team-agent/src/messaging/delivery.rs +255 -31
- package/crates/team-agent/src/messaging/leader_receiver.rs +60 -25
- package/crates/team-agent/src/messaging/tests/runtime.rs +457 -0
- package/crates/team-agent/src/messaging/watchers.rs +36 -19
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -1468,7 +1468,15 @@ fn format_inbox_human(
|
|
|
1468
1468
|
for message in messages {
|
|
1469
1469
|
let sender = message.get("sender").and_then(Value::as_str).unwrap_or("-");
|
|
1470
1470
|
let content = message.get("content").and_then(Value::as_str).unwrap_or("");
|
|
1471
|
-
|
|
1471
|
+
let status = message.get("status").and_then(Value::as_str).unwrap_or("-");
|
|
1472
|
+
let attempts = message
|
|
1473
|
+
.get("delivery_attempts")
|
|
1474
|
+
.and_then(Value::as_i64)
|
|
1475
|
+
.unwrap_or(0);
|
|
1476
|
+
let error = message.get("error").and_then(Value::as_str).unwrap_or("-");
|
|
1477
|
+
lines.push(format!(
|
|
1478
|
+
"- {sender}: {content} [status={status} attempts={attempts} error={error}]"
|
|
1479
|
+
));
|
|
1472
1480
|
}
|
|
1473
1481
|
}
|
|
1474
1482
|
let pending = uncollected_result_count(workspace, owner_team_id)?;
|
|
@@ -90,9 +90,12 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
|
90
90
|
{
|
|
91
91
|
return Ok(CmdResult::from_json(amb, args.json));
|
|
92
92
|
}
|
|
93
|
-
let outcome = messaging::send_message(&selected.run_workspace, &target, &content, &opts)?;
|
|
94
|
-
let mut value = delivery_outcome_json(&outcome, &target, &content, &opts);
|
|
93
|
+
let mut outcome = messaging::send_message(&selected.run_workspace, &target, &content, &opts)?;
|
|
95
94
|
if opts.watch_result {
|
|
95
|
+
outcome = observe_initial_delivery_for_watch(&selected, &target, &outcome, &opts)?;
|
|
96
|
+
}
|
|
97
|
+
let mut value = delivery_outcome_json(&outcome, &target, &content, &opts);
|
|
98
|
+
if opts.watch_result && initial_delivery_allows_watch(outcome.status) {
|
|
96
99
|
if let Some(obj) = value.as_object_mut() {
|
|
97
100
|
obj.insert("watch".to_string(), watch_notice_json(&target, &opts));
|
|
98
101
|
}
|
|
@@ -575,6 +578,48 @@ fn primary_delivery_succeeded(status: DeliveryStatus) -> bool {
|
|
|
575
578
|
)
|
|
576
579
|
}
|
|
577
580
|
|
|
581
|
+
fn initial_delivery_allows_watch(status: DeliveryStatus) -> bool {
|
|
582
|
+
matches!(
|
|
583
|
+
status,
|
|
584
|
+
DeliveryStatus::Delivered | DeliveryStatus::AlreadyDelivered
|
|
585
|
+
)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
fn observe_initial_delivery_for_watch(
|
|
589
|
+
selected: &crate::state::selector::SelectedTeam,
|
|
590
|
+
target: &MessageTarget,
|
|
591
|
+
outcome: &DeliveryOutcome,
|
|
592
|
+
opts: &SendOptions,
|
|
593
|
+
) -> Result<DeliveryOutcome, CliError> {
|
|
594
|
+
if !matches!(target, MessageTarget::Single(agent) if !agent.is_empty()) {
|
|
595
|
+
return Ok(outcome.clone());
|
|
596
|
+
}
|
|
597
|
+
if !matches!(outcome.status, DeliveryStatus::Queued) {
|
|
598
|
+
return Ok(outcome.clone());
|
|
599
|
+
}
|
|
600
|
+
let Some(message_id) = outcome.message_id.as_deref() else {
|
|
601
|
+
return Ok(outcome.clone());
|
|
602
|
+
};
|
|
603
|
+
let store = crate::message_store::MessageStore::open(&selected.run_workspace)
|
|
604
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
605
|
+
let transport = crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
|
|
606
|
+
&selected.run_workspace,
|
|
607
|
+
opts.team.as_ref().map(TeamKey::as_str),
|
|
608
|
+
)
|
|
609
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
610
|
+
let event_log = crate::event_log::EventLog::new(&selected.run_workspace);
|
|
611
|
+
let state = selected_state_with_active_key(selected);
|
|
612
|
+
crate::messaging::delivery::deliver_pending_message(
|
|
613
|
+
&selected.run_workspace,
|
|
614
|
+
&store,
|
|
615
|
+
&transport,
|
|
616
|
+
message_id,
|
|
617
|
+
&event_log,
|
|
618
|
+
&state,
|
|
619
|
+
)
|
|
620
|
+
.map_err(CliError::from)
|
|
621
|
+
}
|
|
622
|
+
|
|
578
623
|
fn is_business_reject_text(error: &str) -> bool {
|
|
579
624
|
let lower = error.to_ascii_lowercase();
|
|
580
625
|
[
|
|
@@ -668,6 +713,8 @@ fn delivery_outcome_json(
|
|
|
668
713
|
json!({
|
|
669
714
|
"ok": outcome.ok,
|
|
670
715
|
"status": delivery_status_wire(outcome.status),
|
|
716
|
+
"delivery_status": api_delivery_status(outcome),
|
|
717
|
+
"delivered": delivery_proven(outcome.status),
|
|
671
718
|
"target": target_wire,
|
|
672
719
|
"agent_id": first_target(target),
|
|
673
720
|
"content_length_bytes": content.len(),
|
|
@@ -681,6 +728,26 @@ fn delivery_outcome_json(
|
|
|
681
728
|
})
|
|
682
729
|
}
|
|
683
730
|
|
|
731
|
+
fn api_delivery_status(outcome: &DeliveryOutcome) -> &'static str {
|
|
732
|
+
if delivery_proven(outcome.status) {
|
|
733
|
+
return "delivered";
|
|
734
|
+
}
|
|
735
|
+
if matches!(outcome.status, DeliveryStatus::Queued) && outcome.message_status.0 == "accepted" {
|
|
736
|
+
return "pending";
|
|
737
|
+
}
|
|
738
|
+
delivery_status_wire(outcome.status)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
fn delivery_proven(status: DeliveryStatus) -> bool {
|
|
742
|
+
matches!(
|
|
743
|
+
status,
|
|
744
|
+
DeliveryStatus::Delivered
|
|
745
|
+
| DeliveryStatus::AlreadyDelivered
|
|
746
|
+
| DeliveryStatus::BroadcastDelivered
|
|
747
|
+
| DeliveryStatus::FanoutDelivered
|
|
748
|
+
)
|
|
749
|
+
}
|
|
750
|
+
|
|
684
751
|
fn add_send_reminder_if_ok(value: &mut Value) {
|
|
685
752
|
if value.get("ok").and_then(Value::as_bool) != Some(true) {
|
|
686
753
|
return;
|
|
@@ -99,6 +99,7 @@ use rusqlite::params;
|
|
|
99
99
|
"tasks": tasks,
|
|
100
100
|
"messages": message_counts(&conn, owner_team_id)?,
|
|
101
101
|
"queued_messages": queued_messages(&conn, owner_team_id, 8)?,
|
|
102
|
+
"pending_leader_notifications": pending_leader_notifications(&conn, owner_team_id, 8)?,
|
|
102
103
|
"results": result_counts(&conn, owner_team_id)?,
|
|
103
104
|
"latest_results": latest_result_summaries(&store, owner_team_id)?,
|
|
104
105
|
"readiness": readiness,
|
|
@@ -639,6 +640,67 @@ use rusqlite::params;
|
|
|
639
640
|
Ok(Value::Array(values))
|
|
640
641
|
}
|
|
641
642
|
|
|
643
|
+
/// 0.5.5 gate054 round-2: leader notifications that were refused with
|
|
644
|
+
/// `rebind_required` (status=failed, error=leader_not_attached) sit as
|
|
645
|
+
/// failed rows in the store; without a dedicated status field the
|
|
646
|
+
/// operator sees only `messages.failed=N` and cannot tell that the
|
|
647
|
+
/// notifications are waiting for a rebind. This field surfaces them
|
|
648
|
+
/// alongside `queued_messages` so `attach-leader` / `takeover` is
|
|
649
|
+
/// visibly the fix. Once the pane is rebound the requeue path flips
|
|
650
|
+
/// each row back to `status=accepted` and it drops out of this list.
|
|
651
|
+
fn pending_leader_notifications(
|
|
652
|
+
conn: &rusqlite::Connection,
|
|
653
|
+
owner_team_id: Option<&str>,
|
|
654
|
+
limit: usize,
|
|
655
|
+
) -> Result<Value, CliError> {
|
|
656
|
+
let limit = i64::try_from(limit).unwrap_or(i64::MAX);
|
|
657
|
+
let sql = match owner_team_id {
|
|
658
|
+
Some(_) => {
|
|
659
|
+
"select message_id, sender, status, error, created_at, delivery_attempts
|
|
660
|
+
from messages
|
|
661
|
+
where recipient = 'leader'
|
|
662
|
+
and status = 'failed'
|
|
663
|
+
and error = 'leader_not_attached'
|
|
664
|
+
and owner_team_id = ?1
|
|
665
|
+
order by created_at desc
|
|
666
|
+
limit ?2"
|
|
667
|
+
}
|
|
668
|
+
None => {
|
|
669
|
+
"select message_id, sender, status, error, created_at, delivery_attempts
|
|
670
|
+
from messages
|
|
671
|
+
where recipient = 'leader'
|
|
672
|
+
and status = 'failed'
|
|
673
|
+
and error = 'leader_not_attached'
|
|
674
|
+
order by created_at desc
|
|
675
|
+
limit ?1"
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
let mut stmt = conn
|
|
679
|
+
.prepare(sql)
|
|
680
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
681
|
+
let map_row = |row: &rusqlite::Row<'_>| {
|
|
682
|
+
Ok(json!({
|
|
683
|
+
"message_id": row.get::<_, String>(0)?,
|
|
684
|
+
"sender": row.get::<_, Option<String>>(1)?,
|
|
685
|
+
"status": row.get::<_, String>(2)?,
|
|
686
|
+
"error": row.get::<_, Option<String>>(3)?,
|
|
687
|
+
"created_at": row.get::<_, Option<String>>(4)?,
|
|
688
|
+
"delivery_attempts": row.get::<_, i64>(5)?,
|
|
689
|
+
"channel": "rebind_required",
|
|
690
|
+
"action": "run team-agent attach-leader or team-agent takeover",
|
|
691
|
+
}))
|
|
692
|
+
};
|
|
693
|
+
let rows = match owner_team_id {
|
|
694
|
+
Some(team) => stmt.query_map(params![team, limit], map_row),
|
|
695
|
+
None => stmt.query_map(params![limit], map_row),
|
|
696
|
+
}
|
|
697
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
698
|
+
let values = rows
|
|
699
|
+
.collect::<Result<Vec<_>, _>>()
|
|
700
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
701
|
+
Ok(Value::Array(values))
|
|
702
|
+
}
|
|
703
|
+
|
|
642
704
|
/// 0.4.x: slim default compact payload — exactly 7 top-level fields.
|
|
643
705
|
/// Diagnostic detail moves to `--detail`. Plan:
|
|
644
706
|
/// /Users/alauda/Documents/code/team-agent-public/.team/artifacts/status-compact-plan.md
|
|
@@ -511,33 +511,26 @@ use super::*;
|
|
|
511
511
|
}
|
|
512
512
|
|
|
513
513
|
#[test]
|
|
514
|
-
fn
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
// (send.py:322-337). That dict MUST survive verbatim into CmdResult Json output.
|
|
514
|
+
fn cmd_send_watch_result_does_not_register_before_delivery() {
|
|
515
|
+
// 0.5.x send contract: --watch-result may only advertise a watcher after
|
|
516
|
+
// initial worker delivery is physically proven.
|
|
518
517
|
let r = cmd_send(&send_args_fixture()).expect("cmd_send returns CmdResult");
|
|
519
518
|
let v = match r.output {
|
|
520
519
|
CmdOutput::Json(v) => v,
|
|
521
520
|
other => panic!("expected Json, got {other:?}"),
|
|
522
521
|
};
|
|
523
|
-
|
|
524
|
-
.get("
|
|
525
|
-
|
|
526
|
-
assert_eq!(
|
|
527
|
-
watch.get("status").and_then(|s| s.as_str()),
|
|
528
|
-
Some("registered"),
|
|
529
|
-
"registered-watcher notice status must be exactly 'registered'"
|
|
522
|
+
assert!(
|
|
523
|
+
v.get("delivery_status").and_then(|s| s.as_str()).is_some(),
|
|
524
|
+
"send output must expose delivery_status; got {v}"
|
|
530
525
|
);
|
|
531
|
-
assert!(watch.get("watcher_id").is_some(), "watch notice carries watcher_id");
|
|
532
526
|
assert_eq!(
|
|
533
|
-
|
|
534
|
-
Some(
|
|
535
|
-
"
|
|
527
|
+
v.get("delivered").and_then(|s| s.as_bool()),
|
|
528
|
+
Some(false),
|
|
529
|
+
"undelivered send outcome must not look delivered"
|
|
536
530
|
);
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
watch
|
|
540
|
-
Some("Team Agent will collect the result and notify the leader when this task reports completion.")
|
|
531
|
+
assert!(
|
|
532
|
+
!v.as_object().unwrap().contains_key("watch"),
|
|
533
|
+
"watch_result:true must not attach result['watch'] before delivery; got {v}"
|
|
541
534
|
);
|
|
542
535
|
}
|
|
543
536
|
|
|
@@ -143,6 +143,7 @@ pub(crate) fn start_agent_at_paths(
|
|
|
143
143
|
if let Ok(spec) = load_team_spec(spec_workspace) {
|
|
144
144
|
write_team_state(spec_workspace, &spec, &state)?;
|
|
145
145
|
}
|
|
146
|
+
replay_worker_target_missing_messages(workspace, agent_id, &team_key, &state, transport)?;
|
|
146
147
|
let coordinator_started = start_coordinator_for_workspace(workspace)?;
|
|
147
148
|
let target = format!("{}:{window}", session_name.as_str());
|
|
148
149
|
write_start_agent_noop_event(workspace, agent_id, &target, coordinator_started)?;
|
|
@@ -282,6 +283,7 @@ pub(crate) fn start_agent_at_paths(
|
|
|
282
283
|
spawn_session_id,
|
|
283
284
|
tmux_start_mode_for_spawn(&spawn, into_existing_session),
|
|
284
285
|
)?;
|
|
286
|
+
replay_worker_target_missing_messages(workspace, agent_id, &team_key, &state, transport)?;
|
|
285
287
|
let coordinator_started = start_coordinator_for_workspace(workspace)?;
|
|
286
288
|
Ok(StartAgentOutcome::Running {
|
|
287
289
|
env: AgentActionEnvelope {
|
|
@@ -297,6 +299,33 @@ pub(crate) fn start_agent_at_paths(
|
|
|
297
299
|
})
|
|
298
300
|
}
|
|
299
301
|
|
|
302
|
+
fn replay_worker_target_missing_messages(
|
|
303
|
+
workspace: &Path,
|
|
304
|
+
agent_id: &AgentId,
|
|
305
|
+
team_key: &str,
|
|
306
|
+
state: &serde_json::Value,
|
|
307
|
+
transport: &dyn crate::transport::Transport,
|
|
308
|
+
) -> Result<(), LifecycleError> {
|
|
309
|
+
let store = crate::message_store::MessageStore::open(workspace)
|
|
310
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
311
|
+
let event_log = crate::event_log::EventLog::new(workspace);
|
|
312
|
+
let ids = crate::messaging::delivery::requeue_worker_target_missing_messages(
|
|
313
|
+
workspace,
|
|
314
|
+
&store,
|
|
315
|
+
&event_log,
|
|
316
|
+
agent_id.as_str(),
|
|
317
|
+
Some(team_key),
|
|
318
|
+
)
|
|
319
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
320
|
+
if !ids.is_empty() {
|
|
321
|
+
crate::messaging::delivery::deliver_pending_messages(
|
|
322
|
+
workspace, state, transport, &event_log,
|
|
323
|
+
)
|
|
324
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
325
|
+
}
|
|
326
|
+
Ok(())
|
|
327
|
+
}
|
|
328
|
+
|
|
300
329
|
fn verify_spawned_pane_matches_target(
|
|
301
330
|
transport: &dyn crate::transport::Transport,
|
|
302
331
|
pane: &crate::transport::PaneId,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
use std::io::Write as _;
|
|
4
4
|
use std::path::Path;
|
|
5
5
|
|
|
6
|
+
use rusqlite::{params, OptionalExtension};
|
|
6
7
|
use serde::Serialize;
|
|
7
8
|
use serde_json::Value;
|
|
8
9
|
|
|
@@ -274,12 +275,7 @@ fn find_latest_nonterminal_task_for(tasks: &[Value], agent_id: &str) -> Option<S
|
|
|
274
275
|
None
|
|
275
276
|
}
|
|
276
277
|
|
|
277
|
-
|
|
278
|
-
/// is empty/null AND for which no result row exists yet. Used by
|
|
279
|
-
/// `report_result` as a message-scoped fallback before defaulting to
|
|
280
|
-
/// `"manual"`, so `collect` can correlate via the existing message-scope path
|
|
281
|
-
/// (`messaging::results::is_message_scoped_result`).
|
|
282
|
-
pub(crate) fn latest_uncorrelated_delivered_message_for(
|
|
278
|
+
pub(crate) fn latest_reportable_message_for(
|
|
283
279
|
workspace: &Path,
|
|
284
280
|
agent_id: &str,
|
|
285
281
|
owner_team_id: Option<&str>,
|
|
@@ -287,33 +283,147 @@ pub(crate) fn latest_uncorrelated_delivered_message_for(
|
|
|
287
283
|
use crate::db::message_store::MessageStore;
|
|
288
284
|
let store = MessageStore::open(workspace).ok()?;
|
|
289
285
|
let conn = crate::db::schema::open_db(store.db_path()).ok()?;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
let mut stmt = conn.prepare(sql).ok()?;
|
|
310
|
-
let id: Option<String> = match owner_team_id {
|
|
311
|
-
Some(team) => stmt
|
|
312
|
-
.query_row(rusqlite::params![agent_id, team], |row| row.get::<_, String>(0))
|
|
313
|
-
.ok(),
|
|
314
|
-
None => stmt
|
|
315
|
-
.query_row(rusqlite::params![agent_id], |row| row.get::<_, String>(0))
|
|
316
|
-
.ok(),
|
|
286
|
+
current_turn_message_for(workspace, &conn, agent_id, owner_team_id)
|
|
287
|
+
.or_else(|| latest_reportable_message_from_db(&conn, agent_id, owner_team_id))
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
fn current_turn_message_for(
|
|
291
|
+
workspace: &Path,
|
|
292
|
+
conn: &rusqlite::Connection,
|
|
293
|
+
agent_id: &str,
|
|
294
|
+
owner_team_id: Option<&str>,
|
|
295
|
+
) -> Option<String> {
|
|
296
|
+
let state = report_scope_state(workspace, owner_team_id)?;
|
|
297
|
+
current_turn_id_from_state(&state, agent_id)
|
|
298
|
+
.filter(|message_id| message_is_reportable(conn, message_id, agent_id, owner_team_id))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
fn report_scope_state(workspace: &Path, owner_team_id: Option<&str>) -> Option<Value> {
|
|
302
|
+
let state = load_runtime_state(workspace).ok()?;
|
|
303
|
+
let Some(team) = owner_team_id.filter(|team| !team.is_empty()) else {
|
|
304
|
+
return Some(state);
|
|
317
305
|
};
|
|
318
|
-
|
|
306
|
+
let canonical = crate::state::projection::resolve_owner_team_id(&state, team)
|
|
307
|
+
.canonical_key()
|
|
308
|
+
.map(str::to_string)
|
|
309
|
+
.unwrap_or_else(|| team.to_string());
|
|
310
|
+
if let Some(projected) = state
|
|
311
|
+
.get("teams")
|
|
312
|
+
.and_then(Value::as_object)
|
|
313
|
+
.and_then(|teams| teams.get(&canonical))
|
|
314
|
+
.cloned()
|
|
315
|
+
{
|
|
316
|
+
Some(projected)
|
|
317
|
+
} else {
|
|
318
|
+
Some(state)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
fn current_turn_id_from_state(state: &Value, agent_id: &str) -> Option<String> {
|
|
323
|
+
if let Some(turn) = state.get("coordinator").and_then(|v| v.get("turn_open")) {
|
|
324
|
+
let armed = turn.get("armed").and_then(Value::as_bool).unwrap_or(false);
|
|
325
|
+
let node_matches = turn
|
|
326
|
+
.get("node_id")
|
|
327
|
+
.and_then(Value::as_str)
|
|
328
|
+
.is_some_and(|node| node == agent_id);
|
|
329
|
+
if armed && node_matches {
|
|
330
|
+
if let Some(turn_id) = turn
|
|
331
|
+
.get("turn_id")
|
|
332
|
+
.and_then(Value::as_str)
|
|
333
|
+
.and_then(non_empty_string)
|
|
334
|
+
{
|
|
335
|
+
return Some(turn_id.to_string());
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
state
|
|
340
|
+
.get("agents")
|
|
341
|
+
.and_then(|agents| agents.get(agent_id))
|
|
342
|
+
.and_then(|agent| agent.get("current_task_id"))
|
|
343
|
+
.and_then(Value::as_str)
|
|
344
|
+
.and_then(non_empty_string)
|
|
345
|
+
.map(ToString::to_string)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
fn message_is_reportable(
|
|
349
|
+
conn: &rusqlite::Connection,
|
|
350
|
+
message_id: &str,
|
|
351
|
+
agent_id: &str,
|
|
352
|
+
owner_team_id: Option<&str>,
|
|
353
|
+
) -> bool {
|
|
354
|
+
conn.query_row(
|
|
355
|
+
"select 1 from messages m
|
|
356
|
+
where m.message_id = ?1
|
|
357
|
+
and m.recipient = ?2
|
|
358
|
+
and (?3 is null or m.owner_team_id = ?3)
|
|
359
|
+
and (m.task_id is null or m.task_id = '')
|
|
360
|
+
and m.status in ('delivered', 'target_resolved', 'submitted', 'injected', 'visible')
|
|
361
|
+
and (m.status = 'delivered' or m.error is null)
|
|
362
|
+
and m.created_at >= coalesce((
|
|
363
|
+
select max(r.created_at) from results r
|
|
364
|
+
where r.agent_id = m.recipient
|
|
365
|
+
and (?3 is null or r.owner_team_id = m.owner_team_id)
|
|
366
|
+
), '0000')
|
|
367
|
+
and not exists (
|
|
368
|
+
select 1 from results r
|
|
369
|
+
where r.task_id = m.message_id and r.agent_id = m.recipient
|
|
370
|
+
)
|
|
371
|
+
limit 1",
|
|
372
|
+
params![message_id, agent_id, owner_team_id],
|
|
373
|
+
|_| Ok(()),
|
|
374
|
+
)
|
|
375
|
+
.optional()
|
|
376
|
+
.ok()
|
|
377
|
+
.flatten()
|
|
378
|
+
.is_some()
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
fn latest_reportable_message_from_db(
|
|
382
|
+
conn: &rusqlite::Connection,
|
|
383
|
+
agent_id: &str,
|
|
384
|
+
owner_team_id: Option<&str>,
|
|
385
|
+
) -> Option<String> {
|
|
386
|
+
let row = conn
|
|
387
|
+
.query_row(
|
|
388
|
+
"select m.message_id, m.status, m.error from messages m
|
|
389
|
+
where m.recipient = ?1
|
|
390
|
+
and (?2 is null or m.owner_team_id = ?2)
|
|
391
|
+
and (m.task_id is null or m.task_id = '')
|
|
392
|
+
and m.created_at >= coalesce((
|
|
393
|
+
select max(r.created_at) from results r
|
|
394
|
+
where r.agent_id = m.recipient
|
|
395
|
+
and (?2 is null or r.owner_team_id = m.owner_team_id)
|
|
396
|
+
), '0000')
|
|
397
|
+
and not exists (
|
|
398
|
+
select 1 from results r
|
|
399
|
+
where r.task_id = m.message_id and r.agent_id = m.recipient
|
|
400
|
+
)
|
|
401
|
+
order by m.created_at desc,
|
|
402
|
+
case when m.status = 'delivered' then 0 else 1 end
|
|
403
|
+
limit 1",
|
|
404
|
+
params![agent_id, owner_team_id],
|
|
405
|
+
|row| {
|
|
406
|
+
Ok((
|
|
407
|
+
row.get::<_, String>(0)?,
|
|
408
|
+
row.get::<_, String>(1)?,
|
|
409
|
+
row.get::<_, Option<String>>(2)?,
|
|
410
|
+
))
|
|
411
|
+
},
|
|
412
|
+
)
|
|
413
|
+
.optional()
|
|
414
|
+
.ok()
|
|
415
|
+
.flatten()?;
|
|
416
|
+
let (message_id, status, error) = row;
|
|
417
|
+
if reportable_message_status(&status, error.as_deref()) {
|
|
418
|
+
Some(message_id)
|
|
419
|
+
} else {
|
|
420
|
+
None
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
fn reportable_message_status(status: &str, error: Option<&str>) -> bool {
|
|
425
|
+
matches!(
|
|
426
|
+
status,
|
|
427
|
+
"delivered" | "target_resolved" | "submitted" | "injected" | "visible"
|
|
428
|
+
) && (status == "delivered" || error.is_none())
|
|
319
429
|
}
|
|
@@ -99,9 +99,9 @@ pub use wire::*;
|
|
|
99
99
|
// pub(crate) 子项 (normalize 的 list helpers、wire 的 dispatch_tool 等) 经此再导出,
|
|
100
100
|
// 使 `#[cfg(test)] mod tests` 的 `use super::*` 与跨子模块引用解析不变。
|
|
101
101
|
pub(crate) use helpers::{
|
|
102
|
-
delivery_outcome_value, ensure_object, enum_value, insert_array,
|
|
103
|
-
non_empty_string, normalize_token, normalized_envelope_value,
|
|
104
|
-
text_of_value, tool_error_reason_wire, tool_runtime_error,
|
|
102
|
+
delivery_outcome_value, ensure_object, enum_value, insert_array, latest_reportable_message_for,
|
|
103
|
+
latest_task_for_assignee, non_empty_string, normalize_token, normalized_envelope_value,
|
|
104
|
+
object_fields, text_field, text_of_value, tool_error_reason_wire, tool_runtime_error,
|
|
105
105
|
};
|
|
106
106
|
pub(crate) use normalize::{
|
|
107
107
|
normalize_artifacts, normalize_changes, normalize_next_actions, normalize_risks, normalize_tests,
|
|
@@ -61,6 +61,134 @@
|
|
|
61
61
|
assert_eq!(env.task_id, TaskId::new("manual"));
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
fn seed_report_message(
|
|
65
|
+
ws: &std::path::Path,
|
|
66
|
+
message_id: &str,
|
|
67
|
+
owner_team_id: &str,
|
|
68
|
+
status: &str,
|
|
69
|
+
created_at: &str,
|
|
70
|
+
) {
|
|
71
|
+
let store = MessageStore::open(ws).unwrap();
|
|
72
|
+
let conn = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
73
|
+
conn.execute(
|
|
74
|
+
"insert into messages(
|
|
75
|
+
message_id, owner_team_id, task_id, sender, recipient, reply_to, requires_ack,
|
|
76
|
+
status, content, artifact_refs, created_at, updated_at, delivered_at,
|
|
77
|
+
acknowledged_at, error, delivery_attempts
|
|
78
|
+
) values (?1, ?2, null, 'leader', 'probe-worker', null, 0,
|
|
79
|
+
?3, 'task', '[]', ?4, ?4, case when ?3 = 'delivered' then ?4 else null end,
|
|
80
|
+
null, null, 0)",
|
|
81
|
+
rusqlite::params![message_id, owner_team_id, status, created_at],
|
|
82
|
+
)
|
|
83
|
+
.unwrap();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fn seed_report_result(
|
|
87
|
+
ws: &std::path::Path,
|
|
88
|
+
result_id: &str,
|
|
89
|
+
owner_team_id: &str,
|
|
90
|
+
task_id: &str,
|
|
91
|
+
created_at: &str,
|
|
92
|
+
) {
|
|
93
|
+
let store = MessageStore::open(ws).unwrap();
|
|
94
|
+
let conn = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
95
|
+
let envelope = json!({
|
|
96
|
+
"schema_version": "result_envelope_v1",
|
|
97
|
+
"result_id": result_id,
|
|
98
|
+
"task_id": task_id,
|
|
99
|
+
"agent_id": "probe-worker",
|
|
100
|
+
"status": "success",
|
|
101
|
+
"summary": "seed",
|
|
102
|
+
"changes": [], "tests": [], "risks": [], "artifacts": [], "next_actions": []
|
|
103
|
+
});
|
|
104
|
+
conn.execute(
|
|
105
|
+
"insert into results(
|
|
106
|
+
result_id, owner_team_id, task_id, agent_id, envelope, status, created_at
|
|
107
|
+
) values (?1, ?2, ?3, 'probe-worker', ?4, 'success', ?5)",
|
|
108
|
+
rusqlite::params![result_id, owner_team_id, task_id, envelope.to_string(), created_at],
|
|
109
|
+
)
|
|
110
|
+
.unwrap();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#[test]
|
|
114
|
+
fn report_result_prefers_current_inflight_message_over_old_delivered_fallback() {
|
|
115
|
+
let ws = unique_ws("report-current-message");
|
|
116
|
+
seed_report_message(
|
|
117
|
+
&ws,
|
|
118
|
+
"msg_old",
|
|
119
|
+
"gate055",
|
|
120
|
+
"delivered",
|
|
121
|
+
"2026-07-06T13:24:25.000000+00:00",
|
|
122
|
+
);
|
|
123
|
+
seed_report_result(
|
|
124
|
+
&ws,
|
|
125
|
+
"res_seed",
|
|
126
|
+
"gate055",
|
|
127
|
+
"task_initial",
|
|
128
|
+
"2026-07-06T13:25:00.000000+00:00",
|
|
129
|
+
);
|
|
130
|
+
seed_report_message(
|
|
131
|
+
&ws,
|
|
132
|
+
"msg_new",
|
|
133
|
+
"gate055",
|
|
134
|
+
"target_resolved",
|
|
135
|
+
"2026-07-06T13:27:35.000000+00:00",
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
let tools = TeamOrchestratorTools::with_identity(
|
|
139
|
+
&ws,
|
|
140
|
+
Some(AgentId::new("probe-worker")),
|
|
141
|
+
Some(TeamKey::new("gate055")),
|
|
142
|
+
);
|
|
143
|
+
let ok = tools.report_result(
|
|
144
|
+
None, Some("S3_RESTART_TOKEN"), ResultStatus::Success,
|
|
145
|
+
None, None, None, None, None,
|
|
146
|
+
None, None,
|
|
147
|
+
).expect("report ok");
|
|
148
|
+
let v = serde_json::to_value(&ok).unwrap();
|
|
149
|
+
assert_eq!(
|
|
150
|
+
v.get("task_id"),
|
|
151
|
+
Some(&json!("msg_new")),
|
|
152
|
+
"current in-flight message must beat stale delivered fallback"
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#[test]
|
|
157
|
+
fn report_result_does_not_backfill_old_delivered_message_after_latest_result() {
|
|
158
|
+
let ws = unique_ws("report-no-old-backfill");
|
|
159
|
+
seed_report_message(
|
|
160
|
+
&ws,
|
|
161
|
+
"msg_old",
|
|
162
|
+
"gate055",
|
|
163
|
+
"delivered",
|
|
164
|
+
"2026-07-06T13:24:25.000000+00:00",
|
|
165
|
+
);
|
|
166
|
+
seed_report_result(
|
|
167
|
+
&ws,
|
|
168
|
+
"res_latest",
|
|
169
|
+
"gate055",
|
|
170
|
+
"task_initial",
|
|
171
|
+
"2026-07-06T13:25:00.000000+00:00",
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
let tools = TeamOrchestratorTools::with_identity(
|
|
175
|
+
&ws,
|
|
176
|
+
Some(AgentId::new("probe-worker")),
|
|
177
|
+
Some(TeamKey::new("gate055")),
|
|
178
|
+
);
|
|
179
|
+
let ok = tools.report_result(
|
|
180
|
+
None, Some("manual follow-up"), ResultStatus::Success,
|
|
181
|
+
None, None, None, None, None,
|
|
182
|
+
None, None,
|
|
183
|
+
).expect("report ok");
|
|
184
|
+
let v = serde_json::to_value(&ok).unwrap();
|
|
185
|
+
assert_eq!(
|
|
186
|
+
v.get("task_id"),
|
|
187
|
+
Some(&json!("manual")),
|
|
188
|
+
"old delivered messages older than latest result must not be reused"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
64
192
|
// ════════════════════════════════════════════════════════════════════════
|
|
65
193
|
// CONTROL-PLANE: request_human creates a requires_ack leader message → needs_human
|
|
66
194
|
// (tools.py:342-346). sender = explicit > env > "unknown" (never leader).
|