@team-agent/installer 0.3.0 → 0.3.2
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 +38 -7
- package/crates/team-agent/src/cli/emit.rs +182 -54
- package/crates/team-agent/src/cli/mod.rs +703 -35
- package/crates/team-agent/src/cli/status_port.rs +170 -44
- package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
- package/crates/team-agent/src/cli/types.rs +1 -0
- package/crates/team-agent/src/coordinator/health.rs +130 -0
- package/crates/team-agent/src/leader/lease.rs +23 -2
- package/crates/team-agent/src/leader/rediscover/tests.rs +1 -0
- package/crates/team-agent/src/leader/rediscover.rs +2 -0
- package/crates/team-agent/src/leader/tests/byte_findings.rs +9 -6
- package/crates/team-agent/src/leader/tests/idle.rs +1 -0
- package/crates/team-agent/src/leader/tests/lease_claim.rs +157 -0
- package/crates/team-agent/src/leader/types.rs +2 -0
- package/crates/team-agent/src/lifecycle/launch.rs +554 -65
- package/crates/team-agent/src/lifecycle/restart/common.rs +65 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +57 -15
- package/crates/team-agent/src/lifecycle/restart/remove.rs +5 -1
- package/crates/team-agent/src/lifecycle/restart.rs +20 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +52 -0
- package/crates/team-agent/src/lifecycle/types.rs +25 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +28 -0
- package/crates/team-agent/src/mcp_server/wire.rs +81 -1
- package/crates/team-agent/src/messaging/delivery.rs +574 -12
- package/crates/team-agent/src/messaging/leader_receiver.rs +26 -37
- package/crates/team-agent/src/messaging/mod.rs +1 -1
- package/crates/team-agent/src/messaging/results.rs +218 -49
- package/crates/team-agent/src/messaging/send.rs +15 -19
- package/crates/team-agent/src/provider/adapter.rs +95 -10
- package/crates/team-agent/src/provider/helpers.rs +10 -1
- package/crates/team-agent/src/state/identity.rs +3 -0
- package/crates/team-agent/src/state/persist.rs +113 -1
- package/crates/team-agent/src/state/projection.rs +127 -3
- package/crates/team-agent/src/tmux_backend/tests.rs +179 -0
- package/crates/team-agent/src/tmux_backend.rs +124 -12
- package/npm/install.mjs +29 -7
- package/package.json +4 -4
|
@@ -7,15 +7,19 @@ use rusqlite::{params, OptionalExtension};
|
|
|
7
7
|
|
|
8
8
|
use crate::event_log::EventLog;
|
|
9
9
|
use crate::message_store::MessageStore;
|
|
10
|
-
use crate::model::enums::Provider;
|
|
10
|
+
use crate::model::enums::{PaneLiveness, Provider};
|
|
11
11
|
use crate::model::ids::TeamKey;
|
|
12
|
-
use crate::transport::{
|
|
12
|
+
use crate::transport::{
|
|
13
|
+
submit_verification_wire, InjectPayload, InjectReport, Key, PaneId, SessionName,
|
|
14
|
+
SubmitVerification, Target, Transport, WindowName,
|
|
15
|
+
};
|
|
13
16
|
|
|
14
17
|
use super::helpers::{message_exists, MessageStatusShadow};
|
|
15
18
|
use super::{
|
|
16
19
|
DeliveryOutcome, DeliveryRefusal, DeliveryStage, DeliveryStatus, MessagingError,
|
|
17
20
|
PaneWidthQuery, TrustRetryPayload,
|
|
18
21
|
};
|
|
22
|
+
use crate::state::projection::OwnerTeamResolution;
|
|
19
23
|
|
|
20
24
|
// ===========================================================================
|
|
21
25
|
// internal_delivery.py — coordinator/调度器侧 thin wrapper (card §65)
|
|
@@ -135,6 +139,76 @@ pub fn deliver_pending_message(
|
|
|
135
139
|
channel: None,
|
|
136
140
|
});
|
|
137
141
|
};
|
|
142
|
+
let mut canonical_owner_team_id = message.owner_team_id.clone();
|
|
143
|
+
let scoped_state;
|
|
144
|
+
let state = match message.owner_team_id.as_deref() {
|
|
145
|
+
Some(team) if !team.is_empty() => {
|
|
146
|
+
match project_state_for_owner_team(workspace, team, state, Some(store), Some(message_id), Some(event_log))? {
|
|
147
|
+
OwnerTeamProjection::Projected { state, canonical_team } => {
|
|
148
|
+
canonical_owner_team_id = Some(canonical_team);
|
|
149
|
+
scoped_state = state;
|
|
150
|
+
&scoped_state
|
|
151
|
+
}
|
|
152
|
+
OwnerTeamProjection::Refused(outcome) => return Ok(outcome),
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
_ => state,
|
|
156
|
+
};
|
|
157
|
+
if message.recipient == "leader" && leader_receiver_has_noncanonical_tmux_socket(state) {
|
|
158
|
+
store.mark(message_id, "failed", Some("leader_not_attached"))?;
|
|
159
|
+
event_log.write(
|
|
160
|
+
"leader_receiver.delivery_blocked",
|
|
161
|
+
serde_json::json!({
|
|
162
|
+
"message_id": message_id,
|
|
163
|
+
"sender": message.sender,
|
|
164
|
+
"reason": "leader_not_attached",
|
|
165
|
+
"channel": "rebind_required",
|
|
166
|
+
"action": "run team-agent claim-leader or team-agent takeover",
|
|
167
|
+
"error": "leader_receiver.tmux_socket is not a canonical full socket path",
|
|
168
|
+
}),
|
|
169
|
+
)?;
|
|
170
|
+
return Ok(DeliveryOutcome {
|
|
171
|
+
ok: false,
|
|
172
|
+
status: DeliveryStatus::Refused,
|
|
173
|
+
message_status: MessageStatusShadow("failed".to_string()),
|
|
174
|
+
message_id: Some(message_id.to_string()),
|
|
175
|
+
verification: Some(
|
|
176
|
+
"run team-agent claim-leader or team-agent takeover".to_string(),
|
|
177
|
+
),
|
|
178
|
+
stage: None,
|
|
179
|
+
reason: Some(DeliveryRefusal::LeaderNotAttached),
|
|
180
|
+
channel: Some("rebind_required".to_string()),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
let delivery_transport =
|
|
184
|
+
delivery_transport_for_recipient(workspace, transport, state, &message.recipient);
|
|
185
|
+
let transport = delivery_transport.as_transport();
|
|
186
|
+
// Do not inject queued leader messages into a synthetic "leader" window.
|
|
187
|
+
if message.recipient == "leader" && !leader_receiver_pane_is_usable(transport, state) {
|
|
188
|
+
store.mark(message_id, "failed", Some("leader_not_attached"))?;
|
|
189
|
+
event_log.write(
|
|
190
|
+
"leader_receiver.delivery_blocked",
|
|
191
|
+
serde_json::json!({
|
|
192
|
+
"message_id": message_id,
|
|
193
|
+
"sender": message.sender,
|
|
194
|
+
"reason": "leader_not_attached",
|
|
195
|
+
"channel": "rebind_required",
|
|
196
|
+
"action": "run team-agent claim-leader or team-agent takeover",
|
|
197
|
+
}),
|
|
198
|
+
)?;
|
|
199
|
+
return Ok(DeliveryOutcome {
|
|
200
|
+
ok: false,
|
|
201
|
+
status: DeliveryStatus::Refused,
|
|
202
|
+
message_status: MessageStatusShadow("failed".to_string()),
|
|
203
|
+
message_id: Some(message_id.to_string()),
|
|
204
|
+
verification: Some(
|
|
205
|
+
"run team-agent claim-leader or team-agent takeover".to_string(),
|
|
206
|
+
),
|
|
207
|
+
stage: None,
|
|
208
|
+
reason: Some(DeliveryRefusal::LeaderNotAttached),
|
|
209
|
+
channel: Some("rebind_required".to_string()),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
138
212
|
let target = resolve_inject_target(state, &message.recipient);
|
|
139
213
|
// Contract B / MUST-10 / N31/N32: physical paste+Enter into a startup trust/update
|
|
140
214
|
// menu is NOT provider delivery — the menu consumes the Enter and the task text
|
|
@@ -170,12 +244,70 @@ pub fn deliver_pending_message(
|
|
|
170
244
|
&message.content,
|
|
171
245
|
message_id,
|
|
172
246
|
);
|
|
173
|
-
|
|
247
|
+
let rendered_len = rendered.len();
|
|
248
|
+
let inject_report = match transport.inject(
|
|
174
249
|
&target,
|
|
175
250
|
&InjectPayload::Text(rendered),
|
|
176
251
|
Key::Enter,
|
|
177
252
|
true,
|
|
178
|
-
)
|
|
253
|
+
) {
|
|
254
|
+
Ok(report) => report,
|
|
255
|
+
Err(error) => {
|
|
256
|
+
if message.recipient == "leader" {
|
|
257
|
+
store.mark(message_id, "failed", Some("leader_not_attached"))?;
|
|
258
|
+
event_log.write(
|
|
259
|
+
"leader_receiver.delivery_blocked",
|
|
260
|
+
serde_json::json!({
|
|
261
|
+
"message_id": message_id,
|
|
262
|
+
"sender": message.sender,
|
|
263
|
+
"reason": "leader_not_attached",
|
|
264
|
+
"channel": "rebind_required",
|
|
265
|
+
"action": "run team-agent claim-leader or team-agent takeover",
|
|
266
|
+
"error": error.to_string(),
|
|
267
|
+
}),
|
|
268
|
+
)?;
|
|
269
|
+
return Ok(DeliveryOutcome {
|
|
270
|
+
ok: false,
|
|
271
|
+
status: DeliveryStatus::Refused,
|
|
272
|
+
message_status: MessageStatusShadow("failed".to_string()),
|
|
273
|
+
message_id: Some(message_id.to_string()),
|
|
274
|
+
verification: Some(
|
|
275
|
+
"run team-agent claim-leader or team-agent takeover".to_string(),
|
|
276
|
+
),
|
|
277
|
+
stage: None,
|
|
278
|
+
reason: Some(DeliveryRefusal::LeaderNotAttached),
|
|
279
|
+
channel: Some("rebind_required".to_string()),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return Err(error.into());
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
if !inject_submit_verified(&inject_report, rendered_len, &message.sender, &message.recipient) {
|
|
286
|
+
let reason = format!(
|
|
287
|
+
"submit_unverified:{}",
|
|
288
|
+
submit_verification_wire(inject_report.submit_verification)
|
|
289
|
+
);
|
|
290
|
+
store.mark(message_id, "submitted_unverified", Some(&reason))?;
|
|
291
|
+
event_log.write(
|
|
292
|
+
"send.unverified",
|
|
293
|
+
serde_json::json!({
|
|
294
|
+
"message_id": message_id,
|
|
295
|
+
"recipient": message.recipient,
|
|
296
|
+
"reason": reason,
|
|
297
|
+
"attempts": inject_report.attempts,
|
|
298
|
+
}),
|
|
299
|
+
)?;
|
|
300
|
+
return Ok(DeliveryOutcome {
|
|
301
|
+
ok: false,
|
|
302
|
+
status: DeliveryStatus::Failed,
|
|
303
|
+
message_status: MessageStatusShadow("submitted_unverified".to_string()),
|
|
304
|
+
message_id: Some(message_id.to_string()),
|
|
305
|
+
verification: Some(reason),
|
|
306
|
+
stage: Some(DeliveryStage::Submit),
|
|
307
|
+
reason: None,
|
|
308
|
+
channel: None,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
179
311
|
store.mark(message_id, "delivered", None)?;
|
|
180
312
|
event_log.write(
|
|
181
313
|
"message.delivered",
|
|
@@ -191,18 +323,39 @@ pub fn deliver_pending_message(
|
|
|
191
323
|
reason: None,
|
|
192
324
|
channel: None,
|
|
193
325
|
};
|
|
194
|
-
|
|
195
|
-
|
|
326
|
+
stamp_first_send_at_if_leader_to_worker_scoped(
|
|
327
|
+
workspace,
|
|
328
|
+
&message.sender,
|
|
329
|
+
&message.recipient,
|
|
330
|
+
canonical_owner_team_id.as_deref(),
|
|
331
|
+
)?;
|
|
332
|
+
record_turn_open_if_leader_to_worker_scoped(
|
|
196
333
|
workspace,
|
|
197
|
-
state,
|
|
198
334
|
&message.sender,
|
|
199
335
|
&message.recipient,
|
|
200
336
|
&outcome,
|
|
201
337
|
event_log,
|
|
338
|
+
canonical_owner_team_id.as_deref(),
|
|
202
339
|
)?;
|
|
203
340
|
Ok(outcome)
|
|
204
341
|
}
|
|
205
342
|
|
|
343
|
+
fn inject_submit_verified(
|
|
344
|
+
report: &InjectReport,
|
|
345
|
+
payload_len: usize,
|
|
346
|
+
sender: &str,
|
|
347
|
+
recipient: &str,
|
|
348
|
+
) -> bool {
|
|
349
|
+
match report.submit_verification {
|
|
350
|
+
SubmitVerification::SendKeysFailed => false,
|
|
351
|
+
SubmitVerification::PastedContentPromptAbsentAfterSubmit => true,
|
|
352
|
+
SubmitVerification::KeySentAfterVisibleToken { .. } => true,
|
|
353
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck => {
|
|
354
|
+
recipient == "leader" || matches!(sender, "leader" | "Leader") || payload_len < 80
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
206
359
|
/// Render a message into the worker-facing protocol block (port of `rust_core.py:render_message`,
|
|
207
360
|
/// golden-verified): `Team Agent message from {sender}[ for {task_id}]:\n\n{content}\n\n
|
|
208
361
|
/// [team-agent-token:{message_id}]`. The worker (fake or real provider) only builds a result_envelope
|
|
@@ -220,7 +373,15 @@ fn render_message(sender: &str, task_id: Option<&str>, content: &str, message_id
|
|
|
220
373
|
/// else a session-qualified `SessionWindow` (state.session_name + the agent's window, defaulting to the
|
|
221
374
|
/// id). NEVER the bare agent-id as a pane — a clientless coordinator cannot resolve that
|
|
222
375
|
/// ("can't find pane: w1", rt-host-a loop #3). Mirrors `coordinator/tick.rs::capture_target`.
|
|
376
|
+
///
|
|
377
|
+
/// Leader delivery uses the bound leader receiver pane. The leader is not a worker agent and
|
|
378
|
+
/// must not fall through to a synthetic `SessionWindow{window="leader"}` target.
|
|
223
379
|
fn resolve_inject_target(state: &serde_json::Value, recipient: &str) -> Target {
|
|
380
|
+
if recipient == "leader" {
|
|
381
|
+
if let Some(pane_id) = leader_receiver_pane_id(state) {
|
|
382
|
+
return Target::Pane(PaneId::new(pane_id));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
224
385
|
let agent = state.get("agents").and_then(|a| a.get(recipient));
|
|
225
386
|
if let Some(pane_id) = agent
|
|
226
387
|
.and_then(|a| a.get("pane_id"))
|
|
@@ -244,6 +405,116 @@ fn resolve_inject_target(state: &serde_json::Value, recipient: &str) -> Target {
|
|
|
244
405
|
}
|
|
245
406
|
}
|
|
246
407
|
|
|
408
|
+
/// Read the bound leader pane id off the projected or team-scoped runtime state.
|
|
409
|
+
fn leader_receiver_pane_id(state: &serde_json::Value) -> Option<&str> {
|
|
410
|
+
leader_receiver_pane_id_in_state(state)
|
|
411
|
+
.or_else(|| active_team_entry(state).and_then(leader_receiver_pane_id_in_state))
|
|
412
|
+
.or_else(|| only_team_entry(state).and_then(leader_receiver_pane_id_in_state))
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
fn leader_receiver_pane_is_usable(transport: &dyn Transport, state: &serde_json::Value) -> bool {
|
|
416
|
+
let Some(pane_id) = leader_receiver_pane_id(state) else {
|
|
417
|
+
return false;
|
|
418
|
+
};
|
|
419
|
+
if transport
|
|
420
|
+
.list_targets()
|
|
421
|
+
.unwrap_or_default()
|
|
422
|
+
.iter()
|
|
423
|
+
.any(|target| target.pane_id.as_str() == pane_id)
|
|
424
|
+
{
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
!matches!(transport.liveness(&PaneId::new(pane_id)), Ok(PaneLiveness::Dead))
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
enum DeliveryTransport<'a> {
|
|
431
|
+
Borrowed(&'a dyn Transport),
|
|
432
|
+
Owned(crate::tmux_backend::TmuxBackend),
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
impl<'a> DeliveryTransport<'a> {
|
|
436
|
+
fn as_transport(&'a self) -> &'a dyn Transport {
|
|
437
|
+
match self {
|
|
438
|
+
Self::Borrowed(transport) => *transport,
|
|
439
|
+
Self::Owned(transport) => transport,
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
fn delivery_transport_for_recipient<'a>(
|
|
445
|
+
workspace: &Path,
|
|
446
|
+
product_transport: &'a dyn Transport,
|
|
447
|
+
state: &serde_json::Value,
|
|
448
|
+
recipient: &str,
|
|
449
|
+
) -> DeliveryTransport<'a> {
|
|
450
|
+
if recipient != "leader" {
|
|
451
|
+
return DeliveryTransport::Borrowed(product_transport);
|
|
452
|
+
}
|
|
453
|
+
let Some(socket) = leader_receiver_tmux_socket(state) else {
|
|
454
|
+
return DeliveryTransport::Borrowed(product_transport);
|
|
455
|
+
};
|
|
456
|
+
if socket == crate::tmux_backend::socket_name_for_workspace(workspace) {
|
|
457
|
+
DeliveryTransport::Borrowed(product_transport)
|
|
458
|
+
} else {
|
|
459
|
+
DeliveryTransport::Owned(crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket))
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
fn leader_receiver_pane_id_in_state(state: &serde_json::Value) -> Option<&str> {
|
|
464
|
+
["leader_receiver", "team_owner"].into_iter().find_map(|key| {
|
|
465
|
+
state
|
|
466
|
+
.get(key)
|
|
467
|
+
.and_then(|r| r.get("pane_id"))
|
|
468
|
+
.and_then(serde_json::Value::as_str)
|
|
469
|
+
.filter(|s| !s.is_empty())
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
fn leader_receiver_tmux_socket(state: &serde_json::Value) -> Option<&str> {
|
|
474
|
+
leader_receiver_field(state, "tmux_socket")
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
fn leader_receiver_has_noncanonical_tmux_socket(state: &serde_json::Value) -> bool {
|
|
478
|
+
leader_receiver_tmux_socket(state)
|
|
479
|
+
.is_some_and(|socket| {
|
|
480
|
+
socket != "default" && !std::path::Path::new(socket).is_absolute()
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
fn leader_receiver_field<'a>(state: &'a serde_json::Value, field: &str) -> Option<&'a str> {
|
|
485
|
+
leader_receiver_field_in_state(state, field)
|
|
486
|
+
.or_else(|| active_team_entry(state).and_then(|team| leader_receiver_field_in_state(team, field)))
|
|
487
|
+
.or_else(|| only_team_entry(state).and_then(|team| leader_receiver_field_in_state(team, field)))
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
fn leader_receiver_field_in_state<'a>(
|
|
491
|
+
state: &'a serde_json::Value,
|
|
492
|
+
field: &str,
|
|
493
|
+
) -> Option<&'a str> {
|
|
494
|
+
state
|
|
495
|
+
.get("leader_receiver")
|
|
496
|
+
.and_then(|receiver| receiver.get(field))
|
|
497
|
+
.and_then(serde_json::Value::as_str)
|
|
498
|
+
.filter(|value| !value.is_empty())
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
fn active_team_entry(state: &serde_json::Value) -> Option<&serde_json::Value> {
|
|
502
|
+
let team = state.get("active_team_key").and_then(serde_json::Value::as_str)?;
|
|
503
|
+
state
|
|
504
|
+
.get("teams")
|
|
505
|
+
.and_then(serde_json::Value::as_object)
|
|
506
|
+
.and_then(|teams| teams.get(team))
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
fn only_team_entry(state: &serde_json::Value) -> Option<&serde_json::Value> {
|
|
510
|
+
let teams = state.get("teams").and_then(serde_json::Value::as_object)?;
|
|
511
|
+
if teams.len() == 1 {
|
|
512
|
+
teams.values().next()
|
|
513
|
+
} else {
|
|
514
|
+
None
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
247
518
|
/// `_deliver_pending_messages` (`delivery.py:484`):扫 pending 队列逐条投递;busy 收件人写
|
|
248
519
|
/// `send.deferred_busy` 跳过 (**不丢**,card §131)。返回投递的 message_id 列表。
|
|
249
520
|
pub fn deliver_pending_messages(
|
|
@@ -266,6 +537,19 @@ pub fn deliver_pending_messages(
|
|
|
266
537
|
let mut delivered = Vec::new();
|
|
267
538
|
for message_id in message_ids {
|
|
268
539
|
if let Some(message) = message_for_delivery(&store, &message_id)? {
|
|
540
|
+
let scoped_state;
|
|
541
|
+
let state = match message.owner_team_id.as_deref() {
|
|
542
|
+
Some(team) if !team.is_empty() => {
|
|
543
|
+
match project_state_for_owner_team(workspace, team, state, Some(&store), Some(&message_id), Some(event_log))? {
|
|
544
|
+
OwnerTeamProjection::Projected { state, .. } => {
|
|
545
|
+
scoped_state = state;
|
|
546
|
+
&scoped_state
|
|
547
|
+
}
|
|
548
|
+
OwnerTeamProjection::Refused(_) => continue,
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
_ => state,
|
|
552
|
+
};
|
|
269
553
|
if recipient_is_busy(state, &message.recipient) {
|
|
270
554
|
event_log.write(
|
|
271
555
|
"send.deferred_busy",
|
|
@@ -292,6 +576,7 @@ struct PendingMessage {
|
|
|
292
576
|
recipient: String,
|
|
293
577
|
content: String,
|
|
294
578
|
task_id: Option<String>,
|
|
579
|
+
owner_team_id: Option<String>,
|
|
295
580
|
}
|
|
296
581
|
|
|
297
582
|
fn message_for_delivery(
|
|
@@ -301,7 +586,7 @@ fn message_for_delivery(
|
|
|
301
586
|
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
302
587
|
let message = conn
|
|
303
588
|
.query_row(
|
|
304
|
-
"select sender, recipient, content, task_id from messages where message_id = ?1",
|
|
589
|
+
"select sender, recipient, content, task_id, owner_team_id from messages where message_id = ?1",
|
|
305
590
|
params![message_id],
|
|
306
591
|
|row| {
|
|
307
592
|
Ok(PendingMessage {
|
|
@@ -309,6 +594,7 @@ fn message_for_delivery(
|
|
|
309
594
|
recipient: row.get::<_, String>(1)?,
|
|
310
595
|
content: row.get::<_, String>(2)?,
|
|
311
596
|
task_id: row.get::<_, Option<String>>(3)?,
|
|
597
|
+
owner_team_id: row.get::<_, Option<String>>(4)?,
|
|
312
598
|
})
|
|
313
599
|
},
|
|
314
600
|
)
|
|
@@ -465,10 +751,28 @@ pub fn record_turn_open_if_leader_to_worker(
|
|
|
465
751
|
event_log: &EventLog,
|
|
466
752
|
) -> Result<(), MessagingError> {
|
|
467
753
|
let _ = state;
|
|
754
|
+
record_turn_open_if_leader_to_worker_scoped(
|
|
755
|
+
workspace,
|
|
756
|
+
sender,
|
|
757
|
+
recipient,
|
|
758
|
+
delivered,
|
|
759
|
+
event_log,
|
|
760
|
+
None,
|
|
761
|
+
)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
fn record_turn_open_if_leader_to_worker_scoped(
|
|
765
|
+
workspace: &Path,
|
|
766
|
+
sender: &str,
|
|
767
|
+
recipient: &str,
|
|
768
|
+
delivered: &DeliveryOutcome,
|
|
769
|
+
event_log: &EventLog,
|
|
770
|
+
owner_team_id: Option<&str>,
|
|
771
|
+
) -> Result<(), MessagingError> {
|
|
468
772
|
if !delivered.ok || !matches!(sender, "leader" | "Leader") || recipient == "leader" {
|
|
469
773
|
return Ok(());
|
|
470
774
|
}
|
|
471
|
-
let mut state =
|
|
775
|
+
let mut state = scoped_state_for_write(workspace, owner_team_id)?;
|
|
472
776
|
let Some(root) = state.as_object_mut() else {
|
|
473
777
|
return Ok(());
|
|
474
778
|
};
|
|
@@ -481,7 +785,7 @@ pub fn record_turn_open_if_leader_to_worker(
|
|
|
481
785
|
serde_json::json!({"armed": true, "node_id": recipient, "turn_id": delivered.message_id}),
|
|
482
786
|
);
|
|
483
787
|
}
|
|
484
|
-
|
|
788
|
+
save_scoped_state(workspace, &state, owner_team_id)?;
|
|
485
789
|
event_log.write(
|
|
486
790
|
"turn_open.armed_after_delivery",
|
|
487
791
|
serde_json::json!({"agent_id": recipient, "message_id": delivered.message_id}),
|
|
@@ -498,10 +802,19 @@ pub fn stamp_first_send_at_if_leader_to_worker(
|
|
|
498
802
|
recipient: &str,
|
|
499
803
|
) -> Result<(), MessagingError> {
|
|
500
804
|
let _ = state;
|
|
805
|
+
stamp_first_send_at_if_leader_to_worker_scoped(workspace, sender, recipient, None)
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
fn stamp_first_send_at_if_leader_to_worker_scoped(
|
|
809
|
+
workspace: &Path,
|
|
810
|
+
sender: &str,
|
|
811
|
+
recipient: &str,
|
|
812
|
+
owner_team_id: Option<&str>,
|
|
813
|
+
) -> Result<(), MessagingError> {
|
|
501
814
|
if !matches!(sender, "leader" | "Leader") || recipient == "leader" {
|
|
502
815
|
return Ok(());
|
|
503
816
|
}
|
|
504
|
-
let mut state =
|
|
817
|
+
let mut state = scoped_state_for_write(workspace, owner_team_id)?;
|
|
505
818
|
let now = chrono::Utc::now().to_rfc3339();
|
|
506
819
|
if let Some(agent) = state
|
|
507
820
|
.get_mut("agents")
|
|
@@ -511,12 +824,261 @@ pub fn stamp_first_send_at_if_leader_to_worker(
|
|
|
511
824
|
{
|
|
512
825
|
if !agent.contains_key("first_send_at") || agent.get("first_send_at").is_some_and(serde_json::Value::is_null) {
|
|
513
826
|
agent.insert("first_send_at".to_string(), serde_json::Value::String(now));
|
|
514
|
-
|
|
827
|
+
save_scoped_state(workspace, &state, owner_team_id)?;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
Ok(())
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
fn scoped_state_for_write(
|
|
834
|
+
workspace: &Path,
|
|
835
|
+
owner_team_id: Option<&str>,
|
|
836
|
+
) -> Result<serde_json::Value, MessagingError> {
|
|
837
|
+
match owner_team_id.filter(|team| !team.is_empty()) {
|
|
838
|
+
Some(team) => {
|
|
839
|
+
let raw = crate::state::persist::load_runtime_state(workspace)?;
|
|
840
|
+
match project_state_for_owner_team_value(&raw, team) {
|
|
841
|
+
Some(projected) => Ok(projected),
|
|
842
|
+
None => Ok(raw),
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
None => Ok(crate::state::persist::load_runtime_state(workspace)?),
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
fn save_scoped_state(
|
|
850
|
+
workspace: &Path,
|
|
851
|
+
state: &serde_json::Value,
|
|
852
|
+
owner_team_id: Option<&str>,
|
|
853
|
+
) -> Result<(), MessagingError> {
|
|
854
|
+
if owner_team_id.filter(|team| !team.is_empty()).is_some() {
|
|
855
|
+
if state
|
|
856
|
+
.get("teams")
|
|
857
|
+
.and_then(serde_json::Value::as_object)
|
|
858
|
+
.is_some_and(|teams| {
|
|
859
|
+
owner_team_id
|
|
860
|
+
.and_then(|team| crate::state::projection::resolve_owner_team_id(state, team).canonical_key().map(str::to_string))
|
|
861
|
+
.is_some_and(|team| teams.contains_key(&team))
|
|
862
|
+
})
|
|
863
|
+
{
|
|
864
|
+
crate::state::projection::save_team_scoped_state(workspace, state)?;
|
|
865
|
+
} else {
|
|
866
|
+
crate::state::persist::save_runtime_state(workspace, state)?;
|
|
867
|
+
}
|
|
868
|
+
} else {
|
|
869
|
+
crate::state::persist::save_runtime_state(workspace, state)?;
|
|
870
|
+
}
|
|
871
|
+
Ok(())
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
enum OwnerTeamProjection {
|
|
875
|
+
Projected { state: serde_json::Value, canonical_team: String },
|
|
876
|
+
Refused(DeliveryOutcome),
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
fn project_state_for_owner_team(
|
|
880
|
+
workspace: &Path,
|
|
881
|
+
team: &str,
|
|
882
|
+
fallback: &serde_json::Value,
|
|
883
|
+
store: Option<&MessageStore>,
|
|
884
|
+
message_id: Option<&str>,
|
|
885
|
+
event_log: Option<&EventLog>,
|
|
886
|
+
) -> Result<OwnerTeamProjection, MessagingError> {
|
|
887
|
+
let raw = crate::state::persist::load_runtime_state(workspace)?;
|
|
888
|
+
let fallback_has_teams = fallback
|
|
889
|
+
.get("teams")
|
|
890
|
+
.and_then(serde_json::Value::as_object)
|
|
891
|
+
.is_some_and(|teams| !teams.is_empty());
|
|
892
|
+
let (mut projection_source, mut resolution) = if fallback_has_teams {
|
|
893
|
+
(fallback, crate::state::projection::resolve_owner_team_id(fallback, team))
|
|
894
|
+
} else {
|
|
895
|
+
(&raw, crate::state::projection::resolve_owner_team_id(&raw, team))
|
|
896
|
+
};
|
|
897
|
+
if !fallback_has_teams && matches!(resolution, OwnerTeamResolution::Unresolved { .. }) {
|
|
898
|
+
let fallback_resolution = crate::state::projection::resolve_owner_team_id(fallback, team);
|
|
899
|
+
if !matches!(fallback_resolution, OwnerTeamResolution::Unresolved { .. }) {
|
|
900
|
+
resolution = fallback_resolution;
|
|
901
|
+
projection_source = fallback;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
let canonical_team = match resolution {
|
|
905
|
+
OwnerTeamResolution::Canonical(canonical) => canonical,
|
|
906
|
+
OwnerTeamResolution::LegacyAlias { requested, canonical } => {
|
|
907
|
+
normalize_owner_team_id_rows(workspace, &requested, &canonical, message_id, event_log)?;
|
|
908
|
+
canonical
|
|
909
|
+
}
|
|
910
|
+
OwnerTeamResolution::Unresolved { requested } => {
|
|
911
|
+
let outcome = refuse_owner_team_resolution(
|
|
912
|
+
store,
|
|
913
|
+
message_id,
|
|
914
|
+
event_log,
|
|
915
|
+
"owner_team_unresolved",
|
|
916
|
+
serde_json::json!({"owner_team_id": requested}),
|
|
917
|
+
DeliveryRefusal::UnknownRecipient,
|
|
918
|
+
)?;
|
|
919
|
+
return Ok(OwnerTeamProjection::Refused(outcome));
|
|
920
|
+
}
|
|
921
|
+
OwnerTeamResolution::Ambiguous { requested, matches } => {
|
|
922
|
+
let outcome = refuse_owner_team_resolution(
|
|
923
|
+
store,
|
|
924
|
+
message_id,
|
|
925
|
+
event_log,
|
|
926
|
+
"owner_team_ambiguous",
|
|
927
|
+
serde_json::json!({"owner_team_id": requested, "matches": matches}),
|
|
928
|
+
DeliveryRefusal::Ambiguous,
|
|
929
|
+
)?;
|
|
930
|
+
return Ok(OwnerTeamProjection::Refused(outcome));
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
if top_level_state_matches_owner_team(fallback, &canonical_team) {
|
|
934
|
+
let mut state = fallback.clone();
|
|
935
|
+
carry_top_level_leader_binding(&mut state, &raw);
|
|
936
|
+
return Ok(OwnerTeamProjection::Projected {
|
|
937
|
+
state,
|
|
938
|
+
canonical_team,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
if top_level_state_matches_owner_team(&raw, &canonical_team) {
|
|
942
|
+
return Ok(OwnerTeamProjection::Projected {
|
|
943
|
+
state: raw,
|
|
944
|
+
canonical_team,
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
if state_has_no_team_entries(projection_source) {
|
|
948
|
+
let mut state = projection_source.clone();
|
|
949
|
+
carry_top_level_leader_binding(&mut state, &raw);
|
|
950
|
+
return Ok(OwnerTeamProjection::Projected {
|
|
951
|
+
state,
|
|
952
|
+
canonical_team,
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
let mut state = project_state_for_owner_team_value(projection_source, &canonical_team)
|
|
956
|
+
.ok_or_else(|| MessagingError::Routing(format!("owner_team_unresolved: {canonical_team}")))?;
|
|
957
|
+
carry_top_level_leader_binding(&mut state, projection_source);
|
|
958
|
+
carry_top_level_leader_binding(&mut state, &raw);
|
|
959
|
+
Ok(OwnerTeamProjection::Projected { state, canonical_team })
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
fn carry_top_level_leader_binding(projected: &mut serde_json::Value, raw: &serde_json::Value) {
|
|
963
|
+
let Some(projected_obj) = projected.as_object_mut() else {
|
|
964
|
+
return;
|
|
965
|
+
};
|
|
966
|
+
for key in ["leader_receiver", "team_owner", "owner_epoch"] {
|
|
967
|
+
if projected_obj.contains_key(key) {
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
if let Some(value) = raw.get(key) {
|
|
971
|
+
projected_obj.insert(key.to_string(), value.clone());
|
|
515
972
|
}
|
|
516
973
|
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
fn state_has_no_team_entries(state: &serde_json::Value) -> bool {
|
|
977
|
+
state
|
|
978
|
+
.get("teams")
|
|
979
|
+
.and_then(serde_json::Value::as_object)
|
|
980
|
+
.is_none_or(serde_json::Map::is_empty)
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
pub(crate) fn normalize_owner_team_id_rows(
|
|
984
|
+
workspace: &Path,
|
|
985
|
+
requested: &str,
|
|
986
|
+
canonical: &str,
|
|
987
|
+
message_id: Option<&str>,
|
|
988
|
+
event_log: Option<&EventLog>,
|
|
989
|
+
) -> Result<(), MessagingError> {
|
|
990
|
+
if requested == canonical {
|
|
991
|
+
return Ok(());
|
|
992
|
+
}
|
|
993
|
+
let store = MessageStore::open(workspace)?;
|
|
994
|
+
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
995
|
+
for table in [
|
|
996
|
+
"messages",
|
|
997
|
+
"results",
|
|
998
|
+
"scheduled_events",
|
|
999
|
+
"agent_health",
|
|
1000
|
+
"result_watchers",
|
|
1001
|
+
"leader_notification_log",
|
|
1002
|
+
] {
|
|
1003
|
+
let sql = format!("update or ignore {table} set owner_team_id = ?1 where owner_team_id = ?2");
|
|
1004
|
+
conn.execute(&sql, params![canonical, requested])?;
|
|
1005
|
+
}
|
|
1006
|
+
if let Some(event_log) = event_log {
|
|
1007
|
+
event_log.write(
|
|
1008
|
+
"owner_team_id.compatibility_alias_migrated",
|
|
1009
|
+
serde_json::json!({
|
|
1010
|
+
"requested_owner_team_id": requested,
|
|
1011
|
+
"canonical_owner_team_id": canonical,
|
|
1012
|
+
"message_id": message_id,
|
|
1013
|
+
}),
|
|
1014
|
+
)?;
|
|
1015
|
+
}
|
|
517
1016
|
Ok(())
|
|
518
1017
|
}
|
|
519
1018
|
|
|
1019
|
+
fn refuse_owner_team_resolution(
|
|
1020
|
+
store: Option<&MessageStore>,
|
|
1021
|
+
message_id: Option<&str>,
|
|
1022
|
+
event_log: Option<&EventLog>,
|
|
1023
|
+
error: &str,
|
|
1024
|
+
details: serde_json::Value,
|
|
1025
|
+
refusal: DeliveryRefusal,
|
|
1026
|
+
) -> Result<DeliveryOutcome, MessagingError> {
|
|
1027
|
+
if let (Some(store), Some(message_id)) = (store, message_id) {
|
|
1028
|
+
store.mark(message_id, "failed", Some(error))?;
|
|
1029
|
+
}
|
|
1030
|
+
if let Some(event_log) = event_log {
|
|
1031
|
+
event_log.write(
|
|
1032
|
+
"owner_team_id.resolution_failed",
|
|
1033
|
+
serde_json::json!({
|
|
1034
|
+
"message_id": message_id,
|
|
1035
|
+
"error": error,
|
|
1036
|
+
"details": details,
|
|
1037
|
+
}),
|
|
1038
|
+
)?;
|
|
1039
|
+
}
|
|
1040
|
+
Ok(DeliveryOutcome {
|
|
1041
|
+
ok: false,
|
|
1042
|
+
status: DeliveryStatus::Refused,
|
|
1043
|
+
message_status: MessageStatusShadow("failed".to_string()),
|
|
1044
|
+
message_id: message_id.map(str::to_string),
|
|
1045
|
+
verification: Some(error.to_string()),
|
|
1046
|
+
stage: None,
|
|
1047
|
+
reason: Some(refusal),
|
|
1048
|
+
channel: Some("owner_team_resolution".to_string()),
|
|
1049
|
+
})
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
fn project_state_for_owner_team_value(
|
|
1053
|
+
raw: &serde_json::Value,
|
|
1054
|
+
team: &str,
|
|
1055
|
+
) -> Option<serde_json::Value> {
|
|
1056
|
+
if let Some(projected) = raw
|
|
1057
|
+
.get("teams")
|
|
1058
|
+
.and_then(serde_json::Value::as_object)
|
|
1059
|
+
.is_some_and(|teams| teams.contains_key(team))
|
|
1060
|
+
.then(|| crate::state::projection::project_top_level_view(raw, team))
|
|
1061
|
+
{
|
|
1062
|
+
return Some(projected);
|
|
1063
|
+
}
|
|
1064
|
+
if top_level_state_matches_owner_team(raw, team) {
|
|
1065
|
+
return None;
|
|
1066
|
+
}
|
|
1067
|
+
None
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
fn top_level_state_matches_owner_team(state: &serde_json::Value, team: &str) -> bool {
|
|
1071
|
+
state
|
|
1072
|
+
.get("active_team_key")
|
|
1073
|
+
.and_then(serde_json::Value::as_str)
|
|
1074
|
+
.is_some_and(|value| value == team)
|
|
1075
|
+
|| crate::state::projection::team_state_key(state) == team
|
|
1076
|
+
|| state
|
|
1077
|
+
.get("session_name")
|
|
1078
|
+
.and_then(serde_json::Value::as_str)
|
|
1079
|
+
.is_some_and(|session| session == team || session.strip_prefix("team-") == Some(team))
|
|
1080
|
+
}
|
|
1081
|
+
|
|
520
1082
|
/// `retry_injection_after_trust_auto_answer` (`trust_auto_answer.py`):leader 路径 trust 应答
|
|
521
1083
|
/// 后重注入 (查 pane_width fail-safe + attempt_trust_auto_answer + 等 dismissal + 重 inject)。
|
|
522
1084
|
pub fn retry_injection_after_trust_auto_answer(
|