@team-agent/installer 0.3.10 → 0.3.12
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/send.rs +9 -2
- package/crates/team-agent/src/coordinator/backoff.rs +83 -2
- package/crates/team-agent/src/coordinator/health.rs +63 -3
- package/crates/team-agent/src/coordinator/tick.rs +327 -167
- package/crates/team-agent/src/mcp_server/helpers.rs +24 -5
- package/crates/team-agent/src/mcp_server/normalize.rs +13 -6
- package/crates/team-agent/src/mcp_server/tests/send.rs +310 -212
- package/crates/team-agent/src/messaging/helpers.rs +30 -10
- package/crates/team-agent/src/messaging/send.rs +71 -14
- package/crates/team-agent/src/messaging/tests/basic.rs +25 -7
- package/crates/team-agent/src/messaging/tests/runtime.rs +489 -125
- package/crates/team-agent/src/messaging/types.rs +19 -4
- package/package.json +4 -4
|
@@ -1,237 +1,335 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
1
|
+
#[test]
|
|
2
|
+
fn requires_ack_for_target_leader_vs_worker() {
|
|
3
|
+
assert!(!requires_ack_for_target(&MessageTarget::Single(
|
|
4
|
+
"leader".to_string()
|
|
5
|
+
)));
|
|
6
|
+
assert!(!requires_ack_for_target(&MessageTarget::Single(
|
|
7
|
+
"Leader".to_string()
|
|
8
|
+
)));
|
|
9
|
+
assert!(requires_ack_for_target(&MessageTarget::Single(
|
|
10
|
+
"alice".to_string()
|
|
11
|
+
)));
|
|
12
|
+
// list: all-leader → false; any non-leader → true
|
|
13
|
+
assert!(!requires_ack_for_target(&MessageTarget::Fanout(vec![
|
|
14
|
+
"leader".to_string(),
|
|
15
|
+
"Leader".to_string()
|
|
16
|
+
])));
|
|
17
|
+
assert!(requires_ack_for_target(&MessageTarget::Fanout(vec![
|
|
18
|
+
"leader".to_string(),
|
|
19
|
+
"alice".to_string()
|
|
20
|
+
])));
|
|
21
|
+
}
|
|
14
22
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
24
|
+
// is_worker_recipient — single str not in {"","*","leader","Leader"} (tools.py:22)
|
|
25
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
26
|
+
#[test]
|
|
27
|
+
fn is_worker_recipient_classification() {
|
|
28
|
+
assert!(is_worker_recipient(&MessageTarget::Single(
|
|
29
|
+
"alice".to_string()
|
|
30
|
+
)));
|
|
31
|
+
assert!(!is_worker_recipient(&MessageTarget::Single("".to_string())));
|
|
32
|
+
assert!(!is_worker_recipient(&MessageTarget::Single(
|
|
33
|
+
"leader".to_string()
|
|
34
|
+
)));
|
|
35
|
+
assert!(!is_worker_recipient(&MessageTarget::Single(
|
|
36
|
+
"Leader".to_string()
|
|
37
|
+
)));
|
|
38
|
+
// Broadcast "*" is NOT a worker recipient
|
|
39
|
+
assert!(!is_worker_recipient(&MessageTarget::Broadcast));
|
|
40
|
+
// Fanout list is NOT a worker recipient (not a single str)
|
|
41
|
+
assert!(!is_worker_recipient(&MessageTarget::Fanout(vec![
|
|
42
|
+
"alice".to_string()
|
|
43
|
+
])));
|
|
44
|
+
}
|
|
29
45
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
46
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
47
|
+
// merge_tasks_by_id — prefer wins, prefer-first insertion order (tools.py:30)
|
|
48
|
+
// Golden: prefer t1(done),t2 + fallback t1(pending),t3,{no id},"notdict"
|
|
49
|
+
// → [t1(done), t2, t3] (t1 from prefer wins; non-dict / no-id dropped)
|
|
50
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
51
|
+
#[test]
|
|
52
|
+
fn merge_tasks_by_id_prefer_wins_no_done_regression() {
|
|
53
|
+
let prefer = vec![
|
|
54
|
+
json!({"id": "t1", "status": "done"}),
|
|
55
|
+
json!({"id": "t2", "status": "pending"}),
|
|
56
|
+
];
|
|
57
|
+
let fallback = vec![
|
|
58
|
+
json!({"id": "t1", "status": "pending"}), // must NOT regress t1
|
|
59
|
+
json!({"id": "t3", "status": "ready"}),
|
|
60
|
+
json!({"no": "id"}), // dropped (no id)
|
|
61
|
+
json!("notdict"), // dropped (not object)
|
|
62
|
+
];
|
|
63
|
+
let merged = merge_tasks_by_id(&prefer, &fallback);
|
|
64
|
+
assert_eq!(merged.len(), 3);
|
|
65
|
+
assert_eq!(merged[0]["id"], json!("t1"));
|
|
66
|
+
assert_eq!(merged[0]["status"], json!("done")); // prefer wins → no regression
|
|
67
|
+
assert_eq!(merged[1]["id"], json!("t2"));
|
|
68
|
+
assert_eq!(merged[2]["id"], json!("t3"));
|
|
69
|
+
}
|
|
54
70
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
72
|
+
// SendOutcome::to_value — worker-accepted async envelope (tools.py:177-182)
|
|
73
|
+
// byte-stable: {status:"accepted",delivery_pending:true,
|
|
74
|
+
// poll_via:"team-agent inbox <id>",message_id:<id>}
|
|
75
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
76
|
+
#[test]
|
|
77
|
+
fn send_outcome_worker_accepted_envelope_byte_stable() {
|
|
78
|
+
let outcome = SendOutcome::WorkerAccepted {
|
|
79
|
+
message_id: "42".to_string(),
|
|
80
|
+
poll_via: "team-agent inbox 42".to_string(),
|
|
81
|
+
};
|
|
82
|
+
let v = outcome.to_value();
|
|
83
|
+
assert_eq!(
|
|
84
|
+
keys(&v),
|
|
85
|
+
vec!["status", "delivery_pending", "poll_via", "message_id"]
|
|
86
|
+
);
|
|
87
|
+
assert_eq!(
|
|
88
|
+
s(&v),
|
|
89
|
+
r#"{"status":"accepted","delivery_pending":true,"poll_via":"team-agent inbox 42","message_id":"42"}"#
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#[test]
|
|
94
|
+
fn send_outcome_direct_renders_compact_body() {
|
|
95
|
+
// leader / * / broadcast path → compacted delegate body, not the accepted envelope.
|
|
96
|
+
let ok = ToolOk {
|
|
97
|
+
fields: {
|
|
98
|
+
let mut m = serde_json::Map::new();
|
|
99
|
+
m.insert("ok".to_string(), json!(true));
|
|
100
|
+
m.insert("status".to_string(), json!("queued"));
|
|
101
|
+
m
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
let v = SendOutcome::Direct(ok).to_value();
|
|
105
|
+
assert_eq!(v.get("status"), Some(&json!("queued")));
|
|
106
|
+
assert!(
|
|
107
|
+
v.get("delivery_pending").is_none(),
|
|
108
|
+
"Direct is NOT the accepted envelope"
|
|
109
|
+
);
|
|
110
|
+
}
|
|
71
111
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
112
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
113
|
+
// CONTROL-PLANE: send_message worker recipient → WorkerAccepted (tools.py:135-183)
|
|
114
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
115
|
+
#[test]
|
|
116
|
+
fn send_message_worker_recipient_returns_accepted_with_poll_hint() {
|
|
117
|
+
// A worker recipient w/ a delivered message_id → async accepted carrying the
|
|
118
|
+
// byte-stable poll hint. Identity anchored on injected env (no candidate scan).
|
|
119
|
+
// golden: a leader WITH owner_team_id on an unseeded ws would hit the C23 cross-team
|
|
120
|
+
// refusal first (worker-1 not in visible peers) -> PeerNotInScope. owner_team_id=None
|
|
121
|
+
// (legacy single-team) bypasses that, isolating the worker-recipient accepted path.
|
|
122
|
+
// The cross-team refusal has its own tests (refuse_cross_team_peer_* / send_message_cross_team_*).
|
|
123
|
+
// A-7: the accepted path needs a REAL stored message_id (tools.py:175-181 —
|
|
124
|
+
// fabricated ids are gone), so the workspace seeds a running worker-1 the
|
|
125
|
+
// delivery layer can actually queue for.
|
|
126
|
+
let ws = unique_ws("send-worker");
|
|
127
|
+
crate::state::persist::save_runtime_state(
|
|
128
|
+
&ws,
|
|
129
|
+
&serde_json::json!({
|
|
130
|
+
"session_name": "team-x",
|
|
131
|
+
"agents": {
|
|
132
|
+
"worker-1": {"status": "running", "agent_id": "worker-1", "window": "worker-1"},
|
|
81
133
|
},
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
134
|
+
}),
|
|
135
|
+
)
|
|
136
|
+
.unwrap();
|
|
137
|
+
let tools = TeamOrchestratorTools::with_identity(&ws, Some(AgentId::new("leader")), None);
|
|
138
|
+
let outcome = tools.send_message(
|
|
139
|
+
&MessageTarget::Single("worker-1".to_string()),
|
|
140
|
+
"do the thing",
|
|
141
|
+
None,
|
|
142
|
+
None,
|
|
143
|
+
None,
|
|
144
|
+
None,
|
|
145
|
+
);
|
|
146
|
+
match outcome {
|
|
147
|
+
Ok(SendOutcome::WorkerAccepted {
|
|
148
|
+
message_id,
|
|
149
|
+
poll_via,
|
|
150
|
+
}) => {
|
|
151
|
+
assert!(!message_id.is_empty());
|
|
152
|
+
assert_eq!(poll_via, format!("team-agent inbox {message_id}"));
|
|
153
|
+
}
|
|
154
|
+
other => panic!("worker recipient must be WorkerAccepted, got {other:?}"),
|
|
86
155
|
}
|
|
156
|
+
}
|
|
87
157
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
158
|
+
#[test]
|
|
159
|
+
fn send_message_worker_recipient_surfaces_dead_coordinator_warning() {
|
|
160
|
+
let ws = unique_ws("send-worker-dead-coord");
|
|
161
|
+
crate::state::persist::save_runtime_state(
|
|
162
|
+
&ws,
|
|
163
|
+
&serde_json::json!({
|
|
164
|
+
"session_name": "team-x",
|
|
165
|
+
"agents": {
|
|
166
|
+
"worker-1": {"status": "running", "agent_id": "worker-1", "window": "worker-1"},
|
|
167
|
+
},
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
.unwrap();
|
|
171
|
+
let coordinator_ws = crate::coordinator::WorkspacePath::new(ws.clone());
|
|
172
|
+
std::fs::create_dir_all(crate::model::paths::runtime_dir(&ws)).unwrap();
|
|
173
|
+
let _ = crate::message_store::MessageStore::open(&ws).unwrap();
|
|
174
|
+
let stale_pid = crate::coordinator::Pid::new(99_999_999);
|
|
175
|
+
crate::coordinator::write_coordinator_metadata(
|
|
176
|
+
&coordinator_ws,
|
|
177
|
+
stale_pid,
|
|
178
|
+
crate::coordinator::MetadataSource::Boot,
|
|
179
|
+
)
|
|
180
|
+
.unwrap();
|
|
181
|
+
std::fs::write(
|
|
182
|
+
crate::coordinator::coordinator_pid_path(&coordinator_ws),
|
|
183
|
+
stale_pid.to_string(),
|
|
184
|
+
)
|
|
185
|
+
.unwrap();
|
|
186
|
+
let tools = TeamOrchestratorTools::with_identity(&ws, Some(AgentId::new("leader")), None);
|
|
187
|
+
|
|
188
|
+
let outcome = tools
|
|
189
|
+
.send_message(
|
|
119
190
|
&MessageTarget::Single("worker-1".to_string()),
|
|
120
191
|
"do the thing",
|
|
121
|
-
None,
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
192
|
+
None,
|
|
193
|
+
None,
|
|
194
|
+
None,
|
|
195
|
+
None,
|
|
196
|
+
)
|
|
197
|
+
.expect("send returns degraded warning, not an MCP error");
|
|
198
|
+
let v = outcome.to_value();
|
|
199
|
+
assert_eq!(v.get("status"), Some(&json!("degraded")));
|
|
200
|
+
assert_eq!(v.get("reason"), Some(&json!("coordinator_unavailable")));
|
|
201
|
+
assert!(
|
|
202
|
+
v.get("warning")
|
|
203
|
+
.and_then(Value::as_str)
|
|
204
|
+
.is_some_and(|warning| warning.contains("message was not queued")),
|
|
205
|
+
"warning must explain the accepted-row avoidance; value={v}"
|
|
206
|
+
);
|
|
207
|
+
assert!(
|
|
208
|
+
v.get("delivery_pending").is_none(),
|
|
209
|
+
"dead coordinator must not return the old accepted async envelope"
|
|
210
|
+
);
|
|
211
|
+
}
|
|
131
212
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
213
|
+
#[test]
|
|
214
|
+
fn send_message_leader_recipient_is_direct_not_accepted() {
|
|
215
|
+
let tools = TeamOrchestratorTools::with_identity(
|
|
216
|
+
&unique_ws("send-leader"),
|
|
217
|
+
Some(AgentId::new("worker-1")),
|
|
218
|
+
Some(TeamKey::new("teamA")),
|
|
219
|
+
);
|
|
220
|
+
let outcome = tools
|
|
221
|
+
.send_message(
|
|
140
222
|
&MessageTarget::Single("leader".to_string()),
|
|
141
223
|
"status update",
|
|
142
|
-
None, None, None, None,
|
|
143
|
-
).expect("leader send ok");
|
|
144
|
-
assert!(matches!(outcome, SendOutcome::Direct(_)),
|
|
145
|
-
"leader recipient → Direct (synchronous), not WorkerAccepted");
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// ════════════════════════════════════════════════════════════════════════
|
|
149
|
-
// CROSS-TEAM PRE-REFUSAL (C23) — refuse_cross_team_peer (tools.py:185-213)
|
|
150
|
-
// ════════════════════════════════════════════════════════════════════════
|
|
151
|
-
#[test]
|
|
152
|
-
fn refuse_cross_team_peer_blocks_unknown_peer_without_workspace_scope() {
|
|
153
|
-
// owner_team set, target a peer NOT in scope, scope != workspace → PeerNotInScope.
|
|
154
|
-
let tools = TeamOrchestratorTools::with_identity(
|
|
155
|
-
Path::new("/tmp/ws"),
|
|
156
|
-
Some(AgentId::new("worker-1")),
|
|
157
|
-
Some(TeamKey::new("teamA")),
|
|
158
|
-
);
|
|
159
|
-
let refusal = tools.refuse_cross_team_peer(
|
|
160
|
-
&MessageTarget::Single("other-team-bob".to_string()),
|
|
161
224
|
None,
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
225
|
+
None,
|
|
226
|
+
None,
|
|
227
|
+
None,
|
|
228
|
+
)
|
|
229
|
+
.expect("leader send ok");
|
|
230
|
+
assert!(
|
|
231
|
+
matches!(outcome, SendOutcome::Direct(_)),
|
|
232
|
+
"leader recipient → Direct (synchronous), not WorkerAccepted"
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
237
|
+
// CROSS-TEAM PRE-REFUSAL (C23) — refuse_cross_team_peer (tools.py:185-213)
|
|
238
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
239
|
+
#[test]
|
|
240
|
+
fn refuse_cross_team_peer_blocks_unknown_peer_without_workspace_scope() {
|
|
241
|
+
// owner_team set, target a peer NOT in scope, scope != workspace → PeerNotInScope.
|
|
242
|
+
let tools = TeamOrchestratorTools::with_identity(
|
|
243
|
+
Path::new("/tmp/ws"),
|
|
244
|
+
Some(AgentId::new("worker-1")),
|
|
245
|
+
Some(TeamKey::new("teamA")),
|
|
246
|
+
);
|
|
247
|
+
let refusal =
|
|
248
|
+
tools.refuse_cross_team_peer(&MessageTarget::Single("other-team-bob".to_string()), None);
|
|
249
|
+
let te = refusal.expect("cross-team peer must be refused");
|
|
250
|
+
assert_eq!(te.reason, ToolErrorReason::PeerNotInScope);
|
|
251
|
+
// hint preserved in extra (tools.py:208-213 status:"refused" + hint)
|
|
252
|
+
let env = te.to_envelope();
|
|
253
|
+
assert_eq!(env.get("status"), Some(&json!("refused")));
|
|
254
|
+
assert_eq!(env.get("reason"), Some(&json!("peer_not_in_scope")));
|
|
255
|
+
assert_eq!(
|
|
170
256
|
env.get("hint"),
|
|
171
257
|
Some(&json!("the requested peer is not part of your team; worker-origin MCP cannot widen team scope."))
|
|
172
258
|
);
|
|
173
|
-
|
|
259
|
+
}
|
|
174
260
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
261
|
+
#[test]
|
|
262
|
+
fn refuse_cross_team_peer_rejects_workspace_scope_override_for_worker() {
|
|
263
|
+
let tools = TeamOrchestratorTools::with_identity(
|
|
264
|
+
Path::new("/tmp/ws"),
|
|
265
|
+
Some(AgentId::new("worker-1")),
|
|
266
|
+
Some(TeamKey::new("teamA")),
|
|
267
|
+
);
|
|
268
|
+
// scope="workspace" is not worker consent to cross team boundaries.
|
|
269
|
+
let te = tools
|
|
270
|
+
.refuse_cross_team_peer(
|
|
184
271
|
&MessageTarget::Single("other-team-bob".to_string()),
|
|
185
272
|
Some(Scope::Workspace),
|
|
186
|
-
)
|
|
187
|
-
|
|
188
|
-
|
|
273
|
+
)
|
|
274
|
+
.expect("workspace scope override must still be refused for worker-origin MCP");
|
|
275
|
+
assert_eq!(te.reason, ToolErrorReason::McpScopeRefused);
|
|
276
|
+
}
|
|
189
277
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
278
|
+
#[test]
|
|
279
|
+
fn refuse_cross_team_peer_allows_leader_broadcast_and_self() {
|
|
280
|
+
let tools = TeamOrchestratorTools::with_identity(
|
|
281
|
+
Path::new("/tmp/ws"),
|
|
282
|
+
Some(AgentId::new("worker-1")),
|
|
283
|
+
Some(TeamKey::new("teamA")),
|
|
284
|
+
);
|
|
285
|
+
// leader / "*" / "" / self are never refused (tools.py:190,195)
|
|
286
|
+
assert!(tools
|
|
287
|
+
.refuse_cross_team_peer(&MessageTarget::Single("leader".to_string()), None)
|
|
288
|
+
.is_none());
|
|
289
|
+
assert!(tools
|
|
290
|
+
.refuse_cross_team_peer(&MessageTarget::Broadcast, None)
|
|
291
|
+
.is_none());
|
|
292
|
+
assert!(tools
|
|
293
|
+
.refuse_cross_team_peer(&MessageTarget::Single("worker-1".to_string()), None)
|
|
294
|
+
.is_none());
|
|
295
|
+
}
|
|
202
296
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
297
|
+
#[test]
|
|
298
|
+
fn refuse_cross_team_peer_no_owner_team_is_legacy_passthrough() {
|
|
299
|
+
// No owner_team_id (legacy single-team) → never refuse (tools.py:192).
|
|
300
|
+
let tools = TeamOrchestratorTools::with_identity(
|
|
301
|
+
Path::new("/tmp/ws"),
|
|
302
|
+
Some(AgentId::new("worker-1")),
|
|
303
|
+
None,
|
|
304
|
+
);
|
|
305
|
+
assert!(tools
|
|
306
|
+
.refuse_cross_team_peer(&MessageTarget::Single("anybody".to_string()), None,)
|
|
307
|
+
.is_none());
|
|
308
|
+
}
|
|
216
309
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
310
|
+
#[test]
|
|
311
|
+
fn send_message_cross_team_peer_surfaces_peer_not_in_scope_error() {
|
|
312
|
+
// End-to-end: send_message to an out-of-scope peer → Err(ToolError{PeerNotInScope})
|
|
313
|
+
// BEFORE any runtime delivery (server-side guard, no peer-name leak).
|
|
314
|
+
let tools = TeamOrchestratorTools::with_identity(
|
|
315
|
+
Path::new("/tmp/ws"),
|
|
316
|
+
Some(AgentId::new("worker-1")),
|
|
317
|
+
Some(TeamKey::new("teamA")),
|
|
318
|
+
);
|
|
319
|
+
let err = tools
|
|
320
|
+
.send_message(
|
|
227
321
|
&MessageTarget::Single("other-team-bob".to_string()),
|
|
228
322
|
"leak attempt",
|
|
229
|
-
None,
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
323
|
+
None,
|
|
324
|
+
None,
|
|
325
|
+
None,
|
|
326
|
+
None,
|
|
327
|
+
)
|
|
328
|
+
.expect_err("out-of-scope peer must be refused");
|
|
329
|
+
assert_eq!(err.reason, ToolErrorReason::PeerNotInScope);
|
|
330
|
+
}
|
|
233
331
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
332
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
333
|
+
// WORKER-ID INFERENCE FALLBACK (bug-085, C17) — report_result identity.
|
|
334
|
+
// explicit > env > "unknown"; task → "manual". NEVER treat worker as leader.
|
|
335
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
@@ -29,6 +29,7 @@ pub(crate) fn status_wire(status: DeliveryStatus) -> &'static str {
|
|
|
29
29
|
DeliveryStatus::Queued => "queued",
|
|
30
30
|
DeliveryStatus::Blocked => "blocked",
|
|
31
31
|
DeliveryStatus::Refused => "refused",
|
|
32
|
+
DeliveryStatus::Degraded => "degraded",
|
|
32
33
|
DeliveryStatus::RetryScheduled => "retry_scheduled",
|
|
33
34
|
DeliveryStatus::TrustAutoAnswerExhausted => "trust_auto_answer_exhausted",
|
|
34
35
|
DeliveryStatus::AlreadyDelivered => "already_delivered",
|
|
@@ -40,7 +41,10 @@ pub(crate) fn status_wire(status: DeliveryStatus) -> &'static str {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
pub(crate) fn message_exists(
|
|
44
|
+
pub(crate) fn message_exists(
|
|
45
|
+
store: &MessageStore,
|
|
46
|
+
message_id: &str,
|
|
47
|
+
) -> Result<bool, MessagingError> {
|
|
44
48
|
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
45
49
|
let found: Option<String> = conn
|
|
46
50
|
.query_row(
|
|
@@ -65,7 +69,10 @@ pub(crate) fn next_run_id() -> String {
|
|
|
65
69
|
id.chars().filter(|c| *c != '_').take(12).collect()
|
|
66
70
|
}
|
|
67
71
|
|
|
68
|
-
pub(crate) fn required_str<'a>(
|
|
72
|
+
pub(crate) fn required_str<'a>(
|
|
73
|
+
value: &'a serde_json::Value,
|
|
74
|
+
key: &str,
|
|
75
|
+
) -> Result<&'a str, MessagingError> {
|
|
69
76
|
value
|
|
70
77
|
.get(key)
|
|
71
78
|
.and_then(|v| v.as_str())
|
|
@@ -85,7 +92,9 @@ pub(crate) fn validate_result_envelope(envelope: &serde_json::Value) -> Result<(
|
|
|
85
92
|
}
|
|
86
93
|
for key in ["changes", "tests", "risks", "artifacts", "next_actions"] {
|
|
87
94
|
if !envelope.get(key).is_some_and(serde_json::Value::is_array) {
|
|
88
|
-
return Err(MessagingError::Validation(format!(
|
|
95
|
+
return Err(MessagingError::Validation(format!(
|
|
96
|
+
"missing required array field: {key}"
|
|
97
|
+
)));
|
|
89
98
|
}
|
|
90
99
|
}
|
|
91
100
|
Ok(())
|
|
@@ -122,10 +131,12 @@ pub(crate) fn non_provider_command(command: &str) -> Option<&str> {
|
|
|
122
131
|
pub(crate) fn latest_prompt_signal(scrollback: &str) -> Option<AgentActivity> {
|
|
123
132
|
let lower = scrollback.to_ascii_lowercase();
|
|
124
133
|
let idle_pos = latest_idle_prompt_pos(scrollback);
|
|
125
|
-
let working_pos = [
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
134
|
+
let working_pos = [
|
|
135
|
+
"working", "thinking", "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
|
|
136
|
+
]
|
|
137
|
+
.iter()
|
|
138
|
+
.filter_map(|needle| lower.rfind(needle))
|
|
139
|
+
.max();
|
|
129
140
|
match (idle_pos, working_pos) {
|
|
130
141
|
(Some(i), Some(w)) if i > w => Some(idle_activity()),
|
|
131
142
|
(Some(_), None) => Some(idle_activity()),
|
|
@@ -168,10 +179,19 @@ pub fn fail_leader_delivery(
|
|
|
168
179
|
error: Option<&str>,
|
|
169
180
|
) -> Result<DeliveryOutcome, MessagingError> {
|
|
170
181
|
let store = MessageStore::open(workspace)?;
|
|
171
|
-
let sender = payload
|
|
172
|
-
|
|
182
|
+
let sender = payload
|
|
183
|
+
.get("sender")
|
|
184
|
+
.and_then(serde_json::Value::as_str)
|
|
185
|
+
.unwrap_or("system");
|
|
186
|
+
let content = payload
|
|
187
|
+
.get("content")
|
|
188
|
+
.and_then(serde_json::Value::as_str)
|
|
189
|
+
.unwrap_or("");
|
|
173
190
|
let task_id = payload.get("task_id").and_then(serde_json::Value::as_str);
|
|
174
|
-
let message_id = match payload
|
|
191
|
+
let message_id = match payload
|
|
192
|
+
.get("message_id")
|
|
193
|
+
.and_then(serde_json::Value::as_str)
|
|
194
|
+
{
|
|
175
195
|
Some(existing) => existing.to_string(),
|
|
176
196
|
None => store.create_message(task_id, sender, "leader", content, None, false, None)?,
|
|
177
197
|
};
|