@team-agent/installer 0.3.10 → 0.3.11
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/send.rs +9 -2
- package/crates/team-agent/src/coordinator/backoff.rs +83 -2
- package/crates/team-agent/src/coordinator/tick.rs +327 -167
- package/crates/team-agent/src/mcp_server/helpers.rs +24 -5
- package/crates/team-agent/src/mcp_server/normalize.rs +13 -6
- package/crates/team-agent/src/mcp_server/tests/send.rs +310 -212
- package/crates/team-agent/src/messaging/helpers.rs +30 -10
- package/crates/team-agent/src/messaging/send.rs +71 -14
- package/crates/team-agent/src/messaging/tests/basic.rs +25 -7
- package/crates/team-agent/src/messaging/tests/runtime.rs +489 -125
- package/crates/team-agent/src/messaging/types.rs +19 -4
- package/package.json +4 -4
|
@@ -43,9 +43,11 @@ fn session_drift_refusal_none_for_no_target_leader_or_broadcast() {
|
|
|
43
43
|
.is_none()
|
|
44
44
|
);
|
|
45
45
|
// target == "*" (broadcast) → None.
|
|
46
|
-
assert!(
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
assert!(
|
|
47
|
+
session_drift_refusal(&state, "*", "leader", "s", None, &log)
|
|
48
|
+
.unwrap()
|
|
49
|
+
.is_none()
|
|
50
|
+
);
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
#[test]
|
|
@@ -53,9 +55,11 @@ fn session_drift_refusal_none_when_status_not_drift() {
|
|
|
53
55
|
let ws = tmp_ws("driftok");
|
|
54
56
|
let log = EventLog::new(&ws);
|
|
55
57
|
let state = json(serde_json::json!({"agents": {"w1": {"status": "idle"}}}));
|
|
56
|
-
assert!(
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
assert!(
|
|
59
|
+
session_drift_refusal(&state, "w1", "leader", "s", None, &log)
|
|
60
|
+
.unwrap()
|
|
61
|
+
.is_none()
|
|
62
|
+
);
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
#[test]
|
|
@@ -149,7 +153,13 @@ fn classify_recent_provider_output_is_working_low_confidence() {
|
|
|
149
153
|
fn classify_idle_prompt_beats_recent_output_for_just_launched_agent() {
|
|
150
154
|
let state = json(serde_json::json!({}));
|
|
151
155
|
let recent = chrono::Utc::now().to_rfc3339();
|
|
152
|
-
let a = classify_agent_activity(
|
|
156
|
+
let a = classify_agent_activity(
|
|
157
|
+
&state,
|
|
158
|
+
"codex ready\n❯ \n",
|
|
159
|
+
false,
|
|
160
|
+
Some("codex"),
|
|
161
|
+
Some(&recent),
|
|
162
|
+
);
|
|
153
163
|
assert_eq!(
|
|
154
164
|
a.status,
|
|
155
165
|
ActivityStatus::Idle,
|
|
@@ -158,7 +168,10 @@ fn classify_idle_prompt_beats_recent_output_for_just_launched_agent() {
|
|
|
158
168
|
the startup banner is recent (activity.rs:192 recent-output fires before latest_prompt_signal:200) \
|
|
159
169
|
— the Stage B dispatch deferred_busy regression. got {a:?}"
|
|
160
170
|
);
|
|
161
|
-
assert_eq!(
|
|
171
|
+
assert_eq!(
|
|
172
|
+
a.confidence, 0.9,
|
|
173
|
+
"golden idle-prompt confidence is 0.9; got {a:?}"
|
|
174
|
+
);
|
|
162
175
|
}
|
|
163
176
|
|
|
164
177
|
// ════════════════════════════════════════════════════════════════════════
|
|
@@ -227,7 +240,10 @@ fn trust_auto_answer_own_workspace_realpath_equal_answers() {
|
|
|
227
240
|
&log,
|
|
228
241
|
)
|
|
229
242
|
.unwrap();
|
|
230
|
-
assert!(
|
|
243
|
+
assert!(
|
|
244
|
+
out.answered,
|
|
245
|
+
"own-workspace realpath-equal prompt must auto-answer"
|
|
246
|
+
);
|
|
231
247
|
assert_eq!(out.reason, "trust_auto_answered");
|
|
232
248
|
}
|
|
233
249
|
|
|
@@ -269,7 +285,10 @@ fn pane_width_failed_forces_exact_match_never_default() {
|
|
|
269
285
|
)
|
|
270
286
|
.unwrap();
|
|
271
287
|
// fail-safe: no width → exact-equality only → truncated prefix refused.
|
|
272
|
-
assert!(
|
|
288
|
+
assert!(
|
|
289
|
+
!out.answered,
|
|
290
|
+
"Failed pane-width must NOT enable prefix/truncation matching"
|
|
291
|
+
);
|
|
273
292
|
assert_eq!(out.reason, "workspace_dir_mismatch");
|
|
274
293
|
assert_eq!(out.action.as_deref(), Some("prompt_leader"));
|
|
275
294
|
}
|
|
@@ -352,6 +371,80 @@ fn send_message_target_not_in_team_is_refused() {
|
|
|
352
371
|
assert_eq!(out.reason, Some(DeliveryRefusal::TargetNotInTeam));
|
|
353
372
|
}
|
|
354
373
|
|
|
374
|
+
#[test]
|
|
375
|
+
fn message_not_silently_stuck_accepted_when_coordinator_dead() {
|
|
376
|
+
let ws = tmp_ws("sendcoorddead");
|
|
377
|
+
crate::state::persist::save_runtime_state(
|
|
378
|
+
&ws,
|
|
379
|
+
&serde_json::json!({
|
|
380
|
+
"session_name": "team-x",
|
|
381
|
+
"agents": {
|
|
382
|
+
"worker-1": {"status": "running", "agent_id": "worker-1", "window": "worker-1"},
|
|
383
|
+
},
|
|
384
|
+
}),
|
|
385
|
+
)
|
|
386
|
+
.unwrap();
|
|
387
|
+
let coordinator_ws = crate::coordinator::WorkspacePath::new(ws.clone());
|
|
388
|
+
std::fs::create_dir_all(crate::model::paths::runtime_dir(&ws)).unwrap();
|
|
389
|
+
let _ = MessageStore::open(&ws).unwrap();
|
|
390
|
+
let stale_pid = crate::coordinator::Pid::new(99_999_999);
|
|
391
|
+
crate::coordinator::write_coordinator_metadata(
|
|
392
|
+
&coordinator_ws,
|
|
393
|
+
stale_pid,
|
|
394
|
+
crate::coordinator::MetadataSource::Boot,
|
|
395
|
+
)
|
|
396
|
+
.unwrap();
|
|
397
|
+
std::fs::write(
|
|
398
|
+
crate::coordinator::coordinator_pid_path(&coordinator_ws),
|
|
399
|
+
stale_pid.to_string(),
|
|
400
|
+
)
|
|
401
|
+
.unwrap();
|
|
402
|
+
|
|
403
|
+
let out = send_message(
|
|
404
|
+
&ws,
|
|
405
|
+
&MessageTarget::Single("worker-1".to_string()),
|
|
406
|
+
"hi",
|
|
407
|
+
&SendOptions::default(),
|
|
408
|
+
)
|
|
409
|
+
.unwrap();
|
|
410
|
+
|
|
411
|
+
assert!(!out.ok);
|
|
412
|
+
assert_eq!(out.status, DeliveryStatus::Degraded);
|
|
413
|
+
assert_eq!(out.message_status.0, "degraded");
|
|
414
|
+
assert_eq!(out.reason, Some(DeliveryRefusal::CoordinatorUnavailable));
|
|
415
|
+
assert!(
|
|
416
|
+
out.verification
|
|
417
|
+
.as_deref()
|
|
418
|
+
.is_some_and(|warning| warning.contains("coordinator is not running")),
|
|
419
|
+
"N38 warning must explain why the message was not queued; out={out:?}"
|
|
420
|
+
);
|
|
421
|
+
let store = MessageStore::open(&ws).unwrap();
|
|
422
|
+
let conn = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
423
|
+
let accepted: i64 = conn
|
|
424
|
+
.query_row(
|
|
425
|
+
"select count(*) from messages where status = 'accepted'",
|
|
426
|
+
[],
|
|
427
|
+
|row| row.get(0),
|
|
428
|
+
)
|
|
429
|
+
.unwrap();
|
|
430
|
+
assert_eq!(
|
|
431
|
+
accepted, 0,
|
|
432
|
+
"dead coordinator send must not strand an accepted row"
|
|
433
|
+
);
|
|
434
|
+
let events = EventLog::new(&ws).tail(20).unwrap();
|
|
435
|
+
assert!(
|
|
436
|
+
events.iter().any(|event| {
|
|
437
|
+
event.get("event").and_then(serde_json::Value::as_str)
|
|
438
|
+
== Some("send.coordinator_unavailable")
|
|
439
|
+
&& event
|
|
440
|
+
.get("message_queued")
|
|
441
|
+
.and_then(serde_json::Value::as_bool)
|
|
442
|
+
== Some(false)
|
|
443
|
+
}),
|
|
444
|
+
"send.coordinator_unavailable event must be durable; events={events:?}"
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
355
448
|
#[test]
|
|
356
449
|
fn send_message_broadcast_empty_team_skips_no_recipients() {
|
|
357
450
|
// send.py:391-393 — "*" with no team recipients →
|
|
@@ -455,14 +548,8 @@ fn report_result_valid_envelope_returns_ok_with_result_id() {
|
|
|
455
548
|
let out = report_result(&ws, &envelope).unwrap();
|
|
456
549
|
// results.py:216-227 — ok True with result_id/task_id/agent_id echoed.
|
|
457
550
|
assert_eq!(out.get("ok").and_then(|v| v.as_bool()), Some(true));
|
|
458
|
-
assert_eq!(
|
|
459
|
-
|
|
460
|
-
Some("t1")
|
|
461
|
-
);
|
|
462
|
-
assert_eq!(
|
|
463
|
-
out.get("agent_id").and_then(|v| v.as_str()),
|
|
464
|
-
Some("alice")
|
|
465
|
-
);
|
|
551
|
+
assert_eq!(out.get("task_id").and_then(|v| v.as_str()), Some("t1"));
|
|
552
|
+
assert_eq!(out.get("agent_id").and_then(|v| v.as_str()), Some("alice"));
|
|
466
553
|
assert!(out.get("result_id").and_then(|v| v.as_str()).is_some());
|
|
467
554
|
}
|
|
468
555
|
|
|
@@ -523,7 +610,9 @@ fn report_result_funnels_into_leader_delivery_primitive_not_queued_scheduled_eve
|
|
|
523
610
|
// No `scheduled_events` rows: the queued parallel path is gone.
|
|
524
611
|
let conn = seed_conn(&store);
|
|
525
612
|
let scheduled_count: i64 = conn
|
|
526
|
-
.query_row("select count(*) from scheduled_events", [], |row|
|
|
613
|
+
.query_row("select count(*) from scheduled_events", [], |row| {
|
|
614
|
+
row.get(0)
|
|
615
|
+
})
|
|
527
616
|
.unwrap();
|
|
528
617
|
assert_eq!(
|
|
529
618
|
scheduled_count, 0,
|
|
@@ -544,15 +633,16 @@ fn report_result_funnels_into_leader_delivery_primitive_not_queued_scheduled_eve
|
|
|
544
633
|
"I-4: leader_notified=false when no leader pane is bound"
|
|
545
634
|
);
|
|
546
635
|
assert!(
|
|
547
|
-
out.get("notification_event_id")
|
|
636
|
+
out.get("notification_event_id")
|
|
637
|
+
.is_some_and(|v| v.is_null()),
|
|
548
638
|
"no scheduled_events row → notification_event_id is null"
|
|
549
639
|
);
|
|
550
640
|
|
|
551
641
|
// Audit events: the funnel emits leader_receiver.delivery_blocked (I-4 rebind),
|
|
552
642
|
// and the legacy mcp.report_result_notify_queued audit is gone.
|
|
553
643
|
let events_path = ws.join(".team").join("logs").join("events.jsonl");
|
|
554
|
-
let event_lines =
|
|
555
|
-
.expect("report_result writes events.jsonl");
|
|
644
|
+
let event_lines =
|
|
645
|
+
std::fs::read_to_string(events_path).expect("report_result writes events.jsonl");
|
|
556
646
|
assert!(
|
|
557
647
|
event_lines.contains("\"leader_receiver.delivery_blocked\""),
|
|
558
648
|
"I-4 rebind path must emit leader_receiver.delivery_blocked audit; got {event_lines}",
|
|
@@ -595,14 +685,7 @@ fn notify_result_watchers_no_match_returns_empty() {
|
|
|
595
685
|
let watchers = vec![json(serde_json::json!({
|
|
596
686
|
"watcher_id": "w-x", "task_id": "OTHER", "agent_id": "alice"
|
|
597
687
|
}))];
|
|
598
|
-
let notices = notify_result_watchers(
|
|
599
|
-
&ws,
|
|
600
|
-
&result,
|
|
601
|
-
&log,
|
|
602
|
-
Some(&watchers),
|
|
603
|
-
None,
|
|
604
|
-
)
|
|
605
|
-
.unwrap();
|
|
688
|
+
let notices = notify_result_watchers(&ws, &result, &log, Some(&watchers), None).unwrap();
|
|
606
689
|
assert!(notices.is_empty());
|
|
607
690
|
}
|
|
608
691
|
|
|
@@ -625,14 +708,16 @@ fn notify_result_watchers_supersedes_duplicate_watchers() {
|
|
|
625
708
|
"created_at": "2026-06-02T11:00:00+00:00"
|
|
626
709
|
})),
|
|
627
710
|
];
|
|
628
|
-
let notices =
|
|
629
|
-
notify_result_watchers(&ws, &result, &log, Some(&watchers), None).unwrap();
|
|
711
|
+
let notices = notify_result_watchers(&ws, &result, &log, Some(&watchers), None).unwrap();
|
|
630
712
|
// The late watcher must appear as a superseded (not-ok) notice — exactly-once.
|
|
631
713
|
let superseded = notices
|
|
632
714
|
.iter()
|
|
633
715
|
.find(|n| n.watcher_id == "w-late")
|
|
634
716
|
.expect("late watcher must be reported");
|
|
635
|
-
assert!(
|
|
717
|
+
assert!(
|
|
718
|
+
!superseded.ok,
|
|
719
|
+
"duplicate watcher must be superseded, not re-delivered"
|
|
720
|
+
);
|
|
636
721
|
}
|
|
637
722
|
|
|
638
723
|
// ════════════════════════════════════════════════════════════════════════
|
|
@@ -654,17 +739,36 @@ fn requeue_after_claim_leader_skips_already_notified() {
|
|
|
654
739
|
let team = TeamKey::new("team-a");
|
|
655
740
|
let pane = PaneId::new("%new-leader");
|
|
656
741
|
|
|
657
|
-
let w_un = seed_watcher(
|
|
742
|
+
let w_un = seed_watcher(
|
|
743
|
+
&store,
|
|
744
|
+
"w-unnotified",
|
|
745
|
+
"team-a",
|
|
746
|
+
"t1",
|
|
747
|
+
"alice",
|
|
748
|
+
"pending",
|
|
749
|
+
None,
|
|
750
|
+
None,
|
|
751
|
+
);
|
|
658
752
|
let w_notified = seed_watcher(
|
|
659
|
-
&store,
|
|
753
|
+
&store,
|
|
754
|
+
"w-notified",
|
|
755
|
+
"team-a",
|
|
756
|
+
"t2",
|
|
757
|
+
"bob",
|
|
758
|
+
"pending",
|
|
759
|
+
None,
|
|
760
|
+
Some("msg_already"),
|
|
660
761
|
);
|
|
661
762
|
|
|
662
|
-
let requeued =
|
|
663
|
-
requeue_after_claim_leader(&ws, &store, &log, &team, &pane, None).unwrap();
|
|
763
|
+
let requeued = requeue_after_claim_leader(&ws, &store, &log, &team, &pane, None).unwrap();
|
|
664
764
|
|
|
665
765
|
// ONLY the un-notified watcher requeues (the notified one is the dedupe gate).
|
|
666
766
|
let ids: Vec<&str> = requeued.iter().map(|n| n.watcher_id.as_str()).collect();
|
|
667
|
-
assert_eq!(
|
|
767
|
+
assert_eq!(
|
|
768
|
+
ids,
|
|
769
|
+
vec![w_un.as_str()],
|
|
770
|
+
"exactly the un-notified watcher requeues"
|
|
771
|
+
);
|
|
668
772
|
assert!(
|
|
669
773
|
!requeued.iter().any(|n| n.watcher_id == w_notified),
|
|
670
774
|
"already-notified watcher must NOT be requeued (Gap 32)"
|
|
@@ -719,10 +823,13 @@ fn requeue_delivery_exhausted_watchers_reopens_only_exhausted() {
|
|
|
719
823
|
None,
|
|
720
824
|
);
|
|
721
825
|
|
|
722
|
-
let requeued =
|
|
723
|
-
requeue_delivery_exhausted_watchers(&ws, &store, &log, &team, &pane).unwrap();
|
|
826
|
+
let requeued = requeue_delivery_exhausted_watchers(&ws, &store, &log, &team, &pane).unwrap();
|
|
724
827
|
|
|
725
|
-
assert_eq!(
|
|
828
|
+
assert_eq!(
|
|
829
|
+
requeued.len(),
|
|
830
|
+
1,
|
|
831
|
+
"only delivery_exhausted unnotified watchers requeue"
|
|
832
|
+
);
|
|
726
833
|
let notice = &requeued[0];
|
|
727
834
|
assert_eq!(notice.watcher_id, exhausted);
|
|
728
835
|
assert_eq!(notice.result_id.as_deref(), Some(rid.as_str()));
|
|
@@ -734,26 +841,55 @@ fn requeue_delivery_exhausted_watchers_reopens_only_exhausted() {
|
|
|
734
841
|
// R8 (golden): attach requeue leaves the watcher at notify_failed and DEFERS retry to the coordinator
|
|
735
842
|
// tick — it does NOT immediately re-deliver (only the claim path retries). So the persisted status is
|
|
736
843
|
// notify_failed, not 'notified'.
|
|
737
|
-
assert_eq!(
|
|
844
|
+
assert_eq!(
|
|
845
|
+
status, "notify_failed",
|
|
846
|
+
"attach requeue flips to notify_failed and defers retry (golden)"
|
|
847
|
+
);
|
|
738
848
|
let (status, notified_id) = watcher_state(&store, ¬ified);
|
|
739
849
|
assert_eq!(status, "delivery_exhausted");
|
|
740
850
|
assert_eq!(notified_id.as_deref(), Some("msg_done"));
|
|
741
851
|
let (status, _notified_id) = watcher_state(&store, &failed);
|
|
742
|
-
assert_eq!(
|
|
852
|
+
assert_eq!(
|
|
853
|
+
status, "notify_failed",
|
|
854
|
+
"non-exhausted watcher is not selected"
|
|
855
|
+
);
|
|
743
856
|
|
|
744
857
|
// R8 (golden leader/__init__.py:46-50): result_watcher.requeued is the ATTACH form
|
|
745
858
|
// {watcher_id, trigger:"attach_leader", new_pane_id} — NOT the claim-style {prior_state,claimed_pane_id,team_id}.
|
|
746
859
|
let events = log.tail(0).unwrap();
|
|
747
|
-
let ev = events
|
|
748
|
-
.
|
|
860
|
+
let ev = events
|
|
861
|
+
.iter()
|
|
862
|
+
.rev()
|
|
863
|
+
.find(|event| {
|
|
864
|
+
event.get("event").and_then(|v| v.as_str()) == Some("result_watcher.requeued")
|
|
865
|
+
})
|
|
749
866
|
.expect("result_watcher.requeued event");
|
|
750
|
-
let keys: std::collections::BTreeSet<&str> = ev
|
|
751
|
-
.
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
867
|
+
let keys: std::collections::BTreeSet<&str> = ev
|
|
868
|
+
.as_object()
|
|
869
|
+
.unwrap()
|
|
870
|
+
.keys()
|
|
871
|
+
.map(String::as_str)
|
|
872
|
+
.filter(|k| *k != "ts" && *k != "event")
|
|
873
|
+
.collect();
|
|
874
|
+
let expected: std::collections::BTreeSet<&str> = ["watcher_id", "trigger", "new_pane_id"]
|
|
875
|
+
.into_iter()
|
|
876
|
+
.collect();
|
|
877
|
+
assert_eq!(
|
|
878
|
+
keys, expected,
|
|
879
|
+
"result_watcher.requeued must be golden attach form {{watcher_id, trigger, new_pane_id}}"
|
|
880
|
+
);
|
|
881
|
+
assert_eq!(
|
|
882
|
+
ev.get("watcher_id").and_then(|v| v.as_str()),
|
|
883
|
+
Some("w-exhausted")
|
|
884
|
+
);
|
|
885
|
+
assert_eq!(
|
|
886
|
+
ev.get("trigger").and_then(|v| v.as_str()),
|
|
887
|
+
Some("attach_leader")
|
|
888
|
+
);
|
|
889
|
+
assert_eq!(
|
|
890
|
+
ev.get("new_pane_id").and_then(|v| v.as_str()),
|
|
891
|
+
Some("%leader")
|
|
892
|
+
);
|
|
757
893
|
}
|
|
758
894
|
|
|
759
895
|
// ════════════════════════════════════════════════════════════════════════
|
|
@@ -767,10 +903,11 @@ fn stuck_cancel_none_alert_type_expands_to_all() {
|
|
|
767
903
|
let ws = tmp_ws("stuckcancel");
|
|
768
904
|
let out = stuck_cancel(&ws, "w1", None, "leader").unwrap();
|
|
769
905
|
// The suppression result must enumerate all three alert types.
|
|
770
|
-
let types = out
|
|
771
|
-
.
|
|
772
|
-
|
|
773
|
-
|
|
906
|
+
let types = out.get("alert_types").and_then(|v| v.as_array()).map(|a| {
|
|
907
|
+
a.iter()
|
|
908
|
+
.filter_map(|x| x.as_str().map(str::to_string))
|
|
909
|
+
.collect::<Vec<_>>()
|
|
910
|
+
});
|
|
774
911
|
assert_eq!(
|
|
775
912
|
types,
|
|
776
913
|
Some(vec![
|
|
@@ -825,9 +962,18 @@ fn collect_accepts_message_scoped_result_for_matching_recipient() {
|
|
|
825
962
|
.and_then(|v| v.as_array())
|
|
826
963
|
.expect("collected_results");
|
|
827
964
|
assert_eq!(collected.len(), 1);
|
|
828
|
-
assert_eq!(
|
|
829
|
-
|
|
830
|
-
|
|
965
|
+
assert_eq!(
|
|
966
|
+
collected[0].get("task_id").and_then(|v| v.as_str()),
|
|
967
|
+
Some(message_id.as_str())
|
|
968
|
+
);
|
|
969
|
+
assert_eq!(
|
|
970
|
+
collected[0].get("agent_id").and_then(|v| v.as_str()),
|
|
971
|
+
Some("w1")
|
|
972
|
+
);
|
|
973
|
+
assert_eq!(
|
|
974
|
+
collected[0].get("scope").and_then(|v| v.as_str()),
|
|
975
|
+
Some("message")
|
|
976
|
+
);
|
|
831
977
|
// D3 (leader-adjudicated): golden collected_results entry is EXACTLY the 8-key summary for BOTH
|
|
832
978
|
// scopes; golden's task_status feeds only the `collect.result` EVENT, never the entry. So a
|
|
833
979
|
// message-scope entry carries NO task_status key (the prior `Some("message_scoped")` lock encoded a
|
|
@@ -837,7 +983,12 @@ fn collect_accepts_message_scoped_result_for_matching_recipient() {
|
|
|
837
983
|
"collected_results entry must NOT carry task_status (golden 8-key summary; event-only); got {:?}",
|
|
838
984
|
collected[0]
|
|
839
985
|
);
|
|
840
|
-
let keys: Vec<&str> = collected[0]
|
|
986
|
+
let keys: Vec<&str> = collected[0]
|
|
987
|
+
.as_object()
|
|
988
|
+
.expect("entry is an object")
|
|
989
|
+
.keys()
|
|
990
|
+
.map(String::as_str)
|
|
991
|
+
.collect();
|
|
841
992
|
assert_eq!(
|
|
842
993
|
keys,
|
|
843
994
|
vec!["result_id", "task_id", "agent_id", "status", "summary", "tests", "created_at", "scope"],
|
|
@@ -868,7 +1019,10 @@ fn collect_rejects_message_scoped_result_without_matching_recipient() {
|
|
|
868
1019
|
.and_then(|v| v.as_array())
|
|
869
1020
|
.expect("invalid_results");
|
|
870
1021
|
assert_eq!(invalid.len(), 1);
|
|
871
|
-
assert_eq!(
|
|
1022
|
+
assert_eq!(
|
|
1023
|
+
invalid[0].get("task_id").and_then(|v| v.as_str()),
|
|
1024
|
+
Some(message_id.as_str())
|
|
1025
|
+
);
|
|
872
1026
|
assert_eq!(
|
|
873
1027
|
invalid[0].get("error").and_then(|v| v.as_str()),
|
|
874
1028
|
Some(format!("unknown task id: {message_id}").as_str())
|
|
@@ -882,7 +1036,10 @@ fn allow_peer_talk_records_bidirectional_allowlist_and_event() {
|
|
|
882
1036
|
assert_eq!(out.get("ok").and_then(|v| v.as_bool()), Some(true));
|
|
883
1037
|
assert_eq!(out.get("a").and_then(|v| v.as_str()), Some("alice"));
|
|
884
1038
|
assert_eq!(out.get("b").and_then(|v| v.as_str()), Some("bob"));
|
|
885
|
-
assert_eq!(
|
|
1039
|
+
assert_eq!(
|
|
1040
|
+
out.get("status").and_then(|v| v.as_str()),
|
|
1041
|
+
Some("compat_noop")
|
|
1042
|
+
);
|
|
886
1043
|
assert_eq!(
|
|
887
1044
|
out.get("reason").and_then(|v| v.as_str()),
|
|
888
1045
|
Some("team_scoped_peer_messages_enabled")
|
|
@@ -893,7 +1050,9 @@ fn allow_peer_talk_records_bidirectional_allowlist_and_event() {
|
|
|
893
1050
|
let rows = conn
|
|
894
1051
|
.prepare("select a, b from peer_allowlist order by a, b")
|
|
895
1052
|
.unwrap()
|
|
896
|
-
.query_map([], |row|
|
|
1053
|
+
.query_map([], |row| {
|
|
1054
|
+
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
|
1055
|
+
})
|
|
897
1056
|
.unwrap()
|
|
898
1057
|
.collect::<Result<Vec<_>, _>>()
|
|
899
1058
|
.unwrap();
|
|
@@ -908,7 +1067,9 @@ fn allow_peer_talk_records_bidirectional_allowlist_and_event() {
|
|
|
908
1067
|
let events = EventLog::new(&ws).tail(10).unwrap();
|
|
909
1068
|
let event = events
|
|
910
1069
|
.iter()
|
|
911
|
-
.find(|event|
|
|
1070
|
+
.find(|event| {
|
|
1071
|
+
event.get("event").and_then(|v| v.as_str()) == Some("communication.peer_allowed")
|
|
1072
|
+
})
|
|
912
1073
|
.expect("communication.peer_allowed event");
|
|
913
1074
|
assert_eq!(event.get("a").and_then(|v| v.as_str()), Some("alice"));
|
|
914
1075
|
assert_eq!(event.get("b").and_then(|v| v.as_str()), Some("bob"));
|
|
@@ -1017,7 +1178,8 @@ fn deliver_pending_message_missing_message_fails() {
|
|
|
1017
1178
|
let store = store_for(&ws);
|
|
1018
1179
|
let log = EventLog::new(&ws);
|
|
1019
1180
|
let t = NoopTransport;
|
|
1020
|
-
let out =
|
|
1181
|
+
let out =
|
|
1182
|
+
deliver_pending_message(&ws, &store, &t, "nope", &log, &serde_json::json!({})).unwrap();
|
|
1021
1183
|
assert!(!out.ok);
|
|
1022
1184
|
assert_eq!(out.status, DeliveryStatus::Failed);
|
|
1023
1185
|
}
|
|
@@ -1046,8 +1208,12 @@ fn fire_due_scheduled_events_fires_each_scheduled_kind() {
|
|
|
1046
1208
|
"%w1",
|
|
1047
1209
|
&serde_json::json!({"content": "ping", "attempt": 1, "max_attempts": 1}),
|
|
1048
1210
|
);
|
|
1049
|
-
let ping_id =
|
|
1050
|
-
|
|
1211
|
+
let ping_id = seed_scheduled_event(
|
|
1212
|
+
&store,
|
|
1213
|
+
ScheduledKind::HealthPing,
|
|
1214
|
+
"%w1",
|
|
1215
|
+
&serde_json::json!({}),
|
|
1216
|
+
);
|
|
1051
1217
|
let trust_id = seed_scheduled_event(
|
|
1052
1218
|
&store,
|
|
1053
1219
|
ScheduledKind::TrustRetry,
|
|
@@ -1065,7 +1231,11 @@ fn fire_due_scheduled_events_fires_each_scheduled_kind() {
|
|
|
1065
1231
|
"scheduled event id {id} (each ScheduledKind) must fire; got {fired:?}"
|
|
1066
1232
|
);
|
|
1067
1233
|
}
|
|
1068
|
-
assert_eq!(
|
|
1234
|
+
assert_eq!(
|
|
1235
|
+
fired.len(),
|
|
1236
|
+
3,
|
|
1237
|
+
"exactly the three seeded due events fire, no extras"
|
|
1238
|
+
);
|
|
1069
1239
|
}
|
|
1070
1240
|
|
|
1071
1241
|
struct UnverifiedInjectTransport;
|
|
@@ -1073,17 +1243,38 @@ impl Transport for UnverifiedInjectTransport {
|
|
|
1073
1243
|
fn kind(&self) -> BackendKind {
|
|
1074
1244
|
BackendKind::Tmux
|
|
1075
1245
|
}
|
|
1076
|
-
fn spawn_first(
|
|
1246
|
+
fn spawn_first(
|
|
1247
|
+
&self,
|
|
1248
|
+
_s: &SessionName,
|
|
1249
|
+
_w: &WindowName,
|
|
1250
|
+
_a: &[String],
|
|
1251
|
+
_c: &Path,
|
|
1252
|
+
_e: &BTreeMap<String, String>,
|
|
1253
|
+
) -> Result<SpawnResult, TransportError> {
|
|
1077
1254
|
unimplemented!("not reached in delivery")
|
|
1078
1255
|
}
|
|
1079
|
-
fn spawn_into(
|
|
1256
|
+
fn spawn_into(
|
|
1257
|
+
&self,
|
|
1258
|
+
_s: &SessionName,
|
|
1259
|
+
_w: &WindowName,
|
|
1260
|
+
_a: &[String],
|
|
1261
|
+
_c: &Path,
|
|
1262
|
+
_e: &BTreeMap<String, String>,
|
|
1263
|
+
) -> Result<SpawnResult, TransportError> {
|
|
1080
1264
|
unimplemented!("not reached in delivery")
|
|
1081
1265
|
}
|
|
1082
|
-
fn inject(
|
|
1266
|
+
fn inject(
|
|
1267
|
+
&self,
|
|
1268
|
+
_t: &Target,
|
|
1269
|
+
_p: &InjectPayload,
|
|
1270
|
+
_s: Key,
|
|
1271
|
+
_b: bool,
|
|
1272
|
+
) -> Result<InjectReport, TransportError> {
|
|
1083
1273
|
Ok(InjectReport {
|
|
1084
1274
|
stage_reached: crate::transport::InjectStage::Submit,
|
|
1085
1275
|
inject_verification: crate::transport::InjectVerification::CaptureContainsToken,
|
|
1086
|
-
submit_verification:
|
|
1276
|
+
submit_verification:
|
|
1277
|
+
crate::transport::SubmitVerification::PastedContentPromptStillPresentAfterSubmit,
|
|
1087
1278
|
turn_verification: crate::transport::TurnVerification::NotYetObserved,
|
|
1088
1279
|
attempts: u32::from(SEND_RETRY_MAX_ATTEMPTS),
|
|
1089
1280
|
})
|
|
@@ -1092,7 +1283,10 @@ impl Transport for UnverifiedInjectTransport {
|
|
|
1092
1283
|
Ok(())
|
|
1093
1284
|
}
|
|
1094
1285
|
fn capture(&self, _t: &Target, range: CaptureRange) -> Result<CapturedText, TransportError> {
|
|
1095
|
-
Ok(CapturedText {
|
|
1286
|
+
Ok(CapturedText {
|
|
1287
|
+
text: String::new(),
|
|
1288
|
+
range,
|
|
1289
|
+
})
|
|
1096
1290
|
}
|
|
1097
1291
|
fn query(&self, _t: &Target, _f: PaneField) -> Result<Option<String>, TransportError> {
|
|
1098
1292
|
Ok(None)
|
|
@@ -1109,7 +1303,12 @@ impl Transport for UnverifiedInjectTransport {
|
|
|
1109
1303
|
fn list_windows(&self, _s: &SessionName) -> Result<Vec<WindowName>, TransportError> {
|
|
1110
1304
|
Ok(Vec::new())
|
|
1111
1305
|
}
|
|
1112
|
-
fn set_session_env(
|
|
1306
|
+
fn set_session_env(
|
|
1307
|
+
&self,
|
|
1308
|
+
_s: &SessionName,
|
|
1309
|
+
_k: &str,
|
|
1310
|
+
_v: &str,
|
|
1311
|
+
) -> Result<SetEnvOutcome, TransportError> {
|
|
1113
1312
|
Ok(SetEnvOutcome::Applied)
|
|
1114
1313
|
}
|
|
1115
1314
|
fn kill_session(&self, _s: &SessionName) -> Result<(), TransportError> {
|
|
@@ -1138,22 +1337,30 @@ fn deliver_pending_exhausted_unverified_send_emits_failed_event() {
|
|
|
1138
1337
|
.create_message(None, "leader", "w1", "ping", None, false, None)
|
|
1139
1338
|
.unwrap();
|
|
1140
1339
|
|
|
1141
|
-
let out = deliver_pending_message(
|
|
1142
|
-
|
|
1340
|
+
let out = deliver_pending_message(
|
|
1341
|
+
&ws,
|
|
1342
|
+
&store,
|
|
1343
|
+
&UnverifiedInjectTransport,
|
|
1344
|
+
&message_id,
|
|
1345
|
+
&log,
|
|
1346
|
+
&state,
|
|
1347
|
+
)
|
|
1348
|
+
.unwrap();
|
|
1143
1349
|
|
|
1144
1350
|
assert!(!out.ok);
|
|
1145
1351
|
assert_eq!(out.message_status.0, "failed");
|
|
1146
1352
|
let events = log.tail(0).unwrap();
|
|
1147
1353
|
assert!(
|
|
1148
|
-
events
|
|
1149
|
-
.
|
|
1150
|
-
|
|
1354
|
+
events.iter().any(
|
|
1355
|
+
|event| event.get("event").and_then(serde_json::Value::as_str) == Some("send.failed")
|
|
1356
|
+
),
|
|
1151
1357
|
"exhausted unverified send must emit send.failed; got {events:?}"
|
|
1152
1358
|
);
|
|
1153
1359
|
assert!(
|
|
1154
|
-
events
|
|
1155
|
-
.
|
|
1156
|
-
|
|
1360
|
+
events.iter().any(
|
|
1361
|
+
|event| event.get("event").and_then(serde_json::Value::as_str)
|
|
1362
|
+
== Some("send.failed_notification")
|
|
1363
|
+
),
|
|
1157
1364
|
"exhausted unverified send must queue a leader-visible notification; got {events:?}"
|
|
1158
1365
|
);
|
|
1159
1366
|
}
|
|
@@ -1179,14 +1386,28 @@ fn retry_result_deliveries_retries_notify_failed_watcher() {
|
|
|
1179
1386
|
|
|
1180
1387
|
let rid = seed_result(&store, "res_r1", "t1", "alice", "success");
|
|
1181
1388
|
let w = seed_watcher(
|
|
1182
|
-
&store,
|
|
1389
|
+
&store,
|
|
1390
|
+
"w-failed",
|
|
1391
|
+
"team-a",
|
|
1392
|
+
"t1",
|
|
1393
|
+
"alice",
|
|
1394
|
+
"notify_failed",
|
|
1395
|
+
Some(&rid),
|
|
1396
|
+
None,
|
|
1183
1397
|
);
|
|
1184
1398
|
|
|
1185
1399
|
let notices = retry_result_deliveries(&ws, &log).unwrap();
|
|
1186
1400
|
|
|
1187
|
-
assert_eq!(
|
|
1401
|
+
assert_eq!(
|
|
1402
|
+
notices.len(),
|
|
1403
|
+
1,
|
|
1404
|
+
"the single notify_failed watcher must be retried"
|
|
1405
|
+
);
|
|
1188
1406
|
let notice = ¬ices[0];
|
|
1189
|
-
assert_eq!(
|
|
1407
|
+
assert_eq!(
|
|
1408
|
+
notice.watcher_id, w,
|
|
1409
|
+
"the retried notice names the seeded watcher"
|
|
1410
|
+
);
|
|
1190
1411
|
assert_eq!(
|
|
1191
1412
|
notice.result_id.as_deref(),
|
|
1192
1413
|
Some(rid.as_str()),
|
|
@@ -1214,18 +1435,31 @@ fn collect_results_and_notify_watchers_returns_concrete_ok_shape() {
|
|
|
1214
1435
|
let log = EventLog::new(&ws);
|
|
1215
1436
|
|
|
1216
1437
|
seed_watcher(
|
|
1217
|
-
&store,
|
|
1438
|
+
&store,
|
|
1439
|
+
"w-orphan",
|
|
1440
|
+
"team-a",
|
|
1441
|
+
"t1",
|
|
1442
|
+
"alice",
|
|
1443
|
+
"notify_failed",
|
|
1444
|
+
Some("res_missing"),
|
|
1445
|
+
None,
|
|
1218
1446
|
);
|
|
1219
1447
|
|
|
1220
1448
|
let out = collect_results_and_notify_watchers(&ws, &log).unwrap();
|
|
1221
|
-
assert_eq!(
|
|
1449
|
+
assert_eq!(
|
|
1450
|
+
out.get("ok").and_then(|v| v.as_bool()),
|
|
1451
|
+
Some(true),
|
|
1452
|
+
"ok==true"
|
|
1453
|
+
);
|
|
1222
1454
|
assert_eq!(
|
|
1223
1455
|
out.get("collected").and_then(|v| v.as_i64()),
|
|
1224
1456
|
Some(0),
|
|
1225
1457
|
"no uncollected results → collected==0"
|
|
1226
1458
|
);
|
|
1227
1459
|
assert_eq!(
|
|
1228
|
-
out.get("notified")
|
|
1460
|
+
out.get("notified")
|
|
1461
|
+
.and_then(|v| v.as_array())
|
|
1462
|
+
.map(|a| a.len()),
|
|
1229
1463
|
Some(0),
|
|
1230
1464
|
"orphan watcher (missing result) is skipped → notified empty"
|
|
1231
1465
|
);
|
|
@@ -1271,26 +1505,52 @@ fn collect_task_scoped_result_collects_and_marks_task_done() {
|
|
|
1271
1505
|
"agents": { "w1": { "provider": "codex" } },
|
|
1272
1506
|
"tasks": [ { "id": "t2", "assignee": "w1", "title": "t2", "status": "pending" } ]
|
|
1273
1507
|
}),
|
|
1274
|
-
)
|
|
1508
|
+
)
|
|
1509
|
+
.unwrap();
|
|
1275
1510
|
let store = store_for(&ws);
|
|
1276
1511
|
seed_result(&store, "res_t2", "t2", "w1", "success");
|
|
1277
1512
|
|
|
1278
1513
|
let out = collect(&ws, None, false).unwrap();
|
|
1279
|
-
assert_eq!(
|
|
1280
|
-
|
|
1514
|
+
assert_eq!(
|
|
1515
|
+
out.get("ok").and_then(|v| v.as_bool()),
|
|
1516
|
+
Some(true),
|
|
1517
|
+
"no invalid → ok:true"
|
|
1518
|
+
);
|
|
1519
|
+
let cr = out
|
|
1520
|
+
.get("collected_results")
|
|
1521
|
+
.and_then(|v| v.as_array())
|
|
1522
|
+
.expect("collected_results");
|
|
1281
1523
|
assert_eq!(cr.len(), 1, "the seeded t2 result must collect");
|
|
1282
|
-
assert_eq!(
|
|
1524
|
+
assert_eq!(
|
|
1525
|
+
cr[0].get("scope").and_then(|v| v.as_str()),
|
|
1526
|
+
Some("task"),
|
|
1527
|
+
"t2 ∈ state.tasks → scope:task"
|
|
1528
|
+
);
|
|
1283
1529
|
assert_eq!(cr[0].get("task_id").and_then(|v| v.as_str()), Some("t2"));
|
|
1284
1530
|
assert_eq!(cr[0].get("agent_id").and_then(|v| v.as_str()), Some("w1"));
|
|
1285
1531
|
assert!(
|
|
1286
|
-
out.get("results")
|
|
1532
|
+
out.get("results")
|
|
1533
|
+
.and_then(|r| r.get("collected"))
|
|
1534
|
+
.and_then(|v| v.as_i64())
|
|
1535
|
+
.unwrap_or(0)
|
|
1536
|
+
>= 1,
|
|
1287
1537
|
"results.collected must be ≥ 1"
|
|
1288
1538
|
);
|
|
1289
1539
|
let st = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
1290
|
-
let t2_status = st
|
|
1291
|
-
.
|
|
1292
|
-
.and_then(|
|
|
1293
|
-
|
|
1540
|
+
let t2_status = st
|
|
1541
|
+
.get("tasks")
|
|
1542
|
+
.and_then(|v| v.as_array())
|
|
1543
|
+
.and_then(|ts| {
|
|
1544
|
+
ts.iter()
|
|
1545
|
+
.find(|t| t.get("id").and_then(|v| v.as_str()) == Some("t2"))
|
|
1546
|
+
})
|
|
1547
|
+
.and_then(|t| t.get("status"))
|
|
1548
|
+
.and_then(|v| v.as_str());
|
|
1549
|
+
assert_eq!(
|
|
1550
|
+
t2_status,
|
|
1551
|
+
Some("done"),
|
|
1552
|
+
"success result → task row status 'done' (runtime.py:1066)"
|
|
1553
|
+
);
|
|
1294
1554
|
}
|
|
1295
1555
|
|
|
1296
1556
|
// (c-C1) collect OUTPUT shape: collected_results entries are the 8-KEY SUMMARY (NO inlined
|
|
@@ -1308,30 +1568,46 @@ fn collect_output_matches_golden_collected_shape() {
|
|
|
1308
1568
|
"agents": { "w1": { "provider": "codex" } },
|
|
1309
1569
|
"tasks": [ { "id": "t2", "assignee": "w1", "title": "t2", "status": "pending" } ]
|
|
1310
1570
|
}),
|
|
1311
|
-
)
|
|
1571
|
+
)
|
|
1572
|
+
.unwrap();
|
|
1312
1573
|
let store = store_for(&ws);
|
|
1313
1574
|
seed_result(&store, "res_t2s", "t2", "w1", "success");
|
|
1314
1575
|
|
|
1315
1576
|
let out = collect(&ws, None, false).unwrap();
|
|
1316
|
-
let cr = out
|
|
1577
|
+
let cr = out
|
|
1578
|
+
.get("collected_results")
|
|
1579
|
+
.and_then(|v| v.as_array())
|
|
1580
|
+
.expect("collected_results");
|
|
1317
1581
|
let e = &cr[0];
|
|
1318
1582
|
// C1: collected_results entry is the 8-key SUMMARY — NO envelope inlined; carries summary+tests.
|
|
1319
1583
|
assert!(e.get("envelope").is_none(),
|
|
1320
1584
|
"collected_results entry must NOT inline `envelope` (golden 8-key summary); the full envelope belongs in `collected`. got {e:?}");
|
|
1321
|
-
assert!(
|
|
1322
|
-
"
|
|
1585
|
+
assert!(
|
|
1586
|
+
e.get("summary").is_some() && e.get("tests").is_some(),
|
|
1587
|
+
"collected_results summary entry must carry `summary`+`tests` (golden results.py:131)"
|
|
1588
|
+
);
|
|
1323
1589
|
// C1: the full envelopes live in a separate top-level `collected` list.
|
|
1324
|
-
let collected = out
|
|
1590
|
+
let collected = out
|
|
1591
|
+
.get("collected")
|
|
1592
|
+
.and_then(|v| v.as_array())
|
|
1325
1593
|
.expect("golden collect returns a top-level `collected` list of full envelopes");
|
|
1326
1594
|
assert!(
|
|
1327
|
-
collected
|
|
1595
|
+
collected
|
|
1596
|
+
.first()
|
|
1597
|
+
.and_then(|env| env.get("schema_version"))
|
|
1598
|
+
.and_then(|v| v.as_str())
|
|
1328
1599
|
== Some("result_envelope_v1"),
|
|
1329
1600
|
"collected[0] must be the full result_envelope_v1 envelope; got {collected:?}"
|
|
1330
1601
|
);
|
|
1331
1602
|
|
|
1332
1603
|
// ── STRENGTHENED (option-B byte-parity, leader-adjudicated 0700cff review) ──
|
|
1333
1604
|
// D3 — task-scope collected_results entry must be EXACTLY the golden 8 keys, in order, NO task_status.
|
|
1334
|
-
let keys: Vec<&str> = e
|
|
1605
|
+
let keys: Vec<&str> = e
|
|
1606
|
+
.as_object()
|
|
1607
|
+
.expect("entry is an object")
|
|
1608
|
+
.keys()
|
|
1609
|
+
.map(String::as_str)
|
|
1610
|
+
.collect();
|
|
1335
1611
|
assert_eq!(
|
|
1336
1612
|
keys,
|
|
1337
1613
|
vec!["result_id", "task_id", "agent_id", "status", "summary", "tests", "created_at", "scope"],
|
|
@@ -1339,10 +1615,24 @@ fn collect_output_matches_golden_collected_shape() {
|
|
|
1339
1615
|
);
|
|
1340
1616
|
// D1+D2 — collect RETURN top-level key order must match golden EXACTLY: delivered_messages BEFORE
|
|
1341
1617
|
// invalid_results, AND a `coordinator` key (mirroring golden _ensure_coordinator_after_collect).
|
|
1342
|
-
let top: Vec<&str> = out
|
|
1618
|
+
let top: Vec<&str> = out
|
|
1619
|
+
.as_object()
|
|
1620
|
+
.expect("collect result is an object")
|
|
1621
|
+
.keys()
|
|
1622
|
+
.map(String::as_str)
|
|
1623
|
+
.collect();
|
|
1343
1624
|
assert_eq!(
|
|
1344
1625
|
top,
|
|
1345
|
-
vec![
|
|
1626
|
+
vec![
|
|
1627
|
+
"ok",
|
|
1628
|
+
"collected",
|
|
1629
|
+
"collected_results",
|
|
1630
|
+
"delivered_messages",
|
|
1631
|
+
"invalid_results",
|
|
1632
|
+
"results",
|
|
1633
|
+
"state_file",
|
|
1634
|
+
"coordinator"
|
|
1635
|
+
],
|
|
1346
1636
|
"collect return top-level key order must match golden return shape; got {top:?}"
|
|
1347
1637
|
);
|
|
1348
1638
|
}
|
|
@@ -1360,7 +1650,8 @@ fn send_with_unknown_task_id_raises_unknown_task() {
|
|
|
1360
1650
|
"agents": { "w1": { "provider": "codex" } },
|
|
1361
1651
|
"tasks": []
|
|
1362
1652
|
}),
|
|
1363
|
-
)
|
|
1653
|
+
)
|
|
1654
|
+
.unwrap();
|
|
1364
1655
|
let _ = store_for(&ws);
|
|
1365
1656
|
let opts = SendOptions {
|
|
1366
1657
|
task_id: Some(crate::model::ids::TaskId::new("t2-unknown")),
|
|
@@ -1435,7 +1726,8 @@ fn send_route_task_id_true_known_task_succeeds() {
|
|
|
1435
1726
|
"agents": { "w1": { "provider": "codex" } },
|
|
1436
1727
|
"tasks": [ { "id": "t-known", "assignee": "w1", "title": "t", "status": "pending" } ]
|
|
1437
1728
|
}),
|
|
1438
|
-
)
|
|
1729
|
+
)
|
|
1730
|
+
.unwrap();
|
|
1439
1731
|
let _ = store_for(&ws);
|
|
1440
1732
|
let opts = SendOptions {
|
|
1441
1733
|
task_id: Some(crate::model::ids::TaskId::new("t-known")),
|
|
@@ -1445,7 +1737,10 @@ fn send_route_task_id_true_known_task_succeeds() {
|
|
|
1445
1737
|
};
|
|
1446
1738
|
let out = send_message(&ws, &MessageTarget::Single("w1".to_string()), "go", &opts)
|
|
1447
1739
|
.expect("route_task_id=true with a KNOWN task must succeed");
|
|
1448
|
-
assert!(
|
|
1740
|
+
assert!(
|
|
1741
|
+
out.message_id.is_some(),
|
|
1742
|
+
"known-task routing send must create the message; got {out:?}"
|
|
1743
|
+
);
|
|
1449
1744
|
}
|
|
1450
1745
|
|
|
1451
1746
|
// ════════════════════════════════════════════════════════════════════════
|
|
@@ -1468,7 +1763,16 @@ fn r8_attach_requeue_exhausted_to_notify_failed_golden_attach_event() {
|
|
|
1468
1763
|
|
|
1469
1764
|
// --- Sub-A: DRIVE w-r8 (team-a) to delivery_exhausted via notify_result_watchers (attempts>=MAX) ---
|
|
1470
1765
|
let rid = seed_result(&store, "res_r8", "t1", "alice", "success");
|
|
1471
|
-
seed_watcher(
|
|
1766
|
+
seed_watcher(
|
|
1767
|
+
&store,
|
|
1768
|
+
"w-r8",
|
|
1769
|
+
"team-a",
|
|
1770
|
+
"t1",
|
|
1771
|
+
"alice",
|
|
1772
|
+
"pending",
|
|
1773
|
+
Some(&rid),
|
|
1774
|
+
None,
|
|
1775
|
+
);
|
|
1472
1776
|
// attempts are EVENT-counted (result_watcher.notify_failed/retry_notified) — seed MAX prior failures.
|
|
1473
1777
|
for n in 0..u64::from(RESULT_DELIVERY_MAX_ATTEMPTS) {
|
|
1474
1778
|
log.write(
|
|
@@ -1476,7 +1780,8 @@ fn r8_attach_requeue_exhausted_to_notify_failed_golden_attach_event() {
|
|
|
1476
1780
|
json(serde_json::json!({"watcher_id": "w-r8", "result_id": rid.as_str(), "status": "notify_failed", "error": "x", "n": n})),
|
|
1477
1781
|
).unwrap();
|
|
1478
1782
|
}
|
|
1479
|
-
let result_env =
|
|
1783
|
+
let result_env =
|
|
1784
|
+
json(serde_json::json!({"result_id": rid.as_str(), "task_id": "t1", "agent_id": "alice"}));
|
|
1480
1785
|
let watcher_view = json(serde_json::json!({
|
|
1481
1786
|
"watcher_id": "w-r8", "task_id": "t1", "agent_id": "alice",
|
|
1482
1787
|
"created_at": "2026-01-01T00:00:00Z", "owner_team_id": "team-a",
|
|
@@ -1489,9 +1794,36 @@ fn r8_attach_requeue_exhausted_to_notify_failed_golden_attach_event() {
|
|
|
1489
1794
|
proves the attach-requeue input is real, not 空过");
|
|
1490
1795
|
|
|
1491
1796
|
// selection-lock fixtures: cross-team exhausted + notified exhausted (Gap-32) + pending.
|
|
1492
|
-
let team_b = seed_watcher(
|
|
1493
|
-
|
|
1494
|
-
|
|
1797
|
+
let team_b = seed_watcher(
|
|
1798
|
+
&store,
|
|
1799
|
+
"w-teamb",
|
|
1800
|
+
"team-b",
|
|
1801
|
+
"t2",
|
|
1802
|
+
"bob",
|
|
1803
|
+
"delivery_exhausted",
|
|
1804
|
+
Some("res_b"),
|
|
1805
|
+
None,
|
|
1806
|
+
);
|
|
1807
|
+
let notif = seed_watcher(
|
|
1808
|
+
&store,
|
|
1809
|
+
"w-notified",
|
|
1810
|
+
"team-a",
|
|
1811
|
+
"t3",
|
|
1812
|
+
"carol",
|
|
1813
|
+
"delivery_exhausted",
|
|
1814
|
+
Some("res_c"),
|
|
1815
|
+
Some("msg_done"),
|
|
1816
|
+
);
|
|
1817
|
+
seed_watcher(
|
|
1818
|
+
&store,
|
|
1819
|
+
"w-pending",
|
|
1820
|
+
"team-a",
|
|
1821
|
+
"t4",
|
|
1822
|
+
"dave",
|
|
1823
|
+
"pending",
|
|
1824
|
+
Some("res_d"),
|
|
1825
|
+
None,
|
|
1826
|
+
);
|
|
1495
1827
|
|
|
1496
1828
|
// --- Sub-B: attach requeue (golden contract) ---
|
|
1497
1829
|
let requeued = requeue_delivery_exhausted_watchers(&ws, &store, &log, &team, &pane).unwrap();
|
|
@@ -1506,24 +1838,50 @@ fn r8_attach_requeue_exhausted_to_notify_failed_golden_attach_event() {
|
|
|
1506
1838
|
"D1 ✦: team-scoped selection — a team-b exhausted watcher must NOT be requeued by a team-a attach (anti cross-team pollution / CP-1)");
|
|
1507
1839
|
// Gap-32: a notified watcher is never requeued; its notified_message_id survives.
|
|
1508
1840
|
let (st_n, nid) = watcher_state(&store, ¬if);
|
|
1509
|
-
assert_eq!(
|
|
1510
|
-
|
|
1841
|
+
assert_eq!(
|
|
1842
|
+
st_n, "delivery_exhausted",
|
|
1843
|
+
"Gap-32: notified watcher not requeued"
|
|
1844
|
+
);
|
|
1845
|
+
assert_eq!(
|
|
1846
|
+
nid.as_deref(),
|
|
1847
|
+
Some("msg_done"),
|
|
1848
|
+
"Gap-32: notified_message_id preserved"
|
|
1849
|
+
);
|
|
1511
1850
|
// only the team-a unnotified exhausted watcher requeues.
|
|
1512
1851
|
let ids: Vec<&str> = requeued.iter().map(|n| n.watcher_id.as_str()).collect();
|
|
1513
|
-
assert_eq!(
|
|
1852
|
+
assert_eq!(
|
|
1853
|
+
ids,
|
|
1854
|
+
vec!["w-r8"],
|
|
1855
|
+
"only team-a unnotified delivery_exhausted watcher requeues"
|
|
1856
|
+
);
|
|
1514
1857
|
|
|
1515
1858
|
// D3: result_watcher.requeued payload == golden ATTACH form {watcher_id, trigger, new_pane_id}.
|
|
1516
1859
|
let events = log.tail(0).unwrap();
|
|
1517
|
-
let ev = events
|
|
1860
|
+
let ev = events
|
|
1861
|
+
.iter()
|
|
1862
|
+
.rev()
|
|
1518
1863
|
.find(|e| e.get("event").and_then(|v| v.as_str()) == Some("result_watcher.requeued"))
|
|
1519
1864
|
.expect("result_watcher.requeued event");
|
|
1520
|
-
let keys: std::collections::BTreeSet<&str> = ev
|
|
1521
|
-
.
|
|
1522
|
-
|
|
1865
|
+
let keys: std::collections::BTreeSet<&str> = ev
|
|
1866
|
+
.as_object()
|
|
1867
|
+
.unwrap()
|
|
1868
|
+
.keys()
|
|
1869
|
+
.map(String::as_str)
|
|
1870
|
+
.filter(|k| *k != "ts" && *k != "event")
|
|
1871
|
+
.collect();
|
|
1872
|
+
let expected: std::collections::BTreeSet<&str> = ["watcher_id", "trigger", "new_pane_id"]
|
|
1873
|
+
.into_iter()
|
|
1874
|
+
.collect();
|
|
1523
1875
|
assert_eq!(keys, expected,
|
|
1524
1876
|
"D3: result_watcher.requeued must be golden ATTACH form {{watcher_id, trigger, new_pane_id}} (leader/__init__.py:46-50), not claim-style; got {keys:?}");
|
|
1525
|
-
assert_eq!(
|
|
1526
|
-
|
|
1877
|
+
assert_eq!(
|
|
1878
|
+
ev.get("trigger").and_then(|v| v.as_str()),
|
|
1879
|
+
Some("attach_leader")
|
|
1880
|
+
);
|
|
1881
|
+
assert_eq!(
|
|
1882
|
+
ev.get("new_pane_id").and_then(|v| v.as_str()),
|
|
1883
|
+
Some("%leader-new")
|
|
1884
|
+
);
|
|
1527
1885
|
}
|
|
1528
1886
|
|
|
1529
1887
|
// E15 (F4.4 双投修)·源码守卫:report_result 的 direct inject 必须被 `if !outcome.ok` 守为
|
|
@@ -1536,8 +1894,14 @@ fn e15_direct_inject_is_gated_by_deliver_failure_not_unconditional() {
|
|
|
1536
1894
|
let inject_call = "match inject_leader_notification_direct(";
|
|
1537
1895
|
let gate_pos = src.find(gate);
|
|
1538
1896
|
let inject_pos = src.find(inject_call);
|
|
1539
|
-
assert!(
|
|
1540
|
-
|
|
1897
|
+
assert!(
|
|
1898
|
+
gate_pos.is_some(),
|
|
1899
|
+
"E15: direct inject must be gated by `if !outcome.ok` (deliver-fail fallback)"
|
|
1900
|
+
);
|
|
1901
|
+
assert!(
|
|
1902
|
+
inject_pos.is_some(),
|
|
1903
|
+
"inject_leader_notification_direct call site must exist (do NOT delete it; #230 fallback)"
|
|
1904
|
+
);
|
|
1541
1905
|
assert!(
|
|
1542
1906
|
gate_pos.unwrap() < inject_pos.unwrap(),
|
|
1543
1907
|
"E15: the `if !outcome.ok` gate must precede the direct-inject call (deliver-success must skip inject → leader gets exactly one copy)"
|