@team-agent/installer 0.5.80 → 0.5.81

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
@@ -581,7 +581,7 @@ dependencies = [
581
581
 
582
582
  [[package]]
583
583
  name = "team-agent"
584
- version = "0.5.80"
584
+ version = "0.5.81"
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.81"
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 }
@@ -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
  }
@@ -24,14 +24,10 @@ pub(crate) fn update_state(
24
24
  }
25
25
  Err(err) => return Err(tool_runtime_error(err)),
26
26
  };
27
- let mut state = selected.state;
28
- ensure_object(&mut state);
29
- append_note(&mut state, note);
30
- crate::state::repository::StateRepository::new(&selected.run_workspace).save_reapplying(
27
+ let state = crate::state::repository::StateRepository::new(&selected.run_workspace).commit(
31
28
  crate::state::repository::StateWriteIntent::McpUpdateStateNote {
32
29
  team_key: Some(&selected.team_key),
33
30
  },
34
- &state,
35
31
  |latest| {
36
32
  ensure_object(latest);
37
33
  append_note(latest, note);
@@ -65,15 +61,10 @@ fn update_state_without_spec(
65
61
  crate::state::selector::SelectorMode::RuntimeOnly,
66
62
  )
67
63
  .map_err(tool_runtime_error)?;
68
- let mut state = selected.state;
69
- ensure_object(&mut state);
70
- seed_legacy_team_key(&mut state, &selected.run_workspace, &selected.team_key);
71
- append_note(&mut state, note);
72
- crate::state::repository::StateRepository::new(&selected.run_workspace).save_reapplying(
64
+ let state = crate::state::repository::StateRepository::new(&selected.run_workspace).commit(
73
65
  crate::state::repository::StateWriteIntent::McpUpdateStateNote {
74
66
  team_key: Some(&selected.team_key),
75
67
  },
76
- &state,
77
68
  |latest| {
78
69
  ensure_object(latest);
79
70
  seed_legacy_team_key(latest, &selected.run_workspace, &selected.team_key);
@@ -142,14 +142,12 @@ impl TeamOrchestratorTools {
142
142
  .canonical_owner_team_key()?
143
143
  .map(|team| team.as_str().to_string())
144
144
  .or_else(|| assignment_team_key(&state));
145
- reconcile_assigned_task(&mut state, team_key.as_deref(), &task_value);
146
145
  crate::state::repository::StateRepository::new(&self.workspace)
147
- .save_reapplying(
146
+ .commit(
148
147
  crate::state::repository::StateWriteIntent::McpAssignTask {
149
148
  team_key: team_key.as_deref(),
150
149
  task_id,
151
150
  },
152
- &state,
153
151
  |latest| {
154
152
  ensure_object(latest);
155
153
  let latest_team_key = team_key.clone().or_else(|| assignment_team_key(latest));
@@ -287,9 +287,8 @@ fn collect_scoped(
287
287
  collected_results.push(summary);
288
288
  }
289
289
  if state_dirty {
290
- crate::state::repository::StateRepository::new(&paths.run_workspace).save_reapplying(
290
+ state = crate::state::repository::StateRepository::new(&paths.run_workspace).commit(
291
291
  crate::state::repository::StateWriteIntent::ResultCollection { owner_team_id },
292
- &state,
293
292
  |latest| {
294
293
  for (task_id, result_id) in &task_updates {
295
294
  mark_task_done(latest, task_id, result_id);
@@ -591,8 +591,48 @@ fn persist_runtime_state_with_merge_options_and_expected(
591
591
  // on-disk latest. Re-strip here so the serialized payload is always
592
592
  // canonical-only when a canonical teams.<key>.team_owner is present.
593
593
  crate::state::ownership::strip_top_level_ownership_if_canonical_present(&mut migrated);
594
- // 字节对拍 Python json.dumps(indent=2, ensure_ascii=False)(无尾换行)。
595
- let payload = serde_json::to_string_pretty(&migrated)?;
594
+ write_state_under_lock(workspace, &path, &migrated)
595
+ }
596
+
597
+ /// Apply a bounded writer to disk under state-save, without a cache shortcut.
598
+ pub(super) fn update_runtime_state(
599
+ workspace: &Path,
600
+ update: impl FnOnce(&Value) -> Result<Value, StateError>,
601
+ ) -> Result<Value, StateError> {
602
+ let path = runtime_state_path(workspace);
603
+ let _lock = RuntimeLock::acquire(workspace, "state-save", 2.0)?;
604
+ // Malformed/unreadable state is an error, never an empty store.
605
+ let mut latest = match std::fs::read_to_string(&path) {
606
+ Ok(text) => serde_json::from_str(&text)?,
607
+ Err(error) if error.kind() == io::ErrorKind::NotFound => json!({}),
608
+ Err(error) => return Err(error.into()),
609
+ };
610
+ normalize_agent_session_state(&mut latest);
611
+ migrate_state_identity(&mut latest, &SystemEnv, workspace)?;
612
+ let _ = migrate_active_team_key(&mut latest);
613
+ let mut committed = update(&latest)?;
614
+ merge_ordinary_state(&mut committed, &latest)?;
615
+ migrate_state_identity(&mut committed, &SystemEnv, workspace)?;
616
+ crate::state::ownership::strip_top_level_ownership_if_canonical_present(&mut committed);
617
+ if committed == latest {
618
+ cache_set(&path, &committed);
619
+ return Ok(committed);
620
+ }
621
+ write_state_under_lock(workspace, &path, &committed)?;
622
+ Ok(committed)
623
+ }
624
+
625
+ /// Reuse ordinary roster, topology, ownership, capture and endpoint guards.
626
+ pub(super) fn merge_ordinary_state(incoming: &mut Value, latest: &Value) -> Result<(), StateError> {
627
+ apply_persist_merge_contract(
628
+ incoming, latest, &BTreeSet::new(), None, &BTreeSet::new(),
629
+ &BTreeSet::new(), &BTreeSet::new(), None, None, None,
630
+ )
631
+ }
632
+
633
+ fn write_state_under_lock(workspace: &Path, path: &Path, migrated: &Value) -> Result<(), StateError> {
634
+ // Shared retry/self-heal/atomic replacement path; caller owns state-save.
635
+ let payload = serde_json::to_string_pretty(migrated)?;
596
636
  let delays = [0.05_f64, 0.2, 0.5];
597
637
 
598
638
  for attempt in 0..=delays.len() {
@@ -775,6 +775,30 @@ pub fn save_team_scoped_state(workspace: &Path, team_state: &Value) -> Result<()
775
775
  save_team_scoped_state_with_deleted_agents(workspace, team_state, &[])
776
776
  }
777
777
 
778
+ /// Materialize a bounded Team update from the lock-held workspace. Siblings
779
+ /// come only from latest; retain the existing primary/legacy projection rules.
780
+ pub(super) fn merge_committed_team(existing: &Value, team_state: &Value, target_key: &str) -> Value {
781
+ let mut primary = existing.get("session_name").and_then(Value::as_str)
782
+ .filter(|name| !name.is_empty()).map(|_| team_state_key(existing));
783
+ if primary.as_deref().is_some_and(|key| key != target_key)
784
+ && existing.get("session_name") == team_state.get("session_name")
785
+ {
786
+ primary = Some(target_key.to_string());
787
+ }
788
+ let mut teams = take_object(existing.get("teams"));
789
+ if teams.is_empty() && primary.as_deref() == Some(target_key) {
790
+ return compact_team_state(team_state);
791
+ }
792
+ teams.insert(target_key.to_string(), compact_team_state(team_state));
793
+ let mut merged = if primary.is_none() || primary.as_deref() == Some(target_key) {
794
+ to_object(team_state)
795
+ } else {
796
+ to_object(existing)
797
+ };
798
+ merged.insert("teams".to_string(), Value::Object(teams));
799
+ Value::Object(merged)
800
+ }
801
+
778
802
  pub(crate) fn save_team_scoped_state_reapplying_after_conflict<F>(
779
803
  workspace: &Path,
780
804
  team_state: &Value,
@@ -46,3 +46,84 @@ fn optional_unmigrated_read_preserves_missing_corrupt_and_valid_shapes(
46
46
  std::fs::remove_dir_all(workspace)?;
47
47
  Ok(())
48
48
  }
49
+
50
+ #[test]
51
+ fn coordinator_observations_keep_legacy_identity_and_shape()
52
+ -> Result<(), Box<dyn std::error::Error>> {
53
+ let nonce = std::time::SystemTime::now()
54
+ .duration_since(std::time::UNIX_EPOCH)?
55
+ .as_nanos();
56
+ let workspace =
57
+ std::env::temp_dir().join(format!("state-repository-legacy-observation-{nonce}"));
58
+ let initial = serde_json::json!({
59
+ "active_team_key": "team",
60
+ "agents": {"w1": {"agent_id": "w1", "provider": "codex", "status": "running"}}
61
+ });
62
+ let path = super::helper_workspace_path(&workspace);
63
+ if let Some(parent) = path.parent() {
64
+ std::fs::create_dir_all(parent)?;
65
+ }
66
+ std::fs::write(&path, serde_json::to_vec(&initial)?)?;
67
+ let repository = super::StateRepository::new(&workspace);
68
+ let before = repository.load_workspace()?;
69
+
70
+ let mut observed = before.clone();
71
+ observed["coordinator"] = serde_json::json!({
72
+ "abnormal_exit_watch": {"w1": {"last_error_observation_key": "error-1"}}
73
+ });
74
+ repository.commit_observations(
75
+ StateWriteIntent::CoordinatorTick {
76
+ team_key: "current",
77
+ },
78
+ &before,
79
+ &observed,
80
+ )?;
81
+ let first = repository.load_workspace()?;
82
+ assert!(
83
+ first.get("teams").is_none(),
84
+ "legacy observation must not synthesize teams.current"
85
+ );
86
+ assert_eq!(first["active_team_key"], serde_json::json!("team"));
87
+ assert_eq!(
88
+ first["coordinator"]["abnormal_exit_watch"]["w1"]["last_error_observation_key"],
89
+ serde_json::json!("error-1")
90
+ );
91
+
92
+ let mut observed = first.clone();
93
+ observed["coordinator"]["abnormal_exit_watch"]["w1"]["last_error_observation_key"] =
94
+ serde_json::json!("error-2");
95
+ repository.commit_observations(
96
+ StateWriteIntent::CoordinatorTick {
97
+ team_key: "current",
98
+ },
99
+ &first,
100
+ &observed,
101
+ )?;
102
+ let second = repository.load_workspace()?;
103
+ assert!(
104
+ second.get("teams").is_none(),
105
+ "legacy shape must remain root-only"
106
+ );
107
+ assert_eq!(second["active_team_key"], serde_json::json!("team"));
108
+ assert_eq!(
109
+ second["coordinator"]["abnormal_exit_watch"]["w1"]["last_error_observation_key"],
110
+ serde_json::json!("error-2")
111
+ );
112
+
113
+ let mut changed_latest = second.clone();
114
+ changed_latest["active_team_key"] = serde_json::json!("other");
115
+ std::fs::write(&path, serde_json::to_vec(&changed_latest)?)?;
116
+ let err = repository
117
+ .commit_observations(
118
+ StateWriteIntent::CoordinatorTick {
119
+ team_key: "current",
120
+ },
121
+ &second,
122
+ &observed,
123
+ )
124
+ .expect_err("identity drift must refuse the stale legacy observation");
125
+ assert!(matches!(err, super::StateError::SaveConflict(_)));
126
+
127
+ std::fs::remove_dir_all(workspace)?;
128
+ Ok(())
129
+ }
@@ -1,5 +1,7 @@
1
1
  //!
2
- //! StateRepository facade.
2
+ //! StateRepository facade. Task/note commits apply bounded mutations under the
3
+ //! existing state-save lock; coordinator commits carry only observation deltas.
4
+ //! Other writer clusters retain their legacy dispatch below.
3
5
  //!
4
6
  //! Ref:
5
7
  //! - `.team/artifacts/s1a-state-repository-design.md` (skeleton)
@@ -142,8 +144,152 @@ impl<'a> StateRepository<'a> {
142
144
  where
143
145
  F: FnOnce(&mut Value),
144
146
  {
147
+ #[cfg(test)]
148
+ test_support::before_commit();
145
149
  route_reapply(self.workspace, intent, state, reapply)
146
150
  }
151
+
152
+ /// Commit a task/note delta once against latest under state-save. Return
153
+ /// the committed Team view for callers that produce Markdown afterwards.
154
+ pub fn commit<F>(&self, intent: StateWriteIntent<'_>, update: F) -> Result<Value, StateError>
155
+ where
156
+ F: FnOnce(&mut Value),
157
+ {
158
+ #[cfg(test)]
159
+ test_support::before_commit();
160
+ let team_key = match intent {
161
+ // Assignment's existing reconcile helper updates the root task
162
+ // array and the explicit Team array independently (legacy parity).
163
+ StateWriteIntent::McpAssignTask { .. } => None,
164
+ StateWriteIntent::McpUpdateStateNote { team_key } => team_key,
165
+ StateWriteIntent::ResultCollection { owner_team_id } => owner_team_id,
166
+ _ => return Err(StateError::SaveFailed("intent does not accept a task/note delta".into())),
167
+ };
168
+ let committed = super::persist::update_runtime_state(self.workspace, |latest| {
169
+ let mut selected = bounded_team_view(latest, team_key)?;
170
+ update(&mut selected);
171
+ Ok(match team_key {
172
+ Some(key) => super::projection::merge_committed_team(latest, &selected, key),
173
+ None => selected,
174
+ })
175
+ })?;
176
+ bounded_team_view(&committed, team_key)
177
+ }
178
+
179
+ /// Fence the sampled incarnation with the existing persist guards, then
180
+ /// commit only this tick's agent observations and coordinator bookkeeping.
181
+ pub fn commit_observations(
182
+ &self,
183
+ intent: StateWriteIntent<'_>,
184
+ before: &Value,
185
+ observed: &Value,
186
+ ) -> Result<(), StateError> {
187
+ let StateWriteIntent::CoordinatorTick { team_key } = intent else {
188
+ return Err(StateError::SaveFailed("intent does not accept observations".into()));
189
+ };
190
+ let sampled_legacy =
191
+ legacy_single_team_state(before) && legacy_single_team_state(observed);
192
+ let sampled_identity = legacy_team_identity(before);
193
+ super::persist::update_runtime_state(self.workspace, |latest| {
194
+ if sampled_legacy {
195
+ if sampled_identity != legacy_team_identity(observed)
196
+ || !legacy_single_team_state(latest)
197
+ || sampled_identity != legacy_team_identity(latest)
198
+ {
199
+ return Err(StateError::SaveConflict(
200
+ "legacy coordinator observation scope changed before commit".to_string(),
201
+ ));
202
+ }
203
+ let mut checked = observed.clone();
204
+ super::persist::merge_ordinary_state(&mut checked, latest)?;
205
+ if !legacy_single_team_state(&checked)
206
+ || sampled_identity != legacy_team_identity(&checked)
207
+ {
208
+ return Err(StateError::SaveConflict(
209
+ "legacy coordinator observation scope changed during validation"
210
+ .to_string(),
211
+ ));
212
+ }
213
+ let mut selected = latest.clone();
214
+ for field in ["agents", "coordinator"] {
215
+ if before[field] != checked[field] {
216
+ apply_observation_delta(
217
+ &before[field],
218
+ &checked[field],
219
+ &mut selected[field],
220
+ );
221
+ }
222
+ }
223
+ return Ok(selected);
224
+ }
225
+ let mut checked = super::projection::merge_committed_team(latest, observed, team_key);
226
+ super::persist::merge_ordinary_state(&mut checked, latest)?;
227
+ let checked = bounded_team_view(&checked, Some(team_key))?;
228
+ let mut selected = bounded_team_view(latest, Some(team_key))?;
229
+ for field in ["agents", "coordinator"] {
230
+ if before[field] != checked[field] {
231
+ apply_observation_delta(&before[field], &checked[field], &mut selected[field]);
232
+ }
233
+ }
234
+ Ok(super::projection::merge_committed_team(latest, &selected, team_key))
235
+ })?;
236
+ Ok(())
237
+ }
238
+ }
239
+
240
+ fn legacy_single_team_state(state: &Value) -> bool {
241
+ match state.get("teams") {
242
+ None => true,
243
+ Some(Value::Object(teams)) => teams.is_empty(),
244
+ Some(_) => false,
245
+ }
246
+ }
247
+
248
+ fn legacy_team_identity(state: &Value) -> [Option<String>; 5] {
249
+ [
250
+ "active_team_key",
251
+ "team_key",
252
+ "session_name",
253
+ "team_dir",
254
+ "spec_path",
255
+ ]
256
+ .map(|field| {
257
+ state
258
+ .get(field)
259
+ .and_then(Value::as_str)
260
+ .filter(|value| !value.is_empty())
261
+ .map(str::to_string)
262
+ })
263
+ }
264
+
265
+ fn bounded_team_view(state: &Value, team_key: Option<&str>) -> Result<Value, StateError> {
266
+ let Some(key) = team_key else { return Ok(state.clone()); };
267
+ if state.get("teams").and_then(Value::as_object).is_some_and(|teams| teams.contains_key(key)) {
268
+ return Ok(super::projection::project_top_level_view(state, key));
269
+ }
270
+ if state.get("teams").and_then(Value::as_object).is_some_and(|teams| !teams.is_empty()) {
271
+ return Err(StateError::TeamSelect(format!("team no longer exists: {key}")));
272
+ }
273
+ Ok(state.clone())
274
+ }
275
+
276
+ // Diffing is limited to the two observation blocks above, never a whole-state
277
+ // merge. Equal fields retain a concurrent writer's value; removed fields clear.
278
+ fn apply_observation_delta(before: &Value, observed: &Value, latest: &mut Value) {
279
+ if before == observed { return; }
280
+ if let Some(observed) = observed.as_object() {
281
+ if !latest.is_object() { *latest = serde_json::json!({}); }
282
+ if let Some(before) = before.as_object() {
283
+ for key in before.keys().filter(|key| !observed.contains_key(*key)) {
284
+ if let Some(latest) = latest.as_object_mut() { latest.remove(key); }
285
+ }
286
+ }
287
+ for (key, value) in observed {
288
+ apply_observation_delta(&before[key], value, &mut latest[key]);
289
+ }
290
+ } else {
291
+ *latest = observed.clone();
292
+ }
147
293
  }
148
294
 
149
295
  /// Closed semantic catalog of workspace state writes. Every allowlisted
@@ -555,6 +701,11 @@ fn route_reapply<F>(
555
701
  where
556
702
  F: FnOnce(&mut Value),
557
703
  {
704
+ if matches!(&intent, StateWriteIntent::McpAssignTask { .. }
705
+ | StateWriteIntent::McpUpdateStateNote { .. } | StateWriteIntent::ResultCollection { .. })
706
+ {
707
+ return StateRepository::new(workspace).commit(intent, reapply).map(|_| ());
708
+ }
558
709
  if reapply_scope(&intent) == ReapplyScope::Team {
559
710
  helper_write_team_scoped_reapply(workspace, state, reapply)
560
711
  } else {
@@ -564,3 +715,20 @@ where
564
715
 
565
716
  #[cfg(test)]
566
717
  mod tests;
718
+
719
+ #[cfg(test)]
720
+ pub(crate) mod test_support {
721
+ thread_local! {
722
+ static BEFORE_COMMIT: std::cell::RefCell<Option<Box<dyn FnOnce()>>> = const {
723
+ std::cell::RefCell::new(None)
724
+ };
725
+ }
726
+
727
+ pub(crate) fn set_before_commit(hook: impl FnOnce() + 'static) {
728
+ BEFORE_COMMIT.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
729
+ }
730
+
731
+ pub(super) fn before_commit() {
732
+ if let Some(hook) = BEFORE_COMMIT.with(|slot| slot.borrow_mut().take()) { hook(); }
733
+ }
734
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.80",
3
+ "version": "0.5.81",
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.80",
24
- "@team-agent/cli-darwin-x64": "0.5.80",
25
- "@team-agent/cli-linux-x64": "0.5.80"
23
+ "@team-agent/cli-darwin-arm64": "0.5.81",
24
+ "@team-agent/cli-darwin-x64": "0.5.81",
25
+ "@team-agent/cli-linux-x64": "0.5.81"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",