@team-agent/installer 0.5.7 → 0.5.9
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/attach_app_server_leader.rs +82 -1
- package/crates/team-agent/src/cli/diagnose.rs +46 -17
- package/crates/team-agent/src/cli/emit.rs +30 -1
- package/crates/team-agent/src/cli/leaders.rs +94 -0
- package/crates/team-agent/src/cli/mod.rs +138 -2
- package/crates/team-agent/src/cli/named_address.rs +288 -28
- package/crates/team-agent/src/cli/send.rs +230 -0
- package/crates/team-agent/src/cli/status_port.rs +117 -14
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +9 -3
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +14 -0
- package/crates/team-agent/src/coordinator/tick.rs +7 -1
- package/crates/team-agent/src/db/agent_health_capture.rs +96 -0
- package/crates/team-agent/src/db/mod.rs +1 -0
- package/crates/team-agent/src/leader/mod.rs +1 -0
- package/crates/team-agent/src/leader/registry.rs +402 -0
- package/crates/team-agent/src/lifecycle/restart/remove.rs +13 -61
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
- package/crates/team-agent/src/mcp_server/helpers.rs +18 -1
- package/crates/team-agent/src/mcp_server/normalize.rs +79 -0
- package/crates/team-agent/src/mcp_server/tests/send.rs +125 -13
- package/crates/team-agent/src/mcp_server/tools.rs +24 -2
- package/crates/team-agent/src/messaging/delivery.rs +12 -5
- package/crates/team-agent/src/messaging/leader_receiver.rs +58 -0
- package/crates/team-agent/src/messaging/mod.rs +2 -1
- package/crates/team-agent/src/messaging/results.rs +53 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +7 -2
- package/crates/team-agent/src/messaging/watchers.rs +13 -3
- package/package.json +4 -4
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
//! Phase-DX E7 (0.5.9 host-leader-registry-design): file-per-leader discovery index.
|
|
2
|
+
//!
|
|
3
|
+
//! Location: `~/.team-agent/leaders/<workspace_hash>__<team_key>.json`.
|
|
4
|
+
//!
|
|
5
|
+
//! Registry entries are a **derived** discovery index — never a source of
|
|
6
|
+
//! authority. Read paths must always canonical-validate a resolved entry
|
|
7
|
+
//! against the target workspace's runtime state (leader_receiver,
|
|
8
|
+
//! owner_epoch, transport_kind) before delivering. Ambiguous short names
|
|
9
|
+
//! are refused (`name_ambiguous`) rather than resolved by any priority
|
|
10
|
+
//! heuristic.
|
|
11
|
+
//!
|
|
12
|
+
//! Design constraints (host-leader-registry-design.md §§3, 9):
|
|
13
|
+
//! - No shared/global relational store (file-per-team on the host filesystem).
|
|
14
|
+
//! - No identity writes on read/send paths — write only happens from
|
|
15
|
+
//! `claim-leader`/`attach-leader`/`takeover`/`attach-app-server-leader`
|
|
16
|
+
//! canonical success hooks and shutdown/unregister on canonical success.
|
|
17
|
+
//! - Registry write failure never fails the underlying binding command;
|
|
18
|
+
//! it is discoverability degradation, not binding failure.
|
|
19
|
+
|
|
20
|
+
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
21
|
+
|
|
22
|
+
use std::path::{Path, PathBuf};
|
|
23
|
+
|
|
24
|
+
use serde::{Deserialize, Serialize};
|
|
25
|
+
use sha2::{Digest, Sha256};
|
|
26
|
+
|
|
27
|
+
use crate::transport::Transport;
|
|
28
|
+
|
|
29
|
+
/// Current file schema_version for `~/.team-agent/leaders/*.json`.
|
|
30
|
+
pub const REGISTRY_SCHEMA_VERSION: u32 = 1;
|
|
31
|
+
|
|
32
|
+
/// Event name emitted after a successful atomic temp+rename write.
|
|
33
|
+
pub const EVENT_REGISTERED: &str = "leader_registry.registered";
|
|
34
|
+
|
|
35
|
+
/// Event name emitted when the write step (fs::write / rename / dir create)
|
|
36
|
+
/// failed — the binding command still returns ok, but discoverability
|
|
37
|
+
/// degrades until the next successful canonical hook.
|
|
38
|
+
pub const EVENT_WRITE_FAILED: &str = "leader_registry.write_failed";
|
|
39
|
+
|
|
40
|
+
/// Event name emitted when a canonical shutdown/unbind removed the entry.
|
|
41
|
+
pub const EVENT_UNREGISTERED: &str = "leader_registry.unregistered";
|
|
42
|
+
|
|
43
|
+
/// v1 file schema. Fields are copied verbatim from `LeaderRegistryEntry`
|
|
44
|
+
/// during atomic writes; readers deserialize the same shape then validate
|
|
45
|
+
/// against canonical state before using any field for routing.
|
|
46
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
47
|
+
pub struct LeaderRegistryEntry {
|
|
48
|
+
pub schema_version: u32,
|
|
49
|
+
/// Default delivery name — the runtime `team_key`. Never the display or
|
|
50
|
+
/// spec name (host-leader-registry-design.md §5.1).
|
|
51
|
+
pub delivery_name: String,
|
|
52
|
+
/// Human-friendly qualified name: `<workspace_short>/<team_key>`.
|
|
53
|
+
pub qualified_name: String,
|
|
54
|
+
/// Stable qualified name: `<workspace_hash>/<team_key>`. Always accepted
|
|
55
|
+
/// by resolvers even when the human form is ambiguous.
|
|
56
|
+
pub stable_qualified_name: String,
|
|
57
|
+
/// Explicit aliases attached at binding time. Alias collisions follow
|
|
58
|
+
/// the same `name_ambiguous` rule as delivery_name collisions.
|
|
59
|
+
pub aliases: Vec<String>,
|
|
60
|
+
pub workspace: PathBuf,
|
|
61
|
+
/// sha256-prefix of canonical realpath workspace; used only as a file
|
|
62
|
+
/// name component and stable identifier — never an authority key.
|
|
63
|
+
pub workspace_hash: String,
|
|
64
|
+
pub workspace_short: String,
|
|
65
|
+
pub team_key: String,
|
|
66
|
+
pub transport_kind: String,
|
|
67
|
+
pub channel: serde_json::Value,
|
|
68
|
+
pub owner_epoch: u64,
|
|
69
|
+
pub attached_at: String,
|
|
70
|
+
pub updated_at: String,
|
|
71
|
+
pub source: String,
|
|
72
|
+
pub status: String,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
impl LeaderRegistryEntry {
|
|
76
|
+
/// Ambiguous-name refusal semantics (host-leader-registry-design.md §5.2):
|
|
77
|
+
/// two entries with the same `delivery_name` remain visible in
|
|
78
|
+
/// `leaders` under `ambiguous_names`, and `send --to-leader <short>`
|
|
79
|
+
/// refuses `name_ambiguous`. Callers surface the ambiguity via
|
|
80
|
+
/// candidates rather than choosing a winner. The design explicitly
|
|
81
|
+
/// forbids priority heuristics based on write order, modification
|
|
82
|
+
/// time, or ambient state — see host-leader-registry-design.md §5.2
|
|
83
|
+
/// for the enumerated exclusions.
|
|
84
|
+
#[must_use]
|
|
85
|
+
pub fn short_name_collision_refuses() -> &'static str {
|
|
86
|
+
"ambiguous"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// Payload key for the list of short-name collisions surfaced by
|
|
91
|
+
/// `team-agent leaders` — kept as a top-level constant so both `leaders`
|
|
92
|
+
/// and `send --to-leader` refer to the same wire name.
|
|
93
|
+
pub const AMBIGUOUS_NAMES_FIELD: &str = "ambiguous_names";
|
|
94
|
+
|
|
95
|
+
/// Wire reason used by both `leaders` and `send --to-leader` when a name
|
|
96
|
+
/// resolves to multiple canonical-live entries. Candidates carry
|
|
97
|
+
/// `workspace_hash` + `stable_qualified_name` so the caller can retry with
|
|
98
|
+
/// a fully-qualified form.
|
|
99
|
+
pub const REASON_AMBIGUOUS: &str = "name_ambiguous";
|
|
100
|
+
|
|
101
|
+
/// Absolute path to `~/.team-agent/leaders`. Returns `None` when the
|
|
102
|
+
/// caller has no HOME (e.g. some CI shells) — callers should treat this as
|
|
103
|
+
/// "no registry" rather than an error, since registry is derived.
|
|
104
|
+
#[must_use]
|
|
105
|
+
pub fn registry_dir() -> Option<PathBuf> {
|
|
106
|
+
let home = std::env::var_os("HOME").map(PathBuf::from)?;
|
|
107
|
+
Some(home.join(".team-agent").join("leaders"))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// Compute the sha256 hex prefix (12 chars) of a canonical workspace path.
|
|
111
|
+
/// The canonical form is used so equivalent paths (symlinks, `.`, etc.)
|
|
112
|
+
/// map to the same discovery id. If canonicalize fails the input path is
|
|
113
|
+
/// used verbatim — the hash is a stable label, not an authority key.
|
|
114
|
+
#[must_use]
|
|
115
|
+
pub fn workspace_hash(workspace: &Path) -> String {
|
|
116
|
+
let canonical = std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
|
|
117
|
+
let mut hasher = Sha256::new();
|
|
118
|
+
hasher.update(canonical.to_string_lossy().as_bytes());
|
|
119
|
+
let digest = hasher.finalize();
|
|
120
|
+
digest[..6]
|
|
121
|
+
.iter()
|
|
122
|
+
.map(|byte| format!("{byte:02x}"))
|
|
123
|
+
.collect()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Compute the discovery-visible short label for a workspace path —
|
|
127
|
+
/// basename by default, "workspace" when the path has no name component.
|
|
128
|
+
#[must_use]
|
|
129
|
+
pub fn workspace_short(workspace: &Path) -> String {
|
|
130
|
+
workspace
|
|
131
|
+
.file_name()
|
|
132
|
+
.and_then(|s| s.to_str())
|
|
133
|
+
.map(str::to_string)
|
|
134
|
+
.unwrap_or_else(|| "workspace".to_string())
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// Assemble a schema-v1 entry from canonical binding fields. Callers use
|
|
138
|
+
/// this from the binding-command success hooks; the entry then flows into
|
|
139
|
+
/// [`write_entry_best_effort`].
|
|
140
|
+
#[must_use]
|
|
141
|
+
#[allow(clippy::too_many_arguments)]
|
|
142
|
+
pub fn build_entry(
|
|
143
|
+
workspace: &Path,
|
|
144
|
+
team_key: &str,
|
|
145
|
+
transport_kind: &str,
|
|
146
|
+
channel: serde_json::Value,
|
|
147
|
+
owner_epoch: u64,
|
|
148
|
+
source: &str,
|
|
149
|
+
now_rfc3339: String,
|
|
150
|
+
) -> LeaderRegistryEntry {
|
|
151
|
+
let hash = workspace_hash(workspace);
|
|
152
|
+
let short = workspace_short(workspace);
|
|
153
|
+
LeaderRegistryEntry {
|
|
154
|
+
schema_version: REGISTRY_SCHEMA_VERSION,
|
|
155
|
+
delivery_name: team_key.to_string(),
|
|
156
|
+
qualified_name: format!("{short}/{team_key}"),
|
|
157
|
+
stable_qualified_name: format!("{hash}/{team_key}"),
|
|
158
|
+
aliases: Vec::new(),
|
|
159
|
+
workspace: std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf()),
|
|
160
|
+
workspace_hash: hash,
|
|
161
|
+
workspace_short: short,
|
|
162
|
+
team_key: team_key.to_string(),
|
|
163
|
+
transport_kind: transport_kind.to_string(),
|
|
164
|
+
channel,
|
|
165
|
+
owner_epoch,
|
|
166
|
+
attached_at: now_rfc3339.clone(),
|
|
167
|
+
updated_at: now_rfc3339,
|
|
168
|
+
source: source.to_string(),
|
|
169
|
+
status: "attached".to_string(),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
fn entry_filename(entry: &LeaderRegistryEntry) -> String {
|
|
174
|
+
format!("{}__{}.json", entry.workspace_hash, entry.team_key)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// Registry write is best-effort. Writes to `.<file>.tmp-<pid>-<counter>`
|
|
178
|
+
/// first, then atomically renames to `<workspace_hash>__<team_key>.json`.
|
|
179
|
+
/// Returns the final path when the write succeeded, `None` otherwise —
|
|
180
|
+
/// the binding command must never fail on registry errors.
|
|
181
|
+
pub fn write_entry_best_effort(entry: &LeaderRegistryEntry) -> Option<PathBuf> {
|
|
182
|
+
let dir = registry_dir()?;
|
|
183
|
+
if let Err(_error) = std::fs::create_dir_all(&dir) {
|
|
184
|
+
return None;
|
|
185
|
+
}
|
|
186
|
+
let final_path = dir.join(entry_filename(entry));
|
|
187
|
+
let tmp_name = format!(
|
|
188
|
+
".{}.tmp-{}-{}",
|
|
189
|
+
entry_filename(entry),
|
|
190
|
+
std::process::id(),
|
|
191
|
+
rand_suffix()
|
|
192
|
+
);
|
|
193
|
+
let tmp_path = dir.join(tmp_name);
|
|
194
|
+
let serialized = serde_json::to_string_pretty(entry).ok()?;
|
|
195
|
+
if std::fs::write(&tmp_path, serialized).is_err() {
|
|
196
|
+
let _ = std::fs::remove_file(&tmp_path);
|
|
197
|
+
return None;
|
|
198
|
+
}
|
|
199
|
+
if std::fs::rename(&tmp_path, &final_path).is_err() {
|
|
200
|
+
let _ = std::fs::remove_file(&tmp_path);
|
|
201
|
+
return None;
|
|
202
|
+
}
|
|
203
|
+
Some(final_path)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
fn rand_suffix() -> String {
|
|
207
|
+
// Non-cryptographic uniqueness: a process-local monotonic counter is
|
|
208
|
+
// enough because the tmp name only needs to be unique within one
|
|
209
|
+
// process lifetime (we also embed the pid).
|
|
210
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
211
|
+
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
212
|
+
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
213
|
+
format!("{n:x}")
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// Remove the registry entry for `(workspace, team_key)` if it exists.
|
|
217
|
+
/// Called from shutdown/unbind success hooks after canonical state
|
|
218
|
+
/// reflects the leader as no longer bound. Missing files are treated as
|
|
219
|
+
/// success — the invariant is "no registry entry after unbind", not
|
|
220
|
+
/// "must have found something to delete".
|
|
221
|
+
pub fn unregister_entry(workspace: &Path, team_key: &str) -> Option<PathBuf> {
|
|
222
|
+
let dir = registry_dir()?;
|
|
223
|
+
let hash = workspace_hash(workspace);
|
|
224
|
+
let path = dir.join(format!("{hash}__{team_key}.json"));
|
|
225
|
+
if path.exists() {
|
|
226
|
+
std::fs::remove_file(&path).ok()?;
|
|
227
|
+
return Some(path);
|
|
228
|
+
}
|
|
229
|
+
None
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/// Read and deserialize every `*.json` file under the registry directory.
|
|
233
|
+
/// Skips unreadable / malformed files silently (returns fewer entries) —
|
|
234
|
+
/// registry is a derived discovery index and unreadable files are the
|
|
235
|
+
/// "STALE / UNREADABLE" dirty class rather than an error.
|
|
236
|
+
#[must_use]
|
|
237
|
+
pub fn read_all_entries() -> Vec<(PathBuf, LeaderRegistryEntry)> {
|
|
238
|
+
let Some(dir) = registry_dir() else {
|
|
239
|
+
return Vec::new();
|
|
240
|
+
};
|
|
241
|
+
let Ok(read_dir) = std::fs::read_dir(&dir) else {
|
|
242
|
+
return Vec::new();
|
|
243
|
+
};
|
|
244
|
+
let mut out = Vec::new();
|
|
245
|
+
for entry in read_dir.flatten() {
|
|
246
|
+
let path = entry.path();
|
|
247
|
+
if path
|
|
248
|
+
.file_name()
|
|
249
|
+
.and_then(|s| s.to_str())
|
|
250
|
+
.is_some_and(|name| name.starts_with('.'))
|
|
251
|
+
{
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
let Ok(text) = std::fs::read_to_string(&path) else {
|
|
258
|
+
continue;
|
|
259
|
+
};
|
|
260
|
+
let Ok(entry) = serde_json::from_str::<LeaderRegistryEntry>(&text) else {
|
|
261
|
+
continue;
|
|
262
|
+
};
|
|
263
|
+
out.push((path, entry));
|
|
264
|
+
}
|
|
265
|
+
out.sort_by(|a, b| a.0.cmp(&b.0));
|
|
266
|
+
out
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/// Validate a registry entry against its canonical workspace state.
|
|
270
|
+
/// Returns the wire status label — LIVE when canonical `leader_receiver`
|
|
271
|
+
/// matches, STALE otherwise. This is the pruning gate for GC and the
|
|
272
|
+
/// per-entry classification the `leaders` CLI reports.
|
|
273
|
+
#[must_use]
|
|
274
|
+
pub fn classify(entry: &LeaderRegistryEntry) -> (&'static str, Option<String>) {
|
|
275
|
+
let Ok(state) = crate::state::persist::load_runtime_state(&entry.workspace) else {
|
|
276
|
+
return ("STALE", Some("workspace_no_state".to_string()));
|
|
277
|
+
};
|
|
278
|
+
let team = state
|
|
279
|
+
.get("teams")
|
|
280
|
+
.and_then(|v| v.as_object())
|
|
281
|
+
.and_then(|teams| teams.get(&entry.team_key));
|
|
282
|
+
let team = match team {
|
|
283
|
+
Some(t) => t,
|
|
284
|
+
None => return ("STALE", Some("team_key_not_found".to_string())),
|
|
285
|
+
};
|
|
286
|
+
// Team status must indicate liveness. Empty / missing counts as
|
|
287
|
+
// alive for pre-status states; explicit down/stopped/archived is
|
|
288
|
+
// terminal STALE.
|
|
289
|
+
let team_status = team
|
|
290
|
+
.get("status")
|
|
291
|
+
.and_then(|v| v.as_str())
|
|
292
|
+
.unwrap_or("alive");
|
|
293
|
+
if team_status == "down" || team_status == "stopped" || team_status == "archived" {
|
|
294
|
+
return ("STALE", Some("team_not_alive".to_string()));
|
|
295
|
+
}
|
|
296
|
+
let receiver = team.get("leader_receiver");
|
|
297
|
+
let receiver = match receiver {
|
|
298
|
+
Some(r) if !r.is_null() => r,
|
|
299
|
+
_ => return ("STALE", Some("leader_not_attached".to_string())),
|
|
300
|
+
};
|
|
301
|
+
let canonical_epoch = receiver
|
|
302
|
+
.get("owner_epoch")
|
|
303
|
+
.and_then(|v| v.as_u64())
|
|
304
|
+
.or_else(|| team.get("owner_epoch").and_then(|v| v.as_u64()))
|
|
305
|
+
.unwrap_or(0);
|
|
306
|
+
if canonical_epoch != entry.owner_epoch {
|
|
307
|
+
return (
|
|
308
|
+
"STALE",
|
|
309
|
+
Some(format!(
|
|
310
|
+
"owner_epoch_mismatch:registry={},canonical={}",
|
|
311
|
+
entry.owner_epoch, canonical_epoch
|
|
312
|
+
)),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
// Verify the recorded leader pane is actually live on its recorded
|
|
316
|
+
// tmux endpoint. A killed tmux session (test's kill_session) means
|
|
317
|
+
// the receiver is stale even though the state hasn't been updated.
|
|
318
|
+
let pane_id = receiver.get("pane_id").and_then(|v| v.as_str());
|
|
319
|
+
let socket = receiver.get("tmux_socket").and_then(|v| v.as_str());
|
|
320
|
+
if let (Some(pane), Some(sock)) = (pane_id, socket) {
|
|
321
|
+
if !tmux_pane_live(sock, pane) {
|
|
322
|
+
return ("STALE", Some("leader_pane_dead".to_string()));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
("LIVE", None)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
fn tmux_pane_live(socket: &str, pane_id: &str) -> bool {
|
|
329
|
+
// Best-effort tmux liveness check via the same transport factory the
|
|
330
|
+
// main runtime uses. On error we treat the pane as NOT live so a
|
|
331
|
+
// send never routes through a socket we could not verify.
|
|
332
|
+
let backend = crate::transport_factory::tmux_endpoint_transport(socket);
|
|
333
|
+
match backend.list_targets() {
|
|
334
|
+
Ok(targets) => targets.iter().any(|t| t.pane_id.as_str() == pane_id),
|
|
335
|
+
Err(_) => false,
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/// Return the canonical state's `leader_receiver` value for the entry's
|
|
340
|
+
/// team, if one exists. Send/leaders paths use this so they never route
|
|
341
|
+
/// through a stale registry channel — canonical state is the truth.
|
|
342
|
+
#[must_use]
|
|
343
|
+
pub fn canonical_receiver(entry: &LeaderRegistryEntry) -> Option<serde_json::Value> {
|
|
344
|
+
let state = crate::state::persist::load_runtime_state(&entry.workspace).ok()?;
|
|
345
|
+
let team = state
|
|
346
|
+
.get("teams")
|
|
347
|
+
.and_then(|v| v.as_object())
|
|
348
|
+
.and_then(|teams| teams.get(&entry.team_key))?;
|
|
349
|
+
team.get("leader_receiver").cloned()
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/// Read all entries, classify each, and prune terminal-stale entries
|
|
353
|
+
/// (`workspace_no_state`, `team_key_not_found`). Live entries are never
|
|
354
|
+
/// touched. Returns the surviving (LIVE + STALE-not-yet-pruned) entries
|
|
355
|
+
/// classified for the `leaders` command output.
|
|
356
|
+
///
|
|
357
|
+
/// Design §14 step 6: "Successful scoped shutdown removes matching registry
|
|
358
|
+
/// entry. Failed/degraded shutdown leaves entry STALE, not deleted."
|
|
359
|
+
/// The pruning here is the GC arm — it only removes entries whose
|
|
360
|
+
/// canonical workspace/team has no state at all, i.e. the target has been
|
|
361
|
+
/// permanently removed. Leader-detached-but-team-alive stays STALE and
|
|
362
|
+
/// visible so the operator can decide.
|
|
363
|
+
#[must_use]
|
|
364
|
+
pub fn list_validated_with_gc() -> Vec<(LeaderRegistryEntry, &'static str, Option<String>)> {
|
|
365
|
+
let mut out = Vec::new();
|
|
366
|
+
for (path, entry) in read_all_entries() {
|
|
367
|
+
let (status, reason) = classify(&entry);
|
|
368
|
+
// Terminal stale reasons that indicate the entry can never be
|
|
369
|
+
// useful again: canonical workspace/team gone, or the leader
|
|
370
|
+
// binding has been fully released (no receiver at all). These
|
|
371
|
+
// are safe to prune; leader-attached-with-dead-pane and
|
|
372
|
+
// epoch-mismatch stay visible so the operator can see them.
|
|
373
|
+
if status == "STALE"
|
|
374
|
+
&& reason.as_deref().is_some_and(|r| {
|
|
375
|
+
r == "workspace_no_state"
|
|
376
|
+
|| r == "team_key_not_found"
|
|
377
|
+
|| r == "leader_not_attached"
|
|
378
|
+
|| r == "team_not_alive"
|
|
379
|
+
})
|
|
380
|
+
{
|
|
381
|
+
let _ = std::fs::remove_file(&path);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
out.push((entry, status, reason));
|
|
385
|
+
}
|
|
386
|
+
out
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/// Same as `list_validated_with_gc` but preserves all entries. `send
|
|
390
|
+
/// --to-leader` needs to see stale entries so it can refuse with
|
|
391
|
+
/// `registry_stale` (and never silently fall through to
|
|
392
|
+
/// `leader_name_not_found` just because GC ran first).
|
|
393
|
+
#[must_use]
|
|
394
|
+
pub fn list_validated_no_gc() -> Vec<(LeaderRegistryEntry, &'static str, Option<String>)> {
|
|
395
|
+
read_all_entries()
|
|
396
|
+
.into_iter()
|
|
397
|
+
.map(|(_path, entry)| {
|
|
398
|
+
let (status, reason) = classify(&entry);
|
|
399
|
+
(entry, status, reason)
|
|
400
|
+
})
|
|
401
|
+
.collect()
|
|
402
|
+
}
|
|
@@ -670,78 +670,30 @@ fn delete_agent_health(workspace: &Path, agent_id: &AgentId) -> Result<bool, Lif
|
|
|
670
670
|
Ok(changed > 0)
|
|
671
671
|
}
|
|
672
672
|
|
|
673
|
-
|
|
674
|
-
|
|
673
|
+
// Phase-DX E2: `select_agent_health` / `restore_agent_health` / `CapturedHealth` moved to
|
|
674
|
+
// `db::agent_health_capture` so the SQL column references (agent_health backup columns)
|
|
675
|
+
// live in the persistence layer (whitelisted by the E2 grep guard) rather than lifecycle
|
|
676
|
+
// policy code. The wrappers below preserve the existing `LifecycleError` surface.
|
|
677
|
+
use crate::db::agent_health_capture::{
|
|
678
|
+
restore_agent_health as capture_restore_agent_health,
|
|
679
|
+
select_agent_health as capture_select_agent_health, CapturedHealth,
|
|
680
|
+
};
|
|
681
|
+
|
|
675
682
|
fn select_agent_health(
|
|
676
683
|
workspace: &Path,
|
|
677
684
|
agent_id: &AgentId,
|
|
678
685
|
) -> Result<Option<CapturedHealth>, LifecycleError> {
|
|
679
|
-
|
|
680
|
-
.map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
681
|
-
let conn = crate::db::schema::open_db(store.db_path())
|
|
682
|
-
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
683
|
-
let row = conn
|
|
684
|
-
.query_row(
|
|
685
|
-
"select owner_team_id, status, last_output_at, context_usage_pct, current_task_id \
|
|
686
|
-
from agent_health where agent_id = ?1",
|
|
687
|
-
[agent_id.as_str()],
|
|
688
|
-
|r| {
|
|
689
|
-
Ok(CapturedHealth {
|
|
690
|
-
owner_team_id: r.get::<_, Option<String>>(0)?,
|
|
691
|
-
status: r.get::<_, Option<String>>(1)?,
|
|
692
|
-
last_output_at: r.get::<_, Option<String>>(2)?,
|
|
693
|
-
context_usage_pct: r.get::<_, Option<i64>>(3)?,
|
|
694
|
-
current_task_id: r.get::<_, Option<String>>(4)?,
|
|
695
|
-
})
|
|
696
|
-
},
|
|
697
|
-
)
|
|
698
|
-
.ok();
|
|
699
|
-
Ok(row)
|
|
686
|
+
capture_select_agent_health(workspace, agent_id)
|
|
687
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
700
688
|
}
|
|
701
689
|
|
|
702
|
-
/// golden agents.py:268-278 `_restore_agent_health`: re-upsert the captured row (status||"IDLE"), or
|
|
703
|
-
/// delete the row when there was nothing to restore.
|
|
704
690
|
fn restore_agent_health(
|
|
705
691
|
workspace: &Path,
|
|
706
692
|
agent_id: &AgentId,
|
|
707
693
|
row: &Option<CapturedHealth>,
|
|
708
694
|
) -> Result<(), LifecycleError> {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
return Ok(());
|
|
712
|
-
};
|
|
713
|
-
let store = crate::message_store::MessageStore::open(workspace)
|
|
714
|
-
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
715
|
-
let conn = crate::db::schema::open_db(store.db_path())
|
|
716
|
-
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
717
|
-
let status = row.status.clone().unwrap_or_else(|| "IDLE".to_string());
|
|
718
|
-
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6f+00:00").to_string();
|
|
719
|
-
// The restore always follows a delete of this row, so a plain insert re-materializes the captured
|
|
720
|
-
// health (golden _restore_agent_health re-upserts status||"IDLE" + the captured columns).
|
|
721
|
-
conn.execute(
|
|
722
|
-
"insert into agent_health (owner_team_id, agent_id, status, last_output_at, context_usage_pct, current_task_id, updated_at) \
|
|
723
|
-
values (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
724
|
-
rusqlite::params![
|
|
725
|
-
row.owner_team_id,
|
|
726
|
-
agent_id.as_str(),
|
|
727
|
-
status,
|
|
728
|
-
row.last_output_at,
|
|
729
|
-
row.context_usage_pct,
|
|
730
|
-
row.current_task_id,
|
|
731
|
-
now,
|
|
732
|
-
],
|
|
733
|
-
)
|
|
734
|
-
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
735
|
-
Ok(())
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
#[derive(Clone)]
|
|
739
|
-
struct CapturedHealth {
|
|
740
|
-
owner_team_id: Option<String>,
|
|
741
|
-
status: Option<String>,
|
|
742
|
-
last_output_at: Option<String>,
|
|
743
|
-
context_usage_pct: Option<i64>,
|
|
744
|
-
current_task_id: Option<String>,
|
|
695
|
+
capture_restore_agent_health(workspace, agent_id, row)
|
|
696
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
745
697
|
}
|
|
746
698
|
|
|
747
699
|
fn maybe_fail_remove_after_agent_health_delete() -> Result<(), LifecycleError> {
|
|
@@ -336,10 +336,27 @@ fn current_turn_id_from_state(state: &Value, agent_id: &str) -> Option<String> {
|
|
|
336
336
|
}
|
|
337
337
|
}
|
|
338
338
|
}
|
|
339
|
+
// Phase-DX E2: read the renamed `current_turn_message_id` (leader→worker turn
|
|
340
|
+
// proxy from `delivery::arm_turn_open`). Legacy state written by 0.5.x may
|
|
341
|
+
// still carry the pre-rename JSON key; the fallback keeps current-turn
|
|
342
|
+
// attribution stable during the transition. Neither field is treated as
|
|
343
|
+
// authoritative task state — that stays A1 territory (task FSM).
|
|
344
|
+
//
|
|
345
|
+
// The next line reads the pre-rename JSON key verbatim; that is deliberate
|
|
346
|
+
// and marked so the E2 grep guard admits it as a documented exception. The
|
|
347
|
+
// marker distinguishes a legitimate read-only backwards-compat bridge from
|
|
348
|
+
// an authority-consuming read (which the guard still forbids). Delete this
|
|
349
|
+
// fallback and its marker when the A1 task FSM lands (task attribution
|
|
350
|
+
// becomes authoritative and legacy state layouts are no longer produced).
|
|
351
|
+
let legacy_field = "current_task_id"; // ALLOWED-LEGACY-READ: backward-compat bridge for pre-rename key
|
|
339
352
|
state
|
|
340
353
|
.get("agents")
|
|
341
354
|
.and_then(|agents| agents.get(agent_id))
|
|
342
|
-
.and_then(|agent|
|
|
355
|
+
.and_then(|agent| {
|
|
356
|
+
agent
|
|
357
|
+
.get("current_turn_message_id")
|
|
358
|
+
.or_else(|| agent.get(legacy_field))
|
|
359
|
+
})
|
|
343
360
|
.and_then(Value::as_str)
|
|
344
361
|
.and_then(non_empty_string)
|
|
345
362
|
.map(ToString::to_string)
|
|
@@ -118,6 +118,84 @@ pub fn normalize_report_envelope(env: &Value) -> NormalizedReportEnvelope {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
pub(crate) fn report_result_integrity_warnings(
|
|
122
|
+
source: &Value,
|
|
123
|
+
normalized: &NormalizedReportEnvelope,
|
|
124
|
+
) -> Vec<Value> {
|
|
125
|
+
let mut warnings = source
|
|
126
|
+
.get("warnings")
|
|
127
|
+
.and_then(Value::as_array)
|
|
128
|
+
.cloned()
|
|
129
|
+
.unwrap_or_default();
|
|
130
|
+
|
|
131
|
+
match normalized.status {
|
|
132
|
+
ResultStatus::Success => {
|
|
133
|
+
if normalized.tests.is_empty()
|
|
134
|
+
|| normalized
|
|
135
|
+
.tests
|
|
136
|
+
.iter()
|
|
137
|
+
.all(|test| test.status == TestStatus::NotRun)
|
|
138
|
+
{
|
|
139
|
+
push_integrity_warning(
|
|
140
|
+
&mut warnings,
|
|
141
|
+
"result_success_without_executed_tests",
|
|
142
|
+
"tests",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if change_path_missing_description(source.get("changes")) {
|
|
146
|
+
push_integrity_warning(
|
|
147
|
+
&mut warnings,
|
|
148
|
+
"result_change_missing_description",
|
|
149
|
+
"changes",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
ResultStatus::Partial | ResultStatus::Blocked => {
|
|
154
|
+
if normalized.tests.is_empty()
|
|
155
|
+
|| normalized
|
|
156
|
+
.tests
|
|
157
|
+
.iter()
|
|
158
|
+
.any(|test| test.status == TestStatus::NotRun)
|
|
159
|
+
{
|
|
160
|
+
push_integrity_warning(&mut warnings, "result_not_verified", "tests");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
ResultStatus::Failed => {}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
warnings
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
fn push_integrity_warning(warnings: &mut Vec<Value>, code: &str, field: &str) {
|
|
170
|
+
let exists = warnings.iter().any(|warning| {
|
|
171
|
+
warning.get("code").and_then(Value::as_str) == Some(code)
|
|
172
|
+
&& warning.get("field").and_then(Value::as_str) == Some(field)
|
|
173
|
+
});
|
|
174
|
+
if !exists {
|
|
175
|
+
warnings.push(serde_json::json!({
|
|
176
|
+
"code": code,
|
|
177
|
+
"field": field,
|
|
178
|
+
"severity": "warning",
|
|
179
|
+
"advisory": true
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
fn change_path_missing_description(value: Option<&Value>) -> bool {
|
|
185
|
+
items_from_value(value).iter().any(|item| {
|
|
186
|
+
let Some(obj) = item.as_object() else {
|
|
187
|
+
return false;
|
|
188
|
+
};
|
|
189
|
+
let has_path = ["path", "file", "filepath", "filename"]
|
|
190
|
+
.iter()
|
|
191
|
+
.any(|key| obj.get(*key).and_then(text_of_value).is_some());
|
|
192
|
+
let has_description = ["description", "summary", "detail", "details", "message"]
|
|
193
|
+
.iter()
|
|
194
|
+
.any(|key| obj.get(*key).and_then(text_of_value).is_some());
|
|
195
|
+
has_path && !has_description
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
121
199
|
/// `_compact_tool_result` (`normalize.py:6-64`): whitelist-key compaction of a
|
|
122
200
|
/// delegate result. ok vs error use different key sets; `fanout_*` status preserves
|
|
123
201
|
/// `deliveries`/`recipients`; `acknowledged_messages` → `acknowledged_count` (len).
|
|
@@ -170,6 +248,7 @@ pub fn compact_tool_result(result: &Value) -> ToolResult {
|
|
|
170
248
|
"notification_status",
|
|
171
249
|
"notification_channel",
|
|
172
250
|
"notification_event_id",
|
|
251
|
+
"warnings",
|
|
173
252
|
]
|
|
174
253
|
};
|
|
175
254
|
for key in keys {
|