@team-agent/installer 0.5.80 → 0.5.82

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.
Files changed (25) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/status_port/brief.rs +40 -16
  4. package/crates/team-agent/src/cli/tests/status_brief.rs +199 -0
  5. package/crates/team-agent/src/communication_mode/mod.rs +3 -3
  6. package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
  7. package/crates/team-agent/src/coordinator/tests/s3_bounded.rs +239 -0
  8. package/crates/team-agent/src/coordinator/tick.rs +21 -13
  9. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +0 -2
  10. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +15 -4
  11. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +0 -1
  12. package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +3 -4
  13. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +0 -1
  14. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +5 -0
  15. package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +0 -1
  16. package/crates/team-agent/src/lifecycle/worker_command_context.rs +47 -13
  17. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +2 -11
  18. package/crates/team-agent/src/mcp_server/tools.rs +1 -3
  19. package/crates/team-agent/src/messaging/results.rs +1 -2
  20. package/crates/team-agent/src/provider/adapters/pi.rs +6 -6
  21. package/crates/team-agent/src/state/persist.rs +42 -2
  22. package/crates/team-agent/src/state/projection.rs +24 -0
  23. package/crates/team-agent/src/state/repository/tests.rs +81 -0
  24. package/crates/team-agent/src/state/repository.rs +169 -1
  25. package/package.json +4 -4
package/Cargo.lock CHANGED
@@ -581,7 +581,7 @@ dependencies = [
581
581
 
582
582
  [[package]]
583
583
  name = "team-agent"
584
- version = "0.5.80"
584
+ version = "0.5.82"
585
585
  dependencies = [
586
586
  "anyhow",
587
587
  "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.80"
12
+ version = "0.5.82"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -294,24 +294,39 @@ fn display_optional(value: &Value) -> String {
294
294
  }
295
295
 
296
296
  fn registered_nodes(state: &Value, selected: Option<&str>) -> Vec<RegisteredNode> {
297
- let agents = state
298
- .get("agents")
299
- .and_then(Value::as_object)
300
- .or_else(|| {
301
- let key = state.get("active_team_key").and_then(Value::as_str)?;
302
- state
303
- .get("teams")
304
- .and_then(|teams| teams.get(key))
305
- .and_then(|team| team.get("agents"))
306
- .and_then(Value::as_object)
307
- });
297
+ // A projected state can still carry stale top-level agents. Once the
298
+ // selector identified a canonical team entry, that entry is authoritative;
299
+ // an empty/missing nested roster must not fall back to the stale projection.
300
+ let canonical_team = state
301
+ .get("active_team_key")
302
+ .and_then(Value::as_str)
303
+ .and_then(|key| state.get("teams").and_then(|teams| teams.get(key)));
304
+ let (agents, lifecycle, defaults) = match canonical_team {
305
+ Some(team) => (
306
+ team.get("agents").and_then(Value::as_object),
307
+ team.get("agent_lifecycle").and_then(Value::as_object),
308
+ team,
309
+ ),
310
+ None => (
311
+ state.get("agents").and_then(Value::as_object),
312
+ state.get("agent_lifecycle").and_then(Value::as_object),
313
+ state,
314
+ ),
315
+ };
308
316
  let Some(agents) = agents else {
309
317
  return Vec::new();
310
318
  };
311
319
  agents
312
320
  .iter()
313
321
  .filter(|(name, _)| selected.is_none_or(|selected| selected == name.as_str()))
314
- .map(|(name, value)| registered_node(name, value, state))
322
+ .filter(|(name, _)| {
323
+ !lifecycle
324
+ .and_then(|entries| entries.get(*name))
325
+ .and_then(|entry| entry.get("state"))
326
+ .and_then(Value::as_str)
327
+ .is_some_and(|state| state == "retired")
328
+ })
329
+ .map(|(name, value)| registered_node(name, value, defaults))
315
330
  .collect()
316
331
  }
317
332
 
@@ -803,7 +818,7 @@ fn parse_probe(bytes: &[u8], requested_endpoint: &str) -> Result<ProbeResult, ()
803
818
  .and_then(Value::as_array)
804
819
  .ok_or(())?
805
820
  .iter()
806
- .filter_map(parse_probe_node)
821
+ .filter_map(|node| parse_probe_node(node, report_socket))
807
822
  .collect();
808
823
  Ok(ProbeResult { nodes })
809
824
  }
@@ -818,9 +833,12 @@ fn probe_has_error(value: &Value) -> bool {
818
833
  }
819
834
  }
