@team-agent/installer 0.5.23 → 0.5.25

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.23"
578
+ version = "0.5.25"
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.23"
12
+ version = "0.5.25"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -171,10 +171,23 @@ pub(crate) fn diagnose_runtime_for_workspace(
171
171
  backend: &dyn Transport,
172
172
  ) -> (Value, Value) {
173
173
  let (mut issues, mut repairs) = diagnose_runtime(state, backend);
174
+ append_legacy_snapshot_issue(workspace, state, &mut issues);
174
175
  append_coordinator_health_issue(workspace, state, &mut issues, &mut repairs);
175
176
  (issues, repairs)
176
177
  }
177
178
 
179
+ fn append_legacy_snapshot_issue(workspace: &std::path::Path, state: &Value, issues: &mut Value) {
180
+ let Ok(Some(details)) = crate::leader::detect_dual_state_divergence(workspace, state) else {
181
+ return;
182
+ };
183
+ if let Some(items) = issues.as_array_mut() {
184
+ items.push(json!({
185
+ "id": "legacy_snapshot_stale",
186
+ "details": details,
187
+ }));
188
+ }
189
+ }
190
+
178
191
  fn append_coordinator_health_issue(
179
192
  workspace: &std::path::Path,
180
193
  state: &Value,
@@ -1615,33 +1615,14 @@ fn state_owner(state: &Value) -> Option<TeamOwner> {
1615
1615
  .and_then(|v| serde_json::from_value(v).ok())
1616
1616
  }
1617
1617
 
1618
- /// `_write_lease_dual_state`(card §85 C17;`__init__.py:588`)。同一锁内写 workspace state.json
1619
- /// + team/<session> snapshot,两份永不分叉。**CROSS-LANE**:snapshot 写经 step 13 restart。
1618
+ /// `_write_lease_dual_state` Foundation-0 F0-2: the historical dual
1619
+ /// write to the legacy per-session snapshot has been retired
1620
+ /// (`.team/artifacts/foundation-0-slice-design.md` §§4-5). This helper
1621
+ /// now persists ONLY the canonical root state; retaining the public
1622
+ /// name for 0.5.x call-site stability. The B0 legacy snapshot is
1623
+ /// diagnostic-only via `lifecycle::save_team_runtime_snapshot`.
1620
1624
  pub fn write_lease_dual_state(workspace: &Path, state: &Value) -> Result<(), LeaderError> {
1621
1625
  crate::state::persist::save_runtime_state(workspace, state)?;
1622
- if let Some(session_name) = state.get("session_name").and_then(Value::as_str) {
1623
- write_team_snapshot_atomic(workspace, session_name, state)?;
1624
- }
1625
- Ok(())
1626
- }
1627
-
1628
- fn write_team_snapshot_atomic(
1629
- workspace: &Path,
1630
- session_name: &str,
1631
- state: &Value,
1632
- ) -> Result<(), LeaderError> {
1633
- let snap_path = crate::lifecycle::helpers::team_snapshot_path(workspace, session_name);
1634
- let parent = snap_path
1635
- .parent()
1636
- .ok_or_else(|| LeaderError::Validation("team snapshot path has no parent".to_string()))?;
1637
- std::fs::create_dir_all(parent)?;
1638
- let tmp = parent.join(format!("state.json.tmp-{}", std::process::id()));
1639
- let data = serde_json::to_vec_pretty(state)?;
1640
- std::fs::write(&tmp, data)?;
1641
- if let Err(error) = std::fs::rename(&tmp, &snap_path) {
1642
- let _ = std::fs::remove_file(&tmp);
1643
- return Err(error.into());
1644
- }
1645
1626
  Ok(())
1646
1627
  }
1647
1628
 
@@ -1769,17 +1750,22 @@ fn compact_team_state_preserving_claim_fields(state: &Value, target_key: &str) -
1769
1750
  entry
1770
1751
  }
1771
1752
 
1772
- /// `_detect_dual_state_divergence`(card §85 C18;`__init__.py:556`)。workspace-level
1773
- /// team-level snapshot owner_uuid/receiver_pane_id/owner_epoch 上是否分叉 `Some(详情)`。
1774
- /// **CROSS-LANE**:snapshot 读经 step 13 restart。
1775
- pub fn detect_dual_state_divergence(
1753
+ /// Foundation-0 F0-2: reader for legacy per-session snapshot vs
1754
+ /// canonical root state. Diagnostic-only after the dual-write retirement
1755
+ /// (`.team/artifacts/foundation-0-slice-design.md` §§4-5); product
1756
+ /// authority code never consults this — it exists so `status`/`diagnose`
1757
+ /// can surface `legacy_snapshot_stale` when the on-disk sidecar has
1758
+ /// drifted. Every touch of the legacy path constants below is marked
1759
+ /// `B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ` so the RED3 grep guard admits
1760
+ /// them as documented exceptions.
1761
+ pub fn detect_dual_state_divergence( // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: diagnostic-only entry point; no product save/route consumer.
1776
1762
  workspace: &Path,
1777
1763
  state: &Value,
1778
1764
  ) -> Result<Option<Value>, LeaderError> {
1779
1765
  let Some(session_name) = state.get("session_name").and_then(Value::as_str) else {
1780
1766
  return Ok(None);
1781
1767
  };
1782
- let snap_path = readable_team_snapshot_path(workspace, session_name);
1768
+ let snap_path = readable_team_snapshot_path(workspace, session_name); // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: diagnostic legacy-shape path lookup.
1783
1769
  if !snap_path.exists() {
1784
1770
  return Ok(None);
1785
1771
  }
@@ -1794,10 +1780,13 @@ pub fn detect_dual_state_divergence(
1794
1780
  .or_else(|| get_path_u64(state, &["leader_receiver", "owner_epoch"]));
1795
1781
  let team_epoch = get_path_u64(&snap, &["team_owner", "owner_epoch"])
1796
1782
  .or_else(|| get_path_u64(&snap, &["leader_receiver", "owner_epoch"]));
1783
+ let workspace_agent_bindings = agent_binding_summary(state);
1784
+ let team_agent_bindings = agent_binding_summary(&snap);
1797
1785
  let diverged = workspace_owner_pane != team_owner_pane
1798
1786
  || workspace_owner_uuid != team_owner_uuid
1799
1787
  || workspace_receiver_pane != team_receiver_pane
1800
- || workspace_epoch != team_epoch;
1788
+ || workspace_epoch != team_epoch
1789
+ || workspace_agent_bindings != team_agent_bindings;
1801
1790
  if !diverged {
1802
1791
  return Ok(None);
1803
1792
  }
@@ -1810,16 +1799,47 @@ pub fn detect_dual_state_divergence(
1810
1799
  "team_receiver_pane": team_receiver_pane,
1811
1800
  "workspace_owner_epoch": workspace_epoch,
1812
1801
  "team_owner_epoch": team_epoch,
1802
+ "workspace_agent_bindings": workspace_agent_bindings,
1803
+ "team_agent_bindings": team_agent_bindings,
1804
+ "_legacy_snapshot_stale": true,
1813
1805
  })))
