@team-agent/installer 0.5.52 → 0.5.54
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 +18 -2
- package/crates/team-agent/src/cli/emit.rs +111 -3
- package/crates/team-agent/src/cli/mod.rs +155 -4
- package/crates/team-agent/src/cli/send/persist.rs +1 -0
- package/crates/team-agent/src/cli/send/presentation.rs +1 -0
- package/crates/team-agent/src/cli/send.rs +1 -0
- package/crates/team-agent/src/cli/spec.rs +4 -1
- package/crates/team-agent/src/cli/tests/lane_c.rs +3 -3
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +1 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +12 -0
- package/crates/team-agent/src/coordinator/tests/basics.rs +4 -4
- package/crates/team-agent/src/db/message_store.rs +31 -2
- package/crates/team-agent/src/db/migration.rs +7 -6
- package/crates/team-agent/src/db/schema.rs +18 -5
- package/crates/team-agent/src/diagnose/orphans.rs +24 -3
- package/crates/team-agent/src/kill_audit.rs +67 -0
- package/crates/team-agent/src/leader/lease.rs +324 -57
- package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
- package/crates/team-agent/src/leader/rediscover.rs +6 -0
- package/crates/team-agent/src/leader/start.rs +144 -23
- package/crates/team-agent/src/leader/tests/idle.rs +3 -0
- package/crates/team-agent/src/leader/types.rs +6 -0
- package/crates/team-agent/src/lib.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +5 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
- package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch.rs +14 -2
- package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -3
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +110 -12
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +8 -2
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
- package/crates/team-agent/src/lifecycle/types.rs +17 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
- package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
- package/crates/team-agent/src/mcp_server/normalize.rs +8 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
- package/crates/team-agent/src/mcp_server/tools.rs +89 -4
- package/crates/team-agent/src/mcp_server/types.rs +7 -0
- package/crates/team-agent/src/mcp_server/wire.rs +65 -4
- package/crates/team-agent/src/messaging/delivery.rs +2 -0
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/leader_channel.rs +32 -5
- package/crates/team-agent/src/messaging/leader_receiver.rs +100 -21
- package/crates/team-agent/src/messaging/mod.rs +2 -1
- package/crates/team-agent/src/messaging/persist.rs +68 -2
- package/crates/team-agent/src/messaging/presentation.rs +307 -0
- package/crates/team-agent/src/messaging/results.rs +130 -3
- package/crates/team-agent/src/messaging/selftest.rs +1 -0
- package/crates/team-agent/src/messaging/send.rs +54 -2
- package/crates/team-agent/src/messaging/tests/runtime.rs +85 -24
- package/crates/team-agent/src/messaging/types.rs +2 -0
- package/crates/team-agent/src/messaging/watchers.rs +1 -0
- package/crates/team-agent/src/provider/adapter.rs +54 -13
- package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
- package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
- package/crates/team-agent/src/provider/session/capture.rs +395 -52
- package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
- package/crates/team-agent/src/provider/session/mod.rs +6 -0
- package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
- package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
- package/crates/team-agent/src/provider/session_scan/common/tests.rs +112 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +53 -92
- package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
- package/crates/team-agent/src/provider/session_scan.rs +3 -3
- package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
- package/crates/team-agent/src/provider/tests.rs +1 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
- package/crates/team-agent/src/tmux_backend.rs +29 -2
- package/package.json +4 -4
- package/schemas/result-envelope.schema.json +10 -0
- package/skills/team-agent/SKILL.md +11 -3
|
@@ -335,7 +335,7 @@ where
|
|
|
335
335
|
// * `transcript_missing` — trigger event has fired (report_result /
|
|
336
336
|
// first_send_at / pane_output_advanced) AND elapsed > threshold
|
|
337
337
|
// AND zero candidates / no backing
|
|
338
|
-
// * `
|
|
338
|
+
// * `pending_first_turn` — no verified backing yet, still in grace
|
|
339
339
|
// The state field surfaces in status/diagnose without changing the
|
|
340
340
|
// authoritative tuple contract (we never write session_id without
|
|
341
341
|
// backing). Throttled event emission is done by the caller (tick).
|
|
@@ -415,11 +415,11 @@ where
|
|
|
415
415
|
/// 0.4.6 Stage 3: classify a pending agent's capture state into one of:
|
|
416
416
|
/// * `transcript_missing` — has had a strong trigger event AND elapsed
|
|
417
417
|
/// beyond grace threshold AND still no backing
|
|
418
|
-
/// * `
|
|
418
|
+
/// * `pending_first_turn` — still in grace period or no trigger yet
|
|
419
419
|
///
|
|
420
420
|
/// Trigger events (CR M2): any of `first_send_at`, `last_result_at`, or a
|
|
421
421
|
/// recent `last_pane_output_at` indicates the worker has been interactively
|
|
422
|
-
/// used / done something. Without ANY trigger, we stay
|
|
422
|
+
/// used / done something. Without ANY trigger, we stay pending
|
|
423
423
|
/// even past the grace window (silent worker is not a failure to capture).
|
|
424
424
|
///
|
|
425
425
|
/// Threshold: 30 seconds from spawned_at OR 15 seconds from the first
|
|
@@ -477,7 +477,7 @@ fn classify_pending_capture_state(
|
|
|
477
477
|
if has_trigger && past_spawn_grace && past_trigger_grace && candidate_count == 0 {
|
|
478
478
|
"transcript_missing"
|
|
479
479
|
} else {
|
|
480
|
-
"
|
|
480
|
+
"pending_first_turn"
|
|
481
481
|
}
|
|
482
482
|
}
|
|
483
483
|
|
|
@@ -488,8 +488,7 @@ pub fn incomplete_resumable_agent_ids(state: &Value) -> Vec<String> {
|
|
|
488
488
|
let mut out = agents
|
|
489
489
|
.iter()
|
|
490
490
|
.filter_map(|(agent_id, agent)| {
|
|
491
|
-
if
|
|
492
|
-
{
|
|
491
|
+
if incomplete_resumable_provider(agent, &mut crate::provider::get_adapter).is_some() {
|
|
493
492
|
Some(agent_id.clone())
|
|
494
493
|
} else {
|
|
495
494
|
None
|
|
@@ -651,27 +650,20 @@ fn pending_session_capture<F>(
|
|
|
651
650
|
where
|
|
652
651
|
F: FnMut(Provider) -> Box<dyn ProviderAdapter>,
|
|
653
652
|
{
|
|
654
|
-
|
|
655
|
-
.get("status")
|
|
656
|
-
.and_then(Value::as_str)
|
|
657
|
-
.is_some_and(|status| status != "running")
|
|
658
|
-
{
|
|
659
|
-
return None;
|
|
660
|
-
}
|
|
661
|
-
if agent_session_complete(agent) {
|
|
662
|
-
return None;
|
|
663
|
-
}
|
|
664
|
-
let provider = agent
|
|
665
|
-
.get("provider")
|
|
666
|
-
.and_then(Value::as_str)
|
|
667
|
-
.and_then(parse_provider)?;
|
|
653
|
+
let provider = incomplete_resumable_provider(agent, adapter_for)?;
|
|
668
654
|
let spawn_cwd = agent
|
|
669
655
|
.get("spawn_cwd")
|
|
670
656
|
.and_then(Value::as_str)
|
|
671
657
|
.filter(|cwd| !cwd.is_empty())?;
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
658
|
+
// New cohorts carry a boundary and are filtered fail-closed by the
|
|
659
|
+
// canonical scanner. Legacy rows may predate spawned_at; keep their
|
|
660
|
+
// boundary absent so they can still use the allocator's identity,
|
|
661
|
+
// collision, and unique-candidate guards instead of being skipped.
|
|
662
|
+
let spawned_at = agent
|
|
663
|
+
.get("spawned_at")
|
|
664
|
+
.and_then(Value::as_str)
|
|
665
|
+
.filter(|value| !value.is_empty())
|
|
666
|
+
.map(str::to_string);
|
|
675
667
|
Some(PendingSessionCapture {
|
|
676
668
|
agent_id: agent_id.to_string(),
|
|
677
669
|
provider,
|
|
@@ -694,25 +686,7 @@ where
|
|
|
694
686
|
.get("pane_pid")
|
|
695
687
|
.and_then(Value::as_u64)
|
|
696
688
|
.and_then(|pid| u32::try_from(pid).ok()),
|
|
697
|
-
spawned_at
|
|
698
|
-
.get("spawned_at")
|
|
699
|
-
.and_then(Value::as_str)
|
|
700
|
-
.filter(|value| !value.is_empty())
|
|
701
|
-
.map(str::to_string),
|
|
702
|
-
// 0.3.31 Codex capture correction: Codex does NOT honor
|
|
703
|
-
// `--session-id`, so any `_pending_session_id` stored for a Codex
|
|
704
|
-
// agent (stale 0.3.30 state, or the framework's pre-spawn token)
|
|
705
|
-
// is a local-only token and must NOT be used as expected_session_id
|
|
706
|
-
// — that would trigger the Stage 1 mismatch guard against Codex's
|
|
707
|
-
// real session_meta.payload.id and permanently reject the correct
|
|
708
|
-
// transcript. Codex capture anchors purely on (cwd, spawned_at).
|
|
709
|
-
// 0.4.7 (B1 verified, partial-resume revert of 9feafc31):
|
|
710
|
-
// Claude --session-id was restored in adapter.rs build_command_plan
|
|
711
|
-
// because Claude ≥ 2.1.185 DOES honour framework-supplied session
|
|
712
|
-
// id and DOES create a transcript at that id. So `_pending_session_id`
|
|
713
|
-
// is once again valid for Claude — re-enable the Stage 1 pre-pass
|
|
714
|
-
// for Claude/ClaudeCode. Codex remains excluded (Codex CLI ignores
|
|
715
|
-
// --session-id, capture must anchor purely on cwd+spawned_at).
|
|
689
|
+
spawned_at,
|
|
716
690
|
expected_session_id: if matches!(provider, Provider::Codex) {
|
|
717
691
|
None
|
|
718
692
|
} else {
|
|
@@ -731,6 +705,34 @@ where
|
|
|
731
705
|
})
|
|
732
706
|
}
|
|
733
707
|
|
|
708
|
+
fn incomplete_resumable_provider<F>(agent: &Value, adapter_for: &mut F) -> Option<Provider>
|
|
709
|
+
where
|
|
710
|
+
F: FnMut(Provider) -> Box<dyn ProviderAdapter>,
|
|
711
|
+
{
|
|
712
|
+
if agent
|
|
713
|
+
.get("status")
|
|
714
|
+
.and_then(Value::as_str)
|
|
715
|
+
.is_some_and(|status| status != "running")
|
|
716
|
+
{
|
|
717
|
+
return None;
|
|
718
|
+
}
|
|
719
|
+
if agent_session_complete(agent) {
|
|
720
|
+
return None;
|
|
721
|
+
}
|
|
722
|
+
let provider = agent
|
|
723
|
+
.get("provider")
|
|
724
|
+
.and_then(Value::as_str)
|
|
725
|
+
.and_then(parse_provider)?;
|
|
726
|
+
agent
|
|
727
|
+
.get("spawn_cwd")
|
|
728
|
+
.and_then(Value::as_str)
|
|
729
|
+
.filter(|cwd| !cwd.is_empty())?;
|
|
730
|
+
if !adapter_for(provider).caps().resume {
|
|
731
|
+
return None;
|
|
732
|
+
}
|
|
733
|
+
Some(provider)
|
|
734
|
+
}
|
|
735
|
+
|
|
734
736
|
fn agent_session_complete(agent: &Value) -> bool {
|
|
735
737
|
// RM-039-SESS-001 step 1 (architect verdict 2026-06-22): historically
|
|
736
738
|
// this returned true the moment `rollout_path` was non-empty — even when
|
|
@@ -966,6 +968,17 @@ fn allocate_session_candidates(
|
|
|
966
968
|
CandidateMatchKind::Any,
|
|
967
969
|
item,
|
|
968
970
|
) {
|
|
971
|
+
Some(candidate)
|
|
972
|
+
if codex_no_expected(item)
|
|
973
|
+
&& candidate_is_visible_to_pending_peer(
|
|
974
|
+
item,
|
|
975
|
+
&candidate,
|
|
976
|
+
pending,
|
|
977
|
+
candidates_by_agent,
|
|
978
|
+
) =>
|
|
979
|
+
{
|
|
980
|
+
ambiguous.insert(item.agent_id.clone());
|
|
981
|
+
}
|
|
969
982
|
Some(candidate) => {
|
|
970
983
|
claimed.extend(captured_provider_session_keys(item, &candidate.captured));
|
|
971
984
|
assignments.insert(item.agent_id.clone(), candidate);
|
|
@@ -983,6 +996,30 @@ fn allocate_session_candidates(
|
|
|
983
996
|
(assignments, ambiguous)
|
|
984
997
|
}
|
|
985
998
|
|
|
999
|
+
fn candidate_is_visible_to_pending_peer(
|
|
1000
|
+
owner: &PendingSessionCapture,
|
|
1001
|
+
candidate: &CapturedSessionCandidate,
|
|
1002
|
+
pending: &[PendingSessionCapture],
|
|
1003
|
+
candidates_by_agent: &BTreeMap<String, Vec<CapturedSessionCandidate>>,
|
|
1004
|
+
) -> bool {
|
|
1005
|
+
let owner_keys = captured_provider_session_keys(owner, &candidate.captured);
|
|
1006
|
+
pending.iter().any(|peer| {
|
|
1007
|
+
peer.agent_id != owner.agent_id
|
|
1008
|
+
&& peer.provider == owner.provider
|
|
1009
|
+
&& peer.team_key == owner.team_key
|
|
1010
|
+
&& candidates_by_agent
|
|
1011
|
+
.get(&peer.agent_id)
|
|
1012
|
+
.is_some_and(|candidates| {
|
|
1013
|
+
candidates.iter().any(|peer_candidate| {
|
|
1014
|
+
!owner_keys.is_disjoint(&captured_provider_session_keys(
|
|
1015
|
+
peer,
|
|
1016
|
+
&peer_candidate.captured,
|
|
1017
|
+
))
|
|
1018
|
+
})
|
|
1019
|
+
})
|
|
1020
|
+
})
|
|
1021
|
+
}
|
|
1022
|
+
|
|
986
1023
|
fn allocate_global_one_to_one(
|
|
987
1024
|
pending: &[PendingSessionCapture],
|
|
988
1025
|
candidates_by_agent: &BTreeMap<String, Vec<CapturedSessionCandidate>>,
|
|
@@ -1150,13 +1187,26 @@ fn claimed_provider_session_keys(
|
|
|
1150
1187
|
) -> BTreeSet<String> {
|
|
1151
1188
|
let mut keys = BTreeSet::new();
|
|
1152
1189
|
let team_key = crate::state::projection::team_state_key(state);
|
|
1153
|
-
// 1.
|
|
1190
|
+
// 1. Every other already-known worker session. The pending row itself is
|
|
1191
|
+
// excluded so an incomplete tuple can still be repaired in place.
|
|
1154
1192
|
for (agent_id, agent) in agents {
|
|
1155
1193
|
if pending_ids.contains(agent_id) {
|
|
1156
1194
|
continue;
|
|
1157
1195
|
}
|
|
1158
1196
|
push_provider_session_keys(&mut keys, &team_key, agent_id, agent);
|
|
1159
1197
|
}
|
|
1198
|
+
if let Some(teams) = state.get("teams").and_then(Value::as_object) {
|
|
1199
|
+
for (nested_team_key, team_state) in teams {
|
|
1200
|
+
if let Some(nested_agents) = team_state.get("agents").and_then(Value::as_object) {
|
|
1201
|
+
for (agent_id, agent) in nested_agents {
|
|
1202
|
+
if nested_team_key == &team_key && pending_ids.contains(agent_id) {
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
push_provider_session_keys(&mut keys, nested_team_key, agent_id, agent);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1160
1210
|
// 2. P0 (lane-046-capture-gap): leader anchor sessions. The leader's
|
|
1161
1211
|
// own provider transcript must never be attributed to a worker. Scan
|
|
1162
1212
|
// state.leader_receiver, state.team_owner, and the same fields under
|
|
@@ -1207,6 +1257,12 @@ fn push_provider_session_keys(
|
|
|
1207
1257
|
keys.insert(global_session_attribution_key_string(session_id));
|
|
1208
1258
|
}
|
|
1209
1259
|
}
|
|
1260
|
+
// Copilot stores every session in one shared session-store.db. Session id
|
|
1261
|
+
// is the attribution identity; treating the shared DB path as globally
|
|
1262
|
+
// exclusive makes an exact expected id collide with every sibling.
|
|
1263
|
+
if matches!(provider, Provider::Copilot) {
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1210
1266
|
for field in ["rollout_path", "transcript_path"] {
|
|
1211
1267
|
if let Some(path) = value
|
|
1212
1268
|
.get(field)
|
|
@@ -1246,15 +1302,17 @@ fn captured_provider_session_keys(
|
|
|
1246
1302
|
}
|
|
1247
1303
|
keys.insert(global_session_attribution_key_string(session_id.as_str()));
|
|
1248
1304
|
}
|
|
1249
|
-
if
|
|
1250
|
-
let
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1305
|
+
if !matches!(owner.provider, Provider::Copilot) {
|
|
1306
|
+
if let Some(rollout_path) = &captured.rollout_path {
|
|
1307
|
+
let path = rollout_path.as_path().to_string_lossy();
|
|
1308
|
+
keys.insert(transcript_attribution_key_string(
|
|
1309
|
+
owner.provider,
|
|
1310
|
+
team_key,
|
|
1311
|
+
owner.agent_id.as_str(),
|
|
1312
|
+
&path,
|
|
1313
|
+
));
|
|
1314
|
+
keys.insert(global_transcript_attribution_key_string(&path));
|
|
1315
|
+
}
|
|
1258
1316
|
}
|
|
1259
1317
|
keys
|
|
1260
1318
|
}
|
|
@@ -1679,6 +1737,45 @@ mod u1_tests {
|
|
|
1679
1737
|
);
|
|
1680
1738
|
}
|
|
1681
1739
|
|
|
1740
|
+
#[test]
|
|
1741
|
+
fn codex_shared_single_candidate_without_identity_is_deferred() {
|
|
1742
|
+
let mut state = serde_json::json!({
|
|
1743
|
+
"agents": {
|
|
1744
|
+
"parent-peer": {
|
|
1745
|
+
"provider": "codex",
|
|
1746
|
+
"status": "running",
|
|
1747
|
+
"spawn_cwd": "/tmp/u1-cwd"
|
|
1748
|
+
},
|
|
1749
|
+
"worker-a": {
|
|
1750
|
+
"provider": "codex",
|
|
1751
|
+
"status": "running",
|
|
1752
|
+
"spawn_cwd": "/tmp/u1-cwd"
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
});
|
|
1756
|
+
let candidate = leader_like_candidate(
|
|
1757
|
+
"019f3327-shared",
|
|
1758
|
+
"/Users/alauda/.codex/sessions/2026/07/06/rollout-shared.jsonl",
|
|
1759
|
+
);
|
|
1760
|
+
let mut canned = BTreeMap::new();
|
|
1761
|
+
canned.insert("parent-peer".to_string(), vec![candidate.clone()]);
|
|
1762
|
+
canned.insert("worker-a".to_string(), vec![candidate]);
|
|
1763
|
+
let canned_for_adapter = canned.clone();
|
|
1764
|
+
let mut adapter_for = move |provider| {
|
|
1765
|
+
Box::new(
|
|
1766
|
+
test_support::CaptureCandidatesAdapter::new(provider, None, "")
|
|
1767
|
+
.with_candidates(canned_for_adapter.clone()),
|
|
1768
|
+
) as Box<dyn ProviderAdapter>
|
|
1769
|
+
};
|
|
1770
|
+
|
|
1771
|
+
let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
|
|
1772
|
+
.expect("capture pass succeeds");
|
|
1773
|
+
|
|
1774
|
+
assert!(report.assigned.is_empty(), "report={report:?}");
|
|
1775
|
+
assert!(state["agents"]["parent-peer"].get("session_id").is_none());
|
|
1776
|
+
assert!(state["agents"]["worker-a"].get("session_id").is_none());
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1682
1779
|
#[test]
|
|
1683
1780
|
fn codex_embedded_identity_assigns_each_same_cwd_worker_to_own_rollout() {
|
|
1684
1781
|
let mut state = serde_json::json!({
|
|
@@ -1869,6 +1966,187 @@ mod u1_tests {
|
|
|
1869
1966
|
);
|
|
1870
1967
|
}
|
|
1871
1968
|
|
|
1969
|
+
#[test]
|
|
1970
|
+
fn pending_capture_state_stays_pending_until_a_trigger_expires() {
|
|
1971
|
+
let old = (chrono::Utc::now() - chrono::Duration::seconds(60)).to_rfc3339();
|
|
1972
|
+
let mut agent = serde_json::Map::new();
|
|
1973
|
+
agent.insert("spawned_at".to_string(), serde_json::json!(old));
|
|
1974
|
+
assert_eq!(
|
|
1975
|
+
super::classify_pending_capture_state(&agent, 0),
|
|
1976
|
+
"pending_first_turn"
|
|
1977
|
+
);
|
|
1978
|
+
|
|
1979
|
+
let trigger = (chrono::Utc::now() - chrono::Duration::seconds(20)).to_rfc3339();
|
|
1980
|
+
agent.insert("first_send_at".to_string(), serde_json::json!(trigger));
|
|
1981
|
+
assert_eq!(
|
|
1982
|
+
super::classify_pending_capture_state(&agent, 0),
|
|
1983
|
+
"transcript_missing"
|
|
1984
|
+
);
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
#[test]
|
|
1988
|
+
fn canonical_pending_capture_preserves_legacy_missing_boundary() {
|
|
1989
|
+
let agent = serde_json::json!({
|
|
1990
|
+
"provider": "codex",
|
|
1991
|
+
"status": "running",
|
|
1992
|
+
"spawn_cwd": "/tmp/u1-cwd",
|
|
1993
|
+
"capture_state": "pending_first_turn"
|
|
1994
|
+
});
|
|
1995
|
+
let mut adapter_for = crate::provider::get_adapter;
|
|
1996
|
+
let pending = pending_session_capture("clone", &agent, &mut adapter_for)
|
|
1997
|
+
.expect("legacy row still participates in canonical capture");
|
|
1998
|
+
assert_eq!(pending.context.spawned_at, None);
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
#[test]
|
|
2002
|
+
fn canonical_delayed_capture_rejects_prior_round_codex_rollout() {
|
|
2003
|
+
let dir =
|
|
2004
|
+
std::env::temp_dir().join(format!("ta-delayed-stale-codex-{}", std::process::id()));
|
|
2005
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
2006
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
2007
|
+
let stale =
|
|
2008
|
+
dir.join("rollout-2026-07-21T23-18-28-019f8541-c986-7f50-b9e6-6503c89df98c.jsonl");
|
|
2009
|
+
std::fs::write(
|
|
2010
|
+
&stale,
|
|
2011
|
+
format!(
|
|
2012
|
+
"{{\"session_meta\":{{\"payload\":{{\"id\":\"019f8541-c986-7f50-b9e6-6503c89df98c\",\"cwd\":\"{}\",\"created_at\":\"2026-07-21T23:18:28+00:00\"}}}}}}\n",
|
|
2013
|
+
dir.to_string_lossy()
|
|
2014
|
+
),
|
|
2015
|
+
)
|
|
2016
|
+
.unwrap();
|
|
2017
|
+
let mut state = serde_json::json!({
|
|
2018
|
+
"agents": {
|
|
2019
|
+
"clone": {
|
|
2020
|
+
"provider": "codex",
|
|
2021
|
+
"status": "running",
|
|
2022
|
+
"spawn_cwd": dir.to_string_lossy(),
|
|
2023
|
+
"spawned_at": "2026-07-22T02:12:27+00:00",
|
|
2024
|
+
"capture_state": "pending_first_turn"
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
2028
|
+
let mut adapter_for = crate::provider::get_adapter;
|
|
2029
|
+
|
|
2030
|
+
let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
|
|
2031
|
+
.expect("delayed capture pass");
|
|
2032
|
+
|
|
2033
|
+
assert!(report.assigned.is_empty());
|
|
2034
|
+
assert!(state["agents"]["clone"]["session_id"].is_null());
|
|
2035
|
+
assert_ne!(
|
|
2036
|
+
state["agents"]["clone"]["capture_state"],
|
|
2037
|
+
serde_json::json!("captured")
|
|
2038
|
+
);
|
|
2039
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
#[test]
|
|
2043
|
+
fn canonical_delayed_capture_ignores_active_foreign_cwd_rollouts() {
|
|
2044
|
+
use std::io::Write;
|
|
2045
|
+
|
|
2046
|
+
let root =
|
|
2047
|
+
std::env::temp_dir().join(format!("ta-codex-dirty-domain-{}", std::process::id()));
|
|
2048
|
+
let _ = std::fs::remove_dir_all(&root);
|
|
2049
|
+
let current_cwd = root.join("current");
|
|
2050
|
+
let foreign_cwd = root.join("foreign");
|
|
2051
|
+
std::fs::create_dir_all(¤t_cwd).unwrap();
|
|
2052
|
+
std::fs::create_dir_all(&foreign_cwd).unwrap();
|
|
2053
|
+
let stale_current = current_cwd.join(
|
|
2054
|
+
"cache/current/rollout-2026-07-21T23-18-28-019f8541-c986-7f50-b9e6-6503c89df98c.jsonl",
|
|
2055
|
+
);
|
|
2056
|
+
let fresh_foreign = current_cwd.join(
|
|
2057
|
+
"cache/foreign/rollout-2026-07-22T04-01-00-019f8644-7060-7000-8000-000000000001.jsonl",
|
|
2058
|
+
);
|
|
2059
|
+
let fresh_current = current_cwd.join(
|
|
2060
|
+
"cache/current/rollout-2026-07-22T04-01-01-019f8644-7450-7000-8000-000000000002.jsonl",
|
|
2061
|
+
);
|
|
2062
|
+
let write_rollout = |path: &std::path::Path, cwd: &std::path::Path, id: &str| {
|
|
2063
|
+
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
2064
|
+
std::fs::write(
|
|
2065
|
+
path,
|
|
2066
|
+
format!(
|
|
2067
|
+
"{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"cwd\":\"{}\"}}}}\n",
|
|
2068
|
+
cwd.to_string_lossy()
|
|
2069
|
+
),
|
|
2070
|
+
)
|
|
2071
|
+
.unwrap();
|
|
2072
|
+
};
|
|
2073
|
+
write_rollout(&stale_current, ¤t_cwd, "stale-current");
|
|
2074
|
+
write_rollout(&fresh_foreign, &foreign_cwd, "fresh-foreign");
|
|
2075
|
+
write_rollout(&fresh_current, ¤t_cwd, "fresh-current");
|
|
2076
|
+
std::fs::OpenOptions::new()
|
|
2077
|
+
.append(true)
|
|
2078
|
+
.open(&stale_current)
|
|
2079
|
+
.unwrap()
|
|
2080
|
+
.write_all(b"{\"type\":\"event_msg\"}\n")
|
|
2081
|
+
.unwrap();
|
|
2082
|
+
let mut state = serde_json::json!({
|
|
2083
|
+
"agents": {"clone": {
|
|
2084
|
+
"provider": "codex", "status": "running",
|
|
2085
|
+
"spawn_cwd": current_cwd.to_string_lossy(),
|
|
2086
|
+
"spawned_at": "2026-07-21T20:00:00+00:00",
|
|
2087
|
+
"capture_state": "pending_first_turn"
|
|
2088
|
+
}}
|
|
2089
|
+
});
|
|
2090
|
+
let mut adapter_for = crate::provider::get_adapter;
|
|
2091
|
+
|
|
2092
|
+
let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
|
|
2093
|
+
.expect("canonical delayed capture");
|
|
2094
|
+
|
|
2095
|
+
assert_eq!(report.candidate_count_by_agent.get("clone"), Some(&1));
|
|
2096
|
+
assert_eq!(state["agents"]["clone"]["session_id"], "fresh-current");
|
|
2097
|
+
assert_eq!(state["agents"]["clone"]["capture_state"], "captured");
|
|
2098
|
+
let _ = std::fs::remove_dir_all(&root);
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
#[test]
|
|
2102
|
+
fn existing_team_session_is_rejected_while_fresh_candidate_is_accepted() {
|
|
2103
|
+
let mut state = serde_json::json!({
|
|
2104
|
+
"session_name": "current",
|
|
2105
|
+
"agents": {
|
|
2106
|
+
"clone": {
|
|
2107
|
+
"provider": "codex",
|
|
2108
|
+
"status": "running",
|
|
2109
|
+
"spawn_cwd": "/tmp/u1-cwd"
|
|
2110
|
+
}
|
|
2111
|
+
},
|
|
2112
|
+
"teams": {
|
|
2113
|
+
"archived": {
|
|
2114
|
+
"agents": {
|
|
2115
|
+
"old-seat": {
|
|
2116
|
+
"provider": "codex",
|
|
2117
|
+
"session_id": "stale-session",
|
|
2118
|
+
"rollout_path": "/tmp/stale.jsonl"
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
});
|
|
2124
|
+
let mut canned = BTreeMap::new();
|
|
2125
|
+
canned.insert(
|
|
2126
|
+
"clone".to_string(),
|
|
2127
|
+
vec![
|
|
2128
|
+
worker_owned_candidate("stale-session", "/tmp/stale.jsonl"),
|
|
2129
|
+
worker_owned_candidate("fresh-session", "/tmp/fresh.jsonl"),
|
|
2130
|
+
],
|
|
2131
|
+
);
|
|
2132
|
+
let canned_for_adapter = canned.clone();
|
|
2133
|
+
let mut adapter_for = move |provider| {
|
|
2134
|
+
Box::new(
|
|
2135
|
+
test_support::CaptureCandidatesAdapter::new(provider, None, "")
|
|
2136
|
+
.with_candidates(canned_for_adapter.clone()),
|
|
2137
|
+
) as Box<dyn ProviderAdapter>
|
|
2138
|
+
};
|
|
2139
|
+
|
|
2140
|
+
let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
|
|
2141
|
+
.expect("capture pass succeeds");
|
|
2142
|
+
|
|
2143
|
+
assert_eq!(report.assigned, vec!["clone".to_string()]);
|
|
2144
|
+
assert_eq!(
|
|
2145
|
+
state["agents"]["clone"]["session_id"].as_str(),
|
|
2146
|
+
Some("fresh-session")
|
|
2147
|
+
);
|
|
2148
|
+
}
|
|
2149
|
+
|
|
1872
2150
|
#[test]
|
|
1873
2151
|
fn capture_pass_keeps_pending_agent_when_one_adapter_capture_fails() {
|
|
1874
2152
|
let mut state = serde_json::json!({
|
|
@@ -2102,6 +2380,71 @@ mod u1_tests {
|
|
|
2102
2380
|
let _ = std::fs::remove_dir(&dir);
|
|
2103
2381
|
}
|
|
2104
2382
|
|
|
2383
|
+
#[test]
|
|
2384
|
+
fn copilot_exact_session_id_does_not_collide_on_shared_store_path() {
|
|
2385
|
+
use crate::provider::{CaptureVia, Confidence, RolloutPath};
|
|
2386
|
+
let dir =
|
|
2387
|
+
std::env::temp_dir().join(format!("ta-copilot-shared-store-{}", std::process::id()));
|
|
2388
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
2389
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
2390
|
+
let store = dir.join("session-store.db");
|
|
2391
|
+
std::fs::write(&store, b"fixture").unwrap();
|
|
2392
|
+
let expected = "22222222-2222-4222-8222-222222222222";
|
|
2393
|
+
let candidate = CapturedSessionCandidate {
|
|
2394
|
+
captured: CapturedSession {
|
|
2395
|
+
session_id: Some(SessionId::new(expected)),
|
|
2396
|
+
rollout_path: Some(RolloutPath::new(store.clone())),
|
|
2397
|
+
captured_via: CaptureVia::FsWatch,
|
|
2398
|
+
attribution_confidence: Confidence::High,
|
|
2399
|
+
spawn_cwd: dir.clone(),
|
|
2400
|
+
},
|
|
2401
|
+
embedded_agent_id: None,
|
|
2402
|
+
positive_agent_id_match: false,
|
|
2403
|
+
agent_path_match: false,
|
|
2404
|
+
};
|
|
2405
|
+
let mut by_agent = BTreeMap::new();
|
|
2406
|
+
by_agent.insert("clone".to_string(), vec![candidate]);
|
|
2407
|
+
let mut state = serde_json::json!({
|
|
2408
|
+
"agents": {
|
|
2409
|
+
"source": {
|
|
2410
|
+
"provider": "copilot",
|
|
2411
|
+
"status": "running",
|
|
2412
|
+
"spawn_cwd": dir.to_string_lossy(),
|
|
2413
|
+
"session_id": "11111111-1111-4111-8111-111111111111",
|
|
2414
|
+
"rollout_path": store.to_string_lossy(),
|
|
2415
|
+
"captured_at": "2026-07-22T01:00:00+00:00",
|
|
2416
|
+
"captured_via": "fs_watch"
|
|
2417
|
+
},
|
|
2418
|
+
"clone": {
|
|
2419
|
+
"provider": "copilot",
|
|
2420
|
+
"status": "running",
|
|
2421
|
+
"spawn_cwd": dir.to_string_lossy(),
|
|
2422
|
+
"spawned_at": "2026-07-22T02:00:00+00:00",
|
|
2423
|
+
"_pending_session_id": expected
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
});
|
|
2427
|
+
let adapter = test_support::CaptureCandidatesAdapter::new(Provider::Copilot, None, "")
|
|
2428
|
+
.with_candidates(by_agent);
|
|
2429
|
+
let mut adapter_for =
|
|
2430
|
+
move |_provider| Box::new(adapter.clone()) as Box<dyn ProviderAdapter>;
|
|
2431
|
+
|
|
2432
|
+
let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
|
|
2433
|
+
.expect("capture shared Copilot store");
|
|
2434
|
+
|
|
2435
|
+
assert_eq!(report.assigned, vec!["clone".to_string()]);
|
|
2436
|
+
assert_eq!(state["agents"]["clone"]["session_id"], expected);
|
|
2437
|
+
assert_eq!(
|
|
2438
|
+
state["agents"]["clone"]["rollout_path"],
|
|
2439
|
+
store.to_string_lossy().as_ref()
|
|
2440
|
+
);
|
|
2441
|
+
assert_eq!(
|
|
2442
|
+
state["agents"]["source"]["session_id"],
|
|
2443
|
+
"11111111-1111-4111-8111-111111111111"
|
|
2444
|
+
);
|
|
2445
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2105
2448
|
/// E57 RED postflight (lane-046-capture-gap): `recover_resume_session_from_events`
|
|
2106
2449
|
/// must refuse to repair a worker's session from a stale `session.captured`
|
|
2107
2450
|
/// event whose rollout file is a Claude LEADER transcript
|