@team-agent/installer 0.5.8 → 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.
@@ -159,6 +159,7 @@ fn named_send_args(
159
159
  message_id: None,
160
160
  pane: pane.map(str::to_string),
161
161
  to_name: to_name.map(str::to_string),
162
+ to_leader: None,
162
163
  }
163
164
  }
164
165
 
@@ -328,11 +329,16 @@ fn resolve_leader_name_not_live() {
328
329
  .with_targets(vec![pane("other", "codex", "%other")]);
329
330
 
330
331
  let err = resolve_name_with_transport(&ws, "alpha/leader", &transport).unwrap_err();
331
- assert_eq!(err.kind, NamedAddressErrorKind::NameNotLive);
332
+ // 0.5.9 E6 taxonomy split: `--to-name <team>/leader` when the team
333
+ // exists but the leader receiver's pane is missing is now
334
+ // `leader_not_attached` (not the coarser `name_not_live`), so a
335
+ // third-party sender can be told what to do without being pushed
336
+ // toward `claim-leader`/`takeover` (owner-only actions).
337
+ assert_eq!(err.kind, NamedAddressErrorKind::LeaderNotAttached);
332
338
  let n38 = err.n38_message();
333
- assert!(n38.contains("claim-leader"), "{n38}");
334
339
  assert!(n38.contains("attach-leader"), "{n38}");
335
- assert!(n38.contains("takeover"), "{n38}");
340
+ assert!(!n38.contains("claim-leader"), "third-party copy must not suggest claim-leader: {n38}");
341
+ assert!(!n38.contains("takeover"), "third-party copy must not suggest takeover: {n38}");
336
342
  let _ = std::fs::remove_dir_all(&ws);
337
343
  }
338
344
 
@@ -448,6 +448,7 @@ use super::*;
448
448
  message_id: None,
449
449
  pane: None,
450
450
  to_name: None,
451
+ to_leader: None,
451
452
  }
452
453
  }
453
454
 
@@ -334,6 +334,11 @@ pub struct SendArgs {
334
334
  /// `target` / `targets` / `--pane`; resolves workspace/team/agent or leader
335
335
  /// name to the current live pane before using direct pane injection.
336
336
  pub to_name: Option<String>,
337
+ /// E7 (0.5.9 host-leader-registry-design): `--to-leader NAME` resolves
338
+ /// NAME through `~/.team-agent/leaders`, canonical-validates, and then
339
+ /// delegates to the E6 leader delivery path (live inject or offline
340
+ /// mailbox with `queued_until_leader_attach`).
341
+ pub to_leader: Option<String>,
337
342
  }
338
343
 
339
344
  /// E23 worker-side emergency fallback for `team_orchestrator.send_message`
@@ -617,6 +622,15 @@ pub struct SessionsArgs {
617
622
  pub team: Option<String>,
618
623
  }
619
624
 
625
+ /// E7 (0.5.9 host-leader-registry-design §4.1): `team-agent leaders`
626
+ /// enumerates the host discovery index and classifies each entry as
627
+ /// LIVE, STALE, or AMBIGUOUS after re-validating against canonical
628
+ /// workspace state.
629
+ #[derive(Debug, Clone, PartialEq, Eq)]
630
+ pub struct LeadersArgs {
631
+ pub json: bool,
632
+ }
633
+
620
634
  /// `validate [spec=team.spec.yaml] --json`(`parser.py:120`)。
621
635
  #[derive(Debug, Clone, PartialEq, Eq)]
