@team-agent/installer 0.3.9 → 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/tests/spine.rs +6 -0
- package/crates/team-agent/src/coordinator/tick.rs +410 -168
- package/crates/team-agent/src/leader/lease.rs +19 -0
- package/crates/team-agent/src/leader/rediscover/tests.rs +12 -0
- package/crates/team-agent/src/leader/rediscover.rs +2 -0
- package/crates/team-agent/src/lifecycle/launch.rs +35 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +17 -3
- package/crates/team-agent/src/lifecycle/restart/common.rs +75 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +201 -3
- package/crates/team-agent/src/lifecycle/restart/selection.rs +51 -14
- package/crates/team-agent/src/lifecycle/restart.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/core.rs +89 -15
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +68 -3
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +3 -1
- 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/delivery.rs +83 -2
- 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 +565 -111
- package/crates/team-agent/src/messaging/types.rs +19 -4
- package/crates/team-agent/src/provider/approvals/parsing.rs +43 -14
- package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +12 -9
- package/crates/team-agent/src/transport/test_support.rs +12 -1
- 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,138 @@ 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
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
struct UnverifiedInjectTransport;
|
|
1242
|
+
impl Transport for UnverifiedInjectTransport {
|
|
1243
|
+
fn kind(&self) -> BackendKind {
|
|
1244
|
+
BackendKind::Tmux
|
|
1245
|
+
}
|
|
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> {
|
|
1254
|
+
unimplemented!("not reached in delivery")
|
|
1255
|
+
}
|
|
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> {
|
|
1264
|
+
unimplemented!("not reached in delivery")
|
|
1265
|
+
}
|
|
1266
|
+
fn inject(
|
|
1267
|
+
&self,
|
|
1268
|
+
_t: &Target,
|
|
1269
|
+
_p: &InjectPayload,
|
|
1270
|
+
_s: Key,
|
|
1271
|
+
_b: bool,
|
|
1272
|
+
) -> Result<InjectReport, TransportError> {
|
|
1273
|
+
Ok(InjectReport {
|
|
1274
|
+
stage_reached: crate::transport::InjectStage::Submit,
|
|
1275
|
+
inject_verification: crate::transport::InjectVerification::CaptureContainsToken,
|
|
1276
|
+
submit_verification:
|
|
1277
|
+
crate::transport::SubmitVerification::PastedContentPromptStillPresentAfterSubmit,
|
|
1278
|
+
turn_verification: crate::transport::TurnVerification::NotYetObserved,
|
|
1279
|
+
attempts: u32::from(SEND_RETRY_MAX_ATTEMPTS),
|
|
1280
|
+
})
|
|
1281
|
+
}
|
|
1282
|
+
fn send_keys(&self, _t: &Target, _k: &[Key]) -> Result<(), TransportError> {
|
|
1283
|
+
Ok(())
|
|
1284
|
+
}
|
|
1285
|
+
fn capture(&self, _t: &Target, range: CaptureRange) -> Result<CapturedText, TransportError> {
|
|
1286
|
+
Ok(CapturedText {
|
|
1287
|
+
text: String::new(),
|
|
1288
|
+
range,
|
|
1289
|
+
})
|
|
1290
|
+
}
|
|
1291
|
+
fn query(&self, _t: &Target, _f: PaneField) -> Result<Option<String>, TransportError> {
|
|
1292
|
+
Ok(None)
|
|
1293
|
+
}
|
|
1294
|
+
fn liveness(&self, _p: &PaneId) -> Result<PaneLiveness, TransportError> {
|
|
1295
|
+
Ok(PaneLiveness::Unknown)
|
|
1296
|
+
}
|
|
1297
|
+
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
1298
|
+
Ok(Vec::new())
|
|
1299
|
+
}
|
|
1300
|
+
fn has_session(&self, _s: &SessionName) -> Result<bool, TransportError> {
|
|
1301
|
+
Ok(true)
|
|
1302
|
+
}
|
|
1303
|
+
fn list_windows(&self, _s: &SessionName) -> Result<Vec<WindowName>, TransportError> {
|
|
1304
|
+
Ok(Vec::new())
|
|
1305
|
+
}
|
|
1306
|
+
fn set_session_env(
|
|
1307
|
+
&self,
|
|
1308
|
+
_s: &SessionName,
|
|
1309
|
+
_k: &str,
|
|
1310
|
+
_v: &str,
|
|
1311
|
+
) -> Result<SetEnvOutcome, TransportError> {
|
|
1312
|
+
Ok(SetEnvOutcome::Applied)
|
|
1313
|
+
}
|
|
1314
|
+
fn kill_session(&self, _s: &SessionName) -> Result<(), TransportError> {
|
|
1315
|
+
Ok(())
|
|
1316
|
+
}
|
|
1317
|
+
fn kill_window(&self, _t: &Target) -> Result<(), TransportError> {
|
|
1318
|
+
Ok(())
|
|
1319
|
+
}
|
|
1320
|
+
fn attach_session(&self, _s: &SessionName) -> Result<AttachOutcome, TransportError> {
|
|
1321
|
+
Ok(AttachOutcome::Attached)
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
#[test]
|
|
1326
|
+
fn deliver_pending_exhausted_unverified_send_emits_failed_event() {
|
|
1327
|
+
let ws = tmp_ws("sendfailed");
|
|
1328
|
+
let store = store_for(&ws);
|
|
1329
|
+
let log = EventLog::new(&ws);
|
|
1330
|
+
let state = serde_json::json!({
|
|
1331
|
+
"session_name": "team-sendfailed",
|
|
1332
|
+
"leader_receiver": {"pane_id": "%leader"},
|
|
1333
|
+
"agents": {"w1": {"provider": "fake", "pane_id": "%1"}}
|
|
1334
|
+
});
|
|
1335
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
1336
|
+
let message_id = store
|
|
1337
|
+
.create_message(None, "leader", "w1", "ping", None, false, None)
|
|
1338
|
+
.unwrap();
|
|
1339
|
+
|
|
1340
|
+
let out = deliver_pending_message(
|
|
1341
|
+
&ws,
|
|
1342
|
+
&store,
|
|
1343
|
+
&UnverifiedInjectTransport,
|
|
1344
|
+
&message_id,
|
|
1345
|
+
&log,
|
|
1346
|
+
&state,
|
|
1347
|
+
)
|
|
1348
|
+
.unwrap();
|
|
1349
|
+
|
|
1350
|
+
assert!(!out.ok);
|
|
1351
|
+
assert_eq!(out.message_status.0, "failed");
|
|
1352
|
+
let events = log.tail(0).unwrap();
|
|
1353
|
+
assert!(
|
|
1354
|
+
events.iter().any(
|
|
1355
|
+
|event| event.get("event").and_then(serde_json::Value::as_str) == Some("send.failed")
|
|
1356
|
+
),
|
|
1357
|
+
"exhausted unverified send must emit send.failed; got {events:?}"
|
|
1358
|
+
);
|
|
1359
|
+
assert!(
|
|
1360
|
+
events.iter().any(
|
|
1361
|
+
|event| event.get("event").and_then(serde_json::Value::as_str)
|
|
1362
|
+
== Some("send.failed_notification")
|
|
1363
|
+
),
|
|
1364
|
+
"exhausted unverified send must queue a leader-visible notification; got {events:?}"
|
|
1365
|
+
);
|
|
1069
1366
|
}
|
|
1070
1367
|
|
|
1071
1368
|
// ════════════════════════════════════════════════════════════════════════
|
|
@@ -1089,14 +1386,28 @@ fn retry_result_deliveries_retries_notify_failed_watcher() {
|
|
|
1089
1386
|
|
|
1090
1387
|
let rid = seed_result(&store, "res_r1", "t1", "alice", "success");
|
|
1091
1388
|
let w = seed_watcher(
|
|
1092
|
-
&store,
|
|
1389
|
+
&store,
|
|
1390
|
+
"w-failed",
|
|
1391
|
+
"team-a",
|
|
1392
|
+
"t1",
|
|
1393
|
+
"alice",
|
|
1394
|
+
"notify_failed",
|
|
1395
|
+
Some(&rid),
|
|
1396
|
+
None,
|
|
1093
1397
|
);
|
|
1094
1398
|
|
|
1095
1399
|
let notices = retry_result_deliveries(&ws, &log).unwrap();
|
|
1096
1400
|
|
|
1097
|
-
assert_eq!(
|
|
1401
|
+
assert_eq!(
|
|
1402
|
+
notices.len(),
|
|
1403
|
+
1,
|
|
1404
|
+
"the single notify_failed watcher must be retried"
|
|
1405
|
+
);
|
|
1098
1406
|
let notice = ¬ices[0];
|
|
1099
|
-
assert_eq!(
|
|
1407
|
+
assert_eq!(
|
|
1408
|
+
notice.watcher_id, w,
|
|
1409
|
+
"the retried notice names the seeded watcher"
|
|
1410
|
+
);
|
|
1100
1411
|
assert_eq!(
|
|
1101
1412
|
notice.result_id.as_deref(),
|
|
1102
1413
|
Some(rid.as_str()),
|
|
@@ -1124,18 +1435,31 @@ fn collect_results_and_notify_watchers_returns_concrete_ok_shape() {
|
|
|
1124
1435
|
let log = EventLog::new(&ws);
|
|
1125
1436
|
|
|
1126
1437
|
seed_watcher(
|
|
1127
|
-
&store,
|
|
1438
|
+
&store,
|
|
1439
|
+
"w-orphan",
|
|
1440
|
+
"team-a",
|
|
1441
|
+
"t1",
|
|
1442
|
+
"alice",
|
|
1443
|
+
"notify_failed",
|
|
1444
|
+
Some("res_missing"),
|
|
1445
|
+
None,
|
|
1128
1446
|
);
|
|
1129
1447
|
|
|
1130
1448
|
let out = collect_results_and_notify_watchers(&ws, &log).unwrap();
|
|
1131
|
-
assert_eq!(
|
|
1449
|
+
assert_eq!(
|
|
1450
|
+
out.get("ok").and_then(|v| v.as_bool()),
|
|
1451
|
+
Some(true),
|
|
1452
|
+
"ok==true"
|
|
1453
|
+
);
|
|
1132
1454
|
assert_eq!(
|
|
1133
1455
|
out.get("collected").and_then(|v| v.as_i64()),
|
|
1134
1456
|
Some(0),
|
|
1135
1457
|
"no uncollected results → collected==0"
|
|
1136
1458
|
);
|
|
1137
1459
|
assert_eq!(
|
|
1138
|
-
out.get("notified")
|
|
1460
|
+
out.get("notified")
|
|
1461
|
+
.and_then(|v| v.as_array())
|
|
1462
|
+
.map(|a| a.len()),
|
|
1139
1463
|
Some(0),
|
|
1140
1464
|
"orphan watcher (missing result) is skipped → notified empty"
|
|
1141
1465
|
);
|
|
@@ -1181,26 +1505,52 @@ fn collect_task_scoped_result_collects_and_marks_task_done() {
|
|
|
1181
1505
|
"agents": { "w1": { "provider": "codex" } },
|
|
1182
1506
|
"tasks": [ { "id": "t2", "assignee": "w1", "title": "t2", "status": "pending" } ]
|
|
1183
1507
|
}),
|
|
1184
|
-
)
|
|
1508
|
+
)
|
|
1509
|
+
.unwrap();
|
|
1185
1510
|
let store = store_for(&ws);
|
|
1186
1511
|
seed_result(&store, "res_t2", "t2", "w1", "success");
|
|
1187
1512
|
|
|
1188
1513
|
let out = collect(&ws, None, false).unwrap();
|
|
1189
|
-
assert_eq!(
|
|
1190
|
-
|
|
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");
|
|
1191
1523
|
assert_eq!(cr.len(), 1, "the seeded t2 result must collect");
|
|
1192
|
-
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
|
+
);
|
|
1193
1529
|
assert_eq!(cr[0].get("task_id").and_then(|v| v.as_str()), Some("t2"));
|
|
1194
1530
|
assert_eq!(cr[0].get("agent_id").and_then(|v| v.as_str()), Some("w1"));
|
|
1195
1531
|
assert!(
|
|
1196
|
-
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,
|
|
1197
1537
|
"results.collected must be ≥ 1"
|
|
1198
1538
|
);
|
|
1199
1539
|
let st = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
1200
|
-
let t2_status = st
|
|
1201
|
-
.
|
|
1202
|
-
.and_then(|
|
|
1203
|
-
|
|
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
|
+
);
|
|
1204
1554
|
}
|
|
1205
1555
|
|
|
1206
1556
|
// (c-C1) collect OUTPUT shape: collected_results entries are the 8-KEY SUMMARY (NO inlined
|
|
@@ -1218,30 +1568,46 @@ fn collect_output_matches_golden_collected_shape() {
|
|
|
1218
1568
|
"agents": { "w1": { "provider": "codex" } },
|
|
1219
1569
|
"tasks": [ { "id": "t2", "assignee": "w1", "title": "t2", "status": "pending" } ]
|
|
1220
1570
|
}),
|
|
1221
|
-
)
|
|
1571
|
+
)
|
|
1572
|
+
.unwrap();
|
|
1222
1573
|
let store = store_for(&ws);
|
|
1223
1574
|
seed_result(&store, "res_t2s", "t2", "w1", "success");
|
|
1224
1575
|
|
|
1225
1576
|
let out = collect(&ws, None, false).unwrap();
|
|
1226
|
-
let cr = out
|
|
1577
|
+
let cr = out
|
|
1578
|
+
.get("collected_results")
|
|
1579
|
+
.and_then(|v| v.as_array())
|
|
1580
|
+
.expect("collected_results");
|
|
1227
1581
|
let e = &cr[0];
|
|
1228
1582
|
// C1: collected_results entry is the 8-key SUMMARY — NO envelope inlined; carries summary+tests.
|
|
1229
1583
|
assert!(e.get("envelope").is_none(),
|
|
1230
1584
|
"collected_results entry must NOT inline `envelope` (golden 8-key summary); the full envelope belongs in `collected`. got {e:?}");
|
|
1231
|
-
assert!(
|
|
1232
|
-
"
|
|
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
|
+
);
|
|
1233
1589
|
// C1: the full envelopes live in a separate top-level `collected` list.
|
|
1234
|
-
let collected = out
|
|
1590
|
+
let collected = out
|
|
1591
|
+
.get("collected")
|
|
1592
|
+
.and_then(|v| v.as_array())
|
|
1235
1593
|
.expect("golden collect returns a top-level `collected` list of full envelopes");
|
|
1236
1594
|
assert!(
|
|
1237
|
-
collected
|
|
1595
|
+
collected
|
|
1596
|
+
.first()
|
|
1597
|
+
.and_then(|env| env.get("schema_version"))
|
|
1598
|
+
.and_then(|v| v.as_str())
|
|
1238
1599
|
== Some("result_envelope_v1"),
|
|
1239
1600
|
"collected[0] must be the full result_envelope_v1 envelope; got {collected:?}"
|
|
1240
1601
|
);
|
|
1241
1602
|
|
|
1242
1603
|
// ── STRENGTHENED (option-B byte-parity, leader-adjudicated 0700cff review) ──
|
|
1243
1604
|
// D3 — task-scope collected_results entry must be EXACTLY the golden 8 keys, in order, NO task_status.
|
|
1244
|
-
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();
|
|
1245
1611
|
assert_eq!(
|
|
1246
1612
|
keys,
|
|
1247
1613
|
vec!["result_id", "task_id", "agent_id", "status", "summary", "tests", "created_at", "scope"],
|
|
@@ -1249,10 +1615,24 @@ fn collect_output_matches_golden_collected_shape() {
|
|
|
1249
1615
|
);
|
|
1250
1616
|
// D1+D2 — collect RETURN top-level key order must match golden EXACTLY: delivered_messages BEFORE
|
|
1251
1617
|
// invalid_results, AND a `coordinator` key (mirroring golden _ensure_coordinator_after_collect).
|
|
1252
|
-
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();
|
|
1253
1624
|
assert_eq!(
|
|
1254
1625
|
top,
|
|
1255
|
-
vec![
|
|
1626
|
+
vec![
|
|
1627
|
+
"ok",
|
|
1628
|
+
"collected",
|
|
1629
|
+
"collected_results",
|
|
1630
|
+
"delivered_messages",
|
|
1631
|
+
"invalid_results",
|
|
1632
|
+
"results",
|
|
1633
|
+
"state_file",
|
|
1634
|
+
"coordinator"
|
|
1635
|
+
],
|
|
1256
1636
|
"collect return top-level key order must match golden return shape; got {top:?}"
|
|
1257
1637
|
);
|
|
1258
1638
|
}
|
|
@@ -1270,7 +1650,8 @@ fn send_with_unknown_task_id_raises_unknown_task() {
|
|
|
1270
1650
|
"agents": { "w1": { "provider": "codex" } },
|
|
1271
1651
|
"tasks": []
|
|
1272
1652
|
}),
|
|
1273
|
-
)
|
|
1653
|
+
)
|
|
1654
|
+
.unwrap();
|
|
1274
1655
|
let _ = store_for(&ws);
|
|
1275
1656
|
let opts = SendOptions {
|
|
1276
1657
|
task_id: Some(crate::model::ids::TaskId::new("t2-unknown")),
|
|
@@ -1345,7 +1726,8 @@ fn send_route_task_id_true_known_task_succeeds() {
|
|
|
1345
1726
|
"agents": { "w1": { "provider": "codex" } },
|
|
1346
1727
|
"tasks": [ { "id": "t-known", "assignee": "w1", "title": "t", "status": "pending" } ]
|
|
1347
1728
|
}),
|
|
1348
|
-
)
|
|
1729
|
+
)
|
|
1730
|
+
.unwrap();
|
|
1349
1731
|
let _ = store_for(&ws);
|
|
1350
1732
|
let opts = SendOptions {
|
|
1351
1733
|
task_id: Some(crate::model::ids::TaskId::new("t-known")),
|
|
@@ -1355,7 +1737,10 @@ fn send_route_task_id_true_known_task_succeeds() {
|
|
|
1355
1737
|
};
|
|
1356
1738
|
let out = send_message(&ws, &MessageTarget::Single("w1".to_string()), "go", &opts)
|
|
1357
1739
|
.expect("route_task_id=true with a KNOWN task must succeed");
|
|
1358
|
-
assert!(
|
|
1740
|
+
assert!(
|
|
1741
|
+
out.message_id.is_some(),
|
|
1742
|
+
"known-task routing send must create the message; got {out:?}"
|
|
1743
|
+
);
|
|
1359
1744
|
}
|
|
1360
1745
|
|
|
1361
1746
|
// ════════════════════════════════════════════════════════════════════════
|
|
@@ -1378,7 +1763,16 @@ fn r8_attach_requeue_exhausted_to_notify_failed_golden_attach_event() {
|
|
|
1378
1763
|
|
|
1379
1764
|
// --- Sub-A: DRIVE w-r8 (team-a) to delivery_exhausted via notify_result_watchers (attempts>=MAX) ---
|
|
1380
1765
|
let rid = seed_result(&store, "res_r8", "t1", "alice", "success");
|
|
1381
|
-
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
|
+
);
|
|
1382
1776
|
// attempts are EVENT-counted (result_watcher.notify_failed/retry_notified) — seed MAX prior failures.
|
|
1383
1777
|
for n in 0..u64::from(RESULT_DELIVERY_MAX_ATTEMPTS) {
|
|
1384
1778
|
log.write(
|
|
@@ -1386,7 +1780,8 @@ fn r8_attach_requeue_exhausted_to_notify_failed_golden_attach_event() {
|
|
|
1386
1780
|
json(serde_json::json!({"watcher_id": "w-r8", "result_id": rid.as_str(), "status": "notify_failed", "error": "x", "n": n})),
|
|
1387
1781
|
).unwrap();
|
|
1388
1782
|
}
|
|
1389
|
-
let result_env =
|
|
1783
|
+
let result_env =
|
|
1784
|
+
json(serde_json::json!({"result_id": rid.as_str(), "task_id": "t1", "agent_id": "alice"}));
|
|
1390
1785
|
let watcher_view = json(serde_json::json!({
|
|
1391
1786
|
"watcher_id": "w-r8", "task_id": "t1", "agent_id": "alice",
|
|
1392
1787
|
"created_at": "2026-01-01T00:00:00Z", "owner_team_id": "team-a",
|
|
@@ -1399,9 +1794,36 @@ fn r8_attach_requeue_exhausted_to_notify_failed_golden_attach_event() {
|
|
|
1399
1794
|
proves the attach-requeue input is real, not 空过");
|
|
1400
1795
|
|
|
1401
1796
|
// selection-lock fixtures: cross-team exhausted + notified exhausted (Gap-32) + pending.
|
|
1402
|
-
let team_b = seed_watcher(
|
|
1403
|
-
|
|
1404
|
-
|
|
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
|
+
);
|
|
1405
1827
|
|
|
1406
1828
|
// --- Sub-B: attach requeue (golden contract) ---
|
|
1407
1829
|
let requeued = requeue_delivery_exhausted_watchers(&ws, &store, &log, &team, &pane).unwrap();
|
|
@@ -1416,24 +1838,50 @@ fn r8_attach_requeue_exhausted_to_notify_failed_golden_attach_event() {
|
|
|
1416
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)");
|
|
1417
1839
|
// Gap-32: a notified watcher is never requeued; its notified_message_id survives.
|
|
1418
1840
|
let (st_n, nid) = watcher_state(&store, ¬if);
|
|
1419
|
-
assert_eq!(
|
|
1420
|
-
|
|
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
|
+
);
|
|
1421
1850
|
// only the team-a unnotified exhausted watcher requeues.
|
|
1422
1851
|
let ids: Vec<&str> = requeued.iter().map(|n| n.watcher_id.as_str()).collect();
|
|
1423
|
-
assert_eq!(
|
|
1852
|
+
assert_eq!(
|
|
1853
|
+
ids,
|
|
1854
|
+
vec!["w-r8"],
|
|
1855
|
+
"only team-a unnotified delivery_exhausted watcher requeues"
|
|
1856
|
+
);
|
|
1424
1857
|
|
|
1425
1858
|
// D3: result_watcher.requeued payload == golden ATTACH form {watcher_id, trigger, new_pane_id}.
|
|
1426
1859
|
let events = log.tail(0).unwrap();
|
|
1427
|
-
let ev = events
|
|
1860
|
+
let ev = events
|
|
1861
|
+
.iter()
|
|
1862
|
+
.rev()
|
|
1428
1863
|
.find(|e| e.get("event").and_then(|v| v.as_str()) == Some("result_watcher.requeued"))
|
|
1429
1864
|
.expect("result_watcher.requeued event");
|
|
1430
|
-
let keys: std::collections::BTreeSet<&str> = ev
|
|
1431
|
-
.
|
|
1432
|
-
|
|
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();
|
|
1433
1875
|
assert_eq!(keys, expected,
|
|
1434
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:?}");
|
|
1435
|
-
assert_eq!(
|
|
1436
|
-
|
|
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
|
+
);
|
|
1437
1885
|
}
|
|
1438
1886
|
|
|
1439
1887
|
// E15 (F4.4 双投修)·源码守卫:report_result 的 direct inject 必须被 `if !outcome.ok` 守为
|
|
@@ -1446,8 +1894,14 @@ fn e15_direct_inject_is_gated_by_deliver_failure_not_unconditional() {
|
|
|
1446
1894
|
let inject_call = "match inject_leader_notification_direct(";
|
|
1447
1895
|
let gate_pos = src.find(gate);
|
|
1448
1896
|
let inject_pos = src.find(inject_call);
|
|
1449
|
-
assert!(
|
|
1450
|
-
|
|
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
|
+
);
|
|
1451
1905
|
assert!(
|
|
1452
1906
|
gate_pos.unwrap() < inject_pos.unwrap(),
|
|
1453
1907
|
"E15: the `if !outcome.ok` gate must precede the direct-inject call (deliver-success must skip inject → leader gets exactly one copy)"
|