@team-agent/installer 0.5.28 → 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,
@@ -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
+ }
@@ -38,10 +38,12 @@ pub enum EndpointConvergenceDecision {
38
38
  Converge {
39
39
  old_endpoint: String,
40
40
  new_endpoint: String,
41
+ reason: &'static str,
41
42
  },
42
43
  RefuseLiveOldEndpoint {
43
44
  old_endpoint: String,
44
45
  new_endpoint: String,
46
+ reason: &'static str,
45
47
  },
46
48
  Unknown,
47
49
  }
@@ -96,13 +98,23 @@ pub fn endpoint_convergence_decision(
96
98
  return EndpointConvergenceDecision::NoConflict;
97
99
  };
98
100
 
101
+ let mut converge_reason = "old_endpoint_dead";
99
102
  for old_endpoint in stale_endpoints {
100
103
  match endpoint_server_alive(&old_endpoint) {
101
104
  Some(true) => {
102
- return EndpointConvergenceDecision::RefuseLiveOldEndpoint {
103
- old_endpoint,
104
- new_endpoint: candidate_endpoint.to_string(),
105
- };
105
+ match old_endpoint_team_liveness(state, team_key, &old_endpoint) {
106
+ OldEndpointTeamLiveness::TeamLive { reason } => {
107
+ return EndpointConvergenceDecision::RefuseLiveOldEndpoint {
108
+ old_endpoint,
109
+ new_endpoint: candidate_endpoint.to_string(),
110
+ reason,
111
+ };
112
+ }
113
+ OldEndpointTeamLiveness::TeamAbsent { reason } => {
114
+ converge_reason = reason;
115
+ }
116
+ OldEndpointTeamLiveness::Unknown => return EndpointConvergenceDecision::Unknown,
117
+ }
106
118
  }
107
119
  Some(false) => {}
108
120
  None => return EndpointConvergenceDecision::Unknown,
@@ -112,9 +124,118 @@ pub fn endpoint_convergence_decision(
112
124
  EndpointConvergenceDecision::Converge {
113
125
  old_endpoint: first_old,
114
126
  new_endpoint: candidate_endpoint.to_string(),
127
+ reason: converge_reason,
128
+ }
129
+ }
130
+
131
+ enum OldEndpointTeamLiveness {
132
+ TeamLive { reason: &'static str },
133
+ TeamAbsent { reason: &'static str },
134
+ Unknown,
135
+ }
136
+
137
+ fn old_endpoint_team_liveness(
138
+ state: &Value,
139
+ team_key: &str,
140
+ old_endpoint: &str,
141
+ ) -> OldEndpointTeamLiveness {
142
+ let team_state = state
143
+ .get("teams")
144
+ .and_then(Value::as_object)
145
+ .and_then(|teams| teams.get(team_key))
146
+ .unwrap_or(state);
147
+ let session = non_empty_str(team_state, "session_name")
148
+ .or_else(|| non_empty_str(state, "session_name"))
149
+ .unwrap_or_default();
150
+ if !session.is_empty() {
151
+ match session_exists_on_endpoint_checked(old_endpoint, session) {
152
+ Some(true) => {
153
+ return OldEndpointTeamLiveness::TeamLive {
154
+ reason: "old_team_session_live",
155
+ };
156
+ }
157
+ Some(false) => {}
158
+ None => return OldEndpointTeamLiveness::Unknown,
159
+ }
160
+ }
161
+ match old_endpoint_has_live_team_tuple(state, team_key, old_endpoint) {
162
+ Some(true) => OldEndpointTeamLiveness::TeamLive {
163
+ reason: "old_team_tuple_live",
164
+ },
165
+ Some(false) => OldEndpointTeamLiveness::TeamAbsent {
166
+ reason: "old_team_session_absent_on_live_endpoint",
167
+ },
168
+ None => OldEndpointTeamLiveness::Unknown,
115
169
  }
116
170
  }
117
171
 
172
+ fn old_endpoint_has_live_team_tuple(
173
+ state: &Value,
174
+ team_key: &str,
175
+ old_endpoint: &str,
176
+ ) -> Option<bool> {
177
+ let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(old_endpoint);
178
+ let targets = backend.list_targets().ok()?;
179
+ let team_state = state
180
+ .get("teams")
181
+ .and_then(Value::as_object)
182
+ .and_then(|teams| teams.get(team_key));
183
+ for observed in &targets {
184
+ if matches!(
185
+ classify_registered_worker_for_observed_pane(team_state.unwrap_or(state), observed),
186
+ WorkerPaneBindingMatch::LiveSameWorker { .. }
187
+ ) || leader_receiver_tuple_matches(team_state.unwrap_or(state), observed)
188
+ {
189
+ return Some(true);
190
+ }
191
+ if team_state.is_some() {
192
+ continue;
193
+ }
194
+ if let Some(teams) = state.get("teams").and_then(Value::as_object) {
195
+ if teams.values().any(|entry| {
196
+ matches!(
197
+ classify_registered_worker_for_observed_pane(entry, observed),
198
+ WorkerPaneBindingMatch::LiveSameWorker { .. }
199
+ ) || leader_receiver_tuple_matches(entry, observed)
200
+ }) {
201
+ return Some(true);
202
+ }
203
+ }
204
+ }
205
+ Some(false)
206
+ }
207
+
208
+ fn leader_receiver_tuple_matches(state: &Value, observed: &PaneInfo) -> bool {
209
+ let Some(receiver) = state.get("leader_receiver") else {
210
+ return false;
211
+ };
212
+ let Some(cached_pane_id) = non_empty_str(receiver, "pane_id") else {
213
+ return false;
214
+ };
215
+ if cached_pane_id != observed.pane_id.as_str() {
216
+ return false;
217
+ }
218
+ let expected_session = non_empty_str(receiver, "session_name")
219
+ .or_else(|| non_empty_str(state, "session_name"))
220
+ .unwrap_or_default();
221
+ if !expected_session.is_empty() && observed.session.as_str() != expected_session {
222
+ return false;
223
+ }
224
+ if let Some(expected_window) = non_empty_str(receiver, "window_name") {
225
+ if observed.window_name.as_ref().map(|w| w.as_str()) != Some(expected_window) {
226
+ return false;
227
+ }
228
+ }
229
+ if let (Some(expected_pid), Some(observed_pid)) =
230
+ (agent_pane_pid(receiver), observed.pane_pid)
231
+ {
232
+ if expected_pid != observed_pid {
233
+ return false;
234
+ }
235
+ }
236
+ true
237
+ }
238
+
118
239
  #[derive(Debug, Clone, PartialEq, Eq)]
119
240
  pub enum WorkerPaneBindingMatch {
120
241
  LiveSameWorker { agent_id: String },
@@ -329,9 +450,13 @@ fn agent_pane_pid(agent: &Value) -> Option<u32> {
329
450
  }
330
451
 
331
452
  fn session_exists_on_endpoint(endpoint: &str, session: &str) -> bool {
453
+ session_exists_on_endpoint_checked(endpoint, session).unwrap_or(false)
454
+ }
455
+
456
+ fn session_exists_on_endpoint_checked(endpoint: &str, session: &str) -> Option<bool> {
332
457
  crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
333
458
  .has_session(&SessionName::new(session.to_string()))
334
- .unwrap_or(false)
459
+ .ok()
335
460
  }
336
461
 
337
462
  fn collect_stale_endpoint(