@team-agent/installer 0.4.11 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +1 -0
- package/crates/team-agent/src/cli/diagnose.rs +2 -15
- package/crates/team-agent/src/cli/emit.rs +98 -5
- package/crates/team-agent/src/cli/mod.rs +2 -0
- package/crates/team-agent/src/cli/named_address.rs +864 -0
- package/crates/team-agent/src/cli/send.rs +104 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +455 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +4 -0
- package/crates/team-agent/src/compiler.rs +11 -20
- package/crates/team-agent/src/coordinator/orphan.rs +2 -2
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
- package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
- package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
- package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
- package/crates/team-agent/src/coordinator/tick.rs +31 -932
- package/crates/team-agent/src/diagnose/orphans.rs +66 -8
- package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
- package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
- package/crates/team-agent/src/leader/rediscover.rs +1 -11
- package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
- package/crates/team-agent/src/lifecycle/launch.rs +135 -58
- package/crates/team-agent/src/lifecycle/lock.rs +302 -0
- package/crates/team-agent/src/lifecycle/mod.rs +1 -0
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
- package/crates/team-agent/src/lifecycle/restart/agent.rs +169 -113
- package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
- package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
- package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +6 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +35 -5
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +675 -0
- package/crates/team-agent/src/lifecycle/tests.rs +3 -0
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
- package/crates/team-agent/src/mcp_server/tools.rs +157 -68
- package/crates/team-agent/src/messaging/activity.rs +43 -18
- package/crates/team-agent/src/messaging/delivery.rs +161 -97
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
- package/crates/team-agent/src/messaging/results.rs +34 -9
- package/crates/team-agent/src/messaging/scheduler.rs +52 -9
- package/crates/team-agent/src/model/spec.rs +10 -6
- package/crates/team-agent/src/provider/adapter.rs +10 -1038
- package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
- package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
- package/crates/team-agent/src/provider/classify.rs +23 -48
- package/crates/team-agent/src/provider/command.rs +16 -7
- package/crates/team-agent/src/provider/faults.rs +93 -53
- package/crates/team-agent/src/provider/session/capture.rs +4 -15
- package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
- package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
- package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
- package/crates/team-agent/src/provider/session_scan.rs +65 -27
- package/crates/team-agent/src/provider/tests/faults.rs +80 -0
- package/crates/team-agent/src/provider/types.rs +33 -1
- package/crates/team-agent/src/provider/wire.rs +171 -1
- package/crates/team-agent/src/state/identity.rs +242 -57
- package/crates/team-agent/src/state/identity_keys.rs +9 -12
- package/crates/team-agent/src/state/mod.rs +5 -1
- package/crates/team-agent/src/state/owner_gate.rs +59 -15
- package/crates/team-agent/src/state/ownership.rs +12 -11
- package/crates/team-agent/src/state/paths.rs +13 -6
- package/crates/team-agent/src/state/persist.rs +671 -128
- package/crates/team-agent/src/state/projection.rs +240 -49
- package/crates/team-agent/src/state/selector.rs +11 -5
- package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
- package/crates/team-agent/src/tmux_backend.rs +42 -5
- package/crates/team-agent/src/transport/test_support.rs +55 -6
- package/package.json +4 -4
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
use super::agent_ops::lanea_team_ws;
|
|
2
|
+
use super::lane_ops::{fork_ws, LaneTransport};
|
|
3
|
+
use super::launch_spawn::{
|
|
4
|
+
quick_start_team_dir, seed_healthy_coordinator, DELEG_ROLE_ALPHA, QS_VALID_ROLE,
|
|
5
|
+
};
|
|
6
|
+
use super::*;
|
|
7
|
+
use crate::lifecycle::lock::{
|
|
8
|
+
acquire_agent_lifecycle_lock_for_test, override_agent_lifecycle_lock_deadlines_for_test,
|
|
9
|
+
LifecycleLockRequest,
|
|
10
|
+
};
|
|
11
|
+
use crate::transport::test_support::OfflineTransport;
|
|
12
|
+
use crate::transport::{
|
|
13
|
+
AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport,
|
|
14
|
+
InjectStage, InjectVerification, Key, PaneField, PaneId, PaneInfo, PaneLiveness, SessionName,
|
|
15
|
+
SetEnvOutcome, SpawnResult, SubmitVerification, Target, Transport, TransportError,
|
|
16
|
+
TurnVerification, WindowName,
|
|
17
|
+
};
|
|
18
|
+
use serde_json::json;
|
|
19
|
+
use std::collections::BTreeSet;
|
|
20
|
+
use std::time::Duration;
|
|
21
|
+
|
|
22
|
+
#[test]
|
|
23
|
+
fn concurrent_reset_discard_session_serializes() {
|
|
24
|
+
let ids = (1..=6).map(|n| format!("w{n}")).collect::<Vec<_>>();
|
|
25
|
+
let role_docs = ids
|
|
26
|
+
.iter()
|
|
27
|
+
.map(|id| (format!("{id}.md"), role_doc(id)))
|
|
28
|
+
.collect::<Vec<_>>();
|
|
29
|
+
let role_refs = role_docs
|
|
30
|
+
.iter()
|
|
31
|
+
.map(|(file, doc)| (file.as_str(), doc.as_str()))
|
|
32
|
+
.collect::<Vec<_>>();
|
|
33
|
+
let team = team_dir_with_roles(&role_refs);
|
|
34
|
+
let workspace = team.parent().expect("team workspace").to_path_buf();
|
|
35
|
+
seed_healthy_coordinator(&workspace);
|
|
36
|
+
|
|
37
|
+
let launch_transport = codex_ready_transport();
|
|
38
|
+
let quick_start = quick_start_with_transport_in_workspace_with_display(
|
|
39
|
+
&workspace,
|
|
40
|
+
&team,
|
|
41
|
+
None,
|
|
42
|
+
true,
|
|
43
|
+
None,
|
|
44
|
+
&launch_transport,
|
|
45
|
+
false,
|
|
46
|
+
)
|
|
47
|
+
.expect("quick-start fixture");
|
|
48
|
+
assert!(
|
|
49
|
+
matches!(quick_start, QuickStartReport::Ready { .. }),
|
|
50
|
+
"fixture must launch: {quick_start:?}"
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
let reset_transport = codex_ready_transport()
|
|
54
|
+
.with_session_present(true)
|
|
55
|
+
.with_windows(ids.iter().map(|id| WindowName::new(id.as_str())).collect());
|
|
56
|
+
let outcomes = ids
|
|
57
|
+
.iter()
|
|
58
|
+
.map(|id| {
|
|
59
|
+
crate::lifecycle::reset_agent_with_transport(
|
|
60
|
+
&workspace,
|
|
61
|
+
&aid(id),
|
|
62
|
+
true,
|
|
63
|
+
false,
|
|
64
|
+
Some("teamdir"),
|
|
65
|
+
&reset_transport,
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
.collect::<Vec<_>>();
|
|
69
|
+
for (id, outcome) in ids.iter().zip(outcomes.iter()) {
|
|
70
|
+
assert!(
|
|
71
|
+
matches!(outcome, Ok(ResetAgentOutcome::Reset { .. })),
|
|
72
|
+
"reset-agent --discard-session must complete for {id}: {outcome:?}"
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let state = crate::state::projection::select_runtime_state(&workspace, Some("teamdir"))
|
|
77
|
+
.expect("team state");
|
|
78
|
+
let agents = state
|
|
79
|
+
.get("agents")
|
|
80
|
+
.and_then(serde_json::Value::as_object)
|
|
81
|
+
.expect("agents map");
|
|
82
|
+
assert_eq!(agents.len(), ids.len(), "one roster row per worker");
|
|
83
|
+
let mut windows = BTreeSet::new();
|
|
84
|
+
let mut panes = BTreeSet::new();
|
|
85
|
+
for id in &ids {
|
|
86
|
+
let agent = agents.get(id).unwrap_or_else(|| panic!("missing {id}"));
|
|
87
|
+
assert_eq!(
|
|
88
|
+
agent.get("status").and_then(|v| v.as_str()),
|
|
89
|
+
Some("running")
|
|
90
|
+
);
|
|
91
|
+
let window = agent
|
|
92
|
+
.get("window")
|
|
93
|
+
.and_then(|v| v.as_str())
|
|
94
|
+
.unwrap_or_else(|| panic!("{id} missing window"));
|
|
95
|
+
assert!(
|
|
96
|
+
windows.insert(window.to_string()),
|
|
97
|
+
"duplicate window {window}"
|
|
98
|
+
);
|
|
99
|
+
let pane = agent
|
|
100
|
+
.get("pane_id")
|
|
101
|
+
.and_then(|v| v.as_str())
|
|
102
|
+
.unwrap_or_else(|| panic!("{id} missing pane_id"));
|
|
103
|
+
assert!(panes.insert(pane.to_string()), "duplicate pane_id {pane}");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let reset_events = read_events(&workspace)
|
|
107
|
+
.into_iter()
|
|
108
|
+
.filter(|event| {
|
|
109
|
+
event.get("event").and_then(serde_json::Value::as_str) == Some("reset_agent.complete")
|
|
110
|
+
})
|
|
111
|
+
.collect::<Vec<_>>();
|
|
112
|
+
assert_eq!(
|
|
113
|
+
reset_events.len(),
|
|
114
|
+
ids.len(),
|
|
115
|
+
"each reset must emit reset_agent.complete"
|
|
116
|
+
);
|
|
117
|
+
let completed = reset_events
|
|
118
|
+
.iter()
|
|
119
|
+
.filter_map(|event| event.get("agent_id").and_then(serde_json::Value::as_str))
|
|
120
|
+
.collect::<BTreeSet<_>>();
|
|
121
|
+
let expected = ids.iter().map(String::as_str).collect::<BTreeSet<_>>();
|
|
122
|
+
assert_eq!(
|
|
123
|
+
completed, expected,
|
|
124
|
+
"complete events must cover all workers"
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#[test]
|
|
129
|
+
fn add_fork_remove_share_lifecycle_lock_behavior() {
|
|
130
|
+
let (add_team, add_workspace, add_role, add_transport) = add_fixture();
|
|
131
|
+
let add_agent = aid("w2");
|
|
132
|
+
let add_state_before =
|
|
133
|
+
crate::state::projection::select_runtime_state(&add_workspace, Some("teamdir")).unwrap();
|
|
134
|
+
let add_spec_before = std::fs::read_to_string(crate::model::paths::runtime_spec_path(
|
|
135
|
+
&add_workspace,
|
|
136
|
+
"teamdir",
|
|
137
|
+
))
|
|
138
|
+
.unwrap();
|
|
139
|
+
let held = hold_lifecycle_lock(&add_workspace, "add-agent", Some(&add_agent));
|
|
140
|
+
let override_guard = override_agent_lifecycle_lock_deadlines_for_test(
|
|
141
|
+
Duration::from_millis(120),
|
|
142
|
+
Duration::from_secs(5),
|
|
143
|
+
);
|
|
144
|
+
assert_lock_timeout(
|
|
145
|
+
crate::lifecycle::add_agent_with_transport(
|
|
146
|
+
&add_team,
|
|
147
|
+
&add_agent,
|
|
148
|
+
&add_role,
|
|
149
|
+
false,
|
|
150
|
+
None,
|
|
151
|
+
&add_transport,
|
|
152
|
+
),
|
|
153
|
+
"add-agent",
|
|
154
|
+
);
|
|
155
|
+
assert!(
|
|
156
|
+
add_transport.spawn_records().is_empty(),
|
|
157
|
+
"blocked add-agent must not spawn a window"
|
|
158
|
+
);
|
|
159
|
+
assert_eq!(
|
|
160
|
+
crate::state::projection::select_runtime_state(&add_workspace, Some("teamdir")).unwrap(),
|
|
161
|
+
add_state_before,
|
|
162
|
+
"blocked add-agent must not add a state row"
|
|
163
|
+
);
|
|
164
|
+
assert_eq!(
|
|
165
|
+
std::fs::read_to_string(crate::model::paths::runtime_spec_path(
|
|
166
|
+
&add_workspace,
|
|
167
|
+
"teamdir",
|
|
168
|
+
))
|
|
169
|
+
.unwrap(),
|
|
170
|
+
add_spec_before,
|
|
171
|
+
"blocked add-agent must not mutate spec"
|
|
172
|
+
);
|
|
173
|
+
drop(override_guard);
|
|
174
|
+
drop(held);
|
|
175
|
+
crate::lifecycle::add_agent_with_transport(
|
|
176
|
+
&add_team,
|
|
177
|
+
&add_agent,
|
|
178
|
+
&add_role,
|
|
179
|
+
false,
|
|
180
|
+
None,
|
|
181
|
+
&add_transport,
|
|
182
|
+
)
|
|
183
|
+
.expect("add-agent succeeds after lock release");
|
|
184
|
+
assert!(
|
|
185
|
+
crate::state::projection::select_runtime_state(&add_workspace, Some("teamdir"))
|
|
186
|
+
.unwrap()
|
|
187
|
+
.pointer("/agents/w2")
|
|
188
|
+
.is_some(),
|
|
189
|
+
"released add-agent must add the worker"
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
let fork_workspace = fork_ws(DELEG_ROLE_ALPHA);
|
|
193
|
+
let fork_transport = LaneTransport::new("team-laneateam", &[]);
|
|
194
|
+
let fork_spec_before = std::fs::read_to_string(fork_workspace.join("team.spec.yaml")).unwrap();
|
|
195
|
+
let fork_state_before = crate::state::persist::load_runtime_state(&fork_workspace).unwrap();
|
|
196
|
+
let fork_agent = aid("newfork");
|
|
197
|
+
let held = hold_lifecycle_lock(&fork_workspace, "fork-agent", Some(&fork_agent));
|
|
198
|
+
let override_guard = override_agent_lifecycle_lock_deadlines_for_test(
|
|
199
|
+
Duration::from_millis(120),
|
|
200
|
+
Duration::from_secs(5),
|
|
201
|
+
);
|
|
202
|
+
assert_lock_timeout(
|
|
203
|
+
crate::lifecycle::fork_agent_with_transport(
|
|
204
|
+
&fork_workspace,
|
|
205
|
+
&aid("alpha"),
|
|
206
|
+
&fork_agent,
|
|
207
|
+
None,
|
|
208
|
+
false,
|
|
209
|
+
None,
|
|
210
|
+
&fork_transport,
|
|
211
|
+
),
|
|
212
|
+
"fork-agent",
|
|
213
|
+
);
|
|
214
|
+
assert!(
|
|
215
|
+
fork_transport.spawns().is_empty(),
|
|
216
|
+
"blocked fork-agent must not spawn a window"
|
|
217
|
+
);
|
|
218
|
+
assert_eq!(
|
|
219
|
+
crate::state::persist::load_runtime_state(&fork_workspace).unwrap(),
|
|
220
|
+
fork_state_before,
|
|
221
|
+
"blocked fork-agent must not add a state row"
|
|
222
|
+
);
|
|
223
|
+
assert_eq!(
|
|
224
|
+
std::fs::read_to_string(fork_workspace.join("team.spec.yaml")).unwrap(),
|
|
225
|
+
fork_spec_before,
|
|
226
|
+
"blocked fork-agent must not mutate spec"
|
|
227
|
+
);
|
|
228
|
+
drop(override_guard);
|
|
229
|
+
drop(held);
|
|
230
|
+
crate::lifecycle::fork_agent_with_transport(
|
|
231
|
+
&fork_workspace,
|
|
232
|
+
&aid("alpha"),
|
|
233
|
+
&fork_agent,
|
|
234
|
+
None,
|
|
235
|
+
false,
|
|
236
|
+
None,
|
|
237
|
+
&fork_transport,
|
|
238
|
+
)
|
|
239
|
+
.expect("fork-agent succeeds after lock release");
|
|
240
|
+
assert!(
|
|
241
|
+
crate::state::persist::load_runtime_state(&fork_workspace)
|
|
242
|
+
.unwrap()
|
|
243
|
+
.pointer("/agents/newfork")
|
|
244
|
+
.is_some(),
|
|
245
|
+
"released fork-agent must add the fork row"
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
let remove_workspace = lanea_team_ws("stopped");
|
|
249
|
+
let remove_transport = LaneTransport::new("team-laneateam", &[]);
|
|
250
|
+
let remove_spec_before =
|
|
251
|
+
std::fs::read_to_string(remove_workspace.join("team.spec.yaml")).unwrap();
|
|
252
|
+
let remove_state_before = crate::state::persist::load_runtime_state(&remove_workspace).unwrap();
|
|
253
|
+
let remove_agent = aid("alpha");
|
|
254
|
+
let held = hold_lifecycle_lock(&remove_workspace, "remove-agent", Some(&remove_agent));
|
|
255
|
+
let override_guard = override_agent_lifecycle_lock_deadlines_for_test(
|
|
256
|
+
Duration::from_millis(120),
|
|
257
|
+
Duration::from_secs(5),
|
|
258
|
+
);
|
|
259
|
+
assert_lock_timeout(
|
|
260
|
+
crate::lifecycle::remove_agent_with_transport(
|
|
261
|
+
&remove_workspace,
|
|
262
|
+
&remove_agent,
|
|
263
|
+
true,
|
|
264
|
+
true,
|
|
265
|
+
None,
|
|
266
|
+
&remove_transport,
|
|
267
|
+
),
|
|
268
|
+
"remove-agent",
|
|
269
|
+
);
|
|
270
|
+
assert!(
|
|
271
|
+
remove_transport.killed().is_empty() && remove_transport.spawns().is_empty(),
|
|
272
|
+
"blocked remove-agent must not touch tmux"
|
|
273
|
+
);
|
|
274
|
+
assert_eq!(
|
|
275
|
+
crate::state::persist::load_runtime_state(&remove_workspace).unwrap(),
|
|
276
|
+
remove_state_before,
|
|
277
|
+
"blocked remove-agent must not remove a state row"
|
|
278
|
+
);
|
|
279
|
+
assert_eq!(
|
|
280
|
+
std::fs::read_to_string(remove_workspace.join("team.spec.yaml")).unwrap(),
|
|
281
|
+
remove_spec_before,
|
|
282
|
+
"blocked remove-agent must not mutate spec"
|
|
283
|
+
);
|
|
284
|
+
drop(override_guard);
|
|
285
|
+
drop(held);
|
|
286
|
+
assert!(
|
|
287
|
+
matches!(
|
|
288
|
+
crate::lifecycle::remove_agent_with_transport(
|
|
289
|
+
&remove_workspace,
|
|
290
|
+
&remove_agent,
|
|
291
|
+
true,
|
|
292
|
+
true,
|
|
293
|
+
None,
|
|
294
|
+
&remove_transport,
|
|
295
|
+
),
|
|
296
|
+
Ok(RemoveAgentOutcome::Removed { .. })
|
|
297
|
+
),
|
|
298
|
+
"remove-agent succeeds after lock release"
|
|
299
|
+
);
|
|
300
|
+
assert!(
|
|
301
|
+
crate::state::persist::load_runtime_state(&remove_workspace)
|
|
302
|
+
.unwrap()
|
|
303
|
+
.pointer("/agents/alpha")
|
|
304
|
+
.is_none(),
|
|
305
|
+
"released remove-agent must remove alpha"
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#[test]
|
|
310
|
+
fn breal_spawn_ownership_mismatch_fails_without_state_pollution() {
|
|
311
|
+
let (workspace, _team) = breal_workspace();
|
|
312
|
+
let transport = BRealTransport::misowned();
|
|
313
|
+
|
|
314
|
+
let result = crate::lifecycle::reset_agent_with_transport(
|
|
315
|
+
&workspace,
|
|
316
|
+
&aid("w1"),
|
|
317
|
+
true,
|
|
318
|
+
false,
|
|
319
|
+
Some("teamdir"),
|
|
320
|
+
&transport,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
assert!(
|
|
324
|
+
matches!(result, Err(LifecycleError::RequirementUnmet(_))),
|
|
325
|
+
"reset must fail closed when spawn returns another worker's live pane: {result:?}"
|
|
326
|
+
);
|
|
327
|
+
let err = result.unwrap_err().to_string();
|
|
328
|
+
assert!(
|
|
329
|
+
err.contains("spawned pane not owned") && err.contains("requested=team-phaseb:w1"),
|
|
330
|
+
"error must name requested/observed ownership; got {err}"
|
|
331
|
+
);
|
|
332
|
+
let state = crate::state::projection::select_runtime_state(&workspace, Some("teamdir"))
|
|
333
|
+
.expect("team state after failed reset");
|
|
334
|
+
let w1 = state.pointer("/agents/w1").expect("w1 state");
|
|
335
|
+
assert_ne!(
|
|
336
|
+
w1.get("pane_id").and_then(serde_json::Value::as_str),
|
|
337
|
+
Some("%5"),
|
|
338
|
+
"failed reset must not write w2's pane into w1 state"
|
|
339
|
+
);
|
|
340
|
+
let started_true = read_events(&workspace).into_iter().any(|event| {
|
|
341
|
+
event.get("event").and_then(serde_json::Value::as_str) == Some("reset_agent.complete")
|
|
342
|
+
&& event.get("agent_id").and_then(serde_json::Value::as_str) == Some("w1")
|
|
343
|
+
&& event.get("started").and_then(serde_json::Value::as_bool) == Some(true)
|
|
344
|
+
});
|
|
345
|
+
assert!(
|
|
346
|
+
!started_true,
|
|
347
|
+
"failed reset must not emit reset_agent.complete started=true"
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#[test]
|
|
352
|
+
fn breal_provider_missing_window_disappeared_fails_closed() {
|
|
353
|
+
let (workspace, _team) = breal_workspace();
|
|
354
|
+
let transport = BRealTransport::dead_after_spawn();
|
|
355
|
+
|
|
356
|
+
let result = crate::lifecycle::reset_agent_with_transport(
|
|
357
|
+
&workspace,
|
|
358
|
+
&aid("w1"),
|
|
359
|
+
true,
|
|
360
|
+
false,
|
|
361
|
+
Some("teamdir"),
|
|
362
|
+
&transport,
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
assert!(
|
|
366
|
+
matches!(result, Err(LifecycleError::RequirementUnmet(_))),
|
|
367
|
+
"reset must return Err when provider exits and requested window disappears: {result:?}"
|
|
368
|
+
);
|
|
369
|
+
let err = result.unwrap_err().to_string();
|
|
370
|
+
assert!(
|
|
371
|
+
err.contains("window disappeared") || err.contains("spawned pane not owned"),
|
|
372
|
+
"error must name window disappearance / ownership failure; got {err}"
|
|
373
|
+
);
|
|
374
|
+
let state = crate::state::projection::select_runtime_state(&workspace, Some("teamdir"))
|
|
375
|
+
.expect("team state after failed reset");
|
|
376
|
+
let w1 = state.pointer("/agents/w1").expect("w1 state");
|
|
377
|
+
assert_ne!(
|
|
378
|
+
w1.get("status").and_then(serde_json::Value::as_str),
|
|
379
|
+
Some("running"),
|
|
380
|
+
"failed reset must not mark w1 running"
|
|
381
|
+
);
|
|
382
|
+
assert_ne!(
|
|
383
|
+
w1.get("pane_id").and_then(serde_json::Value::as_str),
|
|
384
|
+
Some("%9"),
|
|
385
|
+
"failed reset must not persist disappeared pane"
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
#[test]
|
|
390
|
+
fn breal_reset_rehydrates_role_context_from_compiled_spec() {
|
|
391
|
+
let (workspace, _team) = breal_workspace();
|
|
392
|
+
strip_runtime_command_context(&workspace);
|
|
393
|
+
let transport = BRealTransport::owned();
|
|
394
|
+
|
|
395
|
+
crate::lifecycle::reset_agent_with_transport(
|
|
396
|
+
&workspace,
|
|
397
|
+
&aid("w1"),
|
|
398
|
+
true,
|
|
399
|
+
false,
|
|
400
|
+
Some("teamdir"),
|
|
401
|
+
&transport,
|
|
402
|
+
)
|
|
403
|
+
.expect("reset should use spec-rehydrated role context");
|
|
404
|
+
|
|
405
|
+
let spawns = transport.spawn_records();
|
|
406
|
+
let argv = spawns
|
|
407
|
+
.last()
|
|
408
|
+
.map(|record| record.join("\n"))
|
|
409
|
+
.expect("reset must spawn w1");
|
|
410
|
+
assert!(
|
|
411
|
+
argv.contains("role `Phase B Real Worker One`")
|
|
412
|
+
|| argv.contains("role Phase B Real Worker One"),
|
|
413
|
+
"spawn argv must carry role from compiled spec/role doc, got:\n{argv}"
|
|
414
|
+
);
|
|
415
|
+
assert!(
|
|
416
|
+
!argv.contains("role `developer`") && !argv.contains("role developer"),
|
|
417
|
+
"spawn argv must not fall back to generic developer, got:\n{argv}"
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
#[test]
|
|
422
|
+
fn breal_restart_rehydrates_role_context_from_compiled_spec() {
|
|
423
|
+
let (workspace, _team) = breal_one_worker_workspace();
|
|
424
|
+
strip_runtime_command_context(&workspace);
|
|
425
|
+
let transport = BRealTransport::owned();
|
|
426
|
+
|
|
427
|
+
crate::lifecycle::restart_with_transport(&workspace, true, Some("teamdir"), &transport)
|
|
428
|
+
.expect("restart should use spec-rehydrated role context");
|
|
429
|
+
|
|
430
|
+
let spawns = transport.spawn_records();
|
|
431
|
+
let argv = spawns
|
|
432
|
+
.last()
|
|
433
|
+
.map(|record| record.join("\n"))
|
|
434
|
+
.expect("restart must spawn w1");
|
|
435
|
+
assert!(
|
|
436
|
+
argv.contains("role `Phase B Real Worker One`")
|
|
437
|
+
|| argv.contains("role Phase B Real Worker One"),
|
|
438
|
+
"restart spawn argv must carry role from compiled spec/role doc, got:\n{argv}"
|
|
439
|
+
);
|
|
440
|
+
assert!(
|
|
441
|
+
!argv.contains("role `developer`") && !argv.contains("role developer"),
|
|
442
|
+
"restart spawn argv must not fall back to generic developer, got:\n{argv}"
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
fn team_dir_with_roles(role_docs: &[(&str, &str)]) -> PathBuf {
|
|
447
|
+
let team = temp_ws().join("teamdir");
|
|
448
|
+
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
449
|
+
std::fs::write(
|
|
450
|
+
team.join("TEAM.md"),
|
|
451
|
+
"---\nname: phaseb\nobjective: Phase B.\nprovider: codex\n---\n\nPhase B team.\n",
|
|
452
|
+
)
|
|
453
|
+
.unwrap();
|
|
454
|
+
for (file, role_doc) in role_docs {
|
|
455
|
+
std::fs::write(team.join("agents").join(file), role_doc).unwrap();
|
|
456
|
+
}
|
|
457
|
+
team
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
fn breal_workspace() -> (PathBuf, PathBuf) {
|
|
461
|
+
let w1 = "---\nname: w1\nrole: Phase B Real Worker One\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nWorker one body.\n";
|
|
462
|
+
let w2 = "---\nname: w2\nrole: Phase B Real Worker Two\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nWorker two body.\n";
|
|
463
|
+
let team = team_dir_with_roles(&[("w1.md", w1), ("w2.md", w2)]);
|
|
464
|
+
let workspace = team.parent().expect("team workspace").to_path_buf();
|
|
465
|
+
seed_healthy_coordinator(&workspace);
|
|
466
|
+
let launch_transport = codex_ready_transport();
|
|
467
|
+
quick_start_with_transport_in_workspace_with_display(
|
|
468
|
+
&workspace,
|
|
469
|
+
&team,
|
|
470
|
+
None,
|
|
471
|
+
true,
|
|
472
|
+
None,
|
|
473
|
+
&launch_transport,
|
|
474
|
+
false,
|
|
475
|
+
)
|
|
476
|
+
.expect("quick-start breal fixture");
|
|
477
|
+
(workspace, team)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
fn breal_one_worker_workspace() -> (PathBuf, PathBuf) {
|
|
481
|
+
let w1 = "---\nname: w1\nrole: Phase B Real Worker One\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nWorker one body.\n";
|
|
482
|
+
let team = team_dir_with_roles(&[("w1.md", w1)]);
|
|
483
|
+
let workspace = team.parent().expect("team workspace").to_path_buf();
|
|
484
|
+
seed_healthy_coordinator(&workspace);
|
|
485
|
+
let launch_transport = codex_ready_transport();
|
|
486
|
+
quick_start_with_transport_in_workspace_with_display(
|
|
487
|
+
&workspace,
|
|
488
|
+
&team,
|
|
489
|
+
None,
|
|
490
|
+
true,
|
|
491
|
+
None,
|
|
492
|
+
&launch_transport,
|
|
493
|
+
false,
|
|
494
|
+
)
|
|
495
|
+
.expect("quick-start breal one-worker fixture");
|
|
496
|
+
(workspace, team)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
fn strip_runtime_command_context(workspace: &std::path::Path) {
|
|
500
|
+
let mut state = crate::state::projection::select_runtime_state(workspace, Some("teamdir"))
|
|
501
|
+
.expect("team state");
|
|
502
|
+
if let Some(agent) = state
|
|
503
|
+
.pointer_mut("/agents/w1")
|
|
504
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
505
|
+
{
|
|
506
|
+
for field in [
|
|
507
|
+
"role",
|
|
508
|
+
"tools",
|
|
509
|
+
"system_prompt",
|
|
510
|
+
"output_contract",
|
|
511
|
+
"model",
|
|
512
|
+
"effort",
|
|
513
|
+
"permission_mode",
|
|
514
|
+
] {
|
|
515
|
+
agent.remove(field);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
crate::state::projection::save_team_scoped_state(workspace, &state)
|
|
519
|
+
.expect("save stripped team state");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
fn role_doc(id: &str) -> String {
|
|
523
|
+
format!(
|
|
524
|
+
"---\nname: {id}\nrole: {id} Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\n{id} worker.\n"
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
fn add_fixture() -> (PathBuf, PathBuf, PathBuf, OfflineTransport) {
|
|
529
|
+
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
530
|
+
let workspace = team.parent().expect("team workspace").to_path_buf();
|
|
531
|
+
seed_healthy_coordinator(&workspace);
|
|
532
|
+
let launch_transport = codex_ready_transport();
|
|
533
|
+
quick_start_with_transport_in_workspace_with_display(
|
|
534
|
+
&workspace,
|
|
535
|
+
&team,
|
|
536
|
+
None,
|
|
537
|
+
true,
|
|
538
|
+
None,
|
|
539
|
+
&launch_transport,
|
|
540
|
+
false,
|
|
541
|
+
)
|
|
542
|
+
.expect("quick-start add fixture");
|
|
543
|
+
let role = workspace.join("w2-role.md");
|
|
544
|
+
std::fs::write(&role, role_doc("w2")).unwrap();
|
|
545
|
+
(
|
|
546
|
+
team,
|
|
547
|
+
workspace,
|
|
548
|
+
role,
|
|
549
|
+
codex_ready_transport().with_session_present(true),
|
|
550
|
+
)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
fn codex_ready_transport() -> OfflineTransport {
|
|
554
|
+
let mut transport = OfflineTransport::new();
|
|
555
|
+
for pane in 0..32 {
|
|
556
|
+
transport = transport.with_capture_for_pane(format!("%{pane}"), "OpenAI Codex");
|
|
557
|
+
}
|
|
558
|
+
transport
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
fn hold_lifecycle_lock(
|
|
562
|
+
workspace: &std::path::Path,
|
|
563
|
+
operation: &'static str,
|
|
564
|
+
agent_id: Option<&AgentId>,
|
|
565
|
+
) -> crate::lifecycle::lock::LifecycleLockGuard {
|
|
566
|
+
acquire_agent_lifecycle_lock_for_test(
|
|
567
|
+
LifecycleLockRequest {
|
|
568
|
+
workspace,
|
|
569
|
+
operation,
|
|
570
|
+
team: None,
|
|
571
|
+
agent_id,
|
|
572
|
+
},
|
|
573
|
+
Duration::from_millis(200),
|
|
574
|
+
Duration::from_secs(5),
|
|
575
|
+
)
|
|
576
|
+
.expect("hold lifecycle lock")
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
fn assert_lock_timeout<T: std::fmt::Debug>(
|
|
580
|
+
result: Result<T, LifecycleError>,
|
|
581
|
+
expected_operation: &str,
|
|
582
|
+
) {
|
|
583
|
+
assert!(
|
|
584
|
+
matches!(
|
|
585
|
+
result,
|
|
586
|
+
Err(LifecycleError::LifecycleLockTimeout { ref operation, .. })
|
|
587
|
+
if operation == expected_operation
|
|
588
|
+
),
|
|
589
|
+
"expected {expected_operation} lifecycle lock timeout, got {result:?}"
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
fn read_events(workspace: &std::path::Path) -> Vec<serde_json::Value> {
|
|
594
|
+
let path = crate::model::paths::logs_dir(workspace).join("events.jsonl");
|
|
595
|
+
std::fs::read_to_string(path)
|
|
596
|
+
.unwrap_or_default()
|
|
597
|
+
.lines()
|
|
598
|
+
.filter_map(|line| serde_json::from_str(line).ok())
|
|
599
|
+
.collect()
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
#[derive(Clone, Copy)]
|
|
603
|
+
enum BRealMode {
|
|
604
|
+
Misowned,
|
|
605
|
+
DeadAfterSpawn,
|
|
606
|
+
Owned,
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
struct BRealTransport {
|
|
610
|
+
mode: BRealMode,
|
|
611
|
+
spawns: std::sync::Mutex<Vec<Vec<String>>>,
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
impl BRealTransport {
|
|
615
|
+
fn misowned() -> Self {
|
|
616
|
+
Self {
|
|
617
|
+
mode: BRealMode::Misowned,
|
|
618
|
+
spawns: std::sync::Mutex::new(Vec::new()),
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
fn dead_after_spawn() -> Self {
|
|
623
|
+
Self {
|
|
624
|
+
mode: BRealMode::DeadAfterSpawn,
|
|
625
|
+
spawns: std::sync::Mutex::new(Vec::new()),
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
fn owned() -> Self {
|
|
630
|
+
Self {
|
|
631
|
+
mode: BRealMode::Owned,
|
|
632
|
+
spawns: std::sync::Mutex::new(Vec::new()),
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
fn spawn_records(&self) -> Vec<Vec<String>> {
|
|
637
|
+
self.spawns.lock().unwrap().clone()
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
fn spawn_result(
|
|
641
|
+
&self,
|
|
642
|
+
session: &SessionName,
|
|
643
|
+
window: &WindowName,
|
|
644
|
+
argv: &[String],
|
|
645
|
+
) -> SpawnResult {
|
|
646
|
+
self.spawns.lock().unwrap().push(argv.to_vec());
|
|
647
|
+
let pane = match self.mode {
|
|
648
|
+
BRealMode::Misowned => "%5",
|
|
649
|
+
BRealMode::DeadAfterSpawn => "%9",
|
|
650
|
+
BRealMode::Owned => "%10",
|
|
651
|
+
};
|
|
652
|
+
SpawnResult {
|
|
653
|
+
pane_id: PaneId::new(pane),
|
|
654
|
+
session: session.clone(),
|
|
655
|
+
window: window.clone(),
|
|
656
|
+
child_pid: None,
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
impl Transport for BRealTransport {
|
|
662
|
+
fn kind(&self) -> BackendKind {
|
|
663
|
+
BackendKind::Tmux
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
fn spawn_first(
|
|
667
|
+
&self,
|
|
668
|
+
session: &SessionName,
|
|
669
|
+
window: &WindowName,
|
|
670
|
+
argv: &[String],
|
|
671
|
+
_cwd: &std::path::Path,
|
|
672
|
+
_env: &std::collections::BTreeMap<String, String>,
|
|
673
|
+
) -> Result<SpawnResult, TransportError> {
|
|
674
|
+
Ok(self.spawn_result(session, window, argv))
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
fn spawn_into(
|
|
678
|
+
&self,
|
|
679
|
+
session: &SessionName,
|
|
680
|
+
window: &WindowName,
|
|
681
|
+
argv: &[String],
|
|
682
|
+
_cwd: &std::path::Path,
|
|
683
|
+
_env: &std::collections::BTreeMap<String, String>,
|
|
684
|
+
) -> Result<SpawnResult, TransportError> {
|
|
685
|
+
Ok(self.spawn_result(session, window, argv))
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
fn inject(
|
|
689
|
+
&self,
|
|
690
|
+
_target: &Target,
|
|
691
|
+
_payload: &InjectPayload,
|
|
692
|
+
_submit: Key,
|
|
693
|
+
_bracketed: bool,
|
|
694
|
+
) -> Result<InjectReport, TransportError> {
|
|
695
|
+
Ok(InjectReport {
|
|
696
|
+
stage_reached: InjectStage::Submit,
|
|
697
|
+
inject_verification: InjectVerification::NoToken,
|
|
698
|
+
submit_verification: SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
699
|
+
turn_verification: TurnVerification::NotRequired,
|
|
700
|
+
attempts: 1,
|
|
701
|
+
submit_diagnostics: None,
|
|
702
|
+
})
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
fn send_keys(&self, _target: &Target, _keys: &[Key]) -> Result<(), TransportError> {
|
|
706
|
+
Ok(())
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
fn capture(
|
|
710
|
+
&self,
|
|
711
|
+
_target: &Target,
|
|
712
|
+
range: CaptureRange,
|
|
713
|
+
) -> Result<CapturedText, TransportError> {
|
|
714
|
+
Ok(CapturedText {
|
|
715
|
+
text: "OpenAI Codex".to_string(),
|
|
716
|
+
range,
|
|
717
|
+
})
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
fn query(
|
|
721
|
+
&self,
|
|
722
|
+
_target: &Target,
|
|
723
|
+
_field: PaneField,
|
|
724
|
+
) -> Result<Option<String>, TransportError> {
|
|
725
|
+
Ok(Some("node".to_string()))
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
fn liveness(&self, pane: &PaneId) -> Result<PaneLiveness, TransportError> {
|
|
729
|
+
if matches!(self.mode, BRealMode::DeadAfterSpawn) && pane.as_str() == "%9" {
|
|
730
|
+
Ok(PaneLiveness::Dead)
|
|
731
|
+
} else {
|
|
732
|
+
Ok(PaneLiveness::Live)
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
fn has_pane(&self, pane: &PaneId) -> Result<Option<bool>, TransportError> {
|
|
737
|
+
Ok(Some(!matches!(
|
|
738
|
+
(self.mode, pane.as_str()),
|
|
739
|
+
(BRealMode::DeadAfterSpawn, "%9")
|
|
740
|
+
)))
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
744
|
+
let mut targets = Vec::new();
|
|
745
|
+
if matches!(self.mode, BRealMode::Misowned) {
|
|
746
|
+
targets.push(pane_info("team-phaseb", "w2", "%5", 502));
|
|
747
|
+
}
|
|
748
|
+
if matches!(self.mode, BRealMode::Owned) {
|
|
749
|
+
targets.push(pane_info("team-phaseb", "w1", "%10", 501));
|
|
750
|
+
}
|
|
751
|
+
Ok(targets)
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
fn has_session(&self, _session: &SessionName) -> Result<bool, TransportError> {
|
|
755
|
+
Ok(true)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
|
|
759
|
+
Ok(vec![WindowName::new("w1"), WindowName::new("w2")])
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
fn set_session_env(
|
|
763
|
+
&self,
|
|
764
|
+
_session: &SessionName,
|
|
765
|
+
_key: &str,
|
|
766
|
+
_value: &str,
|
|
767
|
+
) -> Result<SetEnvOutcome, TransportError> {
|
|
768
|
+
Ok(SetEnvOutcome::Applied)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
fn kill_session(&self, _session: &SessionName) -> Result<(), TransportError> {
|
|
772
|
+
Ok(())
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
fn kill_window(&self, _target: &Target) -> Result<(), TransportError> {
|
|
776
|
+
Ok(())
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
fn kill_pane(&self, _pane: &PaneId) -> Result<(), TransportError> {
|
|
780
|
+
Ok(())
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
fn attach_session(&self, _session: &SessionName) -> Result<AttachOutcome, TransportError> {
|
|
784
|
+
Ok(AttachOutcome::Attached)
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
fn pane_info(session: &str, window: &str, pane: &str, pid: u32) -> PaneInfo {
|
|
789
|
+
PaneInfo {
|
|
790
|
+
pane_id: PaneId::new(pane),
|
|
791
|
+
session: SessionName::new(session),
|
|
792
|
+
window_index: None,
|
|
793
|
+
window_name: Some(WindowName::new(window)),
|
|
794
|
+
pane_index: None,
|
|
795
|
+
tty: None,
|
|
796
|
+
current_command: Some("node".to_string()),
|
|
797
|
+
current_path: None,
|
|
798
|
+
active: false,
|
|
799
|
+
pane_pid: Some(pid),
|
|
800
|
+
leader_env: std::collections::BTreeMap::new(),
|
|
801
|
+
}
|
|
802
|
+
}
|