@team-agent/installer 0.3.14 → 0.3.16
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 +1 -0
- package/crates/team-agent/src/cli/emit.rs +92 -11
- package/crates/team-agent/src/cli/leader.rs +2 -0
- package/crates/team-agent/src/cli/mod.rs +255 -37
- package/crates/team-agent/src/cli/send.rs +252 -0
- package/crates/team-agent/src/cli/status.rs +5 -1
- package/crates/team-agent/src/cli/status_port.rs +24 -0
- package/crates/team-agent/src/cli/tests/base.rs +21 -3
- package/crates/team-agent/src/cli/tests/divergence.rs +6 -6
- package/crates/team-agent/src/cli/tests/run_delegation.rs +15 -3
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +285 -2
- package/crates/team-agent/src/cli/tests/status_send.rs +25 -0
- package/crates/team-agent/src/cli/types.rs +32 -1
- package/crates/team-agent/src/compiler/tests.rs +2 -2
- package/crates/team-agent/src/compiler.rs +1 -1
- package/crates/team-agent/src/fake_worker.rs +32 -145
- package/crates/team-agent/src/leader/start.rs +272 -8
- package/crates/team-agent/src/leader/tests/basics.rs +1 -0
- package/crates/team-agent/src/leader/tests/identity.rs +121 -25
- package/crates/team-agent/src/leader/types.rs +9 -0
- package/crates/team-agent/src/lifecycle/display.rs +3 -3
- package/crates/team-agent/src/lifecycle/launch.rs +631 -70
- package/crates/team-agent/src/lifecycle/restart/agent.rs +137 -17
- package/crates/team-agent/src/lifecycle/restart/common.rs +38 -2
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +79 -4
- package/crates/team-agent/src/lifecycle/tests/core.rs +14 -5
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +731 -8
- package/crates/team-agent/src/lifecycle/types.rs +5 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +8 -0
- package/crates/team-agent/src/mcp_server/wire.rs +153 -3
- package/crates/team-agent/src/messaging/leader_receiver.rs +276 -43
- package/crates/team-agent/src/messaging/mod.rs +6 -3
- package/crates/team-agent/src/messaging/results.rs +240 -158
- package/crates/team-agent/src/messaging/send.rs +3 -2
- package/crates/team-agent/src/messaging/tests/e23.rs +35 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +6 -2
- package/crates/team-agent/src/messaging/tests/runtime.rs +9 -9
- package/crates/team-agent/src/os_probe.rs +11 -0
- package/crates/team-agent/src/state/persist.rs +6 -0
- package/crates/team-agent/src/tmux_backend.rs +85 -0
- package/crates/team-agent/src/transport/test_support.rs +46 -1
- package/crates/team-agent/src/transport.rs +27 -0
- package/package.json +4 -4
- package/skills/team-agent/references/recovery-runbook.md +277 -0
|
@@ -413,6 +413,8 @@ pub struct LaunchReport {
|
|
|
413
413
|
pub started: Vec<StartedAgent>,
|
|
414
414
|
/// 是否 dry-run(只解析路由/权限,不起进程)。
|
|
415
415
|
pub dry_run: bool,
|
|
416
|
+
/// Selected tmux endpoint for the team transport when known.
|
|
417
|
+
pub tmux_endpoint: Option<String>,
|
|
416
418
|
/// 路由决策(每 task 一条;`routing.decision` 事件)。
|
|
417
419
|
pub routes: Vec<RoutingDecision>,
|
|
418
420
|
/// 权限摘要(每 agent 一条)。
|
|
@@ -436,6 +438,9 @@ pub struct StartedAgent {
|
|
|
436
438
|
pub claude_config_dir: Option<PathBuf>,
|
|
437
439
|
pub provider_projects_root: Option<PathBuf>,
|
|
438
440
|
pub managed_mcp_config: bool,
|
|
441
|
+
pub layout_window: Option<WindowName>,
|
|
442
|
+
pub layout_index: Option<usize>,
|
|
443
|
+
pub pane_index: Option<usize>,
|
|
439
444
|
pub display: WorkerDisplay,
|
|
440
445
|
}
|
|
441
446
|
|
|
@@ -22,6 +22,14 @@ Use Team Agent MCP tools for team-visible coordination:
|
|
|
22
22
|
- Do not pass sender, task_id, agent_id, schema_version, or ack fields unless
|
|
23
23
|
doing a low-level compatibility diagnostic. The MCP runtime fills protocol
|
|
24
24
|
fields from the current worker and task state.
|
|
25
|
+
- Emergency fallback exception: if a leader-bound MCP send/report payload is
|
|
26
|
+
already built but the MCP transport itself fails or the primary delivery path
|
|
27
|
+
errors (for example `Transport closed`, `Connection refused`, `Broken pipe`,
|
|
28
|
+
`EOF`, timeout, or internal delivery error), use `team-agent fallback-send-leader`
|
|
29
|
+
or `team-agent fallback-report-result` exactly once with `--primary-error`.
|
|
30
|
+
Do not use fallback for business refusals such as permission, quota, or unknown
|
|
31
|
+
target. After fallback, tell the leader to run `team-agent restart-agent` to
|
|
32
|
+
refresh the worker MCP transport.
|
|
25
33
|
|
|
26
34
|
If you are blocked or cannot continue, message the leader promptly instead of
|
|
27
35
|
waiting silently. If work takes several minutes, send a short progress update.
|
|
@@ -5,6 +5,7 @@ use std::path::Path;
|
|
|
5
5
|
|
|
6
6
|
use serde_json::Value;
|
|
7
7
|
|
|
8
|
+
use crate::event_log::EventLog;
|
|
8
9
|
use crate::messaging::MessageTarget;
|
|
9
10
|
|
|
10
11
|
use super::helpers::{json_dumps_default, object_fields};
|
|
@@ -172,19 +173,35 @@ fn run_stdio_loop<R: BufRead, W: Write>(
|
|
|
172
173
|
) -> Result<ServerRunReport, McpError> {
|
|
173
174
|
let tools = TeamOrchestratorTools::new(workspace);
|
|
174
175
|
let mut report = ServerRunReport::default();
|
|
176
|
+
let marker = McpServerLifecycleMarker::from_env(workspace);
|
|
177
|
+
marker.write_started();
|
|
178
|
+
let result = run_stdio_loop_inner(&tools, reader, &mut writer, &mut report);
|
|
179
|
+
match &result {
|
|
180
|
+
Ok(_) => marker.write_exit("stdin_eof", None),
|
|
181
|
+
Err(error) => marker.write_exit("fatal_error", Some(&error.to_string())),
|
|
182
|
+
}
|
|
183
|
+
result
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
fn run_stdio_loop_inner<R: BufRead, W: Write>(
|
|
187
|
+
tools: &TeamOrchestratorTools,
|
|
188
|
+
reader: R,
|
|
189
|
+
writer: &mut W,
|
|
190
|
+
report: &mut ServerRunReport,
|
|
191
|
+
) -> Result<ServerRunReport, McpError> {
|
|
175
192
|
for line in reader.lines() {
|
|
176
193
|
let line = line?;
|
|
177
194
|
report.requests_read = report.requests_read.saturating_add(1);
|
|
178
|
-
let frame = handle_stdin_line(
|
|
195
|
+
let frame = handle_stdin_line(tools, &line, report)?;
|
|
179
196
|
if let Some(value) = frame {
|
|
180
|
-
serde_json::to_writer(&mut writer, &value)?;
|
|
197
|
+
serde_json::to_writer(&mut *writer, &value)?;
|
|
181
198
|
writer.write_all(b"\n")?;
|
|
182
199
|
writer.flush()?;
|
|
183
200
|
report.responses_written = report.responses_written.saturating_add(1);
|
|
184
201
|
}
|
|
185
202
|
}
|
|
186
203
|
report.clean_eof = true;
|
|
187
|
-
Ok(report)
|
|
204
|
+
Ok(report.clone())
|
|
188
205
|
}
|
|
189
206
|
|
|
190
207
|
fn handle_stdin_line(
|
|
@@ -247,6 +264,66 @@ fn tool_call_result_value(is_error: bool, body: &Value) -> Value {
|
|
|
247
264
|
})
|
|
248
265
|
}
|
|
249
266
|
|
|
267
|
+
struct McpServerLifecycleMarker {
|
|
268
|
+
workspace: std::path::PathBuf,
|
|
269
|
+
agent_id: Option<String>,
|
|
270
|
+
owner_team_id: Option<String>,
|
|
271
|
+
pid: u32,
|
|
272
|
+
ppid: u32,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
impl McpServerLifecycleMarker {
|
|
276
|
+
fn from_env(workspace: &Path) -> Self {
|
|
277
|
+
Self {
|
|
278
|
+
workspace: workspace.to_path_buf(),
|
|
279
|
+
agent_id: non_empty_env("TEAM_AGENT_ID"),
|
|
280
|
+
owner_team_id: non_empty_env("TEAM_AGENT_OWNER_TEAM_ID"),
|
|
281
|
+
pid: std::process::id(),
|
|
282
|
+
ppid: parent_pid(),
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
fn write_started(&self) {
|
|
287
|
+
self.write("mcp.server_started", None, None);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
fn write_exit(&self, reason: &str, error: Option<&str>) {
|
|
291
|
+
self.write("mcp.server_exit", Some(reason), error);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
fn write(&self, event: &str, reason: Option<&str>, error: Option<&str>) {
|
|
295
|
+
let _ = EventLog::new(&self.workspace).write(
|
|
296
|
+
event,
|
|
297
|
+
serde_json::json!({
|
|
298
|
+
"agent_id": self.agent_id.as_deref(),
|
|
299
|
+
"owner_team_id": self.owner_team_id.as_deref(),
|
|
300
|
+
"workspace": self.workspace.display().to_string(),
|
|
301
|
+
"pid": self.pid,
|
|
302
|
+
"ppid": self.ppid,
|
|
303
|
+
"reason": reason,
|
|
304
|
+
"error": error,
|
|
305
|
+
}),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
fn non_empty_env(key: &str) -> Option<String> {
|
|
311
|
+
std::env::var(key)
|
|
312
|
+
.ok()
|
|
313
|
+
.map(|value| value.trim().to_string())
|
|
314
|
+
.filter(|value| !value.is_empty())
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#[cfg(unix)]
|
|
318
|
+
fn parent_pid() -> u32 {
|
|
319
|
+
u32::try_from(unsafe { libc::getppid() }).unwrap_or(0)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
#[cfg(not(unix))]
|
|
323
|
+
fn parent_pid() -> u32 {
|
|
324
|
+
0
|
|
325
|
+
}
|
|
326
|
+
|
|
250
327
|
fn error_response_value(id: RpcId, code: i64, message: String) -> Value {
|
|
251
328
|
let response = RpcResponse {
|
|
252
329
|
jsonrpc: "2.0".to_string(),
|
|
@@ -489,3 +566,76 @@ fn message_target_from_value(value: Option<&Value>) -> MessageTarget {
|
|
|
489
566
|
_ => MessageTarget::Single(String::new()),
|
|
490
567
|
}
|
|
491
568
|
}
|
|
569
|
+
|
|
570
|
+
#[cfg(test)]
|
|
571
|
+
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
572
|
+
mod e23_lifecycle_marker_tests {
|
|
573
|
+
use super::*;
|
|
574
|
+
|
|
575
|
+
fn marker_ws(tag: &str) -> std::path::PathBuf {
|
|
576
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
577
|
+
static N: AtomicU64 = AtomicU64::new(0);
|
|
578
|
+
let path = std::env::temp_dir().join(format!(
|
|
579
|
+
"ta-rs-mcp-marker-{tag}-{}-{}",
|
|
580
|
+
std::process::id(),
|
|
581
|
+
N.fetch_add(1, Ordering::Relaxed)
|
|
582
|
+
));
|
|
583
|
+
std::fs::create_dir_all(&path).unwrap();
|
|
584
|
+
path
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
#[test]
|
|
588
|
+
fn stdio_loop_writes_started_and_stdin_eof_exit_markers() {
|
|
589
|
+
let ws = marker_ws("eof");
|
|
590
|
+
let input = std::io::Cursor::new(Vec::<u8>::new());
|
|
591
|
+
let mut output = Vec::new();
|
|
592
|
+
|
|
593
|
+
let report = run_stdio_loop(&ws, input, &mut output).unwrap();
|
|
594
|
+
|
|
595
|
+
assert!(report.clean_eof);
|
|
596
|
+
assert!(output.is_empty(), "empty stdin must not write stdout");
|
|
597
|
+
let events = EventLog::new(&ws).tail(0).unwrap();
|
|
598
|
+
assert_eq!(events.len(), 2, "started + stdin_eof exit events");
|
|
599
|
+
assert_eq!(events[0]["event"], serde_json::json!("mcp.server_started"));
|
|
600
|
+
assert_eq!(events[1]["event"], serde_json::json!("mcp.server_exit"));
|
|
601
|
+
assert_eq!(events[1]["reason"], serde_json::json!("stdin_eof"));
|
|
602
|
+
assert_eq!(events[1]["workspace"], serde_json::json!(ws.display().to_string()));
|
|
603
|
+
assert_eq!(events[1]["pid"], serde_json::json!(std::process::id()));
|
|
604
|
+
assert!(events[1].get("ppid").is_some());
|
|
605
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
#[test]
|
|
609
|
+
fn stdio_loop_writes_fatal_error_marker_before_returning_io_error() {
|
|
610
|
+
struct FailingWriter;
|
|
611
|
+
impl Write for FailingWriter {
|
|
612
|
+
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
|
|
613
|
+
Err(std::io::Error::other("simulated stdout failure"))
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
fn flush(&mut self) -> std::io::Result<()> {
|
|
617
|
+
Ok(())
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
let ws = marker_ws("fatal");
|
|
622
|
+
let input = std::io::Cursor::new(
|
|
623
|
+
br#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#.to_vec(),
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
let err = run_stdio_loop(&ws, input, FailingWriter).expect_err("writer failure");
|
|
627
|
+
|
|
628
|
+
assert!(err.to_string().contains("simulated stdout failure"));
|
|
629
|
+
let events = EventLog::new(&ws).tail(0).unwrap();
|
|
630
|
+
assert_eq!(events[0]["event"], serde_json::json!("mcp.server_started"));
|
|
631
|
+
assert_eq!(events[1]["event"], serde_json::json!("mcp.server_exit"));
|
|
632
|
+
assert_eq!(events[1]["reason"], serde_json::json!("fatal_error"));
|
|
633
|
+
assert!(
|
|
634
|
+
events[1]["error"]
|
|
635
|
+
.as_str()
|
|
636
|
+
.unwrap_or_default()
|
|
637
|
+
.contains("simulated stdout failure")
|
|
638
|
+
);
|
|
639
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
use std::path::Path;
|
|
4
4
|
|
|
5
|
+
use rusqlite::OptionalExtension;
|
|
5
6
|
use serde_json::Value;
|
|
6
7
|
|
|
7
8
|
use crate::event_log::EventLog;
|
|
8
9
|
use crate::message_store::{MessageStore, NotificationClaimParams};
|
|
9
10
|
use crate::model::ids::{OwnerEpoch, TaskId};
|
|
10
|
-
use crate::transport::Transport;
|
|
11
|
+
use crate::transport::{InjectPayload, Key, PaneId, Target, Transport};
|
|
11
12
|
|
|
12
13
|
use super::helpers::MessageStatusShadow;
|
|
13
|
-
use super::{DeliveryOutcome, DeliveryStatus, MessagingError};
|
|
14
|
+
use super::{DeliveryOutcome, DeliveryRefusal, DeliveryStatus, MessagingError};
|
|
14
15
|
|
|
15
16
|
/// `_send_to_leader_receiver` (`leader.py:69`) — **N31/N32 funnel primitive**:所有 leader-bound
|
|
16
17
|
/// caller(send_message(to=leader) / report_result / request_human / idle reminder /
|
|
@@ -32,6 +33,36 @@ pub fn send_to_leader_receiver(
|
|
|
32
33
|
requires_ack: bool,
|
|
33
34
|
result_id: Option<&str>,
|
|
34
35
|
event_log: &EventLog,
|
|
36
|
+
) -> Result<DeliveryOutcome, MessagingError> {
|
|
37
|
+
send_to_leader_receiver_with_message_id(
|
|
38
|
+
workspace,
|
|
39
|
+
state,
|
|
40
|
+
leader_id,
|
|
41
|
+
content,
|
|
42
|
+
task_id,
|
|
43
|
+
sender,
|
|
44
|
+
requires_ack,
|
|
45
|
+
result_id,
|
|
46
|
+
None,
|
|
47
|
+
event_log,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Same leader funnel as [`send_to_leader_receiver`], but lets the caller supply a
|
|
52
|
+
/// stable message id for transport-fallback retries. The public function above
|
|
53
|
+
/// keeps the existing call surface unchanged.
|
|
54
|
+
#[allow(clippy::too_many_arguments)]
|
|
55
|
+
pub fn send_to_leader_receiver_with_message_id(
|
|
56
|
+
workspace: &Path,
|
|
57
|
+
state: &serde_json::Value,
|
|
58
|
+
leader_id: &str,
|
|
59
|
+
content: &str,
|
|
60
|
+
task_id: Option<&TaskId>,
|
|
61
|
+
sender: &str,
|
|
62
|
+
requires_ack: bool,
|
|
63
|
+
result_id: Option<&str>,
|
|
64
|
+
requested_message_id: Option<&str>,
|
|
65
|
+
event_log: &EventLog,
|
|
35
66
|
) -> Result<DeliveryOutcome, MessagingError> {
|
|
36
67
|
let store = MessageStore::open(workspace)?;
|
|
37
68
|
let owner_team = active_team_key(workspace, state);
|
|
@@ -41,15 +72,40 @@ pub fn send_to_leader_receiver(
|
|
|
41
72
|
serde_json::json!({"sender": sender, "leader_id": leader_id, "result_id": result_id}),
|
|
42
73
|
)?;
|
|
43
74
|
}
|
|
44
|
-
let message_id =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
75
|
+
let message_id = if let Some(requested) = requested_message_id {
|
|
76
|
+
if store.message_exists(requested)? {
|
|
77
|
+
return Ok(DeliveryOutcome {
|
|
78
|
+
ok: false,
|
|
79
|
+
status: DeliveryStatus::Refused,
|
|
80
|
+
message_status: MessageStatusShadow("refused".to_string()),
|
|
81
|
+
message_id: Some(requested.to_string()),
|
|
82
|
+
verification: None,
|
|
83
|
+
stage: None,
|
|
84
|
+
reason: Some(DeliveryRefusal::Duplicate),
|
|
85
|
+
channel: Some("leader_receiver".to_string()),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
store.create_message_with_id(
|
|
89
|
+
requested,
|
|
90
|
+
task_id.map(TaskId::as_str),
|
|
91
|
+
sender,
|
|
92
|
+
leader_id,
|
|
93
|
+
content,
|
|
94
|
+
None,
|
|
95
|
+
false,
|
|
96
|
+
Some(&owner_team),
|
|
97
|
+
)?
|
|
98
|
+
} else {
|
|
99
|
+
store.create_message(
|
|
100
|
+
task_id.map(TaskId::as_str),
|
|
101
|
+
sender,
|
|
102
|
+
leader_id,
|
|
103
|
+
content,
|
|
104
|
+
None,
|
|
105
|
+
false,
|
|
106
|
+
Some(&owner_team),
|
|
107
|
+
)?
|
|
108
|
+
};
|
|
53
109
|
// #231 exactly-once across rebind: insert the leader_notification_log PK BEFORE the
|
|
54
110
|
// unbound-pane check. That way, if the result row gets blocked (I-4) and later the
|
|
55
111
|
// leader rebinds + reclaim replays this row, the dedup PK still gates any second
|
|
@@ -170,7 +226,9 @@ pub fn claim_leader_receiver(
|
|
|
170
226
|
}
|
|
171
227
|
let next_epoch = owner_epoch(state).unwrap_or(0).saturating_add(1);
|
|
172
228
|
let Some(root) = state.as_object_mut() else {
|
|
173
|
-
return Err(MessagingError::Routing(
|
|
229
|
+
return Err(MessagingError::Routing(
|
|
230
|
+
"runtime state root is not an object".to_string(),
|
|
231
|
+
));
|
|
174
232
|
};
|
|
175
233
|
let owner = root
|
|
176
234
|
.entry("team_owner")
|
|
@@ -225,49 +283,199 @@ pub fn mirror_peer_message_to_leader(
|
|
|
225
283
|
event_log: &EventLog,
|
|
226
284
|
) -> Result<(), MessagingError> {
|
|
227
285
|
let _ = send_to_leader_receiver(
|
|
228
|
-
workspace,
|
|
229
|
-
state,
|
|
230
|
-
"leader",
|
|
231
|
-
content,
|
|
232
|
-
task_id,
|
|
233
|
-
sender,
|
|
234
|
-
false,
|
|
235
|
-
None,
|
|
236
|
-
event_log,
|
|
286
|
+
workspace, state, "leader", content, task_id, sender, false, None, event_log,
|
|
237
287
|
)?;
|
|
238
288
|
let _ = recipient;
|
|
239
289
|
Ok(())
|
|
240
290
|
}
|
|
241
291
|
|
|
292
|
+
/// E23 transport fallback: physically inject the already-built leader payload into
|
|
293
|
+
/// the bound leader pane only after the caller's primary path is known not to have
|
|
294
|
+
/// succeeded. This primitive is reclaim-neutral: it reads leader_receiver/team_owner
|
|
295
|
+
/// fields but never writes binding state.
|
|
296
|
+
#[allow(clippy::too_many_arguments)]
|
|
297
|
+
pub fn deliver_to_leader_fallback_pane(
|
|
298
|
+
workspace: &Path,
|
|
299
|
+
state: &Value,
|
|
300
|
+
message_id: &str,
|
|
301
|
+
result_id: Option<&str>,
|
|
302
|
+
content: &str,
|
|
303
|
+
primary_ok: bool,
|
|
304
|
+
primary_error: Option<&str>,
|
|
305
|
+
event_log: &EventLog,
|
|
306
|
+
) -> Result<DeliveryOutcome, MessagingError> {
|
|
307
|
+
if primary_ok {
|
|
308
|
+
return Ok(DeliveryOutcome {
|
|
309
|
+
ok: true,
|
|
310
|
+
status: DeliveryStatus::AlreadyDelivered,
|
|
311
|
+
message_status: MessageStatusShadow("delivered".to_string()),
|
|
312
|
+
message_id: Some(message_id.to_string()),
|
|
313
|
+
verification: None,
|
|
314
|
+
stage: None,
|
|
315
|
+
reason: None,
|
|
316
|
+
channel: Some("leader_receiver".to_string()),
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let store = MessageStore::open(workspace)?;
|
|
321
|
+
if message_already_delivered(&store, message_id)? {
|
|
322
|
+
return Ok(DeliveryOutcome {
|
|
323
|
+
ok: true,
|
|
324
|
+
status: DeliveryStatus::AlreadyDelivered,
|
|
325
|
+
message_status: MessageStatusShadow("delivered".to_string()),
|
|
326
|
+
message_id: Some(message_id.to_string()),
|
|
327
|
+
verification: None,
|
|
328
|
+
stage: None,
|
|
329
|
+
reason: None,
|
|
330
|
+
channel: Some("leader_receiver".to_string()),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let owner_team_id = active_team_key(workspace, state);
|
|
335
|
+
let pane_id = leader_pane_id(state).map(str::to_string);
|
|
336
|
+
let primary_error = primary_error
|
|
337
|
+
.filter(|error| !error.trim().is_empty())
|
|
338
|
+
.unwrap_or("primary delivery failed");
|
|
339
|
+
let attempt_payload = serde_json::json!({
|
|
340
|
+
"message_id": message_id,
|
|
341
|
+
"result_id": result_id,
|
|
342
|
+
"owner_team_id": owner_team_id,
|
|
343
|
+
"pane_id": pane_id,
|
|
344
|
+
"primary_error": primary_error,
|
|
345
|
+
"delivered_via": "fallback_pane",
|
|
346
|
+
});
|
|
347
|
+
event_log.write(
|
|
348
|
+
"leader_receiver.fallback_pane_attempt",
|
|
349
|
+
attempt_payload.clone(),
|
|
350
|
+
)?;
|
|
351
|
+
|
|
352
|
+
let Some(pane_id) = pane_id.filter(|pane| !pane.is_empty() && pane != "__team_agent_unbound__")
|
|
353
|
+
else {
|
|
354
|
+
let failed = serde_json::json!({
|
|
355
|
+
"message_id": message_id,
|
|
356
|
+
"result_id": result_id,
|
|
357
|
+
"owner_team_id": owner_team_id,
|
|
358
|
+
"pane_id": null,
|
|
359
|
+
"primary_error": primary_error,
|
|
360
|
+
"delivered_via": "fallback_pane",
|
|
361
|
+
"reason": "no_bound_pane",
|
|
362
|
+
});
|
|
363
|
+
event_log.write("leader_receiver.fallback_pane_failed", failed)?;
|
|
364
|
+
return Ok(DeliveryOutcome {
|
|
365
|
+
ok: false,
|
|
366
|
+
status: DeliveryStatus::Blocked,
|
|
367
|
+
message_status: MessageStatusShadow("blocked".to_string()),
|
|
368
|
+
message_id: Some(message_id.to_string()),
|
|
369
|
+
verification: Some("run team-agent claim-leader or team-agent takeover".to_string()),
|
|
370
|
+
stage: None,
|
|
371
|
+
reason: Some(DeliveryRefusal::LeaderNotAttached),
|
|
372
|
+
channel: Some("fallback_pane".to_string()),
|
|
373
|
+
});
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
let rendered = render_fallback_pane_message(content, message_id, primary_error);
|
|
377
|
+
let target = Target::Pane(PaneId::new(&pane_id));
|
|
378
|
+
let payload = InjectPayload::Text(rendered);
|
|
379
|
+
let inject_result = leader_tmux_socket(state)
|
|
380
|
+
.and_then(|socket| {
|
|
381
|
+
let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket);
|
|
382
|
+
backend.inject(&target, &payload, Key::Enter, true).ok()
|
|
383
|
+
})
|
|
384
|
+
.map(Ok)
|
|
385
|
+
.unwrap_or_else(|| {
|
|
386
|
+
let backend = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
|
|
387
|
+
backend.inject(&target, &payload, Key::Enter, true)
|
|
388
|
+
})
|
|
389
|
+
.or_else(|_| {
|
|
390
|
+
let backend = crate::tmux_backend::TmuxBackend::new();
|
|
391
|
+
backend.inject(&target, &payload, Key::Enter, true)
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
match inject_result {
|
|
395
|
+
Ok(report) => {
|
|
396
|
+
store.mark(message_id, "delivered", None)?;
|
|
397
|
+
event_log.write(
|
|
398
|
+
"leader_receiver.fallback_pane_submitted",
|
|
399
|
+
serde_json::json!({
|
|
400
|
+
"message_id": message_id,
|
|
401
|
+
"result_id": result_id,
|
|
402
|
+
"owner_team_id": owner_team_id,
|
|
403
|
+
"pane_id": pane_id,
|
|
404
|
+
"primary_error": primary_error,
|
|
405
|
+
"delivered_via": "fallback_pane",
|
|
406
|
+
"verification": format!("{:?}", report.submit_verification),
|
|
407
|
+
}),
|
|
408
|
+
)?;
|
|
409
|
+
Ok(DeliveryOutcome {
|
|
410
|
+
ok: true,
|
|
411
|
+
status: DeliveryStatus::Delivered,
|
|
412
|
+
message_status: MessageStatusShadow("delivered".to_string()),
|
|
413
|
+
message_id: Some(message_id.to_string()),
|
|
414
|
+
verification: Some("delivered_via=fallback_pane".to_string()),
|
|
415
|
+
stage: None,
|
|
416
|
+
reason: None,
|
|
417
|
+
channel: Some("fallback_pane".to_string()),
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
Err(error) => {
|
|
421
|
+
event_log.write(
|
|
422
|
+
"leader_receiver.fallback_pane_failed",
|
|
423
|
+
serde_json::json!({
|
|
424
|
+
"message_id": message_id,
|
|
425
|
+
"result_id": result_id,
|
|
426
|
+
"owner_team_id": owner_team_id,
|
|
427
|
+
"pane_id": pane_id,
|
|
428
|
+
"primary_error": primary_error,
|
|
429
|
+
"delivered_via": "fallback_pane",
|
|
430
|
+
"reason": error.to_string(),
|
|
431
|
+
}),
|
|
432
|
+
)?;
|
|
433
|
+
Ok(DeliveryOutcome {
|
|
434
|
+
ok: false,
|
|
435
|
+
status: DeliveryStatus::Failed,
|
|
436
|
+
message_status: MessageStatusShadow("failed".to_string()),
|
|
437
|
+
message_id: Some(message_id.to_string()),
|
|
438
|
+
verification: Some(error.to_string()),
|
|
439
|
+
stage: None,
|
|
440
|
+
reason: Some(DeliveryRefusal::TmuxTargetMissing),
|
|
441
|
+
channel: Some("fallback_pane".to_string()),
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
242
447
|
pub(crate) fn active_team_key(workspace: &Path, state: &Value) -> String {
|
|
243
448
|
state
|
|
244
449
|
.get("active_team_key")
|
|
245
450
|
.and_then(Value::as_str)
|
|
246
451
|
.filter(|team| !team.is_empty())
|
|
247
452
|
.map(ToString::to_string)
|
|
248
|
-
.or_else(||
|
|
453
|
+
.or_else(|| {
|
|
454
|
+
workspace
|
|
455
|
+
.file_name()
|
|
456
|
+
.map(|name| name.to_string_lossy().to_string())
|
|
457
|
+
})
|
|
249
458
|
.unwrap_or_else(|| "current".to_string())
|
|
250
459
|
}
|
|
251
460
|
|
|
252
461
|
fn owner_epoch(state: &Value) -> Option<u64> {
|
|
253
462
|
receiver_or_owner_field(state, "team_owner", "owner_epoch")
|
|
254
463
|
.and_then(Value::as_u64)
|
|
255
|
-
.or_else(||
|
|
464
|
+
.or_else(|| {
|
|
465
|
+
receiver_or_owner_field(state, "leader_receiver", "owner_epoch").and_then(Value::as_u64)
|
|
466
|
+
})
|
|
256
467
|
}
|
|
257
468
|
|
|
258
469
|
fn receiver_or_owner_field<'a>(state: &'a Value, record: &str, field: &str) -> Option<&'a Value> {
|
|
259
|
-
state
|
|
260
|
-
.get(
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
.and_then(|team| team.get(record))
|
|
269
|
-
.and_then(|v| v.get(field))
|
|
270
|
-
})
|
|
470
|
+
state.get(record).and_then(|v| v.get(field)).or_else(|| {
|
|
471
|
+
let active = state.get("active_team_key").and_then(Value::as_str)?;
|
|
472
|
+
state
|
|
473
|
+
.get("teams")
|
|
474
|
+
.and_then(Value::as_object)
|
|
475
|
+
.and_then(|teams| teams.get(active))
|
|
476
|
+
.and_then(|team| team.get(record))
|
|
477
|
+
.and_then(|v| v.get(field))
|
|
478
|
+
})
|
|
271
479
|
}
|
|
272
480
|
|
|
273
481
|
fn leader_record_field<'a>(state: &'a Value, field: &str) -> Option<&'a Value> {
|
|
@@ -284,8 +492,7 @@ fn leader_session_uuid(state: &Value) -> Option<&str> {
|
|
|
284
492
|
}
|
|
285
493
|
|
|
286
494
|
pub(crate) fn leader_pane_bound_but_not_live(workspace: &Path, state: &Value) -> bool {
|
|
287
|
-
leader_pane_id(state)
|
|
288
|
-
.is_some_and(|pane_id| !leader_pane_is_live(workspace, state, pane_id))
|
|
495
|
+
leader_pane_id(state).is_some_and(|pane_id| !leader_pane_is_live(workspace, state, pane_id))
|
|
289
496
|
}
|
|
290
497
|
|
|
291
498
|
fn leader_pane_is_live(workspace: &Path, state: &Value, pane_id: &str) -> bool {
|
|
@@ -304,7 +511,9 @@ fn leader_pane_is_live(workspace: &Path, state: &Value, pane_id: &str) -> bool {
|
|
|
304
511
|
.list_targets()
|
|
305
512
|
.unwrap_or_default(),
|
|
306
513
|
);
|
|
307
|
-
targets
|
|
514
|
+
targets
|
|
515
|
+
.iter()
|
|
516
|
+
.any(|target| target.pane_id.as_str() == pane_id)
|
|
308
517
|
}
|
|
309
518
|
|
|
310
519
|
fn leader_pane_id(state: &Value) -> Option<&str> {
|
|
@@ -318,11 +527,35 @@ fn leader_tmux_socket(state: &Value) -> Option<&str> {
|
|
|
318
527
|
.filter(|socket| std::path::Path::new(socket).is_absolute())
|
|
319
528
|
}
|
|
320
529
|
|
|
321
|
-
fn
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
)
|
|
530
|
+
fn message_already_delivered(
|
|
531
|
+
store: &MessageStore,
|
|
532
|
+
message_id: &str,
|
|
533
|
+
) -> Result<bool, MessagingError> {
|
|
534
|
+
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
535
|
+
let status = conn
|
|
536
|
+
.query_row(
|
|
537
|
+
"select status from messages where message_id = ?1",
|
|
538
|
+
[message_id],
|
|
539
|
+
|row| row.get::<_, String>(0),
|
|
540
|
+
)
|
|
541
|
+
.optional()?;
|
|
542
|
+
Ok(matches!(
|
|
543
|
+
status.as_deref(),
|
|
544
|
+
Some("delivered" | "acknowledged" | "submitted" | "submitted_unverified")
|
|
545
|
+
))
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
fn render_fallback_pane_message(content: &str, message_id: &str, primary_error: &str) -> String {
|
|
549
|
+
format!(
|
|
550
|
+
"Team Agent fallback delivery\n\
|
|
551
|
+
delivered_via=fallback_pane\n\
|
|
552
|
+
primary_error: {primary_error}\n\n\
|
|
553
|
+
{content}\n\n\
|
|
554
|
+
[team-agent-token:{message_id}]"
|
|
555
|
+
)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
fn copy_candidate_field(out: &mut serde_json::Map<String, Value>, candidate: &Value, key: &str) {
|
|
326
559
|
if let Some(value) = candidate.get(key) {
|
|
327
560
|
out.insert(key.to_string(), value.clone());
|
|
328
561
|
}
|
|
@@ -83,16 +83,19 @@ pub use delivery::{
|
|
|
83
83
|
};
|
|
84
84
|
pub use helpers::fail_leader_delivery;
|
|
85
85
|
pub use leader_receiver::{
|
|
86
|
-
claim_leader_receiver,
|
|
86
|
+
claim_leader_receiver, deliver_to_leader_fallback_pane, mirror_peer_message_to_leader,
|
|
87
|
+
send_to_leader_receiver, send_to_leader_receiver_with_message_id,
|
|
87
88
|
};
|
|
88
89
|
pub use peers::allow_peer_talk;
|
|
89
90
|
pub use results::{
|
|
90
91
|
collect, collect_for_team, collect_results_and_notify_watchers, report_result,
|
|
91
|
-
report_result_for_owner_team,
|
|
92
|
+
report_result_for_owner_team, report_result_for_owner_team_with_primary_error,
|
|
92
93
|
};
|
|
93
94
|
pub use scheduler::{detect_stuck_agents, fire_due_scheduled_events, stuck_cancel, stuck_list};
|
|
94
95
|
pub use selftest::{evaluate_idle_behavior, run_comms_selftest, CommsSelftestDriver};
|
|
95
|
-
pub use send::{
|
|
96
|
+
pub use send::{
|
|
97
|
+
apply_worker_sender_bypass, send_message, session_drift_refusal, MessageTarget, SendOptions,
|
|
98
|
+
};
|
|
96
99
|
pub use trust::{attempt_trust_auto_answer, TrustAnswerOutcome};
|
|
97
100
|
pub use types::{
|
|
98
101
|
ActivityStatus, AgentActivity, AlertSnapshot, AlertSuppression, AlertType, CheckEvidence,
|