1814
1806
  }
1815
1807
 
1816
- fn readable_team_snapshot_path(workspace: &Path, session_name: &str) -> PathBuf {
1817
- let safe_path = crate::lifecycle::helpers::team_snapshot_path(workspace, session_name);
1808
+ fn agent_binding_summary(state: &Value) -> Value {
1809
+ let mut out = serde_json::Map::new();
1810
+ let Some(agents) = state.get("agents").and_then(Value::as_object) else {
1811
+ return Value::Object(out);
1812
+ };
1813
+ for (agent_id, agent) in agents {
1814
+ let mut binding = serde_json::Map::new();
1815
+ for key in [
1816
+ "pane_id",
1817
+ "pane_pid",
1818
+ "tmux_endpoint",
1819
+ "tmux_socket",
1820
+ "window",
1821
+ "window_name",
1822
+ ] {
1823
+ if let Some(value) = agent.get(key) {
1824
+ binding.insert(key.to_string(), value.clone());
1825
+ }
1826
+ }
1827
+ if !binding.is_empty() {
1828
+ out.insert(agent_id.clone(), Value::Object(binding));
1829
+ }
1830
+ }
1831
+ Value::Object(out)
1832
+ }
1833
+
1834
+ fn readable_team_snapshot_path(workspace: &Path, session_name: &str) -> PathBuf { // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: diagnostic-only path resolver.
1835
+ let safe_path = crate::lifecycle::helpers::team_snapshot_path(workspace, session_name); // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: reuses helpers safe legacy path.
1818
1836
  if safe_path.exists() {
1819
1837
  return safe_path;
1820
1838
  }
1839
+ // Raw legacy `runtime/teams` fallback for pre-safe-shape snapshots. // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ
1840
+ let legacy_dir = "teams"; // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ
1821
1841
  crate::model::paths::runtime_dir(workspace)
1822
- .join("teams")
1842
+ .join(legacy_dir)
1823
1843
  .join(session_name)
1824
1844
  .join("state.json")
1825
1845
  }
@@ -135,7 +135,12 @@ fn try_readopt_writes_owner_receiver_dual_state_and_events() {
135
135
  assert_eq!(owner_view["claimed_via"], json!("attach-leader"));
136
136
  assert_eq!(owner_view["owner_epoch"], json!(4));
137
137
  assert_eq!(receiver_view["discovery"], json!("attach_readopt"));
138
- assert!(crate::model::paths::runtime_dir(&ws)
138
+ // Foundation-0 F0-2: the try_readopt success path no longer side-
139
+ // effects a legacy per-session snapshot; canonical teams.<key> is
140
+ // the sole authority. The legacy sidecar stays absent unless a
141
+ // diagnostic writer explicitly created it
142
+ // (`.team/artifacts/foundation-0-slice-design.md` §§4-5).
143
+ assert!(!crate::model::paths::runtime_dir(&ws)
139
144
  .join("teams")
140
145
  .join("sess")
141
146
  .join("state.json")
@@ -112,7 +112,15 @@ use super::*;
112
112
  // 强化:带 session_name 时必须落 BOTH —— workspace state.json + team/<session> snapshot,
113
113
  // 两份 team_owner.pane_id / owner_epoch 必须一致(永不分叉)。空 body Ok(()) 会被这里抓。
114
114
  #[test]
115
- fn write_lease_dual_state_persists_both_locations_without_divergence() {
115
+ // Foundation-0 F0-2 conversion of the C17 dual-write invariant test:
116
+ // legacy per-session snapshot dual-write has been retired
117
+ // (`.team/artifacts/foundation-0-slice-design.md` §§4-5). The lease
118
+ // helper now persists ONLY the canonical root state; a legacy
119
+ // snapshot is diagnostic-only and never re-appears as a side effect
120
+ // of `write_lease_dual_state`. This test enforces exactly that:
121
+ // canonical root gets the owner tuple, and the legacy per-session
122
+ // path stays absent unless a diagnostic path explicitly writes it.
123
+ fn write_lease_dual_state_persists_canonical_root_only_no_legacy_side_effect() {
116
124
  let ws = std::env::temp_dir().join(format!("ta_rs_dual_{}", std::process::id()));
117
125
  std::fs::create_dir_all(&ws).unwrap();
118
126
  let state = serde_json::json!({
@@ -121,32 +129,33 @@ use super::*;
121
129
  "leader_receiver": {"pane_id": "%1", "owner_epoch": 2},
122
130
  });
123
131
  write_lease_dual_state(&ws, &state).unwrap();
124
- // (1) workspace state.json(<ws>/.team/runtime/state.json)被写,team_owner.pane_id==%1。
132
+ // Canonical root state.json IS written; F0-2 keeps this as the
133
+ // single product authority.
125
134
  let ws_path = crate::state::persist::runtime_state_path(&ws);
126
135
  let ws_state: serde_json::Value =
127
136
  serde_json::from_str(&std::fs::read_to_string(&ws_path).expect("workspace state.json 必须存在")).unwrap();
128
137
  assert_eq!(ws_state["team_owner"]["pane_id"], serde_json::json!("%1"));
129
138
  assert_eq!(ws_state["team_owner"]["owner_epoch"], serde_json::json!(2));
130
- // (2) team-level snapshot(<ws>/.team/runtime/teams/<session>/state.json)被写。
131
- let snap_path = crate::model::paths::runtime_dir(&ws)
132
- .join("teams")
133
- .join("team-sess")
134
- .join("state.json");
135
- let snap_state: serde_json::Value =
136
- serde_json::from_str(&std::fs::read_to_string(&snap_path).expect("team snapshot state.json 必须存在")).unwrap();
137
- // (3) 两份永不分叉:owner pane_id / owner_epoch 必须相等(C17 核心不变量)。
138
- assert_eq!(
139
- ws_state["team_owner"]["pane_id"], snap_state["team_owner"]["pane_id"],
140
- "workspace 与 team snapshot 的 owner pane 不得分叉"
141
- );
142
- assert_eq!(
143
- ws_state["team_owner"]["owner_epoch"], snap_state["team_owner"]["owner_epoch"],
144
- "workspace 与 team snapshot 的 owner_epoch 不得分叉"
139
+ // Legacy per-session snapshot MUST NOT be a side effect of the
140
+ // lease save path any more — this is the RED2 authority-write
141
+ // guard shape at the unit-test layer.
142
+ let legacy_snap = crate::lifecycle::helpers::team_snapshot_path(&ws, "team-sess");
143
+ assert!(
144
+ !legacy_snap.exists(),
145
+ "F0-2: write_lease_dual_state must not write the legacy per-session snapshot; found {}",
146
+ legacy_snap.display(),
145
147
  );
146
148
  }
147
149
 
148
150
  #[test]
149
151
  fn lease_divergence_reads_restart_snapshot_for_special_session_names() {
152
+ // Foundation-0 F0-2 conversion: `write_lease_dual_state` no longer
153
+ // side-effects a legacy snapshot; the divergence detector is a
154
+ // diagnostic reader that fires only when a diagnostic writer
155
+ // (e.g. `lifecycle::save_team_runtime_snapshot`) has produced
156
+ // the sidecar. Sequence exercised here: canonical root save →
157
+ // diagnostic snapshot save with a different pane → divergence
158
+ // reader observes the drift.
150
159
  let ws = std::env::temp_dir().join(format!("ta_rs_dual_special_{}", std::process::id()));
151
160
  std::fs::create_dir_all(&ws).unwrap();
152
161
  let state = serde_json::json!({
@@ -160,17 +169,21 @@ use super::*;
160
169
  .join("teams")
161
170
  .join("team:proj-中文")
162
171
  .join("state.json");
172
+ // F0-2: canonical save must not create either the restart-safe
173
+ // or the raw legacy snapshot as a side effect.
163
174
  assert!(
164
- safe_path.exists(),
165
- "lease snapshot must use the restart-safe path: {}",
175
+ !safe_path.exists(),
176
+ "F0-2: canonical write_lease_dual_state must not create the restart-safe legacy snapshot: {}",
166
177
  safe_path.display()
167
178
  );
168
179
  assert!(
169
180
  !raw_path.exists(),
170
- "new lease writes must not create the legacy raw session path: {}",
181
+ "F0-2: canonical write_lease_dual_state must not create the raw legacy snapshot: {}",
171
182
  raw_path.display()
172
183
  );
173
184
 
185
+ // Diagnostic write path with a divergent pane so we can exercise
186
+ // the divergence reader below.
174
187
  let restart_state = serde_json::json!({
175
188
  "session_name": "team:proj-中文",
176
189
  "team_owner": {"pane_id": "%9", "owner_epoch": 2, "leader_session_uuid": "uuuu"},
@@ -180,13 +193,18 @@ use super::*;
180
193
  assert_eq!(restart_path, safe_path);
181
194
  assert!(
182
195
  restart_path.exists(),
183
- "restart snapshot must be written to a real file: {}",
196
+ "diagnostic snapshot must be written to a real file: {}",
184
197
  restart_path.display()
185
198
  );
186
199
 
200
+ // F0-2 RED1 diagnostic marker is present on the on-disk file.
201
+ let snap_state: serde_json::Value =
202
+ serde_json::from_str(&std::fs::read_to_string(&restart_path).unwrap()).unwrap();
203
+ assert_eq!(snap_state["_not_authoritative"], serde_json::json!(true));
204
+
187
205
  let d = detect_dual_state_divergence(&ws, &state)
188
206
  .unwrap()
189
- .expect("divergence detector must read the restart-written snapshot");
207
+ .expect("divergence detector must read the diagnostic snapshot");
190
208
  assert_eq!(d["workspace_owner_pane"], serde_json::json!("%1"));
191
209
  assert_eq!(d["team_owner_pane"], serde_json::json!("%9"));
192
210
  assert_eq!(d["workspace_receiver_pane"], serde_json::json!("%1"));
@@ -10,13 +10,16 @@ use super::*;
10
10
  /// (bound-pane liveness + caller eligibility) deterministically with no tmux.
11
11
  #[test]
12
12
  #[serial_test::serial(env)]
13
- fn claim_lease_no_incident_vacant_acquire_advances_epoch_and_dual_writes() {
13
+ // Foundation-0 F0-2 conversion: `claim_lease_no_incident` used to
14
+ // dual-write the legacy per-session snapshot; that is retired
15
+ // (`.team/artifacts/foundation-0-slice-design.md` §§4-5). Canonical
16
+ // teams.<team_key> now carries the sole authoritative owner tuple.
17
+ fn claim_lease_no_incident_vacant_acquire_advances_epoch_and_writes_canonical_only() {
14
18
  let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
15
19
  let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
16
20
  let ws = p2_temp_ws("claim_vacant");
17
21
  let team_id = TeamKey::new("current");
18
22
  let caller = PaneId::new("%5");
19
- // empty (vacant) state with a session so dual-state writes both files.
20
23
  let mut state = serde_json::json!({"session_name": "team-agent-x"});
21
24
  let event_log = crate::event_log::EventLog::new(&ws);
22
25
  let live = seeded_liveness(&["%5"]);
@@ -35,45 +38,25 @@ use super::*;
35
38
  let receiver = r.receiver.as_ref().expect("claimed → receiver");
36
39
  assert_eq!(receiver.pane_id, caller);
37
40
  assert_eq!(receiver.discovery, Some(Discovery::ClaimLeader));
38
- // Stage 3d (identity-boundary unified plan, architect direction
39
- // 2026-06-23): canonical owner now lives at teams.<team_key>; the
40
- // top-level dual-write was dropped. Read through the ownership
41
- // repository which preserves the legacy precedence for callers
42
- // that still consult top-level on migration states.
43
41
  let ws_path = crate::state::persist::runtime_state_path(&ws);
44
42
  let ws_state: serde_json::Value =
45
43
  serde_json::from_str(&std::fs::read_to_string(&ws_path).unwrap()).unwrap();
46
- // The canonical team_key is what `team_state_key(state)` derives
47
- // (here: state's session_name "team-agent-x" since no explicit
48
- // team_key/team_dir/spec_path was seeded). The test's `team_id`
49
- // parameter is the LOOKUP key but `claim_lease_no_incident` uses
50
- // the state-derived key for canonical writes. Stage 5 will unify
51
- // these via TeamRuntimePaths.
52
44
  let canonical_key = crate::state::projection::team_state_key(&ws_state);
53
45
  let ws_owner =
54
46
  crate::state::ownership::read_owner_value(&ws_state, &canonical_key)
55
47
  .expect("Stage 3d: vacant acquire writes canonical owner");
56
48
  assert_eq!(ws_owner["pane_id"], serde_json::json!("%5"));
57
49
  assert_eq!(ws_owner["owner_epoch"], serde_json::json!(1));
58
- // team_id parameter is currently unused by the canonical write
59
- // (architect Stage 5 will close this gap); silence the warning.
60
50
  let _ = &team_id;
51
+ // F0-2: canonical claim path must NOT side-effect the legacy
52
+ // per-session snapshot any more.
61
53
  let snap_path = crate::model::paths::runtime_dir(&ws)
62
54
  .join("teams").join("team-agent-x").join("state.json");
63
- let snap: serde_json::Value =
64
- serde_json::from_str(&std::fs::read_to_string(&snap_path).unwrap()).unwrap();
65
- // Snapshot is per-session; team_state_key on the snapshot derives
66
- // from its session_name. Read top-level first (legacy snapshot
67
- // layout), fall back to derived team key.
68
- let snap_owner = snap
69
- .get("team_owner")
70
- .or_else(|| {
71
- let key = crate::state::projection::team_state_key(&snap);
72
- snap.get("teams").and_then(|t| t.get(key)).and_then(|e| e.get("team_owner"))
73
- })
74
- .expect("dual-state snapshot owner");
75
- assert_eq!(snap_owner["pane_id"], serde_json::json!("%5"), "dual-state snapshot owner");
76
- assert_eq!(snap_owner["owner_epoch"], serde_json::json!(1));
55
+ assert!(
56
+ !snap_path.exists(),
57
+ "F0-2: vacant claim must not write a legacy per-session snapshot; found {}",
58
+ snap_path.display(),
59
+ );
77
60
  }
78
61
 
79
62
  // RED — DEAD-OWNER RECOVER: bound pane %1 absent from live set, caller %5
@@ -6,8 +6,24 @@ use std::path::{Path, PathBuf};
6
6
 
7
7
  use super::*;
8
8
 
9
- /// `save_team_runtime_snapshot(workspace, state)`(`restart/snapshot.py:42`,**bug-084**)。
10
- /// `os.replace` 原子写;`EACCES/EPERM/EBUSY` 退避重试,**绝不 unwrap**。
9
+ /// `save_team_runtime_snapshot(workspace, state)` — Foundation-0 F0-2:
10
+ /// this is now a **diagnostic-only** legacy per-session snapshot writer.
11
+ /// Root/projection `.team/runtime/state.json` is the sole product
12
+ /// authority; the file this writes carries `_not_authoritative:true`
13
+ /// and pointers back to the canonical path so no reader can mistake it
14
+ /// for the real runtime state. See
15
+ /// `.team/artifacts/foundation-0-slice-design.md` §§4-5 F0-2.
16
+ ///
17
+ /// Product save paths (attach/claim/start-agent/restart/stop/remove)
18
+ /// must NOT call this — the RED2 grep guard enforces that. Retained
19
+ /// callers are diagnostic/migration/test paths only.
20
+ ///
21
+ /// Preserves the legacy path shape (`.team/runtime/teams/<session>/state.json`)
22
+ /// so 0.5.x operators inspecting the file see the diagnostic marker
23
+ /// rather than being surprised by a missing file. The B3 canonical
24
+ /// team-key layout will live under a different path
25
+ /// (`teams/<team_key>/...`), so this file also cannot be mistaken for
26
+ /// the future canonical shape.
11
27
  pub fn save_team_runtime_snapshot(
12
28
  workspace: &Path,
13
29
  state: &serde_json::Value,
@@ -16,17 +32,34 @@ pub fn save_team_runtime_snapshot(
16
32
  .get("session_name")
17
33
  .and_then(|v| v.as_str())
18
34
  .ok_or_else(|| LifecycleError::StatePersist("session_name is required".to_string()))?;
19
- // golden restart/snapshot.py:46-47 — team_runtime_snapshot_dir = runtime_dir(workspace)/teams/<safe>,
20
- // and paths.py:25-26 runtime_dir = workspace/.team/runtime. Use the crate path helper so the snapshot
21
- // lands at <ws>/.team/runtime/teams/<safe>, matching golden AND the rest of the crate (not the
22
- // ".team"-less <ws>/runtime/teams that the original port wrote).
23
35
  let path = team_snapshot_path(workspace, session_name);
24
36
  let dir = path
25
37
  .parent()
26
38
  .ok_or_else(|| LifecycleError::StatePersist("snapshot path has no parent".to_string()))?;
27
39
  fs::create_dir_all(&dir).map_err(|e| persist_err("create snapshot dir", &e))?;
40
+ let canonical_path = crate::state::persist::runtime_state_path(workspace);
41
+ let generated_at = chrono::Utc::now().to_rfc3339();
42
+ // F0-2 RED1: annotate the snapshot payload with diagnostic-only
43
+ // metadata so any reader can immediately see it is derived, not
44
+ // authoritative. `_canonical_state_path` points at the real
45
+ // runtime authority; `_derived_from` names the code path that
46
+ // produced this file; `_generated_at` records freshness so stale
47
+ // snapshots are easy to identify.
48
+ let mut annotated = state.clone();
49
+ if let Some(obj) = annotated.as_object_mut() {
50
+ obj.insert("_not_authoritative".to_string(), serde_json::json!(true));
51
+ obj.insert(
52
+ "_canonical_state_path".to_string(),
53
+ serde_json::json!(canonical_path.to_string_lossy()),
54
+ );
55
+ obj.insert(
56
+ "_derived_from".to_string(),
57
+ serde_json::json!("lifecycle::save_team_runtime_snapshot"),
58
+ );
59
+ obj.insert("_generated_at".to_string(), serde_json::json!(generated_at));
60
+ }
28
61
  let tmp = dir.join("state.json.tmp");
29
- let data = serde_json::to_vec_pretty(state)
62
+ let data = serde_json::to_vec_pretty(&annotated)
30
63
  .map_err(|e| LifecycleError::StatePersist(format!("serialize snapshot: {e}")))?;
31
64
  fs::write(&tmp, data).map_err(|e| persist_err("write snapshot temp", &e))?;
32
65
  fs::rename(&tmp, &path).map_err(|e| persist_err("replace snapshot", &e))?;
@@ -915,12 +915,9 @@ pub(super) fn stop_agent_at_paths(
915
915
  )
916
916
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
917
917
  // golden operations.py:96-99: snapshot (side-effect), then state_file = write_team_state path.
918
- // snapshot.py:19-21 returns None silently when session_name is falsy — mirror that no-op here so
919
- // a workspace whose persisted state lacks session_name proceeds to write team_state and return ok
920
- // (do NOT swallow OTHER snapshot errors). The stop/reset/remove-force paths all inherit this guard.
921
- if session_name_present(&state) {
922
- crate::lifecycle::helpers::save_team_runtime_snapshot(workspace, &state)?;
923
- }
918
+ // Foundation-0 F0-2: legacy per-session snapshot dual-write retired
919
+ // (`.team/artifacts/foundation-0-slice-design.md` §§4-5). Root state
920
+ // remains the only save target on the stop path.
924
921
  let state_file = write_team_state(spec_workspace, &spec, &state)?;
925
922
  write_stop_complete_event(workspace, agent_id, &target_str, stopped)?;
926
923
  Ok(StopAgentReport {
@@ -186,17 +186,12 @@ fn remove_agent_at_paths(
186
186
  );
187
187
  match result {
188
188
  Ok(success) => {
189
- // golden agents.py:135: _save_team_runtime_snapshot runs OUTSIDE the try/except, and
190
- // snapshot.py:19-21 returns None (no error) when session_name is falsy. Mirror that here:
191
- // only snapshot when session_name is present, and never let it roll the committed removal back.
192
- if success
193
- .removed_state
194
- .get("session_name")
195
- .and_then(|v| v.as_str())
196
- .is_some_and(|s| !s.is_empty())
197
- {
198
- let _ = crate::lifecycle::helpers::save_team_runtime_snapshot(workspace, &success.removed_state)?;
199
- }
189
+ // Foundation-0 F0-2: the historical dual-write to the legacy
190
+ // per-session snapshot has been retired
191
+ // (`.team/artifacts/foundation-0-slice-design.md` §§4-5).
192
+ // Root/projection is the sole runtime authority; the
193
+ // snapshot writer stayed in `lifecycle::helpers` only for
194
+ // diagnostic/migration/test callers.
200
195
  write_remove_complete_event(
201
196
  paths.run_workspace,
202
197
  agent_id,
@@ -280,8 +280,10 @@ pub(crate) fn latest_reportable_message_for(
280
280
  agent_id: &str,
281
281
  owner_team_id: Option<&str>,
282
282
  ) -> Option<String> {
283
- current_reportable_message_for(workspace, agent_id, owner_team_id)
284
- .or_else(|| latest_delivered_direct_message_for(workspace, agent_id, owner_team_id))
283
+ match direct_message_attribution_for(workspace, agent_id, owner_team_id) {
284
+ DirectMessageAttribution::Reportable(message_id) => Some(message_id),
285
+ DirectMessageAttribution::BlockedByNewer { .. } | DirectMessageAttribution::None => None,
286
+ }
285
287
  }
286
288
 
287
289
  pub(crate) fn current_reportable_message_for(
@@ -306,6 +308,49 @@ pub(crate) fn latest_delivered_direct_message_for(
306
308
  latest_reportable_message_from_db(&conn, agent_id, owner_team_id)
307
309
  }
308
310
 
311
+ #[derive(Debug, Clone, PartialEq, Eq)]
312
+ pub(crate) enum DirectMessageAttribution {
313
+ Reportable(String),
314
+ BlockedByNewer {
315
+ message_id: String,
316
+ status: String,
317
+ error: Option<String>,
318
+ },
319
+ None,
320
+ }
321
+
322
+ pub(crate) fn direct_message_attribution_for(
323
+ workspace: &Path,
324
+ agent_id: &str,
325
+ owner_team_id: Option<&str>,
326
+ ) -> DirectMessageAttribution {
327
+ use crate::db::message_store::MessageStore;
328
+ let Ok(store) = MessageStore::open(workspace) else {
329
+ return DirectMessageAttribution::None;
330
+ };
331
+ let Ok(conn) = crate::db::schema::open_db(store.db_path()) else {
332
+ return DirectMessageAttribution::None;
333
+ };
334
+ if let Some(message_id) = current_turn_message_for(workspace, &conn, agent_id, owner_team_id) {
335
+ return DirectMessageAttribution::Reportable(message_id);
336
+ }
337
+ match latest_direct_message_from_db(&conn, agent_id, owner_team_id) {
338
+ Some(candidate)
339
+ if reportable_message_status(&candidate.status, candidate.error.as_deref()) =>
340
+ {
341
+ DirectMessageAttribution::Reportable(candidate.message_id)
342
+ }
343
+ Some(candidate) if blocks_historical_fallback(&candidate.status) => {
344
+ DirectMessageAttribution::BlockedByNewer {
345
+ message_id: candidate.message_id,
346
+ status: candidate.status,
347
+ error: candidate.error,
348
+ }
349
+ }
350
+ Some(_) | None => DirectMessageAttribution::None,
351
+ }
352
+ }
353
+
309
354
  fn current_turn_message_for(
310
355
  workspace: &Path,
311
356
  conn: &rusqlite::Connection,
@@ -419,9 +464,27 @@ fn latest_reportable_message_from_db(
419
464
  agent_id: &str,
420
465
  owner_team_id: Option<&str>,
421
466
  ) -> Option<String> {
422
- let row = conn
423
- .query_row(
424
- "select m.message_id, m.status, m.error from messages m
467
+ let candidate = latest_direct_message_from_db(conn, agent_id, owner_team_id)?;
468
+ if reportable_message_status(&candidate.status, candidate.error.as_deref()) {
469
+ Some(candidate.message_id)
470
+ } else {
471
+ None
472
+ }
473
+ }
474
+
475
+ struct DirectMessageCandidate {
476
+ message_id: String,
477
+ status: String,
478
+ error: Option<String>,
479
+ }
480
+
481
+ fn latest_direct_message_from_db(
482
+ conn: &rusqlite::Connection,
483
+ agent_id: &str,
484
+ owner_team_id: Option<&str>,
485
+ ) -> Option<DirectMessageCandidate> {
486
+ conn.query_row(
487
+ "select m.message_id, m.status, m.error from messages m
425
488
  where m.recipient = ?1
426
489
  and (?2 is null or m.owner_team_id = ?2)
427
490
  and (m.task_id is null or m.task_id = '')
@@ -437,29 +500,25 @@ fn latest_reportable_message_from_db(
437
500
  order by m.created_at desc,
438
501
  case when m.status = 'delivered' then 0 else 1 end
439
502
  limit 1",
440
- params![agent_id, owner_team_id],
441
- |row| {
442
- Ok((
443
- row.get::<_, String>(0)?,
444
- row.get::<_, String>(1)?,
445
- row.get::<_, Option<String>>(2)?,
446
- ))
447
- },
448
- )
449
- .optional()
450
- .ok()
451
- .flatten()?;
452
- let (message_id, status, error) = row;
453
- if reportable_message_status(&status, error.as_deref()) {
454
- Some(message_id)
455
- } else {
456
- None
457
- }
503
+ params![agent_id, owner_team_id],
504
+ |row| {
505
+ Ok(DirectMessageCandidate {
506
+ message_id: row.get::<_, String>(0)?,
507
+ status: row.get::<_, String>(1)?,
508
+ error: row.get::<_, Option<String>>(2)?,
509
+ })
510
+ },
511
+ )
512
+ .optional()
513
+ .ok()
514
+ .flatten()
458
515
  }
459
516
 
460
517
  fn reportable_message_status(status: &str, error: Option<&str>) -> bool {
461
- matches!(
462
- status,
463
- "delivered" | "submitted" | "injected" | "visible"
464
- ) && (status == "delivered" || error.is_none())
518
+ matches!(status, "delivered" | "submitted" | "injected" | "visible")
519
+ && (status == "delivered" || error.is_none())
520
+ }
521
+
522
+ fn blocks_historical_fallback(status: &str) -> bool {
523
+ matches!(status, "failed" | "refused" | "blocked")
465
524
  }
@@ -238,6 +238,9 @@ pub fn compact_tool_result(result: &Value) -> ToolResult {
238
238
  "durably_stored",
239
239
  "result_id",
240
240
  "task_id",
241
+ "attributed_message_id",
242
+ "attribution_scope",
243
+ "task_id_source",
241
244
  "agent_id",
242
245
  "new_agent_id",
243
246
  "source_agent_id",
@@ -20,10 +20,10 @@ use crate::state::persist::{
20
20
  use crate::messaging::{self, MessageTarget, SendOptions};
21
21
 
22
22
  use super::helpers::{
23
- current_reportable_message_for, delivery_outcome_value, ensure_object, enum_value,
24
- insert_array, is_worker_recipient, json_dumps_default, latest_delivered_direct_message_for,
23
+ current_reportable_message_for, delivery_outcome_value, direct_message_attribution_for,
24
+ ensure_object, enum_value, insert_array, is_worker_recipient, json_dumps_default,
25
25
  latest_task_for_assignee, non_empty_string, normalized_envelope_value, object_fields,
26
- requires_ack_for_target, tool_runtime_error,
26
+ requires_ack_for_target, tool_runtime_error, DirectMessageAttribution,
27
27
  };
28
28
  use super::normalize::{
29
29
  compact_tool_result, normalize_report_envelope, normalize_result_status_observed,
@@ -336,42 +336,81 @@ impl TeamOrchestratorTools {
336
336
  // message-scoped fallback, before defaulting to "manual".
337
337
  // 1. explicit arg
338
338
  // 2. current physical direct turn for this agent
339
- // 3. latest delivered direct message with no result yet
340
- // 4. latest nonterminal assigned task in teams.<owner>.tasks
341
- // (scoped) top-level tasks
339
+ // 3. bounded latest reportable direct message with no result yet
340
+ // 4. latest nonterminal assigned task in teams.<owner>.tasks,
341
+ // only when no newer failed/refused/blocked direct turn blocks fallback
342
342
  // 5. "manual" — truly uncorrelated; collect still rejects
343
343
  let owner_team_id_str = self.owner_team_id.as_ref().map(|t| t.as_str().to_string());
344
- let resolved = task_id
345
- .map(ToString::to_string)
346
- .or_else(|| {
347
- self.agent_id.as_ref().and_then(|agent| {
348
- current_reportable_message_for(
349
- &self.workspace,
350
- agent.as_str(),
351
- owner_team_id_str.as_deref(),
352
- )
353
- })
354
- })
355
- .or_else(|| {
356
- self.agent_id.as_ref().and_then(|agent| {
357
- latest_delivered_direct_message_for(
358
- &self.workspace,
359
- agent.as_str(),
360
- owner_team_id_str.as_deref(),
361
- )
362
- })
363
- })
364
- .or_else(|| {
365
- self.agent_id.as_ref().and_then(|agent| {
366
- latest_task_for_assignee(
344
+ let mut attributed_message_id = None;
345
+ let mut attribution_scope = None;
346
+ let mut task_id_source = None;
347
+ let resolved = if let Some(task_id) = task_id {
348
+ task_id.to_string()
349
+ } else if let Some(agent) = self.agent_id.as_ref() {
350
+ if let Some(message_id) = current_reportable_message_for(
351
+ &self.workspace,
352
+ agent.as_str(),
353
+ owner_team_id_str.as_deref(),
354
+ ) {
355
+ attributed_message_id = Some(message_id.clone());
356
+ attribution_scope = Some("message");
357
+ task_id_source = Some("current_turn_message");
358
+ message_id
359
+ } else {
360
+ match direct_message_attribution_for(
361
+ &self.workspace,
362
+ agent.as_str(),
363
+ owner_team_id_str.as_deref(),
364
+ ) {
365
+ DirectMessageAttribution::Reportable(message_id) => {
366
+ attributed_message_id = Some(message_id.clone());
367
+ attribution_scope = Some("message");
368
+ task_id_source = Some("direct_message");
369
+ message_id
370
+ }
371
+ DirectMessageAttribution::BlockedByNewer {
372
+ message_id,
373
+ status,
374
+ error,
375
+ } => {
376
+ push_report_warning(
377
+ obj,
378
+ serde_json::json!({
379
+ "code": "result_attribution_blocked_by_newer_direct_message",
380
+ "field": "task_id",
381
+ "severity": "warning",
382
+ "advisory": true,
383
+ "message_id": message_id,
384
+ "message_status": status,
385
+ "message_error": error,
386
+ }),
387
+ );
388
+ "manual".to_string()
389
+ }
390
+ DirectMessageAttribution::None => latest_task_for_assignee(
367
391
  &self.workspace,
368
392
  agent.as_str(),
369
393
  owner_team_id_str.as_deref(),
370
394
  )
371
- })
372
- })
373
- .unwrap_or_else(|| "manual".to_string());
395
+ .unwrap_or_else(|| "manual".to_string()),
396
+ }
397
+ }
398
+ } else {
399
+ "manual".to_string()
400
+ };
374
401
  obj.insert("task_id".to_string(), Value::String(resolved));
402
+ if let Some(message_id) = attributed_message_id {
403
+ obj.entry("attributed_message_id")
404
+ .or_insert(Value::String(message_id));
405
+ }
406
+ if let Some(scope) = attribution_scope {
407
+ obj.entry("attribution_scope")
408
+ .or_insert(Value::String(scope.to_string()));
409
+ }
410
+ if let Some(source) = task_id_source {
411
+ obj.entry("task_id_source")
412
+ .or_insert(Value::String(source.to_string()));
413
+ }
375
414
  }
376
415
  if !obj.contains_key("agent_id") {
377
416
  let resolved = agent_id
@@ -411,6 +450,7 @@ impl TeamOrchestratorTools {
411
450
  let normalized = normalize_report_envelope(&base);
412
451
  let warnings = report_result_integrity_warnings(&base, &normalized);
413
452
  let mut env_value = normalized_envelope_value(&normalized);
453
+ copy_report_attribution_fields(&base, &mut env_value);
414
454
  if !warnings.is_empty() {
415
455
  if let Some(obj) = env_value.as_object_mut() {
416
456
  obj.insert("warnings".to_string(), Value::Array(warnings));
@@ -977,6 +1017,47 @@ fn merge_object_fields(existing: &mut Value, incoming: &Value) {
977
1017
  }
978
1018
  }
979
1019
 
1020
+ fn copy_report_attribution_fields(source: &Value, target: &mut Value) {
1021
+ let Some(src) = source.as_object() else {
1022
+ return;
1023
+ };
1024
+ let Some(dst) = target.as_object_mut() else {
1025
+ return;
1026
+ };
1027
+ for key in [
1028
+ "attributed_message_id",
1029
+ "attribution_scope",
1030
+ "task_id_source",
1031
+ ] {
1032
+ if let Some(value) = src.get(key) {
1033
+ dst.entry(key.to_string()).or_insert(value.clone());
1034
+ }
1035
+ }
1036
+ }
1037
+
1038
+ fn push_report_warning(obj: &mut serde_json::Map<String, Value>, warning: Value) {
1039
+ let Some(code) = warning.get("code").and_then(Value::as_str) else {
1040
+ return;
1041
+ };
1042
+ let Some(field) = warning.get("field").and_then(Value::as_str) else {
1043
+ return;
1044
+ };
1045
+ match obj.get_mut("warnings") {
1046
+ Some(Value::Array(warnings)) => {
1047
+ let exists = warnings.iter().any(|existing| {
1048
+ existing.get("code").and_then(Value::as_str) == Some(code)
1049
+ && existing.get("field").and_then(Value::as_str) == Some(field)
1050
+ });
1051
+ if !exists {
1052
+ warnings.push(warning);
1053
+ }
1054
+ }
1055
+ _ => {
1056
+ obj.insert("warnings".to_string(), Value::Array(vec![warning]));
1057
+ }
1058
+ }
1059
+ }
1060
+
980
1061
  fn write_team_tasks(state: &mut Value, team_key: &str, tasks: Vec<Value>) {
981
1062
  let Some(root) = state.as_object_mut() else {
982
1063
  return;
@@ -697,6 +697,7 @@ fn report_result_for_owner_team_inner(
697
697
  "task_id".to_string(),
698
698
  serde_json::Value::String(task_id.to_string()),
699
699
  );
700
+ copy_report_attribution_fields(envelope, &mut out);
700
701
  out.insert(
701
702
  "agent_id".to_string(),
702
703
  serde_json::Value::String(agent_id.to_string()),
@@ -927,6 +928,7 @@ fn report_result_for_owner_team_inner(
927
928
  "task_id".to_string(),
928
929
  serde_json::Value::String(task_id.to_string()),
929
930
  );
931
+ copy_report_attribution_fields(envelope, &mut out);
930
932
  out.insert(
931
933
  "agent_id".to_string(),
932
934
  serde_json::Value::String(agent_id.to_string()),
@@ -968,6 +970,21 @@ fn report_result_for_owner_team_inner(
968
970
  Ok(serde_json::Value::Object(out))
969
971
  }
970
972
 
973
+ fn copy_report_attribution_fields(
974
+ envelope: &serde_json::Value,
975
+ out: &mut serde_json::Map<String, serde_json::Value>,
976
+ ) {
977
+ for key in [
978
+ "attributed_message_id",
979
+ "attribution_scope",
980
+ "task_id_source",
981
+ ] {
982
+ if let Some(value) = envelope.get(key) {
983
+ out.insert(key.to_string(), value.clone());
984
+ }
985
+ }
986
+ }
987
+
971
988
  fn fallback_primary_error_text(cli_primary_error: Option<&str>, observed: String) -> String {
972
989
  match cli_primary_error.filter(|error| !error.trim().is_empty()) {
973
990
  Some(error) => format!("{error}; {observed}"),
@@ -5,7 +5,7 @@
5
5
  //! paths or behaviour. It introduces a shared vocabulary that Stage 2 (owner
6
6
  //! repository), Stage 5 (per-team runtime state), and Stage 6 (per-team
7
7
  //! coordinator) will all consume, so that no later stage has to hand-build
8
- //! `.team/runtime/<team_key>/...` paths or guess the canonical team_key from
8
+ //! `.team/runtime/teams/<team_key>/...` paths or guess the canonical team_key from
9
9
  //! a display name.
10
10
  //!
11
11
  //! Canonical rules:
@@ -15,10 +15,10 @@
15
15
  //! - For the foundation slice, `TeamScope::new(team_key)` accepts whatever
16
16
  //! the caller already resolved (state/selector + state/persist already do
17
17
  //! this work). Slug/hash promotion to global uniqueness lands in Stage 5.
18
- //! - `TeamRuntimePaths` is the SINGLE place where the
19
- //! `.team/runtime/<team_key>/` layout is constructed. Anywhere downstream
20
- //! code today hand-joins `runtime_dir(ws).join(team_key).join(...)` it
21
- //! should migrate to call a method on `TeamRuntimePaths` in a later stage.
18
+ //! - `TeamRuntimePaths` is the SINGLE place where the future B3
19
+ //! `.team/runtime/teams/<team_key>/` layout is constructed. Anywhere
20
+ //! downstream code today hand-joins runtime team paths should migrate to
21
+ //! call a method on `TeamRuntimePaths` in a later stage.
22
22
  //!
23
23
  //! Single-team behaviour: unchanged. The foundation does not move data, does
24
24
  //! not introduce new files, and is invoked by zero existing call sites yet.
@@ -72,15 +72,15 @@ impl TeamScope {
72
72
  }
73
73
  }
74
74
 
75
- /// Single source of `.team/runtime/<team_key>/...` path construction.
75
+ /// Single source of future B3 `.team/runtime/teams/<team_key>/...` path construction.
76
76
  ///
77
77
  /// Stage 2 (owner repository), Stage 5 (per-team state), Stage 6 (per-team
78
78
  /// coordinator), and Stage 7 (per-team tmux socket) will all migrate from
79
79
  /// hand-built paths to `TeamRuntimePaths`. Today nothing reads from these
80
80
  /// methods yet — the foundation just owns the layout decision so when later
81
- /// stages start writing `.team/runtime/<team_key>/state.json` (Stage 5) or
82
- /// `.team/runtime/<team_key>/coordinator.pid` (Stage 6), there is exactly
83
- /// one place to change the layout.
81
+ /// stages start writing `.team/runtime/teams/<team_key>/state.json` (Stage 5)
82
+ /// or `.team/runtime/teams/<team_key>/coordinator.pid` (Stage 6), there is
83
+ /// exactly one place to change the layout.
84
84
  #[derive(Debug, Clone)]
85
85
  pub struct TeamRuntimePaths {
86
86
  workspace: PathBuf,
@@ -110,34 +110,36 @@ impl TeamRuntimePaths {
110
110
  &self.team_key
111
111
  }
112
112
 
113
- /// `.team/runtime/<team_key>/` — the team's runtime directory. Today this
114
- /// already exists (runtime_spec lives under it). Stage 5 will start
115
- /// writing `state.json` here too.
113
+ /// `.team/runtime/teams/<team_key>/` — the team's future B3 runtime
114
+ /// directory. It is not used as product authority until the B3 migration;
115
+ /// current callers must keep using `runtime_state_path`.
116
116
  pub fn team_dir(&self) -> PathBuf {
117
- runtime_dir(&self.workspace).join(&self.team_key)
117
+ runtime_dir(&self.workspace)
118
+ .join("teams")
119
+ .join(&self.team_key)
118
120
  }
119
121
 
120
- /// `.team/runtime/<team_key>/team.spec.yaml` runtime spec. Mirrors the
121
- /// existing `runtime_spec_path` helper; provided here so downstream
122
- /// callers don't need to import two layout APIs.
122
+ /// Transitional runtime spec path. Mirrors the existing
123
+ /// `runtime_spec_path` helper while specs remain at
124
+ /// `.team/runtime/<team_key>/team.spec.yaml`.
123
125
  pub fn spec_path(&self) -> PathBuf {
124
126
  runtime_spec_path(&self.workspace, &self.team_key)
125
127
  }
126
128
 
127
- /// `.team/runtime/<team_key>/state.json` — the canonical per-team state
128
- /// path that Stage 5 will start writing. Not used by Stage 0 product
129
- /// code; callers must continue using `runtime_state_path` until Stage 5
129
+ /// `.team/runtime/teams/<team_key>/state.json` — the canonical per-team
130
+ /// state path that B3 will start writing. Not used by current product
131
+ /// code; callers must continue using `runtime_state_path` until B3
130
132
  /// migrates the truth source.
131
133
  pub fn state_path(&self) -> PathBuf {
132
134
  self.team_dir().join("state.json")
133
135
  }
134
136
 
135
- /// `.team/runtime/<team_key>/coordinator.pid` — Stage 6 sidecar location.
137
+ /// `.team/runtime/teams/<team_key>/coordinator.pid` — future sidecar location.
136
138
  pub fn coordinator_pid_path(&self) -> PathBuf {
137
139
  self.team_dir().join("coordinator.pid")
138
140
  }
139
141
 
140
- /// `.team/runtime/<team_key>/coordinator.log` — Stage 6 sidecar location.
142
+ /// `.team/runtime/teams/<team_key>/coordinator.log` — future sidecar location.
141
143
  pub fn coordinator_log_path(&self) -> PathBuf {
142
144
  self.team_dir().join("coordinator.log")
143
145
  }
@@ -240,23 +242,20 @@ mod tests {
240
242
  }
241
243
 
242
244
  #[test]
243
- fn team_runtime_paths_layout_matches_existing_runtime_dir_layout() {
245
+ fn team_runtime_paths_layout_preannounces_b3_state_dir() {
244
246
  let paths = TeamRuntimePaths::new(PathBuf::from("/ws/proj"), "alpha");
245
- assert_eq!(
246
- paths.team_dir(),
247
- PathBuf::from("/ws/proj/.team/runtime/alpha")
248
- );
247
+ let b3_team_dir = PathBuf::from("/ws/proj/.team/runtime")
248
+ .join("teams")
249
+ .join("alpha");
250
+ assert_eq!(paths.team_dir(), b3_team_dir);
249
251
  assert_eq!(
250
252
  paths.spec_path(),
251
253
  PathBuf::from("/ws/proj/.team/runtime/alpha/team.spec.yaml")
252
254
  );
253
- assert_eq!(
254
- paths.state_path(),
255
- PathBuf::from("/ws/proj/.team/runtime/alpha/state.json")
256
- );
255
+ assert_eq!(paths.state_path(), b3_team_dir.join("state.json"));
257
256
  assert_eq!(
258
257
  paths.coordinator_pid_path(),
259
- PathBuf::from("/ws/proj/.team/runtime/alpha/coordinator.pid")
258
+ b3_team_dir.join("coordinator.pid")
260
259
  );
261
260
  }
262
261
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.23",
3
+ "version": "0.5.25",
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.23",
24
- "@team-agent/cli-darwin-x64": "0.5.23",
25
- "@team-agent/cli-linux-x64": "0.5.23"
23
+ "@team-agent/cli-darwin-arm64": "0.5.25",
24
+ "@team-agent/cli-darwin-x64": "0.5.25",
25
+ "@team-agent/cli-linux-x64": "0.5.25"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",