@team-agent/installer 0.3.20 → 0.3.22
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/emit.rs +9 -1
- package/crates/team-agent/src/cli/mod.rs +158 -2
- package/crates/team-agent/src/cli/status.rs +61 -0
- package/crates/team-agent/src/cli/status_port.rs +2 -2
- package/crates/team-agent/src/cli/tests/base.rs +26 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +233 -9
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +251 -2
- package/crates/team-agent/src/coordinator/tests/energy.rs +548 -0
- package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/tick.rs +452 -17
- package/crates/team-agent/src/lifecycle/display.rs +23 -302
- package/crates/team-agent/src/lifecycle/launch.rs +38 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -6
- package/crates/team-agent/src/lifecycle/restart/common.rs +76 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +295 -13
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +30 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +89 -5
- package/crates/team-agent/src/messaging/delivery.rs +324 -95
- package/crates/team-agent/src/messaging/scheduler.rs +95 -73
- package/crates/team-agent/src/messaging/send.rs +10 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +460 -0
- package/crates/team-agent/src/messaging/tests/spine.rs +51 -2
- package/crates/team-agent/src/session_capture.rs +261 -1
- package/crates/team-agent/src/tmux_backend/tests.rs +97 -1
- package/crates/team-agent/src/tmux_backend.rs +138 -2
- package/crates/team-agent/src/transport/test_support.rs +25 -0
- package/crates/team-agent/src/transport.rs +11 -0
- package/package.json +4 -4
|
@@ -10,8 +10,8 @@ use crate::message_store::MessageStore;
|
|
|
10
10
|
use crate::model::enums::{PaneLiveness, Provider};
|
|
11
11
|
use crate::model::ids::TeamKey;
|
|
12
12
|
use crate::transport::{
|
|
13
|
-
submit_verification_wire, InjectPayload, InjectReport, Key, PaneId,
|
|
14
|
-
SubmitVerification, Target, Transport, WindowName,
|
|
13
|
+
submit_verification_wire, InjectPayload, InjectReport, InjectVerification, Key, PaneId,
|
|
14
|
+
PaneInfo, SessionName, SubmitVerification, Target, Transport, WindowName,
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
use super::helpers::{message_exists, MessageStatusShadow};
|
|
@@ -84,11 +84,19 @@ pub fn tmux_pane_width(transport: &dyn Transport, target: &Target) -> PaneWidthQ
|
|
|
84
84
|
match result {
|
|
85
85
|
Ok(Some(raw)) => match raw.trim().parse::<u32>() {
|
|
86
86
|
Ok(pane_width) if pane_width > 0 => PaneWidthQuery::Ok { pane_width },
|
|
87
|
-
Ok(_) => PaneWidthQuery::Failed {
|
|
88
|
-
|
|
87
|
+
Ok(_) => PaneWidthQuery::Failed {
|
|
88
|
+
error: "non_positive_width".to_string(),
|
|
89
|
+
},
|
|
90
|
+
Err(_) => PaneWidthQuery::Failed {
|
|
91
|
+
error: "unparseable_output".to_string(),
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
Ok(None) => PaneWidthQuery::Failed {
|
|
95
|
+
error: "empty_output".to_string(),
|
|
96
|
+
},
|
|
97
|
+
Err(err) => PaneWidthQuery::Failed {
|
|
98
|
+
error: format!("tmux_query_failed:{err}"),
|
|
89
99
|
},
|
|
90
|
-
Ok(None) => PaneWidthQuery::Failed { error: "empty_output".to_string() },
|
|
91
|
-
Err(err) => PaneWidthQuery::Failed { error: format!("tmux_query_failed:{err}") },
|
|
92
100
|
}
|
|
93
101
|
}
|
|
94
102
|
|
|
@@ -131,8 +139,18 @@ pub fn deliver_pending_message(
|
|
|
131
139
|
let scoped_state;
|
|
132
140
|
let state = match message.owner_team_id.as_deref() {
|
|
133
141
|
Some(team) if !team.is_empty() => {
|
|
134
|
-
match project_state_for_owner_team(
|
|
135
|
-
|
|
142
|
+
match project_state_for_owner_team(
|
|
143
|
+
workspace,
|
|
144
|
+
team,
|
|
145
|
+
state,
|
|
146
|
+
Some(store),
|
|
147
|
+
Some(message_id),
|
|
148
|
+
Some(event_log),
|
|
149
|
+
)? {
|
|
150
|
+
OwnerTeamProjection::Projected {
|
|
151
|
+
state,
|
|
152
|
+
canonical_team,
|
|
153
|
+
} => {
|
|
136
154
|
canonical_owner_team_id = Some(canonical_team);
|
|
137
155
|
scoped_state = state;
|
|
138
156
|
&scoped_state
|
|
@@ -142,7 +160,11 @@ pub fn deliver_pending_message(
|
|
|
142
160
|
}
|
|
143
161
|
_ => state,
|
|
144
162
|
};
|
|
145
|
-
if
|
|
163
|
+
let attempt = if store.claim_for_delivery(message_id)? {
|
|
164
|
+
message.delivery_attempts.saturating_add(1)
|
|
165
|
+
} else if message.status == "target_resolved" {
|
|
166
|
+
bump_delivery_attempts(store, message_id)?
|
|
167
|
+
} else {
|
|
146
168
|
return Ok(DeliveryOutcome {
|
|
147
169
|
ok: false,
|
|
148
170
|
status: DeliveryStatus::Refused,
|
|
@@ -153,7 +175,7 @@ pub fn deliver_pending_message(
|
|
|
153
175
|
reason: Some(DeliveryRefusal::MessageAlreadyClaimed),
|
|
154
176
|
channel: None,
|
|
155
177
|
});
|
|
156
|
-
}
|
|
178
|
+
};
|
|
157
179
|
if message.recipient == "leader" && leader_receiver_has_noncanonical_tmux_socket(state) {
|
|
158
180
|
store.mark(message_id, "failed", Some("leader_not_attached"))?;
|
|
159
181
|
event_log.write(
|
|
@@ -172,9 +194,7 @@ pub fn deliver_pending_message(
|
|
|
172
194
|
status: DeliveryStatus::Refused,
|
|
173
195
|
message_status: MessageStatusShadow("failed".to_string()),
|
|
174
196
|
message_id: Some(message_id.to_string()),
|
|
175
|
-
verification: Some(
|
|
176
|
-
"run team-agent claim-leader or team-agent takeover".to_string(),
|
|
177
|
-
),
|
|
197
|
+
verification: Some("run team-agent claim-leader or team-agent takeover".to_string()),
|
|
178
198
|
stage: None,
|
|
179
199
|
reason: Some(DeliveryRefusal::LeaderNotAttached),
|
|
180
200
|
channel: Some("rebind_required".to_string()),
|
|
@@ -201,15 +221,13 @@ pub fn deliver_pending_message(
|
|
|
201
221
|
status: DeliveryStatus::Refused,
|
|
202
222
|
message_status: MessageStatusShadow("failed".to_string()),
|
|
203
223
|
message_id: Some(message_id.to_string()),
|
|
204
|
-
verification: Some(
|
|
205
|
-
"run team-agent claim-leader or team-agent takeover".to_string(),
|
|
206
|
-
),
|
|
224
|
+
verification: Some("run team-agent claim-leader or team-agent takeover".to_string()),
|
|
207
225
|
stage: None,
|
|
208
226
|
reason: Some(DeliveryRefusal::LeaderNotAttached),
|
|
209
227
|
channel: Some("rebind_required".to_string()),
|
|
210
228
|
});
|
|
211
229
|
}
|
|
212
|
-
let target = resolve_inject_target(state, &message.recipient);
|
|
230
|
+
let target = resolve_inject_target(state, &message.recipient, transport);
|
|
213
231
|
// Contract B / MUST-10 / N31/N32: physical paste+Enter into a startup trust/update
|
|
214
232
|
// menu is NOT provider delivery — the menu consumes the Enter and the task text
|
|
215
233
|
// is lost (PROBE-2 root-cause). Before injection, peek at the recipient's pane for
|
|
@@ -244,48 +262,97 @@ pub fn deliver_pending_message(
|
|
|
244
262
|
&message.content,
|
|
245
263
|
message_id,
|
|
246
264
|
);
|
|
247
|
-
let inject_report =
|
|
248
|
-
&target,
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
265
|
+
let inject_report =
|
|
266
|
+
match transport.inject(&target, &InjectPayload::Text(rendered), Key::Enter, true) {
|
|
267
|
+
Ok(report) => report,
|
|
268
|
+
Err(error) => {
|
|
269
|
+
let reason = format!("inject_failed:{error}");
|
|
270
|
+
if message.recipient == "leader" {
|
|
271
|
+
store.mark(message_id, "failed", Some("leader_not_attached"))?;
|
|
272
|
+
event_log.write(
|
|
273
|
+
"leader_receiver.delivery_blocked",
|
|
274
|
+
serde_json::json!({
|
|
275
|
+
"message_id": message_id,
|
|
276
|
+
"sender": message.sender,
|
|
277
|
+
"reason": "leader_not_attached",
|
|
278
|
+
"channel": "rebind_required",
|
|
279
|
+
"action": "run team-agent claim-leader or team-agent takeover",
|
|
280
|
+
"error": error.to_string(),
|
|
281
|
+
}),
|
|
282
|
+
)?;
|
|
283
|
+
return Ok(DeliveryOutcome {
|
|
284
|
+
ok: false,
|
|
285
|
+
status: DeliveryStatus::Refused,
|
|
286
|
+
message_status: MessageStatusShadow("failed".to_string()),
|
|
287
|
+
message_id: Some(message_id.to_string()),
|
|
288
|
+
verification: Some(
|
|
289
|
+
"run team-agent claim-leader or team-agent takeover".to_string(),
|
|
290
|
+
),
|
|
291
|
+
stage: None,
|
|
292
|
+
reason: Some(DeliveryRefusal::LeaderNotAttached),
|
|
293
|
+
channel: Some("rebind_required".to_string()),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
257
296
|
event_log.write(
|
|
258
|
-
"
|
|
297
|
+
"send.inject_failed",
|
|
259
298
|
serde_json::json!({
|
|
260
299
|
"message_id": message_id,
|
|
261
|
-
"
|
|
262
|
-
"
|
|
263
|
-
"
|
|
264
|
-
"action": "run team-agent claim-leader or team-agent takeover",
|
|
300
|
+
"recipient": message.recipient,
|
|
301
|
+
"attempts": attempt,
|
|
302
|
+
"max_attempts": SEND_RETRY_MAX_ATTEMPTS,
|
|
265
303
|
"error": error.to_string(),
|
|
266
304
|
}),
|
|
267
305
|
)?;
|
|
306
|
+
if attempt >= u32::from(SEND_RETRY_MAX_ATTEMPTS) {
|
|
307
|
+
store.mark(message_id, "failed", Some("send_inject_exhausted"))?;
|
|
308
|
+
emit_send_failed_exhausted(
|
|
309
|
+
workspace,
|
|
310
|
+
state,
|
|
311
|
+
event_log,
|
|
312
|
+
message_id,
|
|
313
|
+
&message.recipient,
|
|
314
|
+
attempt,
|
|
315
|
+
"send_inject_exhausted",
|
|
316
|
+
&reason,
|
|
317
|
+
)?;
|
|
318
|
+
return Ok(DeliveryOutcome {
|
|
319
|
+
ok: false,
|
|
320
|
+
status: DeliveryStatus::Failed,
|
|
321
|
+
message_status: MessageStatusShadow("failed".to_string()),
|
|
322
|
+
message_id: Some(message_id.to_string()),
|
|
323
|
+
verification: Some(reason),
|
|
324
|
+
stage: Some(DeliveryStage::Inject),
|
|
325
|
+
reason: None,
|
|
326
|
+
channel: None,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
store.mark(message_id, "target_resolved", Some(&reason))?;
|
|
268
330
|
return Ok(DeliveryOutcome {
|
|
269
331
|
ok: false,
|
|
270
|
-
status: DeliveryStatus::
|
|
271
|
-
message_status: MessageStatusShadow("
|
|
332
|
+
status: DeliveryStatus::Degraded,
|
|
333
|
+
message_status: MessageStatusShadow("target_resolved".to_string()),
|
|
272
334
|
message_id: Some(message_id.to_string()),
|
|
273
|
-
verification: Some(
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
reason: Some(DeliveryRefusal::LeaderNotAttached),
|
|
278
|
-
channel: Some("rebind_required".to_string()),
|
|
335
|
+
verification: Some(reason),
|
|
336
|
+
stage: Some(DeliveryStage::Inject),
|
|
337
|
+
reason: None,
|
|
338
|
+
channel: None,
|
|
279
339
|
});
|
|
280
340
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
341
|
+
};
|
|
342
|
+
let submit_verified = inject_submit_verified(&inject_report);
|
|
343
|
+
let readback_verified = pane_readback_verified(&inject_report);
|
|
344
|
+
// U1 #7: delivery is verified only when the submit succeeded AND the token was read
|
|
345
|
+
// back as visible in the pane. A submit-verified-but-token-missing inject (silent
|
|
346
|
+
// paste drop) is NOT delivered — it falls through to the unverified/degraded path.
|
|
347
|
+
if !(submit_verified && readback_verified) {
|
|
348
|
+
let reason = if !readback_verified {
|
|
349
|
+
"pane_readback_unverified:capture_missing_token".to_string()
|
|
350
|
+
} else {
|
|
351
|
+
format!(
|
|
352
|
+
"submit_unverified:{}",
|
|
353
|
+
submit_verification_wire(inject_report.submit_verification)
|
|
354
|
+
)
|
|
355
|
+
};
|
|
289
356
|
event_log.write(
|
|
290
357
|
"send.unverified",
|
|
291
358
|
serde_json::json!({
|
|
@@ -304,6 +371,7 @@ pub fn deliver_pending_message(
|
|
|
304
371
|
message_id,
|
|
305
372
|
&message.recipient,
|
|
306
373
|
inject_report.attempts,
|
|
374
|
+
"send_unverified_exhausted",
|
|
307
375
|
&reason,
|
|
308
376
|
)?;
|
|
309
377
|
return Ok(DeliveryOutcome {
|
|
@@ -371,6 +439,20 @@ fn inject_submit_verified(report: &InjectReport) -> bool {
|
|
|
371
439
|
}
|
|
372
440
|
}
|
|
373
441
|
|
|
442
|
+
/// U1 #7 step-2: pane-readback gate. The submit may report verified (a key was sent)
|
|
443
|
+
/// while the pasted token never actually landed in the pane — `inject()` step-1 now
|
|
444
|
+
/// surfaces that as `InjectVerification::CaptureMissingToken`. A delivery is only truly
|
|
445
|
+
/// verified when BOTH the submit succeeded AND the token was read back as visible.
|
|
446
|
+
/// `CaptureMissingToken` → readback failed → not delivered (degraded/unverified). All
|
|
447
|
+
/// other inject_verification variants (incl. token-less payloads, empty sends, new
|
|
448
|
+
/// pasted-content prompt) are not readback-negative and pass this gate.
|
|
449
|
+
fn pane_readback_verified(report: &InjectReport) -> bool {
|
|
450
|
+
!matches!(
|
|
451
|
+
report.inject_verification,
|
|
452
|
+
InjectVerification::CaptureMissingToken
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
374
456
|
/// Render a message into the worker-facing protocol block (port of `rust_core.py:render_message`,
|
|
375
457
|
/// golden-verified): `Team Agent message from {sender}[ for {task_id}]:\n\n{content}\n\n
|
|
376
458
|
/// [team-agent-token:{message_id}]`. The worker (fake or real provider) only builds a result_envelope
|
|
@@ -391,20 +473,18 @@ fn render_message(sender: &str, task_id: Option<&str>, content: &str, message_id
|
|
|
391
473
|
///
|
|
392
474
|
/// Leader delivery uses the bound leader receiver pane. The leader is not a worker agent and
|
|
393
475
|
/// must not fall through to a synthetic `SessionWindow{window="leader"}` target.
|
|
394
|
-
fn resolve_inject_target(
|
|
476
|
+
fn resolve_inject_target(
|
|
477
|
+
state: &serde_json::Value,
|
|
478
|
+
recipient: &str,
|
|
479
|
+
transport: &dyn Transport,
|
|
480
|
+
) -> Target {
|
|
481
|
+
let live_targets = transport.list_targets().unwrap_or_default();
|
|
395
482
|
if recipient == "leader" {
|
|
396
483
|
if let Some(pane_id) = leader_receiver_pane_id(state) {
|
|
397
484
|
return Target::Pane(PaneId::new(pane_id));
|
|
398
485
|
}
|
|
399
486
|
}
|
|
400
487
|
let agent = state.get("agents").and_then(|a| a.get(recipient));
|
|
401
|
-
if let Some(pane_id) = agent
|
|
402
|
-
.and_then(|a| a.get("pane_id"))
|
|
403
|
-
.and_then(serde_json::Value::as_str)
|
|
404
|
-
.filter(|s| !s.is_empty())
|
|
405
|
-
{
|
|
406
|
-
return Target::Pane(PaneId::new(pane_id));
|
|
407
|
-
}
|
|
408
488
|
let session = state
|
|
409
489
|
.get("session_name")
|
|
410
490
|
.and_then(serde_json::Value::as_str)
|
|
@@ -414,12 +494,57 @@ fn resolve_inject_target(state: &serde_json::Value, recipient: &str) -> Target {
|
|
|
414
494
|
.and_then(serde_json::Value::as_str)
|
|
415
495
|
.filter(|s| !s.is_empty())
|
|
416
496
|
.unwrap_or(recipient);
|
|
497
|
+
let cached_pane = agent
|
|
498
|
+
.and_then(|a| a.get("pane_id"))
|
|
499
|
+
.and_then(serde_json::Value::as_str)
|
|
500
|
+
.filter(|s| !s.is_empty())
|
|
501
|
+
.map(PaneId::new);
|
|
502
|
+
if let Some(pane) = cached_pane.as_ref() {
|
|
503
|
+
if live_targets
|
|
504
|
+
.iter()
|
|
505
|
+
.any(|target| target.pane_id.as_str() == pane.as_str())
|
|
506
|
+
{
|
|
507
|
+
return Target::Pane(pane.clone());
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if let Some(live_pane) = live_pane_for_session_window(&live_targets, session, window) {
|
|
511
|
+
return Target::Pane(live_pane);
|
|
512
|
+
}
|
|
513
|
+
if let Some(pane) = cached_pane {
|
|
514
|
+
if live_targets.is_empty() && !cached_pane_known_dead(transport, &pane) {
|
|
515
|
+
return Target::Pane(pane);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
417
518
|
Target::SessionWindow {
|
|
418
519
|
session: SessionName::new(session),
|
|
419
520
|
window: WindowName::new(window),
|
|
420
521
|
}
|
|
421
522
|
}
|
|
422
523
|
|
|
524
|
+
fn live_pane_for_session_window(
|
|
525
|
+
targets: &[PaneInfo],
|
|
526
|
+
session: &str,
|
|
527
|
+
window: &str,
|
|
528
|
+
) -> Option<PaneId> {
|
|
529
|
+
targets
|
|
530
|
+
.iter()
|
|
531
|
+
.find(|target| {
|
|
532
|
+
target.session.as_str() == session
|
|
533
|
+
&& target
|
|
534
|
+
.window_name
|
|
535
|
+
.as_ref()
|
|
536
|
+
.is_some_and(|name| name.as_str() == window)
|
|
537
|
+
})
|
|
538
|
+
.map(|target| target.pane_id.clone())
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
fn cached_pane_known_dead(transport: &dyn Transport, pane: &PaneId) -> bool {
|
|
542
|
+
if matches!(transport.has_pane(pane), Ok(Some(false))) {
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
matches!(transport.liveness(pane), Ok(PaneLiveness::Dead))
|
|
546
|
+
}
|
|
547
|
+
|
|
423
548
|
/// Read the bound leader pane id off the projected or team-scoped runtime state.
|
|
424
549
|
fn leader_receiver_pane_id(state: &serde_json::Value) -> Option<&str> {
|
|
425
550
|
leader_receiver_pane_id_in_state(state)
|
|
@@ -439,7 +564,10 @@ fn leader_receiver_pane_is_usable(transport: &dyn Transport, state: &serde_json:
|
|
|
439
564
|
{
|
|
440
565
|
return true;
|
|
441
566
|
}
|
|
442
|
-
!matches!(
|
|
567
|
+
!matches!(
|
|
568
|
+
transport.liveness(&PaneId::new(pane_id)),
|
|
569
|
+
Ok(PaneLiveness::Dead)
|
|
570
|
+
)
|
|
443
571
|
}
|
|
444
572
|
|
|
445
573
|
enum DeliveryTransport<'a> {
|
|
@@ -523,13 +651,15 @@ fn delivery_transport_for_recipient<'a>(
|
|
|
523
651
|
}
|
|
524
652
|
|
|
525
653
|
fn leader_receiver_pane_id_in_state(state: &serde_json::Value) -> Option<&str> {
|
|
526
|
-
["leader_receiver", "team_owner"]
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
654
|
+
["leader_receiver", "team_owner"]
|
|
655
|
+
.into_iter()
|
|
656
|
+
.find_map(|key| {
|
|
657
|
+
state
|
|
658
|
+
.get(key)
|
|
659
|
+
.and_then(|r| r.get("pane_id"))
|
|
660
|
+
.and_then(serde_json::Value::as_str)
|
|
661
|
+
.filter(|s| !s.is_empty() && *s != "__team_agent_unbound__")
|
|
662
|
+
})
|
|
533
663
|
}
|
|
534
664
|
|
|
535
665
|
fn leader_receiver_tmux_socket(state: &serde_json::Value) -> Option<&str> {
|
|
@@ -538,15 +668,17 @@ fn leader_receiver_tmux_socket(state: &serde_json::Value) -> Option<&str> {
|
|
|
538
668
|
|
|
539
669
|
fn leader_receiver_has_noncanonical_tmux_socket(state: &serde_json::Value) -> bool {
|
|
540
670
|
leader_receiver_tmux_socket(state)
|
|
541
|
-
.is_some_and(|socket|
|
|
542
|
-
socket != "default" && !std::path::Path::new(socket).is_absolute()
|
|
543
|
-
})
|
|
671
|
+
.is_some_and(|socket| socket != "default" && !std::path::Path::new(socket).is_absolute())
|
|
544
672
|
}
|
|
545
673
|
|
|
546
674
|
fn leader_receiver_field<'a>(state: &'a serde_json::Value, field: &str) -> Option<&'a str> {
|
|
547
675
|
leader_receiver_field_in_state(state, field)
|
|
548
|
-
.or_else(||
|
|
549
|
-
|
|
676
|
+
.or_else(|| {
|
|
677
|
+
active_team_entry(state).and_then(|team| leader_receiver_field_in_state(team, field))
|
|
678
|
+
})
|
|
679
|
+
.or_else(|| {
|
|
680
|
+
only_team_entry(state).and_then(|team| leader_receiver_field_in_state(team, field))
|
|
681
|
+
})
|
|
550
682
|
}
|
|
551
683
|
|
|
552
684
|
fn leader_receiver_field_in_state<'a>(
|
|
@@ -567,6 +699,7 @@ fn emit_send_failed_exhausted(
|
|
|
567
699
|
message_id: &str,
|
|
568
700
|
recipient: &str,
|
|
569
701
|
attempts: u32,
|
|
702
|
+
failure_reason: &str,
|
|
570
703
|
verification: &str,
|
|
571
704
|
) -> Result<(), MessagingError> {
|
|
572
705
|
event_log.write(
|
|
@@ -576,12 +709,12 @@ fn emit_send_failed_exhausted(
|
|
|
576
709
|
"recipient": recipient,
|
|
577
710
|
"attempts": attempts,
|
|
578
711
|
"max_attempts": SEND_RETRY_MAX_ATTEMPTS,
|
|
579
|
-
"reason":
|
|
712
|
+
"reason": failure_reason,
|
|
580
713
|
"verification": verification,
|
|
581
714
|
}),
|
|
582
715
|
)?;
|
|
583
716
|
let content = format!(
|
|
584
|
-
"send.failed\nerror: send to {recipient}
|
|
717
|
+
"send.failed\nerror: send to {recipient} failed with {failure_reason} after {attempts}/{SEND_RETRY_MAX_ATTEMPTS} attempts\naction: inspect the target pane and retry the send\nlog: .team/logs/events.jsonl"
|
|
585
718
|
);
|
|
586
719
|
match crate::messaging::send_to_leader_receiver(
|
|
587
720
|
workspace,
|
|
@@ -620,7 +753,9 @@ fn emit_send_failed_exhausted(
|
|
|
620
753
|
}
|
|
621
754
|
|
|
622
755
|
fn active_team_entry(state: &serde_json::Value) -> Option<&serde_json::Value> {
|
|
623
|
-
let team = state
|
|
756
|
+
let team = state
|
|
757
|
+
.get("active_team_key")
|
|
758
|
+
.and_then(serde_json::Value::as_str)?;
|
|
624
759
|
state
|
|
625
760
|
.get("teams")
|
|
626
761
|
.and_then(serde_json::Value::as_object)
|
|
@@ -661,12 +796,31 @@ pub fn deliver_pending_messages(
|
|
|
661
796
|
let scoped_state;
|
|
662
797
|
let state = match message.owner_team_id.as_deref() {
|
|
663
798
|
Some(team) if !team.is_empty() => {
|
|
664
|
-
match project_state_for_owner_team(
|
|
799
|
+
match project_state_for_owner_team(
|
|
800
|
+
workspace,
|
|
801
|
+
team,
|
|
802
|
+
state,
|
|
803
|
+
Some(&store),
|
|
804
|
+
Some(&message_id),
|
|
805
|
+
Some(event_log),
|
|
806
|
+
)? {
|
|
665
807
|
OwnerTeamProjection::Projected { state, .. } => {
|
|
666
808
|
scoped_state = state;
|
|
667
809
|
&scoped_state
|
|
668
810
|
}
|
|
669
|
-
OwnerTeamProjection::Refused(
|
|
811
|
+
OwnerTeamProjection::Refused(outcome) => {
|
|
812
|
+
let _ = event_log.write(
|
|
813
|
+
"delivery.projection_refused",
|
|
814
|
+
serde_json::json!({
|
|
815
|
+
"message_id": message_id.as_str(),
|
|
816
|
+
"owner_team_id": team,
|
|
817
|
+
"status": super::helpers::status_wire(outcome.status),
|
|
818
|
+
"verification": outcome.verification,
|
|
819
|
+
"reason": format!("{:?}", outcome.reason),
|
|
820
|
+
}),
|
|
821
|
+
);
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
670
824
|
}
|
|
671
825
|
}
|
|
672
826
|
_ => state,
|
|
@@ -684,7 +838,26 @@ pub fn deliver_pending_messages(
|
|
|
684
838
|
continue;
|
|
685
839
|
}
|
|
686
840
|
}
|
|
687
|
-
let outcome = deliver_pending_message(
|
|
841
|
+
let outcome = match deliver_pending_message(
|
|
842
|
+
workspace,
|
|
843
|
+
&store,
|
|
844
|
+
transport,
|
|
845
|
+
&message_id,
|
|
846
|
+
event_log,
|
|
847
|
+
state,
|
|
848
|
+
) {
|
|
849
|
+
Ok(outcome) => outcome,
|
|
850
|
+
Err(error) => {
|
|
851
|
+
let _ = event_log.write(
|
|
852
|
+
"delivery.item_blocked",
|
|
853
|
+
serde_json::json!({
|
|
854
|
+
"message_id": message_id.as_str(),
|
|
855
|
+
"error": error.to_string(),
|
|
856
|
+
}),
|
|
857
|
+
);
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
};
|
|
688
861
|
if outcome.ok {
|
|
689
862
|
delivered.push(message_id);
|
|
690
863
|
}
|
|
@@ -699,6 +872,7 @@ struct PendingMessage {
|
|
|
699
872
|
task_id: Option<String>,
|
|
700
873
|
owner_team_id: Option<String>,
|
|
701
874
|
status: String,
|
|
875
|
+
delivery_attempts: u32,
|
|
702
876
|
}
|
|
703
877
|
|
|
704
878
|
fn message_for_delivery(
|
|
@@ -708,7 +882,7 @@ fn message_for_delivery(
|
|
|
708
882
|
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
709
883
|
let message = conn
|
|
710
884
|
.query_row(
|
|
711
|
-
"select sender, recipient, content, task_id, owner_team_id, status from messages where message_id = ?1",
|
|
885
|
+
"select sender, recipient, content, task_id, owner_team_id, status, delivery_attempts from messages where message_id = ?1",
|
|
712
886
|
params![message_id],
|
|
713
887
|
|row| {
|
|
714
888
|
Ok(PendingMessage {
|
|
@@ -718,6 +892,7 @@ fn message_for_delivery(
|
|
|
718
892
|
task_id: row.get::<_, Option<String>>(3)?,
|
|
719
893
|
owner_team_id: row.get::<_, Option<String>>(4)?,
|
|
720
894
|
status: row.get::<_, String>(5)?,
|
|
895
|
+
delivery_attempts: row.get::<_, i64>(6)?.max(0) as u32,
|
|
721
896
|
})
|
|
722
897
|
},
|
|
723
898
|
)
|
|
@@ -725,6 +900,23 @@ fn message_for_delivery(
|
|
|
725
900
|
Ok(message)
|
|
726
901
|
}
|
|
727
902
|
|
|
903
|
+
fn bump_delivery_attempts(store: &MessageStore, message_id: &str) -> Result<u32, MessagingError> {
|
|
904
|
+
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
905
|
+
conn.execute(
|
|
906
|
+
"update messages
|
|
907
|
+
set delivery_attempts = delivery_attempts + 1,
|
|
908
|
+
updated_at = ?2
|
|
909
|
+
where message_id = ?1",
|
|
910
|
+
params![message_id, chrono::Utc::now().to_rfc3339()],
|
|
911
|
+
)?;
|
|
912
|
+
let attempts = conn.query_row(
|
|
913
|
+
"select delivery_attempts from messages where message_id = ?1",
|
|
914
|
+
params![message_id],
|
|
915
|
+
|row| row.get::<_, i64>(0),
|
|
916
|
+
)?;
|
|
917
|
+
Ok(attempts.max(0) as u32)
|
|
918
|
+
}
|
|
919
|
+
|
|
728
920
|
/// Pre-inject gate (Contract B): peek the recipient pane and answer "is there an
|
|
729
921
|
/// actionable provider startup prompt right now (trust menu or update prompt)" using
|
|
730
922
|
/// the SHARED provider/startup_prompt recognizers — no second classifier, no provider
|
|
@@ -744,7 +936,10 @@ fn recipient_pane_has_actionable_startup_prompt(
|
|
|
744
936
|
let provider = agent
|
|
745
937
|
.and_then(|agent| agent.get("provider"))
|
|
746
938
|
.and_then(serde_json::Value::as_str);
|
|
747
|
-
if !matches!(
|
|
939
|
+
if !matches!(
|
|
940
|
+
provider,
|
|
941
|
+
Some("codex" | "claude" | "claude_code" | "copilot")
|
|
942
|
+
) {
|
|
748
943
|
return false;
|
|
749
944
|
}
|
|
750
945
|
// step2-retry/scrollback root-cause (rt binary 6c9c6c1c): once the agent's
|
|
@@ -803,7 +998,11 @@ pub fn handle_trust_retry_needed(
|
|
|
803
998
|
event_log: &EventLog,
|
|
804
999
|
) -> Result<DeliveryOutcome, MessagingError> {
|
|
805
1000
|
if payload.attempt >= payload.max_attempts {
|
|
806
|
-
let _ = store.mark(
|
|
1001
|
+
let _ = store.mark(
|
|
1002
|
+
&payload.message_id,
|
|
1003
|
+
"failed",
|
|
1004
|
+
Some("trust_auto_answer_exhausted"),
|
|
1005
|
+
);
|
|
807
1006
|
event_log.write(
|
|
808
1007
|
"leader_panes.trust_auto_answer_exhausted",
|
|
809
1008
|
serde_json::json!({"message_id": payload.message_id, "attempt": payload.attempt}),
|
|
@@ -872,7 +1071,14 @@ pub fn execute_trust_retry(
|
|
|
872
1071
|
let _ = owner_team_id;
|
|
873
1072
|
let _ = store.mark(&payload.message_id, "accepted", None);
|
|
874
1073
|
let state = crate::state::persist::load_runtime_state(workspace)?;
|
|
875
|
-
deliver_pending_message(
|
|
1074
|
+
deliver_pending_message(
|
|
1075
|
+
workspace,
|
|
1076
|
+
store,
|
|
1077
|
+
transport,
|
|
1078
|
+
&payload.message_id,
|
|
1079
|
+
event_log,
|
|
1080
|
+
&state,
|
|
1081
|
+
)
|
|
876
1082
|
}
|
|
877
1083
|
|
|
878
1084
|
/// `_record_turn_open_if_leader_to_worker` (`delivery.py:430`):**take-over arm 来自真实投递**
|
|
@@ -887,12 +1093,7 @@ pub fn record_turn_open_if_leader_to_worker(
|
|
|
887
1093
|
) -> Result<(), MessagingError> {
|
|
888
1094
|
let _ = state;
|
|
889
1095
|
record_turn_open_if_leader_to_worker_scoped(
|
|
890
|
-
workspace,
|
|
891
|
-
sender,
|
|
892
|
-
recipient,
|
|
893
|
-
delivered,
|
|
894
|
-
event_log,
|
|
895
|
-
None,
|
|
1096
|
+
workspace, sender, recipient, delivered, event_log, None,
|
|
896
1097
|
)
|
|
897
1098
|
}
|
|
898
1099
|
|
|
@@ -957,7 +1158,11 @@ fn stamp_first_send_at_if_leader_to_worker_scoped(
|
|
|
957
1158
|
.and_then(|agents| agents.get_mut(recipient))
|
|
958
1159
|
.and_then(serde_json::Value::as_object_mut)
|
|
959
1160
|
{
|
|
960
|
-
if !agent.contains_key("first_send_at")
|
|
1161
|
+
if !agent.contains_key("first_send_at")
|
|
1162
|
+
|| agent
|
|
1163
|
+
.get("first_send_at")
|
|
1164
|
+
.is_some_and(serde_json::Value::is_null)
|
|
1165
|
+
{
|
|
961
1166
|
agent.insert("first_send_at".to_string(), serde_json::Value::String(now));
|
|
962
1167
|
save_scoped_state(workspace, &state, owner_team_id)?;
|
|
963
1168
|
}
|
|
@@ -992,7 +1197,11 @@ fn save_scoped_state(
|
|
|
992
1197
|
.and_then(serde_json::Value::as_object)
|
|
993
1198
|
.is_some_and(|teams| {
|
|
994
1199
|
owner_team_id
|
|
995
|
-
.and_then(|team|
|
|
1200
|
+
.and_then(|team| {
|
|
1201
|
+
crate::state::projection::resolve_owner_team_id(state, team)
|
|
1202
|
+
.canonical_key()
|
|
1203
|
+
.map(str::to_string)
|
|
1204
|
+
})
|
|
996
1205
|
.is_some_and(|team| teams.contains_key(&team))
|
|
997
1206
|
})
|
|
998
1207
|
{
|
|
@@ -1007,7 +1216,10 @@ fn save_scoped_state(
|
|
|
1007
1216
|
}
|
|
1008
1217
|
|
|
1009
1218
|
enum OwnerTeamProjection {
|
|
1010
|
-
Projected {
|
|
1219
|
+
Projected {
|
|
1220
|
+
state: serde_json::Value,
|
|
1221
|
+
canonical_team: String,
|
|
1222
|
+
},
|
|
1011
1223
|
Refused(DeliveryOutcome),
|
|
1012
1224
|
}
|
|
1013
1225
|
|
|
@@ -1025,9 +1237,15 @@ fn project_state_for_owner_team(
|
|
|
1025
1237
|
.and_then(serde_json::Value::as_object)
|
|
1026
1238
|
.is_some_and(|teams| !teams.is_empty());
|
|
1027
1239
|
let (mut projection_source, mut resolution) = if fallback_has_teams {
|
|
1028
|
-
(
|
|
1240
|
+
(
|
|
1241
|
+
fallback,
|
|
1242
|
+
crate::state::projection::resolve_owner_team_id(fallback, team),
|
|
1243
|
+
)
|
|
1029
1244
|
} else {
|
|
1030
|
-
(
|
|
1245
|
+
(
|
|
1246
|
+
&raw,
|
|
1247
|
+
crate::state::projection::resolve_owner_team_id(&raw, team),
|
|
1248
|
+
)
|
|
1031
1249
|
};
|
|
1032
1250
|
if !fallback_has_teams && matches!(resolution, OwnerTeamResolution::Unresolved { .. }) {
|
|
1033
1251
|
let fallback_resolution = crate::state::projection::resolve_owner_team_id(fallback, team);
|
|
@@ -1038,7 +1256,10 @@ fn project_state_for_owner_team(
|
|
|
1038
1256
|
}
|
|
1039
1257
|
let canonical_team = match resolution {
|
|
1040
1258
|
OwnerTeamResolution::Canonical(canonical) => canonical,
|
|
1041
|
-
OwnerTeamResolution::LegacyAlias {
|
|
1259
|
+
OwnerTeamResolution::LegacyAlias {
|
|
1260
|
+
requested,
|
|
1261
|
+
canonical,
|
|
1262
|
+
} => {
|
|
1042
1263
|
normalize_owner_team_id_rows(workspace, &requested, &canonical, message_id, event_log)?;
|
|
1043
1264
|
canonical
|
|
1044
1265
|
}
|
|
@@ -1088,10 +1309,15 @@ fn project_state_for_owner_team(
|
|
|
1088
1309
|
});
|
|
1089
1310
|
}
|
|
1090
1311
|
let mut state = project_state_for_owner_team_value(projection_source, &canonical_team)
|
|
1091
|
-
.ok_or_else(||
|
|
1312
|
+
.ok_or_else(|| {
|
|
1313
|
+
MessagingError::Routing(format!("owner_team_unresolved: {canonical_team}"))
|
|
1314
|
+
})?;
|
|
1092
1315
|
carry_top_level_leader_binding(&mut state, projection_source);
|
|
1093
1316
|
carry_top_level_leader_binding(&mut state, &raw);
|
|
1094
|
-
Ok(OwnerTeamProjection::Projected {
|
|
1317
|
+
Ok(OwnerTeamProjection::Projected {
|
|
1318
|
+
state,
|
|
1319
|
+
canonical_team,
|
|
1320
|
+
})
|
|
1095
1321
|
}
|
|
1096
1322
|
|
|
1097
1323
|
fn carry_top_level_leader_binding(projected: &mut serde_json::Value, raw: &serde_json::Value) {
|
|
@@ -1135,7 +1361,8 @@ pub(crate) fn normalize_owner_team_id_rows(
|
|
|
1135
1361
|
"result_watchers",
|
|
1136
1362
|
"leader_notification_log",
|
|
1137
1363
|
] {
|
|
1138
|
-
let sql =
|
|
1364
|
+
let sql =
|
|
1365
|
+
format!("update or ignore {table} set owner_team_id = ?1 where owner_team_id = ?2");
|
|
1139
1366
|
conn.execute(&sql, params![canonical, requested])?;
|
|
1140
1367
|
}
|
|
1141
1368
|
if let Some(event_log) = event_log {
|
|
@@ -1225,7 +1452,9 @@ pub fn retry_injection_after_trust_auto_answer(
|
|
|
1225
1452
|
provider: Provider,
|
|
1226
1453
|
event_log: &EventLog,
|
|
1227
1454
|
) -> Result<DeliveryOutcome, MessagingError> {
|
|
1228
|
-
let _ = (
|
|
1455
|
+
let _ = (
|
|
1456
|
+
workspace, state, transport, target, text, provider, event_log,
|
|
1457
|
+
);
|
|
1229
1458
|
Ok(DeliveryOutcome {
|
|
1230
1459
|
ok: false,
|
|
1231
1460
|
status: DeliveryStatus::RetryScheduled,
|