@team-agent/installer 0.3.21 → 0.3.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +9 -1
  4. package/crates/team-agent/src/cli/mod.rs +158 -2
  5. package/crates/team-agent/src/cli/status.rs +61 -0
  6. package/crates/team-agent/src/cli/status_port.rs +2 -2
  7. package/crates/team-agent/src/cli/tests/base.rs +26 -0
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +233 -9
  9. package/crates/team-agent/src/coordinator/tests/energy.rs +548 -0
  10. package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/coordinator/tick.rs +452 -17
  12. package/crates/team-agent/src/lifecycle/display.rs +23 -302
  13. package/crates/team-agent/src/lifecycle/launch.rs +38 -0
  14. package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -6
  15. package/crates/team-agent/src/lifecycle/restart/common.rs +76 -0
  16. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +295 -13
  17. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +30 -0
  18. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +89 -5
  19. package/crates/team-agent/src/messaging/delivery.rs +324 -95
  20. package/crates/team-agent/src/messaging/scheduler.rs +95 -73
  21. package/crates/team-agent/src/messaging/send.rs +10 -0
  22. package/crates/team-agent/src/messaging/tests/runtime.rs +460 -0
  23. package/crates/team-agent/src/messaging/tests/spine.rs +51 -2
  24. package/crates/team-agent/src/session_capture.rs +261 -1
  25. package/crates/team-agent/src/tmux_backend/tests.rs +97 -1
  26. package/crates/team-agent/src/tmux_backend.rs +138 -2
  27. package/crates/team-agent/src/transport/test_support.rs +25 -0
  28. package/crates/team-agent/src/transport.rs +11 -0
  29. package/package.json +4 -4