820
835
 
821
- fn parse_probe_node(value: &Value) -> Option<ProbeNode> {
836
+ fn parse_probe_node(value: &Value, report_socket: &str) -> Option<ProbeNode> {
822
837
  Some(ProbeNode {
823
- socket: non_empty(value, "socket")?,
838
+ // The accepted producer binds the socket once at the report envelope
839
+ // and omits the redundant per-node field. An explicit node socket is
840
+ // still preserved for strict mismatch rejection below.
841
+ socket: non_empty(value, "socket").unwrap_or_else(|| report_socket.to_string()),
824
842
  session: non_empty(value, "session")?,
825
843
  window: non_empty(value, "window_name")?,
826
844
  pane: non_empty(value, "pane_id")?,
@@ -926,7 +944,13 @@ fn match_result<'a>(
926
944
  fn matches_registered(node: &RegisteredNode, observed: &ProbeNode) -> bool {
927
945
  node.endpoint.as_deref() == Some(observed.socket.as_str())
928
946
  && node.session.as_deref() == Some(observed.session.as_str())
929
- && node.window.as_deref() == Some(observed.window.as_str())
947
+ // Older registrations may not persist a window. The endpoint,
948
+ // session, and pane tuple remains the narrow compatibility key; a
949
+ // present window is still an exact constraint.
950
+ && node
951
+ .window
952
+ .as_deref()
953
+ .is_none_or(|window| window == observed.window)
930
954
  && node.pane.as_deref() == Some(observed.pane.as_str())
931
955
  }
932
956
 
@@ -685,6 +685,205 @@ fn cmd_status_multi_endpoint_uses_total_budget() {
685
685
  let _ = std::fs::remove_dir_all(&ws);
686
686
  }
687
687
 
688
+ #[cfg(unix)]
689
+ #[test]
690
+ #[serial(status_brief)]
691
+ fn cmd_status_uses_canonical_roster_and_filters_same_team_retired_tombstones() {
692
+ let ws = brief_workspace("canonical-roster");
693
+ let mut stale = serde_json::Map::new();
694
+ for index in 0..220 {
695
+ stale.insert(
696
+ format!("stale-{index}"),
697
+ production_agent(&format!("stale-{index}"), &format!("%{index}")),
698
+ );
699
+ }
700
+ let mut stopped = production_agent("stopped", "%9");
701
+ stopped["status"] = json!("stopped");
702
+ let state = json!({
703
+ "session_name": "team-demo",
704
+ "tmux_endpoint": "/tmp/ta-status-brief.sock",
705
+ "tmux_socket": "/tmp/ta-status-brief.sock",
706
+ "active_team_key": "demo",
707
+ "agents": stale,
708
+ "teams": {
709
+ "demo": {
710
+ "status": "alive",
711
+ "session_name": "team-demo",
712
+ "tmux_endpoint": "/tmp/ta-status-brief.sock",
713
+ "agents": {
714
+ "live": production_agent("live", "%7"),
715
+ "retired": production_agent("retired", "%8"),
716
+ "stopped": stopped
717
+ },
718
+ "agent_lifecycle": {
719
+ "retired": {"state": "retired"}
720
+ }
721
+ },
722
+ "sibling": {
723
+ "status": "alive",
724
+ "agent_lifecycle": {"live": {"state": "retired"}}
725
+ }
726
+ }
727
+ });
728
+ write_state(&ws, &state);
729
+ let bin = write_nodeprobe(
730
+ &ws.join("probe"),
731
+ "printf '%s\\n' '{\"schema_version\":1,\"socket\":\"/tmp/ta-status-brief.sock\",\"nodes\":[]}'\n",
732
+ );
733
+ let nodes = status_port::with_test_nodeprobe(bin, || {
734
+ json_nodes(cmd_status(&status_args(&ws, true, None, Some("demo"))).expect("status"))
735
+ });
736
+ let mut names = nodes
737
+ .iter()
738
+ .filter_map(|node| node["name"].as_str())
739
+ .collect::<Vec<_>>();
740
+ names.sort_unstable();
741
+ assert_eq!(names, vec!["live", "stopped"]);
742
+ assert_eq!(
743
+ nodes
744
+ .iter()
745
+ .find(|node| node["name"] == "stopped")
746
+ .and_then(|node| node["runtime_status"].as_str()),
747
+ Some("stopped")
748
+ );
749
+ let err = cmd_status(&status_args(&ws, true, Some("retired"), Some("demo"))).unwrap_err();
750
+ assert!(err.to_string().contains("unknown agent id: retired"), "{err}");
751
+
752
+ let empty = brief_workspace("canonical-empty");
753
+ write_state(
754
+ &empty,
755
+ &json!({
756
+ "active_team_key": "demo",
757
+ "agents": {"stale": production_agent("stale", "%1")},
758
+ "teams": {
759
+ "demo": {
760
+ "status": "alive",
761
+ "agents": {},
762
+ "agent_lifecycle": {}
763
+ }
764
+ }
765
+ }),
766
+ );
767
+ assert!(json_nodes(cmd_status(&status_args(&empty, true, None, None)).expect("status")).is_empty());
768
+ let _ = std::fs::remove_dir_all(&ws);
769
+ let _ = std::fs::remove_dir_all(&empty);
770
+ }
771
+
772
+ #[cfg(unix)]
773
+ #[test]
774
+ #[serial(status_brief)]
775
+ fn cmd_status_allows_only_missing_window_compatibility_match() {
776
+ let ws = brief_workspace("missing-window");
777
+ let mut agent = production_agent("worker", "%7");
778
+ agent.as_object_mut().unwrap().remove("window");
779
+ agent.as_object_mut().unwrap().remove("layout_window");
780
+ agent
781
+ .get_mut("display")
782
+ .and_then(Value::as_object_mut)
783
+ .unwrap()
784
+ .remove("window");
785
+ write_state(
786
+ &ws,
787
+ &production_state(json!({"worker": agent})),
788
+ );
789
+ let report = producer_report(
790
+ "/tmp/ta-status-brief.sock",
791
+ "observed-window",
792
+ "%7",
793
+ "pi_activity_channel",
794
+ );
795
+ let bin = write_nodeprobe(&ws.join("probe"), &format!("cat <<'EOF'\n{report}\nEOF\n"));
796
+ let nodes = status_port::with_test_nodeprobe(bin, || {
797
+ json_nodes(cmd_status(&status_args(&ws, true, None, None)).expect("status"))
798
+ });
799
+ assert_eq!(nodes[0]["runtime_status"], "running");
800
+ assert_eq!(nodes[0]["activity"], "idle");
801
+ assert_eq!(
802
+ nodes[0]["tmux_command"],
803
+ "tmux -S '/tmp/ta-status-brief.sock' attach -t 'team-demo:observed-window.%7'"
804
+ );
805
+ let _ = std::fs::remove_dir_all(&ws);
806
+
807
+ let conflict = brief_workspace("present-window-conflict");
808
+ write_state(
809
+ &conflict,
810
+ &production_state(json!({"worker": production_agent("worker", "%7")})),
811
+ );
812
+ let report = producer_report(
813
+ "/tmp/ta-status-brief.sock",
814
+ "different-window",
815
+ "%7",
816
+ "pi_activity_channel",
817
+ );
818
+ let bin = write_nodeprobe(&conflict.join("probe"), &format!("cat <<'EOF'\n{report}\nEOF\n"));
819
+ let nodes = status_port::with_test_nodeprobe(bin, || {
820
+ json_nodes(cmd_status(&status_args(&conflict, true, None, None)).expect("status"))
821
+ });
822
+ assert_eq!(nodes[0]["runtime_status"], "unknown");
823
+ assert!(nodes[0]["tmux_command"].is_null());
824
+ let _ = std::fs::remove_dir_all(&conflict);
825
+ }
826
+
827
+ #[cfg(unix)]
828
+ #[test]
829
+ #[serial(status_brief)]
830
+ fn cmd_status_accepts_envelope_bound_node_socket_but_rejects_explicit_mismatch() {
831
+ let ws = brief_workspace("envelope-node-socket");
832
+ write_state(
833
+ &ws,
834
+ &production_state(json!({"worker": production_agent("worker", "%7")})),
835
+ );
836
+ let mut report: Value =
837
+ serde_json::from_str(&producer_report(
838
+ "/tmp/ta-status-brief.sock",
839
+ "worker",
840
+ "%7",
841
+ "pi_activity_channel",
842
+ ))
843
+ .unwrap();
844
+ report["nodes"][0].as_object_mut().unwrap().remove("socket");
845
+ let report = serde_json::to_string(&report).unwrap();
846
+ let bin = write_nodeprobe(&ws.join("probe"), &format!("cat <<'EOF'\n{report}\nEOF\n"));
847
+ let nodes = status_port::with_test_nodeprobe(bin, || {
848
+ json_nodes(cmd_status(&status_args(&ws, true, None, None)).expect("status"))
849
+ });
850
+ assert_eq!(nodes[0]["runtime_status"], "running");
851
+ assert_eq!(nodes[0]["activity"], "idle");
852
+ assert_eq!(nodes[0]["health"], "normal");
853
+ assert_eq!(nodes[0]["session_name"], "pi-session");
854
+ assert_eq!(
855
+ nodes[0]["tmux_command"],
856
+ "tmux -S '/tmp/ta-status-brief.sock' attach -t 'team-demo:worker.%7'"
857
+ );
858
+ let _ = std::fs::remove_dir_all(&ws);
859
+
860
+ let mismatch = brief_workspace("explicit-node-socket-mismatch");
861
+ write_state(
862
+ &mismatch,
863
+ &production_state(json!({"worker": production_agent("worker", "%7")})),
864
+ );
865
+ let mut report: Value =
866
+ serde_json::from_str(&producer_report(
867
+ "/tmp/ta-status-brief.sock",
868
+ "worker",
869
+ "%7",
870
+ "pi_activity_channel",
871
+ ))
872
+ .unwrap();
873
+ report["nodes"][0]["socket"] = json!("/tmp/other.sock");
874
+ let report = serde_json::to_string(&report).unwrap();
875
+ let bin = write_nodeprobe(
876
+ &mismatch.join("probe"),
877
+ &format!("cat <<'EOF'\n{report}\nEOF\n"),
878
+ );
879
+ let nodes = status_port::with_test_nodeprobe(bin, || {
880
+ json_nodes(cmd_status(&status_args(&mismatch, true, None, None)).expect("status"))
881
+ });
882
+ assert_eq!(nodes[0]["runtime_status"], "unknown");
883
+ assert!(nodes[0]["tmux_command"].is_null());
884
+ let _ = std::fs::remove_dir_all(&mismatch);
885
+ }
886
+
688
887
  #[cfg(unix)]
689
888
  fn pid_alive(pid: u32) -> bool {
690
889
  unsafe { libc::kill(pid as i32, 0) == 0 }
@@ -12,9 +12,9 @@ const LEADER_CENTRIC_RUNTIME_CONTRACT: &str = r#"# Team Agent communication cont
12
12
 
13
13
  - Progress, blockers, questions: {send_message}(to='leader', content='...')
14
14
 
15
- When you receive a message from the leader or a teammate, you MUST respond
16
- through MCP tools. Writing a reply in your terminal does nothing — the sender
17
- will never see it."#;
15
+ Respond through Team Agent MCP tools only to actionable requests or questions; writing in your terminal does not deliver it.
16
+
17
+ "#;
18
18
 
19
19
  const ORCHESTRATED_RUNTIME_CONTRACT: &str = r#"# Team Agent communication contract: orchestrated
20
20
 
@@ -282,6 +282,7 @@ fn read_event_log_dir(dir: &std::path::Path) -> Vec<serde_json::Value> {
282
282
  }
283
283
 
284
284
  mod a0_lostupdate;
285
+ mod s3_bounded;
285
286
  mod abnormal;
286
287
  mod api_error_recovery;
287
288
  mod basics;
@@ -0,0 +1,239 @@
1
+ use super::*;
2
+ use serde_json::json;
3
+ use std::io::{BufRead, Write};
4
+ use std::path::{Path, PathBuf};
5
+ use std::process::{Child, Command, Stdio};
6
+ use crate::state::repository::{StateRepository, StateWriteIntent};
7
+
8
+ const WORKER: &str = "coordinator::tests::s3_bounded::s3_process_worker";
9
+
10
+ fn isolated(name: &str, check: impl FnOnce(&Path)) {
11
+ if std::env::var("TA_S3_CASE").as_deref() == Ok(name) {
12
+ check(&std::env::current_dir().unwrap());
13
+ return;
14
+ }
15
+ let dir = std::env::temp_dir().join(format!("ta-s3-{name}-{}", std::process::id()));
16
+ std::fs::create_dir_all(&dir).unwrap();
17
+ let output = Command::new(std::env::current_exe().unwrap())
18
+ .args(["--exact", &format!("coordinator::tests::s3_bounded::{name}"), "--nocapture"])
19
+ .env_clear().env("HOME", dir.join("home")).env("TA_S3_CASE", name)
20
+ .current_dir(&dir).output().unwrap();
21
+ print!("{}", String::from_utf8_lossy(&output.stdout));
22
+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
23
+ std::fs::remove_dir_all(dir).unwrap();
24
+ }
25
+
26
+ fn disk(dir: &Path) -> Value {
27
+ serde_json::from_str(&std::fs::read_to_string(crate::state::persist::runtime_state_path(dir)).unwrap()).unwrap()
28
+ }
29
+
30
+ fn seed(dir: &Path, legacy: bool) {
31
+ std::fs::create_dir_all(dir).unwrap();
32
+ let team = |key: &str| {
33
+ let spec = dir.join(".team/runtime").join(key).join("team.spec.yaml");
34
+ std::fs::create_dir_all(spec.parent().unwrap()).unwrap();
35
+ std::fs::write(&spec, format!("name: {key}\nagents: []\ntasks: []\n")).unwrap();
36
+ json!({
37
+ "session_name": key, "team_dir": dir.join(".team").join(key), "spec_path": spec,
38
+ "agents": {"w1": {"agent_id": "w1", "provider": "fake", "status": "running",
39
+ "window": "w1", "pane_id": "%11", "spawn_epoch": 1,
40
+ "session_id": "fixture-session", "startup_prompts": "handled"}},
41
+ "tasks": [{"id": "original", "title": "Original task", "assignee": "w1", "status": "pending"}],
42
+ "notes": ["original note"]
43
+ })
44
+ };
45
+ let mut state = team("T1");
46
+ state["active_team_key"] = json!("T1");
47
+ if !legacy { state["teams"] = json!({"T1": state.clone(), "T2": team("T2")}); }
48
+ crate::state::persist::save_runtime_state(dir, &state).unwrap();
49
+ MessageStore::open(dir).unwrap();
50
+ std::fs::write(dir.join("result.json"), json!({
51
+ "schema_version": "result_envelope_v1", "result_id": "result-original",
52
+ "task_id": "original", "agent_id": "w1", "status": "success", "summary": "done",
53
+ "artifacts": [], "changes": [], "tests": [], "risks": [], "next_actions": []
54
+ }).to_string()).unwrap();
55
+ }
56
+
57
+ fn task(id: &str) -> Value { json!({"id": id, "title": id, "assignee": "w1"}) }
58
+
59
+ fn start_writer(dir: &Path, action: &str, team: Option<&str>, payload: Value, pause: bool) -> Child {
60
+ let mut child = Command::new(std::env::current_exe().unwrap())
61
+ .args(["--exact", WORKER, "--ignored", "--nocapture"])
62
+ .env_clear().env("HOME", dir.join("home"))
63
+ .env("TA_S3_ACTION", action).env("TA_S3_TEAM", team.unwrap_or(""))
64
+ .env("TA_S3_PAYLOAD", payload.to_string()).env("TA_S3_PAUSE", if pause { "1" } else { "0" })
65
+ .current_dir(dir).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped())
66
+ .spawn().unwrap();
67
+ if pause {
68
+ let mut reader = std::io::BufReader::new(child.stdout.take().unwrap());
69
+ loop {
70
+ let mut line = String::new();
71
+ assert!(reader.read_line(&mut line).unwrap() > 0, "writer exited before barrier");
72
+ print!("{line}");
73
+ if line.contains("S3_READY") { break; }
74
+ }
75
+ child.stdout = Some(reader.into_inner());
76
+ }
77
+ child
78
+ }
79
+
80
+ fn finish(mut child: Child, paused: bool) {
81
+ if paused { child.stdin.take().unwrap().write_all(b"commit\n").unwrap(); }
82
+ let output = child.wait_with_output().unwrap();
83
+ print!("{}", String::from_utf8_lossy(&output.stdout));
84
+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
85
+ }
86
+
87
+ fn write(dir: &Path, action: &str, team: Option<&str>, payload: Value) {
88
+ finish(start_writer(dir, action, team, payload, false), false);
89
+ }
90
+
91
+ #[test]
92
+ #[ignore = "hermetic subprocess entry; parent contracts invoke this exact test"]
93
+ fn s3_process_worker() {
94
+ let dir = std::env::current_dir().unwrap();
95
+ let action = std::env::var("TA_S3_ACTION").unwrap();
96
+ let team = std::env::var("TA_S3_TEAM").unwrap();
97
+ let team = (!team.is_empty()).then_some(team.as_str());
98
+ let payload: Value = serde_json::from_str(&std::env::var("TA_S3_PAYLOAD").unwrap()).unwrap();
99
+ if std::env::var("TA_S3_PAUSE").as_deref() == Ok("1") {
100
+ crate::state::repository::test_support::set_before_commit(|| {
101
+ println!("S3_READY");
102
+ std::io::stdout().flush().unwrap();
103
+ let mut line = String::new();
104
+ std::io::stdin().read_line(&mut line).unwrap();
105
+ assert_eq!(line.trim(), "commit");
106
+ });
107
+ }
108
+ let tools = crate::mcp_server::TeamOrchestratorTools::with_identity(
109
+ &dir, Some(crate::model::ids::AgentId::new("leader")), team.map(crate::model::ids::TeamKey::new),
110
+ );
111
+ match action.as_str() {
112
+ "assign" => { tools.assign_task(&payload, Some("bounded contract")).unwrap(); }
113
+ "note" => { println!("S3 note={:?}", tools.update_state(payload.as_str().unwrap()).unwrap()); }
114
+ "collect" => {
115
+ let output = crate::messaging::results::collect_for_team(&dir, Some(&dir.join("result.json")), false, team).unwrap();
116
+ assert_eq!(output["ok"], true);
117
+ println!("S3 collect={output}");
118
+ }
119
+ "sibling" => {
120
+ let mut state = StateRepository::new(&dir).load_workspace().unwrap();
121
+ state["teams"]["T3"] = json!({"session_name": "T3", "tasks": [task("third")], "notes": ["third"]});
122
+ StateRepository::new(&dir).save(StateWriteIntent::LaunchTeam { team_key: "T3" }, &state).unwrap();
123
+ }
124
+ "topology" => {
125
+ let mut state = StateRepository::new(&dir).load_team(Some("T1")).unwrap();
126
+ state["agents"]["w1"]["pane_id"] = json!("%22");
127
+ state["agents"]["w1"]["spawn_epoch"] = json!(2);
128
+ // A concurrent restart owns topology for this agent. StartAgent's
129
+ // add-only tail intentionally carries no existing-agent authority.
130
+ StateRepository::new(&dir).save(StateWriteIntent::RestartTeam {
131
+ team_key: "T1", topology_authority_agent_ids: &["w1"],
132
+ skip_capture_backfill_agent_ids: &[],
133
+ }, &state).unwrap();
134
+ assert_eq!(disk(&dir)["teams"]["T1"]["agents"]["w1"]["pane_id"], "%22");
135
+ }
136
+ _ => panic!("unexpected test action"),
137
+ }
138
+ println!("S3_COMMITTED {action}");
139
+ }
140
+
141
+ fn coordinator(dir: &Path, hook: SaveHook) -> Coordinator {
142
+ let transport = OfflineTransport::new().with_session_present(true)
143
+ .with_windows(vec![WindowName::new("live-peer")]);
144
+ Coordinator::for_test(WorkspacePath::new(dir.to_path_buf()),
145
+ Box::new(MockRegistry::new(&[], &[])), Box::new(transport), None, None)
146
+ .with_before_save_hook(hook)
147
+ }
148
+
149
+ #[test]
150
+ fn s3_tick_preserves_tasks_notes_and_siblings() {
151
+ isolated("s3_tick_preserves_tasks_notes_and_siblings", |dir| {
152
+ seed(dir, false);
153
+ let hook: SaveHook = Box::new(|ws, observed| {
154
+ assert_eq!(observed["agents"]["w1"]["worker_state"], "DEAD", "tick must produce a real observation delta");
155
+ write(ws.as_path(), "assign", Some("T1"), task("new-task"));
156
+ write(ws.as_path(), "note", Some("T1"), json!("concurrent note"));
157
+ write(ws.as_path(), "collect", Some("T1"), Value::Null);
158
+ write(ws.as_path(), "note", Some("T2"), json!("sibling note"));
159
+ write(ws.as_path(), "sibling", None, Value::Null);
160
+ let latest = disk(ws.as_path());
161
+ assert_eq!(latest["teams"]["T1"]["agents"]["w1"]["pane_id"], observed["agents"]["w1"]["pane_id"]);
162
+ println!("S3 before tick save topology unchanged; committed business={}", latest["teams"]);
163
+ Ok(())
164
+ });
165
+ let report = coordinator(dir, hook).tick().unwrap();
166
+ assert!(report.ok, "tick must save its observation: {report:?}");
167
+ let state = disk(dir);
168
+ let team = &state["teams"]["T1"];
169
+ assert_eq!(team["tasks"][0]["accepted_result_id"], "result-original");
170
+ assert!(team["tasks"].as_array().unwrap().iter().any(|row| row["id"] == "new-task"));
171
+ assert_eq!(team["notes"], json!(["original note", "concurrent note"]));
172
+ assert_eq!(team["agents"]["w1"]["worker_state"], "DEAD");
173
+ assert_eq!(state["teams"]["T2"]["notes"], json!(["original note", "sibling note"]));
174
+ assert_eq!(state["teams"]["T3"]["notes"], json!(["third"]));
175
+ assert_eq!(state["tasks"], team["tasks"]);
176
+ assert_eq!(StateRepository::new(dir).load_team(Some("T1")).unwrap()["tasks"], team["tasks"]);
177
+ println!("S3 after tick save={state}");
178
+ });
179
+ }
180
+
181
+ #[test]
182
+ fn s3_business_writers_commit_once_on_latest() {
183
+ isolated("s3_business_writers_commit_once_on_latest", |root| {
184
+ for (i, (left, right)) in [
185
+ (("assign", task("left")), ("assign", task("right"))),
186
+ (("note", json!("same note")), ("note", json!("same note"))),
187
+ (("note", json!("same note")), ("assign", task("right"))),
188
+ (("collect", Value::Null), ("assign", task("right"))),
189
+ (("collect", Value::Null), ("note", json!("same note"))),
190
+ ].into_iter().enumerate() {
191
+ let dir = root.join(i.to_string());
192
+ seed(&dir, false);
193
+ let waiting = start_writer(&dir, left.0, Some("T1"), left.1, true);
194
+ write(&dir, right.0, Some("T1"), right.1);
195
+ finish(waiting, true);
196
+ let state = disk(&dir);
197
+ let team = &state["teams"]["T1"];
198
+ if left.0 == "assign" { assert!(team["tasks"].as_array().unwrap().iter().any(|task| task["id"] == "left")); }
199
+ if right.0 == "assign" { assert!(team["tasks"].as_array().unwrap().iter().any(|task| task["id"] == "right")); }
200
+ if left.0 == "collect" { assert_eq!(team["tasks"][0]["accepted_result_id"], "result-original"); }
201
+ let notes = usize::from(left.0 == "note") + usize::from(right.0 == "note");
202
+ assert_eq!(team["notes"].as_array().unwrap().len(), 1 + notes);
203
+ if left.0 == "note" && right.0 == "assign" {
204
+ let markdown = std::fs::read_to_string(dir.join(".team/runtime/T1/team_state.md")).unwrap();
205
+ assert!(markdown.contains("right"), "note must render the committed task view: {markdown}");
206
+ }
207
+ if left.0 == "collect" {
208
+ write(&dir, "assign", Some("T1"), json!({"id": "original", "assignee": "w1", "title": "edited"}));
209
+ assert_eq!(disk(&dir)["teams"]["T1"]["tasks"][0]["accepted_result_id"], "result-original");
210
+ }
211
+ println!("S3 pair {i} canonical={team}");
212
+ }
213
+ });
214
+ }
215
+
216
+ #[test]
217
+ fn s3_legacy_note_and_collection_keep_independent_updates() {
218
+ isolated("s3_legacy_note_and_collection_keep_independent_updates", |dir| {
219
+ seed(dir, true);
220
+ let waiting = start_writer(dir, "collect", None, Value::Null, true);
221
+ write(dir, "note", None, json!("legacy note"));
222
+ finish(waiting, true);
223
+ let state = disk(dir);
224
+ assert_eq!(state["tasks"][0]["accepted_result_id"], "result-original");
225
+ assert_eq!(state["notes"], json!(["original note", "legacy note"]));
226
+ assert!(state.get("teams").is_none(), "legacy single Team shape remains valid");
227
+ });
228
+ }
229
+
230
+ #[test]
231
+ fn s3_observation_keeps_live_topology_conflict() {
232
+ isolated("s3_observation_keeps_live_topology_conflict", |dir| {
233
+ seed(dir, false);
234
+ let hook: SaveHook = Box::new(|ws, _| { write(ws.as_path(), "topology", Some("T1"), Value::Null); Ok(()) });
235
+ let report = coordinator(dir, hook).tick().unwrap();
236
+ assert_eq!(report.reason, Some(TickStopReason::PersistenceDegraded));
237
+ assert_eq!(disk(dir)["teams"]["T1"]["agents"]["w1"]["pane_id"], "%22");
238
+ });
239
+ }
@@ -193,6 +193,8 @@ pub struct Coordinator {
193
193
  /// bug-084 save 注入钩。`None` ⇔ 真实 `state::save_runtime_state`。
194
194
  #[allow(dead_code)]
195
195
  save_hook: Option<SaveHook>,
196
+ #[cfg(test)]
197
+ before_save_hook: Option<SaveHook>,
196
198
  /// tick 副作用 ORDER 探针。`None` ⇔ 不记录(生产)。
197
199
  #[allow(dead_code)]
198
200
  order_recorder: Option<OrderRecorder>,
@@ -219,6 +221,8 @@ impl Coordinator {
219
221
  transport,
220
222
  daemon_team_key: None,
221
223
  save_hook: None,
224
+ #[cfg(test)]
225
+ before_save_hook: None,
222
226
  order_recorder: None,
223
227
  }
224
228
  }
@@ -263,10 +267,18 @@ impl Coordinator {
263
267
  transport,
264
268
  daemon_team_key: None,
265
269
  save_hook,
270
+ before_save_hook: None,
266
271
  order_recorder,
267
272
  }
268
273
  }
269
274
 
275
+ /// A test barrier before the real repository save; never replaces the save.
276
+ #[cfg(test)]
277
+ pub(crate) fn with_before_save_hook(mut self, hook: SaveHook) -> Self {
278
+ self.before_save_hook = Some(hook);
279
+ self
280
+ }
281
+
270
282
  // ── tick 编排(lifecycle.py:250-385)──────────────────────────────────────
271
283
 
272
284
  /// 单次 tick(`coordinator_tick`,`lifecycle.py:250`)。固定顺序串 step 8-11 原子:
@@ -308,6 +320,7 @@ impl Coordinator {
308
320
  self.daemon_team_key.as_deref(),
309
321
  )
310
322
  .unwrap_or(raw_state);
323
+ let before_observation = state.clone();
311
324
  let store = crate::message_store::MessageStore::open(self.workspace.as_path())?;
312
325
  let event_log = EventLog::new(self.workspace.as_path());
313
326
  increment_coordinator_tick_iteration_count(&self.workspace);
@@ -487,26 +500,21 @@ impl Coordinator {
487
500
  };
488
501
 
489
502
  self.record_step(TickStepGroup::Persist, "atomic_save");
503
+ #[cfg(test)]
504
+ if let Some(hook) = &self.before_save_hook {
505
+ hook(&self.workspace, &state)?;
506
+ }
490
507
  let saved = match &self.save_hook {
491
508
  Some(hook) => hook(&self.workspace, &state),
492
509
  None => {
493
- // 0.5.42 S1b (s1b-writer-cluster-locate.md §3.2 /
494
- // §4.4): the daemon tick's single terminal save
495
- // routes through `StateRepository` with the
496
- // `CoordinatorTick` intent. Repository dispatch is
497
- // byte-identical to the old `save_team_scoped_state`
498
- // helper (see `state/repository.rs:CoordinatorTick =>
499
- // helper_write_team_scoped`), so persist/merge/lock/
500
- // atomic-rename semantics and degraded-mapping stay
501
- // unchanged. No cached repository, no extra load, no
502
- // retry — the caller still owns the `Value` and
503
- // failure still returns `TickReport{PersistenceDegraded}`
504
- // via the `saved.is_err()` branch below.
510
+ // Keep the sampled topology guard, but commit only this tick's
511
+ // observations onto latest so task/note writers cannot be lost.
505
512
  let team_key = crate::state::projection::team_state_key(&state);
506
- crate::state::repository::StateRepository::new(self.workspace.as_path()).save(
513
+ crate::state::repository::StateRepository::new(self.workspace.as_path()).commit_observations(
507
514
  crate::state::repository::StateWriteIntent::CoordinatorTick {
508
515
  team_key: team_key.as_str(),
509
516
  },
517
+ &before_observation,
510
518
  &state,
511
519
  )
512
520
  }