@team-agent/installer 0.3.8 → 0.3.10
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/emit.rs +20 -4
- package/crates/team-agent/src/cli/leader.rs +12 -8
- package/crates/team-agent/src/cli/tests/base.rs +14 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +6 -2
- package/crates/team-agent/src/coordinator/tests/spine.rs +6 -0
- package/crates/team-agent/src/coordinator/tick.rs +83 -1
- 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/leader/start.rs +34 -23
- package/crates/team-agent/src/leader/tests/identity.rs +22 -0
- package/crates/team-agent/src/leader/tests/wake_start_owner.rs +13 -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 +211 -6
- package/crates/team-agent/src/lifecycle/restart/selection.rs +51 -14
- package/crates/team-agent/src/lifecycle/restart.rs +8 -4
- package/crates/team-agent/src/lifecycle/tests/core.rs +89 -15
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +144 -3
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +3 -1
- package/crates/team-agent/src/messaging/delivery.rs +83 -2
- package/crates/team-agent/src/messaging/results.rs +27 -22
- package/crates/team-agent/src/messaging/tests/runtime.rs +108 -0
- 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
|
@@ -581,6 +581,82 @@ fn red2_restart_empty_workspace_still_reports_missing() {
|
|
|
581
581
|
let _ = std::fs::remove_dir_all(&ws);
|
|
582
582
|
}
|
|
583
583
|
|
|
584
|
+
// RED-2-STILL (P0) — the ENTRY gate (input_has_no_local_team_context) must judge on the
|
|
585
|
+
// canonical_run_workspace-resolved path, not raw. quick-start <team_dir> lands .team under the
|
|
586
|
+
// resolved workspace (team_dir's parent when invoked from there); restart <team_dir> with raw gate
|
|
587
|
+
// checks team_dir's own (empty) .team → false "no context" early-exit before the down-moved gate.
|
|
588
|
+
//
|
|
589
|
+
// Two forms (leader hard requirement — must lock BOTH, not the coincidental same-dir form):
|
|
590
|
+
// Form A: .team in the team_dir's PARENT (standard quick-start <dir> from parent cwd).
|
|
591
|
+
// Form B: .team in the team_dir itself (cwd == team_dir).
|
|
592
|
+
#[test]
|
|
593
|
+
fn red2still_entry_gate_resolves_parent_dot_team_form_a() {
|
|
594
|
+
// Build: base/ (has .team) + base/teamdir/{TEAM.md, agents/} (role docs, NO own .team).
|
|
595
|
+
let base = temp_ws();
|
|
596
|
+
let team_dir = base.join("teamdir");
|
|
597
|
+
std::fs::create_dir_all(team_dir.join("agents")).unwrap();
|
|
598
|
+
std::fs::write(team_dir.join("TEAM.md"), "---\nname: teamdir\nobjective: o\nprovider: codex\n---\nt.\n").unwrap();
|
|
599
|
+
std::fs::write(team_dir.join("agents").join("w1.md"), QS_VALID_ROLE).unwrap();
|
|
600
|
+
// .team lives in the PARENT (base), with a runtime spec — mirrors quick-start <dir> from base cwd.
|
|
601
|
+
let spec = crate::compiler::compile_team(&team_dir).unwrap();
|
|
602
|
+
let runtime_spec = crate::model::paths::runtime_spec_path(&base, "teamdir");
|
|
603
|
+
std::fs::create_dir_all(runtime_spec.parent().unwrap()).unwrap();
|
|
604
|
+
std::fs::write(&runtime_spec, crate::model::yaml::dumps(&spec)).unwrap();
|
|
605
|
+
// Seed minimal team state under base so resolve finds it.
|
|
606
|
+
crate::state::persist::save_runtime_state(
|
|
607
|
+
&base,
|
|
608
|
+
&json!({"session_name": "team-teamdir", "team_dir": team_dir.to_string_lossy(),
|
|
609
|
+
"spec_path": runtime_spec.to_string_lossy(),
|
|
610
|
+
"agents": {"w1": {"status": "running", "provider": "codex"}}}),
|
|
611
|
+
)
|
|
612
|
+
.unwrap();
|
|
613
|
+
// Entry gate on team_dir: raw team_dir has NO .team (it's in parent), but resolved → base has it.
|
|
614
|
+
assert!(
|
|
615
|
+
crate::lifecycle::restart::input_has_no_local_team_context(&team_dir),
|
|
616
|
+
"precondition: raw team_dir has no local .team (it's in the parent)"
|
|
617
|
+
);
|
|
618
|
+
let resolved = crate::model::paths::canonical_run_workspace(&team_dir).unwrap();
|
|
619
|
+
assert!(
|
|
620
|
+
!crate::lifecycle::restart::input_has_no_local_team_context(&resolved),
|
|
621
|
+
"RED-2-STILL: gate on canonical_run_workspace-resolved path must see parent .team (false)"
|
|
622
|
+
);
|
|
623
|
+
// Full restart on team_dir must NOT early-exit with missing spec.
|
|
624
|
+
let transport = OfflineTransport::new();
|
|
625
|
+
let text = format!("{:?}", restart_with_transport(&team_dir, false, None, &transport));
|
|
626
|
+
assert!(
|
|
627
|
+
!text.contains("missing spec for restart") && !text.contains("active team spec not found"),
|
|
628
|
+
"RED-2-STILL Form A: restart <team_dir> (.team in parent) must not report missing; got {text}"
|
|
629
|
+
);
|
|
630
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
#[test]
|
|
634
|
+
fn red2still_entry_gate_same_dir_dot_team_form_b() {
|
|
635
|
+
// Form B: .team in team_dir itself (cwd == team_dir at quick-start time).
|
|
636
|
+
let team_dir = temp_ws();
|
|
637
|
+
std::fs::create_dir_all(team_dir.join("agents")).unwrap();
|
|
638
|
+
std::fs::write(team_dir.join("TEAM.md"), "---\nname: tb\nobjective: o\nprovider: codex\n---\nt.\n").unwrap();
|
|
639
|
+
std::fs::write(team_dir.join("agents").join("w1.md"), QS_VALID_ROLE).unwrap();
|
|
640
|
+
let spec = crate::compiler::compile_team(&team_dir).unwrap();
|
|
641
|
+
let runtime_spec = crate::model::paths::runtime_spec_path(&team_dir, "tb");
|
|
642
|
+
std::fs::create_dir_all(runtime_spec.parent().unwrap()).unwrap();
|
|
643
|
+
std::fs::write(&runtime_spec, crate::model::yaml::dumps(&spec)).unwrap();
|
|
644
|
+
crate::state::persist::save_runtime_state(
|
|
645
|
+
&team_dir,
|
|
646
|
+
&json!({"session_name": "team-tb", "team_dir": team_dir.to_string_lossy(),
|
|
647
|
+
"spec_path": runtime_spec.to_string_lossy(),
|
|
648
|
+
"agents": {"w1": {"status": "running", "provider": "codex"}}}),
|
|
649
|
+
)
|
|
650
|
+
.unwrap();
|
|
651
|
+
let transport = OfflineTransport::new();
|
|
652
|
+
let text = format!("{:?}", restart_with_transport(&team_dir, false, None, &transport));
|
|
653
|
+
assert!(
|
|
654
|
+
!text.contains("missing spec for restart") && !text.contains("active team spec not found"),
|
|
655
|
+
"RED-2-STILL Form B: restart <team_dir> (.team in team_dir) must not report missing; got {text}"
|
|
656
|
+
);
|
|
657
|
+
let _ = std::fs::remove_dir_all(&team_dir);
|
|
658
|
+
}
|
|
659
|
+
|
|
584
660
|
// E5 task#3 / RC-A6b — restart with role definitions MISSING explicitly refuses (lists what's
|
|
585
661
|
// missing) and leaves the previous runtime spec in place (no silent path, no data destruction).
|
|
586
662
|
#[test]
|
|
@@ -869,6 +945,10 @@ const DELEG_ROLE_WORKER2: &str = "---\nname: worker2\nrole: Second Worker\nprovi
|
|
|
869
945
|
pub(super) fn restart_ws_two_resumable_workers() -> PathBuf {
|
|
870
946
|
let ws = temp_ws().join("restartteam");
|
|
871
947
|
std::fs::create_dir_all(ws.join("agents")).unwrap();
|
|
948
|
+
let alpha_rollout = ws.join("alpha-rollout.jsonl");
|
|
949
|
+
let bravo_rollout = ws.join("bravo-rollout.jsonl");
|
|
950
|
+
std::fs::write(&alpha_rollout, "{}\n").unwrap();
|
|
951
|
+
std::fs::write(&bravo_rollout, "{}\n").unwrap();
|
|
872
952
|
std::fs::write(ws.join("TEAM.md"), "---\nname: restartteam\nobjective: Restart probe.\nprovider: codex\n---\n\nteam.\n").unwrap();
|
|
873
953
|
std::fs::write(ws.join("agents").join("alpha.md"), DELEG_ROLE_ALPHA).unwrap();
|
|
874
954
|
std::fs::write(ws.join("agents").join("bravo.md"), DELEG_ROLE_BRAVO).unwrap();
|
|
@@ -879,8 +959,8 @@ pub(super) fn restart_ws_two_resumable_workers() -> PathBuf {
|
|
|
879
959
|
&json!({
|
|
880
960
|
"session_name": "team-restartteam",
|
|
881
961
|
"agents": {
|
|
882
|
-
"alpha": {"status": "running", "provider": "codex", "session_id": "sess-a", "first_send_at": "2026-05-27T10:00:00+00:00"},
|
|
883
|
-
"bravo": {"status": "running", "provider": "codex", "session_id": "sess-b", "first_send_at": "2026-05-27T10:00:00+00:00"}
|
|
962
|
+
"alpha": {"status": "running", "provider": "codex", "session_id": "sess-a", "rollout_path": alpha_rollout.to_string_lossy(), "first_send_at": "2026-05-27T10:00:00+00:00"},
|
|
963
|
+
"bravo": {"status": "running", "provider": "codex", "session_id": "sess-b", "rollout_path": bravo_rollout.to_string_lossy(), "first_send_at": "2026-05-27T10:00:00+00:00"}
|
|
884
964
|
}
|
|
885
965
|
}),
|
|
886
966
|
)
|
|
@@ -889,6 +969,33 @@ pub(super) fn restart_ws_two_resumable_workers() -> PathBuf {
|
|
|
889
969
|
ws
|
|
890
970
|
}
|
|
891
971
|
|
|
972
|
+
fn restart_ws_one_resumable_worker() -> PathBuf {
|
|
973
|
+
let ws = temp_ws().join("restartone");
|
|
974
|
+
std::fs::create_dir_all(ws.join("agents")).unwrap();
|
|
975
|
+
let rollout = ws.join("alpha-rollout.jsonl");
|
|
976
|
+
std::fs::write(&rollout, "{}\n").unwrap();
|
|
977
|
+
std::fs::write(
|
|
978
|
+
ws.join("TEAM.md"),
|
|
979
|
+
"---\nname: restartone\nobjective: Restart readiness probe.\nprovider: codex\n---\n\nteam.\n",
|
|
980
|
+
)
|
|
981
|
+
.unwrap();
|
|
982
|
+
std::fs::write(ws.join("agents").join("alpha.md"), DELEG_ROLE_ALPHA).unwrap();
|
|
983
|
+
let spec = crate::compiler::compile_team(&ws).expect("compile 1-agent team");
|
|
984
|
+
std::fs::write(ws.join("team.spec.yaml"), crate::model::yaml::dumps(&spec)).unwrap();
|
|
985
|
+
crate::state::persist::save_runtime_state(
|
|
986
|
+
&ws,
|
|
987
|
+
&json!({
|
|
988
|
+
"session_name": "team-restartone",
|
|
989
|
+
"agents": {
|
|
990
|
+
"alpha": {"status": "running", "provider": "codex", "session_id": "sess-a", "rollout_path": rollout.to_string_lossy(), "first_send_at": "2026-05-27T10:00:00+00:00"}
|
|
991
|
+
}
|
|
992
|
+
}),
|
|
993
|
+
)
|
|
994
|
+
.unwrap();
|
|
995
|
+
seed_healthy_coordinator(&ws);
|
|
996
|
+
ws
|
|
997
|
+
}
|
|
998
|
+
|
|
892
999
|
// 2 [P0] — restart_with_transport must drive the REAL Route-B resume spawn: one spawn per resumable
|
|
893
1000
|
// worker. The first resumed worker recreates the session with spawn_first; later workers may use
|
|
894
1001
|
// spawn_into only after a live-session check proves that recreated session still exists. Each spawn
|
|
@@ -944,6 +1051,38 @@ fn restart_with_transport_spawns_resumable_workers_not_stub() {
|
|
|
944
1051
|
);
|
|
945
1052
|
}
|
|
946
1053
|
|
|
1054
|
+
#[test]
|
|
1055
|
+
fn restart_times_out_when_spawned_worker_pane_is_not_addressable() {
|
|
1056
|
+
let ws = restart_ws_one_resumable_worker();
|
|
1057
|
+
let transport = OfflineTransport::new().with_spawned_panes_addressable(false);
|
|
1058
|
+
|
|
1059
|
+
let result =
|
|
1060
|
+
restart_with_transport_with_readiness_deadline(&ws, false, None, &transport, Some(0));
|
|
1061
|
+
|
|
1062
|
+
let text = format!("{result:?}");
|
|
1063
|
+
assert!(
|
|
1064
|
+
text.contains("restart not ready")
|
|
1065
|
+
&& text.contains("worker pane addressable: no")
|
|
1066
|
+
&& text.contains("Action:")
|
|
1067
|
+
&& text.contains("Log:"),
|
|
1068
|
+
"restart must refuse with N38 readiness timeout details, not return ok; got {text}"
|
|
1069
|
+
);
|
|
1070
|
+
assert!(
|
|
1071
|
+
!matches!(result, Ok(RestartReport::Restarted { .. })),
|
|
1072
|
+
"restart readiness timeout must not return Restarted ok"
|
|
1073
|
+
);
|
|
1074
|
+
let events = crate::event_log::EventLog::new(&ws).tail(20).unwrap();
|
|
1075
|
+
let timeout = events
|
|
1076
|
+
.iter()
|
|
1077
|
+
.find(|event| event.get("event").and_then(|v| v.as_str()) == Some("restart.readiness_timeout"))
|
|
1078
|
+
.expect("restart.readiness_timeout event");
|
|
1079
|
+
assert_eq!(
|
|
1080
|
+
timeout.get("worker_pane_addressable").and_then(|v| v.as_bool()),
|
|
1081
|
+
Some(false),
|
|
1082
|
+
"timeout event must carry the failed readiness condition: {timeout}"
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
947
1086
|
// 3 [P0] — start_agent_with_transport on a non-paused agent with a session_id must spawn EXACTLY ONE
|
|
948
1087
|
// worker (resume) carrying the provider build_command. Today the stub returns RequirementUnmet with
|
|
949
1088
|
// ZERO spawns -> RED at recorded.len().
|
|
@@ -951,11 +1090,13 @@ fn restart_with_transport_spawns_resumable_workers_not_stub() {
|
|
|
951
1090
|
fn start_agent_with_transport_spawns_resume_not_stub() {
|
|
952
1091
|
let ws = temp_ws().join("startagentws");
|
|
953
1092
|
std::fs::create_dir_all(&ws).unwrap();
|
|
1093
|
+
let rollout = ws.join("alpha-rollout.jsonl");
|
|
1094
|
+
std::fs::write(&rollout, "{}\n").unwrap();
|
|
954
1095
|
crate::state::persist::save_runtime_state(
|
|
955
1096
|
&ws,
|
|
956
1097
|
&json!({
|
|
957
1098
|
"session_name": "team-sa",
|
|
958
|
-
"agents": {"alpha": {"status": "running", "provider": "codex", "session_id": "sess-a", "first_send_at": "2026-05-27T10:00:00+00:00"}}
|
|
1099
|
+
"agents": {"alpha": {"status": "running", "provider": "codex", "session_id": "sess-a", "rollout_path": rollout.to_string_lossy(), "first_send_at": "2026-05-27T10:00:00+00:00"}}
|
|
959
1100
|
}),
|
|
960
1101
|
)
|
|
961
1102
|
.unwrap();
|
|
@@ -88,11 +88,13 @@ impl crate::transport::Transport for SessionProbeRecordingTransport {
|
|
|
88
88
|
fn respawn_ws_one_resumable_worker() -> PathBuf {
|
|
89
89
|
let ws = temp_ws().join("respawn_dead_session");
|
|
90
90
|
std::fs::create_dir_all(&ws).unwrap();
|
|
91
|
+
let rollout = ws.join("alpha-rollout.jsonl");
|
|
92
|
+
std::fs::write(&rollout, "{}\n").unwrap();
|
|
91
93
|
crate::state::persist::save_runtime_state(
|
|
92
94
|
&ws,
|
|
93
95
|
&json!({
|
|
94
96
|
"session_name": "team-sa",
|
|
95
|
-
"agents": {"alpha": {"status": "running", "provider": "codex", "session_id": "sess-a", "first_send_at": "2026-05-27T10:00:00+00:00"}}
|
|
97
|
+
"agents": {"alpha": {"status": "running", "provider": "codex", "session_id": "sess-a", "rollout_path": rollout.to_string_lossy(), "first_send_at": "2026-05-27T10:00:00+00:00"}}
|
|
96
98
|
}),
|
|
97
99
|
)
|
|
98
100
|
.unwrap();
|
|
@@ -17,7 +17,7 @@ use crate::transport::{
|
|
|
17
17
|
use super::helpers::{message_exists, MessageStatusShadow};
|
|
18
18
|
use super::{
|
|
19
19
|
DeliveryOutcome, DeliveryRefusal, DeliveryStage, DeliveryStatus, MessagingError,
|
|
20
|
-
PaneWidthQuery, TrustRetryPayload,
|
|
20
|
+
PaneWidthQuery, TrustRetryPayload, SEND_RETRY_MAX_ATTEMPTS,
|
|
21
21
|
};
|
|
22
22
|
use crate::state::projection::OwnerTeamResolution;
|
|
23
23
|
|
|
@@ -286,7 +286,6 @@ pub fn deliver_pending_message(
|
|
|
286
286
|
"submit_unverified:{}",
|
|
287
287
|
submit_verification_wire(inject_report.submit_verification)
|
|
288
288
|
);
|
|
289
|
-
store.mark(message_id, "submitted_unverified", Some(&reason))?;
|
|
290
289
|
event_log.write(
|
|
291
290
|
"send.unverified",
|
|
292
291
|
serde_json::json!({
|
|
@@ -296,6 +295,29 @@ pub fn deliver_pending_message(
|
|
|
296
295
|
"attempts": inject_report.attempts,
|
|
297
296
|
}),
|
|
298
297
|
)?;
|
|
298
|
+
if inject_report.attempts >= u32::from(SEND_RETRY_MAX_ATTEMPTS) {
|
|
299
|
+
store.mark(message_id, "failed", Some("send_unverified_exhausted"))?;
|
|
300
|
+
emit_send_failed_exhausted(
|
|
301
|
+
workspace,
|
|
302
|
+
state,
|
|
303
|
+
event_log,
|
|
304
|
+
message_id,
|
|
305
|
+
&message.recipient,
|
|
306
|
+
inject_report.attempts,
|
|
307
|
+
&reason,
|
|
308
|
+
)?;
|
|
309
|
+
return Ok(DeliveryOutcome {
|
|
310
|
+
ok: false,
|
|
311
|
+
status: DeliveryStatus::Failed,
|
|
312
|
+
message_status: MessageStatusShadow("failed".to_string()),
|
|
313
|
+
message_id: Some(message_id.to_string()),
|
|
314
|
+
verification: Some(reason),
|
|
315
|
+
stage: Some(DeliveryStage::Submit),
|
|
316
|
+
reason: None,
|
|
317
|
+
channel: None,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
store.mark(message_id, "submitted_unverified", Some(&reason))?;
|
|
299
321
|
return Ok(DeliveryOutcome {
|
|
300
322
|
ok: false,
|
|
301
323
|
status: DeliveryStatus::Failed,
|
|
@@ -538,6 +560,65 @@ fn leader_receiver_field_in_state<'a>(
|
|
|
538
560
|
.filter(|value| !value.is_empty())
|
|
539
561
|
}
|
|
540
562
|
|
|
563
|
+
fn emit_send_failed_exhausted(
|
|
564
|
+
workspace: &Path,
|
|
565
|
+
state: &serde_json::Value,
|
|
566
|
+
event_log: &EventLog,
|
|
567
|
+
message_id: &str,
|
|
568
|
+
recipient: &str,
|
|
569
|
+
attempts: u32,
|
|
570
|
+
verification: &str,
|
|
571
|
+
) -> Result<(), MessagingError> {
|
|
572
|
+
event_log.write(
|
|
573
|
+
"send.failed",
|
|
574
|
+
serde_json::json!({
|
|
575
|
+
"message_id": message_id,
|
|
576
|
+
"recipient": recipient,
|
|
577
|
+
"attempts": attempts,
|
|
578
|
+
"max_attempts": SEND_RETRY_MAX_ATTEMPTS,
|
|
579
|
+
"reason": "send_unverified_exhausted",
|
|
580
|
+
"verification": verification,
|
|
581
|
+
}),
|
|
582
|
+
)?;
|
|
583
|
+
let content = format!(
|
|
584
|
+
"send.failed\nerror: send to {recipient} remained unverified after {attempts}/{SEND_RETRY_MAX_ATTEMPTS} attempts\naction: inspect the target pane and retry the send\nlog: .team/logs/events.jsonl"
|
|
585
|
+
);
|
|
586
|
+
match crate::messaging::send_to_leader_receiver(
|
|
587
|
+
workspace,
|
|
588
|
+
state,
|
|
589
|
+
"leader",
|
|
590
|
+
&content,
|
|
591
|
+
None,
|
|
592
|
+
"coordinator",
|
|
593
|
+
false,
|
|
594
|
+
Some(&format!("send.failed:{message_id}")),
|
|
595
|
+
event_log,
|
|
596
|
+
) {
|
|
597
|
+
Ok(outcome) => {
|
|
598
|
+
event_log.write(
|
|
599
|
+
"send.failed_notification",
|
|
600
|
+
serde_json::json!({
|
|
601
|
+
"message_id": message_id,
|
|
602
|
+
"recipient": recipient,
|
|
603
|
+
"leader_notification_status": super::helpers::status_wire(outcome.status),
|
|
604
|
+
"leader_message_id": outcome.message_id,
|
|
605
|
+
}),
|
|
606
|
+
)?;
|
|
607
|
+
}
|
|
608
|
+
Err(error) => {
|
|
609
|
+
event_log.write(
|
|
610
|
+
"send.failed_notification_failed",
|
|
611
|
+
serde_json::json!({
|
|
612
|
+
"message_id": message_id,
|
|
613
|
+
"recipient": recipient,
|
|
614
|
+
"error": error.to_string(),
|
|
615
|
+
}),
|
|
616
|
+
)?;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
Ok(())
|
|
620
|
+
}
|
|
621
|
+
|
|
541
622
|
fn active_team_entry(state: &serde_json::Value) -> Option<&serde_json::Value> {
|
|
542
623
|
let team = state.get("active_team_key").and_then(serde_json::Value::as_str)?;
|
|
543
624
|
state
|
|
@@ -689,28 +689,33 @@ pub fn report_result_for_owner_team(
|
|
|
689
689
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
690
690
|
}
|
|
691
691
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
692
|
+
// E15(F4.4 双投修):direct inject 是 deliver **失败时的兜底**,不是无条件第二投。
|
|
693
|
+
// deliver loop 成功(outcome.ok)→ 跳过 direct inject,leader 仅收 deliver 那一条;
|
|
694
|
+
// 全失败 → direct inject 兜底投一条(不丢 result,守 #230/MUST-8)。两全:恰一条。
|
|
695
|
+
if !outcome.ok {
|
|
696
|
+
match inject_leader_notification_direct(workspace, &delivery_state, &content, &message_id) {
|
|
697
|
+
Ok(()) => {
|
|
698
|
+
store.mark(&message_id, "delivered", None)?;
|
|
699
|
+
outcome = crate::messaging::DeliveryOutcome {
|
|
700
|
+
ok: true,
|
|
701
|
+
status: crate::messaging::DeliveryStatus::Delivered,
|
|
702
|
+
message_status: super::helpers::MessageStatusShadow("delivered".to_string()),
|
|
703
|
+
message_id: Some(message_id),
|
|
704
|
+
verification: None,
|
|
705
|
+
stage: None,
|
|
706
|
+
reason: None,
|
|
707
|
+
channel: Some("leader_receiver".to_string()),
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
Err(reason) => {
|
|
711
|
+
event_log.write(
|
|
712
|
+
"leader_receiver.direct_inject_skipped",
|
|
713
|
+
serde_json::json!({
|
|
714
|
+
"message_id": message_id,
|
|
715
|
+
"reason": reason,
|
|
716
|
+
}),
|
|
717
|
+
)?;
|
|
718
|
+
}
|
|
714
719
|
}
|
|
715
720
|
}
|
|
716
721
|
}
|
|
@@ -1068,6 +1068,96 @@ fn fire_due_scheduled_events_fires_each_scheduled_kind() {
|
|
|
1068
1068
|
assert_eq!(fired.len(), 3, "exactly the three seeded due events fire, no extras");
|
|
1069
1069
|
}
|
|
1070
1070
|
|
|
1071
|
+
struct UnverifiedInjectTransport;
|
|
1072
|
+
impl Transport for UnverifiedInjectTransport {
|
|
1073
|
+
fn kind(&self) -> BackendKind {
|
|
1074
|
+
BackendKind::Tmux
|
|
1075
|
+
}
|
|
1076
|
+
fn spawn_first(&self, _s: &SessionName, _w: &WindowName, _a: &[String], _c: &Path, _e: &BTreeMap<String, String>) -> Result<SpawnResult, TransportError> {
|
|
1077
|
+
unimplemented!("not reached in delivery")
|
|
1078
|
+
}
|
|
1079
|
+
fn spawn_into(&self, _s: &SessionName, _w: &WindowName, _a: &[String], _c: &Path, _e: &BTreeMap<String, String>) -> Result<SpawnResult, TransportError> {
|
|
1080
|
+
unimplemented!("not reached in delivery")
|
|
1081
|
+
}
|
|
1082
|
+
fn inject(&self, _t: &Target, _p: &InjectPayload, _s: Key, _b: bool) -> Result<InjectReport, TransportError> {
|
|
1083
|
+
Ok(InjectReport {
|
|
1084
|
+
stage_reached: crate::transport::InjectStage::Submit,
|
|
1085
|
+
inject_verification: crate::transport::InjectVerification::CaptureContainsToken,
|
|
1086
|
+
submit_verification: crate::transport::SubmitVerification::PastedContentPromptStillPresentAfterSubmit,
|
|
1087
|
+
turn_verification: crate::transport::TurnVerification::NotYetObserved,
|
|
1088
|
+
attempts: u32::from(SEND_RETRY_MAX_ATTEMPTS),
|
|
1089
|
+
})
|
|
1090
|
+
}
|
|
1091
|
+
fn send_keys(&self, _t: &Target, _k: &[Key]) -> Result<(), TransportError> {
|
|
1092
|
+
Ok(())
|
|
1093
|
+
}
|
|
1094
|
+
fn capture(&self, _t: &Target, range: CaptureRange) -> Result<CapturedText, TransportError> {
|
|
1095
|
+
Ok(CapturedText { text: String::new(), range })
|
|
1096
|
+
}
|
|
1097
|
+
fn query(&self, _t: &Target, _f: PaneField) -> Result<Option<String>, TransportError> {
|
|
1098
|
+
Ok(None)
|
|
1099
|
+
}
|
|
1100
|
+
fn liveness(&self, _p: &PaneId) -> Result<PaneLiveness, TransportError> {
|
|
1101
|
+
Ok(PaneLiveness::Unknown)
|
|
1102
|
+
}
|
|
1103
|
+
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
1104
|
+
Ok(Vec::new())
|
|
1105
|
+
}
|
|
1106
|
+
fn has_session(&self, _s: &SessionName) -> Result<bool, TransportError> {
|
|
1107
|
+
Ok(true)
|
|
1108
|
+
}
|
|
1109
|
+
fn list_windows(&self, _s: &SessionName) -> Result<Vec<WindowName>, TransportError> {
|
|
1110
|
+
Ok(Vec::new())
|
|
1111
|
+
}
|
|
1112
|
+
fn set_session_env(&self, _s: &SessionName, _k: &str, _v: &str) -> Result<SetEnvOutcome, TransportError> {
|
|
1113
|
+
Ok(SetEnvOutcome::Applied)
|
|
1114
|
+
}
|
|
1115
|
+
fn kill_session(&self, _s: &SessionName) -> Result<(), TransportError> {
|
|
1116
|
+
Ok(())
|
|
1117
|
+
}
|
|
1118
|
+
fn kill_window(&self, _t: &Target) -> Result<(), TransportError> {
|
|
1119
|
+
Ok(())
|
|
1120
|
+
}
|
|
1121
|
+
fn attach_session(&self, _s: &SessionName) -> Result<AttachOutcome, TransportError> {
|
|
1122
|
+
Ok(AttachOutcome::Attached)
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
#[test]
|
|
1127
|
+
fn deliver_pending_exhausted_unverified_send_emits_failed_event() {
|
|
1128
|
+
let ws = tmp_ws("sendfailed");
|
|
1129
|
+
let store = store_for(&ws);
|
|
1130
|
+
let log = EventLog::new(&ws);
|
|
1131
|
+
let state = serde_json::json!({
|
|
1132
|
+
"session_name": "team-sendfailed",
|
|
1133
|
+
"leader_receiver": {"pane_id": "%leader"},
|
|
1134
|
+
"agents": {"w1": {"provider": "fake", "pane_id": "%1"}}
|
|
1135
|
+
});
|
|
1136
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
1137
|
+
let message_id = store
|
|
1138
|
+
.create_message(None, "leader", "w1", "ping", None, false, None)
|
|
1139
|
+
.unwrap();
|
|
1140
|
+
|
|
1141
|
+
let out = deliver_pending_message(&ws, &store, &UnverifiedInjectTransport, &message_id, &log, &state)
|
|
1142
|
+
.unwrap();
|
|
1143
|
+
|
|
1144
|
+
assert!(!out.ok);
|
|
1145
|
+
assert_eq!(out.message_status.0, "failed");
|
|
1146
|
+
let events = log.tail(0).unwrap();
|
|
1147
|
+
assert!(
|
|
1148
|
+
events
|
|
1149
|
+
.iter()
|
|
1150
|
+
.any(|event| event.get("event").and_then(serde_json::Value::as_str) == Some("send.failed")),
|
|
1151
|
+
"exhausted unverified send must emit send.failed; got {events:?}"
|
|
1152
|
+
);
|
|
1153
|
+
assert!(
|
|
1154
|
+
events
|
|
1155
|
+
.iter()
|
|
1156
|
+
.any(|event| event.get("event").and_then(serde_json::Value::as_str) == Some("send.failed_notification")),
|
|
1157
|
+
"exhausted unverified send must queue a leader-visible notification; got {events:?}"
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1071
1161
|
// ════════════════════════════════════════════════════════════════════════
|
|
1072
1162
|
// GROUP V — retry_result_deliveries: re-route notify_failed watchers with
|
|
1073
1163
|
// dedupe_reason rebind_retry. result_delivery.py:19-35.
|
|
@@ -1435,3 +1525,21 @@ fn r8_attach_requeue_exhausted_to_notify_failed_golden_attach_event() {
|
|
|
1435
1525
|
assert_eq!(ev.get("trigger").and_then(|v| v.as_str()), Some("attach_leader"));
|
|
1436
1526
|
assert_eq!(ev.get("new_pane_id").and_then(|v| v.as_str()), Some("%leader-new"));
|
|
1437
1527
|
}
|
|
1528
|
+
|
|
1529
|
+
// E15 (F4.4 双投修)·源码守卫:report_result 的 direct inject 必须被 `if !outcome.ok` 守为
|
|
1530
|
+
// deliver 失败时的真兜底,绝不在 deliver 成功后无条件再投一次(=用户看到两条同内容回复)。
|
|
1531
|
+
#[test]
|
|
1532
|
+
fn e15_direct_inject_is_gated_by_deliver_failure_not_unconditional() {
|
|
1533
|
+
let src = include_str!("../results.rs");
|
|
1534
|
+
// 锁:inject_leader_notification_direct 调用点之前必须有 `if !outcome.ok` 门。
|
|
1535
|
+
let gate = "if !outcome.ok {";
|
|
1536
|
+
let inject_call = "match inject_leader_notification_direct(";
|
|
1537
|
+
let gate_pos = src.find(gate);
|
|
1538
|
+
let inject_pos = src.find(inject_call);
|
|
1539
|
+
assert!(gate_pos.is_some(), "E15: direct inject must be gated by `if !outcome.ok` (deliver-fail fallback)");
|
|
1540
|
+
assert!(inject_pos.is_some(), "inject_leader_notification_direct call site must exist (do NOT delete it; #230 fallback)");
|
|
1541
|
+
assert!(
|
|
1542
|
+
gate_pos.unwrap() < inject_pos.unwrap(),
|
|
1543
|
+
"E15: the `if !outcome.ok` gate must precede the direct-inject call (deliver-success must skip inject → leader gets exactly one copy)"
|
|
1544
|
+
);
|
|
1545
|
+
}
|