@team-agent/installer 0.5.117 → 0.5.119
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/mod.rs +20 -0
- package/crates/team-agent/src/db/message_store.rs +10 -3
- package/crates/team-agent/src/leader/start.rs +27 -47
- package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +2 -7
- package/crates/team-agent/src/messaging/delivery.rs +157 -136
- package/crates/team-agent/src/messaging/delivery_consumption_tests.rs +223 -0
- package/crates/team-agent/src/messaging/enter_rescue.rs +182 -154
- package/crates/team-agent/src/messaging/enter_rescue_tests.rs +344 -277
- package/crates/team-agent/src/messaging/mod.rs +1 -1
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +6 -5
- package/crates/team-agent/src/messaging/tests/spine.rs +27 -28
- package/crates/team-agent/src/tmux_backend/enter_rescue.rs +325 -0
- package/crates/team-agent/src/tmux_backend/enter_rescue_tests.rs +432 -0
- package/crates/team-agent/src/tmux_backend/native_input.rs +19 -0
- package/crates/team-agent/src/tmux_backend/native_input_tests.rs +141 -4
- package/crates/team-agent/src/tmux_backend.rs +27 -0
- package/crates/team-agent/src/transport/enter_rescue.rs +166 -0
- package/crates/team-agent/src/transport/native_input.rs +136 -10
- package/crates/team-agent/src/transport/test_support.rs +25 -0
- package/crates/team-agent/src/transport.rs +34 -0
- package/package.json +4 -4
|
@@ -299,34 +299,6 @@ fn prepare_message_state(
|
|
|
299
299
|
}
|
|
300
300
|
}
|
|
301
301
|
}
|
|
302
|
-
if batch
|
|
303
|
-
&& recipient_is_busy(
|
|
304
|
-
prepared.scoped.as_ref().unwrap_or(state),
|
|
305
|
-
&message.recipient,
|
|
306
|
-
)
|
|
307
|
-
{
|
|
308
|
-
event_log.write(
|
|
309
|
-
"send.deferred_busy",
|
|
310
|
-
serde_json::json!({
|
|
311
|
-
"message_id": message_id,
|
|
312
|
-
"sender": message.sender,
|
|
313
|
-
"recipient": message.recipient,
|
|
314
|
-
"reason": "recipient_busy",
|
|
315
|
-
}),
|
|
316
|
-
)?;
|
|
317
|
-
return Ok(Err(DeliveryOutcome {
|
|
318
|
-
ok: false,
|
|
319
|
-
status: DeliveryStatus::Queued,
|
|
320
|
-
message_status: MessageStatusShadow(message.status.clone()),
|
|
321
|
-
message_id: Some(message_id.to_string()),
|
|
322
|
-
verification: None,
|
|
323
|
-
stage: None,
|
|
324
|
-
reason: None,
|
|
325
|
-
channel: None,
|
|
326
|
-
ack_forced_off: false,
|
|
327
|
-
turn_verification: None,
|
|
328
|
-
}));
|
|
329
|
-
}
|
|
330
302
|
Ok(Ok(prepared))
|
|
331
303
|
}
|
|
332
304
|
|
|
@@ -639,21 +611,30 @@ fn deliver_prepared_message(
|
|
|
639
611
|
});
|
|
640
612
|
}
|
|
641
613
|
if token_already_visible(transport, state, &message.recipient, &target, message_id) {
|
|
614
|
+
// Visibility prevents a second paste; it does not prove consumption.
|
|
615
|
+
let consumed = existing_token_consumed(transport, state, &message.recipient, &target, message_id);
|
|
616
|
+
let status = if consumed { "delivered" } else { "submitted_unverified" };
|
|
617
|
+
let reason = if consumed { "message_token_consumed" } else { "message_token_visible_without_consumption" };
|
|
642
618
|
event_log.write(
|
|
643
619
|
"delivery.duplicate_token_refused",
|
|
644
620
|
serde_json::json!({
|
|
645
621
|
"message_id": message_id,
|
|
646
622
|
"recipient": message.recipient,
|
|
647
|
-
"reason":
|
|
623
|
+
"reason": reason,
|
|
624
|
+
"consumed": consumed,
|
|
648
625
|
}),
|
|
649
626
|
)?;
|
|
650
|
-
store.mark(message_id,
|
|
627
|
+
store.mark(message_id, status, (!consumed).then_some(reason))?;
|
|
628
|
+
if !consumed {
|
|
629
|
+
bind_enter_watchdog(store, transport, state, &target, message_id, &message,
|
|
630
|
+
canonical_owner_team_id.as_deref(), event_log)?;
|
|
631
|
+
}
|
|
651
632
|
return Ok(DeliveryOutcome {
|
|
652
|
-
ok:
|
|
653
|
-
status: DeliveryStatus::AlreadyDelivered,
|
|
654
|
-
message_status: MessageStatusShadow(
|
|
633
|
+
ok: consumed,
|
|
634
|
+
status: if consumed { DeliveryStatus::AlreadyDelivered } else { DeliveryStatus::Failed },
|
|
635
|
+
message_status: MessageStatusShadow(status.to_string()),
|
|
655
636
|
message_id: Some(message_id.to_string()),
|
|
656
|
-
verification: Some(
|
|
637
|
+
verification: Some(reason.to_string()),
|
|
657
638
|
stage: Some(DeliveryStage::Submit),
|
|
658
639
|
reason: None,
|
|
659
640
|
channel: None,
|
|
@@ -682,34 +663,33 @@ fn deliver_prepared_message(
|
|
|
682
663
|
canonical_owner_team_id.as_deref(),
|
|
683
664
|
resolved.metadata.as_ref(),
|
|
684
665
|
);
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
}),
|
|
705
|
-
)
|
|
706
|
-
});
|
|
666
|
+
// v0.5.113 delivery: address/owner and pane serialization are authoritative;
|
|
667
|
+
// composer appearance is not admission. Native CLIs buffer/queue input.
|
|
668
|
+
let inject_attempt = crate::tmux_backend::with_paste_to_submit_floor(
|
|
669
|
+
paste_to_submit_floor_for_recipient(state, &message.recipient),
|
|
670
|
+
|| {
|
|
671
|
+
crate::tmux_backend::with_cursor_single_enter(
|
|
672
|
+
recipient_requires_single_enter(state, &message.recipient),
|
|
673
|
+
|| {
|
|
674
|
+
transport.inject_with_submit_observer(
|
|
675
|
+
&target,
|
|
676
|
+
&payload,
|
|
677
|
+
Key::Enter,
|
|
678
|
+
true,
|
|
679
|
+
Some(&submit_observer),
|
|
680
|
+
)
|
|
681
|
+
},
|
|
682
|
+
)
|
|
683
|
+
},
|
|
684
|
+
);
|
|
707
685
|
let inject_report = match inject_attempt {
|
|
708
686
|
Ok(report) => {
|
|
709
687
|
// grok 忙时第一次回车只入显式队列。默认 send-now:只重按回车顶出去。
|
|
710
688
|
// 无 `Enter:send now` 时零次额外按键,claude/codex 行为不变。
|
|
711
689
|
// cursor:第二下 Enter 打断进行中回合,不走 flush。
|
|
712
|
-
if !
|
|
690
|
+
if !crate::provider::submit_now::keep_provider_queue_requested()
|
|
691
|
+
&& recipient_allows_explicit_queue_flush(state, &message.recipient)
|
|
692
|
+
{
|
|
713
693
|
match crate::provider::submit_now::flush_explicit_queue(transport, &target) {
|
|
714
694
|
Ok(flush) if flush.extra_enters > 0 => {
|
|
715
695
|
let _ = event_log.write(
|
|
@@ -739,28 +719,6 @@ fn deliver_prepared_message(
|
|
|
739
719
|
}
|
|
740
720
|
Err(error) => {
|
|
741
721
|
let reason = format!("inject_failed:{error}");
|
|
742
|
-
if guarded_native {
|
|
743
|
-
let pasted = native_evidence.as_ref().filter(|evidence|
|
|
744
|
-
evidence.phase >= crate::transport::native_input::NativeInputPhase::MayHavePasted);
|
|
745
|
-
let status = if let Some(evidence) = pasted {
|
|
746
|
-
super::enter_rescue::park(store, evidence, attempt, &reason, event_log)?;
|
|
747
|
-
"submitted_unverified"
|
|
748
|
-
} else {
|
|
749
|
-
super::enter_rescue::release_no_effect(store, message_id, attempt)?;
|
|
750
|
-
"target_resolved"
|
|
751
|
-
};
|
|
752
|
-
event_log.write("native_input.deferred", serde_json::json!({
|
|
753
|
-
"message_id": message_id, "reason": reason, "status": status,
|
|
754
|
-
"may_have_pasted": pasted.is_some(),
|
|
755
|
-
}))?;
|
|
756
|
-
return Ok(DeliveryOutcome {
|
|
757
|
-
ok: false, status: DeliveryStatus::Queued,
|
|
758
|
-
message_status: MessageStatusShadow(status.to_string()),
|
|
759
|
-
message_id: Some(message_id.to_string()), verification: Some(reason),
|
|
760
|
-
stage: Some(DeliveryStage::Inject), reason: None, channel: None,
|
|
761
|
-
ack_forced_off: false, turn_verification: None,
|
|
762
|
-
});
|
|
763
|
-
}
|
|
764
722
|
if message.recipient == "leader" {
|
|
765
723
|
store.mark(message_id, "failed", Some("leader_not_attached"))?;
|
|
766
724
|
event_log.write(
|
|
@@ -870,9 +828,12 @@ fn deliver_prepared_message(
|
|
|
870
828
|
// A successful tmux command is never provider-acceptance proof. Leader and
|
|
871
829
|
// worker delivery both require post-submit evidence; leader additionally
|
|
872
830
|
// waits for its authoritative provider transcript below.
|
|
873
|
-
let
|
|
831
|
+
let native_consumed = observed_token_consumed(transport, state, &message.recipient, &target, message_id);
|
|
832
|
+
let verified = submit_verified && native_consumed != Some(false);
|
|
874
833
|
if !verified {
|
|
875
|
-
let reason = if
|
|
834
|
+
let reason = if native_consumed == Some(false) {
|
|
835
|
+
"submit_unverified:current_composer_or_consumption_unknown".to_string()
|
|
836
|
+
} else if !readback_verified {
|
|
876
837
|
"pane_readback_unverified:capture_missing_token".to_string()
|
|
877
838
|
} else {
|
|
878
839
|
format!(
|
|
@@ -926,11 +887,9 @@ fn deliver_prepared_message(
|
|
|
926
887
|
turn_verification: Some(turn_verification),
|
|
927
888
|
});
|
|
928
889
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
store.mark(message_id, "submitted_unverified", Some(&reason))?;
|
|
933
|
-
}
|
|
890
|
+
store.mark(message_id, "submitted_unverified", Some(&reason))?;
|
|
891
|
+
bind_enter_watchdog(store, transport, state, &target, message_id, &message,
|
|
892
|
+
canonical_owner_team_id.as_deref(), event_log)?;
|
|
934
893
|
return Ok(DeliveryOutcome {
|
|
935
894
|
ok: false,
|
|
936
895
|
status: DeliveryStatus::Failed,
|
|
@@ -997,6 +956,8 @@ fn deliver_prepared_message(
|
|
|
997
956
|
}),
|
|
998
957
|
)?;
|
|
999
958
|
store.mark(message_id, "submitted_unverified", Some(&reason))?;
|
|
959
|
+
bind_enter_watchdog(store, transport, state, &target, message_id, &message,
|
|
960
|
+
canonical_owner_team_id.as_deref(), event_log)?;
|
|
1000
961
|
return Ok(DeliveryOutcome {
|
|
1001
962
|
ok: false,
|
|
1002
963
|
status: DeliveryStatus::Failed,
|
|
@@ -2305,8 +2266,8 @@ fn only_team_entry(state: &serde_json::Value) -> Option<&serde_json::Value> {
|
|
|
2305
2266
|
}
|
|
2306
2267
|
}
|
|
2307
2268
|
|
|
2308
|
-
///
|
|
2309
|
-
///
|
|
2269
|
+
/// Deliver pending rows without activity/composer admission; native CLIs queue input.
|
|
2270
|
+
/// Returns the message IDs whose post-submit evidence verified delivery.
|
|
2310
2271
|
pub fn deliver_pending_messages(
|
|
2311
2272
|
workspace: &Path,
|
|
2312
2273
|
state: &serde_json::Value,
|
|
@@ -2500,13 +2461,6 @@ fn recipient_provider(state: &serde_json::Value, recipient: &str) -> Option<Prov
|
|
|
2500
2461
|
.and_then(parse_canonical_provider)
|
|
2501
2462
|
}
|
|
2502
2463
|
|
|
2503
|
-
fn recipient_is_cursor_agent(state: &serde_json::Value, recipient: &str) -> bool {
|
|
2504
|
-
matches!(
|
|
2505
|
-
recipient_provider(state, recipient),
|
|
2506
|
-
Some(Provider::CursorAgent)
|
|
2507
|
-
)
|
|
2508
|
-
}
|
|
2509
|
-
|
|
2510
2464
|
/// ---
|
|
2511
2465
|
/// purpose: paste→Enter 地板按收件人 provider 选择
|
|
2512
2466
|
/// contract: CursorAgent 与 Grok 为 1s;其余(含 claude)为 ZERO
|
|
@@ -2549,16 +2503,6 @@ pub(crate) fn recipient_allows_explicit_queue_flush(
|
|
|
2549
2503
|
)
|
|
2550
2504
|
}
|
|
2551
2505
|
|
|
2552
|
-
pub(crate) fn recipient_is_busy(state: &serde_json::Value, recipient: &str) -> bool {
|
|
2553
|
-
state
|
|
2554
|
-
.get("agents")
|
|
2555
|
-
.and_then(serde_json::Value::as_object)
|
|
2556
|
-
.and_then(|agents| agents.get(recipient))
|
|
2557
|
-
.and_then(|agent| agent.get("status"))
|
|
2558
|
-
.and_then(serde_json::Value::as_str)
|
|
2559
|
-
== Some("busy")
|
|
2560
|
-
}
|
|
2561
|
-
|
|
2562
2506
|
fn leader_rollout_path(state: &serde_json::Value) -> Option<std::path::PathBuf> {
|
|
2563
2507
|
if let Some(receiver) = leader_receiver_value(state).filter(|receiver| {
|
|
2564
2508
|
receiver.get("provider").and_then(serde_json::Value::as_str) == Some("pi")
|
|
@@ -2838,11 +2782,13 @@ pub(crate) fn record_turn_open_if_leader_to_worker(
|
|
|
2838
2782
|
pub(super) fn with_rescue_submit_observer<R>(
|
|
2839
2783
|
workspace: &Path,
|
|
2840
2784
|
store: &MessageStore,
|
|
2841
|
-
|
|
2785
|
+
context: &crate::transport::enter_rescue::EnterRescueContext,
|
|
2842
2786
|
event_log: &EventLog,
|
|
2843
2787
|
execute: impl FnOnce(&dyn SubmitObserver) -> Result<R, MessagingError>,
|
|
2844
2788
|
) -> Result<R, MessagingError> {
|
|
2845
|
-
let request =
|
|
2789
|
+
let request = context;
|
|
2790
|
+
let binding = context.target.as_ref().filter(|target| target.bound())
|
|
2791
|
+
.ok_or_else(|| MessagingError::Validation("enter_rescue: original binding missing".into()))?;
|
|
2846
2792
|
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
2847
2793
|
let sender: String = conn.query_row(
|
|
2848
2794
|
"select sender from messages where message_id=?1 and owner_team_id=?2 and recipient=?3 and status='submitted_unverified'",
|
|
@@ -2850,18 +2796,18 @@ pub(super) fn with_rescue_submit_observer<R>(
|
|
|
2850
2796
|
|row| row.get(0),
|
|
2851
2797
|
)?;
|
|
2852
2798
|
let metadata = TargetMetadata {
|
|
2853
|
-
tmux_endpoint: Some(
|
|
2854
|
-
target_session:
|
|
2855
|
-
target_window:
|
|
2856
|
-
target_pane_id:
|
|
2857
|
-
target_pane_pid: Some(
|
|
2799
|
+
tmux_endpoint: Some(binding.endpoint.clone()),
|
|
2800
|
+
target_session: binding.session.clone(),
|
|
2801
|
+
target_window: binding.window.clone(),
|
|
2802
|
+
target_pane_id: binding.pane_id.clone(),
|
|
2803
|
+
target_pane_pid: Some(binding.pane_pid),
|
|
2858
2804
|
resolved_from: "enter_rescue_owned_binding",
|
|
2859
2805
|
};
|
|
2860
2806
|
let mut observer = CurrentTurnSubmitObserver::new(
|
|
2861
2807
|
workspace, &sender, &request.recipient, &request.message_id, event_log,
|
|
2862
2808
|
Some(&request.owner_team_id), Some(&metadata),
|
|
2863
2809
|
);
|
|
2864
|
-
observer.
|
|
2810
|
+
observer.rescue_context = Some(context);
|
|
2865
2811
|
let result = execute(&observer)?;
|
|
2866
2812
|
if let Some(error) = observer.take_error() { return Err(error); }
|
|
2867
2813
|
Ok(result)
|
|
@@ -2875,7 +2821,7 @@ struct CurrentTurnSubmitObserver<'a> {
|
|
|
2875
2821
|
event_log: &'a EventLog,
|
|
2876
2822
|
owner_team_id: Option<&'a str>,
|
|
2877
2823
|
metadata: Option<&'a TargetMetadata>,
|
|
2878
|
-
|
|
2824
|
+
rescue_context: Option<&'a crate::transport::enter_rescue::EnterRescueContext>,
|
|
2879
2825
|
fired: Cell<bool>,
|
|
2880
2826
|
error: RefCell<Option<MessagingError>>,
|
|
2881
2827
|
}
|
|
@@ -2898,7 +2844,7 @@ impl<'a> CurrentTurnSubmitObserver<'a> {
|
|
|
2898
2844
|
event_log,
|
|
2899
2845
|
owner_team_id,
|
|
2900
2846
|
metadata,
|
|
2901
|
-
|
|
2847
|
+
rescue_context: None,
|
|
2902
2848
|
fired: Cell::new(false),
|
|
2903
2849
|
error: RefCell::new(None),
|
|
2904
2850
|
}
|
|
@@ -2914,8 +2860,8 @@ impl SubmitObserver for CurrentTurnSubmitObserver<'_> {
|
|
|
2914
2860
|
if self.fired.replace(true) {
|
|
2915
2861
|
return;
|
|
2916
2862
|
}
|
|
2917
|
-
let result = if let Some(
|
|
2918
|
-
record_rescue_turn_after_submit(self.workspace, self.sender,
|
|
2863
|
+
let result = if let Some(context) = self.rescue_context {
|
|
2864
|
+
record_rescue_turn_after_submit(self.workspace, self.sender, context, self.event_log, self.metadata)
|
|
2919
2865
|
} else {
|
|
2920
2866
|
record_current_turn_after_inject_if_leader_to_worker_scoped(
|
|
2921
2867
|
self.workspace, self.sender, self.recipient, self.message_id,
|
|
@@ -2929,11 +2875,13 @@ impl SubmitObserver for CurrentTurnSubmitObserver<'_> {
|
|
|
2929
2875
|
fn record_rescue_turn_after_submit(
|
|
2930
2876
|
workspace: &Path,
|
|
2931
2877
|
sender: &str,
|
|
2932
|
-
|
|
2878
|
+
context: &crate::transport::enter_rescue::EnterRescueContext,
|
|
2933
2879
|
event_log: &EventLog,
|
|
2934
2880
|
metadata: Option<&TargetMetadata>,
|
|
2935
2881
|
) -> Result<(), MessagingError> {
|
|
2936
|
-
let request =
|
|
2882
|
+
let request = context;
|
|
2883
|
+
let target = context.target.as_ref().filter(|target| target.bound())
|
|
2884
|
+
.ok_or_else(|| MessagingError::Validation("enter_rescue: original binding missing".into()))?;
|
|
2937
2885
|
if !matches!(sender, "leader" | "Leader") { return Ok(()); }
|
|
2938
2886
|
crate::state::repository::StateRepository::new(workspace).commit_messaging_checked(
|
|
2939
2887
|
crate::state::repository::StateWriteIntent::MessagingDeliveryState {
|
|
@@ -2944,12 +2892,11 @@ fn record_rescue_turn_after_submit(
|
|
|
2944
2892
|
let matches = agent.is_some_and(|agent| {
|
|
2945
2893
|
agent.get("status").and_then(serde_json::Value::as_str) == Some("running")
|
|
2946
2894
|
&& agent.get("explicit_stop").and_then(serde_json::Value::as_bool) != Some(true)
|
|
2947
|
-
&& agent.get("pane_id").and_then(serde_json::Value::as_str) == Some(
|
|
2948
|
-
&& agent.get("pane_pid").and_then(serde_json::Value::as_u64) == Some(u64::from(
|
|
2949
|
-
&& request.
|
|
2950
|
-
&& agent.get("
|
|
2951
|
-
&&
|
|
2952
|
-
&& selected.get("session_name").and_then(serde_json::Value::as_str) == Some(request.session.as_str())
|
|
2895
|
+
&& agent.get("pane_id").and_then(serde_json::Value::as_str) == Some(target.pane_id.as_str())
|
|
2896
|
+
&& agent.get("pane_pid").and_then(serde_json::Value::as_u64) == Some(u64::from(target.pane_pid))
|
|
2897
|
+
&& agent.get("window").and_then(serde_json::Value::as_str).unwrap_or(&request.recipient) == target.window
|
|
2898
|
+
&& agent.get("provider").cloned().and_then(|value| serde_json::from_value::<Provider>(value).ok()) == Some(target.provider)
|
|
2899
|
+
&& selected.get("session_name").and_then(serde_json::Value::as_str) == Some(target.session.as_str())
|
|
2953
2900
|
});
|
|
2954
2901
|
if !matches {
|
|
2955
2902
|
return Err(crate::state::StateError::SaveConflict("rescue receiver changed before turn commit".into()));
|
|
@@ -3486,17 +3433,87 @@ fn text_contains_message_token(text: &str, message_id: &str) -> bool {
|
|
|
3486
3433
|
!message_id.is_empty() && text.contains(&message_token_marker(message_id))
|
|
3487
3434
|
}
|
|
3488
3435
|
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
///
|
|
3495
|
-
///
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3436
|
+
fn marker_occurrences(text: &str, message_id: &str) -> usize {
|
|
3437
|
+
text.lines().map(str::trim).collect::<String>()
|
|
3438
|
+
.matches(&message_token_marker(message_id)).count()
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
/// Provider-specific parsing is behind an observation port, never a delivery branch.
|
|
3442
|
+
/// None retains existing non-native transport receipts; unknown native frames veto success.
|
|
3443
|
+
fn observed_token_consumed(
|
|
3444
|
+
transport: &dyn Transport,
|
|
3445
|
+
state: &serde_json::Value,
|
|
3446
|
+
recipient: &str,
|
|
3447
|
+
target: &Target,
|
|
3448
|
+
message_id: &str,
|
|
3449
|
+
) -> Option<bool> {
|
|
3450
|
+
let provider = recipient_provider(state, recipient)?;
|
|
3451
|
+
match transport.observe_native_input(target, provider) {
|
|
3452
|
+
Ok(Some(frame)) => Some(frame.composer.is_empty()
|
|
3453
|
+
&& marker_occurrences(&frame.transcript, message_id) == 1),
|
|
3454
|
+
Ok(None) => None,
|
|
3455
|
+
Err(_) => Some(false),
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
fn existing_token_consumed(
|
|
3460
|
+
transport: &dyn Transport,
|
|
3461
|
+
state: &serde_json::Value,
|
|
3462
|
+
recipient: &str,
|
|
3463
|
+
target: &Target,
|
|
3464
|
+
message_id: &str,
|
|
3465
|
+
) -> bool {
|
|
3466
|
+
observed_token_consumed(transport, state, recipient, target, message_id)
|
|
3467
|
+
.unwrap_or_else(|| recipient_transcript_contains(state, recipient, &message_token_marker(message_id)))
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
#[allow(clippy::too_many_arguments)]
|
|
3471
|
+
fn bind_enter_watchdog(
|
|
3472
|
+
store: &MessageStore,
|
|
3473
|
+
transport: &dyn Transport,
|
|
3474
|
+
state: &serde_json::Value,
|
|
3475
|
+
target: &Target,
|
|
3476
|
+
message_id: &str,
|
|
3477
|
+
message: &PendingMessage,
|
|
3478
|
+
owner_team_id: Option<&str>,
|
|
3479
|
+
event_log: &EventLog,
|
|
3480
|
+
) -> Result<(), MessagingError> {
|
|
3481
|
+
use crate::transport::enter_rescue::{EnterRescueContext, EnterRescueTarget};
|
|
3482
|
+
use sha2::{Digest, Sha256};
|
|
3483
|
+
let rendered = render_message(&message.sender, message.task_id.as_deref(), &message.content, message_id);
|
|
3484
|
+
let agent = state.get("agents").and_then(|agents| agents.get(&message.recipient));
|
|
3485
|
+
let selected = (|| {
|
|
3486
|
+
let agent = agent?;
|
|
3487
|
+
Some(EnterRescueTarget::selected(
|
|
3488
|
+
recipient_provider(state, &message.recipient)?,
|
|
3489
|
+
agent.get("pane_id")?.as_str()?.to_string(),
|
|
3490
|
+
u32::try_from(agent.get("pane_pid")?.as_u64()?).ok()?,
|
|
3491
|
+
state.get("session_name")?.as_str()?.to_string(),
|
|
3492
|
+
agent.get("window").and_then(serde_json::Value::as_str)
|
|
3493
|
+
.unwrap_or(&message.recipient).to_string(),
|
|
3494
|
+
))
|
|
3495
|
+
})();
|
|
3496
|
+
let context = EnterRescueContext {
|
|
3497
|
+
message_id: message_id.into(),
|
|
3498
|
+
owner_team_id: owner_team_id.unwrap_or_default().into(),
|
|
3499
|
+
recipient: message.recipient.clone(),
|
|
3500
|
+
payload_sha256: format!("{:x}", Sha256::digest(rendered.as_bytes())),
|
|
3501
|
+
target: selected,
|
|
3502
|
+
proof: None,
|
|
3503
|
+
};
|
|
3504
|
+
match transport.snapshot_enter_rescue(target, &context, &rendered) {
|
|
3505
|
+
Ok(snapshot) => { super::enter_rescue::bind_pending_context(store, &snapshot)?; }
|
|
3506
|
+
Err(error) => {
|
|
3507
|
+
event_log.write("enter_rescue.snapshot_unavailable", serde_json::json!({
|
|
3508
|
+
"message_id": message_id, "reason": error.to_string(),
|
|
3509
|
+
}))?;
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
Ok(())
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3515
|
+
/// Visibility prevents a duplicate paste; only a separate consumption receipt permits success.
|
|
3516
|
+
/// Read failures do not manufacture visibility. Capture stays bounded to Tail(80).
|
|
3500
3517
|
pub(crate) fn token_already_visible(
|
|
3501
3518
|
transport: &dyn Transport,
|
|
3502
3519
|
state: &serde_json::Value,
|
|
@@ -3623,6 +3640,10 @@ fn rollout_tail_contains_result(
|
|
|
3623
3640
|
Ok(haystack.contains(needle))
|
|
3624
3641
|
}
|
|
3625
3642
|
|
|
3643
|
+
#[cfg(test)]
|
|
3644
|
+
#[path = "delivery_consumption_tests.rs"]
|
|
3645
|
+
mod consumption_tests;
|
|
3646
|
+
|
|
3626
3647
|
#[cfg(test)]
|
|
3627
3648
|
mod paste_floor_tests {
|
|
3628
3649
|
use super::*;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#![allow(clippy::unwrap_used)]
|
|
2
|
+
|
|
3
|
+
use super::*;
|
|
4
|
+
use crate::transport::{native_input::InputFrame, test_support::OfflineTransport};
|
|
5
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
6
|
+
|
|
7
|
+
struct Fixture {
|
|
8
|
+
workspace: std::path::PathBuf,
|
|
9
|
+
store: MessageStore,
|
|
10
|
+
state: serde_json::Value,
|
|
11
|
+
}
|
|
12
|
+
impl Fixture {
|
|
13
|
+
fn new(provider: &str) -> Self {
|
|
14
|
+
static NEXT: AtomicU64 = AtomicU64::new(0);
|
|
15
|
+
let workspace = std::env::temp_dir().join(format!(
|
|
16
|
+
"ta-consumption-{}-{}",
|
|
17
|
+
std::process::id(),
|
|
18
|
+
NEXT.fetch_add(1, Ordering::Relaxed)
|
|
19
|
+
));
|
|
20
|
+
std::fs::create_dir_all(&workspace).unwrap();
|
|
21
|
+
let store = MessageStore::open(&workspace).unwrap();
|
|
22
|
+
let conn = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
23
|
+
conn.execute("insert into messages(message_id,owner_team_id,sender,recipient,content,status,created_at,updated_at,delivery_attempts) values('msg_one','team','operator','worker','body','accepted','old','old',0)", []).unwrap();
|
|
24
|
+
let state = serde_json::json!({"team_key":"team","session_name":"session","agents":{"worker":{
|
|
25
|
+
"status":"running","provider":provider,"pane_id":"%1","pane_pid":2,"window":"worker"
|
|
26
|
+
}}});
|
|
27
|
+
Self {
|
|
28
|
+
workspace,
|
|
29
|
+
store,
|
|
30
|
+
state,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
fn transport(&self) -> OfflineTransport {
|
|
34
|
+
OfflineTransport::new()
|
|
35
|
+
.with_session_present(true)
|
|
36
|
+
.with_targets(vec![PaneInfo {
|
|
37
|
+
pane_id: PaneId::new("%1"),
|
|
38
|
+
session: SessionName::new("session"),
|
|
39
|
+
window_index: Some(1),
|
|
40
|
+
window_name: Some(WindowName::new("worker")),
|
|
41
|
+
pane_index: Some(0),
|
|
42
|
+
tty: None,
|
|
43
|
+
current_command: None,
|
|
44
|
+
current_path: Some(self.workspace.clone()),
|
|
45
|
+
active: true,
|
|
46
|
+
pane_pid: Some(2),
|
|
47
|
+
leader_env: Default::default(),
|
|
48
|
+
}])
|
|
49
|
+
.with_default_liveness(crate::transport::PaneLiveness::Live)
|
|
50
|
+
}
|
|
51
|
+
fn deliver(&self, transport: &OfflineTransport) -> DeliveryOutcome {
|
|
52
|
+
let message = message_for_delivery(&self.store, "msg_one")
|
|
53
|
+
.unwrap()
|
|
54
|
+
.unwrap();
|
|
55
|
+
deliver_prepared_message(
|
|
56
|
+
&self.workspace,
|
|
57
|
+
&self.store,
|
|
58
|
+
transport,
|
|
59
|
+
"msg_one",
|
|
60
|
+
&EventLog::new(&self.workspace),
|
|
61
|
+
&self.state,
|
|
62
|
+
message,
|
|
63
|
+
PreparedMessageState {
|
|
64
|
+
scoped: None,
|
|
65
|
+
canonical_owner_team_id: Some("team".into()),
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
.unwrap()
|
|
69
|
+
}
|
|
70
|
+
fn receipt(&self) -> (String, i64) {
|
|
71
|
+
let conn = crate::db::schema::open_db(self.store.db_path()).unwrap();
|
|
72
|
+
(
|
|
73
|
+
conn.query_row(
|
|
74
|
+
"select status from messages where message_id='msg_one'",
|
|
75
|
+
[],
|
|
76
|
+
|r| r.get(0),
|
|
77
|
+
)
|
|
78
|
+
.unwrap(),
|
|
79
|
+
conn.query_row(
|
|
80
|
+
"select count(*) from scheduled_events where kind='enter_rescue'",
|
|
81
|
+
[],
|
|
82
|
+
|r| r.get(0),
|
|
83
|
+
)
|
|
84
|
+
.unwrap(),
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
impl Drop for Fixture {
|
|
89
|
+
fn drop(&mut self) {
|
|
90
|
+
let _ = std::fs::remove_dir_all(&self.workspace);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
fn frame(composer: &str, transcript: &str) -> InputFrame {
|
|
94
|
+
InputFrame {
|
|
95
|
+
composer: composer.into(),
|
|
96
|
+
transcript: transcript.into(),
|
|
97
|
+
identity: "known-current-ui".into(),
|
|
98
|
+
footer: "owned cwd".into(),
|
|
99
|
+
busy: false,
|
|
100
|
+
capsule: None,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#[test]
|
|
105
|
+
fn current_token_in_composer_is_not_duplicate_delivered_or_repasted() {
|
|
106
|
+
let f = Fixture::new("cursor_agent");
|
|
107
|
+
let marker = message_token_marker("msg_one");
|
|
108
|
+
let t = f
|
|
109
|
+
.transport()
|
|
110
|
+
.with_capture_for_pane("%1", &marker)
|
|
111
|
+
.with_native_input_observation(frame(&marker, &marker));
|
|
112
|
+
let out = f.deliver(&t);
|
|
113
|
+
assert!(!out.ok);
|
|
114
|
+
assert_eq!(f.receipt(), ("submitted_unverified".into(), 1));
|
|
115
|
+
assert!(t.inject_payloads().is_empty());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#[test]
|
|
119
|
+
fn successful_transport_report_does_not_override_unconsumed_composer() {
|
|
120
|
+
for composer in [
|
|
121
|
+
"[team-agent-token:msg_one]",
|
|
122
|
+
"[Pasted text #1 +25 lines]",
|
|
123
|
+
"foreign draft",
|
|
124
|
+
] {
|
|
125
|
+
let f = Fixture::new("cursor_agent");
|
|
126
|
+
let t = f
|
|
127
|
+
.transport()
|
|
128
|
+
.with_native_input_observation(frame(composer, ""));
|
|
129
|
+
let out = f.deliver(&t);
|
|
130
|
+
assert!(!out.ok, "composer={composer}");
|
|
131
|
+
assert_eq!(f.receipt(), ("submitted_unverified".into(), 1));
|
|
132
|
+
assert_eq!(t.inject_payloads().len(), 1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#[test]
|
|
137
|
+
fn unknown_current_native_surface_is_unverified_not_success() {
|
|
138
|
+
let f = Fixture::new("pi");
|
|
139
|
+
let t = f
|
|
140
|
+
.transport()
|
|
141
|
+
.with_native_input_error("native_surface_unknown");
|
|
142
|
+
assert!(!f.deliver(&t).ok);
|
|
143
|
+
assert_eq!(f.receipt(), ("submitted_unverified".into(), 1));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#[test]
|
|
147
|
+
fn pane_visibility_without_an_observation_protocol_is_not_duplicate_acceptance() {
|
|
148
|
+
let f = Fixture::new("fake");
|
|
149
|
+
let t = f
|
|
150
|
+
.transport()
|
|
151
|
+
.with_capture_for_pane("%1", message_token_marker("msg_one"));
|
|
152
|
+
assert!(!f.deliver(&t).ok);
|
|
153
|
+
assert_eq!(f.receipt(), ("submitted_unverified".into(), 1));
|
|
154
|
+
assert!(t.inject_payloads().is_empty());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#[test]
|
|
158
|
+
fn current_empty_composer_and_unique_transcript_token_prove_consumption() {
|
|
159
|
+
let f = Fixture::new("cursor_agent");
|
|
160
|
+
let marker = message_token_marker("msg_one");
|
|
161
|
+
let t = f
|
|
162
|
+
.transport()
|
|
163
|
+
.with_capture_for_pane("%1", &marker)
|
|
164
|
+
.with_native_input_observation(frame("", &marker));
|
|
165
|
+
assert!(f.deliver(&t).ok);
|
|
166
|
+
assert_eq!(f.receipt(), ("delivered".into(), 0));
|
|
167
|
+
assert!(t.inject_payloads().is_empty());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#[test]
|
|
171
|
+
fn busy_answers_or_duplicate_history_tokens_do_not_prove_consumption() {
|
|
172
|
+
let f = Fixture::new("cursor_agent");
|
|
173
|
+
let target = Target::Pane(PaneId::new("%1"));
|
|
174
|
+
for transcript in [
|
|
175
|
+
"answer is 5535",
|
|
176
|
+
"[team-agent-token:msg_one]\n[team-agent-token:msg_one]",
|
|
177
|
+
] {
|
|
178
|
+
let mut view = frame("", transcript);
|
|
179
|
+
view.busy = true;
|
|
180
|
+
let t = f.transport().with_native_input_observation(view);
|
|
181
|
+
assert_eq!(
|
|
182
|
+
observed_token_consumed(&t, &f.state, "worker", &target, "msg_one"),
|
|
183
|
+
Some(false)
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#[test]
|
|
189
|
+
fn unsupported_observation_keeps_existing_positive_transport_receipt() {
|
|
190
|
+
let f = Fixture::new("fake");
|
|
191
|
+
assert!(f.deliver(&f.transport()).ok);
|
|
192
|
+
assert_eq!(f.receipt(), ("delivered".into(), 0));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
#[test]
|
|
196
|
+
fn mark_unverified_schedules_once_and_insert_failure_rolls_back_status() {
|
|
197
|
+
let f = Fixture::new("fake");
|
|
198
|
+
f.store
|
|
199
|
+
.mark("msg_one", "submitted_unverified", Some("opaque no proof"))
|
|
200
|
+
.unwrap();
|
|
201
|
+
f.store
|
|
202
|
+
.mark("msg_one", "submitted_unverified", Some("again"))
|
|
203
|
+
.unwrap();
|
|
204
|
+
assert_eq!(f.receipt(), ("submitted_unverified".into(), 1));
|
|
205
|
+
let conn = crate::db::schema::open_db(f.store.db_path()).unwrap();
|
|
206
|
+
let payload: String = conn
|
|
207
|
+
.query_row("select payload_json from scheduled_events", [], |r| {
|
|
208
|
+
r.get(0)
|
|
209
|
+
})
|
|
210
|
+
.unwrap();
|
|
211
|
+
let job: serde_json::Value = serde_json::from_str(&payload).unwrap();
|
|
212
|
+
assert!(job["context"]["target"].is_null());
|
|
213
|
+
assert!(job["context"]["proof"].is_null());
|
|
214
|
+
|
|
215
|
+
let g = Fixture::new("fake");
|
|
216
|
+
let conn = crate::db::schema::open_db(g.store.db_path()).unwrap();
|
|
217
|
+
conn.execute_batch("create trigger refuse_rescue before insert on scheduled_events begin select raise(abort,'fixture failure'); end;").unwrap();
|
|
218
|
+
assert!(g
|
|
219
|
+
.store
|
|
220
|
+
.mark("msg_one", "submitted_unverified", None)
|
|
221
|
+
.is_err());
|
|
222
|
+
assert_eq!(g.receipt(), ("accepted".into(), 0));
|
|
223
|
+
}
|