@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.
Files changed (32) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/attach_app_server_leader.rs +82 -1
  4. package/crates/team-agent/src/cli/diagnose.rs +46 -17
  5. package/crates/team-agent/src/cli/emit.rs +30 -1
  6. package/crates/team-agent/src/cli/leaders.rs +94 -0
  7. package/crates/team-agent/src/cli/mod.rs +138 -2
  8. package/crates/team-agent/src/cli/named_address.rs +288 -28
  9. package/crates/team-agent/src/cli/send.rs +230 -0
  10. package/crates/team-agent/src/cli/status_port.rs +117 -14
  11. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  12. package/crates/team-agent/src/cli/tests/named_address.rs +9 -3
  13. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  14. package/crates/team-agent/src/cli/types.rs +14 -0
  15. package/crates/team-agent/src/coordinator/tick.rs +7 -1
  16. package/crates/team-agent/src/db/agent_health_capture.rs +96 -0
  17. package/crates/team-agent/src/db/mod.rs +1 -0
  18. package/crates/team-agent/src/leader/mod.rs +1 -0
  19. package/crates/team-agent/src/leader/registry.rs +402 -0
  20. package/crates/team-agent/src/lifecycle/restart/remove.rs +13 -61
  21. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
  22. package/crates/team-agent/src/mcp_server/helpers.rs +18 -1
  23. package/crates/team-agent/src/mcp_server/normalize.rs +79 -0
  24. package/crates/team-agent/src/mcp_server/tests/send.rs +125 -13
  25. package/crates/team-agent/src/mcp_server/tools.rs +24 -2
  26. package/crates/team-agent/src/messaging/delivery.rs +12 -5
  27. package/crates/team-agent/src/messaging/leader_receiver.rs +58 -0
  28. package/crates/team-agent/src/messaging/mod.rs +2 -1
  29. package/crates/team-agent/src/messaging/results.rs +53 -0
  30. package/crates/team-agent/src/messaging/tests/runtime.rs +7 -2
  31. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  32. 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
- /// golden agents.py:185 `copy.deepcopy(store.agent_health().get(agent_id))` read the row BEFORE delete
674
- /// so the rollback can re-upsert it. Returns the captured health columns, or None if absent.
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
- let store = crate::message_store::MessageStore::open(workspace)
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
- let Some(row) = row else {
710
- delete_agent_health(workspace, agent_id)?;
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> {
@@ -205,6 +205,7 @@ fn run_phase_golden(spec: PhaseGolden) -> Value {
205
205
  message_id: Some("phase-golden-message".to_string()),
206
206
  pane: None,
207
207
  to_name: None,
208
+ to_leader: None,
208
209
  });
209
210
  let collect = cmd_collect(&CollectArgs {
210
211
  workspace: workspace.clone(),
@@ -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| agent.get("current_task_id"))
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 {