@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 CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.5"
578
+ version = "0.5.7"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.5"
12
+ version = "0.5.7"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -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(CmdResult::from_json(value, args.json));
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(CmdResult::from_json(value, args.json));
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(CmdResult::from_json(amb, args.json));
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(CmdResult::from_json(value, args.json))
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!(crate::cli::SEND_REMINDER));
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 r = cmd_send(&send_args_fixture()).expect("cmd_send returns CmdResult");
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 r = cmd_send(&send_args_fixture()).expect("cmd_send returns CmdResult");
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 remove_agent_at_paths(
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<RemoveAgentOutcome, LifecycleError> {
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
- if !dynamic_agent && !from_spec {
84
- return Ok(RemoveAgentOutcome::RefusedFromSpecConfirm {
85
- agent_id: agent_id.clone(),
86
- });
87
- }
88
- if agent_is_running(&state, agent_id, transport) && !force {
89
- return Ok(RemoveAgentOutcome::RefusedForceRequired {
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(paths.run_workspace, paths.spec_workspace, &spec, &state, agent_id)?;
98
- rollback.restore_running = force && agent_is_running(&state, agent_id, transport);
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
- /// Find the most recent delivered direct message to `agent_id` whose `task_id`
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
- let sql = match owner_team_id {
291
- Some(_) => "select m.message_id from messages m \
292
- where m.recipient = ?1 and m.status = 'delivered' \
293
- and (m.task_id is null or m.task_id = '') \
294
- and m.owner_team_id = ?2 \
295
- and not exists ( \
296
- select 1 from results r \
297
- where r.task_id = m.message_id and r.agent_id = m.recipient \
298
- ) \
299
- order by m.created_at desc limit 1",
300
- None => "select m.message_id from messages m \
301
- where m.recipient = ?1 and m.status = 'delivered' \
302
- and (m.task_id is null or m.task_id = '') \
303
- and not exists ( \
304
- select 1 from results r \
305
- where r.task_id = m.message_id and r.agent_id = m.recipient \
306
- ) \
307
- order by m.created_at desc limit 1",
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
- id
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, latest_task_for_assignee,
103
- non_empty_string, normalize_token, normalized_envelope_value, object_fields, text_field,
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, latest_uncorrelated_delivered_message_for,
25
- non_empty_string, normalized_envelope_value, object_fields, requires_ack_for_target,
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. latest delivered direct message to this agent with no
330
- // task id and no result yet (message-scope correlation —
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
- latest_uncorrelated_delivered_message_for(
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 注入**成功后**才调 `record_turn_open_after_delivery`,绝不凭空 arm。
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.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.5",
24
- "@team-agent/cli-darwin-x64": "0.5.5",
25
- "@team-agent/cli-linux-x64": "0.5.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",