@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/diagnose.rs +1 -1
- package/crates/team-agent/src/cli/emit.rs +211 -59
- package/crates/team-agent/src/cli/mod.rs +45 -19
- package/crates/team-agent/src/cli/send.rs +19 -20
- package/crates/team-agent/src/cli/spec.rs +222 -0
- package/crates/team-agent/src/cli/status.rs +29 -0
- package/crates/team-agent/src/cli/status_port.rs +36 -4
- package/crates/team-agent/src/leader/lease.rs +127 -27
- package/crates/team-agent/src/lifecycle/launch.rs +6 -1
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +85 -0
- package/crates/team-agent/src/state/mod.rs +1 -0
- package/crates/team-agent/src/state/repository.rs +387 -0
- package/crates/team-agent/src/topology.rs +130 -5
- package/package.json +4 -4
|
@@ -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
|
-
|
|
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,
|
|
@@ -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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
.
|
|
459
|
+
.ok()
|
|
335
460
|
}
|
|
336
461
|
|
|
337
462
|
fn collect_stale_endpoint(
|