@team-agent/installer 0.3.21 → 0.3.23
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/emit.rs +9 -1
- package/crates/team-agent/src/cli/mod.rs +158 -2
- package/crates/team-agent/src/cli/status.rs +61 -0
- package/crates/team-agent/src/cli/status_port.rs +2 -2
- package/crates/team-agent/src/cli/tests/base.rs +26 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +233 -9
- package/crates/team-agent/src/coordinator/tests/energy.rs +548 -0
- package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/tick.rs +452 -17
- package/crates/team-agent/src/lifecycle/display.rs +23 -302
- package/crates/team-agent/src/lifecycle/launch.rs +38 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -6
- package/crates/team-agent/src/lifecycle/restart/common.rs +179 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +295 -13
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +30 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +89 -5
- package/crates/team-agent/src/messaging/delivery.rs +324 -95
- package/crates/team-agent/src/messaging/scheduler.rs +95 -73
- package/crates/team-agent/src/messaging/send.rs +10 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +460 -0
- package/crates/team-agent/src/messaging/tests/spine.rs +51 -2
- package/crates/team-agent/src/session_capture.rs +261 -1
- package/crates/team-agent/src/tmux_backend/tests.rs +97 -1
- package/crates/team-agent/src/tmux_backend.rs +138 -2
- package/crates/team-agent/src/transport/test_support.rs +25 -0
- package/crates/team-agent/src/transport.rs +11 -0
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +2 -1
- package/skills/team-agent/references/team-in-team.md +127 -0
|
@@ -187,6 +187,16 @@ pub(super) fn spawn_agent_window(
|
|
|
187
187
|
)
|
|
188
188
|
};
|
|
189
189
|
let spawn = result.map_err(|e| LifecycleError::Transport(e.to_string()))?;
|
|
190
|
+
if layout_placement.is_some() {
|
|
191
|
+
crate::lifecycle::launch::configure_adaptive_pane_title(
|
|
192
|
+
workspace,
|
|
193
|
+
transport,
|
|
194
|
+
session_name,
|
|
195
|
+
&window,
|
|
196
|
+
&spawn.pane_id,
|
|
197
|
+
agent_id.as_str(),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
190
200
|
let _ = adapter.handle_startup_prompts(
|
|
191
201
|
transport,
|
|
192
202
|
&crate::transport::Target::Pane(spawn.pane_id.clone()),
|
|
@@ -247,6 +257,72 @@ pub(super) fn persist_effective_approval_policy_for_restart(
|
|
|
247
257
|
crate::lifecycle::launch::persist_effective_approval_policy(agent, safety);
|
|
248
258
|
}
|
|
249
259
|
|
|
260
|
+
pub(super) fn save_restart_projected_state(
|
|
261
|
+
workspace: &Path,
|
|
262
|
+
state: &mut serde_json::Value,
|
|
263
|
+
team_key: &str,
|
|
264
|
+
) -> Result<(), LifecycleError> {
|
|
265
|
+
sync_restart_team_projections(state, team_key);
|
|
266
|
+
crate::state::projection::save_team_scoped_state(workspace, state)
|
|
267
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
pub(super) fn restart_projection_team_key(
|
|
271
|
+
state: &serde_json::Value,
|
|
272
|
+
team: Option<&str>,
|
|
273
|
+
) -> String {
|
|
274
|
+
team.filter(|key| !key.is_empty())
|
|
275
|
+
.map(str::to_string)
|
|
276
|
+
.or_else(|| {
|
|
277
|
+
state
|
|
278
|
+
.get("active_team_key")
|
|
279
|
+
.and_then(serde_json::Value::as_str)
|
|
280
|
+
.filter(|key| !key.is_empty())
|
|
281
|
+
.map(str::to_string)
|
|
282
|
+
})
|
|
283
|
+
.unwrap_or_else(|| crate::state::projection::team_state_key(state))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
pub(super) fn sync_restart_team_projections(state: &mut serde_json::Value, team_key: &str) {
|
|
287
|
+
let Some(teams) = state.get("teams").and_then(serde_json::Value::as_object) else {
|
|
288
|
+
return;
|
|
289
|
+
};
|
|
290
|
+
if teams.is_empty() {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
let compact = crate::state::projection::compact_team_state(state);
|
|
294
|
+
let active_key = state
|
|
295
|
+
.get("active_team_key")
|
|
296
|
+
.and_then(serde_json::Value::as_str)
|
|
297
|
+
.filter(|key| !key.is_empty())
|
|
298
|
+
.map(str::to_string);
|
|
299
|
+
let derived_key = crate::state::projection::team_state_key(state);
|
|
300
|
+
let Some(teams) = state
|
|
301
|
+
.get_mut("teams")
|
|
302
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
303
|
+
else {
|
|
304
|
+
return;
|
|
305
|
+
};
|
|
306
|
+
let mut keys = Vec::new();
|
|
307
|
+
if !team_key.is_empty() {
|
|
308
|
+
keys.push(team_key.to_string());
|
|
309
|
+
}
|
|
310
|
+
if let Some(active_key) = active_key {
|
|
311
|
+
keys.push(active_key);
|
|
312
|
+
}
|
|
313
|
+
if !derived_key.is_empty() {
|
|
314
|
+
keys.push(derived_key);
|
|
315
|
+
}
|
|
316
|
+
if teams.contains_key("current") {
|
|
317
|
+
keys.push("current".to_string());
|
|
318
|
+
}
|
|
319
|
+
keys.sort();
|
|
320
|
+
keys.dedup();
|
|
321
|
+
for key in keys {
|
|
322
|
+
teams.insert(key, compact.clone());
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
250
326
|
pub(super) fn state_session_name(state: &serde_json::Value) -> SessionName {
|
|
251
327
|
state
|
|
252
328
|
.get("session_name")
|
|
@@ -327,6 +403,7 @@ pub(super) fn resume_backing_exists_for_agent(
|
|
|
327
403
|
Provider::Claude | Provider::ClaudeCode => {
|
|
328
404
|
rollout_path_exists(rollout_path)
|
|
329
405
|
|| event_log_transcript_exists(workspace, agent_id.as_str(), session_id.as_str())
|
|
406
|
+
|| claude_project_transcript_exists(agent, session_id.as_str())
|
|
330
407
|
}
|
|
331
408
|
Provider::Copilot => copilot_session_store_has_session(session_id.as_str()),
|
|
332
409
|
Provider::GeminiCli | Provider::Fake => false,
|
|
@@ -372,6 +449,42 @@ fn event_transcript_path(event: &serde_json::Value) -> Option<PathBuf> {
|
|
|
372
449
|
.map(PathBuf::from)
|
|
373
450
|
}
|
|
374
451
|
|
|
452
|
+
/// E36 fix-B: a real Claude worker writes its session transcript to
|
|
453
|
+
/// `<claude_projects_root>/<workspace-slug>/<session_id>.jsonl` even when neither
|
|
454
|
+
/// `rollout_path` was persisted to state nor a `session.captured` event was logged.
|
|
455
|
+
/// That landed transcript is itself a valid resume backing — restart was wrongly
|
|
456
|
+
/// refusing resumable workers because it only checked the two paths above. Scan the
|
|
457
|
+
/// projects root recursively for `<session_id>.jsonl` (session_id is a unique UUID,
|
|
458
|
+
/// so we avoid recomputing the project-dir slug, which is brittle for non-ASCII
|
|
459
|
+
/// workspace paths).
|
|
460
|
+
fn claude_project_transcript_exists(agent: &serde_json::Value, session_id: &str) -> bool {
|
|
461
|
+
if session_id.is_empty() {
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
let projects_root = agent
|
|
465
|
+
.get("claude_projects_root")
|
|
466
|
+
.and_then(serde_json::Value::as_str)
|
|
467
|
+
.filter(|value| !value.is_empty())
|
|
468
|
+
.map(PathBuf::from)
|
|
469
|
+
.or_else(|| {
|
|
470
|
+
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude").join("projects"))
|
|
471
|
+
});
|
|
472
|
+
let Some(projects_root) = projects_root else {
|
|
473
|
+
return false;
|
|
474
|
+
};
|
|
475
|
+
if !projects_root.is_dir() {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
let transcript_name = format!("{session_id}.jsonl");
|
|
479
|
+
let Ok(project_dirs) = std::fs::read_dir(&projects_root) else {
|
|
480
|
+
return false;
|
|
481
|
+
};
|
|
482
|
+
project_dirs
|
|
483
|
+
.flatten()
|
|
484
|
+
.filter(|entry| entry.path().is_dir())
|
|
485
|
+
.any(|entry| entry.path().join(&transcript_name).is_file())
|
|
486
|
+
}
|
|
487
|
+
|
|
375
488
|
fn copilot_session_store_has_session(session_id: &str) -> bool {
|
|
376
489
|
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
|
|
377
490
|
return false;
|
|
@@ -832,3 +945,69 @@ pub(super) fn is_dynamic_agent(
|
|
|
832
945
|
.and_then(YamlValue::as_str)
|
|
833
946
|
.is_some_and(|s| !s.is_empty())
|
|
834
947
|
}
|
|
948
|
+
|
|
949
|
+
#[cfg(test)]
|
|
950
|
+
mod e36_transcript_backing_tests {
|
|
951
|
+
use super::*;
|
|
952
|
+
|
|
953
|
+
struct ScratchDir(PathBuf);
|
|
954
|
+
impl ScratchDir {
|
|
955
|
+
fn new(tag: &str) -> Self {
|
|
956
|
+
let pid = std::process::id();
|
|
957
|
+
let base = std::env::temp_dir().join(format!("ta-e36-{tag}-{pid}"));
|
|
958
|
+
std::fs::create_dir_all(&base).expect("scratch dir");
|
|
959
|
+
ScratchDir(base)
|
|
960
|
+
}
|
|
961
|
+
fn path(&self) -> &Path {
|
|
962
|
+
&self.0
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
impl Drop for ScratchDir {
|
|
966
|
+
fn drop(&mut self) {
|
|
967
|
+
let _ = std::fs::remove_dir_all(&self.0);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// E36 fix-B RED→GREEN: a real Claude worker that sent a message has its session
|
|
972
|
+
// transcript landed at <projects_root>/<slug>/<session_id>.jsonl, but neither
|
|
973
|
+
// rollout_path was persisted to state nor a session.captured event was logged.
|
|
974
|
+
// Before the fix, claude_project_transcript_exists did not exist and restart
|
|
975
|
+
// refused such a worker. This asserts the landed transcript is recognized.
|
|
976
|
+
#[test]
|
|
977
|
+
fn claude_project_transcript_is_recognized_without_rollout_or_capture_event() {
|
|
978
|
+
let scratch = ScratchDir::new("recognized");
|
|
979
|
+
let projects_root = scratch.path().join("projects");
|
|
980
|
+
let slug_dir = projects_root.join("-Users-alauda-Documents-code---rust---9");
|
|
981
|
+
std::fs::create_dir_all(&slug_dir).expect("mkdir slug");
|
|
982
|
+
let session_id = "87742d3f-0b4e-4fc1-ad35-447ac2340b65";
|
|
983
|
+
std::fs::write(slug_dir.join(format!("{session_id}.jsonl")), b"{}\n").expect("transcript");
|
|
984
|
+
|
|
985
|
+
let agent = serde_json::json!({
|
|
986
|
+
"claude_projects_root": projects_root.to_string_lossy(),
|
|
987
|
+
});
|
|
988
|
+
assert!(
|
|
989
|
+
claude_project_transcript_exists(&agent, session_id),
|
|
990
|
+
"landed claude transcript must count as resume backing (E36 fix-B)"
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
#[test]
|
|
995
|
+
fn missing_claude_transcript_is_not_backing() {
|
|
996
|
+
let scratch = ScratchDir::new("missing");
|
|
997
|
+
let projects_root = scratch.path().join("projects");
|
|
998
|
+
std::fs::create_dir_all(&projects_root).expect("mkdir");
|
|
999
|
+
let agent = serde_json::json!({
|
|
1000
|
+
"claude_projects_root": projects_root.to_string_lossy(),
|
|
1001
|
+
});
|
|
1002
|
+
assert!(
|
|
1003
|
+
!claude_project_transcript_exists(&agent, "deadbeef-0000-0000-0000-000000000000"),
|
|
1004
|
+
"no transcript file => no backing"
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
#[test]
|
|
1009
|
+
fn empty_session_id_is_not_backing() {
|
|
1010
|
+
let agent = serde_json::json!({});
|
|
1011
|
+
assert!(!claude_project_transcript_exists(&agent, ""));
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
@@ -23,8 +23,7 @@ pub fn restart_with_session_convergence_deadline(
|
|
|
23
23
|
session_converge_deadline_ms: Option<u64>,
|
|
24
24
|
) -> Result<RestartReport, LifecycleError> {
|
|
25
25
|
let paths = lifecycle_paths(workspace, team)?;
|
|
26
|
-
let transport =
|
|
27
|
-
lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
|
|
26
|
+
let transport = lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
|
|
28
27
|
restart_with_transport_with_session_convergence_deadline(
|
|
29
28
|
workspace,
|
|
30
29
|
allow_fresh,
|
|
@@ -148,12 +147,10 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
148
147
|
allow_fresh,
|
|
149
148
|
)?;
|
|
150
149
|
if convergence.converged && convergence.changed {
|
|
151
|
-
|
|
152
|
-
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
150
|
+
save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
|
|
153
151
|
}
|
|
154
152
|
if repair_resume_sessions_from_event_log(&selected.run_workspace, &mut state)? {
|
|
155
|
-
|
|
156
|
-
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
153
|
+
save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
|
|
157
154
|
let missing_after_repair = restart_required_missing_session_agent_ids(&state);
|
|
158
155
|
convergence.changed = true;
|
|
159
156
|
convergence.converged = missing_after_repair.is_empty();
|
|
@@ -173,8 +170,7 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
173
170
|
});
|
|
174
171
|
}
|
|
175
172
|
if !convergence.converged && convergence.changed {
|
|
176
|
-
|
|
177
|
-
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
173
|
+
save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
|
|
178
174
|
}
|
|
179
175
|
let mut forced_fresh_missing = if convergence.converged {
|
|
180
176
|
std::collections::BTreeSet::new()
|
|
@@ -222,8 +218,7 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
222
218
|
.map_err(|e| LifecycleError::Transport(e.to_string()))?;
|
|
223
219
|
mark_leader_receiver_rebind_required(&mut state, &session_name);
|
|
224
220
|
mark_restart_targets_stopped_after_teardown(&mut state, &plan.decisions);
|
|
225
|
-
|
|
226
|
-
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
221
|
+
save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
|
|
227
222
|
}
|
|
228
223
|
let mut successful_agents: Vec<RestartedAgent> = Vec::new();
|
|
229
224
|
let mut failed_agents: Vec<RestartFailedAgent> = Vec::new();
|
|
@@ -353,8 +348,7 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
353
348
|
}
|
|
354
349
|
}
|
|
355
350
|
// END_B5_RESTART_ISOLATION_LOOP
|
|
356
|
-
|
|
357
|
-
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
351
|
+
save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
|
|
358
352
|
if fatal_resume_failure {
|
|
359
353
|
let attach_commands = Vec::new();
|
|
360
354
|
let next_actions = restart_failure_next_actions(&failed_agents);
|
|
@@ -868,6 +862,14 @@ fn mark_restart_targets_stopped_after_teardown(
|
|
|
868
862
|
}
|
|
869
863
|
}
|
|
870
864
|
|
|
865
|
+
fn save_restart_state(
|
|
866
|
+
workspace: &Path,
|
|
867
|
+
state: &mut serde_json::Value,
|
|
868
|
+
team_key: &str,
|
|
869
|
+
) -> Result<(), LifecycleError> {
|
|
870
|
+
save_restart_projected_state(workspace, state, team_key)
|
|
871
|
+
}
|
|
872
|
+
|
|
871
873
|
fn mark_agent_respawned(
|
|
872
874
|
state: &mut serde_json::Value,
|
|
873
875
|
agent_id: &AgentId,
|
|
@@ -906,7 +908,13 @@ fn mark_agent_respawned(
|
|
|
906
908
|
});
|
|
907
909
|
if let Some(pane_pid) = pane_pid {
|
|
908
910
|
agent.insert("pane_pid".to_string(), serde_json::json!(pane_pid));
|
|
911
|
+
} else {
|
|
912
|
+
agent.remove("pane_pid");
|
|
909
913
|
}
|
|
914
|
+
agent.insert(
|
|
915
|
+
"spawned_at".to_string(),
|
|
916
|
+
serde_json::json!(chrono::Utc::now().to_rfc3339()),
|
|
917
|
+
);
|
|
910
918
|
if matches!(
|
|
911
919
|
restart_mode,
|
|
912
920
|
StartMode::Fresh | StartMode::FreshAfterMissingRollout
|
|
@@ -929,7 +937,10 @@ fn mark_agent_respawned(
|
|
|
929
937
|
"layout_index".to_string(),
|
|
930
938
|
serde_json::json!(placement.layout_index),
|
|
931
939
|
);
|
|
932
|
-
agent.insert(
|
|
940
|
+
agent.insert(
|
|
941
|
+
"pane_index".to_string(),
|
|
942
|
+
serde_json::json!(placement.pane_index),
|
|
943
|
+
);
|
|
933
944
|
agent.insert(
|
|
934
945
|
"display".to_string(),
|
|
935
946
|
serde_json::json!({
|
|
@@ -963,6 +974,8 @@ fn mark_agent_respawned(
|
|
|
963
974
|
}
|
|
964
975
|
agent.remove("startup_prompts");
|
|
965
976
|
agent.remove("startup_prompt_status");
|
|
977
|
+
agent.remove("startup_prompt_probe_epoch");
|
|
978
|
+
agent.remove("startup_prompt_probe_disabled_at");
|
|
966
979
|
Ok(())
|
|
967
980
|
}
|
|
968
981
|
|
|
@@ -1403,3 +1416,272 @@ fn restart_candidate_has_context(state: &serde_json::Value) -> bool {
|
|
|
1403
1416
|
})
|
|
1404
1417
|
})
|
|
1405
1418
|
}
|
|
1419
|
+
|
|
1420
|
+
#[cfg(test)]
|
|
1421
|
+
mod tests {
|
|
1422
|
+
use super::*;
|
|
1423
|
+
use std::collections::BTreeMap;
|
|
1424
|
+
use std::path::Path;
|
|
1425
|
+
|
|
1426
|
+
use crate::transport::{
|
|
1427
|
+
AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport, Key,
|
|
1428
|
+
PaneField, PaneInfo, PaneLiveness, SetEnvOutcome, SpawnResult, Target, Transport,
|
|
1429
|
+
TransportError,
|
|
1430
|
+
};
|
|
1431
|
+
|
|
1432
|
+
struct RespawnEpochTransport;
|
|
1433
|
+
|
|
1434
|
+
impl Transport for RespawnEpochTransport {
|
|
1435
|
+
fn kind(&self) -> BackendKind {
|
|
1436
|
+
BackendKind::Tmux
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
fn spawn_first(
|
|
1440
|
+
&self,
|
|
1441
|
+
_session: &SessionName,
|
|
1442
|
+
_window: &WindowName,
|
|
1443
|
+
_argv: &[String],
|
|
1444
|
+
_cwd: &Path,
|
|
1445
|
+
_env: &BTreeMap<String, String>,
|
|
1446
|
+
) -> Result<SpawnResult, TransportError> {
|
|
1447
|
+
unimplemented!("not used by respawn epoch test")
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
fn spawn_into(
|
|
1451
|
+
&self,
|
|
1452
|
+
_session: &SessionName,
|
|
1453
|
+
_window: &WindowName,
|
|
1454
|
+
_argv: &[String],
|
|
1455
|
+
_cwd: &Path,
|
|
1456
|
+
_env: &BTreeMap<String, String>,
|
|
1457
|
+
) -> Result<SpawnResult, TransportError> {
|
|
1458
|
+
unimplemented!("not used by respawn epoch test")
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
fn inject(
|
|
1462
|
+
&self,
|
|
1463
|
+
_target: &Target,
|
|
1464
|
+
_payload: &InjectPayload,
|
|
1465
|
+
_submit: Key,
|
|
1466
|
+
_bracketed: bool,
|
|
1467
|
+
) -> Result<InjectReport, TransportError> {
|
|
1468
|
+
unimplemented!("not used by respawn epoch test")
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
fn send_keys(&self, _target: &Target, _keys: &[Key]) -> Result<(), TransportError> {
|
|
1472
|
+
unimplemented!("not used by respawn epoch test")
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
fn capture(
|
|
1476
|
+
&self,
|
|
1477
|
+
_target: &Target,
|
|
1478
|
+
range: CaptureRange,
|
|
1479
|
+
) -> Result<CapturedText, TransportError> {
|
|
1480
|
+
Ok(CapturedText {
|
|
1481
|
+
text: String::new(),
|
|
1482
|
+
range,
|
|
1483
|
+
})
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
fn query(
|
|
1487
|
+
&self,
|
|
1488
|
+
_target: &Target,
|
|
1489
|
+
_field: PaneField,
|
|
1490
|
+
) -> Result<Option<String>, TransportError> {
|
|
1491
|
+
Ok(None)
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
fn liveness(&self, _pane: &PaneId) -> Result<PaneLiveness, TransportError> {
|
|
1495
|
+
Ok(PaneLiveness::Live)
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
1499
|
+
Ok(Vec::new())
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
fn has_session(&self, _session: &SessionName) -> Result<bool, TransportError> {
|
|
1503
|
+
Ok(true)
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
|
|
1507
|
+
Ok(Vec::new())
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
fn set_session_env(
|
|
1511
|
+
&self,
|
|
1512
|
+
_session: &SessionName,
|
|
1513
|
+
_key: &str,
|
|
1514
|
+
_value: &str,
|
|
1515
|
+
) -> Result<SetEnvOutcome, TransportError> {
|
|
1516
|
+
Ok(SetEnvOutcome::Applied)
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
fn kill_session(&self, _session: &SessionName) -> Result<(), TransportError> {
|
|
1520
|
+
Ok(())
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
fn kill_window(&self, _target: &Target) -> Result<(), TransportError> {
|
|
1524
|
+
Ok(())
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
fn attach_session(&self, _session: &SessionName) -> Result<AttachOutcome, TransportError> {
|
|
1528
|
+
Ok(AttachOutcome::Attached)
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
fn disabled_safety() -> DangerousApproval {
|
|
1533
|
+
DangerousApproval {
|
|
1534
|
+
enabled: false,
|
|
1535
|
+
source: DangerousApprovalSource::Disabled,
|
|
1536
|
+
inherited: false,
|
|
1537
|
+
provider: None,
|
|
1538
|
+
flag: None,
|
|
1539
|
+
worker_capability_above_leader: false,
|
|
1540
|
+
ancestry_binary_name: None,
|
|
1541
|
+
unexpected_binary: false,
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
#[test]
|
|
1546
|
+
fn respawn_refreshes_startup_probe_epoch_inputs() {
|
|
1547
|
+
let mut state = serde_json::json!({
|
|
1548
|
+
"agents": {
|
|
1549
|
+
"w1": {
|
|
1550
|
+
"status": "running",
|
|
1551
|
+
"window": "w1-old",
|
|
1552
|
+
"pane_id": "%old",
|
|
1553
|
+
"pane_pid": 1010,
|
|
1554
|
+
"spawned_at": "2026-06-01T00:00:00+00:00",
|
|
1555
|
+
"startup_prompts": "disabled_for_epoch",
|
|
1556
|
+
"startup_prompt_status": "disabled_for_epoch",
|
|
1557
|
+
"startup_prompt_probe_epoch": "pane_pid:1010",
|
|
1558
|
+
"startup_prompt_probe_disabled_at": "2026-06-01T00:02:30+00:00"
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
});
|
|
1562
|
+
let spawn = SpawnedAgentWindow {
|
|
1563
|
+
spawn: SpawnResult {
|
|
1564
|
+
pane_id: PaneId::new("%new"),
|
|
1565
|
+
session: SessionName::new("team-epoch"),
|
|
1566
|
+
window: WindowName::new("w1"),
|
|
1567
|
+
child_pid: Some(2020),
|
|
1568
|
+
},
|
|
1569
|
+
plan: crate::provider::CommandPlan::argv_only(vec!["codex".to_string()]),
|
|
1570
|
+
profile_launch: crate::provider::ProviderProfileLaunch::default(),
|
|
1571
|
+
layout_placement: None,
|
|
1572
|
+
};
|
|
1573
|
+
let before = chrono::Utc::now();
|
|
1574
|
+
|
|
1575
|
+
mark_agent_respawned(
|
|
1576
|
+
&mut state,
|
|
1577
|
+
&AgentId::new("w1"),
|
|
1578
|
+
StartMode::Resumed,
|
|
1579
|
+
&spawn,
|
|
1580
|
+
&RespawnEpochTransport,
|
|
1581
|
+
&disabled_safety(),
|
|
1582
|
+
)
|
|
1583
|
+
.expect("mark respawned");
|
|
1584
|
+
|
|
1585
|
+
let agent = state.pointer("/agents/w1").expect("agent");
|
|
1586
|
+
assert_eq!(
|
|
1587
|
+
agent.get("pane_id").and_then(serde_json::Value::as_str),
|
|
1588
|
+
Some("%new")
|
|
1589
|
+
);
|
|
1590
|
+
assert_eq!(
|
|
1591
|
+
agent.get("pane_pid").and_then(serde_json::Value::as_u64),
|
|
1592
|
+
Some(2020)
|
|
1593
|
+
);
|
|
1594
|
+
let spawned_at = agent
|
|
1595
|
+
.get("spawned_at")
|
|
1596
|
+
.and_then(serde_json::Value::as_str)
|
|
1597
|
+
.expect("spawned_at refreshed");
|
|
1598
|
+
let spawned_at = chrono::DateTime::parse_from_rfc3339(spawned_at)
|
|
1599
|
+
.expect("spawned_at rfc3339")
|
|
1600
|
+
.with_timezone(&chrono::Utc);
|
|
1601
|
+
assert!(
|
|
1602
|
+
spawned_at >= before - chrono::Duration::seconds(1),
|
|
1603
|
+
"respawn must start a fresh startup-probe grace window; spawned_at={spawned_at}, before={before}"
|
|
1604
|
+
);
|
|
1605
|
+
for key in [
|
|
1606
|
+
"startup_prompts",
|
|
1607
|
+
"startup_prompt_status",
|
|
1608
|
+
"startup_prompt_probe_epoch",
|
|
1609
|
+
"startup_prompt_probe_disabled_at",
|
|
1610
|
+
] {
|
|
1611
|
+
assert!(
|
|
1612
|
+
agent.get(key).is_none(),
|
|
1613
|
+
"respawned pane must not inherit old startup prompt epoch field {key}"
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
#[test]
|
|
1619
|
+
fn restart_projection_sync_updates_active_team_and_current_alias() {
|
|
1620
|
+
let mut state = serde_json::json!({
|
|
1621
|
+
"active_team_key": "team-alpha",
|
|
1622
|
+
"team_dir": "/tmp/team-alpha",
|
|
1623
|
+
"session_name": "team-alpha",
|
|
1624
|
+
"agents": {
|
|
1625
|
+
"alpha": {
|
|
1626
|
+
"status": "running",
|
|
1627
|
+
"pane_id": "%new",
|
|
1628
|
+
"pane_pid": 47650,
|
|
1629
|
+
"spawned_at": "2026-06-13T04:00:00+00:00"
|
|
1630
|
+
}
|
|
1631
|
+
},
|
|
1632
|
+
"teams": {
|
|
1633
|
+
"current": {
|
|
1634
|
+
"team_dir": "/tmp/team-alpha",
|
|
1635
|
+
"session_name": "team-alpha",
|
|
1636
|
+
"agents": {
|
|
1637
|
+
"alpha": {
|
|
1638
|
+
"status": "running",
|
|
1639
|
+
"pane_id": "%new",
|
|
1640
|
+
"pane_pid": 47650,
|
|
1641
|
+
"spawned_at": "2026-06-13T04:00:00+00:00"
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
},
|
|
1645
|
+
"team-alpha": {
|
|
1646
|
+
"team_dir": "/tmp/team-alpha",
|
|
1647
|
+
"session_name": "team-alpha",
|
|
1648
|
+
"agents": {
|
|
1649
|
+
"alpha": {
|
|
1650
|
+
"status": "running",
|
|
1651
|
+
"pane_id": "%old",
|
|
1652
|
+
"pane_pid": 46784,
|
|
1653
|
+
"spawned_at": "2026-06-13T03:00:00+00:00",
|
|
1654
|
+
"startup_prompt_probe_epoch": "pane_pid:46784"
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
sync_restart_team_projections(&mut state, "team-alpha");
|
|
1662
|
+
|
|
1663
|
+
for pointer in [
|
|
1664
|
+
"/agents/alpha",
|
|
1665
|
+
"/teams/current/agents/alpha",
|
|
1666
|
+
"/teams/team-alpha/agents/alpha",
|
|
1667
|
+
] {
|
|
1668
|
+
let agent = state.pointer(pointer).expect(pointer);
|
|
1669
|
+
assert_eq!(
|
|
1670
|
+
agent.get("pane_id").and_then(serde_json::Value::as_str),
|
|
1671
|
+
Some("%new")
|
|
1672
|
+
);
|
|
1673
|
+
assert_eq!(
|
|
1674
|
+
agent.get("pane_pid").and_then(serde_json::Value::as_u64),
|
|
1675
|
+
Some(47650)
|
|
1676
|
+
);
|
|
1677
|
+
assert_eq!(
|
|
1678
|
+
agent.get("spawned_at").and_then(serde_json::Value::as_str),
|
|
1679
|
+
Some("2026-06-13T04:00:00+00:00")
|
|
1680
|
+
);
|
|
1681
|
+
assert!(
|
|
1682
|
+
agent.get("startup_prompt_probe_epoch").is_none(),
|
|
1683
|
+
"{pointer} must not retain the old startup probe epoch"
|
|
1684
|
+
);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
@@ -1118,6 +1118,20 @@ fn quick_start_default_adaptive_groups_workers_into_layout_panes() {
|
|
|
1118
1118
|
vec!["spawn_first", "spawn_split", "spawn_split", "spawn_into"],
|
|
1119
1119
|
"4 default-adaptive workers should create team-w1 with 3 panes, then team-w2"
|
|
1120
1120
|
);
|
|
1121
|
+
assert_eq!(
|
|
1122
|
+
transport
|
|
1123
|
+
.pane_title_records()
|
|
1124
|
+
.iter()
|
|
1125
|
+
.map(|(_, window, pane, title)| (window.as_str(), pane.as_str(), title.as_str()))
|
|
1126
|
+
.collect::<Vec<_>>(),
|
|
1127
|
+
vec![
|
|
1128
|
+
("team-w1", "%0", "w1"),
|
|
1129
|
+
("team-w1", "%1", "w2"),
|
|
1130
|
+
("team-w1", "%2", "w3"),
|
|
1131
|
+
("team-w2", "%3", "w4"),
|
|
1132
|
+
],
|
|
1133
|
+
"adaptive workers must set tmux pane titles to the agent id"
|
|
1134
|
+
);
|
|
1121
1135
|
assert_eq!(
|
|
1122
1136
|
launch
|
|
1123
1137
|
.started
|
|
@@ -1147,6 +1161,14 @@ fn quick_start_default_adaptive_groups_workers_into_layout_panes() {
|
|
|
1147
1161
|
);
|
|
1148
1162
|
|
|
1149
1163
|
let (_raw, state) = raw_runtime_state(workspace);
|
|
1164
|
+
assert!(
|
|
1165
|
+
transport
|
|
1166
|
+
.pane_title_records()
|
|
1167
|
+
.iter()
|
|
1168
|
+
.all(|(session, _, _, _)| Some(session.as_str())
|
|
1169
|
+
== state.get("session_name").and_then(serde_json::Value::as_str)),
|
|
1170
|
+
"pane titles must be configured inside the team session"
|
|
1171
|
+
);
|
|
1150
1172
|
assert_eq!(state["display_backend"], json!("adaptive"));
|
|
1151
1173
|
assert_eq!(state.pointer("/agents/w1/window"), Some(&json!("team-w1")));
|
|
1152
1174
|
assert_eq!(state.pointer("/agents/w1/layout_window"), Some(&json!("team-w1")));
|
|
@@ -1155,6 +1177,14 @@ fn quick_start_default_adaptive_groups_workers_into_layout_panes() {
|
|
|
1155
1177
|
state.pointer("/agents/w1/display/backend"),
|
|
1156
1178
|
Some(&json!("adaptive"))
|
|
1157
1179
|
);
|
|
1180
|
+
assert_eq!(
|
|
1181
|
+
state.pointer("/agents/w1/display/pane_title"),
|
|
1182
|
+
Some(&json!("w1"))
|
|
1183
|
+
);
|
|
1184
|
+
assert_eq!(
|
|
1185
|
+
state.pointer("/agents/w1/display/linked_session"),
|
|
1186
|
+
Some(&json!(null))
|
|
1187
|
+
);
|
|
1158
1188
|
assert_eq!(
|
|
1159
1189
|
state.pointer("/agents/w4/layout_window"),
|
|
1160
1190
|
Some(&json!("team-w2"))
|