@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 CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.4"
578
+ version = "0.5.6"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.4"
12
+ version = "0.5.6"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -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
- lines.push(format!("- {sender}: {content}"));
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 cmd_send_watch_result_surfaces_registered_notice() {
515
- // gate CRITICAL: --watch-result -> SendOptions.watch_result=true -> send_message attaches
516
- // result['watch']={status:'registered', watcher_id, task_id, agent_id, notice}
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
- let watch = v
524
- .get("watch")
525
- .expect("watch_result:true MUST attach result['watch'] (send.py:326)");
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
- watch.get("agent_id").and_then(|s| s.as_str()),
534
- Some("alice"),
535
- "watch notice agent_id == the send target"
527
+ v.get("delivered").and_then(|s| s.as_bool()),
528
+ Some(false),
529
+ "undelivered send outcome must not look delivered"
536
530
  );
537
- // non-queued notice golden bytes (send.py:335):
538
- assert_eq!(
539
- watch.get("notice").and_then(|s| s.as_str()),
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
- /// Find the most recent delivered direct message to `agent_id` whose `task_id`
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
- let sql = match owner_team_id {
291
- Some(_) => "select m.message_id from messages m \
292
- where m.recipient = ?1 and m.status = 'delivered' \
293
- and (m.task_id is null or m.task_id = '') \
294
- and m.owner_team_id = ?2 \
295
- and not exists ( \
296
- select 1 from results r \
297
- where r.task_id = m.message_id and r.agent_id = m.recipient \
298
- ) \
299
- order by m.created_at desc limit 1",
300
- None => "select m.message_id from messages m \
301
- where m.recipient = ?1 and m.status = 'delivered' \
302
- and (m.task_id is null or m.task_id = '') \
303
- and not exists ( \
304
- select 1 from results r \
305
- where r.task_id = m.message_id and r.agent_id = m.recipient \
306
- ) \
307
- order by m.created_at desc limit 1",
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
- id
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, latest_task_for_assignee,
103
- non_empty_string, normalize_token, normalized_envelope_value, object_fields, text_field,
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).