@team-agent/installer 0.5.52 → 0.5.54
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 +18 -2
- package/crates/team-agent/src/cli/emit.rs +111 -3
- package/crates/team-agent/src/cli/mod.rs +155 -4
- package/crates/team-agent/src/cli/send/persist.rs +1 -0
- package/crates/team-agent/src/cli/send/presentation.rs +1 -0
- package/crates/team-agent/src/cli/send.rs +1 -0
- package/crates/team-agent/src/cli/spec.rs +4 -1
- package/crates/team-agent/src/cli/tests/lane_c.rs +3 -3
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +1 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +12 -0
- package/crates/team-agent/src/coordinator/tests/basics.rs +4 -4
- package/crates/team-agent/src/db/message_store.rs +31 -2
- package/crates/team-agent/src/db/migration.rs +7 -6
- package/crates/team-agent/src/db/schema.rs +18 -5
- package/crates/team-agent/src/diagnose/orphans.rs +24 -3
- package/crates/team-agent/src/kill_audit.rs +67 -0
- package/crates/team-agent/src/leader/lease.rs +324 -57
- package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
- package/crates/team-agent/src/leader/rediscover.rs +6 -0
- package/crates/team-agent/src/leader/start.rs +144 -23
- package/crates/team-agent/src/leader/tests/idle.rs +3 -0
- package/crates/team-agent/src/leader/types.rs +6 -0
- package/crates/team-agent/src/lib.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +5 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
- package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch.rs +14 -2
- package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -3
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +110 -12
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +8 -2
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
- package/crates/team-agent/src/lifecycle/types.rs +17 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
- package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
- package/crates/team-agent/src/mcp_server/normalize.rs +8 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
- package/crates/team-agent/src/mcp_server/tools.rs +89 -4
- package/crates/team-agent/src/mcp_server/types.rs +7 -0
- package/crates/team-agent/src/mcp_server/wire.rs +65 -4
- package/crates/team-agent/src/messaging/delivery.rs +2 -0
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/leader_channel.rs +32 -5
- package/crates/team-agent/src/messaging/leader_receiver.rs +100 -21
- package/crates/team-agent/src/messaging/mod.rs +2 -1
- package/crates/team-agent/src/messaging/persist.rs +68 -2
- package/crates/team-agent/src/messaging/presentation.rs +307 -0
- package/crates/team-agent/src/messaging/results.rs +130 -3
- package/crates/team-agent/src/messaging/selftest.rs +1 -0
- package/crates/team-agent/src/messaging/send.rs +54 -2
- package/crates/team-agent/src/messaging/tests/runtime.rs +85 -24
- package/crates/team-agent/src/messaging/types.rs +2 -0
- package/crates/team-agent/src/messaging/watchers.rs +1 -0
- package/crates/team-agent/src/provider/adapter.rs +54 -13
- package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
- package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
- package/crates/team-agent/src/provider/session/capture.rs +395 -52
- package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
- package/crates/team-agent/src/provider/session/mod.rs +6 -0
- package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
- package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
- package/crates/team-agent/src/provider/session_scan/common/tests.rs +112 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +53 -92
- package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
- package/crates/team-agent/src/provider/session_scan.rs +3 -3
- package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
- package/crates/team-agent/src/provider/tests.rs +1 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
- package/crates/team-agent/src/tmux_backend.rs +29 -2
- package/package.json +4 -4
- package/schemas/result-envelope.schema.json +10 -0
- package/skills/team-agent/SKILL.md +11 -3
|
@@ -33,6 +33,7 @@ pub fn tools_contract() -> Vec<Value> {
|
|
|
33
33
|
McpTool::StopAgent,
|
|
34
34
|
McpTool::ResetAgent,
|
|
35
35
|
McpTool::AddAgent,
|
|
36
|
+
McpTool::CloneAgent,
|
|
36
37
|
McpTool::ForkAgent,
|
|
37
38
|
McpTool::RequestHuman,
|
|
38
39
|
McpTool::StuckList,
|
|
@@ -368,16 +369,20 @@ fn python_repr(value: &str) -> String {
|
|
|
368
369
|
fn tool_contract(tool: McpTool) -> Value {
|
|
369
370
|
let (description, required) = match tool {
|
|
370
371
|
McpTool::SendMessage => (
|
|
371
|
-
"Send a message to a teammate, the leader, or '*' for all other team members.
|
|
372
|
+
"Send a message to a teammate, the leader, or '*' for all other team members. Team Agent fills identity and delivery metadata; optional presentation routing is durable and never drops the message.",
|
|
372
373
|
vec!["to", "content"],
|
|
373
374
|
),
|
|
374
375
|
McpTool::AssignTask => ("Assign or update a task in the team graph and deliver it to its assignee.", vec!["task"]),
|
|
375
|
-
McpTool::ReportResult => (
|
|
376
|
+
McpTool::ReportResult => (
|
|
377
|
+
"Report task completion with a durable result envelope. Optional presentation routing controls live leader display, not persistence.",
|
|
378
|
+
Vec::new(),
|
|
379
|
+
),
|
|
376
380
|
McpTool::UpdateState => ("Append a note to team state and rewrite team_state.md.", vec!["note"]),
|
|
377
381
|
McpTool::GetTeamStatus => ("Return machine-readable team status.", Vec::new()),
|
|
378
382
|
McpTool::StopAgent => ("Stop a running worker.", vec!["agent_id"]),
|
|
379
383
|
McpTool::ResetAgent => ("Reset one worker to a fresh session.", vec!["agent_id", "discard_session"]),
|
|
380
384
|
McpTool::AddAgent => ("Add a first-class worker from a role file.", vec!["new_agent_id", "role_file_path"]),
|
|
385
|
+
McpTool::CloneAgent => ("Clone a worker role into a fresh provider session.", vec!["source_agent_id", "as_agent_id"]),
|
|
381
386
|
McpTool::ForkAgent => ("Fork a running worker.", vec!["source_agent_id", "as_agent_id"]),
|
|
382
387
|
McpTool::RequestHuman => ("Ask the leader or user for human input.", vec!["question"]),
|
|
383
388
|
McpTool::StuckList => ("List manually suppressed idle alerts.", Vec::new()),
|
|
@@ -417,6 +422,11 @@ fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
|
|
|
417
422
|
string_property("Target agent id, 'leader', or '*' for broadcast."),
|
|
418
423
|
);
|
|
419
424
|
insert_property(&mut properties, "content", string_property("Message body."));
|
|
425
|
+
insert_property(
|
|
426
|
+
&mut properties,
|
|
427
|
+
"presentation",
|
|
428
|
+
presentation_property("Optional durable presentation routing."),
|
|
429
|
+
);
|
|
420
430
|
}
|
|
421
431
|
McpTool::ReportResult => {
|
|
422
432
|
insert_property(
|
|
@@ -465,6 +475,11 @@ fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
|
|
|
465
475
|
"agent_id",
|
|
466
476
|
string_property("Optional reporting agent id override."),
|
|
467
477
|
);
|
|
478
|
+
insert_property(
|
|
479
|
+
&mut properties,
|
|
480
|
+
"presentation",
|
|
481
|
+
presentation_property("Optional durable presentation routing."),
|
|
482
|
+
);
|
|
468
483
|
}
|
|
469
484
|
McpTool::UpdateState => {
|
|
470
485
|
insert_property(
|
|
@@ -522,6 +537,23 @@ fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
|
|
|
522
537
|
string_property("Optional display label."),
|
|
523
538
|
);
|
|
524
539
|
}
|
|
540
|
+
McpTool::CloneAgent => {
|
|
541
|
+
insert_property(
|
|
542
|
+
&mut properties,
|
|
543
|
+
"source_agent_id",
|
|
544
|
+
string_property("Agent id to clone from."),
|
|
545
|
+
);
|
|
546
|
+
insert_property(
|
|
547
|
+
&mut properties,
|
|
548
|
+
"as_agent_id",
|
|
549
|
+
string_property("Agent id for the cloned worker."),
|
|
550
|
+
);
|
|
551
|
+
insert_property(
|
|
552
|
+
&mut properties,
|
|
553
|
+
"label",
|
|
554
|
+
string_property("Optional display label."),
|
|
555
|
+
);
|
|
556
|
+
}
|
|
525
557
|
McpTool::RequestHuman => {
|
|
526
558
|
insert_property(
|
|
527
559
|
&mut properties,
|
|
@@ -571,6 +603,23 @@ fn object_property(description: &str) -> Value {
|
|
|
571
603
|
serde_json::json!({"type": "object", "description": description, "additionalProperties": true})
|
|
572
604
|
}
|
|
573
605
|
|
|
606
|
+
fn presentation_property(description: &str) -> Value {
|
|
607
|
+
serde_json::json!({
|
|
608
|
+
"type": "object",
|
|
609
|
+
"description": description,
|
|
610
|
+
"properties": {
|
|
611
|
+
"sink": {"type": "string", "enum": ["leader", "casefile", "silent"]},
|
|
612
|
+
"class": {"type": "string", "enum": [
|
|
613
|
+
"message", "progress", "stage_result", "stage_pass", "bounce",
|
|
614
|
+
"blocking", "final_review", "timeout"
|
|
615
|
+
]},
|
|
616
|
+
"case_id": {"type": "string"}
|
|
617
|
+
},
|
|
618
|
+
"required": ["sink", "class"],
|
|
619
|
+
"additionalProperties": false
|
|
620
|
+
})
|
|
621
|
+
}
|
|
622
|
+
|
|
574
623
|
fn array_property(description: &str) -> Value {
|
|
575
624
|
serde_json::json!({"type": "array", "description": description, "items": {"type": "object", "additionalProperties": true}})
|
|
576
625
|
}
|
|
@@ -591,12 +640,13 @@ pub(crate) fn dispatch_tool(
|
|
|
591
640
|
McpTool::SendMessage => {
|
|
592
641
|
let target = message_target_from_value(args.get("to"));
|
|
593
642
|
let content = args.get("content").and_then(Value::as_str).unwrap_or("");
|
|
594
|
-
let outcome = tools.
|
|
643
|
+
let outcome = tools.send_message_with_presentation(
|
|
595
644
|
&target,
|
|
596
645
|
content,
|
|
597
646
|
None,
|
|
598
647
|
None,
|
|
599
648
|
None,
|
|
649
|
+
args.get("presentation"),
|
|
600
650
|
)?;
|
|
601
651
|
match outcome {
|
|
602
652
|
SendOutcome::WorkerAccepted { .. } => Ok(ToolOk {
|
|
@@ -605,7 +655,7 @@ pub(crate) fn dispatch_tool(
|
|
|
605
655
|
SendOutcome::Direct(ok) => Ok(ok),
|
|
606
656
|
}
|
|
607
657
|
}
|
|
608
|
-
McpTool::ReportResult => tools.
|
|
658
|
+
McpTool::ReportResult => tools.report_result_with_presentation(
|
|
609
659
|
args.get("envelope"),
|
|
610
660
|
args.get("summary").and_then(Value::as_str),
|
|
611
661
|
// cr verdict (T3-1 refined): an unknown status literal normalizes to
|
|
@@ -637,6 +687,7 @@ pub(crate) fn dispatch_tool(
|
|
|
637
687
|
.map(Vec::as_slice),
|
|
638
688
|
args.get("task_id").and_then(Value::as_str),
|
|
639
689
|
args.get("agent_id").and_then(Value::as_str),
|
|
690
|
+
args.get("presentation"),
|
|
640
691
|
),
|
|
641
692
|
McpTool::UpdateState => {
|
|
642
693
|
tools.update_state(args.get("note").and_then(Value::as_str).unwrap_or(""))
|
|
@@ -668,6 +719,15 @@ pub(crate) fn dispatch_tool(
|
|
|
668
719
|
.unwrap_or(""),
|
|
669
720
|
args.get("label").and_then(Value::as_str),
|
|
670
721
|
),
|
|
722
|
+
McpTool::CloneAgent => tools.clone_agent(
|
|
723
|
+
args.get("source_agent_id")
|
|
724
|
+
.and_then(Value::as_str)
|
|
725
|
+
.unwrap_or(""),
|
|
726
|
+
args.get("as_agent_id")
|
|
727
|
+
.and_then(Value::as_str)
|
|
728
|
+
.unwrap_or(""),
|
|
729
|
+
args.get("label").and_then(Value::as_str),
|
|
730
|
+
),
|
|
671
731
|
McpTool::RequestHuman => tools.request_human(
|
|
672
732
|
args.get("question").and_then(Value::as_str).unwrap_or(""),
|
|
673
733
|
args.get("task_id").and_then(Value::as_str),
|
|
@@ -695,6 +755,7 @@ fn scope_ceiling_tool(tool: McpTool) -> bool {
|
|
|
695
755
|
| McpTool::GetTeamStatus
|
|
696
756
|
| McpTool::StopAgent
|
|
697
757
|
| McpTool::ResetAgent
|
|
758
|
+
| McpTool::CloneAgent
|
|
698
759
|
| McpTool::ForkAgent
|
|
699
760
|
)
|
|
700
761
|
}
|
|
@@ -57,6 +57,7 @@ pub fn deliver_stored_message(
|
|
|
57
57
|
requires_ack,
|
|
58
58
|
None,
|
|
59
59
|
super::InitialDisposition::Accepted,
|
|
60
|
+
None,
|
|
60
61
|
)?
|
|
61
62
|
else {
|
|
62
63
|
unreachable!("internal delivery does not accept caller-supplied ids")
|
|
@@ -1828,6 +1829,7 @@ pub fn deliver_pending_messages(
|
|
|
1828
1829
|
let store = MessageStore::open(workspace)?;
|
|
1829
1830
|
let message_ids = {
|
|
1830
1831
|
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
1832
|
+
// `stored_only` is intentionally absent: C6 chose durable presentation without C5.
|
|
1831
1833
|
let mut stmt = conn.prepare(
|
|
1832
1834
|
"select message_id from messages
|
|
1833
1835
|
where status in (
|
|
@@ -25,6 +25,7 @@ static RESULT_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
25
25
|
pub(crate) fn status_wire(status: DeliveryStatus) -> &'static str {
|
|
26
26
|
match status {
|
|
27
27
|
DeliveryStatus::Delivered => "delivered",
|
|
28
|
+
DeliveryStatus::StoredOnly => "stored_only",
|
|
28
29
|
DeliveryStatus::Failed => "failed",
|
|
29
30
|
DeliveryStatus::Queued => "queued",
|
|
30
31
|
DeliveryStatus::Blocked => "blocked",
|
|
@@ -101,6 +101,7 @@ pub fn resolve_live_leader_channel(
|
|
|
101
101
|
.current_path
|
|
102
102
|
.as_deref()
|
|
103
103
|
.is_some_and(|path| !path_is_in_workspace(path, workspace))
|
|
104
|
+
&& !explicit_claim_authority_matches(workspace, receiver, &observed)
|
|
104
105
|
{
|
|
105
106
|
return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch);
|
|
106
107
|
}
|
|
@@ -113,14 +114,40 @@ pub fn resolve_live_leader_channel(
|
|
|
113
114
|
}))
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
fn
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
fn explicit_claim_authority_matches(
|
|
118
|
+
workspace: &Path,
|
|
119
|
+
receiver: &Value,
|
|
120
|
+
observed: &PaneInfo,
|
|
121
|
+
) -> bool {
|
|
122
|
+
if string_field(receiver, "scope_authority") != Some("explicit_claim") {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
let Some(authorized_workspace) = string_field(receiver, "authorized_team_workspace") else {
|
|
126
|
+
return false;
|
|
127
|
+
};
|
|
128
|
+
if canonical_path(Path::new(authorized_workspace)) != canonical_path(workspace) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
let Some(recorded_nonce) = string_field(receiver, "binding_nonce") else {
|
|
132
|
+
return false;
|
|
133
|
+
};
|
|
134
|
+
observed
|
|
135
|
+
.leader_env
|
|
136
|
+
.get(crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY)
|
|
137
|
+
.filter(|nonce| !nonce.is_empty())
|
|
138
|
+
.is_some_and(|live_nonce| live_nonce == recorded_nonce)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
pub(crate) fn path_is_in_workspace(path: &Path, workspace: &Path) -> bool {
|
|
142
|
+
let path = canonical_path(path);
|
|
143
|
+
let workspace = canonical_path(workspace);
|
|
121
144
|
path == workspace || path.starts_with(workspace)
|
|
122
145
|
}
|
|
123
146
|
|
|
147
|
+
fn canonical_path(path: &Path) -> std::path::PathBuf {
|
|
148
|
+
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
|
149
|
+
}
|
|
150
|
+
|
|
124
151
|
fn receiver_transport_conflicts(receiver: &Value) -> bool {
|
|
125
152
|
let mode = receiver.get("mode").and_then(Value::as_str);
|
|
126
153
|
let transport_kind = receiver.get("transport_kind").and_then(Value::as_str);
|
|
@@ -14,6 +14,7 @@ use crate::transport::{
|
|
|
14
14
|
|
|
15
15
|
use super::helpers::MessageStatusShadow;
|
|
16
16
|
use super::persist::persist_internal_send;
|
|
17
|
+
use super::presentation::PresentationDecision;
|
|
17
18
|
use super::{
|
|
18
19
|
DeliveryOutcome, DeliveryRefusal, DeliveryStage, DeliveryStatus, InitialDisposition,
|
|
19
20
|
InternalSendKind, MessagingError, PersistResolution,
|
|
@@ -69,6 +70,35 @@ pub fn send_to_leader_receiver_with_message_id(
|
|
|
69
70
|
result_id: Option<&str>,
|
|
70
71
|
requested_message_id: Option<&str>,
|
|
71
72
|
event_log: &EventLog,
|
|
73
|
+
) -> Result<DeliveryOutcome, MessagingError> {
|
|
74
|
+
send_to_leader_receiver_with_presentation(
|
|
75
|
+
workspace,
|
|
76
|
+
state,
|
|
77
|
+
leader_id,
|
|
78
|
+
content,
|
|
79
|
+
task_id,
|
|
80
|
+
sender,
|
|
81
|
+
requires_ack,
|
|
82
|
+
result_id,
|
|
83
|
+
requested_message_id,
|
|
84
|
+
&PresentationDecision::default(),
|
|
85
|
+
event_log,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#[allow(clippy::too_many_arguments)]
|
|
90
|
+
pub fn send_to_leader_receiver_with_presentation(
|
|
91
|
+
workspace: &Path,
|
|
92
|
+
state: &serde_json::Value,
|
|
93
|
+
leader_id: &str,
|
|
94
|
+
content: &str,
|
|
95
|
+
task_id: Option<&TaskId>,
|
|
96
|
+
sender: &str,
|
|
97
|
+
requires_ack: bool,
|
|
98
|
+
result_id: Option<&str>,
|
|
99
|
+
requested_message_id: Option<&str>,
|
|
100
|
+
presentation: &PresentationDecision,
|
|
101
|
+
event_log: &EventLog,
|
|
72
102
|
) -> Result<DeliveryOutcome, MessagingError> {
|
|
73
103
|
let store = MessageStore::open(workspace)?;
|
|
74
104
|
let owner_team = active_team_key(workspace, state);
|
|
@@ -90,6 +120,7 @@ pub fn send_to_leader_receiver_with_message_id(
|
|
|
90
120
|
false,
|
|
91
121
|
requested_message_id,
|
|
92
122
|
InitialDisposition::Accepted,
|
|
123
|
+
Some(presentation),
|
|
93
124
|
)? {
|
|
94
125
|
PersistResolution::Duplicate(requested) => {
|
|
95
126
|
return Ok(DeliveryOutcome {
|
|
@@ -258,8 +289,7 @@ pub fn deliver_to_leader_fallback_pane(
|
|
|
258
289
|
attempt_payload.clone(),
|
|
259
290
|
)?;
|
|
260
291
|
|
|
261
|
-
let Some(
|
|
262
|
-
else {
|
|
292
|
+
let Some(receiver) = leader_receiver_record(state) else {
|
|
263
293
|
let failed = serde_json::json!({
|
|
264
294
|
"message_id": message_id,
|
|
265
295
|
"result_id": result_id,
|
|
@@ -267,7 +297,7 @@ pub fn deliver_to_leader_fallback_pane(
|
|
|
267
297
|
"pane_id": null,
|
|
268
298
|
"primary_error": primary_error,
|
|
269
299
|
"delivered_via": "fallback_pane",
|
|
270
|
-
"reason": "
|
|
300
|
+
"reason": "no_bound_receiver",
|
|
271
301
|
});
|
|
272
302
|
event_log.write("leader_receiver.fallback_pane_failed", failed)?;
|
|
273
303
|
return Ok(DeliveryOutcome {
|
|
@@ -283,7 +313,6 @@ pub fn deliver_to_leader_fallback_pane(
|
|
|
283
313
|
};
|
|
284
314
|
|
|
285
315
|
let rendered = render_fallback_pane_message(content, message_id, primary_error);
|
|
286
|
-
let target = Target::Pane(PaneId::new(&pane_id));
|
|
287
316
|
let payload = InjectPayload::Text(rendered);
|
|
288
317
|
// 0.5.5 gate054 cross-team notify boundary: when the leader receiver
|
|
289
318
|
// has a recorded `tmux_socket` (canonical), that socket is a HARD
|
|
@@ -301,25 +330,63 @@ pub fn deliver_to_leader_fallback_pane(
|
|
|
301
330
|
// stays pending in the store, coordinator surfaces the pending
|
|
302
331
|
// notification via status/monitor, and no cross-server injection is
|
|
303
332
|
// attempted.
|
|
304
|
-
let
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
None =>
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
333
|
+
let socket_bound = leader_tmux_socket(state).is_some();
|
|
334
|
+
let transports: Vec<Box<dyn Transport>> = match leader_tmux_socket(state) {
|
|
335
|
+
Some(socket) => vec![Box::new(crate::transport_factory::tmux_endpoint_transport(
|
|
336
|
+
socket,
|
|
337
|
+
))],
|
|
338
|
+
None => vec![
|
|
339
|
+
Box::new(crate::transport_factory::tmux_workspace_transport(
|
|
340
|
+
workspace,
|
|
341
|
+
)),
|
|
342
|
+
Box::new(crate::transport_factory::tmux_default_transport()),
|
|
343
|
+
],
|
|
344
|
+
};
|
|
345
|
+
let mut resolved = None;
|
|
346
|
+
let mut refusal = "leader channel unavailable".to_string();
|
|
347
|
+
for transport in transports {
|
|
348
|
+
match super::leader_channel::resolve_live_leader_channel(
|
|
349
|
+
workspace,
|
|
350
|
+
receiver,
|
|
351
|
+
transport.as_ref(),
|
|
352
|
+
) {
|
|
353
|
+
super::leader_channel::LeaderChannelResolution::Live(
|
|
354
|
+
super::leader_channel::LiveLeaderChannel::DirectTmux(channel),
|
|
355
|
+
) => {
|
|
356
|
+
resolved = Some((transport, channel));
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
other => refusal = format!("{other:?}"),
|
|
321
360
|
}
|
|
361
|
+
}
|
|
362
|
+
let Some((transport, channel)) = resolved else {
|
|
363
|
+
event_log.write(
|
|
364
|
+
"leader_receiver.fallback_pane_failed",
|
|
365
|
+
serde_json::json!({
|
|
366
|
+
"message_id": message_id,
|
|
367
|
+
"result_id": result_id,
|
|
368
|
+
"owner_team_id": owner_team_id,
|
|
369
|
+
"pane_id": pane_id,
|
|
370
|
+
"primary_error": primary_error,
|
|
371
|
+
"delivered_via": "fallback_pane",
|
|
372
|
+
"socket_bound": socket_bound,
|
|
373
|
+
"reason": refusal,
|
|
374
|
+
}),
|
|
375
|
+
)?;
|
|
376
|
+
return Ok(DeliveryOutcome {
|
|
377
|
+
ok: false,
|
|
378
|
+
status: DeliveryStatus::Blocked,
|
|
379
|
+
message_status: MessageStatusShadow("blocked".to_string()),
|
|
380
|
+
message_id: Some(message_id.to_string()),
|
|
381
|
+
verification: Some("run team-agent claim-leader or team-agent takeover".to_string()),
|
|
382
|
+
stage: None,
|
|
383
|
+
reason: Some(DeliveryRefusal::LeaderNotAttached),
|
|
384
|
+
channel: Some("rebind_required".to_string()),
|
|
385
|
+
});
|
|
322
386
|
};
|
|
387
|
+
let target = Target::Pane(PaneId::new(channel.pane_id));
|
|
388
|
+
let inject_result: Result<InjectReport, TransportError> =
|
|
389
|
+
transport.inject(&target, &payload, Key::Enter, true);
|
|
323
390
|
|
|
324
391
|
match inject_result {
|
|
325
392
|
Ok(report) => {
|
|
@@ -486,6 +553,17 @@ fn leader_record_field<'a>(state: &'a Value, field: &str) -> Option<&'a Value> {
|
|
|
486
553
|
.or_else(|| receiver_or_owner_field(state, "team_owner", field))
|
|
487
554
|
}
|
|
488
555
|
|
|
556
|
+
fn leader_receiver_record(state: &Value) -> Option<&Value> {
|
|
557
|
+
state.get("leader_receiver").or_else(|| {
|
|
558
|
+
let active = state.get("active_team_key").and_then(Value::as_str)?;
|
|
559
|
+
state
|
|
560
|
+
.get("teams")
|
|
561
|
+
.and_then(Value::as_object)
|
|
562
|
+
.and_then(|teams| teams.get(active))
|
|
563
|
+
.and_then(|team| team.get("leader_receiver"))
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
|
|
489
567
|
fn owner_epoch_i64(state: &Value) -> Option<i64> {
|
|
490
568
|
owner_epoch(state).and_then(|epoch| i64::try_from(epoch).ok())
|
|
491
569
|
}
|
|
@@ -596,6 +674,7 @@ pub fn enqueue_leader_mailbox_until_attach(
|
|
|
596
674
|
false,
|
|
597
675
|
None,
|
|
598
676
|
InitialDisposition::QueuedUntilLeaderAttach,
|
|
677
|
+
None,
|
|
599
678
|
)?
|
|
600
679
|
else {
|
|
601
680
|
unreachable!("offline mailbox does not accept caller-supplied ids")
|
|
@@ -68,6 +68,7 @@ pub mod leader_channel;
|
|
|
68
68
|
pub mod leader_receiver;
|
|
69
69
|
pub mod peers;
|
|
70
70
|
pub mod persist;
|
|
71
|
+
pub mod presentation;
|
|
71
72
|
pub mod results;
|
|
72
73
|
pub mod scheduler;
|
|
73
74
|
pub mod selftest;
|
|
@@ -94,7 +95,7 @@ pub use leader_channel::{
|
|
|
94
95
|
pub use leader_receiver::{
|
|
95
96
|
deliver_to_leader_fallback_pane, enqueue_leader_mailbox_until_attach,
|
|
96
97
|
mirror_peer_message_to_leader, send_to_leader_receiver,
|
|
97
|
-
send_to_leader_receiver_with_message_id,
|
|
98
|
+
send_to_leader_receiver_with_message_id, send_to_leader_receiver_with_presentation,
|
|
98
99
|
};
|
|
99
100
|
pub use peers::allow_peer_talk;
|
|
100
101
|
pub use persist::{
|
|
@@ -5,6 +5,7 @@ use std::path::{Path, PathBuf};
|
|
|
5
5
|
use crate::db::message_store::{MessageRowStatus, MessageStore, PersistMessageInput};
|
|
6
6
|
use crate::model::ids::{AgentId, TaskId, TeamKey};
|
|
7
7
|
|
|
8
|
+
use super::presentation::PresentationDecision;
|
|
8
9
|
use super::send::TrustedSender;
|
|
9
10
|
use super::MessagingError;
|
|
10
11
|
|
|
@@ -64,6 +65,7 @@ impl DeliveryBlocker {
|
|
|
64
65
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
65
66
|
pub enum InitialDisposition {
|
|
66
67
|
Accepted,
|
|
68
|
+
StoredOnly,
|
|
67
69
|
QueuedUntilLeaderAttach,
|
|
68
70
|
Blocked(DeliveryBlocker),
|
|
69
71
|
}
|
|
@@ -72,6 +74,7 @@ impl InitialDisposition {
|
|
|
72
74
|
fn row_status(self) -> MessageRowStatus {
|
|
73
75
|
match self {
|
|
74
76
|
Self::Accepted => MessageRowStatus::Accepted,
|
|
77
|
+
Self::StoredOnly => MessageRowStatus::StoredOnly,
|
|
75
78
|
Self::QueuedUntilLeaderAttach => MessageRowStatus::QueuedUntilLeaderAttach,
|
|
76
79
|
Self::Blocked(DeliveryBlocker::CoordinatorUnavailable) => {
|
|
77
80
|
MessageRowStatus::QueuedCoordinatorUnavailable
|
|
@@ -82,7 +85,7 @@ impl InitialDisposition {
|
|
|
82
85
|
fn error(self) -> Option<&'static str> {
|
|
83
86
|
match self {
|
|
84
87
|
Self::Blocked(blocker) => Some(blocker.as_str()),
|
|
85
|
-
Self::Accepted | Self::QueuedUntilLeaderAttach => None,
|
|
88
|
+
Self::Accepted | Self::StoredOnly | Self::QueuedUntilLeaderAttach => None,
|
|
86
89
|
}
|
|
87
90
|
}
|
|
88
91
|
}
|
|
@@ -100,6 +103,7 @@ pub struct ResolvedSendIntent {
|
|
|
100
103
|
pub requires_ack: bool,
|
|
101
104
|
pub requested_message_id: Option<String>,
|
|
102
105
|
pub initial_disposition: InitialDisposition,
|
|
106
|
+
pub presentation: PresentationDecision,
|
|
103
107
|
}
|
|
104
108
|
|
|
105
109
|
impl ResolvedSendIntent {
|
|
@@ -128,6 +132,7 @@ impl ResolvedSendIntent {
|
|
|
128
132
|
requires_ack,
|
|
129
133
|
requested_message_id,
|
|
130
134
|
initial_disposition: InitialDisposition::Accepted,
|
|
135
|
+
presentation: PresentationDecision::default(),
|
|
131
136
|
}
|
|
132
137
|
}
|
|
133
138
|
}
|
|
@@ -158,6 +163,7 @@ pub fn persist_resolved_send(
|
|
|
158
163
|
}
|
|
159
164
|
}
|
|
160
165
|
let status = intent.initial_disposition.row_status();
|
|
166
|
+
let presentation = serde_json::to_string(&intent.presentation)?;
|
|
161
167
|
let message_id = store.persist_message(PersistMessageInput {
|
|
162
168
|
message_id: intent.requested_message_id.as_deref(),
|
|
163
169
|
owner_team_id: intent.owner_team_id.as_ref().map(TeamKey::as_str),
|
|
@@ -168,6 +174,7 @@ pub fn persist_resolved_send(
|
|
|
168
174
|
requires_ack: intent.requires_ack,
|
|
169
175
|
status,
|
|
170
176
|
content: &intent.content,
|
|
177
|
+
presentation: &presentation,
|
|
171
178
|
error: intent.initial_disposition.error(),
|
|
172
179
|
})?;
|
|
173
180
|
Ok(PersistResolution::Persisted(PersistedSend {
|
|
@@ -177,7 +184,9 @@ pub fn persist_resolved_send(
|
|
|
177
184
|
row_status: status,
|
|
178
185
|
blocker: match intent.initial_disposition {
|
|
179
186
|
InitialDisposition::Blocked(blocker) => Some(blocker),
|
|
180
|
-
InitialDisposition::Accepted
|
|
187
|
+
InitialDisposition::Accepted
|
|
188
|
+
| InitialDisposition::StoredOnly
|
|
189
|
+
| InitialDisposition::QueuedUntilLeaderAttach => None,
|
|
181
190
|
},
|
|
182
191
|
}))
|
|
183
192
|
}
|
|
@@ -195,6 +204,7 @@ pub(crate) fn persist_internal_send(
|
|
|
195
204
|
requires_ack: bool,
|
|
196
205
|
requested_message_id: Option<&str>,
|
|
197
206
|
initial_disposition: InitialDisposition,
|
|
207
|
+
presentation: Option<&PresentationDecision>,
|
|
198
208
|
) -> Result<PersistResolution, MessagingError> {
|
|
199
209
|
let mut intent = ResolvedSendIntent::accepted(
|
|
200
210
|
SendOrigin::Internal(kind),
|
|
@@ -209,6 +219,9 @@ pub(crate) fn persist_internal_send(
|
|
|
209
219
|
requested_message_id.map(ToOwned::to_owned),
|
|
210
220
|
);
|
|
211
221
|
intent.initial_disposition = initial_disposition;
|
|
222
|
+
if let Some(presentation) = presentation {
|
|
223
|
+
intent.presentation = presentation.clone();
|
|
224
|
+
}
|
|
212
225
|
persist_resolved_send(&intent)
|
|
213
226
|
}
|
|
214
227
|
|
|
@@ -266,6 +279,59 @@ mod tests {
|
|
|
266
279
|
assert_eq!(error.as_deref(), Some("coordinator_unavailable"));
|
|
267
280
|
}
|
|
268
281
|
|
|
282
|
+
#[test]
|
|
283
|
+
fn stored_only_persists_requested_presentation_without_delivery_eligibility() {
|
|
284
|
+
let workspace = workspace("stored-only");
|
|
285
|
+
let mut intent = ResolvedSendIntent::accepted(
|
|
286
|
+
SendOrigin::Mcp,
|
|
287
|
+
&workspace,
|
|
288
|
+
Some(TeamKey::new("team-a")),
|
|
289
|
+
LogicalRecipient::Leader,
|
|
290
|
+
TrustedSender::from_runtime_identity(AgentId::new("worker_a")),
|
|
291
|
+
None,
|
|
292
|
+
"stage evidence",
|
|
293
|
+
None,
|
|
294
|
+
false,
|
|
295
|
+
None,
|
|
296
|
+
);
|
|
297
|
+
intent.initial_disposition = InitialDisposition::StoredOnly;
|
|
298
|
+
intent.presentation = super::super::presentation::decide_presentation(
|
|
299
|
+
&super::super::presentation::PresentationRequest {
|
|
300
|
+
sink: super::super::presentation::PresentationSink::Casefile,
|
|
301
|
+
class: super::super::presentation::PresentationClass::StageResult,
|
|
302
|
+
case_id: Some("case-7".to_string()),
|
|
303
|
+
},
|
|
304
|
+
super::super::presentation::PresentationSource::Send,
|
|
305
|
+
);
|
|
306
|
+
let PersistResolution::Persisted(persisted) = persist_resolved_send(&intent).unwrap()
|
|
307
|
+
else {
|
|
308
|
+
panic!("fresh intent must persist")
|
|
309
|
+
};
|
|
310
|
+
assert_eq!(persisted.row_status, MessageRowStatus::StoredOnly);
|
|
311
|
+
let store = MessageStore::open(&workspace).unwrap();
|
|
312
|
+
let connection = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
313
|
+
let (status, presentation): (String, String) = connection
|
|
314
|
+
.query_row(
|
|
315
|
+
"select status, presentation from messages where message_id = ?1",
|
|
316
|
+
[&persisted.message_id],
|
|
317
|
+
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
318
|
+
)
|
|
319
|
+
.unwrap();
|
|
320
|
+
assert_eq!(status, "stored_only");
|
|
321
|
+
assert_eq!(
|
|
322
|
+
serde_json::from_str::<serde_json::Value>(&presentation).unwrap(),
|
|
323
|
+
serde_json::json!({
|
|
324
|
+
"sink": "casefile",
|
|
325
|
+
"class": "stage_result",
|
|
326
|
+
"case_id": "case-7",
|
|
327
|
+
"requested_sink": "casefile",
|
|
328
|
+
"effective_sink": "casefile",
|
|
329
|
+
"policy_reason": "requested_sink:casefile",
|
|
330
|
+
"policy_version": "team-presentation-v1"
|
|
331
|
+
})
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
269
335
|
#[test]
|
|
270
336
|
fn production_row_creation_is_owned_by_persisted_primitive() {
|
|
271
337
|
for (name, source) in [
|