@team-agent/installer 0.5.28 → 0.5.31
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/adapters.rs +24 -4
- 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 +166 -27
- package/crates/team-agent/src/lifecycle/launch.rs +6 -1
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +94 -2
- 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
|
@@ -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(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.31",
|
|
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.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.5.31",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.31",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.31"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|