@team-agent/installer 0.5.49 → 0.5.51
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 +6 -4
- package/crates/team-agent/src/cli/emit.rs +91 -39
- package/crates/team-agent/src/cli/mod.rs +33 -15
- package/crates/team-agent/src/cli/named_address.rs +82 -53
- package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
- package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
- package/crates/team-agent/src/cli/send/persist.rs +154 -0
- package/crates/team-agent/src/cli/send/presentation.rs +333 -0
- package/crates/team-agent/src/cli/send/resolve.rs +361 -0
- package/crates/team-agent/src/cli/send.rs +103 -1308
- package/crates/team-agent/src/cli/spec.rs +2 -2
- package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
- package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
- package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
- package/crates/team-agent/src/cli/status_port/format.rs +145 -0
- package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
- package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
- package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
- package/crates/team-agent/src/cli/status_port/store.rs +412 -0
- package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
- package/crates/team-agent/src/cli/status_port.rs +47 -1548
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -1
- package/crates/team-agent/src/cli/tests/named_address.rs +9 -7
- package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -3
- package/crates/team-agent/src/cli/tests/status_send.rs +17 -33
- package/crates/team-agent/src/cli/types.rs +5 -8
- package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
- package/crates/team-agent/src/coordinator/tick.rs +37 -0
- package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
- package/crates/team-agent/src/db/message_store.rs +154 -44
- package/crates/team-agent/src/event_log.rs +73 -0
- package/crates/team-agent/src/leader/start.rs +28 -4
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +297 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
- package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
- package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
- package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
- package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
- package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
- package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
- package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -26
- package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +67 -26
- package/crates/team-agent/src/lifecycle/restart/remove.rs +435 -72
- package/crates/team-agent/src/lifecycle/restart.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +575 -17
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +55 -2
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -1
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
- package/crates/team-agent/src/mcp_server/mod.rs +2 -2
- package/crates/team-agent/src/mcp_server/tests/send.rs +22 -15
- package/crates/team-agent/src/mcp_server/tests/wire.rs +6 -0
- package/crates/team-agent/src/mcp_server/tools.rs +26 -15
- package/crates/team-agent/src/mcp_server/wire.rs +2 -18
- package/crates/team-agent/src/messaging/activity.rs +4 -2
- package/crates/team-agent/src/messaging/address.rs +86 -0
- package/crates/team-agent/src/messaging/delivery.rs +165 -39
- package/crates/team-agent/src/messaging/helpers.rs +17 -13
- package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
- package/crates/team-agent/src/messaging/mod.rs +11 -2
- package/crates/team-agent/src/messaging/persist.rs +309 -0
- package/crates/team-agent/src/messaging/results.rs +16 -24
- package/crates/team-agent/src/messaging/scheduler.rs +4 -2
- package/crates/team-agent/src/messaging/selftest.rs +19 -12
- package/crates/team-agent/src/messaging/send.rs +133 -58
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
- package/crates/team-agent/src/messaging/watchers.rs +13 -3
- package/crates/team-agent/src/redaction.rs +72 -2
- package/crates/team-agent/src/state/persist.rs +2 -1
- package/crates/team-agent/src/state/repository/tests.rs +47 -0
- package/crates/team-agent/src/state/repository.rs +59 -16
- package/package.json +4 -4
|
@@ -5,12 +5,16 @@ use std::path::Path;
|
|
|
5
5
|
use crate::coordinator::{CoordinatorHealthStatus, WorkspacePath};
|
|
6
6
|
use crate::event_log::EventLog;
|
|
7
7
|
use crate::model::enums::PaneLiveness;
|
|
8
|
-
use crate::model::ids::{TaskId, TeamKey};
|
|
8
|
+
use crate::model::ids::{AgentId, TaskId, TeamKey};
|
|
9
9
|
use crate::transport::{PaneId, Transport};
|
|
10
10
|
|
|
11
11
|
use super::helpers::{status_wire, MessageStatusShadow};
|
|
12
12
|
use super::leader_receiver::{send_to_leader_receiver, send_to_leader_receiver_with_message_id};
|
|
13
|
-
use super::{
|
|
13
|
+
use super::{
|
|
14
|
+
persist_resolved_send, DeliveryBlocker, DeliveryOutcome, DeliveryRefusal, DeliveryStatus,
|
|
15
|
+
InitialDisposition, LogicalRecipient, MessagingError, PersistResolution, ResolvedSendIntent,
|
|
16
|
+
SendOrigin,
|
|
17
|
+
};
|
|
14
18
|
|
|
15
19
|
/// 发件目标:单 target / 广播 `*` / 扇出 list (`send.py:36` `target: str|list[str]|None`)。
|
|
16
20
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
@@ -23,15 +27,39 @@ pub enum MessageTarget {
|
|
|
23
27
|
Fanout(Vec<String>),
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
/// Sender identity captured from a framework-owned runtime context.
|
|
31
|
+
///
|
|
32
|
+
/// Public CLI/MCP inputs never construct this value from a caller-supplied
|
|
33
|
+
/// string. The wrapper keeps that trust boundary visible throughout delivery
|
|
34
|
+
/// instead of degrading the identity back to an untyped option field.
|
|
35
|
+
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
|
36
|
+
#[serde(transparent)]
|
|
37
|
+
pub struct TrustedSender(AgentId);
|
|
38
|
+
|
|
39
|
+
impl TrustedSender {
|
|
40
|
+
pub fn from_runtime_identity(agent_id: AgentId) -> Self {
|
|
41
|
+
Self(agent_id)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
pub fn leader() -> Self {
|
|
45
|
+
Self(AgentId::new("leader"))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
pub fn as_str(&self) -> &str {
|
|
49
|
+
self.0.as_str()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
26
53
|
/// `send_message` 选项 (`send.py:36`:Python 大量默认参数 → typed 选项 struct)。
|
|
27
54
|
#[derive(Debug, Clone)]
|
|
28
55
|
pub struct SendOptions {
|
|
56
|
+
pub origin: SendOrigin,
|
|
29
57
|
pub task_id: Option<TaskId>,
|
|
30
58
|
/// `route_task_id`(golden `send.py:190`,默认 `True`):仅 **routing**(CLI `send --task`)时把
|
|
31
59
|
/// `task_id` 当真任务校验/路由。**投递/fanout/internal/coordinator** 路径传 `false`
|
|
32
60
|
/// (`internal_delivery.py:44`、`send.py:412/481`),此时 `task_id` 只是标签,**不校验 state.tasks**。
|
|
33
61
|
pub route_task_id: bool,
|
|
34
|
-
pub sender:
|
|
62
|
+
pub sender: TrustedSender,
|
|
35
63
|
pub requires_ack: bool,
|
|
36
64
|
pub confirm_human: bool,
|
|
37
65
|
pub wait_visible: bool,
|
|
@@ -50,9 +78,10 @@ impl Default for SendOptions {
|
|
|
50
78
|
fn default() -> Self {
|
|
51
79
|
// 默认值对齐 Python 签名 (`send.py:38-40`)。
|
|
52
80
|
Self {
|
|
81
|
+
origin: SendOrigin::Internal(super::InternalSendKind::Delivery),
|
|
53
82
|
task_id: None,
|
|
54
83
|
route_task_id: true,
|
|
55
|
-
sender:
|
|
84
|
+
sender: TrustedSender::leader(),
|
|
56
85
|
requires_ack: true,
|
|
57
86
|
confirm_human: false,
|
|
58
87
|
wait_visible: true,
|
|
@@ -95,7 +124,7 @@ pub fn send_message(
|
|
|
95
124
|
"leader",
|
|
96
125
|
content,
|
|
97
126
|
opts.task_id.as_ref(),
|
|
98
|
-
|
|
127
|
+
opts.sender.as_str(),
|
|
99
128
|
opts.requires_ack,
|
|
100
129
|
None,
|
|
101
130
|
opts.message_id.as_deref(),
|
|
@@ -119,7 +148,7 @@ pub fn send_message(
|
|
|
119
148
|
}
|
|
120
149
|
MessageTarget::Single(target) => target,
|
|
121
150
|
MessageTarget::Broadcast => {
|
|
122
|
-
let recipients = broadcast_recipients(&state,
|
|
151
|
+
let recipients = broadcast_recipients(&state, opts.sender.as_str(), opts.team.as_ref());
|
|
123
152
|
return fanout_send(
|
|
124
153
|
workspace,
|
|
125
154
|
&state,
|
|
@@ -163,13 +192,13 @@ pub fn send_message(
|
|
|
163
192
|
&state,
|
|
164
193
|
recipient,
|
|
165
194
|
"leader",
|
|
166
|
-
|
|
195
|
+
opts.sender.as_str(),
|
|
167
196
|
opts.task_id.as_ref(),
|
|
168
197
|
&event_log,
|
|
169
198
|
)? {
|
|
170
199
|
return Ok(outcome);
|
|
171
200
|
}
|
|
172
|
-
if let Some(outcome) = send_owner_gate_refusal(workspace, &state,
|
|
201
|
+
if let Some(outcome) = send_owner_gate_refusal(workspace, &state, opts.sender.as_str())? {
|
|
173
202
|
return Ok(outcome);
|
|
174
203
|
}
|
|
175
204
|
if opts.route_task_id {
|
|
@@ -184,46 +213,47 @@ pub fn send_message(
|
|
|
184
213
|
}
|
|
185
214
|
let stale_worker_target_missing =
|
|
186
215
|
stale_worker_target_missing_preflight(workspace, &state, recipient)?;
|
|
187
|
-
if
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
216
|
+
let coordinator_unavailable = if stale_worker_target_missing {
|
|
217
|
+
None
|
|
218
|
+
} else {
|
|
219
|
+
coordinator_unavailable_outcome(workspace, recipient, opts, &event_log)?
|
|
220
|
+
};
|
|
221
|
+
if let Some(gate) = coordinator_unavailable
|
|
222
|
+
.as_ref()
|
|
223
|
+
.filter(|gate| !gate.persist)
|
|
224
|
+
{
|
|
225
|
+
return Ok(gate.outcome.clone());
|
|
193
226
|
}
|
|
194
|
-
let
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
227
|
+
let mut intent = ResolvedSendIntent::accepted(
|
|
228
|
+
opts.origin,
|
|
229
|
+
workspace,
|
|
230
|
+
opts.team.clone(),
|
|
231
|
+
LogicalRecipient::from_resolved(recipient),
|
|
232
|
+
opts.sender.clone(),
|
|
233
|
+
opts.task_id.clone(),
|
|
234
|
+
content,
|
|
235
|
+
None,
|
|
236
|
+
opts.requires_ack,
|
|
237
|
+
opts.message_id.clone(),
|
|
238
|
+
);
|
|
239
|
+
if coordinator_unavailable
|
|
240
|
+
.as_ref()
|
|
241
|
+
.is_some_and(|gate| gate.persist)
|
|
242
|
+
{
|
|
243
|
+
intent.initial_disposition =
|
|
244
|
+
InitialDisposition::Blocked(DeliveryBlocker::CoordinatorUnavailable);
|
|
245
|
+
}
|
|
246
|
+
let persisted = match persist_resolved_send(&intent)? {
|
|
247
|
+
PersistResolution::Persisted(persisted) => persisted,
|
|
248
|
+
PersistResolution::Duplicate(message_id) => {
|
|
201
249
|
return Ok(refused_outcome_with_id(
|
|
202
250
|
DeliveryRefusal::Duplicate,
|
|
203
|
-
Some(
|
|
251
|
+
Some(message_id),
|
|
204
252
|
));
|
|
205
253
|
}
|
|
206
|
-
store.create_message_with_id(
|
|
207
|
-
requested,
|
|
208
|
-
task_id,
|
|
209
|
-
&opts.sender,
|
|
210
|
-
recipient,
|
|
211
|
-
content,
|
|
212
|
-
None,
|
|
213
|
-
opts.requires_ack,
|
|
214
|
-
owner_team_id,
|
|
215
|
-
)?
|
|
216
|
-
} else {
|
|
217
|
-
store.create_message(
|
|
218
|
-
task_id,
|
|
219
|
-
&opts.sender,
|
|
220
|
-
recipient,
|
|
221
|
-
content,
|
|
222
|
-
None,
|
|
223
|
-
opts.requires_ack,
|
|
224
|
-
owner_team_id,
|
|
225
|
-
)?
|
|
226
254
|
};
|
|
255
|
+
let message_id = persisted.message_id.clone();
|
|
256
|
+
let store = crate::message_store::MessageStore::open(workspace)?;
|
|
227
257
|
if stale_worker_target_missing {
|
|
228
258
|
return super::delivery::mark_worker_target_missing(
|
|
229
259
|
&store,
|
|
@@ -233,10 +263,37 @@ pub fn send_message(
|
|
|
233
263
|
None,
|
|
234
264
|
);
|
|
235
265
|
}
|
|
266
|
+
if let Some(CoordinatorUnavailableGate {
|
|
267
|
+
mut outcome,
|
|
268
|
+
persist: true,
|
|
269
|
+
}) = coordinator_unavailable
|
|
270
|
+
{
|
|
271
|
+
let Some(blocker) = persisted.blocker else {
|
|
272
|
+
return Err(MessagingError::Validation(
|
|
273
|
+
"coordinator-blocked persistence returned no blocker".to_string(),
|
|
274
|
+
));
|
|
275
|
+
};
|
|
276
|
+
let message_status = persisted.row_status.as_str();
|
|
277
|
+
event_log.write(
|
|
278
|
+
"send.message_queued",
|
|
279
|
+
serde_json::json!({
|
|
280
|
+
"message_id": message_id,
|
|
281
|
+
"recipient": recipient,
|
|
282
|
+
"sender": opts.sender,
|
|
283
|
+
"message_status": message_status,
|
|
284
|
+
"blocker": blocker.as_str(),
|
|
285
|
+
}),
|
|
286
|
+
)?;
|
|
287
|
+
outcome.ok = true;
|
|
288
|
+
outcome.status = DeliveryStatus::Blocked;
|
|
289
|
+
outcome.message_status = MessageStatusShadow(message_status.to_string());
|
|
290
|
+
outcome.message_id = Some(message_id);
|
|
291
|
+
return Ok(outcome);
|
|
292
|
+
}
|
|
236
293
|
Ok(DeliveryOutcome {
|
|
237
294
|
ok: true,
|
|
238
295
|
status: DeliveryStatus::Queued,
|
|
239
|
-
message_status: MessageStatusShadow(
|
|
296
|
+
message_status: MessageStatusShadow(persisted.row_status.as_str().to_string()),
|
|
240
297
|
message_id: Some(message_id),
|
|
241
298
|
verification: None,
|
|
242
299
|
stage: None,
|
|
@@ -308,12 +365,17 @@ fn refused_outcome_with_verification(
|
|
|
308
365
|
}
|
|
309
366
|
}
|
|
310
367
|
|
|
368
|
+
struct CoordinatorUnavailableGate {
|
|
369
|
+
outcome: DeliveryOutcome,
|
|
370
|
+
persist: bool,
|
|
371
|
+
}
|
|
372
|
+
|
|
311
373
|
fn coordinator_unavailable_outcome(
|
|
312
374
|
workspace: &Path,
|
|
313
375
|
recipient: &str,
|
|
314
376
|
opts: &SendOptions,
|
|
315
377
|
event_log: &EventLog,
|
|
316
|
-
) -> Result<Option<
|
|
378
|
+
) -> Result<Option<CoordinatorUnavailableGate>, MessagingError> {
|
|
317
379
|
let coordinator_workspace = WorkspacePath::new(workspace.to_path_buf());
|
|
318
380
|
let health = crate::coordinator::coordinator_health(&coordinator_workspace);
|
|
319
381
|
if health.ok || matches!(health.status, CoordinatorHealthStatus::Missing) {
|
|
@@ -343,9 +405,19 @@ fn coordinator_unavailable_outcome(
|
|
|
343
405
|
)?;
|
|
344
406
|
return Ok(None);
|
|
345
407
|
}
|
|
346
|
-
let
|
|
347
|
-
|
|
408
|
+
let persist = !matches!(
|
|
409
|
+
health.metadata_mismatch_reason.as_deref(),
|
|
410
|
+
Some("protocol_version_mismatch" | "message_store_schema_version_mismatch")
|
|
348
411
|
);
|
|
412
|
+
let warning = if persist {
|
|
413
|
+
format!(
|
|
414
|
+
"coordinator is not running; message was queued for {recipient} and will retry. Run `team-agent diagnose` or restart the team."
|
|
415
|
+
)
|
|
416
|
+
} else {
|
|
417
|
+
format!(
|
|
418
|
+
"coordinator protocol or schema is incompatible; message was not queued for {recipient}. Run `team-agent diagnose` or upgrade the runtime."
|
|
419
|
+
)
|
|
420
|
+
};
|
|
349
421
|
event_log.write(
|
|
350
422
|
"send.coordinator_unavailable",
|
|
351
423
|
serde_json::json!({
|
|
@@ -354,22 +426,25 @@ fn coordinator_unavailable_outcome(
|
|
|
354
426
|
"coordinator_status": health.status,
|
|
355
427
|
"coordinator_pid": health.pid.map(|pid| pid.get()),
|
|
356
428
|
"metadata_mismatch_reason": health.metadata_mismatch_reason,
|
|
357
|
-
"message_queued":
|
|
429
|
+
"message_queued": persist,
|
|
358
430
|
"warning": warning,
|
|
359
431
|
"coordinator_log": crate::coordinator::coordinator_log_path(&coordinator_workspace)
|
|
360
432
|
.display()
|
|
361
433
|
.to_string(),
|
|
362
434
|
}),
|
|
363
435
|
)?;
|
|
364
|
-
Ok(Some(
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
436
|
+
Ok(Some(CoordinatorUnavailableGate {
|
|
437
|
+
persist,
|
|
438
|
+
outcome: DeliveryOutcome {
|
|
439
|
+
ok: false,
|
|
440
|
+
status: DeliveryStatus::Degraded,
|
|
441
|
+
message_status: MessageStatusShadow("degraded".to_string()),
|
|
442
|
+
message_id: None,
|
|
443
|
+
verification: Some(warning),
|
|
444
|
+
stage: None,
|
|
445
|
+
reason: Some(DeliveryRefusal::CoordinatorUnavailable),
|
|
446
|
+
channel: Some("coordinator_unavailable".to_string()),
|
|
447
|
+
},
|
|
373
448
|
}))
|
|
374
449
|
}
|
|
375
450
|
|
|
@@ -690,7 +765,7 @@ fn fanout_send(
|
|
|
690
765
|
let mut delivered_count = 0usize;
|
|
691
766
|
let mut attempted_count = 0usize;
|
|
692
767
|
for recipient in recipients {
|
|
693
|
-
if recipient.is_empty() || recipient ==
|
|
768
|
+
if recipient.is_empty() || recipient == opts.sender.as_str() {
|
|
694
769
|
continue;
|
|
695
770
|
}
|
|
696
771
|
attempted_count = attempted_count.saturating_add(1);
|
|
@@ -701,7 +776,7 @@ fn fanout_send(
|
|
|
701
776
|
recipient,
|
|
702
777
|
content,
|
|
703
778
|
opts.task_id.as_ref(),
|
|
704
|
-
|
|
779
|
+
opts.sender.as_str(),
|
|
705
780
|
opts.requires_ack,
|
|
706
781
|
None,
|
|
707
782
|
event_log,
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
//! Unit regressions for leader injection receipt acceptance.
|
|
2
|
+
//!
|
|
3
|
+
//! These fixtures intentionally seed DB/state/transcript internals. Per MUST-15,
|
|
4
|
+
//! that synthetic setup belongs only in src/**/tests and is not acceptance proof.
|
|
5
|
+
|
|
6
|
+
use super::*;
|
|
7
|
+
use std::collections::BTreeMap;
|
|
8
|
+
use std::path::{Path, PathBuf};
|
|
9
|
+
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
10
|
+
use std::sync::{Arc, Mutex};
|
|
11
|
+
|
|
12
|
+
use crate::event_log::EventLog;
|
|
13
|
+
use crate::message_store::MessageStore;
|
|
14
|
+
use crate::transport::{
|
|
15
|
+
AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport,
|
|
16
|
+
InjectStage, InjectVerification, Key, PaneField, PaneId, PaneInfo, PaneLiveness, SessionName,
|
|
17
|
+
SetEnvOutcome, SpawnResult, SubmitVerification, Target, Transport, TransportError,
|
|
18
|
+
TurnVerification, WindowName,
|
|
19
|
+
};
|
|
20
|
+
use rusqlite::params;
|
|
21
|
+
|
|
22
|
+
#[test]
|
|
23
|
+
fn tmux_leader_submit_without_provider_receipt_stays_pending_then_accepts_same_row() {
|
|
24
|
+
let case = Case::new("receipt");
|
|
25
|
+
let transport = RecordingTransport::new("");
|
|
26
|
+
let message_id = case.seed_message("receipt canary");
|
|
27
|
+
|
|
28
|
+
let first = deliver_pending_message(
|
|
29
|
+
&case.workspace,
|
|
30
|
+
&case.store,
|
|
31
|
+
&transport,
|
|
32
|
+
&message_id,
|
|
33
|
+
&case.event_log,
|
|
34
|
+
&case.state,
|
|
35
|
+
)
|
|
36
|
+
.expect("first delivery attempt");
|
|
37
|
+
|
|
38
|
+
assert!(
|
|
39
|
+
first.ok,
|
|
40
|
+
"verified transport submission is accepted even while provider receipt is pending: {first:?}"
|
|
41
|
+
);
|
|
42
|
+
assert_eq!(
|
|
43
|
+
case.message_status(&message_id),
|
|
44
|
+
"submitted_pending_acceptance"
|
|
45
|
+
);
|
|
46
|
+
assert_eq!(transport.inject_count(), 1);
|
|
47
|
+
assert!(
|
|
48
|
+
case.events().contains("leader_receiver.acceptance_pending"),
|
|
49
|
+
"missing receipt must be observable"
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
std::fs::write(
|
|
53
|
+
&case.rollout,
|
|
54
|
+
format!(r#"{{"type":"user","message":"[team-agent-token:{message_id}]"}}"#),
|
|
55
|
+
)
|
|
56
|
+
.unwrap();
|
|
57
|
+
let second = deliver_pending_message(
|
|
58
|
+
&case.workspace,
|
|
59
|
+
&case.store,
|
|
60
|
+
&transport,
|
|
61
|
+
&message_id,
|
|
62
|
+
&case.event_log,
|
|
63
|
+
&case.state,
|
|
64
|
+
)
|
|
65
|
+
.expect("receipt observation");
|
|
66
|
+
|
|
67
|
+
assert!(
|
|
68
|
+
second.ok,
|
|
69
|
+
"provider receipt advances the same row: {second:?}"
|
|
70
|
+
);
|
|
71
|
+
assert_eq!(case.message_status(&message_id), "delivered");
|
|
72
|
+
assert_eq!(
|
|
73
|
+
transport.inject_count(),
|
|
74
|
+
1,
|
|
75
|
+
"receipt observation must not inject the message a second time"
|
|
76
|
+
);
|
|
77
|
+
assert!(case.delivery_consumed_at(&message_id).is_some());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#[test]
|
|
81
|
+
fn attached_leader_mailbox_is_rechecked_by_the_normal_delivery_tick() {
|
|
82
|
+
let case = Case::new("mailbox");
|
|
83
|
+
let transport = RecordingTransport::new("");
|
|
84
|
+
let message_id = case.seed_message("mailbox canary");
|
|
85
|
+
case.store
|
|
86
|
+
.mark(&message_id, "queued_until_leader_attach", None)
|
|
87
|
+
.unwrap();
|
|
88
|
+
|
|
89
|
+
let delivered =
|
|
90
|
+
deliver_pending_messages(&case.workspace, &case.state, &transport, &case.event_log)
|
|
91
|
+
.expect("normal coordinator delivery pass");
|
|
92
|
+
|
|
93
|
+
assert!(delivered.is_empty(), "provider receipt is still pending");
|
|
94
|
+
assert_eq!(
|
|
95
|
+
case.message_status(&message_id),
|
|
96
|
+
"submitted_pending_acceptance"
|
|
97
|
+
);
|
|
98
|
+
assert_eq!(transport.inject_count(), 1);
|
|
99
|
+
assert!(case.events().contains("leader_receiver.mailbox_requeued"));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
struct Case {
|
|
103
|
+
workspace: PathBuf,
|
|
104
|
+
rollout: PathBuf,
|
|
105
|
+
store: MessageStore,
|
|
106
|
+
event_log: EventLog,
|
|
107
|
+
state: serde_json::Value,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
impl Case {
|
|
111
|
+
fn new(tag: &str) -> Self {
|
|
112
|
+
let workspace = tmp_ws(&format!("leader-inject-acceptance-{tag}"));
|
|
113
|
+
let rollout = workspace.join("leader.jsonl");
|
|
114
|
+
std::fs::write(&rollout, "").unwrap();
|
|
115
|
+
let state = serde_json::json!({
|
|
116
|
+
"active_team_key": "acceptance-team",
|
|
117
|
+
"session_name": "team-acceptance",
|
|
118
|
+
"leader_receiver": {
|
|
119
|
+
"pane_id": "%leader",
|
|
120
|
+
"status": "attached",
|
|
121
|
+
"provider": "claude",
|
|
122
|
+
"rollout_path": rollout,
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
|
|
126
|
+
let store = MessageStore::open(&workspace).unwrap();
|
|
127
|
+
let event_log = EventLog::new(&workspace);
|
|
128
|
+
Self {
|
|
129
|
+
workspace,
|
|
130
|
+
rollout,
|
|
131
|
+
store,
|
|
132
|
+
event_log,
|
|
133
|
+
state,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fn seed_message(&self, content: &str) -> String {
|
|
138
|
+
self.store
|
|
139
|
+
.create_message(None, "worker", "leader", content, None, false, None)
|
|
140
|
+
.unwrap()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
fn message_status(&self, message_id: &str) -> String {
|
|
144
|
+
let conn = crate::db::schema::open_db(self.store.db_path()).unwrap();
|
|
145
|
+
conn.query_row(
|
|
146
|
+
"select status from messages where message_id = ?1",
|
|
147
|
+
[message_id],
|
|
148
|
+
|row| row.get(0),
|
|
149
|
+
)
|
|
150
|
+
.unwrap()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
fn delivery_consumed_at(&self, message_id: &str) -> Option<String> {
|
|
154
|
+
let conn = crate::db::schema::open_db(self.store.db_path()).unwrap();
|
|
155
|
+
conn.query_row(
|
|
156
|
+
"select consumed_at from delivery_tokens where message_id = ?1",
|
|
157
|
+
params![message_id],
|
|
158
|
+
|row| row.get(0),
|
|
159
|
+
)
|
|
160
|
+
.unwrap()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
fn events(&self) -> String {
|
|
164
|
+
self.event_log
|
|
165
|
+
.tail(0)
|
|
166
|
+
.unwrap()
|
|
167
|
+
.into_iter()
|
|
168
|
+
.map(|event| event.to_string())
|
|
169
|
+
.collect::<Vec<_>>()
|
|
170
|
+
.join("\n")
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#[derive(Clone)]
|
|
175
|
+
struct RecordingTransport {
|
|
176
|
+
injects: Arc<AtomicUsize>,
|
|
177
|
+
capture: Arc<Mutex<String>>,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
impl RecordingTransport {
|
|
181
|
+
fn new(capture: &str) -> Self {
|
|
182
|
+
Self {
|
|
183
|
+
injects: Arc::new(AtomicUsize::new(0)),
|
|
184
|
+
capture: Arc::new(Mutex::new(capture.to_string())),
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
fn inject_count(&self) -> usize {
|
|
189
|
+
self.injects.load(Ordering::Relaxed)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
impl Transport for RecordingTransport {
|
|
194
|
+
fn kind(&self) -> BackendKind {
|
|
195
|
+
BackendKind::Tmux
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
fn spawn_first(
|
|
199
|
+
&self,
|
|
200
|
+
_session: &SessionName,
|
|
201
|
+
_window: &WindowName,
|
|
202
|
+
_argv: &[String],
|
|
203
|
+
_cwd: &Path,
|
|
204
|
+
_env: &BTreeMap<String, String>,
|
|
205
|
+
) -> Result<SpawnResult, TransportError> {
|
|
206
|
+
unreachable!("delivery contract does not spawn")
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
fn spawn_into(
|
|
210
|
+
&self,
|
|
211
|
+
_session: &SessionName,
|
|
212
|
+
_window: &WindowName,
|
|
213
|
+
_argv: &[String],
|
|
214
|
+
_cwd: &Path,
|
|
215
|
+
_env: &BTreeMap<String, String>,
|
|
216
|
+
) -> Result<SpawnResult, TransportError> {
|
|
217
|
+
unreachable!("delivery contract does not spawn")
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
fn inject(
|
|
221
|
+
&self,
|
|
222
|
+
_target: &Target,
|
|
223
|
+
_payload: &InjectPayload,
|
|
224
|
+
_submit: Key,
|
|
225
|
+
_bracketed_paste: bool,
|
|
226
|
+
) -> Result<InjectReport, TransportError> {
|
|
227
|
+
self.injects.fetch_add(1, Ordering::Relaxed);
|
|
228
|
+
Ok(InjectReport {
|
|
229
|
+
stage_reached: InjectStage::Submit,
|
|
230
|
+
inject_verification: InjectVerification::CaptureContainsToken,
|
|
231
|
+
submit_verification: SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
232
|
+
turn_verification: TurnVerification::NotYetObserved,
|
|
233
|
+
attempts: 1,
|
|
234
|
+
submit_diagnostics: None,
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
fn send_keys(&self, _target: &Target, _keys: &[Key]) -> Result<(), TransportError> {
|
|
239
|
+
Ok(())
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
fn capture(
|
|
243
|
+
&self,
|
|
244
|
+
_target: &Target,
|
|
245
|
+
range: CaptureRange,
|
|
246
|
+
) -> Result<CapturedText, TransportError> {
|
|
247
|
+
Ok(CapturedText {
|
|
248
|
+
text: self.capture.lock().unwrap().clone(),
|
|
249
|
+
range,
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
fn query(&self, _target: &Target, _field: PaneField) -> Result<Option<String>, TransportError> {
|
|
254
|
+
Ok(None)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
fn liveness(&self, _pane: &PaneId) -> Result<PaneLiveness, TransportError> {
|
|
258
|
+
Ok(PaneLiveness::Live)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
262
|
+
Ok(vec![PaneInfo {
|
|
263
|
+
pane_id: PaneId::new("%leader"),
|
|
264
|
+
session: SessionName::new("team-acceptance"),
|
|
265
|
+
window_index: None,
|
|
266
|
+
window_name: Some(WindowName::new("leader")),
|
|
267
|
+
pane_index: None,
|
|
268
|
+
tty: None,
|
|
269
|
+
current_command: Some("claude".to_string()),
|
|
270
|
+
current_path: None,
|
|
271
|
+
active: true,
|
|
272
|
+
pane_pid: None,
|
|
273
|
+
leader_env: BTreeMap::new(),
|
|
274
|
+
}])
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
fn has_session(&self, _session: &SessionName) -> Result<bool, TransportError> {
|
|
278
|
+
Ok(true)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
|
|
282
|
+
Ok(vec![WindowName::new("leader")])
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
fn set_session_env(
|
|
286
|
+
&self,
|
|
287
|
+
_session: &SessionName,
|
|
288
|
+
_key: &str,
|
|
289
|
+
_value: &str,
|
|
290
|
+
) -> Result<SetEnvOutcome, TransportError> {
|
|
291
|
+
Ok(SetEnvOutcome::Applied)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
fn kill_session(&self, _session: &SessionName) -> Result<(), TransportError> {
|
|
295
|
+
Ok(())
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
fn kill_window(&self, _target: &Target) -> Result<(), TransportError> {
|
|
299
|
+
Ok(())
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
fn attach_session(&self, _session: &SessionName) -> Result<AttachOutcome, TransportError> {
|
|
303
|
+
Ok(AttachOutcome::Attached)
|
|
304
|
+
}
|
|
305
|
+
}
|