@team-agent/installer 0.3.22 → 0.3.24
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/leader/lease.rs +34 -13
- package/crates/team-agent/src/leader/tests/lease_api.rs +79 -0
- package/crates/team-agent/src/lifecycle/helpers.rs +11 -3
- package/crates/team-agent/src/lifecycle/launch.rs +2 -16
- package/crates/team-agent/src/lifecycle/restart/common.rs +103 -0
- package/crates/team-agent/src/messaging/delivery.rs +74 -27
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
- package/crates/team-agent/src/messaging/tests/wave2.rs +689 -0
- package/crates/team-agent/src/state/projection.rs +5 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +46 -0
- package/crates/team-agent/src/tmux_backend.rs +17 -5
- package/crates/team-agent/src/transport/test_support.rs +79 -6
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +2 -1
- package/skills/team-agent/references/team-in-team.md +127 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
//! U1 波2 contracts (RED-first → GREEN after fix).
|
|
2
|
+
//!
|
|
3
|
+
//! Wave-2 closes three hardening gaps the wave-1 7-acute audit identified but
|
|
4
|
+
//! left untouched:
|
|
5
|
+
//!
|
|
6
|
+
//! * U1-A — `resolve_inject_target` LEADER branch had no liveness/fallback
|
|
7
|
+
//! gate: a stale `leader_receiver.pane_id` would be returned verbatim, and
|
|
8
|
+
//! the upstream `leader_receiver_pane_is_usable` would also fail (pane_id
|
|
9
|
+
//! no longer in `list_targets`, liveness Dead) → message goes terminal
|
|
10
|
+
//! `failed:leader_not_attached` on a mere pane-id drift. The wave-2 fix
|
|
11
|
+
//! widens "usable" to include "leader session+window exists" and lets the
|
|
12
|
+
//! resolver fall back to that live pane.
|
|
13
|
+
//! * U1-B — `transport.list_targets().unwrap_or_default()` SWALLOWED server
|
|
14
|
+
//! jitter (`Err` ≡ subprocess fork failed) as an empty vec. With the cache
|
|
15
|
+
//! codepath at :513-516 (`if live_targets.is_empty() && !known_dead { reuse }`)
|
|
16
|
+
//! a true jitter Err would reuse a never-validated cached_pane → injects
|
|
17
|
+
//! into a possibly-dead pane. The wave-2 fix DEFERs (degraded outcome,
|
|
18
|
+
//! `target_resolved` status) on Err and only treats Ok(empty) as authoritative
|
|
19
|
+
//! evidence that the session has no panes.
|
|
20
|
+
//! * U1-C — The delivery peek SITE at `delivery.rs:960` read `CaptureRange::Full`,
|
|
21
|
+
//! so the WHOLE scrollback (including answered-trust residue that scrolled off
|
|
22
|
+
//! screens ago) was classified — keeping a `Do you trust …` + `› 1.` glyph tail
|
|
23
|
+
//! alive forever and parking idle codex panes in `queued_until_trust`. Wave-2
|
|
24
|
+
//! narrows the peek to `Tail(80)`: a LIVE trust modal Codex pins to the visible
|
|
25
|
+
//! region (still answered), but residual scrolled-off modals no longer match.
|
|
26
|
+
//! **Recognizer recency** (the codex actionable-shape early-return at
|
|
27
|
+
//! `startup_prompt.rs:86`) is NOT changed in wave-2 — the rfind-position recency
|
|
28
|
+
//! model claude/copilot use is invalidated on Codex by CR-063 pre-render
|
|
29
|
+
//! (Update box + banner + `› Find…` input prompt pre-render BELOW a LIVE trust
|
|
30
|
+
//! modal — real-machine fixture `SUBROOT_FULL_TRUST_PANE_AFTER_QUICKSTART`
|
|
31
|
+
//! proves it). The narrower "scrollback residue idle-pane" problem needs a
|
|
32
|
+
//! Codex-specific shape (not rfind), tracked as U1-C-1 follow-up.
|
|
33
|
+
|
|
34
|
+
use super::*;
|
|
35
|
+
use crate::messaging::deliver_pending_message;
|
|
36
|
+
use crate::transport::test_support::OfflineTransport;
|
|
37
|
+
use crate::transport::{PaneLiveness, PaneId, SessionName, WindowName};
|
|
38
|
+
|
|
39
|
+
fn pane_info_w(pane_id: &str, session: &str, window: &str) -> PaneInfo {
|
|
40
|
+
PaneInfo {
|
|
41
|
+
pane_id: PaneId::new(pane_id),
|
|
42
|
+
session: SessionName::new(session),
|
|
43
|
+
window_index: None,
|
|
44
|
+
window_name: Some(WindowName::new(window)),
|
|
45
|
+
pane_index: None,
|
|
46
|
+
tty: None,
|
|
47
|
+
current_command: None,
|
|
48
|
+
current_path: None,
|
|
49
|
+
active: true,
|
|
50
|
+
pane_pid: None,
|
|
51
|
+
leader_env: BTreeMap::new(),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
56
|
+
// U1-A — LEADER pane_id drift fallback chain
|
|
57
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
58
|
+
|
|
59
|
+
/// **Scenario**: state binds leader to `%old`, but tmux replaced it with `%new`
|
|
60
|
+
/// in the same `leader-session`/`leader` window (pane drift — leader is ALIVE,
|
|
61
|
+
/// just at a new id; this happens on tmux respawn-pane, `kill-pane` of an
|
|
62
|
+
/// inner shell, panel resize, etc.).
|
|
63
|
+
///
|
|
64
|
+
/// **Pre-fix behaviour**: `leader_receiver_pane_is_usable` returns false (old
|
|
65
|
+
/// pane gone from list, liveness Dead) → mark failed leader_not_attached.
|
|
66
|
+
/// We never even reach `resolve_inject_target`.
|
|
67
|
+
///
|
|
68
|
+
/// **Post-fix expectation**: usable widens to "leader session+window exists";
|
|
69
|
+
/// resolver falls back to the new live pane; delivery either delivers or fails
|
|
70
|
+
/// for an unrelated reason — but NOT `leader_not_attached`.
|
|
71
|
+
#[test]
|
|
72
|
+
#[ignore = "U1-A drift fallback excised for 0.3.24 (macmini RED v2, res_e4b40473d36f). \
|
|
73
|
+
Mock contract preserved verbatim for v0.3.25 fix-forward — the real-machine \
|
|
74
|
+
fix requires writer-shape + projection + rediscover-writer triad, NOT the \
|
|
75
|
+
wave-2 single-fn fallback. See .team/artifacts/u1-a-realmachine-v2-fix-or-excise.md."]
|
|
76
|
+
fn u1_a_leader_pane_drift_falls_back_to_live_session_window_not_failed() {
|
|
77
|
+
let ws = tmp_ws("u1a-drift");
|
|
78
|
+
let store = store_for(&ws);
|
|
79
|
+
let log = EventLog::new(&ws);
|
|
80
|
+
let state = serde_json::json!({
|
|
81
|
+
"session_name": "leader-session",
|
|
82
|
+
"leader_receiver": {
|
|
83
|
+
"pane_id": "%old",
|
|
84
|
+
"session_name": "leader-session",
|
|
85
|
+
"window_name": "leader",
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
89
|
+
let message_id = store
|
|
90
|
+
.create_message(None, "coordinator", "leader", "hi", None, false, None)
|
|
91
|
+
.unwrap();
|
|
92
|
+
|
|
93
|
+
// Drift: %old is GONE; %new took its place under the same session/window.
|
|
94
|
+
let transport = OfflineTransport::new()
|
|
95
|
+
.with_targets(vec![pane_info_w("%new", "leader-session", "leader")])
|
|
96
|
+
.with_session_present(true)
|
|
97
|
+
.with_liveness("%old", PaneLiveness::Dead)
|
|
98
|
+
.with_liveness("%new", PaneLiveness::Live);
|
|
99
|
+
|
|
100
|
+
let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
|
|
101
|
+
.unwrap();
|
|
102
|
+
|
|
103
|
+
assert!(
|
|
104
|
+
out.reason != Some(DeliveryRefusal::LeaderNotAttached),
|
|
105
|
+
"U1-A: pane DRIFT (not leader death) must not terminal-fail as \
|
|
106
|
+
leader_not_attached; got reason={:?} status={:?}",
|
|
107
|
+
out.reason,
|
|
108
|
+
out.message_status.0
|
|
109
|
+
);
|
|
110
|
+
assert_ne!(
|
|
111
|
+
out.message_status.0,
|
|
112
|
+
"failed",
|
|
113
|
+
"U1-A: drift must not be terminal; got status={}",
|
|
114
|
+
out.message_status.0
|
|
115
|
+
);
|
|
116
|
+
let inject_targets = transport.inject_targets();
|
|
117
|
+
assert!(
|
|
118
|
+
inject_targets.iter().any(|t| matches!(t, Target::Pane(p) if p.as_str() == "%new")),
|
|
119
|
+
"U1-A: fallback chain must inject into the LIVE pane (%new), not the stale \
|
|
120
|
+
%old; injected targets={inject_targets:?}"
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// **Scenario**: state has no `leader_receiver.pane_id` AND no leader session
|
|
125
|
+
/// metadata. Leader is genuinely not attached.
|
|
126
|
+
///
|
|
127
|
+
/// **Expectation (unchanged by wave-2)**: still `leader_not_attached`. The
|
|
128
|
+
/// fallback chain MUST NOT swallow a real leader-not-attached into a synthetic
|
|
129
|
+
/// `SessionWindow{window:"leader"}` injection. Same invariant as `resolve_inject_target`
|
|
130
|
+
/// doc: "must not fall through to a synthetic SessionWindow{window=leader}".
|
|
131
|
+
#[test]
|
|
132
|
+
fn u1_a_leader_truly_not_attached_still_leader_not_attached_not_synthetic_target() {
|
|
133
|
+
let ws = tmp_ws("u1a-truly-dead");
|
|
134
|
+
let store = store_for(&ws);
|
|
135
|
+
let log = EventLog::new(&ws);
|
|
136
|
+
let state = serde_json::json!({
|
|
137
|
+
// no session_name, no leader_receiver pane/window — truly unattached
|
|
138
|
+
"leader_receiver": {},
|
|
139
|
+
});
|
|
140
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
141
|
+
let message_id = store
|
|
142
|
+
.create_message(None, "coordinator", "leader", "hi", None, false, None)
|
|
143
|
+
.unwrap();
|
|
144
|
+
|
|
145
|
+
// Transport returns NO panes at all (no leader window anywhere).
|
|
146
|
+
let transport = OfflineTransport::new()
|
|
147
|
+
.with_targets(vec![])
|
|
148
|
+
.with_session_present(false);
|
|
149
|
+
|
|
150
|
+
let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
|
|
151
|
+
.unwrap();
|
|
152
|
+
|
|
153
|
+
assert_eq!(
|
|
154
|
+
out.reason,
|
|
155
|
+
Some(DeliveryRefusal::LeaderNotAttached),
|
|
156
|
+
"U1-A: truly unattached leader must still terminal-fail as \
|
|
157
|
+
leader_not_attached (regression guard)"
|
|
158
|
+
);
|
|
159
|
+
assert!(
|
|
160
|
+
transport.inject_targets().is_empty(),
|
|
161
|
+
"U1-A: truly unattached leader must NOT attempt a synthetic inject; \
|
|
162
|
+
got {:?}",
|
|
163
|
+
transport.inject_targets()
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
168
|
+
// U1-B — list_targets Err is server jitter, NOT empty
|
|
169
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
170
|
+
|
|
171
|
+
/// **Scenario**: a WORKER recipient has `pane_id=%cached` (last-known) in state.
|
|
172
|
+
/// On this tick, tmux server jitters and `list_targets()` returns Err. The
|
|
173
|
+
/// cached pane has NOT been validated this tick.
|
|
174
|
+
///
|
|
175
|
+
/// **Pre-fix behaviour**: `list_targets().unwrap_or_default()` → empty vec →
|
|
176
|
+
/// resolver falls through to `if live_targets.is_empty() && !cached_pane_known_dead`
|
|
177
|
+
/// → REUSES `%cached` blindly → inject into an unverified (possibly dead) pane.
|
|
178
|
+
///
|
|
179
|
+
/// **Post-fix expectation**: Err is observed as such → delivery DEFERs (status
|
|
180
|
+
/// stays at `target_resolved`, NOT `delivered`, NOT `failed`), with a
|
|
181
|
+
/// jitter-named verification reason, so the next tick reattempts on a fresh
|
|
182
|
+
/// `list_targets`.
|
|
183
|
+
#[test]
|
|
184
|
+
fn u1_b_list_targets_err_defers_does_not_reuse_unvalidated_cached_pane() {
|
|
185
|
+
let ws = tmp_ws("u1b-jitter");
|
|
186
|
+
let store = store_for(&ws);
|
|
187
|
+
let log = EventLog::new(&ws);
|
|
188
|
+
let state = serde_json::json!({
|
|
189
|
+
"session_name": "team-jitter",
|
|
190
|
+
"agents": {
|
|
191
|
+
"w1": {
|
|
192
|
+
"provider": "fake",
|
|
193
|
+
"pane_id": "%cached",
|
|
194
|
+
"window": "w1",
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
199
|
+
let message_id = store
|
|
200
|
+
.create_message(None, "leader", "w1", "ping", None, false, None)
|
|
201
|
+
.unwrap();
|
|
202
|
+
|
|
203
|
+
let transport = OfflineTransport::new()
|
|
204
|
+
.with_list_targets_error("tmux server connection refused (jitter)")
|
|
205
|
+
.with_session_present(true)
|
|
206
|
+
.with_default_liveness(PaneLiveness::Live); // cached looks alive in isolation
|
|
207
|
+
|
|
208
|
+
let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
|
|
209
|
+
.unwrap();
|
|
210
|
+
|
|
211
|
+
assert!(
|
|
212
|
+
!out.ok,
|
|
213
|
+
"U1-B: jitter must not declare delivered (false-green guard)"
|
|
214
|
+
);
|
|
215
|
+
assert_ne!(
|
|
216
|
+
out.message_status.0,
|
|
217
|
+
"delivered",
|
|
218
|
+
"U1-B: jitter must not mark delivered; got status={}",
|
|
219
|
+
out.message_status.0
|
|
220
|
+
);
|
|
221
|
+
assert_ne!(
|
|
222
|
+
out.message_status.0,
|
|
223
|
+
"failed",
|
|
224
|
+
"U1-B: jitter must not terminal-fail this tick (defer); got status={}",
|
|
225
|
+
out.message_status.0
|
|
226
|
+
);
|
|
227
|
+
assert!(
|
|
228
|
+
transport.inject_targets().is_empty(),
|
|
229
|
+
"U1-B: jitter must NOT inject into the unvalidated cached_pane; \
|
|
230
|
+
injected={:?}",
|
|
231
|
+
transport.inject_targets()
|
|
232
|
+
);
|
|
233
|
+
let events = log.tail(0).unwrap();
|
|
234
|
+
assert!(
|
|
235
|
+
events.iter().any(|event| {
|
|
236
|
+
let reason = event.get("reason").and_then(serde_json::Value::as_str).unwrap_or("");
|
|
237
|
+
let verif = event.get("verification").and_then(serde_json::Value::as_str).unwrap_or("");
|
|
238
|
+
reason.contains("list_targets")
|
|
239
|
+
|| verif.contains("list_targets")
|
|
240
|
+
|| reason.contains("server_jitter")
|
|
241
|
+
|| verif.contains("server_jitter")
|
|
242
|
+
}),
|
|
243
|
+
"U1-B: defer must carry a named reason (list_targets / server_jitter), \
|
|
244
|
+
not a silent skip; got events={events:?}"
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/// **Scenario**: Ok(empty vec) — list_targets succeeded but the session is
|
|
249
|
+
/// genuinely empty (no panes at all). This is NOT jitter, it's authoritative
|
|
250
|
+
/// evidence the session has nothing.
|
|
251
|
+
///
|
|
252
|
+
/// **Expectation (unchanged)**: existing fallthrough behaviour. The point is
|
|
253
|
+
/// that wave-2 must DISTINGUISH Err from Ok(empty); this guard test asserts the
|
|
254
|
+
/// Ok(empty) branch still flows to the pre-existing logic.
|
|
255
|
+
#[test]
|
|
256
|
+
fn u1_b_list_targets_ok_empty_is_authoritative_not_deferred() {
|
|
257
|
+
let ws = tmp_ws("u1b-emptyok");
|
|
258
|
+
let store = store_for(&ws);
|
|
259
|
+
let log = EventLog::new(&ws);
|
|
260
|
+
let state = serde_json::json!({
|
|
261
|
+
"session_name": "team-emptyok",
|
|
262
|
+
"agents": {
|
|
263
|
+
"w1": {
|
|
264
|
+
"provider": "fake",
|
|
265
|
+
"pane_id": "%cached",
|
|
266
|
+
"window": "w1",
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
271
|
+
let message_id = store
|
|
272
|
+
.create_message(None, "leader", "w1", "ping", None, false, None)
|
|
273
|
+
.unwrap();
|
|
274
|
+
|
|
275
|
+
// Ok(empty) + cached pane explicitly NOT known dead (has_pane=None, liveness=Unknown)
|
|
276
|
+
// → resolver should reuse cached_pane (pre-existing path). The point of this guard:
|
|
277
|
+
// wave-2 must NOT degrade the Ok(empty) branch into a defer.
|
|
278
|
+
let transport = OfflineTransport::new()
|
|
279
|
+
.with_targets(vec![])
|
|
280
|
+
.with_session_present(true)
|
|
281
|
+
.with_default_liveness(PaneLiveness::Unknown);
|
|
282
|
+
|
|
283
|
+
let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
|
|
284
|
+
.unwrap();
|
|
285
|
+
|
|
286
|
+
// We don't assert ok=true (the fake provider may or may not roundtrip);
|
|
287
|
+
// we assert the pre-existing inject ATTEMPT happened (i.e. NOT deferred
|
|
288
|
+
// silently), proving wave-2 did not over-tighten.
|
|
289
|
+
assert!(
|
|
290
|
+
!transport.inject_targets().is_empty(),
|
|
291
|
+
"U1-B regression guard: Ok(empty) is authoritative — resolver must \
|
|
292
|
+
still attempt the existing fallback (cached_pane reuse); got no \
|
|
293
|
+
injections (status={}, reason={:?})",
|
|
294
|
+
out.message_status.0,
|
|
295
|
+
out.reason
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
300
|
+
// U1-C — delivery peek site narrows Full → Tail(80)
|
|
301
|
+
//
|
|
302
|
+
// NOTE on scope vs the wave-2 brief: the brief also called for an rfind-recency
|
|
303
|
+
// guard inside `has_actionable_trust_shape` mirroring claude/copilot. The
|
|
304
|
+
// real-machine fixture `SUBROOT_FULL_TRUST_PANE_AFTER_QUICKSTART` (CR-063 pre-
|
|
305
|
+
// render: Update box + banner + `› Find…` input prompt all rendered BELOW a
|
|
306
|
+
// LIVE trust modal in the SAME capture) proves rfind-position recency does NOT
|
|
307
|
+
// transfer to codex — banner-position > trust-position is a routine pre-render
|
|
308
|
+
// artefact, not a "trust is stale" signal. That sub-item is deferred to a
|
|
309
|
+
// follow-up (U1-C-1) that needs a codex-specific shape, not rfind. Wave-2
|
|
310
|
+
// keeps the narrower, defensible fix: delivery's pre-inject peek uses Tail(80)
|
|
311
|
+
// instead of Full, so scrolled-off answered-trust residue can no longer trick
|
|
312
|
+
// the gate into parking idle codex panes forever. A LIVE trust modal is pinned
|
|
313
|
+
// by codex to the visible region, so it still gets answered.
|
|
314
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
315
|
+
|
|
316
|
+
/// **Pre-fix behaviour**: `delivery.rs:960` calls `capture(target, Full)` for
|
|
317
|
+
/// the startup-prompt peek site. Full reads the entire pane scrollback (up to
|
|
318
|
+
/// the configured limit), which on an idle codex worker can carry the
|
|
319
|
+
/// already-answered trust modal text long after dismissal.
|
|
320
|
+
///
|
|
321
|
+
/// **Post-fix expectation**: the peek site uses `CaptureRange::Tail(80)`. The
|
|
322
|
+
/// peek is a delivery-time pre-check, NOT the startup-prompts dismissal phase
|
|
323
|
+
/// (that path needs Full to anchor recency on its own terms). We verify the
|
|
324
|
+
/// `CaptureRange` the gate passed to the transport.
|
|
325
|
+
#[test]
|
|
326
|
+
fn u1_c_delivery_peek_uses_tail_eighty_not_full() {
|
|
327
|
+
use crate::transport::CaptureRange;
|
|
328
|
+
|
|
329
|
+
let ws = tmp_ws("u1c-tail-range");
|
|
330
|
+
let store = store_for(&ws);
|
|
331
|
+
let log = EventLog::new(&ws);
|
|
332
|
+
let state = serde_json::json!({
|
|
333
|
+
"session_name": "team-tail-range",
|
|
334
|
+
"agents": {
|
|
335
|
+
"w1": {
|
|
336
|
+
"provider": "codex",
|
|
337
|
+
"pane_id": "%cdx",
|
|
338
|
+
"window": "w1",
|
|
339
|
+
"startup_prompts": "pending", // NOT handled — triggers peek path
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
344
|
+
let message_id = store
|
|
345
|
+
.create_message(None, "leader", "w1", "ping", None, false, None)
|
|
346
|
+
.unwrap();
|
|
347
|
+
|
|
348
|
+
// Empty capture text → classify returns KeepPolling → peek returns false → inject
|
|
349
|
+
// proceeds. We only care about WHICH range the peek site asked for.
|
|
350
|
+
let transport = OfflineTransport::new()
|
|
351
|
+
.with_targets(vec![pane_info_w("%cdx", "team-tail-range", "w1")])
|
|
352
|
+
.with_session_present(true)
|
|
353
|
+
.with_default_liveness(PaneLiveness::Live)
|
|
354
|
+
.with_capture_for_pane("%cdx", "OpenAI Codex\ncodex>\n");
|
|
355
|
+
|
|
356
|
+
let _ = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
|
|
357
|
+
.unwrap();
|
|
358
|
+
|
|
359
|
+
let ranges = transport.capture_ranges();
|
|
360
|
+
assert!(
|
|
361
|
+
!ranges.is_empty(),
|
|
362
|
+
"U1-C: delivery peek must call capture() for codex worker with non-terminal \
|
|
363
|
+
startup_prompts; got no capture calls"
|
|
364
|
+
);
|
|
365
|
+
assert!(
|
|
366
|
+
ranges.iter().any(|r| matches!(r, CaptureRange::Tail(n) if *n == 80)),
|
|
367
|
+
"U1-C: delivery peek site must request Tail(80), not Full. ranges={ranges:?}. \
|
|
368
|
+
Full was the pre-wave-2 default and caused scrolled-off answered-trust \
|
|
369
|
+
residue to be classified as actionable, parking idle codex panes forever."
|
|
370
|
+
);
|
|
371
|
+
assert!(
|
|
372
|
+
!ranges.iter().any(|r| matches!(r, CaptureRange::Full)),
|
|
373
|
+
"U1-C: delivery peek must NOT request Full (residue-trap). ranges={ranges:?}"
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/// **Regression guard**: a TRULY actionable codex trust modal — startup_prompts
|
|
378
|
+
/// pending, Tail(80) capture shows the live trust modal in the visible region.
|
|
379
|
+
///
|
|
380
|
+
/// **Expectation (unchanged)**: still parks as `queued_until_trust`. Wave-2 must
|
|
381
|
+
/// not regress the legitimate trust-defer path: a live modal in Tail(80) is
|
|
382
|
+
/// still detected by the unchanged `has_actionable_trust_shape` early-return.
|
|
383
|
+
#[test]
|
|
384
|
+
fn u1_c_delivery_real_trust_modal_still_parks_queued_until_trust() {
|
|
385
|
+
let ws = tmp_ws("u1c-realtrust");
|
|
386
|
+
let store = store_for(&ws);
|
|
387
|
+
let log = EventLog::new(&ws);
|
|
388
|
+
let state = serde_json::json!({
|
|
389
|
+
"session_name": "team-realtrust",
|
|
390
|
+
"agents": {
|
|
391
|
+
"w1": {
|
|
392
|
+
"provider": "codex",
|
|
393
|
+
"pane_id": "%cdx",
|
|
394
|
+
"window": "w1",
|
|
395
|
+
"startup_prompts": "pending",
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
400
|
+
let message_id = store
|
|
401
|
+
.create_message(None, "leader", "w1", "ping", None, false, None)
|
|
402
|
+
.unwrap();
|
|
403
|
+
|
|
404
|
+
// Live trust modal — fits well inside Tail(80).
|
|
405
|
+
let live_trust = "\
|
|
406
|
+
Loading codex...
|
|
407
|
+
Do you trust the contents of this directory?
|
|
408
|
+
› 1. Yes, continue
|
|
409
|
+
› 2. No, exit
|
|
410
|
+
";
|
|
411
|
+
|
|
412
|
+
let transport = OfflineTransport::new()
|
|
413
|
+
.with_targets(vec![pane_info_w("%cdx", "team-realtrust", "w1")])
|
|
414
|
+
.with_session_present(true)
|
|
415
|
+
.with_default_liveness(PaneLiveness::Live)
|
|
416
|
+
.with_capture_for_pane("%cdx", live_trust);
|
|
417
|
+
|
|
418
|
+
let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
|
|
419
|
+
.unwrap();
|
|
420
|
+
|
|
421
|
+
assert_eq!(
|
|
422
|
+
out.message_status.0,
|
|
423
|
+
"queued_until_trust",
|
|
424
|
+
"U1-C regression guard: a live trust modal in Tail(80) must still park as \
|
|
425
|
+
queued_until_trust; got status={} reason={:?}",
|
|
426
|
+
out.message_status.0,
|
|
427
|
+
out.reason
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
432
|
+
// U1-A wave-2 follow-on — REAL-MACHINE-FAITHFUL contracts for the projection-null
|
|
433
|
+
// regression that escaped the original wave-2 fix (macmini fixture
|
|
434
|
+
// `res_dc8f8c8e20f6`, see `.team/artifacts/u1-a-realmachine-fail-fix-spec.md`).
|
|
435
|
+
//
|
|
436
|
+
// The original U1-A test at line 72 of this file uses a SYNTHETIC state shape
|
|
437
|
+
// that pre-populates BOTH `state.session_name` AND `leader_receiver.session_name`
|
|
438
|
+
// AND omits `owner_team_id` on the message. All three are simultaneously WRONG
|
|
439
|
+
// for the real-machine isolated-team scenario, which is exactly why the wave-2
|
|
440
|
+
// fix shipped GREEN here but the bug reproduced on the device.
|
|
441
|
+
//
|
|
442
|
+
// The real-machine writer
|
|
443
|
+
// (`lifecycle/launch.rs::seed_launched_owner_from_caller_with_provider_lookup`,
|
|
444
|
+
// lines 4440-4468) builds the `leader_receiver` JSON WITHOUT `session_name`
|
|
445
|
+
// and `window_name`. Real-machine messages from worker_b → leader DO carry
|
|
446
|
+
// `owner_team_id` on the row at send time, so the delivery path at
|
|
447
|
+
// `messaging/delivery.rs:140-162` re-projects the runtime state through
|
|
448
|
+
// `project_state_for_owner_team` → `project_top_level_view`
|
|
449
|
+
// (`state/projection.rs:301-306`). The projection's `setdefault(null)` contract
|
|
450
|
+
// converts a missing `state.session_name` on the team entry to a JSON null at
|
|
451
|
+
// the projected top level. From there the fallback chain at `delivery.rs:604-606`
|
|
452
|
+
// (`leader_receiver_session_name`) hits null on both the receiver AND the
|
|
453
|
+
// projected root, returns None, the `!session.is_empty()` gate at
|
|
454
|
+
// `delivery.rs:542` is skipped, and the U1-A drift fallback in
|
|
455
|
+
// `leader_receiver_pane_is_usable` ALSO short-circuits with empty session —
|
|
456
|
+
// back to `LeaderNotAttached` on a live %new.
|
|
457
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
458
|
+
|
|
459
|
+
/// **Real-machine scenario** (macmini fixture `res_dc8f8c8e20f6`):
|
|
460
|
+
/// * Isolated team `t1` launched via quick-start; tmux session = `team-iso`.
|
|
461
|
+
/// * `leader_receiver` written by the isolated-team seed path carries
|
|
462
|
+
/// `pane_id` / `mode` / `status` / `discovery` / `tmux_socket` but
|
|
463
|
+
/// **NO** `session_name` and **NO** `window_name` (writer never sets them
|
|
464
|
+
/// — see `lifecycle/launch.rs:4440-4468`).
|
|
465
|
+
/// * worker_b → leader message carries `owner_team_id = "t1"` on the SQLite
|
|
466
|
+
/// row (real-machine sender always populates this for isolated teams).
|
|
467
|
+
/// * On delivery tick, `tmux respawn-pane -k -t %old` had already drifted
|
|
468
|
+
/// the leader pane: `%old` is Dead, `%new` lives at session `team-iso`,
|
|
469
|
+
/// window `leader`.
|
|
470
|
+
///
|
|
471
|
+
/// **Pre-fix behaviour (the bug that shipped in dd85ba7)**:
|
|
472
|
+
/// delivery.rs:140 projects state via `project_top_level_view` → projection
|
|
473
|
+
/// inserts `session_name: null` at projected top level
|
|
474
|
+
/// (`state/projection.rs:301-302` `unwrap_or(Value::Null)` branch).
|
|
475
|
+
/// Receiver lacks session_name → fallback at `delivery.rs:606` reads
|
|
476
|
+
/// projected `state.session_name` → null → `.as_str()` → None.
|
|
477
|
+
/// `leader_receiver_pane_is_usable` sees session=`""` → skips drift probe →
|
|
478
|
+
/// returns false → message terminal-fails `LeaderNotAttached`, even though
|
|
479
|
+
/// %new is alive at the right session+window.
|
|
480
|
+
///
|
|
481
|
+
/// **Post-fix expectation**: projection.rs:300-306 falls back to the ROOT
|
|
482
|
+
/// `state.session_name` before inserting null. The drift probe then succeeds
|
|
483
|
+
/// and %new is selected as inject target.
|
|
484
|
+
///
|
|
485
|
+
/// **0.3.24 status**: IGNORED. The wave-2 single-fn fallback (whose mock this
|
|
486
|
+
/// asserted) was excised after macmini RED v2 (res_e4b40473d36f) proved that
|
|
487
|
+
/// projection+drift-fallback alone do not close the real-machine gap. The
|
|
488
|
+
/// v0.3.25 fix-forward will re-enable this contract with a real-tmux macmini
|
|
489
|
+
/// counterpart — see `.team/artifacts/u1-a-realmachine-v2-fix-or-excise.md`.
|
|
490
|
+
#[test]
|
|
491
|
+
#[ignore = "U1-A deferred to v0.3.25 — wave-2 single-fn fallback excised for 0.3.24 ship"]
|
|
492
|
+
fn u1_a_projected_team_state_with_null_session_still_finds_drifted_leader_pane() {
|
|
493
|
+
let ws = tmp_ws("u1a-proj-null-session");
|
|
494
|
+
let store = store_for(&ws);
|
|
495
|
+
let log = EventLog::new(&ws);
|
|
496
|
+
|
|
497
|
+
// Real-machine state shape: nested `teams.t1` entry, receiver as the writer
|
|
498
|
+
// at lifecycle/launch.rs:4440-4468 produces it (NO session_name, NO window_name).
|
|
499
|
+
// Top-level `session_name` is the canonical isolated-team session; the team
|
|
500
|
+
// entry does NOT carry it (matches real-machine `state.json` snapshots).
|
|
501
|
+
let state = serde_json::json!({
|
|
502
|
+
"active_team_key": "t1",
|
|
503
|
+
"session_name": "team-iso",
|
|
504
|
+
"teams": {
|
|
505
|
+
"t1": {
|
|
506
|
+
// NOTE: no session_name on the team entry — this is exactly the
|
|
507
|
+
// projection-trigger condition. Real machine relies on the root
|
|
508
|
+
// session_name; the projection setdefault(null) destroys it.
|
|
509
|
+
"leader_receiver": {
|
|
510
|
+
"mode": "direct_tmux",
|
|
511
|
+
"status": "attached",
|
|
512
|
+
"pane_id": "%old",
|
|
513
|
+
"pane": "%old",
|
|
514
|
+
"leader_session_uuid": "00000000-0000-0000-0000-000000000001",
|
|
515
|
+
"owner_epoch": 1,
|
|
516
|
+
"discovery": "quick_start",
|
|
517
|
+
// NOTE: no tmux_socket (the isolated-team seed path at
|
|
518
|
+
// lifecycle/launch.rs:4440-4468 marks it OPTIONAL; setting a
|
|
519
|
+
// foreign socket here would route us through
|
|
520
|
+
// `delivery_transport_for_recipient`'s real-tmux endpoint
|
|
521
|
+
// backend, which is correct production behaviour but bypasses
|
|
522
|
+
// OfflineTransport and is asserted on by the real-machine
|
|
523
|
+
// gated stub at the end of this section).
|
|
524
|
+
// NOTE: no session_name. No window_name. Faithful to
|
|
525
|
+
// seed_launched_owner_from_caller_with_provider_lookup
|
|
526
|
+
// (lifecycle/launch.rs:4440-4468) which never writes them.
|
|
527
|
+
},
|
|
528
|
+
"team_owner": {
|
|
529
|
+
"pane_id": "%old",
|
|
530
|
+
"leader_session_uuid": "00000000-0000-0000-0000-000000000001",
|
|
531
|
+
"owner_epoch": 1,
|
|
532
|
+
"claimed_via": "quick-start",
|
|
533
|
+
},
|
|
534
|
+
"owner_epoch": 1,
|
|
535
|
+
"agents": {},
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
540
|
+
|
|
541
|
+
// Critical faithfulness knob: owner_team_id IS set on the row, exactly as
|
|
542
|
+
// the real-machine sender (worker_b → leader on isolated team) writes it.
|
|
543
|
+
// This is what triggers the projection path at delivery.rs:140-162 that
|
|
544
|
+
// the pre-existing u1_a test above silently skipped by passing None.
|
|
545
|
+
let message_id = store
|
|
546
|
+
.create_message(None, "worker_b", "leader", "hi", None, false, Some("t1"))
|
|
547
|
+
.unwrap();
|
|
548
|
+
|
|
549
|
+
// Drift: %old is Dead; %new took its place under session "team-iso", window "leader".
|
|
550
|
+
let transport = OfflineTransport::new()
|
|
551
|
+
.with_targets(vec![pane_info_w("%new", "team-iso", "leader")])
|
|
552
|
+
.with_session_present(true)
|
|
553
|
+
.with_liveness("%old", PaneLiveness::Dead)
|
|
554
|
+
.with_liveness("%new", PaneLiveness::Live);
|
|
555
|
+
|
|
556
|
+
let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
|
|
557
|
+
.unwrap();
|
|
558
|
+
|
|
559
|
+
assert!(
|
|
560
|
+
out.reason != Some(DeliveryRefusal::LeaderNotAttached),
|
|
561
|
+
"U1-A projection-null escape (real-machine fixture res_dc8f8c8e20f6): \
|
|
562
|
+
worker_b → leader on isolated team with owner_team_id projects state \
|
|
563
|
+
through project_top_level_view, which setdefault(null)s session_name \
|
|
564
|
+
when the team entry lacks it; the U1-A fallback chain then sees empty \
|
|
565
|
+
session and terminal-fails. Got reason={:?} status={:?} — must NOT be \
|
|
566
|
+
LeaderNotAttached when %new is live at session+window.",
|
|
567
|
+
out.reason,
|
|
568
|
+
out.message_status.0
|
|
569
|
+
);
|
|
570
|
+
assert_ne!(
|
|
571
|
+
out.message_status.0,
|
|
572
|
+
"failed",
|
|
573
|
+
"U1-A projection-null escape: drift via projected state must not be \
|
|
574
|
+
terminal; got status={}",
|
|
575
|
+
out.message_status.0
|
|
576
|
+
);
|
|
577
|
+
let inject_targets = transport.inject_targets();
|
|
578
|
+
assert!(
|
|
579
|
+
inject_targets.iter().any(|t| matches!(t, Target::Pane(p) if p.as_str() == "%new")),
|
|
580
|
+
"U1-A projection-null escape: fallback chain must inject into the LIVE \
|
|
581
|
+
pane (%new) reachable via session+window probe; injected={inject_targets:?}"
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/// **Projection-primitive contract**: direct test on
|
|
586
|
+
/// `state::projection::project_top_level_view` that exposes the
|
|
587
|
+
/// `unwrap_or(Value::Null)` bug at `state/projection.rs:301-306` in isolation.
|
|
588
|
+
///
|
|
589
|
+
/// **Pre-fix behaviour**: when the team entry lacks `session_name`, the
|
|
590
|
+
/// projection inserts JSON null at the projected top level, regardless of
|
|
591
|
+
/// whether the ROOT state has a valid `session_name`. Every downstream consumer
|
|
592
|
+
/// uses `.and_then(Value::as_str)` which treats null and missing identically,
|
|
593
|
+
/// so the bug is silent at projection time and only manifests at delivery time.
|
|
594
|
+
///
|
|
595
|
+
/// **Post-fix expectation**: when the team entry lacks `session_name`, fall
|
|
596
|
+
/// back to the ROOT `state.session_name` before inserting null. This is the
|
|
597
|
+
/// minimal-blast-radius fix — when the team entry HAS session_name the
|
|
598
|
+
/// behaviour is bit-identical (entry branch wins), and when both lack it the
|
|
599
|
+
/// projected value is still null (unchanged → `project_top_level_view_golden`
|
|
600
|
+
/// + `project_top_level_view_does_not_fall_to_toplevel_owner_when_teams_exist`
|
|
601
|
+
/// in `state/projection.rs` stay green).
|
|
602
|
+
///
|
|
603
|
+
/// **0.3.24 status**: IGNORED. The projection or_else fallback was reverted
|
|
604
|
+
/// along with the wave-2 U1-A drift fallback because it had no other consumer.
|
|
605
|
+
/// v0.3.25 will re-introduce it as part of the writer-shape + projection +
|
|
606
|
+
/// rediscover-writer triad.
|
|
607
|
+
#[test]
|
|
608
|
+
#[ignore = "U1-A deferred to v0.3.25 — projection or_else fallback reverted with the wave-2 excision"]
|
|
609
|
+
fn u1_a_project_top_level_view_falls_back_to_root_session_name_when_entry_lacks_it() {
|
|
610
|
+
let state = serde_json::json!({
|
|
611
|
+
"session_name": "root-sess",
|
|
612
|
+
"teams": {
|
|
613
|
+
"t1": {
|
|
614
|
+
"agents": {},
|
|
615
|
+
// NO session_name on the entry — projection must fall back to root.
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
let projected = crate::state::projection::project_top_level_view(&state, "t1");
|
|
621
|
+
|
|
622
|
+
let session = projected.get("session_name");
|
|
623
|
+
assert!(
|
|
624
|
+
session.is_some(),
|
|
625
|
+
"projection must produce a session_name key (matches Python setdefault \
|
|
626
|
+
contract); got {projected:?}"
|
|
627
|
+
);
|
|
628
|
+
assert_eq!(
|
|
629
|
+
session.and_then(serde_json::Value::as_str),
|
|
630
|
+
Some("root-sess"),
|
|
631
|
+
"U1-A projection contract: when team entry lacks session_name, the \
|
|
632
|
+
projection MUST fall back to ROOT state.session_name; instead it \
|
|
633
|
+
inserted {:?}. This is the root cause of the U1-A real-machine FAIL — \
|
|
634
|
+
every delivery.rs fallback that calls .and_then(Value::as_str) treats \
|
|
635
|
+
null as missing.",
|
|
636
|
+
session
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/// **Regression guard**: when NEITHER the team entry NOR the root state has
|
|
641
|
+
/// `session_name`, projection must still produce a `session_name` key with
|
|
642
|
+
/// JSON null (Python `setdefault(null)` contract — keeps the existing
|
|
643
|
+
/// `project_top_level_view_does_not_fall_to_toplevel_owner_when_teams_exist`
|
|
644
|
+
/// behaviour intact at `state/projection.rs:706`).
|
|
645
|
+
#[test]
|
|
646
|
+
fn u1_a_project_top_level_view_inserts_null_session_name_when_neither_root_nor_entry_has_it() {
|
|
647
|
+
let state = serde_json::json!({
|
|
648
|
+
// NO root session_name.
|
|
649
|
+
"teams": {
|
|
650
|
+
"t1": {
|
|
651
|
+
"agents": {},
|
|
652
|
+
// NO entry session_name.
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
let projected = crate::state::projection::project_top_level_view(&state, "t1");
|
|
657
|
+
assert!(
|
|
658
|
+
projected.get("session_name").is_some(),
|
|
659
|
+
"regression guard: projection must always insert session_name key \
|
|
660
|
+
(Python setdefault contract); got {projected:?}"
|
|
661
|
+
);
|
|
662
|
+
assert_eq!(
|
|
663
|
+
projected.get("session_name"),
|
|
664
|
+
Some(&serde_json::Value::Null),
|
|
665
|
+
"regression guard: when neither root nor entry has session_name, the \
|
|
666
|
+
projected value is JSON null (unchanged from main HEAD behaviour); \
|
|
667
|
+
got {:?}",
|
|
668
|
+
projected.get("session_name")
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Real-machine integration stub (#[ignore]) — macmini scenario res_dc8f8c8e20f6.
|
|
673
|
+
// The deterministic source of truth is the three OfflineTransport contracts above.
|
|
674
|
+
// This stub claims the test name so the macmini harness can link it.
|
|
675
|
+
#[test]
|
|
676
|
+
#[ignore = "real-machine: macmini fixture res_dc8f8c8e20f6 — requires real tmux + isolated team launch + respawn-pane drift"]
|
|
677
|
+
fn u1_a_macmini_res_dc8f8c8e20f6_isolated_team_respawn_pane_drift_delivers() {
|
|
678
|
+
if std::env::var("TEAMAGENT_MACMINI_E2E").as_deref() != Ok("1") {
|
|
679
|
+
eprintln!("skipping: TEAMAGENT_MACMINI_E2E != 1");
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
panic!(
|
|
683
|
+
"stub: implement against tools/macmini-ssh/run_macmini_gap32_u1a_drift_e2e.sh \
|
|
684
|
+
(follow-up). The OfflineTransport unit contracts above are the deterministic \
|
|
685
|
+
source of truth; a real-machine replay was observed manually on the macmini \
|
|
686
|
+
host (worker_b → leader, %old dead, %new alive, projection nulls \
|
|
687
|
+
session_name, terminal-fails leader_not_attached pre-fix)."
|
|
688
|
+
);
|
|
689
|
+
}
|