622
636
  pub struct ValidateArgs {
@@ -81,6 +81,7 @@ pub mod lease;
81
81
  pub mod owner_bind;
82
82
  pub mod provider_attribution;
83
83
  pub mod rediscover;
84
+ pub mod registry;
84
85
  pub mod start;
85
86
  pub mod takeover;
86
87
  pub mod types;
@@ -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
+ }
@@ -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(),
@@ -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 {
@@ -26,6 +26,7 @@ use super::helpers::{
26
26
  };
27
27
  use super::normalize::{
28
28
  compact_tool_result, normalize_report_envelope, normalize_result_status_observed,
29
+ report_result_integrity_warnings,
29
30
  };
30
31
  use super::types::{
31
32
  Scope, SendOutcome, ToolError, ToolErrorReason, ToolOk, ToolResult, VisiblePeers,
@@ -399,7 +400,13 @@ impl TeamOrchestratorTools {
399
400
  self.note_unknown_result_status(&raw);
400
401
  }
401
402
  let normalized = normalize_report_envelope(&base);
402
- let env_value = normalized_envelope_value(&normalized);
403
+ let warnings = report_result_integrity_warnings(&base, &normalized);
404
+ let mut env_value = normalized_envelope_value(&normalized);
405
+ if !warnings.is_empty() {
406
+ if let Some(obj) = env_value.as_object_mut() {
407
+ obj.insert("warnings".to_string(), Value::Array(warnings));
408
+ }
409
+ }
403
410
  let owner_team = self.canonical_owner_team_key()?;
404
411
  messaging::report_result_for_owner_team(
405
412
  &self.workspace,
@@ -541,3 +541,61 @@ fn render_fallback_pane_message(content: &str, message_id: &str, primary_error:
541
541
  [team-agent-token:{message_id}]"
542
542
  )
543
543
  }
544
+
545
+ /// E6 (0.5.9 offline-mailbox-toname-design §6.3): enqueue a leader-bound message
546
+ /// row in the TARGET workspace `team.db` with status `queued_until_leader_attach`.
547
+ ///
548
+ /// This is a durable "leader mailbox" write, NOT a delivery. Coordinator delivery
549
+ /// never claims this status (§3.3), so the row will not churn or be marked
550
+ /// `failed` while the leader is unattached. When the target owner runs
551
+ /// `attach-leader` / `claim-leader`, the existing `requeue_blocked_leader_messages`
552
+ /// helper flips the same row to `accepted` and the standard delivery pipeline
553
+ /// injects it exactly once.
554
+ ///
555
+ /// Safety boundary (§5): only the `messages` table is touched. Owner identity
556
+ /// (`leader_receiver` / `team_owner` / `owner_epoch`) is never written by this
557
+ /// path, and no provider/worker process is spawned.
558
+ pub fn enqueue_leader_mailbox_until_attach(
559
+ target_workspace: &Path,
560
+ canonical_team_key: &str,
561
+ content: &str,
562
+ task_id: Option<&TaskId>,
563
+ sender: &str,
564
+ event_log: &EventLog,
565
+ ) -> Result<DeliveryOutcome, MessagingError> {
566
+ let store = MessageStore::open(target_workspace)?;
567
+ let message_id = store.create_message(
568
+ task_id.map(TaskId::as_str),
569
+ sender,
570
+ "leader",
571
+ content,
572
+ None,
573
+ false,
574
+ Some(canonical_team_key),
575
+ )?;
576
+ // The default row status after `create_message` is `accepted`. Flip it to
577
+ // `queued_until_leader_attach` so it stays out of `claim_for_delivery`
578
+ // eligible set and out of `deliver_pending_messages` scan until an
579
+ // attach/claim explicitly requeues it (see requeue_blocked_leader_messages).
580
+ store.mark(&message_id, "queued_until_leader_attach", None)?;
581
+ event_log.write(
582
+ "leader_mailbox.queued_until_attach",
583
+ serde_json::json!({
584
+ "message_id": message_id,
585
+ "owner_team_id": canonical_team_key,
586
+ "sender": sender,
587
+ "target_workspace": target_workspace.display().to_string(),
588
+ "channel": "leader_mailbox",
589
+ }),
590
+ )?;
591
+ Ok(DeliveryOutcome {
592
+ ok: true,
593
+ status: DeliveryStatus::Queued,
594
+ message_status: MessageStatusShadow("queued_until_leader_attach".to_string()),
595
+ message_id: Some(message_id),
596
+ verification: None,
597
+ stage: None,
598
+ reason: None,
599
+ channel: Some("leader_mailbox".to_string()),
600
+ })
601
+ }
@@ -83,7 +83,8 @@ pub use delivery::{
83
83
  };
84
84
  pub use helpers::fail_leader_delivery;
85
85
  pub use leader_receiver::{
86
- deliver_to_leader_fallback_pane, mirror_peer_message_to_leader, send_to_leader_receiver,
86
+ deliver_to_leader_fallback_pane, enqueue_leader_mailbox_until_attach,
87
+ mirror_peer_message_to_leader, send_to_leader_receiver,
87
88
  send_to_leader_receiver_with_message_id,
88
89
  };
89
90
  pub use peers::allow_peer_talk;