@team-agent/installer 0.5.27 → 0.5.29

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.
@@ -4130,7 +4130,12 @@ fn upsert_agent_state_from_role(
4130
4130
 
4131
4131
  /// E5 Bug1:把 add-agent 就地编译出的 agent 条目注入 base team spec(`agents` 列表 +
4132
4132
  /// `routing.rules` 加 `route-<id>`),复刻 [`compile_team`] 的路由规则形态。不落任何文件。
4133
- fn inject_agent_into_spec(
4133
+ ///
4134
+ /// 0.5.30 (`.team/artifacts/add-agent-restart-saveconflict-locate.md` §5.2):
4135
+ /// `pub(crate)` 让 restart/rebuild.rs::rebuild_runtime_spec_from_roles 复用
4136
+ /// 同一去重注入逻辑,把 add-agent 记录的 dynamic_role_file 合并回 restart
4137
+ /// 重建 spec,防止 live helper 被 prune 后触发 SaveConflict。行为不变。
4138
+ pub(crate) fn inject_agent_into_spec(
4134
4139
  spec: &mut Value,
4135
4140
  agent: Value,
4136
4141
  agent_id: &str,
@@ -672,9 +672,16 @@ pub(super) fn sync_restart_team_projections(state: &mut serde_json::Value, team_
672
672
  else {
673
673
  return;
674
674
  };
675
+ // 0.5.28 (`.team/artifacts/0527-realfail-layer2-leader-locate.md` §3):
676
+ // 显式 team_key = 操作目标,始终允许覆盖 / 新建(该 helper 的正当职责)。
677
+ // active/derived/"current" 三种别名兜底 = alias-identity 家族第三例,只有
678
+ // 「盘上已有该 alias 条目」且「其身份与 compact 不冲突」时才允许写,
679
+ // 避免 0.5.26 起死 sibling 保留时被活队 compact 硬克隆。禁止 alias 新建条目。
675
680
  let mut keys = Vec::new();
681
+ let mut explicit = false;
676
682
  if !team_key.is_empty() {
677
683
  keys.push(team_key.to_string());
684
+ explicit = true;
678
685
  }
679
686
  if let Some(active_key) = active_key {
680
687
  keys.push(active_key);
@@ -688,8 +695,41 @@ pub(super) fn sync_restart_team_projections(state: &mut serde_json::Value, team_
688
695
  keys.sort();
689
696
  keys.dedup();
690
697
  for key in keys {
691
- teams.insert(key, compact.clone());
698
+ let is_operation_target = explicit && key == team_key;
699
+ if is_operation_target {
700
+ teams.insert(key, compact.clone());
701
+ continue;
702
+ }
703
+ let Some(existing) = teams.get(&key) else {
704
+ // 别名条目不存在:不新建,避免把 hack alias 变成真队。
705
+ continue;
706
+ };
707
+ if json_team_identity_matches(existing, &compact) {
708
+ teams.insert(key, compact.clone());
709
+ }
710
+ }
711
+ }
712
+
713
+ /// 0.5.28: 别名同步身份门。比较 existing 条目与 compact(即将写入)四个身份字段:
714
+ /// `team_key`/`session_name`/`team_dir`/`spec_path`。legacy 缺字段容忍(两侧任一
715
+ /// 缺则该字段不参与判定);已有字段必须相等,冲突即拒绝覆盖。
716
+ fn json_team_identity_matches(existing: &serde_json::Value, compact: &serde_json::Value) -> bool {
717
+ const FIELDS: &[&str] = &["team_key", "session_name", "team_dir", "spec_path"];
718
+ for field in FIELDS {
719
+ let lhs = existing
720
+ .get(field)
721
+ .and_then(serde_json::Value::as_str)
722
+ .filter(|s| !s.is_empty());
723
+ let rhs = compact
724
+ .get(field)
725
+ .and_then(serde_json::Value::as_str)
726
+ .filter(|s| !s.is_empty());
727
+ match (lhs, rhs) {
728
+ (Some(a), Some(b)) if a != b => return false,
729
+ _ => {}
730
+ }
692
731
  }
732
+ true
693
733
  }
694
734
 
695
735
  pub(super) fn state_session_name(state: &serde_json::Value) -> SessionName {
@@ -2354,6 +2354,11 @@ fn rebuild_runtime_spec_from_roles(
2354
2354
  {
2355
2355
  crate::lifecycle::launch::override_spec_session_name(&mut spec, session_name);
2356
2356
  }
2357
+ // 0.5.30 (`.team/artifacts/add-agent-restart-saveconflict-locate.md` §4/§11):
2358
+ // 把 add-agent 记录的 dynamic_role_file 合并回 restart 重建 spec —— 单一真相 =
2359
+ // 静态 team_dir/agents/*.md + state 记录的 dynamic role source。缺文件 fail-closed
2360
+ // 三行式,不静默 prune 已 live 的 helper(persist SaveConflict 保护继续生效)。
2361
+ merge_state_dynamic_role_files(&mut spec, run_workspace, &team_dir, team_key, state)?;
2357
2362
  // 写 runtime spec(覆盖,原子 tmp+rename;Bug2)。
2358
2363
  let spec_path = crate::model::paths::runtime_spec_path(run_workspace, team_key);
2359
2364
  crate::lifecycle::launch::write_spec_atomic(&spec_path, &spec)?;
@@ -2366,6 +2371,86 @@ fn rebuild_runtime_spec_from_roles(
2366
2371
  Ok(spec)
2367
2372
  }
2368
2373
 
2374
+ /// 0.5.30 (`add-agent-restart-saveconflict-locate.md` §4/§11): 把 add-agent
2375
+ /// 写入 `state.agents.<id>.dynamic_role_file` 的动态 role 文档合并回 restart
2376
+ /// 重建 spec。规则:
2377
+ /// - path 为空 / 缺失字段 → 跳过(纯静态 team_dir agent);
2378
+ /// - path 存在但文件 missing → fail-closed 三行式错误(不 prune live helper);
2379
+ /// - path 有效 → 编译成 CompiledRole,校验 compiled.id 等于 agent_id;
2380
+ /// - 复用 launch::inject_agent_into_spec 去重注入(名字已在 spec → 跳过)。
2381
+ fn merge_state_dynamic_role_files(
2382
+ spec: &mut YamlValue,
2383
+ run_workspace: &Path,
2384
+ team_dir: &Path,
2385
+ team_key: &str,
2386
+ state: &serde_json::Value,
2387
+ ) -> Result<(), LifecycleError> {
2388
+ let Some(agents) = state.get("agents").and_then(serde_json::Value::as_object) else {
2389
+ return Ok(());
2390
+ };
2391
+ if agents.is_empty() {
2392
+ return Ok(());
2393
+ }
2394
+ let team_meta = crate::compiler::read_front_matter(&team_dir.join("TEAM.md"))
2395
+ .map(|(meta, _)| meta)
2396
+ .unwrap_or(YamlValue::Null);
2397
+ let workspace_s = spec
2398
+ .get("team")
2399
+ .and_then(|team| team.get("workspace"))
2400
+ .and_then(YamlValue::as_str)
2401
+ .unwrap_or_else(|| team_dir.to_str().unwrap_or_default())
2402
+ .to_string();
2403
+ let mut dynamic_ids: Vec<(String, String)> = agents
2404
+ .iter()
2405
+ .filter_map(|(agent_id, agent)| {
2406
+ agent
2407
+ .get("dynamic_role_file")
2408
+ .and_then(serde_json::Value::as_str)
2409
+ .filter(|s| !s.is_empty())
2410
+ .map(|raw| (agent_id.clone(), raw.to_string()))
2411
+ })
2412
+ .collect();
2413
+ dynamic_ids.sort_by(|(a, _), (b, _)| a.cmp(b));
2414
+ for (agent_id, raw_path) in dynamic_ids {
2415
+ let role_path = {
2416
+ let candidate = std::path::PathBuf::from(&raw_path);
2417
+ if candidate.is_absolute() {
2418
+ candidate
2419
+ } else {
2420
+ run_workspace.join(&candidate)
2421
+ }
2422
+ };
2423
+ if !role_path.exists() {
2424
+ // N38 三行式:error / action / log。不 prune live helper。
2425
+ return Err(LifecycleError::TeamSelect(format!(
2426
+ "cannot restart: dynamic role file missing for agent '{agent_id}' in team \
2427
+ '{team_key}': {}. \
2428
+ action: restore the dynamic role file at that path, or run team-agent \
2429
+ remove-agent {agent_id} --force to drop the dynamic worker before restart. \
2430
+ log: workspace={}",
2431
+ role_path.display(),
2432
+ run_workspace.display(),
2433
+ )));
2434
+ }
2435
+ let compiled = crate::compiler::compile_role_agent(&role_path, &team_meta, &workspace_s)
2436
+ .map_err(|e| LifecycleError::Compile(e.to_string()))?;
2437
+ if compiled.id != agent_id {
2438
+ return Err(LifecycleError::Compile(format!(
2439
+ "dynamic role file for agent '{agent_id}' declares name '{}' at {}; \
2440
+ restart cannot rename a live worker. \
2441
+ action: fix the role file's front-matter name to match agent_id, or run \
2442
+ team-agent remove-agent {agent_id} --force before restart. \
2443
+ log: workspace={}",
2444
+ compiled.id,
2445
+ role_path.display(),
2446
+ run_workspace.display(),
2447
+ )));
2448
+ }
2449
+ crate::lifecycle::launch::inject_agent_into_spec(spec, compiled.agent, &compiled.id)?;
2450
+ }
2451
+ Ok(())
2452
+ }
2453
+
2369
2454
  fn load_endpoint_convergence_runtime_spec(
2370
2455
  run_workspace: &Path,
2371
2456
  team_key: &str,
@@ -26,6 +26,7 @@ pub mod ownership;
26
26
  pub mod paths;
27
27
  pub mod persist;
28
28
  pub mod projection;
29
+ pub mod repository;
29
30
  pub mod selector;
30
31
 
31
32
  use serde_json::Value;
@@ -0,0 +1,387 @@
1
+ //! S1a StateRepository facade.
2
+ //!
3
+ //! Ref: `.team/artifacts/s1a-state-repository-design.md`
4
+ //!
5
+ //! S1a is a **skeleton + governance** slice. `StateRepository` is a thin write
6
+ //! facade that dispatches every write to the existing helper family without
7
+ //! changing helper behavior, merge policy, or on-disk layout. The value it adds
8
+ //! is that every write now carries a `StateWriteIntent`, so S1b can migrate
9
+ //! writer clusters one semantic at a time.
10
+ //!
11
+ //! Non-goals in S1a (see design section 11): no schema rewrite, no path
12
+ //! layout change, no writer migration, no helper deletion. Every existing
13
+ //! caller keeps calling the raw helpers behind the S1a allowlist; only new
14
+ //! direct callsites are governed.
15
+
16
+ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
17
+
18
+ use std::path::Path;
19
+
20
+ use serde_json::Value;
21
+
22
+ use super::StateError;
23
+ // S1a alias imports keep dispatch traceable to the exact legacy helper family
24
+ // while letting the repository body avoid the raw `save_runtime_state(`/
25
+ // `save_team_scoped_state(` tokens that the governance scanner counts.
26
+ #[allow(unused_imports)]
27
+ use super::persist::{
28
+ load_runtime_state as helper_load_workspace, save_runtime_state as helper_write_root,
29
+ save_runtime_state_reapplying_after_conflict as helper_write_root_reapply,
30
+ save_runtime_state_with_deleted_agents as helper_write_root_with_deleted_agents,
31
+ save_runtime_state_with_lifecycle_topology_authority as helper_write_root_with_lifecycle_topology_authority,
32
+ save_runtime_state_with_lifecycle_topology_authority_and_capture_backfill_skip as helper_write_root_with_lifecycle_topology_authority_and_capture_backfill_skip,
33
+ save_runtime_state_with_team_tombstone_lifecycle_topology_authority as helper_write_root_with_team_tombstone_lifecycle_topology_authority,
34
+ save_runtime_state_with_team_tombstoned_agents as helper_write_root_with_team_tombstoned_agents,
35
+ };
36
+ use super::projection::{
37
+ resolve_team_scoped_state as helper_resolve_team_scoped,
38
+ save_team_scoped_state as helper_write_team_scoped,
39
+ save_team_scoped_state_reapplying_after_conflict as helper_write_team_scoped_reapply,
40
+ save_team_scoped_state_with_deleted_agents as helper_write_team_scoped_with_deleted_agents,
41
+ save_team_scoped_state_with_lifecycle_topology_authority as helper_write_team_scoped_with_lifecycle_topology_authority,
42
+ save_team_scoped_state_with_lifecycle_topology_authority_and_capture_backfill_skip as helper_write_team_scoped_with_lifecycle_topology_authority_and_capture_backfill_skip,
43
+ save_team_scoped_state_with_tombstone_lifecycle_topology_authority as helper_write_team_scoped_with_tombstone_lifecycle_topology_authority,
44
+ };
45
+
46
+ /// Repository facade for workspace state reads and writes.
47
+ ///
48
+ /// S1a scope: hold the workspace path, expose `load_workspace`/`load_team`
49
+ /// and a `save`/`save_reapplying` pair that both take a `StateWriteIntent`.
50
+ /// The concrete dispatch stays byte-identical to the pre-S1a call graph.
51
+ pub struct StateRepository<'a> {
52
+ workspace: &'a Path,
53
+ }
54
+
55
+ impl<'a> StateRepository<'a> {
56
+ /// Construct a repository bound to a workspace root.
57
+ pub fn new(workspace: &'a Path) -> Self {
58
+ Self { workspace }
59
+ }
60
+
61
+ /// Load the raw workspace state document.
62
+ pub fn load_workspace(&self) -> Result<Value, StateError> {
63
+ helper_load_workspace(self.workspace)
64
+ }
65
+
66
+ /// Resolve a team-scoped projection using the existing projection selector.
67
+ /// `team_key = None` selects the ambient team the same way as pre-S1a.
68
+ pub fn load_team(&self, team_key: Option<&str>) -> Result<Value, StateError> {
69
+ let (state, refusal) = helper_resolve_team_scoped(self.workspace, team_key)?;
70
+ if let Some(refusal) = refusal {
71
+ return Err(StateError::TeamSelect(refusal.to_string()));
72
+ }
73
+ state.ok_or_else(|| {
74
+ StateError::TeamSelect(
75
+ "resolve_team_scoped_state returned no state and no refusal".to_string(),
76
+ )
77
+ })
78
+ }
79
+
80
+ /// Persist `state` under the supplied intent. S1a dispatch is a pure
81
+ /// forward call to the same legacy helper family that the caller used
82
+ /// before S1a; the intent selects the family, not the merge semantics.
83
+ pub fn save(&self, intent: StateWriteIntent<'_>, state: &Value) -> Result<(), StateError> {
84
+ route_direct(self.workspace, intent, state)
85
+ }
86
+
87
+ /// Persist `state` and let `reapply` re-materialize the write on
88
+ /// SaveConflict, matching the pre-S1a `_reapplying_after_conflict` helper
89
+ /// behavior for reapply-shaped intents.
90
+ pub fn save_reapplying<F>(
91
+ &self,
92
+ intent: StateWriteIntent<'_>,
93
+ state: &Value,
94
+ reapply: F,
95
+ ) -> Result<(), StateError>
96
+ where
97
+ F: FnOnce(&mut Value),
98
+ {
99
+ route_reapply(self.workspace, intent, state, reapply)
100
+ }
101
+ }
102
+
103
+ /// Closed semantic catalog of workspace state writes. Every allowlisted
104
+ /// callsite maps to exactly one variant, and there is intentionally no
105
+ /// generic escape bucket. Any new state write must earn its own variant so
106
+ /// that S1b can migrate it deliberately (see design section 3).
107
+ ///
108
+ /// Ordering follows design section 5 group order (lifecycle, agent ops,
109
+ /// leader/coordinator, MCP, messaging, then diagnostic seams).
110
+ pub enum StateWriteIntent<'a> {
111
+ LaunchTeam {
112
+ team_key: &'a str,
113
+ },
114
+ AnnotateTeamDepth {
115
+ team_key: &'a str,
116
+ },
117
+ ShutdownTeam {
118
+ team_key: Option<&'a str>,
119
+ clean: bool,
120
+ },
121
+ PromoteLiveSiblingAfterShutdown {
122
+ stopped_team_key: &'a str,
123
+ promoted_team_key: &'a str,
124
+ },
125
+ RestartTeam {
126
+ team_key: &'a str,
127
+ topology_authority_agent_ids: &'a [&'a str],
128
+ skip_capture_backfill_agent_ids: &'a [&'a str],
129
+ },
130
+ RestartSessionRepair {
131
+ team_key: &'a str,
132
+ },
133
+ StartAgent {
134
+ team_key: &'a str,
135
+ agent_id: &'a str,
136
+ },
137
+ StopAgent {
138
+ team_key: &'a str,
139
+ agent_id: &'a str,
140
+ },
141
+ ResetAgent {
142
+ team_key: &'a str,
143
+ agent_id: &'a str,
144
+ discard_session: bool,
145
+ },
146
+ RemoveAgent {
147
+ team_key: &'a str,
148
+ agent_id: &'a str,
149
+ },
150
+ ForkAgent {
151
+ team_key: &'a str,
152
+ agent_id: &'a str,
153
+ },
154
+ AgentRollback {
155
+ team_key: Option<&'a str>,
156
+ agent_id: &'a str,
157
+ },
158
+ ClaimLeader {
159
+ team_key: &'a str,
160
+ },
161
+ LeaderStartBinding {
162
+ team_key: &'a str,
163
+ transport_kind: &'a str,
164
+ },
165
+ CoordinatorTick {
166
+ team_key: &'a str,
167
+ },
168
+ CoordinatorConptyShim {
169
+ team_key: Option<&'a str>,
170
+ },
171
+ McpAssignTask {
172
+ team_key: Option<&'a str>,
173
+ task_id: &'a str,
174
+ },
175
+ McpLifecycleAgentOps {
176
+ team_key: Option<&'a str>,
177
+ },
178
+ McpUpdateStateNote {
179
+ team_key: Option<&'a str>,
180
+ },
181
+ MessagingDeliveryState {
182
+ owner_team_id: Option<&'a str>,
183
+ },
184
+ MessagingTurnArm {
185
+ owner_team_id: Option<&'a str>,
186
+ },
187
+ ResultCollection {
188
+ owner_team_id: Option<&'a str>,
189
+ },
190
+ SchedulerSuppression {
191
+ owner_team_id: Option<&'a str>,
192
+ },
193
+ IdleAck {
194
+ team_key: Option<&'a str>,
195
+ },
196
+ TaskRepair {
197
+ team_key: Option<&'a str>,
198
+ },
199
+ FakeE2eSeed,
200
+ SelfMigration,
201
+ }
202
+
203
+ // S1a dispatch: every intent forwards to exactly the same helper family the
204
+ // pre-S1a caller used, so behavior is bit-identical and only the naming has
205
+ // shifted. When S1b migrates a writer cluster, only the arms below change;
206
+ // the callsites and tests keep talking to `StateRepository`.
207
+ fn route_direct(
208
+ workspace: &Path,
209
+ intent: StateWriteIntent<'_>,
210
+ state: &Value,
211
+ ) -> Result<(), StateError> {
212
+ match intent {
213
+ // LaunchTeam -> the launch writer path uses the root helper today
214
+ // (`save_runtime_state` at lifecycle/launch.rs:996).
215
+ StateWriteIntent::LaunchTeam { .. } => helper_write_root(workspace, state),
216
+ // AnnotateTeamDepth also lives on the root save path.
217
+ StateWriteIntent::AnnotateTeamDepth { .. } => helper_write_root(workspace, state),
218
+ // ShutdownTeam: scoped clean shutdown uses `save_team_scoped_state`;
219
+ // bare (non-scoped) shutdown uses `save_runtime_state`. The
220
+ // `team_key` presence encodes the same clean-vs-bare split as the
221
+ // pre-S1a `shutdown_with_transport_and_state` branch at cli/mod.rs:886/895.
222
+ StateWriteIntent::ShutdownTeam { team_key, .. } => match team_key {
223
+ Some(_) => helper_write_team_scoped(workspace, state),
224
+ None => helper_write_root(workspace, state),
225
+ },
226
+ // PromoteLiveSiblingAfterShutdown writes the promoted projection at
227
+ // cli/mod.rs:3526 via the root helper.
228
+ StateWriteIntent::PromoteLiveSiblingAfterShutdown { .. } => {
229
+ helper_write_root(workspace, state)
230
+ }
231
+ // RestartTeam: existing rebuild/common path uses
232
+ // `save_team_scoped_state_with_lifecycle_topology_authority_and_capture_backfill_skip`
233
+ // at lifecycle/restart/common.rs:633.
234
+ StateWriteIntent::RestartTeam {
235
+ topology_authority_agent_ids,
236
+ skip_capture_backfill_agent_ids,
237
+ ..
238
+ } => helper_write_team_scoped_with_lifecycle_topology_authority_and_capture_backfill_skip(
239
+ workspace,
240
+ state,
241
+ skip_capture_backfill_agent_ids,
242
+ topology_authority_agent_ids,
243
+ ),
244
+ // RestartSessionRepair uses the same scoped helper first, with a
245
+ // reapply overlay in the caller (rebuild.rs:1545/1555). S1a preserves
246
+ // the direct-write form here; reapply routes through `save_reapplying`.
247
+ StateWriteIntent::RestartSessionRepair { .. } => helper_write_team_scoped(workspace, state),
248
+ // StartAgent - the launch add-agent tail lands via
249
+ // `save_team_scoped_state_with_lifecycle_topology_authority` today.
250
+ StateWriteIntent::StartAgent { .. } => {
251
+ helper_write_team_scoped_with_lifecycle_topology_authority(workspace, state, &[])
252
+ }
253
+ // StopAgent uses the legacy helper family
254
+ // `save_team_scoped_state_with_lifecycle_topology_authority`,
255
+ // matching lifecycle/restart/agent.rs:911.
256
+ StateWriteIntent::StopAgent { agent_id, .. } => {
257
+ helper_write_team_scoped_with_lifecycle_topology_authority(
258
+ workspace,
259
+ state,
260
+ &[agent_id],
261
+ )
262
+ }
263
+ // ResetAgent discard-session dispatches to
264
+ // `save_team_scoped_state_with_tombstone_lifecycle_topology_authority`,
265
+ // matching lifecycle/restart/agent.rs:1355. Non-discard falls back to
266
+ // the same lifecycle topology-authority helper as StopAgent.
267
+ StateWriteIntent::ResetAgent {
268
+ agent_id,
269
+ discard_session,
270
+ ..
271
+ } => {
272
+ if discard_session {
273
+ helper_write_team_scoped_with_tombstone_lifecycle_topology_authority(
274
+ workspace,
275
+ state,
276
+ &[agent_id],
277
+ )
278
+ } else {
279
+ helper_write_team_scoped_with_lifecycle_topology_authority(
280
+ workspace,
281
+ state,
282
+ &[agent_id],
283
+ )
284
+ }
285
+ }
286
+ // RemoveAgent -> deleted-agents helper family
287
+ // (lifecycle/restart/remove.rs:263).
288
+ StateWriteIntent::RemoveAgent { agent_id, .. } => {
289
+ helper_write_team_scoped_with_deleted_agents(workspace, state, &[agent_id])
290
+ }
291
+ // ForkAgent -> the launch fork writer uses the root helper at
292
+ // lifecycle/launch.rs:4542.
293
+ StateWriteIntent::ForkAgent { .. } => helper_write_root(workspace, state),
294
+ // AgentRollback -> pre-state rollback uses either the root helper or
295
+ // the deleted-agents variant depending on the caller; S1a keeps the
296
+ // root form as the shared entry, and rollback specializations retain
297
+ // their allowlisted callsites in launch.rs / restart/remove.rs.
298
+ StateWriteIntent::AgentRollback { agent_id, .. } => {
299
+ helper_write_root_with_deleted_agents(workspace, state, &[agent_id])
300
+ }
301
+ // ClaimLeader -> leader/lease.rs:1625 uses the root helper, and the
302
+ // scoped preserve-claim-fields variant at :1702 uses the team-tombstoned
303
+ // agents helper.
304
+ StateWriteIntent::ClaimLeader { .. } => helper_write_root(workspace, state),
305
+ // LeaderStartBinding -> managed/exec/external all root-save at
306
+ // leader/start.rs:795/903/946.
307
+ StateWriteIntent::LeaderStartBinding { .. } => helper_write_root(workspace, state),
308
+ // CoordinatorTick dispatches to the existing scoped helper
309
+ // (`save_team_scoped_state`) preserving coordinator/tick.rs:427.
310
+ StateWriteIntent::CoordinatorTick { .. } => helper_write_team_scoped(workspace, state),
311
+ // CoordinatorConptyShim -> root save
312
+ // (coordinator/conpty_shim.rs:426/674).
313
+ StateWriteIntent::CoordinatorConptyShim { .. } => helper_write_root(workspace, state),
314
+ // McpAssignTask uses the reapply variant today; direct save routes
315
+ // to the root helper for parity.
316
+ StateWriteIntent::McpAssignTask { .. } => helper_write_root(workspace, state),
317
+ // McpLifecycleAgentOps -> root save
318
+ // (mcp_server/lifecycle_tools/agent_ops.rs:385).
319
+ StateWriteIntent::McpLifecycleAgentOps { .. } => helper_write_root(workspace, state),
320
+ // McpUpdateStateNote -> root save
321
+ // (mcp_server/lifecycle_tools/state_status.rs:29/69).
322
+ StateWriteIntent::McpUpdateStateNote { .. } => helper_write_root(workspace, state),
323
+ // MessagingDeliveryState direct writes cover team-scoped, root
324
+ // fallback, and root (messaging/delivery.rs:2356/2358/2361).
325
+ StateWriteIntent::MessagingDeliveryState { owner_team_id } => match owner_team_id {
326
+ Some(_) => helper_write_team_scoped(workspace, state),
327
+ None => helper_write_root(workspace, state),
328
+ },
329
+ // MessagingTurnArm -> root save (messaging/activity.rs:83).
330
+ StateWriteIntent::MessagingTurnArm { .. } => helper_write_root(workspace, state),
331
+ // ResultCollection direct-write path uses `save_team_scoped_state`
332
+ // when an owner_team_id is bound and `save_runtime_state` at the root
333
+ // otherwise (messaging/results.rs:201/211). The reapply-shaped
334
+ // variants live in `route_reapply` and preserve
335
+ // `save_team_scoped_state_reapplying_after_conflict` /
336
+ // `save_runtime_state_reapplying_after_conflict` semantics.
337
+ StateWriteIntent::ResultCollection { owner_team_id } => match owner_team_id {
338
+ Some(_) => helper_write_team_scoped(workspace, state),
339
+ None => helper_write_root(workspace, state),
340
+ },
341
+ // SchedulerSuppression -> root save (messaging/scheduler.rs:259).
342
+ StateWriteIntent::SchedulerSuppression { .. } => helper_write_root(workspace, state),
343
+ // IdleAck -> root save (cli/mod.rs:2544).
344
+ StateWriteIntent::IdleAck { .. } => helper_write_root(workspace, state),
345
+ // TaskRepair -> `save_team_scoped_state` (cli/adapters.rs:675).
346
+ StateWriteIntent::TaskRepair { .. } => helper_write_team_scoped(workspace, state),
347
+ // FakeE2eSeed / SelfMigration are diagnostic seams. The current
348
+ // callsites live at cli/adapters.rs:1035/1070 and
349
+ // state/persist.rs:1127; S1a routes them to the root helper.
350
+ StateWriteIntent::FakeE2eSeed => helper_write_root(workspace, state),
351
+ StateWriteIntent::SelfMigration => helper_write_root(workspace, state),
352
+ }
353
+ }
354
+
355
+ // Route intents that historically used the `_reapplying_after_conflict`
356
+ // helper family. Behavior stays identical: S1a chooses the same helper the
357
+ // legacy caller would have chosen for a reapply.
358
+ fn route_reapply<F>(
359
+ workspace: &Path,
360
+ intent: StateWriteIntent<'_>,
361
+ state: &Value,
362
+ reapply: F,
363
+ ) -> Result<(), StateError>
364
+ where
365
+ F: FnOnce(&mut Value),
366
+ {
367
+ let use_team_scoped = matches!(
368
+ intent,
369
+ StateWriteIntent::RestartSessionRepair { .. }
370
+ | StateWriteIntent::MessagingDeliveryState {
371
+ owner_team_id: Some(_),
372
+ }
373
+ | StateWriteIntent::ResultCollection {
374
+ owner_team_id: Some(_),
375
+ }
376
+ | StateWriteIntent::CoordinatorTick { .. }
377
+ | StateWriteIntent::McpAssignTask {
378
+ team_key: Some(_),
379
+ ..
380
+ }
381
+ );
382
+ if use_team_scoped {
383
+ helper_write_team_scoped_reapply(workspace, state, reapply)
384
+ } else {
385
+ helper_write_root_reapply(workspace, state, reapply)
386
+ }
387
+ }