@@ -0,0 +1,548 @@
1
+ use super::*;
2
+
3
+ use std::collections::BTreeMap;
4
+ use std::path::{Path, PathBuf};
5
+ use std::sync::{Arc, Mutex};
6
+
7
+ use serde_json::{json, Value};
8
+
9
+ use crate::event_log::EventLog;
10
+ use crate::message_store::MessageStore;
11
+ use crate::state::persist::{load_runtime_state, save_runtime_state};
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
+
19
+ #[test]
20
+ fn slice1_stopped_or_missing_agents_do_not_probe_or_emit_capture_failures() {
21
+ let workspace = temp_ws("stopped-missing");
22
+ save_runtime_state(
23
+ &workspace,
24
+ &json!({
25
+ "session_name": "team-energy",
26
+ "agents": {
27
+ "stopped": {
28
+ "provider": "codex",
29
+ "status": "stopped",
30
+ "window": "stopped",
31
+ "pane_id": "%stopped"
32
+ },
33
+ "missing": {
34
+ "provider": "codex",
35
+ "status": "running",
36
+ "window": "missing",
37
+ "pane_id": "%missing"
38
+ }
39
+ }
40
+ }),
41
+ )
42
+ .unwrap();
43
+ let transport = EnergyTransport::new()
44
+ .with_session_present(true)
45
+ .with_windows(vec![WindowName::new("live")])
46
+ .with_capture_error("pane missing");
47
+ let calls = transport.calls.clone();
48
+ let coord = coordinator(&workspace, transport);
49
+
50
+ coord.tick().expect("tick");
51
+
52
+ assert_eq!(
53
+ count_calls(&calls, "capture"),
54
+ 0,
55
+ "stopped and absent-window agents must not be capture-probed"
56
+ );
57
+ let events = read_event_log_dir(&workspace);
58
+ assert!(
59
+ !events
60
+ .iter()
61
+ .any(|event| event_name(event).is_some_and(|name| {
62
+ name == "provider.startup_prompt_failed"
63
+ || name == "runtime_approval.capture_failed"
64
+ || name == "coordinator.agent_capture_failed"
65
+ })),
66
+ "ineligible agents must not create repeated capture failure events: {events:?}"
67
+ );
68
+ }
69
+
70
+ #[test]
71
+ fn slice1_startup_probe_disables_for_epoch_after_grace_and_reopens_on_new_epoch() {
72
+ let workspace = temp_ws("startup-epoch");
73
+ save_runtime_state(
74
+ &workspace,
75
+ &json!({
76
+ "session_name": "team-energy",
77
+ "agents": {
78
+ "w1": running_agent(json!({
79
+ "spawned_at": old_rfc3339(),
80
+ "pane_pid": 1010,
81
+ "activity": {"status": "idle"},
82
+ "coordinator_idle_capture_next_at": future_rfc3339(),
83
+ "runtime_approval_probe": {
84
+ "backoff_secs": 30,
85
+ "next_probe_at": future_rfc3339()
86
+ }
87
+ }))
88
+ }
89
+ }),
90
+ )
91
+ .unwrap();
92
+ let transport = EnergyTransport::new()
93
+ .with_session_present(true)
94
+ .with_windows(vec![WindowName::new("w1")])
95
+ .with_targets(vec![pane("%1", "w1")])
96
+ .with_capture_text("startup trust prompt would be here");
97
+ let calls = transport.calls.clone();
98
+ let coord = coordinator(&workspace, transport);
99
+
100
+ coord.tick().expect("first tick");
101
+ assert_eq!(
102
+ count_calls(&calls, "capture"),
103
+ 0,
104
+ "startup probing after grace must disable without capture; calls={:?}",
105
+ calls.lock().unwrap()
106
+ );
107
+ let disabled_state = load_runtime_state(&workspace).unwrap();
108
+ let disabled_epoch = disabled_state
109
+ .pointer("/agents/w1/startup_prompt_probe_epoch")
110
+ .and_then(Value::as_str)
111
+ .expect("disabled epoch stored")
112
+ .to_string();
113
+ assert_eq!(
114
+ disabled_state
115
+ .pointer("/agents/w1/startup_prompt_status")
116
+ .and_then(Value::as_str),
117
+ Some("disabled_for_epoch")
118
+ );
119
+
120
+ let reopened_workspace = temp_ws("startup-epoch-reopen");
121
+ save_runtime_state(
122
+ &reopened_workspace,
123
+ &json!({
124
+ "session_name": "team-energy",
125
+ "agents": {
126
+ "w1": running_agent(json!({
127
+ "spawned_at": chrono::Utc::now().to_rfc3339(),
128
+ "pane_pid": 2020,
129
+ "startup_prompts": "disabled_for_epoch",
130
+ "startup_prompt_status": "disabled_for_epoch",
131
+ "startup_prompt_probe_epoch": disabled_epoch,
132
+ "startup_prompt_probe_disabled_at": old_rfc3339(),
133
+ "activity": {"status": "idle"},
134
+ "coordinator_idle_capture_next_at": future_rfc3339(),
135
+ "runtime_approval_probe": {
136
+ "backoff_secs": 30,
137
+ "next_probe_at": future_rfc3339()
138
+ }
139
+ }))
140
+ }
141
+ }),
142
+ )
143
+ .unwrap();
144
+ let reopened_transport = EnergyTransport::new()
145
+ .with_session_present(true)
146
+ .with_windows(vec![WindowName::new("w1")])
147
+ .with_targets(vec![pane("%1", "w1")])
148
+ .with_capture_text("ordinary ready output");
149
+ let reopened = coordinator(&reopened_workspace, reopened_transport);
150
+ reopened.tick().expect("second tick");
151
+ let after = load_runtime_state(&reopened_workspace).unwrap();
152
+
153
+ assert_ne!(
154
+ after
155
+ .pointer("/agents/w1/startup_prompt_status")
156
+ .and_then(Value::as_str),
157
+ Some("disabled_for_epoch"),
158
+ "new process epoch must not remain disabled under the old epoch marker"
159
+ );
160
+ }
161
+
162
+ #[test]
163
+ fn slice1_runtime_approval_probe_backs_off_but_remains_recurring() {
164
+ let workspace = temp_ws("runtime-backoff");
165
+ save_runtime_state(
166
+ &workspace,
167
+ &json!({
168
+ "session_name": "team-energy",
169
+ "agents": {
170
+ "w1": running_agent(json!({
171
+ "startup_prompts": "handled",
172
+ "startup_prompt_status": "handled",
173
+ "activity": {"status": "idle"},
174
+ "coordinator_idle_capture_next_at": future_rfc3339()
175
+ }))
176
+ }
177
+ }),
178
+ )
179
+ .unwrap();
180
+ let transport = EnergyTransport::new()
181
+ .with_session_present(true)
182
+ .with_windows(vec![WindowName::new("w1")])
183
+ .with_targets(vec![pane("%1", "w1")])
184
+ .with_capture_text("ordinary output, no approval prompt");
185
+ let calls = transport.calls.clone();
186
+ let coord = coordinator(&workspace, transport);
187
+
188
+ coord.tick().expect("first tick");
189
+ let first_capture_count = count_calls(&calls, "capture");
190
+ assert!(
191
+ first_capture_count > 0,
192
+ "first eligible runtime approval probe must capture"
193
+ );
194
+ let first = load_runtime_state(&workspace).unwrap();
195
+ assert_eq!(
196
+ first
197
+ .pointer("/agents/w1/runtime_approval_probe/backoff_secs")
198
+ .and_then(Value::as_i64),
199
+ Some(30),
200
+ "first empty runtime approval probe starts at 30s backoff"
201
+ );
202
+ assert!(
203
+ first
204
+ .pointer("/agents/w1/runtime_approval_probe/next_probe_at")
205
+ .and_then(Value::as_str)
206
+ .is_some(),
207
+ "runtime approval stores a next probe time instead of disabling forever"
208
+ );
209
+
210
+ coord.tick().expect("second tick");
211
+ assert_eq!(
212
+ count_calls(&calls, "capture"),
213
+ first_capture_count,
214
+ "second tick before next_probe_at must not capture again; calls={:?}",
215
+ calls.lock().unwrap()
216
+ );
217
+ }
218
+
219
+ #[test]
220
+ fn slice1_idle_tick_skips_per_agent_capture_when_no_work_is_pending() {
221
+ let workspace = temp_ws("idle-zero-capture");
222
+ save_runtime_state(
223
+ &workspace,
224
+ &json!({
225
+ "session_name": "team-energy",
226
+ "agents": {
227
+ "w1": running_agent(json!({
228
+ "startup_prompts": "handled",
229
+ "startup_prompt_status": "handled",
230
+ "activity": {"status": "idle"},
231
+ "coordinator_idle_capture_next_at": future_rfc3339(),
232
+ "runtime_approval_probe": {
233
+ "backoff_secs": 30,
234
+ "next_probe_at": future_rfc3339()
235
+ }
236
+ }))
237
+ }
238
+ }),
239
+ )
240
+ .unwrap();
241
+ let transport = EnergyTransport::new()
242
+ .with_session_present(true)
243
+ .with_windows(vec![WindowName::new("w1")])
244
+ .with_targets(vec![pane("%1", "w1")])
245
+ .with_capture_error("idle capture should not happen");
246
+ let calls = transport.calls.clone();
247
+ let coord = coordinator(&workspace, transport);
248
+
249
+ coord.tick().expect("tick");
250
+
251
+ assert_eq!(
252
+ count_calls(&calls, "capture"),
253
+ 0,
254
+ "warm idle tick with no work must perform zero per-agent capture-pane calls; calls={:?}",
255
+ calls.lock().unwrap()
256
+ );
257
+ }
258
+
259
+ #[test]
260
+ fn slice1_idle_tick_captures_when_delivery_work_is_pending() {
261
+ let workspace = temp_ws("idle-with-work");
262
+ save_runtime_state(
263
+ &workspace,
264
+ &json!({
265
+ "session_name": "team-energy",
266
+ "agents": {
267
+ "w1": running_agent(json!({
268
+ "startup_prompts": "handled",
269
+ "startup_prompt_status": "handled",
270
+ "activity": {"status": "idle"},
271
+ "coordinator_idle_capture_next_at": future_rfc3339()
272
+ }))
273
+ }
274
+ }),
275
+ )
276
+ .unwrap();
277
+ let store = MessageStore::open(&workspace).unwrap();
278
+ store
279
+ .create_message(None, "leader", "w1", "hello", None, false, None)
280
+ .unwrap();
281
+ let transport = EnergyTransport::new()
282
+ .with_session_present(true)
283
+ .with_windows(vec![WindowName::new("w1")])
284
+ .with_targets(vec![pane("%1", "w1")])
285
+ .with_capture_text("❯\n");
286
+ let calls = transport.calls.clone();
287
+ let coord = coordinator(&workspace, transport);
288
+
289
+ coord.tick().expect("tick");
290
+
291
+ assert!(
292
+ count_calls(&calls, "capture") > 0,
293
+ "pending delivery work must override idle capture suppression"
294
+ );
295
+ }
296
+
297
+ fn coordinator(workspace: &Path, transport: EnergyTransport) -> Coordinator {
298
+ Coordinator::for_test(
299
+ WorkspacePath::new(workspace.to_path_buf()),
300
+ Box::new(MockRegistry::new(&[], &[])),
301
+ Box::new(transport),
302
+ None,
303
+ None,
304
+ )
305
+ }
306
+
307
+ fn running_agent(extra: Value) -> Value {
308
+ let mut agent = json!({
309
+ "provider": "codex",
310
+ "status": "running",
311
+ "window": "w1",
312
+ "pane_id": "%1",
313
+ "pane_pid": 1001,
314
+ "spawned_at": chrono::Utc::now().to_rfc3339()
315
+ });
316
+ merge_object(&mut agent, extra);
317
+ agent
318
+ }
319
+
320
+ fn merge_object(dst: &mut Value, src: Value) {
321
+ let Some(dst) = dst.as_object_mut() else {
322
+ return;
323
+ };
324
+ let Some(src) = src.as_object() else {
325
+ return;
326
+ };
327
+ for (key, value) in src {
328
+ dst.insert(key.clone(), value.clone());
329
+ }
330
+ }
331
+
332
+ fn pane(pane_id: &str, window: &str) -> PaneInfo {
333
+ PaneInfo {
334
+ pane_id: PaneId::new(pane_id),
335
+ session: SessionName::new("team-energy"),
336
+ window_index: Some(0),
337
+ window_name: Some(WindowName::new(window)),
338
+ pane_index: Some(0),
339
+ tty: None,
340
+ current_command: Some("codex".to_string()),
341
+ current_path: None,
342
+ active: true,
343
+ pane_pid: Some(1001),
344
+ leader_env: BTreeMap::new(),
345
+ }
346
+ }
347
+
348
+ fn count_calls(calls: &Arc<Mutex<Vec<&'static str>>>, name: &str) -> usize {
349
+ calls
350
+ .lock()
351
+ .unwrap()
352
+ .iter()
353
+ .filter(|call| **call == name)
354
+ .count()
355
+ }
356
+
357
+ fn event_name(event: &Value) -> Option<&str> {
358
+ event.get("event").and_then(Value::as_str)
359
+ }
360
+
361
+ fn temp_ws(tag: &str) -> PathBuf {
362
+ let dir = std::env::temp_dir().join(format!(
363
+ "ta-rs-slice1-{tag}-{}-{}",
364
+ std::process::id(),
365
+ chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
366
+ ));
367
+ let _ = std::fs::remove_dir_all(&dir);
368
+ std::fs::create_dir_all(&dir).unwrap();
369
+ std::fs::canonicalize(dir).unwrap()
370
+ }
371
+
372
+ fn old_rfc3339() -> String {
373
+ (chrono::Utc::now() - chrono::Duration::minutes(10)).to_rfc3339()
374
+ }
375
+
376
+ fn future_rfc3339() -> String {
377
+ (chrono::Utc::now() + chrono::Duration::minutes(10)).to_rfc3339()
378
+ }
379
+
380
+ #[derive(Clone)]
381
+ struct EnergyTransport {
382
+ calls: Arc<Mutex<Vec<&'static str>>>,
383
+ session_present: bool,
384
+ windows: Vec<WindowName>,
385
+ targets: Vec<PaneInfo>,
386
+ capture_text: String,
387
+ capture_error: Option<String>,
388
+ }
389
+
390
+ impl EnergyTransport {
391
+ fn new() -> Self {
392
+ Self {
393
+ calls: Arc::new(Mutex::new(Vec::new())),
394
+ session_present: false,
395
+ windows: Vec::new(),
396
+ targets: Vec::new(),
397
+ capture_text: String::new(),
398
+ capture_error: None,
399
+ }
400
+ }
401
+
402
+ fn with_session_present(mut self, present: bool) -> Self {
403
+ self.session_present = present;
404
+ self
405
+ }
406
+
407
+ fn with_windows(mut self, windows: Vec<WindowName>) -> Self {
408
+ self.windows = windows;
409
+ self
410
+ }
411
+
412
+ fn with_targets(mut self, targets: Vec<PaneInfo>) -> Self {
413
+ self.targets = targets;
414
+ self
415
+ }
416
+
417
+ fn with_capture_text(mut self, text: &str) -> Self {
418
+ self.capture_text = text.to_string();
419
+ self.capture_error = None;
420
+ self
421
+ }
422
+
423
+ fn with_capture_error(mut self, error: &str) -> Self {
424
+ self.capture_error = Some(error.to_string());
425
+ self
426
+ }
427
+
428
+ fn record(&self, call: &'static str) {
429
+ self.calls.lock().unwrap().push(call);
430
+ }
431
+ }
432
+
433
+ impl Transport for EnergyTransport {
434
+ fn kind(&self) -> BackendKind {
435
+ BackendKind::Tmux
436
+ }
437
+
438
+ fn spawn_first(
439
+ &self,
440
+ _session: &SessionName,
441
+ _window: &WindowName,
442
+ _argv: &[String],
443
+ _cwd: &Path,
444
+ _env: &BTreeMap<String, String>,
445
+ ) -> Result<SpawnResult, TransportError> {
446
+ unreachable!("energy tests do not spawn")
447
+ }
448
+
449
+ fn spawn_into(
450
+ &self,
451
+ _session: &SessionName,
452
+ _window: &WindowName,
453
+ _argv: &[String],
454
+ _cwd: &Path,
455
+ _env: &BTreeMap<String, String>,
456
+ ) -> Result<SpawnResult, TransportError> {
457
+ unreachable!("energy tests do not spawn")
458
+ }
459
+
460
+ fn inject(
461
+ &self,
462
+ _target: &Target,
463
+ _payload: &InjectPayload,
464
+ _submit: Key,
465
+ _bracketed: bool,
466
+ ) -> Result<InjectReport, TransportError> {
467
+ self.record("inject");
468
+ Ok(InjectReport {
469
+ stage_reached: InjectStage::Submit,
470
+ inject_verification: InjectVerification::CaptureContainsToken,
471
+ submit_verification: SubmitVerification::EnterSentWithoutPlaceholderCheck,
472
+ turn_verification: TurnVerification::NotYetObserved,
473
+ attempts: 1,
474
+ })
475
+ }
476
+
477
+ fn send_keys(&self, _target: &Target, _keys: &[Key]) -> Result<(), TransportError> {
478
+ self.record("send_keys");
479
+ Ok(())
480
+ }
481
+
482
+ fn capture(
483
+ &self,
484
+ _target: &Target,
485
+ range: CaptureRange,
486
+ ) -> Result<CapturedText, TransportError> {
487
+ self.record("capture");
488
+ if let Some(error) = &self.capture_error {
489
+ return Err(TransportError::Capture {
490
+ source: std::io::Error::other(error.clone()),
491
+ });
492
+ }
493
+ Ok(CapturedText {
494
+ text: self.capture_text.clone(),
495
+ range,
496
+ })
497
+ }
498
+
499
+ fn query(&self, _target: &Target, _field: PaneField) -> Result<Option<String>, TransportError> {
500
+ self.record("query");
501
+ Ok(None)
502
+ }
503
+
504
+ fn liveness(&self, _pane: &PaneId) -> Result<PaneLiveness, TransportError> {
505
+ self.record("liveness");
506
+ Ok(PaneLiveness::Live)
507
+ }
508
+
509
+ fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
510
+ self.record("list_targets");
511
+ Ok(self.targets.clone())
512
+ }
513
+
514
+ fn has_session(&self, _session: &SessionName) -> Result<bool, TransportError> {
515
+ self.record("has_session");
516
+ Ok(self.session_present)
517
+ }
518
+
519
+ fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
520
+ self.record("list_windows");
521
+ Ok(self.windows.clone())
522
+ }
523
+
524
+ fn set_session_env(
525
+ &self,
526
+ _session: &SessionName,
527
+ _key: &str,
528
+ _value: &str,
529
+ ) -> Result<SetEnvOutcome, TransportError> {
530
+ self.record("set_session_env");
531
+ Ok(SetEnvOutcome::Applied)
532
+ }
533
+
534
+ fn kill_session(&self, _session: &SessionName) -> Result<(), TransportError> {
535
+ self.record("kill_session");
536
+ Ok(())
537
+ }
538
+
539
+ fn kill_window(&self, _target: &Target) -> Result<(), TransportError> {
540
+ self.record("kill_window");
541
+ Ok(())
542
+ }
543
+
544
+ fn attach_session(&self, _session: &SessionName) -> Result<AttachOutcome, TransportError> {
545
+ self.record("attach_session");
546
+ Ok(AttachOutcome::Attached)
547
+ }
548
+ }
@@ -307,5 +307,6 @@ mod spine;
307
307
  mod health_sync;
308
308
  mod takeover;
309
309
  mod daemon;
310
+ mod energy;
310
311
  mod main_preserved;
311
312
  mod a0_lostupdate;