@team-agent/installer 0.5.50 → 0.5.52
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/adapters.rs +5 -3
- package/crates/team-agent/src/cli/emit.rs +2 -1
- package/crates/team-agent/src/cli/mod.rs +32 -14
- package/crates/team-agent/src/cli/named_address.rs +46 -115
- package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
- package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
- package/crates/team-agent/src/cli/send/persist.rs +154 -0
- package/crates/team-agent/src/cli/send/presentation.rs +333 -0
- package/crates/team-agent/src/cli/send/resolve.rs +361 -0
- package/crates/team-agent/src/cli/send.rs +44 -1169
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
- package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
- package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
- package/crates/team-agent/src/cli/status_port/format.rs +145 -0
- package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
- package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
- package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
- package/crates/team-agent/src/cli/status_port/store.rs +412 -0
- package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
- package/crates/team-agent/src/cli/status_port.rs +47 -1548
- package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +1 -0
- package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
- package/crates/team-agent/src/coordinator/tick.rs +37 -0
- package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
- package/crates/team-agent/src/db/message_store.rs +154 -44
- package/crates/team-agent/src/event_log.rs +73 -0
- package/crates/team-agent/src/leader/lease.rs +202 -16
- package/crates/team-agent/src/leader/start.rs +28 -4
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +303 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
- package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
- package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
- package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
- package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
- package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
- package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
- package/crates/team-agent/src/lifecycle/restart/agent.rs +63 -29
- package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +167 -27
- package/crates/team-agent/src/lifecycle/restart/remove.rs +503 -77
- package/crates/team-agent/src/lifecycle/restart.rs +26 -2
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +906 -17
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +67 -2
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
- package/crates/team-agent/src/mcp_server/mod.rs +1 -1
- package/crates/team-agent/src/mcp_server/tests/send.rs +6 -10
- package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
- package/crates/team-agent/src/mcp_server/tests.rs +11 -4
- package/crates/team-agent/src/mcp_server/tools.rs +14 -5
- package/crates/team-agent/src/messaging/activity.rs +4 -2
- package/crates/team-agent/src/messaging/address.rs +86 -0
- package/crates/team-agent/src/messaging/delivery.rs +288 -183
- package/crates/team-agent/src/messaging/helpers.rs +17 -13
- package/crates/team-agent/src/messaging/leader_channel.rs +171 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
- package/crates/team-agent/src/messaging/mod.rs +15 -2
- package/crates/team-agent/src/messaging/persist.rs +309 -0
- package/crates/team-agent/src/messaging/results.rs +16 -24
- package/crates/team-agent/src/messaging/scheduler.rs +4 -2
- package/crates/team-agent/src/messaging/selftest.rs +19 -12
- package/crates/team-agent/src/messaging/send.rs +101 -49
- package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +2 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
- package/crates/team-agent/src/messaging/watchers.rs +13 -3
- package/crates/team-agent/src/redaction.rs +72 -2
- package/crates/team-agent/src/state/persist.rs +284 -21
- package/crates/team-agent/src/state/projection.rs +245 -30
- package/crates/team-agent/src/state/repository/intent.rs +13 -0
- package/crates/team-agent/src/state/repository/tests.rs +47 -0
- package/crates/team-agent/src/state/repository.rs +94 -27
- package/crates/team-agent/src/state/selector.rs +9 -18
- package/package.json +4 -4
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
use std::path::Path;
|
|
2
|
+
|
|
3
|
+
use serde_json::Value;
|
|
4
|
+
|
|
5
|
+
use crate::codex_app_server::AppServerBinding;
|
|
6
|
+
use crate::transport::{PaneInfo, Transport};
|
|
7
|
+
|
|
8
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
9
|
+
pub enum LiveLeaderChannel {
|
|
10
|
+
DirectTmux(DirectTmuxLeaderChannel),
|
|
11
|
+
CodexAppServer(AppServerBinding),
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
15
|
+
pub struct DirectTmuxLeaderChannel {
|
|
16
|
+
pub pane_id: String,
|
|
17
|
+
pub tmux_socket: Option<String>,
|
|
18
|
+
pub observed: PaneInfo,
|
|
19
|
+
pub metadata_drift: Vec<&'static str>,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
23
|
+
pub enum LeaderChannelResolution {
|
|
24
|
+
Live(LiveLeaderChannel),
|
|
25
|
+
Unbound(LeaderChannelUnbound),
|
|
26
|
+
ProbeFailed(String),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
30
|
+
pub enum LeaderChannelUnbound {
|
|
31
|
+
ReceiverNotAttached,
|
|
32
|
+
TransportConflict,
|
|
33
|
+
MissingPaneId,
|
|
34
|
+
NonCanonicalTmuxSocket,
|
|
35
|
+
EndpointMismatch,
|
|
36
|
+
PaneNotLive,
|
|
37
|
+
PaneWorkspaceMismatch,
|
|
38
|
+
AppServerBindingInvalid,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Resolve the canonical receiver's live physical channel without mutating
|
|
42
|
+
/// ownership. For direct tmux, `(absolute socket, pane id)` plus an available
|
|
43
|
+
/// pane cwd inside the target workspace is the authority. Session/window/tty/
|
|
44
|
+
/// fingerprint are observations and can only report drift.
|
|
45
|
+
pub fn resolve_live_leader_channel(
|
|
46
|
+
workspace: &Path,
|
|
47
|
+
receiver: &Value,
|
|
48
|
+
transport: &dyn Transport,
|
|
49
|
+
) -> LeaderChannelResolution {
|
|
50
|
+
if receiver
|
|
51
|
+
.get("status")
|
|
52
|
+
.and_then(Value::as_str)
|
|
53
|
+
.is_some_and(|status| status != "attached")
|
|
54
|
+
{
|
|
55
|
+
return LeaderChannelResolution::Unbound(LeaderChannelUnbound::ReceiverNotAttached);
|
|
56
|
+
}
|
|
57
|
+
if receiver_transport_conflicts(receiver) {
|
|
58
|
+
return LeaderChannelResolution::Unbound(LeaderChannelUnbound::TransportConflict);
|
|
59
|
+
}
|
|
60
|
+
if crate::codex_app_server::receiver_is_app_server(receiver) {
|
|
61
|
+
return match crate::codex_app_server::binding_from_receiver(receiver) {
|
|
62
|
+
Ok(binding) => {
|
|
63
|
+
LeaderChannelResolution::Live(LiveLeaderChannel::CodexAppServer(binding))
|
|
64
|
+
}
|
|
65
|
+
Err(_) => {
|
|
66
|
+
LeaderChannelResolution::Unbound(LeaderChannelUnbound::AppServerBindingInvalid)
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let Some(pane_id) = receiver
|
|
72
|
+
.get("pane_id")
|
|
73
|
+
.and_then(Value::as_str)
|
|
74
|
+
.filter(|pane| !pane.is_empty())
|
|
75
|
+
else {
|
|
76
|
+
return LeaderChannelResolution::Unbound(LeaderChannelUnbound::MissingPaneId);
|
|
77
|
+
};
|
|
78
|
+
let tmux_socket = receiver
|
|
79
|
+
.get("tmux_socket")
|
|
80
|
+
.and_then(Value::as_str)
|
|
81
|
+
.filter(|socket| !socket.is_empty());
|
|
82
|
+
if tmux_socket.is_some_and(|socket| !std::path::Path::new(socket).is_absolute()) {
|
|
83
|
+
return LeaderChannelResolution::Unbound(LeaderChannelUnbound::NonCanonicalTmuxSocket);
|
|
84
|
+
}
|
|
85
|
+
if let Some(expected) = tmux_socket {
|
|
86
|
+
if transport.tmux_endpoint().as_deref() != Some(expected) {
|
|
87
|
+
return LeaderChannelResolution::Unbound(LeaderChannelUnbound::EndpointMismatch);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let targets = match transport.list_targets() {
|
|
91
|
+
Ok(targets) => targets,
|
|
92
|
+
Err(error) => return LeaderChannelResolution::ProbeFailed(error.to_string()),
|
|
93
|
+
};
|
|
94
|
+
let Some(observed) = targets
|
|
95
|
+
.into_iter()
|
|
96
|
+
.find(|target| target.pane_id.as_str() == pane_id)
|
|
97
|
+
else {
|
|
98
|
+
return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneNotLive);
|
|
99
|
+
};
|
|
100
|
+
if observed
|
|
101
|
+
.current_path
|
|
102
|
+
.as_deref()
|
|
103
|
+
.is_some_and(|path| !path_is_in_workspace(path, workspace))
|
|
104
|
+
{
|
|
105
|
+
return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch);
|
|
106
|
+
}
|
|
107
|
+
let metadata_drift = receiver_metadata_drift(receiver, &observed);
|
|
108
|
+
LeaderChannelResolution::Live(LiveLeaderChannel::DirectTmux(DirectTmuxLeaderChannel {
|
|
109
|
+
pane_id: pane_id.to_string(),
|
|
110
|
+
tmux_socket: tmux_socket.map(str::to_string),
|
|
111
|
+
observed,
|
|
112
|
+
metadata_drift,
|
|
113
|
+
}))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fn path_is_in_workspace(path: &Path, workspace: &Path) -> bool {
|
|
117
|
+
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
|
118
|
+
let workspace = workspace
|
|
119
|
+
.canonicalize()
|
|
120
|
+
.unwrap_or_else(|_| workspace.to_path_buf());
|
|
121
|
+
path == workspace || path.starts_with(workspace)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
fn receiver_transport_conflicts(receiver: &Value) -> bool {
|
|
125
|
+
let mode = receiver.get("mode").and_then(Value::as_str);
|
|
126
|
+
let transport_kind = receiver.get("transport_kind").and_then(Value::as_str);
|
|
127
|
+
matches!((mode, transport_kind), (Some(mode), Some(kind)) if !mode.is_empty() && !kind.is_empty() && mode != kind)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
fn receiver_metadata_drift(receiver: &Value, observed: &PaneInfo) -> Vec<&'static str> {
|
|
131
|
+
let mut drift = Vec::new();
|
|
132
|
+
if string_field(receiver, "session_name")
|
|
133
|
+
.is_some_and(|expected| expected != observed.session.as_str())
|
|
134
|
+
{
|
|
135
|
+
drift.push("session_name");
|
|
136
|
+
}
|
|
137
|
+
if string_field(receiver, "window_name").is_some_and(|expected| {
|
|
138
|
+
observed.window_name.as_ref().map(|value| value.as_str()) != Some(expected)
|
|
139
|
+
}) {
|
|
140
|
+
drift.push("window_name");
|
|
141
|
+
}
|
|
142
|
+
if string_field(receiver, "pane_tty")
|
|
143
|
+
.is_some_and(|expected| observed.tty.as_deref() != Some(expected))
|
|
144
|
+
{
|
|
145
|
+
drift.push("pane_tty");
|
|
146
|
+
}
|
|
147
|
+
let observed_fingerprint = format!(
|
|
148
|
+
"{}|{}|{}|{}",
|
|
149
|
+
observed.session.as_str(),
|
|
150
|
+
observed
|
|
151
|
+
.window_index
|
|
152
|
+
.map_or_else(String::new, |value| value.to_string()),
|
|
153
|
+
observed
|
|
154
|
+
.pane_index
|
|
155
|
+
.map_or_else(String::new, |value| value.to_string()),
|
|
156
|
+
observed.tty.as_deref().unwrap_or("")
|
|
157
|
+
);
|
|
158
|
+
if string_field(receiver, "fingerprint")
|
|
159
|
+
.is_some_and(|expected| expected != observed_fingerprint)
|
|
160
|
+
{
|
|
161
|
+
drift.push("fingerprint");
|
|
162
|
+
}
|
|
163
|
+
drift
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
|
|
167
|
+
value
|
|
168
|
+
.get(key)
|
|
169
|
+
.and_then(Value::as_str)
|
|
170
|
+
.filter(|value| !value.is_empty())
|
|
171
|
+
}
|
|
@@ -13,7 +13,11 @@ use crate::transport::{
|
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
use super::helpers::MessageStatusShadow;
|
|
16
|
-
use super::
|
|
16
|
+
use super::persist::persist_internal_send;
|
|
17
|
+
use super::{
|
|
18
|
+
DeliveryOutcome, DeliveryRefusal, DeliveryStage, DeliveryStatus, InitialDisposition,
|
|
19
|
+
InternalSendKind, MessagingError, PersistResolution,
|
|
20
|
+
};
|
|
17
21
|
|
|
18
22
|
/// `_send_to_leader_receiver` (`leader.py:69`) — **N31/N32 funnel primitive**:所有 leader-bound
|
|
19
23
|
/// caller(send_message(to=leader) / report_result / request_human / idle reminder /
|
|
@@ -74,39 +78,32 @@ pub fn send_to_leader_receiver_with_message_id(
|
|
|
74
78
|
serde_json::json!({"sender": sender, "leader_id": leader_id, "result_id": result_id}),
|
|
75
79
|
)?;
|
|
76
80
|
}
|
|
77
|
-
let message_id =
|
|
78
|
-
|
|
81
|
+
let message_id = match persist_internal_send(
|
|
82
|
+
workspace,
|
|
83
|
+
InternalSendKind::LeaderNotification,
|
|
84
|
+
Some(&owner_team),
|
|
85
|
+
task_id.map(TaskId::as_str),
|
|
86
|
+
sender,
|
|
87
|
+
leader_id,
|
|
88
|
+
content,
|
|
89
|
+
None,
|
|
90
|
+
false,
|
|
91
|
+
requested_message_id,
|
|
92
|
+
InitialDisposition::Accepted,
|
|
93
|
+
)? {
|
|
94
|
+
PersistResolution::Duplicate(requested) => {
|
|
79
95
|
return Ok(DeliveryOutcome {
|
|
80
96
|
ok: false,
|
|
81
97
|
status: DeliveryStatus::Refused,
|
|
82
98
|
message_status: MessageStatusShadow("refused".to_string()),
|
|
83
|
-
message_id: Some(requested
|
|
99
|
+
message_id: Some(requested),
|
|
84
100
|
verification: None,
|
|
85
101
|
stage: None,
|
|
86
102
|
reason: Some(DeliveryRefusal::Duplicate),
|
|
87
103
|
channel: Some("leader_receiver".to_string()),
|
|
88
104
|
});
|
|
89
105
|
}
|
|
90
|
-
|
|
91
|
-
requested,
|
|
92
|
-
task_id.map(TaskId::as_str),
|
|
93
|
-
sender,
|
|
94
|
-
leader_id,
|
|
95
|
-
content,
|
|
96
|
-
None,
|
|
97
|
-
false,
|
|
98
|
-
Some(&owner_team),
|
|
99
|
-
)?
|
|
100
|
-
} else {
|
|
101
|
-
store.create_message(
|
|
102
|
-
task_id.map(TaskId::as_str),
|
|
103
|
-
sender,
|
|
104
|
-
leader_id,
|
|
105
|
-
content,
|
|
106
|
-
None,
|
|
107
|
-
false,
|
|
108
|
-
Some(&owner_team),
|
|
109
|
-
)?
|
|
106
|
+
PersistResolution::Persisted(persisted) => persisted.message_id,
|
|
110
107
|
};
|
|
111
108
|
// #231 exactly-once across rebind: insert the leader_notification_log PK BEFORE the
|
|
112
109
|
// unbound-pane check. That way, if the result row gets blocked (I-4) and later the
|
|
@@ -332,7 +329,32 @@ pub fn deliver_to_leader_fallback_pane(
|
|
|
332
329
|
let submit_ok = super::delivery::inject_submit_verified(&report);
|
|
333
330
|
let readback_ok = super::delivery::pane_readback_verified(&report);
|
|
334
331
|
if submit_ok {
|
|
335
|
-
store.
|
|
332
|
+
store.record_delivery_submission(message_id, readback_ok)?;
|
|
333
|
+
if !super::delivery::leader_transcript_has_token(state, message_id) {
|
|
334
|
+
store.mark(message_id, "submitted_pending_acceptance", None)?;
|
|
335
|
+
event_log.write(
|
|
336
|
+
"leader_receiver.acceptance_pending",
|
|
337
|
+
serde_json::json!({
|
|
338
|
+
"message_id": message_id,
|
|
339
|
+
"result_id": result_id,
|
|
340
|
+
"reason": "provider_receipt_not_observed",
|
|
341
|
+
"channel": "fallback_pane",
|
|
342
|
+
}),
|
|
343
|
+
)?;
|
|
344
|
+
return Ok(DeliveryOutcome {
|
|
345
|
+
ok: true,
|
|
346
|
+
status: DeliveryStatus::RetryScheduled,
|
|
347
|
+
message_status: MessageStatusShadow(
|
|
348
|
+
"submitted_pending_acceptance".to_string(),
|
|
349
|
+
),
|
|
350
|
+
message_id: Some(message_id.to_string()),
|
|
351
|
+
verification: Some("provider_receipt_not_observed".to_string()),
|
|
352
|
+
stage: Some(DeliveryStage::Submit),
|
|
353
|
+
reason: None,
|
|
354
|
+
channel: Some("leader_acceptance_pending".to_string()),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
store.mark_delivered_with_receipt(message_id)?;
|
|
336
358
|
event_log.write(
|
|
337
359
|
"leader_receiver.fallback_pane_submitted",
|
|
338
360
|
serde_json::json!({
|
|
@@ -562,21 +584,24 @@ pub fn enqueue_leader_mailbox_until_attach(
|
|
|
562
584
|
sender: &str,
|
|
563
585
|
event_log: &EventLog,
|
|
564
586
|
) -> Result<DeliveryOutcome, MessagingError> {
|
|
565
|
-
let
|
|
566
|
-
|
|
587
|
+
let PersistResolution::Persisted(persisted) = persist_internal_send(
|
|
588
|
+
target_workspace,
|
|
589
|
+
InternalSendKind::OfflineMailbox,
|
|
590
|
+
Some(canonical_team_key),
|
|
567
591
|
task_id.map(TaskId::as_str),
|
|
568
592
|
sender,
|
|
569
593
|
"leader",
|
|
570
594
|
content,
|
|
571
595
|
None,
|
|
572
596
|
false,
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
597
|
+
None,
|
|
598
|
+
InitialDisposition::QueuedUntilLeaderAttach,
|
|
599
|
+
)?
|
|
600
|
+
else {
|
|
601
|
+
unreachable!("offline mailbox does not accept caller-supplied ids")
|
|
602
|
+
};
|
|
603
|
+
let message_status = persisted.row_status.as_str().to_string();
|
|
604
|
+
let message_id = persisted.message_id;
|
|
580
605
|
event_log.write(
|
|
581
606
|
"leader_mailbox.queued_until_attach",
|
|
582
607
|
serde_json::json!({
|
|
@@ -590,7 +615,7 @@ pub fn enqueue_leader_mailbox_until_attach(
|
|
|
590
615
|
Ok(DeliveryOutcome {
|
|
591
616
|
ok: true,
|
|
592
617
|
status: DeliveryStatus::Queued,
|
|
593
|
-
message_status: MessageStatusShadow(
|
|
618
|
+
message_status: MessageStatusShadow(message_status),
|
|
594
619
|
message_id: Some(message_id),
|
|
595
620
|
verification: None,
|
|
596
621
|
stage: None,
|
|
@@ -61,10 +61,13 @@ use crate::message_store::MessageStore;
|
|
|
61
61
|
use crate::transport::{PaneId, Target, Transport};
|
|
62
62
|
|
|
63
63
|
pub mod activity;
|
|
64
|
+
pub mod address;
|
|
64
65
|
pub mod delivery;
|
|
65
66
|
pub mod helpers;
|
|
67
|
+
pub mod leader_channel;
|
|
66
68
|
pub mod leader_receiver;
|
|
67
69
|
pub mod peers;
|
|
70
|
+
pub mod persist;
|
|
68
71
|
pub mod results;
|
|
69
72
|
pub mod scheduler;
|
|
70
73
|
pub mod selftest;
|
|
@@ -75,19 +78,29 @@ pub mod watchers;
|
|
|
75
78
|
|
|
76
79
|
// ── re-export: 保持 `crate::messaging::X` 与 test `super::X` 解析不变 ──────────
|
|
77
80
|
pub use activity::{classify_agent_activity, detect_cross_worker_deadlocks, detect_idle_fallbacks};
|
|
81
|
+
pub use address::{parse_logical_address, LogicalAddress, LogicalAddressTarget};
|
|
78
82
|
pub use delivery::{
|
|
79
|
-
deliver_pending_message, deliver_pending_messages,
|
|
80
|
-
|
|
83
|
+
deliver_pending_message, deliver_pending_messages, deliver_persisted_message,
|
|
84
|
+
deliver_stored_message, execute_trust_retry, handle_trust_retry_needed,
|
|
85
|
+
record_turn_open_if_leader_to_worker,
|
|
81
86
|
retry_injection_after_trust_auto_answer, stamp_first_send_at_if_leader_to_worker,
|
|
82
87
|
tmux_pane_width,
|
|
83
88
|
};
|
|
84
89
|
pub use helpers::fail_leader_delivery;
|
|
90
|
+
pub use leader_channel::{
|
|
91
|
+
resolve_live_leader_channel, DirectTmuxLeaderChannel, LeaderChannelResolution,
|
|
92
|
+
LeaderChannelUnbound, LiveLeaderChannel,
|
|
93
|
+
};
|
|
85
94
|
pub use leader_receiver::{
|
|
86
95
|
deliver_to_leader_fallback_pane, enqueue_leader_mailbox_until_attach,
|
|
87
96
|
mirror_peer_message_to_leader, send_to_leader_receiver,
|
|
88
97
|
send_to_leader_receiver_with_message_id,
|
|
89
98
|
};
|
|
90
99
|
pub use peers::allow_peer_talk;
|
|
100
|
+
pub use persist::{
|
|
101
|
+
persist_resolved_send, DeliveryBlocker, InitialDisposition, InternalSendKind,
|
|
102
|
+
LogicalRecipient, PersistResolution, PersistedSend, ResolvedSendIntent, SendOrigin,
|
|
103
|
+
};
|
|
91
104
|
pub use results::{
|
|
92
105
|
collect, collect_for_team, collect_results_and_notify_watchers, report_result,
|
|
93
106
|
report_result_for_owner_team, report_result_for_owner_team_with_primary_error,
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
//! Durable boundary between resolved logical addressing and delivery/recovery.
|
|
2
|
+
|
|
3
|
+
use std::path::{Path, PathBuf};
|
|
4
|
+
|
|
5
|
+
use crate::db::message_store::{MessageRowStatus, MessageStore, PersistMessageInput};
|
|
6
|
+
use crate::model::ids::{AgentId, TaskId, TeamKey};
|
|
7
|
+
|
|
8
|
+
use super::send::TrustedSender;
|
|
9
|
+
use super::MessagingError;
|
|
10
|
+
|
|
11
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
12
|
+
pub enum InternalSendKind {
|
|
13
|
+
Delivery,
|
|
14
|
+
LeaderNotification,
|
|
15
|
+
OfflineMailbox,
|
|
16
|
+
Selftest,
|
|
17
|
+
Watcher,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
21
|
+
pub enum SendOrigin {
|
|
22
|
+
Cli,
|
|
23
|
+
Mcp,
|
|
24
|
+
Internal(InternalSendKind),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
28
|
+
pub enum LogicalRecipient {
|
|
29
|
+
Worker(AgentId),
|
|
30
|
+
Leader,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
impl LogicalRecipient {
|
|
34
|
+
pub fn from_resolved(value: impl Into<String>) -> Self {
|
|
35
|
+
let value = value.into();
|
|
36
|
+
if value == "leader" {
|
|
37
|
+
Self::Leader
|
|
38
|
+
} else {
|
|
39
|
+
Self::Worker(AgentId::new(value))
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
pub fn as_str(&self) -> &str {
|
|
44
|
+
match self {
|
|
45
|
+
Self::Worker(agent) => agent.as_str(),
|
|
46
|
+
Self::Leader => "leader",
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
52
|
+
pub enum DeliveryBlocker {
|
|
53
|
+
CoordinatorUnavailable,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
impl DeliveryBlocker {
|
|
57
|
+
pub const fn as_str(self) -> &'static str {
|
|
58
|
+
match self {
|
|
59
|
+
Self::CoordinatorUnavailable => "coordinator_unavailable",
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
65
|
+
pub enum InitialDisposition {
|
|
66
|
+
Accepted,
|
|
67
|
+
QueuedUntilLeaderAttach,
|
|
68
|
+
Blocked(DeliveryBlocker),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
impl InitialDisposition {
|
|
72
|
+
fn row_status(self) -> MessageRowStatus {
|
|
73
|
+
match self {
|
|
74
|
+
Self::Accepted => MessageRowStatus::Accepted,
|
|
75
|
+
Self::QueuedUntilLeaderAttach => MessageRowStatus::QueuedUntilLeaderAttach,
|
|
76
|
+
Self::Blocked(DeliveryBlocker::CoordinatorUnavailable) => {
|
|
77
|
+
MessageRowStatus::QueuedCoordinatorUnavailable
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fn error(self) -> Option<&'static str> {
|
|
83
|
+
match self {
|
|
84
|
+
Self::Blocked(blocker) => Some(blocker.as_str()),
|
|
85
|
+
Self::Accepted | Self::QueuedUntilLeaderAttach => None,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#[derive(Debug, Clone)]
|
|
91
|
+
pub struct ResolvedSendIntent {
|
|
92
|
+
pub origin: SendOrigin,
|
|
93
|
+
pub target_workspace: PathBuf,
|
|
94
|
+
pub owner_team_id: Option<TeamKey>,
|
|
95
|
+
pub recipient: LogicalRecipient,
|
|
96
|
+
pub sender: TrustedSender,
|
|
97
|
+
pub task_id: Option<TaskId>,
|
|
98
|
+
pub content: String,
|
|
99
|
+
pub reply_to: Option<String>,
|
|
100
|
+
pub requires_ack: bool,
|
|
101
|
+
pub requested_message_id: Option<String>,
|
|
102
|
+
pub initial_disposition: InitialDisposition,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
impl ResolvedSendIntent {
|
|
106
|
+
#[allow(clippy::too_many_arguments)]
|
|
107
|
+
pub fn accepted(
|
|
108
|
+
origin: SendOrigin,
|
|
109
|
+
workspace: &Path,
|
|
110
|
+
owner_team_id: Option<TeamKey>,
|
|
111
|
+
recipient: LogicalRecipient,
|
|
112
|
+
sender: TrustedSender,
|
|
113
|
+
task_id: Option<TaskId>,
|
|
114
|
+
content: impl Into<String>,
|
|
115
|
+
reply_to: Option<String>,
|
|
116
|
+
requires_ack: bool,
|
|
117
|
+
requested_message_id: Option<String>,
|
|
118
|
+
) -> Self {
|
|
119
|
+
Self {
|
|
120
|
+
origin,
|
|
121
|
+
target_workspace: workspace.to_path_buf(),
|
|
122
|
+
owner_team_id,
|
|
123
|
+
recipient,
|
|
124
|
+
sender,
|
|
125
|
+
task_id,
|
|
126
|
+
content: content.into(),
|
|
127
|
+
reply_to,
|
|
128
|
+
requires_ack,
|
|
129
|
+
requested_message_id,
|
|
130
|
+
initial_disposition: InitialDisposition::Accepted,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
136
|
+
pub struct PersistedSend {
|
|
137
|
+
pub message_id: String,
|
|
138
|
+
pub owner_team_id: Option<TeamKey>,
|
|
139
|
+
pub recipient: LogicalRecipient,
|
|
140
|
+
pub row_status: MessageRowStatus,
|
|
141
|
+
pub blocker: Option<DeliveryBlocker>,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
145
|
+
pub enum PersistResolution {
|
|
146
|
+
Persisted(PersistedSend),
|
|
147
|
+
Duplicate(String),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/// The only production entry point that creates a durable message row.
|
|
151
|
+
pub fn persist_resolved_send(
|
|
152
|
+
intent: &ResolvedSendIntent,
|
|
153
|
+
) -> Result<PersistResolution, MessagingError> {
|
|
154
|
+
let store = MessageStore::open(&intent.target_workspace)?;
|
|
155
|
+
if let Some(requested) = intent.requested_message_id.as_deref() {
|
|
156
|
+
if store.message_exists(requested)? {
|
|
157
|
+
return Ok(PersistResolution::Duplicate(requested.to_string()));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
let status = intent.initial_disposition.row_status();
|
|
161
|
+
let message_id = store.persist_message(PersistMessageInput {
|
|
162
|
+
message_id: intent.requested_message_id.as_deref(),
|
|
163
|
+
owner_team_id: intent.owner_team_id.as_ref().map(TeamKey::as_str),
|
|
164
|
+
task_id: intent.task_id.as_ref().map(TaskId::as_str),
|
|
165
|
+
sender: intent.sender.as_str(),
|
|
166
|
+
recipient: intent.recipient.as_str(),
|
|
167
|
+
reply_to: intent.reply_to.as_deref(),
|
|
168
|
+
requires_ack: intent.requires_ack,
|
|
169
|
+
status,
|
|
170
|
+
content: &intent.content,
|
|
171
|
+
error: intent.initial_disposition.error(),
|
|
172
|
+
})?;
|
|
173
|
+
Ok(PersistResolution::Persisted(PersistedSend {
|
|
174
|
+
message_id,
|
|
175
|
+
owner_team_id: intent.owner_team_id.clone(),
|
|
176
|
+
recipient: intent.recipient.clone(),
|
|
177
|
+
row_status: status,
|
|
178
|
+
blocker: match intent.initial_disposition {
|
|
179
|
+
InitialDisposition::Blocked(blocker) => Some(blocker),
|
|
180
|
+
InitialDisposition::Accepted | InitialDisposition::QueuedUntilLeaderAttach => None,
|
|
181
|
+
},
|
|
182
|
+
}))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#[allow(clippy::too_many_arguments)]
|
|
186
|
+
pub(crate) fn persist_internal_send(
|
|
187
|
+
workspace: &Path,
|
|
188
|
+
kind: InternalSendKind,
|
|
189
|
+
owner_team_id: Option<&str>,
|
|
190
|
+
task_id: Option<&str>,
|
|
191
|
+
sender: &str,
|
|
192
|
+
recipient: &str,
|
|
193
|
+
content: &str,
|
|
194
|
+
reply_to: Option<&str>,
|
|
195
|
+
requires_ack: bool,
|
|
196
|
+
requested_message_id: Option<&str>,
|
|
197
|
+
initial_disposition: InitialDisposition,
|
|
198
|
+
) -> Result<PersistResolution, MessagingError> {
|
|
199
|
+
let mut intent = ResolvedSendIntent::accepted(
|
|
200
|
+
SendOrigin::Internal(kind),
|
|
201
|
+
workspace,
|
|
202
|
+
owner_team_id.map(TeamKey::new),
|
|
203
|
+
LogicalRecipient::from_resolved(recipient),
|
|
204
|
+
TrustedSender::from_runtime_identity(AgentId::new(sender)),
|
|
205
|
+
task_id.map(TaskId::new),
|
|
206
|
+
content,
|
|
207
|
+
reply_to.map(ToOwned::to_owned),
|
|
208
|
+
requires_ack,
|
|
209
|
+
requested_message_id.map(ToOwned::to_owned),
|
|
210
|
+
);
|
|
211
|
+
intent.initial_disposition = initial_disposition;
|
|
212
|
+
persist_resolved_send(&intent)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#[cfg(test)]
|
|
216
|
+
mod tests {
|
|
217
|
+
use super::*;
|
|
218
|
+
|
|
219
|
+
fn workspace(tag: &str) -> PathBuf {
|
|
220
|
+
let path =
|
|
221
|
+
std::env::temp_dir().join(format!("ta-send-persist-{tag}-{}", std::process::id()));
|
|
222
|
+
std::fs::create_dir_all(&path).unwrap();
|
|
223
|
+
path
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
#[test]
|
|
227
|
+
fn coordinator_blocker_is_durable_and_not_delivered() {
|
|
228
|
+
let workspace = workspace("coordinator-blocker");
|
|
229
|
+
let mut intent = ResolvedSendIntent::accepted(
|
|
230
|
+
SendOrigin::Cli,
|
|
231
|
+
&workspace,
|
|
232
|
+
Some(TeamKey::new("carc")),
|
|
233
|
+
LogicalRecipient::from_resolved("w1"),
|
|
234
|
+
TrustedSender::leader(),
|
|
235
|
+
None,
|
|
236
|
+
"probe",
|
|
237
|
+
None,
|
|
238
|
+
true,
|
|
239
|
+
None,
|
|
240
|
+
);
|
|
241
|
+
intent.initial_disposition =
|
|
242
|
+
InitialDisposition::Blocked(DeliveryBlocker::CoordinatorUnavailable);
|
|
243
|
+
let PersistResolution::Persisted(persisted) = persist_resolved_send(&intent).unwrap()
|
|
244
|
+
else {
|
|
245
|
+
panic!("fresh intent must persist")
|
|
246
|
+
};
|
|
247
|
+
assert_eq!(
|
|
248
|
+
persisted.row_status,
|
|
249
|
+
MessageRowStatus::QueuedCoordinatorUnavailable
|
|
250
|
+
);
|
|
251
|
+
assert_eq!(
|
|
252
|
+
persisted.blocker,
|
|
253
|
+
Some(DeliveryBlocker::CoordinatorUnavailable)
|
|
254
|
+
);
|
|
255
|
+
let store = MessageStore::open(&workspace).unwrap();
|
|
256
|
+
let connection = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
257
|
+
let (status, delivered_at, error): (String, Option<String>, Option<String>) = connection
|
|
258
|
+
.query_row(
|
|
259
|
+
"select status, delivered_at, error from messages where message_id = ?1",
|
|
260
|
+
[&persisted.message_id],
|
|
261
|
+
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|
262
|
+
)
|
|
263
|
+
.unwrap();
|
|
264
|
+
assert_eq!(status, "queued_coordinator_unavailable");
|
|
265
|
+
assert_eq!(delivered_at, None);
|
|
266
|
+
assert_eq!(error.as_deref(), Some("coordinator_unavailable"));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
#[test]
|
|
270
|
+
fn production_row_creation_is_owned_by_persisted_primitive() {
|
|
271
|
+
for (name, source) in [
|
|
272
|
+
("send", include_str!("send.rs")),
|
|
273
|
+
("leader_receiver", include_str!("leader_receiver.rs")),
|
|
274
|
+
("delivery", include_str!("delivery.rs")),
|
|
275
|
+
("watchers", include_str!("watchers.rs")),
|
|
276
|
+
("helpers", include_str!("helpers.rs")),
|
|
277
|
+
("selftest", include_str!("selftest.rs")),
|
|
278
|
+
] {
|
|
279
|
+
assert!(
|
|
280
|
+
!source.contains(".create_message(")
|
|
281
|
+
&& !source.contains(".create_message_with_id("),
|
|
282
|
+
"{name} still creates message rows outside persist_resolved_send"
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
#[test]
|
|
288
|
+
fn persisted_truth_and_workspace_identity_have_forward_consumers() {
|
|
289
|
+
for (name, source) in [
|
|
290
|
+
("send", include_str!("send.rs")),
|
|
291
|
+
("delivery", include_str!("delivery.rs")),
|
|
292
|
+
("leader_receiver", include_str!("leader_receiver.rs")),
|
|
293
|
+
] {
|
|
294
|
+
assert!(
|
|
295
|
+
source.contains("persisted.row_status"),
|
|
296
|
+
"{name} rebuilds wire truth instead of consuming PersistedSend"
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
let watchers = include_str!("watchers.rs");
|
|
300
|
+
assert!(
|
|
301
|
+
!watchers.contains(".and_then(std::path::Path::parent)"),
|
|
302
|
+
"watcher identity must not be derived from the team.db layout"
|
|
303
|
+
);
|
|
304
|
+
assert!(
|
|
305
|
+
watchers.contains("deliver_primary_watcher(\n workspace,"),
|
|
306
|
+
"watcher must forward the caller's canonical workspace"
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|