@team-agent/installer 0.5.5 → 0.5.7
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/mod.rs +130 -1
- package/crates/team-agent/src/cli/send.rs +95 -5
- package/crates/team-agent/src/cli/tests/main_preserved.rs +54 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +109 -2
- package/crates/team-agent/src/lifecycle/restart/remove.rs +94 -15
- package/crates/team-agent/src/lifecycle/restart.rs +1 -1
- package/crates/team-agent/src/lifecycle/types.rs +15 -0
- package/crates/team-agent/src/mcp_server/helpers.rs +144 -34
- package/crates/team-agent/src/mcp_server/mod.rs +3 -3
- package/crates/team-agent/src/mcp_server/tests/tools.rs +128 -0
- package/crates/team-agent/src/mcp_server/tools.rs +6 -7
- package/crates/team-agent/src/messaging/delivery.rs +46 -1
- package/crates/team-agent/src/messaging/tests/runtime.rs +11 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -2314,12 +2314,29 @@ pub mod lifecycle_port {
|
|
|
2314
2314
|
force: bool,
|
|
2315
2315
|
team: Option<&str>,
|
|
2316
2316
|
) -> Result<Value, CliError> {
|
|
2317
|
+
let agent_id = crate::model::ids::AgentId::new(agent);
|
|
2318
|
+
match crate::lifecycle::remove_agent_flag_requirements(workspace, &agent_id, team) {
|
|
2319
|
+
Ok(requirements) => {
|
|
2320
|
+
if !remove_agent_missing_flags(from_spec, confirm, force, &requirements).is_empty() {
|
|
2321
|
+
return Ok(remove_agent_flag_refusal(
|
|
2322
|
+
workspace,
|
|
2323
|
+
agent,
|
|
2324
|
+
team,
|
|
2325
|
+
from_spec,
|
|
2326
|
+
confirm,
|
|
2327
|
+
force,
|
|
2328
|
+
&requirements,
|
|
2329
|
+
));
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
Err(error) if confirm => return Ok(error_value(error)),
|
|
2333
|
+
Err(_) => {}
|
|
2334
|
+
}
|
|
2317
2335
|
if !confirm {
|
|
2318
2336
|
return Ok(
|
|
2319
2337
|
json!({"ok": false, "agent_id": agent, "error": "remove-agent requires --confirm"}),
|
|
2320
2338
|
);
|
|
2321
2339
|
}
|
|
2322
|
-
let agent_id = crate::model::ids::AgentId::new(agent);
|
|
2323
2340
|
match crate::lifecycle::remove_agent(workspace, &agent_id, from_spec, force, team) {
|
|
2324
2341
|
Ok(report @ crate::lifecycle::RemoveAgentOutcome::Removed { .. }) => Ok(json!({
|
|
2325
2342
|
"ok": true,
|
|
@@ -2343,9 +2360,121 @@ pub mod lifecycle_port {
|
|
|
2343
2360
|
"error": "agent is running; remove-agent requires --force",
|
|
2344
2361
|
"action": "rerun with --force to stop and remove the running agent",
|
|
2345
2362
|
})),
|
|
2363
|
+
Ok(crate::lifecycle::RemoveAgentOutcome::RefusedRequiredFlags { .. }) => Ok(json!({
|
|
2364
|
+
"ok": false,
|
|
2365
|
+
"agent_id": agent,
|
|
2366
|
+
"status": "refused",
|
|
2367
|
+
"reason": "remove_agent_flags_required",
|
|
2368
|
+
"error": "remove-agent required flags changed; rerun the command from the latest refusal",
|
|
2369
|
+
})),
|
|
2346
2370
|
Err(e) => Ok(error_value(e)),
|
|
2347
2371
|
}
|
|
2348
2372
|
}
|
|
2373
|
+
|
|
2374
|
+
fn remove_agent_flag_refusal(
|
|
2375
|
+
workspace: &Path,
|
|
2376
|
+
agent: &str,
|
|
2377
|
+
team: Option<&str>,
|
|
2378
|
+
from_spec: bool,
|
|
2379
|
+
confirm: bool,
|
|
2380
|
+
force: bool,
|
|
2381
|
+
requirements: &crate::lifecycle::RemoveAgentFlagRequirements,
|
|
2382
|
+
) -> Value {
|
|
2383
|
+
let required_flags = remove_agent_required_flags(requirements);
|
|
2384
|
+
let missing_flags = remove_agent_missing_flags(from_spec, confirm, force, requirements);
|
|
2385
|
+
let reason = if missing_flags.len() == 1 {
|
|
2386
|
+
match missing_flags[0] {
|
|
2387
|
+
"--confirm" => "confirm_required",
|
|
2388
|
+
"--from-spec" => "from_spec_confirm_required",
|
|
2389
|
+
"--force" => "force_required",
|
|
2390
|
+
_ => "remove_agent_flags_required",
|
|
2391
|
+
}
|
|
2392
|
+
} else {
|
|
2393
|
+
"remove_agent_flags_required"
|
|
2394
|
+
};
|
|
2395
|
+
let command = remove_agent_command(workspace, agent, team, &required_flags);
|
|
2396
|
+
let required = required_flags.join(" ");
|
|
2397
|
+
json!({
|
|
2398
|
+
"ok": false,
|
|
2399
|
+
"agent_id": agent,
|
|
2400
|
+
"status": "refused",
|
|
2401
|
+
"reason": reason,
|
|
2402
|
+
"error": format!("remove-agent requires {required} for this agent"),
|
|
2403
|
+
"action": format!("rerun: {command}"),
|
|
2404
|
+
"command": command,
|
|
2405
|
+
"missing_flags": missing_flags,
|
|
2406
|
+
"required_flags": required_flags,
|
|
2407
|
+
"state": {
|
|
2408
|
+
"from_spec_required": requirements.from_spec_required,
|
|
2409
|
+
"running": requirements.force_required,
|
|
2410
|
+
"has_session": requirements.has_session,
|
|
2411
|
+
},
|
|
2412
|
+
})
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
fn remove_agent_missing_flags(
|
|
2416
|
+
from_spec: bool,
|
|
2417
|
+
confirm: bool,
|
|
2418
|
+
force: bool,
|
|
2419
|
+
requirements: &crate::lifecycle::RemoveAgentFlagRequirements,
|
|
2420
|
+
) -> Vec<&'static str> {
|
|
2421
|
+
remove_agent_required_flags(requirements)
|
|
2422
|
+
.into_iter()
|
|
2423
|
+
.filter(|flag| match *flag {
|
|
2424
|
+
"--from-spec" => !from_spec,
|
|
2425
|
+
"--confirm" => !confirm,
|
|
2426
|
+
"--force" => !force,
|
|
2427
|
+
_ => false,
|
|
2428
|
+
})
|
|
2429
|
+
.collect()
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
fn remove_agent_required_flags(
|
|
2433
|
+
requirements: &crate::lifecycle::RemoveAgentFlagRequirements,
|
|
2434
|
+
) -> Vec<&'static str> {
|
|
2435
|
+
let mut flags = Vec::new();
|
|
2436
|
+
if requirements.from_spec_required {
|
|
2437
|
+
flags.push("--from-spec");
|
|
2438
|
+
}
|
|
2439
|
+
flags.push("--confirm");
|
|
2440
|
+
if requirements.force_required {
|
|
2441
|
+
flags.push("--force");
|
|
2442
|
+
}
|
|
2443
|
+
flags
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
fn remove_agent_command(
|
|
2447
|
+
workspace: &Path,
|
|
2448
|
+
agent: &str,
|
|
2449
|
+
team: Option<&str>,
|
|
2450
|
+
required_flags: &[&str],
|
|
2451
|
+
) -> String {
|
|
2452
|
+
let mut parts = vec![
|
|
2453
|
+
"team-agent".to_string(),
|
|
2454
|
+
"remove-agent".to_string(),
|
|
2455
|
+
shell_arg(agent),
|
|
2456
|
+
"--workspace".to_string(),
|
|
2457
|
+
shell_arg(&workspace.to_string_lossy()),
|
|
2458
|
+
];
|
|
2459
|
+
if let Some(team) = team {
|
|
2460
|
+
parts.push("--team".to_string());
|
|
2461
|
+
parts.push(shell_arg(team));
|
|
2462
|
+
}
|
|
2463
|
+
parts.extend(required_flags.iter().map(|flag| (*flag).to_string()));
|
|
2464
|
+
parts.join(" ")
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
fn shell_arg(raw: &str) -> String {
|
|
2468
|
+
if !raw.is_empty()
|
|
2469
|
+
&& raw
|
|
2470
|
+
.bytes()
|
|
2471
|
+
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'.' | b'_' | b'-' | b':'))
|
|
2472
|
+
{
|
|
2473
|
+
raw.to_string()
|
|
2474
|
+
} else {
|
|
2475
|
+
format!("'{}'", raw.replace('\'', "'\\''"))
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2349
2478
|
/// `runtime.acknowledge_idle`(`cmd_acknowledge_idle`)。
|
|
2350
2479
|
pub fn acknowledge_idle(workspace: &Path, team: Option<&str>) -> Result<Value, CliError> {
|
|
2351
2480
|
let mut state = crate::state::persist::load_runtime_state(workspace)
|
|
@@ -39,7 +39,7 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
|
39
39
|
args.json,
|
|
40
40
|
)?;
|
|
41
41
|
add_send_reminder_if_ok(&mut value);
|
|
42
|
-
return Ok(
|
|
42
|
+
return Ok(cmd_send_result(value, args.json));
|
|
43
43
|
}
|
|
44
44
|
// F1 (0.3.26, cross-team send): --pane <pane_id> direct targeting.
|
|
45
45
|
// Mutually exclusive with target / --to (agent-name routing).
|
|
@@ -68,7 +68,7 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
|
68
68
|
args.json,
|
|
69
69
|
)?;
|
|
70
70
|
add_send_reminder_if_ok(&mut value);
|
|
71
|
-
return Ok(
|
|
71
|
+
return Ok(cmd_send_result(value, args.json));
|
|
72
72
|
}
|
|
73
73
|
let selected = crate::state::selector::resolve_active_team(
|
|
74
74
|
&args.workspace,
|
|
@@ -88,7 +88,7 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
|
88
88
|
if let Some(amb) =
|
|
89
89
|
routing_ambiguous_value(&selected.run_workspace, args, &target, &content, &opts)
|
|
90
90
|
{
|
|
91
|
-
return Ok(
|
|
91
|
+
return Ok(cmd_send_result(amb, args.json));
|
|
92
92
|
}
|
|
93
93
|
let mut outcome = messaging::send_message(&selected.run_workspace, &target, &content, &opts)?;
|
|
94
94
|
if opts.watch_result {
|
|
@@ -101,7 +101,7 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
add_send_reminder_if_ok(&mut value);
|
|
104
|
-
Ok(
|
|
104
|
+
Ok(cmd_send_result(value, args.json))
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/// F1 (0.3.26): direct pane-id send — bypasses agent-name routing + team
|
|
@@ -752,8 +752,98 @@ fn add_send_reminder_if_ok(value: &mut Value) {
|
|
|
752
752
|
if value.get("ok").and_then(Value::as_bool) != Some(true) {
|
|
753
753
|
return;
|
|
754
754
|
}
|
|
755
|
+
let reminder = send_reminder_for_value(value);
|
|
755
756
|
if let Some(obj) = value.as_object_mut() {
|
|
756
|
-
obj.insert("reminder".to_string(), json!(
|
|
757
|
+
obj.insert("reminder".to_string(), json!(reminder));
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
fn cmd_send_result(value: Value, as_json: bool) -> CmdResult {
|
|
762
|
+
let exit = if value.get("ok").and_then(Value::as_bool) == Some(false) {
|
|
763
|
+
ExitCode::Error
|
|
764
|
+
} else {
|
|
765
|
+
ExitCode::Ok
|
|
766
|
+
};
|
|
767
|
+
if as_json {
|
|
768
|
+
CmdResult::from_json(value, true)
|
|
769
|
+
} else {
|
|
770
|
+
CmdResult {
|
|
771
|
+
output: CmdOutput::Human(send_human_output(&value)),
|
|
772
|
+
exit,
|
|
773
|
+
as_json: false,
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
fn send_human_output(value: &Value) -> String {
|
|
779
|
+
let mut parts = vec![
|
|
780
|
+
send_human_field(value, "ok"),
|
|
781
|
+
format!("status: {}", send_human_status(value)),
|
|
782
|
+
send_human_field(value, "message_id"),
|
|
783
|
+
format!("target: {}", send_human_target(value)),
|
|
784
|
+
];
|
|
785
|
+
for key in ["verification", "stage", "reason", "channel"] {
|
|
786
|
+
if !value.get(key).is_none_or(Value::is_null) {
|
|
787
|
+
parts.push(send_human_field(value, key));
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
parts.join(" ")
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
fn send_human_field(value: &Value, key: &str) -> String {
|
|
794
|
+
let rendered = value
|
|
795
|
+
.get(key)
|
|
796
|
+
.map(send_human_value)
|
|
797
|
+
.unwrap_or_else(|| "None".to_string());
|
|
798
|
+
format!("{key}: {rendered}")
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
fn send_human_target(value: &Value) -> String {
|
|
802
|
+
["target", "agent_id", "pane_id", "to_name"]
|
|
803
|
+
.iter()
|
|
804
|
+
.find_map(|key| value.get(*key).filter(|v| !v.is_null()))
|
|
805
|
+
.map(send_human_value)
|
|
806
|
+
.unwrap_or_else(|| "None".to_string())
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
fn send_human_status(value: &Value) -> String {
|
|
810
|
+
value
|
|
811
|
+
.get("status")
|
|
812
|
+
.map(send_human_value)
|
|
813
|
+
.unwrap_or_else(|| {
|
|
814
|
+
if value.get("ok").and_then(Value::as_bool) == Some(true) {
|
|
815
|
+
"delivered".to_string()
|
|
816
|
+
} else {
|
|
817
|
+
"failed".to_string()
|
|
818
|
+
}
|
|
819
|
+
})
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
fn send_human_value(value: &Value) -> String {
|
|
823
|
+
let text = match value {
|
|
824
|
+
Value::Null => "None".to_string(),
|
|
825
|
+
Value::Bool(true) => "True".to_string(),
|
|
826
|
+
Value::Bool(false) => "False".to_string(),
|
|
827
|
+
Value::Number(n) => n.to_string(),
|
|
828
|
+
Value::String(s) => s.clone(),
|
|
829
|
+
Value::Array(_) | Value::Object(_) => {
|
|
830
|
+
serde_json::to_string(value).unwrap_or_else(|_| "None".to_string())
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
text.replace(['\r', '\n'], " ")
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
fn send_reminder_for_value(value: &Value) -> &'static str {
|
|
837
|
+
let delivered = value.get("delivered").and_then(Value::as_bool);
|
|
838
|
+
let status = value.get("status").and_then(Value::as_str);
|
|
839
|
+
let delivery_status = value.get("delivery_status").and_then(Value::as_str);
|
|
840
|
+
if delivered == Some(false)
|
|
841
|
+
|| matches!(status, Some("queued"))
|
|
842
|
+
|| matches!(delivery_status, Some("pending"))
|
|
843
|
+
{
|
|
844
|
+
"Message queued; coordinator will notify when the worker receives it. Do not poll the worker terminal with capture-pane."
|
|
845
|
+
} else {
|
|
846
|
+
crate::cli::SEND_REMINDER
|
|
757
847
|
}
|
|
758
848
|
}
|
|
759
849
|
|
|
@@ -493,6 +493,60 @@ fn seed_team_spec(ws: &std::path::Path) {
|
|
|
493
493
|
.unwrap();
|
|
494
494
|
}
|
|
495
495
|
#[test]
|
|
496
|
+
fn remove_agent_spec_running_refusal_lists_all_required_flags_once() {
|
|
497
|
+
let ws = tmp_workspace();
|
|
498
|
+
seed_remove_agent_workspace(&ws, "running");
|
|
499
|
+
let out = json_output(
|
|
500
|
+
cmd_remove_agent(&RemoveAgentArgs {
|
|
501
|
+
agent: "fake_impl".to_string(),
|
|
502
|
+
workspace: ws.clone(),
|
|
503
|
+
team: None,
|
|
504
|
+
from_spec: false,
|
|
505
|
+
confirm: false,
|
|
506
|
+
force: false,
|
|
507
|
+
json: true,
|
|
508
|
+
})
|
|
509
|
+
.unwrap(),
|
|
510
|
+
);
|
|
511
|
+
assert_eq!(out["ok"], json!(false));
|
|
512
|
+
assert_eq!(out["status"], json!("refused"));
|
|
513
|
+
assert_eq!(out["reason"], json!("remove_agent_flags_required"));
|
|
514
|
+
for flag in ["--from-spec", "--confirm", "--force"] {
|
|
515
|
+
assert!(
|
|
516
|
+
out["error"].as_str().is_some_and(|s| s.contains(flag))
|
|
517
|
+
&& out["action"].as_str().is_some_and(|s| s.contains(flag))
|
|
518
|
+
&& out["command"].as_str().is_some_and(|s| s.contains(flag)),
|
|
519
|
+
"refusal must mention {flag} in error/action/copyable command; got {out}"
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
let with_confirm = json_output(
|
|
523
|
+
cmd_remove_agent(&RemoveAgentArgs {
|
|
524
|
+
agent: "fake_impl".to_string(),
|
|
525
|
+
workspace: ws.clone(),
|
|
526
|
+
team: None,
|
|
527
|
+
from_spec: false,
|
|
528
|
+
confirm: true,
|
|
529
|
+
force: false,
|
|
530
|
+
json: true,
|
|
531
|
+
})
|
|
532
|
+
.unwrap(),
|
|
533
|
+
);
|
|
534
|
+
assert_eq!(with_confirm["reason"], json!("remove_agent_flags_required"));
|
|
535
|
+
for flag in ["--from-spec", "--confirm", "--force"] {
|
|
536
|
+
assert!(
|
|
537
|
+
with_confirm["error"].as_str().is_some_and(|s| s.contains(flag))
|
|
538
|
+
&& with_confirm["action"].as_str().is_some_and(|s| s.contains(flag))
|
|
539
|
+
&& with_confirm["command"].as_str().is_some_and(|s| s.contains(flag)),
|
|
540
|
+
"refusal with --confirm must still give the full command including {flag}; got {with_confirm}"
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
544
|
+
assert!(
|
|
545
|
+
state["agents"].get("fake_impl").is_some(),
|
|
546
|
+
"refused remove-agent must not delete the spec-defined running agent"
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
#[test]
|
|
496
550
|
fn remove_agent_running_refusal_is_not_success_envelope() {
|
|
497
551
|
let ws = tmp_workspace();
|
|
498
552
|
seed_remove_agent_workspace(&ws, "running");
|
|
@@ -451,6 +451,28 @@ use super::*;
|
|
|
451
451
|
}
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
+
fn queued_send_args_fixture(json: bool) -> SendArgs {
|
|
455
|
+
let ws = deleg_uniq_dir("send-human");
|
|
456
|
+
let _ = crate::message_store::MessageStore::open(&ws).unwrap();
|
|
457
|
+
crate::state::persist::save_runtime_state(
|
|
458
|
+
&ws,
|
|
459
|
+
&json!({
|
|
460
|
+
"active_team_key": "current",
|
|
461
|
+
"teams": {"current": {"agents": {"w1": {"provider": "codex"}}}}
|
|
462
|
+
}),
|
|
463
|
+
)
|
|
464
|
+
.unwrap();
|
|
465
|
+
SendArgs {
|
|
466
|
+
workspace: ws,
|
|
467
|
+
target: Some("w1".into()),
|
|
468
|
+
team: None,
|
|
469
|
+
task: None,
|
|
470
|
+
watch_result: false,
|
|
471
|
+
json,
|
|
472
|
+
..send_args_fixture()
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
454
476
|
#[test]
|
|
455
477
|
fn send_options_negates_no_ack_and_no_wait_and_carries_watch() {
|
|
456
478
|
// golden (commands.py:172,174,176): requires_ack=not no_ack; wait_visible=not no_wait;
|
|
@@ -494,7 +516,11 @@ use super::*;
|
|
|
494
516
|
fn cmd_send_joins_message_with_single_space() {
|
|
495
517
|
// golden (commands.py:169): " ".join(["hello","world","foo"]) == "hello world foo".
|
|
496
518
|
// Drive cmd_send; the joined content must reach send_message (RED until ported).
|
|
497
|
-
let
|
|
519
|
+
let args = SendArgs {
|
|
520
|
+
json: true,
|
|
521
|
+
..send_args_fixture()
|
|
522
|
+
};
|
|
523
|
+
let r = cmd_send(&args).expect("cmd_send returns CmdResult");
|
|
498
524
|
// The delegate's DeliveryOutcome -> Json must carry an `ok` key feeding exit-code.
|
|
499
525
|
match r.output {
|
|
500
526
|
CmdOutput::Json(ref v) => {
|
|
@@ -510,11 +536,92 @@ use super::*;
|
|
|
510
536
|
}
|
|
511
537
|
}
|
|
512
538
|
|
|
539
|
+
#[test]
|
|
540
|
+
fn cmd_send_default_human_output_is_one_line_without_false_delivered() {
|
|
541
|
+
let r = cmd_send(&queued_send_args_fixture(false)).expect("cmd_send returns CmdResult");
|
|
542
|
+
assert!(!r.as_json);
|
|
543
|
+
let text = emit(&r.output, r.as_json).expect("send should render human text");
|
|
544
|
+
let lines: Vec<_> = text.lines().collect();
|
|
545
|
+
assert_eq!(lines.len(), 1, "default send output must be one line: {text}");
|
|
546
|
+
assert!(
|
|
547
|
+
lines[0].contains("ok:")
|
|
548
|
+
&& lines[0].contains("status:")
|
|
549
|
+
&& lines[0].contains("message_id:")
|
|
550
|
+
&& lines[0].contains("target:"),
|
|
551
|
+
"default send output must keep only the core fields; got {text}"
|
|
552
|
+
);
|
|
553
|
+
assert!(
|
|
554
|
+
!text.contains("delivered"),
|
|
555
|
+
"queued send output must not claim or mention delivered; got {text}"
|
|
556
|
+
);
|
|
557
|
+
for hidden in [
|
|
558
|
+
"agent_id:",
|
|
559
|
+
"sender:",
|
|
560
|
+
"message_status:",
|
|
561
|
+
"verification:",
|
|
562
|
+
"stage:",
|
|
563
|
+
"reason:",
|
|
564
|
+
"channel:",
|
|
565
|
+
"reminder:",
|
|
566
|
+
] {
|
|
567
|
+
assert!(
|
|
568
|
+
!text.contains(hidden),
|
|
569
|
+
"default send output should hide {hidden} unless needed; got {text}"
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
#[test]
|
|
575
|
+
fn cmd_send_json_shape_keeps_056_fields() {
|
|
576
|
+
let args = queued_send_args_fixture(true);
|
|
577
|
+
let r = cmd_send(&args).expect("cmd_send returns CmdResult");
|
|
578
|
+
let v = match r.output {
|
|
579
|
+
CmdOutput::Json(v) => v,
|
|
580
|
+
other => panic!("--json send must emit Json, got {other:?}"),
|
|
581
|
+
};
|
|
582
|
+
let obj = v.as_object().expect("--json send output must be object");
|
|
583
|
+
for key in [
|
|
584
|
+
"ok",
|
|
585
|
+
"status",
|
|
586
|
+
"delivery_status",
|
|
587
|
+
"delivered",
|
|
588
|
+
"target",
|
|
589
|
+
"agent_id",
|
|
590
|
+
"content_length_bytes",
|
|
591
|
+
"sender",
|
|
592
|
+
"message_id",
|
|
593
|
+
"message_status",
|
|
594
|
+
"verification",
|
|
595
|
+
"stage",
|
|
596
|
+
"reason",
|
|
597
|
+
"channel",
|
|
598
|
+
"reminder",
|
|
599
|
+
] {
|
|
600
|
+
assert!(obj.contains_key(key), "--json send shape lost {key}: {v}");
|
|
601
|
+
}
|
|
602
|
+
assert_eq!(v.get("verification"), Some(&serde_json::Value::Null));
|
|
603
|
+
assert_eq!(v.get("stage"), Some(&serde_json::Value::Null));
|
|
604
|
+
assert_eq!(v.get("reason"), Some(&serde_json::Value::Null));
|
|
605
|
+
assert_eq!(v.get("channel"), Some(&serde_json::Value::Null));
|
|
606
|
+
assert_eq!(v.get("delivered").and_then(|d| d.as_bool()), Some(false));
|
|
607
|
+
assert!(
|
|
608
|
+
!v.get("reminder")
|
|
609
|
+
.and_then(|reminder| reminder.as_str())
|
|
610
|
+
.unwrap_or_default()
|
|
611
|
+
.contains("Message delivered."),
|
|
612
|
+
"queued JSON reminder must not contradict delivered:false: {v}"
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
513
616
|
#[test]
|
|
514
617
|
fn cmd_send_watch_result_does_not_register_before_delivery() {
|
|
515
618
|
// 0.5.x send contract: --watch-result may only advertise a watcher after
|
|
516
619
|
// initial worker delivery is physically proven.
|
|
517
|
-
let
|
|
620
|
+
let args = SendArgs {
|
|
621
|
+
json: true,
|
|
622
|
+
..send_args_fixture()
|
|
623
|
+
};
|
|
624
|
+
let r = cmd_send(&args).expect("cmd_send returns CmdResult");
|
|
518
625
|
let v = match r.output {
|
|
519
626
|
CmdOutput::Json(v) => v,
|
|
520
627
|
other => panic!("expected Json, got {other:?}"),
|
|
@@ -60,15 +60,37 @@ pub fn remove_agent_with_transport(
|
|
|
60
60
|
)
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
fn
|
|
63
|
+
pub fn remove_agent_flag_requirements(
|
|
64
|
+
workspace: &Path,
|
|
65
|
+
agent_id: &AgentId,
|
|
66
|
+
team: Option<&str>,
|
|
67
|
+
) -> Result<RemoveAgentFlagRequirements, LifecycleError> {
|
|
68
|
+
let paths = lifecycle_paths(workspace, team)?;
|
|
69
|
+
let transport =
|
|
70
|
+
lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
|
|
71
|
+
Ok(remove_agent_preflight(
|
|
72
|
+
&paths.run_workspace,
|
|
73
|
+
&paths.spec_workspace,
|
|
74
|
+
agent_id,
|
|
75
|
+
team,
|
|
76
|
+
&transport,
|
|
77
|
+
)?
|
|
78
|
+
.requirements)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
struct RemoveAgentPreflight {
|
|
82
|
+
state: serde_json::Value,
|
|
83
|
+
spec: YamlValue,
|
|
84
|
+
requirements: RemoveAgentFlagRequirements,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fn remove_agent_preflight(
|
|
64
88
|
workspace: &Path,
|
|
65
89
|
spec_workspace: &Path,
|
|
66
90
|
agent_id: &AgentId,
|
|
67
|
-
from_spec: bool,
|
|
68
|
-
force: bool,
|
|
69
91
|
team: Option<&str>,
|
|
70
92
|
transport: &dyn crate::transport::Transport,
|
|
71
|
-
) -> Result<
|
|
93
|
+
) -> Result<RemoveAgentPreflight, LifecycleError> {
|
|
72
94
|
// golden agents.py:34-41: resolve_team_scoped_state FIRST (surfaces the team_target_ambiguous /
|
|
73
95
|
// team_target_unresolved refusal before the owner gate), THEN the owner gate, THEN load_spec +
|
|
74
96
|
// _find_worker (unknown-worker raise). Mirror the stop/reset wiring so remove is byte-symmetric:
|
|
@@ -80,27 +102,84 @@ fn remove_agent_at_paths(
|
|
|
80
102
|
return Err(unknown_worker(agent_id));
|
|
81
103
|
};
|
|
82
104
|
let dynamic_agent = is_dynamic_agent(&state, spec_agent, agent_id);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
105
|
+
let force_required = agent_is_running(&state, agent_id, transport);
|
|
106
|
+
let has_session = state
|
|
107
|
+
.get("session_name")
|
|
108
|
+
.and_then(|v| v.as_str())
|
|
109
|
+
.is_some_and(|s| !s.is_empty())
|
|
110
|
+
|| state
|
|
111
|
+
.get("agents")
|
|
112
|
+
.and_then(|v| v.get(agent_id.as_str()))
|
|
113
|
+
.is_some_and(agent_has_session);
|
|
114
|
+
Ok(RemoveAgentPreflight {
|
|
115
|
+
state,
|
|
116
|
+
spec,
|
|
117
|
+
requirements: RemoveAgentFlagRequirements {
|
|
90
118
|
agent_id: agent_id.clone(),
|
|
119
|
+
from_spec_required: !dynamic_agent,
|
|
120
|
+
force_required,
|
|
121
|
+
has_session,
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
fn agent_has_session(agent: &serde_json::Value) -> bool {
|
|
127
|
+
["session_id", "_pending_session_id", "rollout_path"]
|
|
128
|
+
.iter()
|
|
129
|
+
.any(|key| {
|
|
130
|
+
agent
|
|
131
|
+
.get(key)
|
|
132
|
+
.and_then(|v| v.as_str())
|
|
133
|
+
.is_some_and(|s| !s.is_empty())
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fn remove_agent_at_paths(
|
|
138
|
+
workspace: &Path,
|
|
139
|
+
spec_workspace: &Path,
|
|
140
|
+
agent_id: &AgentId,
|
|
141
|
+
from_spec: bool,
|
|
142
|
+
force: bool,
|
|
143
|
+
team: Option<&str>,
|
|
144
|
+
transport: &dyn crate::transport::Transport,
|
|
145
|
+
) -> Result<RemoveAgentOutcome, LifecycleError> {
|
|
146
|
+
let preflight = remove_agent_preflight(workspace, spec_workspace, agent_id, team, transport)?;
|
|
147
|
+
let missing_from_spec = preflight.requirements.from_spec_required && !from_spec;
|
|
148
|
+
let missing_force = preflight.requirements.force_required && !force;
|
|
149
|
+
if missing_from_spec || missing_force {
|
|
150
|
+
return Ok(if missing_from_spec && missing_force {
|
|
151
|
+
RemoveAgentOutcome::RefusedRequiredFlags {
|
|
152
|
+
agent_id: agent_id.clone(),
|
|
153
|
+
from_spec_required: true,
|
|
154
|
+
force_required: true,
|
|
155
|
+
}
|
|
156
|
+
} else if missing_from_spec {
|
|
157
|
+
RemoveAgentOutcome::RefusedFromSpecConfirm {
|
|
158
|
+
agent_id: agent_id.clone(),
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
RemoveAgentOutcome::RefusedForceRequired {
|
|
162
|
+
agent_id: agent_id.clone(),
|
|
163
|
+
}
|
|
91
164
|
});
|
|
92
165
|
}
|
|
93
166
|
let paths = LifecyclePathRefs {
|
|
94
167
|
run_workspace: workspace,
|
|
95
168
|
spec_workspace,
|
|
96
169
|
};
|
|
97
|
-
let mut rollback = RemoveRollback::capture(
|
|
98
|
-
|
|
170
|
+
let mut rollback = RemoveRollback::capture(
|
|
171
|
+
paths.run_workspace,
|
|
172
|
+
paths.spec_workspace,
|
|
173
|
+
&preflight.spec,
|
|
174
|
+
&preflight.state,
|
|
175
|
+
agent_id,
|
|
176
|
+
)?;
|
|
177
|
+
rollback.restore_running = force && preflight.requirements.force_required;
|
|
99
178
|
let result = remove_agent_inner(
|
|
100
179
|
&paths,
|
|
101
180
|
agent_id,
|
|
102
|
-
&spec,
|
|
103
|
-
state,
|
|
181
|
+
&preflight.spec,
|
|
182
|
+
preflight.state,
|
|
104
183
|
force,
|
|
105
184
|
team,
|
|
106
185
|
transport,
|
|
@@ -50,7 +50,7 @@ pub use rebuild::{
|
|
|
50
50
|
restart, restart_candidates, restart_with_session_convergence_deadline, restart_with_transport,
|
|
51
51
|
restart_with_transport_with_readiness_deadline, select_restart_state,
|
|
52
52
|
};
|
|
53
|
-
pub use remove::{remove_agent, remove_agent_with_transport};
|
|
53
|
+
pub use remove::{remove_agent, remove_agent_flag_requirements, remove_agent_with_transport};
|
|
54
54
|
pub use selection::{
|
|
55
55
|
classify_first_send_at, classify_restart_plan, decide_start_mode, python_type_name,
|
|
56
56
|
};
|
|
@@ -609,6 +609,15 @@ pub struct ForkAgentReport {
|
|
|
609
609
|
pub session_id: Option<SessionId>,
|
|
610
610
|
}
|
|
611
611
|
|
|
612
|
+
/// Read-only remove-agent flag requirements for the target's current state.
|
|
613
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
614
|
+
pub struct RemoveAgentFlagRequirements {
|
|
615
|
+
pub agent_id: AgentId,
|
|
616
|
+
pub from_spec_required: bool,
|
|
617
|
+
pub force_required: bool,
|
|
618
|
+
pub has_session: bool,
|
|
619
|
+
}
|
|
620
|
+
|
|
612
621
|
/// `remove_agent(...)` 结果(`agents.py:54/56/150`)。`_RemoveRollback` 快照
|
|
613
622
|
/// spec/state/team_state/role_file/agent_health 字节级回滚(Gap 16)。
|
|
614
623
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
@@ -624,6 +633,12 @@ pub enum RemoveAgentOutcome {
|
|
|
624
633
|
RefusedFromSpecConfirm { agent_id: AgentId },
|
|
625
634
|
/// 运行中未传 force(`agents.py:56`)。
|
|
626
635
|
RefusedForceRequired { agent_id: AgentId },
|
|
636
|
+
/// Multiple required destructive flags are absent; report them together.
|
|
637
|
+
RefusedRequiredFlags {
|
|
638
|
+
agent_id: AgentId,
|
|
639
|
+
from_spec_required: bool,
|
|
640
|
+
force_required: bool,
|
|
641
|
+
},
|
|
627
642
|
}
|
|
628
643
|
|
|
629
644
|
/// `restart(...)` 结果(`orchestration.py:114/142/387`)。Route B:**先全量验证**
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
use std::io::Write as _;
|
|
4
4
|
use std::path::Path;
|
|
5
5
|
|
|
6
|
+
use rusqlite::{params, OptionalExtension};
|
|
6
7
|
use serde::Serialize;
|
|
7
8
|
use serde_json::Value;
|
|
8
9
|
|
|
@@ -274,12 +275,7 @@ fn find_latest_nonterminal_task_for(tasks: &[Value], agent_id: &str) -> Option<S
|
|
|
274
275
|
None
|
|
275
276
|
}
|
|
276
277
|
|
|
277
|
-
|
|
278
|
-
/// is empty/null AND for which no result row exists yet. Used by
|
|
279
|
-
/// `report_result` as a message-scoped fallback before defaulting to
|
|
280
|
-
/// `"manual"`, so `collect` can correlate via the existing message-scope path
|
|
281
|
-
/// (`messaging::results::is_message_scoped_result`).
|
|
282
|
-
pub(crate) fn latest_uncorrelated_delivered_message_for(
|
|
278
|
+
pub(crate) fn latest_reportable_message_for(
|
|
283
279
|
workspace: &Path,
|
|
284
280
|
agent_id: &str,
|
|
285
281
|
owner_team_id: Option<&str>,
|
|
@@ -287,33 +283,147 @@ pub(crate) fn latest_uncorrelated_delivered_message_for(
|
|
|
287
283
|
use crate::db::message_store::MessageStore;
|
|
288
284
|
let store = MessageStore::open(workspace).ok()?;
|
|
289
285
|
let conn = crate::db::schema::open_db(store.db_path()).ok()?;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
let mut stmt = conn.prepare(sql).ok()?;
|
|
310
|
-
let id: Option<String> = match owner_team_id {
|
|
311
|
-
Some(team) => stmt
|
|
312
|
-
.query_row(rusqlite::params![agent_id, team], |row| row.get::<_, String>(0))
|
|
313
|
-
.ok(),
|
|
314
|
-
None => stmt
|
|
315
|
-
.query_row(rusqlite::params![agent_id], |row| row.get::<_, String>(0))
|
|
316
|
-
.ok(),
|
|
286
|
+
current_turn_message_for(workspace, &conn, agent_id, owner_team_id)
|
|
287
|
+
.or_else(|| latest_reportable_message_from_db(&conn, agent_id, owner_team_id))
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
fn current_turn_message_for(
|
|
291
|
+
workspace: &Path,
|
|
292
|
+
conn: &rusqlite::Connection,
|
|
293
|
+
agent_id: &str,
|
|
294
|
+
owner_team_id: Option<&str>,
|
|
295
|
+
) -> Option<String> {
|
|
296
|
+
let state = report_scope_state(workspace, owner_team_id)?;
|
|
297
|
+
current_turn_id_from_state(&state, agent_id)
|
|
298
|
+
.filter(|message_id| message_is_reportable(conn, message_id, agent_id, owner_team_id))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
fn report_scope_state(workspace: &Path, owner_team_id: Option<&str>) -> Option<Value> {
|
|
302
|
+
let state = load_runtime_state(workspace).ok()?;
|
|
303
|
+
let Some(team) = owner_team_id.filter(|team| !team.is_empty()) else {
|
|
304
|
+
return Some(state);
|
|
317
305
|
};
|
|
318
|
-
|
|
306
|
+
let canonical = crate::state::projection::resolve_owner_team_id(&state, team)
|
|
307
|
+
.canonical_key()
|
|
308
|
+
.map(str::to_string)
|
|
309
|
+
.unwrap_or_else(|| team.to_string());
|
|
310
|
+
if let Some(projected) = state
|
|
311
|
+
.get("teams")
|
|
312
|
+
.and_then(Value::as_object)
|
|
313
|
+
.and_then(|teams| teams.get(&canonical))
|
|
314
|
+
.cloned()
|
|
315
|
+
{
|
|
316
|
+
Some(projected)
|
|
317
|
+
} else {
|
|
318
|
+
Some(state)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
fn current_turn_id_from_state(state: &Value, agent_id: &str) -> Option<String> {
|
|
323
|
+
if let Some(turn) = state.get("coordinator").and_then(|v| v.get("turn_open")) {
|
|
324
|
+
let armed = turn.get("armed").and_then(Value::as_bool).unwrap_or(false);
|
|
325
|
+
let node_matches = turn
|
|
326
|
+
.get("node_id")
|
|
327
|
+
.and_then(Value::as_str)
|
|
328
|
+
.is_some_and(|node| node == agent_id);
|
|
329
|
+
if armed && node_matches {
|
|
330
|
+
if let Some(turn_id) = turn
|
|
331
|
+
.get("turn_id")
|
|
332
|
+
.and_then(Value::as_str)
|
|
333
|
+
.and_then(non_empty_string)
|
|
334
|
+
{
|
|
335
|
+
return Some(turn_id.to_string());
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
state
|
|
340
|
+
.get("agents")
|
|
341
|
+
.and_then(|agents| agents.get(agent_id))
|
|
342
|
+
.and_then(|agent| agent.get("current_task_id"))
|
|
343
|
+
.and_then(Value::as_str)
|
|
344
|
+
.and_then(non_empty_string)
|
|
345
|
+
.map(ToString::to_string)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
fn message_is_reportable(
|
|
349
|
+
conn: &rusqlite::Connection,
|
|
350
|
+
message_id: &str,
|
|
351
|
+
agent_id: &str,
|
|
352
|
+
owner_team_id: Option<&str>,
|
|
353
|
+
) -> bool {
|
|
354
|
+
conn.query_row(
|
|
355
|
+
"select 1 from messages m
|
|
356
|
+
where m.message_id = ?1
|
|
357
|
+
and m.recipient = ?2
|
|
358
|
+
and (?3 is null or m.owner_team_id = ?3)
|
|
359
|
+
and (m.task_id is null or m.task_id = '')
|
|
360
|
+
and m.status in ('delivered', 'target_resolved', 'submitted', 'injected', 'visible')
|
|
361
|
+
and (m.status = 'delivered' or m.error is null)
|
|
362
|
+
and m.created_at >= coalesce((
|
|
363
|
+
select max(r.created_at) from results r
|
|
364
|
+
where r.agent_id = m.recipient
|
|
365
|
+
and (?3 is null or r.owner_team_id = m.owner_team_id)
|
|
366
|
+
), '0000')
|
|
367
|
+
and not exists (
|
|
368
|
+
select 1 from results r
|
|
369
|
+
where r.task_id = m.message_id and r.agent_id = m.recipient
|
|
370
|
+
)
|
|
371
|
+
limit 1",
|
|
372
|
+
params![message_id, agent_id, owner_team_id],
|
|
373
|
+
|_| Ok(()),
|
|
374
|
+
)
|
|
375
|
+
.optional()
|
|
376
|
+
.ok()
|
|
377
|
+
.flatten()
|
|
378
|
+
.is_some()
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
fn latest_reportable_message_from_db(
|
|
382
|
+
conn: &rusqlite::Connection,
|
|
383
|
+
agent_id: &str,
|
|
384
|
+
owner_team_id: Option<&str>,
|
|
385
|
+
) -> Option<String> {
|
|
386
|
+
let row = conn
|
|
387
|
+
.query_row(
|
|
388
|
+
"select m.message_id, m.status, m.error from messages m
|
|
389
|
+
where m.recipient = ?1
|
|
390
|
+
and (?2 is null or m.owner_team_id = ?2)
|
|
391
|
+
and (m.task_id is null or m.task_id = '')
|
|
392
|
+
and m.created_at >= coalesce((
|
|
393
|
+
select max(r.created_at) from results r
|
|
394
|
+
where r.agent_id = m.recipient
|
|
395
|
+
and (?2 is null or r.owner_team_id = m.owner_team_id)
|
|
396
|
+
), '0000')
|
|
397
|
+
and not exists (
|
|
398
|
+
select 1 from results r
|
|
399
|
+
where r.task_id = m.message_id and r.agent_id = m.recipient
|
|
400
|
+
)
|
|
401
|
+
order by m.created_at desc,
|
|
402
|
+
case when m.status = 'delivered' then 0 else 1 end
|
|
403
|
+
limit 1",
|
|
404
|
+
params![agent_id, owner_team_id],
|
|
405
|
+
|row| {
|
|
406
|
+
Ok((
|
|
407
|
+
row.get::<_, String>(0)?,
|
|
408
|
+
row.get::<_, String>(1)?,
|
|
409
|
+
row.get::<_, Option<String>>(2)?,
|
|
410
|
+
))
|
|
411
|
+
},
|
|
412
|
+
)
|
|
413
|
+
.optional()
|
|
414
|
+
.ok()
|
|
415
|
+
.flatten()?;
|
|
416
|
+
let (message_id, status, error) = row;
|
|
417
|
+
if reportable_message_status(&status, error.as_deref()) {
|
|
418
|
+
Some(message_id)
|
|
419
|
+
} else {
|
|
420
|
+
None
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
fn reportable_message_status(status: &str, error: Option<&str>) -> bool {
|
|
425
|
+
matches!(
|
|
426
|
+
status,
|
|
427
|
+
"delivered" | "target_resolved" | "submitted" | "injected" | "visible"
|
|
428
|
+
) && (status == "delivered" || error.is_none())
|
|
319
429
|
}
|
|
@@ -99,9 +99,9 @@ pub use wire::*;
|
|
|
99
99
|
// pub(crate) 子项 (normalize 的 list helpers、wire 的 dispatch_tool 等) 经此再导出,
|
|
100
100
|
// 使 `#[cfg(test)] mod tests` 的 `use super::*` 与跨子模块引用解析不变。
|
|
101
101
|
pub(crate) use helpers::{
|
|
102
|
-
delivery_outcome_value, ensure_object, enum_value, insert_array,
|
|
103
|
-
non_empty_string, normalize_token, normalized_envelope_value,
|
|
104
|
-
text_of_value, tool_error_reason_wire, tool_runtime_error,
|
|
102
|
+
delivery_outcome_value, ensure_object, enum_value, insert_array, latest_reportable_message_for,
|
|
103
|
+
latest_task_for_assignee, non_empty_string, normalize_token, normalized_envelope_value,
|
|
104
|
+
object_fields, text_field, text_of_value, tool_error_reason_wire, tool_runtime_error,
|
|
105
105
|
};
|
|
106
106
|
pub(crate) use normalize::{
|
|
107
107
|
normalize_artifacts, normalize_changes, normalize_next_actions, normalize_risks, normalize_tests,
|
|
@@ -61,6 +61,134 @@
|
|
|
61
61
|
assert_eq!(env.task_id, TaskId::new("manual"));
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
fn seed_report_message(
|
|
65
|
+
ws: &std::path::Path,
|
|
66
|
+
message_id: &str,
|
|
67
|
+
owner_team_id: &str,
|
|
68
|
+
status: &str,
|
|
69
|
+
created_at: &str,
|
|
70
|
+
) {
|
|
71
|
+
let store = MessageStore::open(ws).unwrap();
|
|
72
|
+
let conn = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
73
|
+
conn.execute(
|
|
74
|
+
"insert into messages(
|
|
75
|
+
message_id, owner_team_id, task_id, sender, recipient, reply_to, requires_ack,
|
|
76
|
+
status, content, artifact_refs, created_at, updated_at, delivered_at,
|
|
77
|
+
acknowledged_at, error, delivery_attempts
|
|
78
|
+
) values (?1, ?2, null, 'leader', 'probe-worker', null, 0,
|
|
79
|
+
?3, 'task', '[]', ?4, ?4, case when ?3 = 'delivered' then ?4 else null end,
|
|
80
|
+
null, null, 0)",
|
|
81
|
+
rusqlite::params![message_id, owner_team_id, status, created_at],
|
|
82
|
+
)
|
|
83
|
+
.unwrap();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fn seed_report_result(
|
|
87
|
+
ws: &std::path::Path,
|
|
88
|
+
result_id: &str,
|
|
89
|
+
owner_team_id: &str,
|
|
90
|
+
task_id: &str,
|
|
91
|
+
created_at: &str,
|
|
92
|
+
) {
|
|
93
|
+
let store = MessageStore::open(ws).unwrap();
|
|
94
|
+
let conn = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
95
|
+
let envelope = json!({
|
|
96
|
+
"schema_version": "result_envelope_v1",
|
|
97
|
+
"result_id": result_id,
|
|
98
|
+
"task_id": task_id,
|
|
99
|
+
"agent_id": "probe-worker",
|
|
100
|
+
"status": "success",
|
|
101
|
+
"summary": "seed",
|
|
102
|
+
"changes": [], "tests": [], "risks": [], "artifacts": [], "next_actions": []
|
|
103
|
+
});
|
|
104
|
+
conn.execute(
|
|
105
|
+
"insert into results(
|
|
106
|
+
result_id, owner_team_id, task_id, agent_id, envelope, status, created_at
|
|
107
|
+
) values (?1, ?2, ?3, 'probe-worker', ?4, 'success', ?5)",
|
|
108
|
+
rusqlite::params![result_id, owner_team_id, task_id, envelope.to_string(), created_at],
|
|
109
|
+
)
|
|
110
|
+
.unwrap();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#[test]
|
|
114
|
+
fn report_result_prefers_current_inflight_message_over_old_delivered_fallback() {
|
|
115
|
+
let ws = unique_ws("report-current-message");
|
|
116
|
+
seed_report_message(
|
|
117
|
+
&ws,
|
|
118
|
+
"msg_old",
|
|
119
|
+
"gate055",
|
|
120
|
+
"delivered",
|
|
121
|
+
"2026-07-06T13:24:25.000000+00:00",
|
|
122
|
+
);
|
|
123
|
+
seed_report_result(
|
|
124
|
+
&ws,
|
|
125
|
+
"res_seed",
|
|
126
|
+
"gate055",
|
|
127
|
+
"task_initial",
|
|
128
|
+
"2026-07-06T13:25:00.000000+00:00",
|
|
129
|
+
);
|
|
130
|
+
seed_report_message(
|
|
131
|
+
&ws,
|
|
132
|
+
"msg_new",
|
|
133
|
+
"gate055",
|
|
134
|
+
"target_resolved",
|
|
135
|
+
"2026-07-06T13:27:35.000000+00:00",
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
let tools = TeamOrchestratorTools::with_identity(
|
|
139
|
+
&ws,
|
|
140
|
+
Some(AgentId::new("probe-worker")),
|
|
141
|
+
Some(TeamKey::new("gate055")),
|
|
142
|
+
);
|
|
143
|
+
let ok = tools.report_result(
|
|
144
|
+
None, Some("S3_RESTART_TOKEN"), ResultStatus::Success,
|
|
145
|
+
None, None, None, None, None,
|
|
146
|
+
None, None,
|
|
147
|
+
).expect("report ok");
|
|
148
|
+
let v = serde_json::to_value(&ok).unwrap();
|
|
149
|
+
assert_eq!(
|
|
150
|
+
v.get("task_id"),
|
|
151
|
+
Some(&json!("msg_new")),
|
|
152
|
+
"current in-flight message must beat stale delivered fallback"
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#[test]
|
|
157
|
+
fn report_result_does_not_backfill_old_delivered_message_after_latest_result() {
|
|
158
|
+
let ws = unique_ws("report-no-old-backfill");
|
|
159
|
+
seed_report_message(
|
|
160
|
+
&ws,
|
|
161
|
+
"msg_old",
|
|
162
|
+
"gate055",
|
|
163
|
+
"delivered",
|
|
164
|
+
"2026-07-06T13:24:25.000000+00:00",
|
|
165
|
+
);
|
|
166
|
+
seed_report_result(
|
|
167
|
+
&ws,
|
|
168
|
+
"res_latest",
|
|
169
|
+
"gate055",
|
|
170
|
+
"task_initial",
|
|
171
|
+
"2026-07-06T13:25:00.000000+00:00",
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
let tools = TeamOrchestratorTools::with_identity(
|
|
175
|
+
&ws,
|
|
176
|
+
Some(AgentId::new("probe-worker")),
|
|
177
|
+
Some(TeamKey::new("gate055")),
|
|
178
|
+
);
|
|
179
|
+
let ok = tools.report_result(
|
|
180
|
+
None, Some("manual follow-up"), ResultStatus::Success,
|
|
181
|
+
None, None, None, None, None,
|
|
182
|
+
None, None,
|
|
183
|
+
).expect("report ok");
|
|
184
|
+
let v = serde_json::to_value(&ok).unwrap();
|
|
185
|
+
assert_eq!(
|
|
186
|
+
v.get("task_id"),
|
|
187
|
+
Some(&json!("manual")),
|
|
188
|
+
"old delivered messages older than latest result must not be reused"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
64
192
|
// ════════════════════════════════════════════════════════════════════════
|
|
65
193
|
// CONTROL-PLANE: request_human creates a requires_ack leader message → needs_human
|
|
66
194
|
// (tools.py:342-346). sender = explicit > env > "unknown" (never leader).
|
|
@@ -21,9 +21,8 @@ use crate::messaging::{self, MessageTarget, SendOptions};
|
|
|
21
21
|
|
|
22
22
|
use super::helpers::{
|
|
23
23
|
delivery_outcome_value, ensure_object, enum_value, insert_array, is_worker_recipient,
|
|
24
|
-
json_dumps_default, latest_task_for_assignee,
|
|
25
|
-
|
|
26
|
-
tool_runtime_error,
|
|
24
|
+
json_dumps_default, latest_reportable_message_for, latest_task_for_assignee, non_empty_string,
|
|
25
|
+
normalized_envelope_value, object_fields, requires_ack_for_target, tool_runtime_error,
|
|
27
26
|
};
|
|
28
27
|
use super::normalize::{
|
|
29
28
|
compact_tool_result, normalize_report_envelope, normalize_result_status_observed,
|
|
@@ -326,9 +325,9 @@ impl TeamOrchestratorTools {
|
|
|
326
325
|
// 1. explicit arg
|
|
327
326
|
// 2. latest nonterminal task assigned to this agent in
|
|
328
327
|
// teams.<owner>.tasks (scoped) → top-level tasks
|
|
329
|
-
// 3.
|
|
330
|
-
// task id and no result yet (message-scope
|
|
331
|
-
// collect path: is_message_scoped_result)
|
|
328
|
+
// 3. current/in-flight reportable direct message to this agent
|
|
329
|
+
// with no task id and no result yet (message-scope
|
|
330
|
+
// correlation — collect path: is_message_scoped_result)
|
|
332
331
|
// 4. "manual" — truly uncorrelated; collect still rejects
|
|
333
332
|
let owner_team_id_str = self.owner_team_id.as_ref().map(|t| t.as_str().to_string());
|
|
334
333
|
let resolved = task_id
|
|
@@ -344,7 +343,7 @@ impl TeamOrchestratorTools {
|
|
|
344
343
|
})
|
|
345
344
|
.or_else(|| {
|
|
346
345
|
self.agent_id.as_ref().and_then(|agent| {
|
|
347
|
-
|
|
346
|
+
latest_reportable_message_for(
|
|
348
347
|
&self.workspace,
|
|
349
348
|
agent.as_str(),
|
|
350
349
|
owner_team_id_str.as_deref(),
|
|
@@ -460,6 +460,14 @@ pub fn deliver_pending_message(
|
|
|
460
460
|
});
|
|
461
461
|
}
|
|
462
462
|
};
|
|
463
|
+
record_current_turn_after_inject_if_leader_to_worker_scoped(
|
|
464
|
+
workspace,
|
|
465
|
+
&message.sender,
|
|
466
|
+
&message.recipient,
|
|
467
|
+
message_id,
|
|
468
|
+
event_log,
|
|
469
|
+
canonical_owner_team_id.as_deref(),
|
|
470
|
+
)?;
|
|
463
471
|
let submit_verified = inject_submit_verified(&inject_report);
|
|
464
472
|
let readback_verified = pane_readback_verified(&inject_report);
|
|
465
473
|
// Leader pane: inject success is delivery proof. Worker pane: post-submit
|
|
@@ -1892,7 +1900,7 @@ pub fn execute_trust_retry(
|
|
|
1892
1900
|
}
|
|
1893
1901
|
|
|
1894
1902
|
/// `_record_turn_open_if_leader_to_worker` (`delivery.py:430`):**take-over arm 来自真实投递**
|
|
1895
|
-
/// (card §121) —— 仅 leader→worker
|
|
1903
|
+
/// (card §121) —— 仅 leader→worker 注入/投递成功后才 arm,绝不凭空 arm。
|
|
1896
1904
|
pub fn record_turn_open_if_leader_to_worker(
|
|
1897
1905
|
workspace: &Path,
|
|
1898
1906
|
state: &serde_json::Value,
|
|
@@ -1907,6 +1915,30 @@ pub fn record_turn_open_if_leader_to_worker(
|
|
|
1907
1915
|
)
|
|
1908
1916
|
}
|
|
1909
1917
|
|
|
1918
|
+
fn record_current_turn_after_inject_if_leader_to_worker_scoped(
|
|
1919
|
+
workspace: &Path,
|
|
1920
|
+
sender: &str,
|
|
1921
|
+
recipient: &str,
|
|
1922
|
+
message_id: &str,
|
|
1923
|
+
event_log: &EventLog,
|
|
1924
|
+
owner_team_id: Option<&str>,
|
|
1925
|
+
) -> Result<(), MessagingError> {
|
|
1926
|
+
if !matches!(sender, "leader" | "Leader") || recipient == "leader" {
|
|
1927
|
+
return Ok(());
|
|
1928
|
+
}
|
|
1929
|
+
let message_id = Some(message_id.to_string());
|
|
1930
|
+
let mut state = scoped_state_for_write(workspace, owner_team_id)?;
|
|
1931
|
+
arm_turn_open(&mut state, recipient, &message_id);
|
|
1932
|
+
save_scoped_state_reapplying_after_conflict(workspace, &state, owner_team_id, |latest| {
|
|
1933
|
+
arm_turn_open(latest, recipient, &message_id);
|
|
1934
|
+
})?;
|
|
1935
|
+
event_log.write(
|
|
1936
|
+
"turn_open.armed_after_inject",
|
|
1937
|
+
serde_json::json!({"agent_id": recipient, "message_id": message_id}),
|
|
1938
|
+
)?;
|
|
1939
|
+
Ok(())
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1910
1942
|
fn record_turn_open_if_leader_to_worker_scoped(
|
|
1911
1943
|
workspace: &Path,
|
|
1912
1944
|
sender: &str,
|
|
@@ -1943,6 +1975,19 @@ fn arm_turn_open(state: &mut serde_json::Value, recipient: &str, message_id: &Op
|
|
|
1943
1975
|
serde_json::json!({"armed": true, "node_id": recipient, "turn_id": message_id}),
|
|
1944
1976
|
);
|
|
1945
1977
|
}
|
|
1978
|
+
if let Some(agent) = root
|
|
1979
|
+
.get_mut("agents")
|
|
1980
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
1981
|
+
.and_then(|agents| agents.get_mut(recipient))
|
|
1982
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
1983
|
+
{
|
|
1984
|
+
agent.insert(
|
|
1985
|
+
"current_task_id".to_string(),
|
|
1986
|
+
message_id.as_ref().map_or(serde_json::Value::Null, |id| {
|
|
1987
|
+
serde_json::Value::String(id.clone())
|
|
1988
|
+
}),
|
|
1989
|
+
);
|
|
1990
|
+
}
|
|
1946
1991
|
}
|
|
1947
1992
|
|
|
1948
1993
|
/// `_stamp_first_send_at_if_leader_to_worker` (`delivery.py:380`):首次 leader→worker 投递戳
|
|
@@ -1575,6 +1575,17 @@ fn deliver_pending_submit_unverified_is_not_saved_by_readback() {
|
|
|
1575
1575
|
"0.3.30: positive readback alone must not mark delivered when submit is unverified"
|
|
1576
1576
|
);
|
|
1577
1577
|
assert_ne!(out.message_status.0, "delivered");
|
|
1578
|
+
let updated = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
1579
|
+
assert_eq!(
|
|
1580
|
+
updated.pointer("/coordinator/turn_open/turn_id"),
|
|
1581
|
+
Some(&serde_json::json!(message_id)),
|
|
1582
|
+
"current-turn fact is armed immediately after physical inject succeeds"
|
|
1583
|
+
);
|
|
1584
|
+
assert_eq!(
|
|
1585
|
+
updated.pointer("/agents/w1/current_task_id"),
|
|
1586
|
+
Some(&serde_json::json!(message_id)),
|
|
1587
|
+
"agent current_task_id is armed without marking the message delivered"
|
|
1588
|
+
);
|
|
1578
1589
|
}
|
|
1579
1590
|
|
|
1580
1591
|
#[test]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.5.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.5.7",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.7",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.7"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|