@team-agent/installer 0.5.77 → 0.5.80
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 +161 -29
- package/crates/team-agent/src/cli/diagnose.rs +333 -68
- package/crates/team-agent/src/cli/emit.rs +24 -8
- package/crates/team-agent/src/cli/mod.rs +543 -98
- package/crates/team-agent/src/cli/models.rs +393 -32
- package/crates/team-agent/src/cli/named_address.rs +19 -5
- package/crates/team-agent/src/cli/profile.rs +35 -4
- package/crates/team-agent/src/cli/spec.rs +7 -2
- package/crates/team-agent/src/cli/status_port/compact.rs +12 -8
- package/crates/team-agent/src/cli/status_port/runtime.rs +3 -1
- package/crates/team-agent/src/cli/status_port/snapshot.rs +15 -12
- package/crates/team-agent/src/cli/status_port/store.rs +1 -1
- package/crates/team-agent/src/cli/status_port/tests.rs +37 -1
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
- package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
- package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
- package/crates/team-agent/src/cli/types.rs +1 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +122 -2
- package/crates/team-agent/src/leader/lease.rs +492 -33
- package/crates/team-agent/src/leader/owner_bind.rs +40 -12
- package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
- package/crates/team-agent/src/leader/rediscover.rs +36 -6
- package/crates/team-agent/src/leader/start.rs +197 -88
- package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +86 -23
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
- package/crates/team-agent/src/lifecycle/launch.rs +30 -7
- package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
- package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
- package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +996 -66
- package/crates/team-agent/src/lifecycle/restart.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
- package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
- package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -4
- package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
- package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
- package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
- package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
- package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt +9 -0
- package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
- package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
- package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
- package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
- package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
- package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +3 -2
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +54 -9
- package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +71 -0
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
- package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
- package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
- package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/crates/team-agent/src/mcp_server/wire.rs +140 -11
- package/crates/team-agent/src/messaging/delivery.rs +221 -80
- package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +127 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
- package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
- package/crates/team-agent/src/provider/session_scan/common/tests.rs +204 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +24 -2
- package/crates/team-agent/src/state/abnormal_watch/tests/mod.rs +248 -0
- package/crates/team-agent/src/state/abnormal_watch.rs +144 -0
- package/crates/team-agent/src/state/mod.rs +1 -0
- package/crates/team-agent/src/state/persist.rs +102 -4
- package/crates/team-agent/src/state/projection.rs +7 -1
- package/crates/team-agent/src/state/repository.rs +29 -0
- package/crates/team-agent/src/test_support/models_timeout_fixture.rs +43 -21
- package/crates/team-agent/src/tmux_backend/tests.rs +149 -4
- package/crates/team-agent/src/tmux_backend.rs +239 -127
- package/crates/team-agent/src/transport.rs +78 -0
- package/crates/team-agent/src/transport_factory.rs +53 -0
- package/package.json +4 -5
- package/skills/team-agent/SKILL.md +45 -17
- package/skills/team-agent/command-coverage.json +4 -29
- package/npm/check-skill-links.mjs +0 -88
- package/skills/team-agent/references/team-agent-operator.md +0 -672
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
//! Scope and lifecycle boundaries for the abnormal-exit observation cache.
|
|
2
|
+
use std::collections::BTreeSet;
|
|
3
|
+
|
|
4
|
+
use serde_json::{json, Value};
|
|
5
|
+
|
|
6
|
+
fn members(state: &Value) -> Option<BTreeSet<String>> {
|
|
7
|
+
Some(state.get("agents")?.as_object()?.keys().cloned().collect())
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// A missing roster is unknown. Only a complete, lock-merged roster authorizes
|
|
11
|
+
// discarding absent members; liveness, pause status and record age do not.
|
|
12
|
+
fn scopes(state: &Value) -> Vec<(Option<String>, Option<BTreeSet<String>>)> {
|
|
13
|
+
let mut root = members(state);
|
|
14
|
+
if state.get("teams").is_some_and(|teams| !teams.is_object()) {
|
|
15
|
+
root = None;
|
|
16
|
+
}
|
|
17
|
+
let mut scopes = Vec::new();
|
|
18
|
+
if let Some(teams) = state.get("teams").and_then(Value::as_object) {
|
|
19
|
+
for (team, entry) in teams {
|
|
20
|
+
let roster = members(entry);
|
|
21
|
+
root = match (root, &roster) {
|
|
22
|
+
(Some(mut union), Some(keys)) => {
|
|
23
|
+
union.extend(keys.iter().cloned());
|
|
24
|
+
Some(union)
|
|
25
|
+
}
|
|
26
|
+
_ => None,
|
|
27
|
+
};
|
|
28
|
+
scopes.push((Some(team.clone()), roster));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
scopes.push((None, root));
|
|
32
|
+
scopes
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
pub(crate) fn needs_compaction(state: &Value) -> bool {
|
|
36
|
+
scopes(state).into_iter().any(|(team, roster)| {
|
|
37
|
+
let entry = team.as_ref().map_or(state, |key| &state["teams"][key]);
|
|
38
|
+
roster.is_some_and(|keys| {
|
|
39
|
+
entry
|
|
40
|
+
.pointer("/coordinator/abnormal_exit_watch")
|
|
41
|
+
.and_then(Value::as_object)
|
|
42
|
+
.is_some_and(|watch| watch.keys().any(|key| !keys.contains(key)))
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Call only after the persistence lock's latest-roster merge.
|
|
48
|
+
pub(crate) fn compact_after_merge(state: &mut Value, latest: Option<&Value>, deleted: &[&str]) {
|
|
49
|
+
let latest_scopes = latest.map(scopes).unwrap_or_default();
|
|
50
|
+
for (team, mut roster) in scopes(state) {
|
|
51
|
+
// The normal merge may omit a roster row with no allowlisted stub
|
|
52
|
+
// fields, or a whole sibling absent from a stale input. Membership in
|
|
53
|
+
// lock-held latest still forbids pruning its observation. Only explicit
|
|
54
|
+
// deletion authority can subtract it from this retention union.
|
|
55
|
+
if let Some((_, latest_roster)) = latest_scopes.iter().find(|(key, _)| key == &team) {
|
|
56
|
+
roster = match (roster, latest_roster) {
|
|
57
|
+
(Some(mut keys), Some(latest_keys)) => {
|
|
58
|
+
keys.extend(
|
|
59
|
+
latest_keys
|
|
60
|
+
.iter()
|
|
61
|
+
.filter(|key| !deleted.contains(&key.as_str()))
|
|
62
|
+
.cloned(),
|
|
63
|
+
);
|
|
64
|
+
Some(keys)
|
|
65
|
+
}
|
|
66
|
+
_ => None,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
let Some(keys) = roster else { continue };
|
|
70
|
+
let entry = match team {
|
|
71
|
+
Some(team) => &mut state["teams"][&team],
|
|
72
|
+
None => &mut *state,
|
|
73
|
+
};
|
|
74
|
+
if let Some(watch) = entry
|
|
75
|
+
.pointer_mut("/coordinator/abnormal_exit_watch")
|
|
76
|
+
.and_then(Value::as_object_mut)
|
|
77
|
+
{
|
|
78
|
+
watch.retain(|key, _| keys.contains(key));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// New observations carry their team and cohort, never just a bare agent id.
|
|
84
|
+
pub(crate) fn observation_identity(state: &Value, agent_id: &str) -> Option<Value> {
|
|
85
|
+
let agent = state.get("agents")?.get(agent_id)?;
|
|
86
|
+
let provider = crate::provider::wire::parse_provider(agent.get("provider")?.as_str()?)?;
|
|
87
|
+
let path = ["rollout_path", "transcript_path", "session_log_path"]
|
|
88
|
+
.into_iter()
|
|
89
|
+
.find_map(|key| agent.get(key).and_then(Value::as_str))
|
|
90
|
+
.filter(|path| !path.is_empty())?;
|
|
91
|
+
let spawn_epoch = agent.get("spawn_epoch").and_then(Value::as_u64);
|
|
92
|
+
let spawned_at = agent
|
|
93
|
+
.get("spawned_at")
|
|
94
|
+
.and_then(Value::as_str)
|
|
95
|
+
.filter(|value| !value.is_empty());
|
|
96
|
+
// Preserve the watcher's existing legacy cohort: missing spawn fields
|
|
97
|
+
// remain explicit nulls, never a fabricated generation. A later known
|
|
98
|
+
// cohort changes this identity and establishes a new baseline.
|
|
99
|
+
let team = state
|
|
100
|
+
.get("active_team_key")
|
|
101
|
+
.and_then(Value::as_str)
|
|
102
|
+
.filter(|team| !team.is_empty())
|
|
103
|
+
.map(str::to_string)
|
|
104
|
+
.or_else(|| {
|
|
105
|
+
let team = super::projection::team_state_key(state);
|
|
106
|
+
(team != super::projection::CURRENT_TEAM_ALIAS).then_some(team)
|
|
107
|
+
})?;
|
|
108
|
+
Some(json!({
|
|
109
|
+
"team_key": team,
|
|
110
|
+
"agent_id": agent_id,
|
|
111
|
+
"provider": crate::provider::wire::provider_wire(provider),
|
|
112
|
+
"path": path,
|
|
113
|
+
"spawn_epoch": spawn_epoch,
|
|
114
|
+
"spawned_at": spawned_at,
|
|
115
|
+
}))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
pub(crate) fn matches_observation(state: &Value, agent_id: &str, row: &Value) -> bool {
|
|
119
|
+
observation_identity(state, agent_id)
|
|
120
|
+
.is_some_and(|identity| row.get("watch_identity") == Some(&identity))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Legacy/foreign-cohort facts cannot be reused as current observation evidence.
|
|
124
|
+
/// This is invoked only when actually observing the member, not for paused or
|
|
125
|
+
/// otherwise ineligible members. The next scan establishes the ordinary baseline.
|
|
126
|
+
pub(crate) fn begin_observation(state: &mut Value, agent_id: &str) {
|
|
127
|
+
let identity = observation_identity(state, agent_id);
|
|
128
|
+
if let Some(watch) = state
|
|
129
|
+
.pointer_mut("/coordinator/abnormal_exit_watch")
|
|
130
|
+
.and_then(Value::as_object_mut)
|
|
131
|
+
{
|
|
132
|
+
if watch.get(agent_id).is_some_and(|row| {
|
|
133
|
+
identity
|
|
134
|
+
.as_ref()
|
|
135
|
+
.is_none_or(|identity| row.get("watch_identity") != Some(identity))
|
|
136
|
+
}) {
|
|
137
|
+
watch.remove(agent_id);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#[cfg(test)]
|
|
143
|
+
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
144
|
+
mod tests;
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
//! 绝不 in-place truncate,绝不让审计/重试失败拖垮可见的原 state。
|
|
16
16
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
17
17
|
|
|
18
|
+
pub(crate) mod abnormal_watch;
|
|
18
19
|
pub mod identity;
|
|
19
20
|
// Stage 0 of identity-boundary unified plan (architect direction 2026-06-23):
|
|
20
21
|
// shared identity primitives. Pure additive — Stage 1+ consume these.
|
|
@@ -377,6 +377,7 @@ fn save_runtime_state_with_merge_options(
|
|
|
377
377
|
exact_owner_seed_to_clear,
|
|
378
378
|
None,
|
|
379
379
|
None,
|
|
380
|
+
None,
|
|
380
381
|
)
|
|
381
382
|
}
|
|
382
383
|
|
|
@@ -399,6 +400,7 @@ pub(crate) fn save_runtime_state_with_receiver_authority_and_expected(
|
|
|
399
400
|
None,
|
|
400
401
|
Some((expected_owner, expected_receiver)),
|
|
401
402
|
None,
|
|
403
|
+
None,
|
|
402
404
|
)
|
|
403
405
|
}
|
|
404
406
|
|
|
@@ -421,6 +423,38 @@ pub(crate) fn save_runtime_state_with_exact_owner_receiver_cleanup(
|
|
|
421
423
|
None,
|
|
422
424
|
None,
|
|
423
425
|
Some((team_key, expected_owner, expected_receiver)),
|
|
426
|
+
None,
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
pub(crate) fn save_runtime_state_with_exact_owner_receiver_restore(
|
|
431
|
+
workspace: &Path,
|
|
432
|
+
state: &Value,
|
|
433
|
+
team_key: &str,
|
|
434
|
+
expected_owner: &Value,
|
|
435
|
+
expected_receiver: &Value,
|
|
436
|
+
previous_owner: &Value,
|
|
437
|
+
previous_receiver: &Value,
|
|
438
|
+
) -> Result<(), StateError> {
|
|
439
|
+
persist_runtime_state_with_merge_options_and_expected(
|
|
440
|
+
workspace,
|
|
441
|
+
state,
|
|
442
|
+
&[],
|
|
443
|
+
None,
|
|
444
|
+
&[],
|
|
445
|
+
None,
|
|
446
|
+
&[],
|
|
447
|
+
Some(team_key),
|
|
448
|
+
None,
|
|
449
|
+
None,
|
|
450
|
+
None,
|
|
451
|
+
Some((
|
|
452
|
+
team_key,
|
|
453
|
+
expected_owner,
|
|
454
|
+
expected_receiver,
|
|
455
|
+
previous_owner,
|
|
456
|
+
previous_receiver,
|
|
457
|
+
)),
|
|
424
458
|
)
|
|
425
459
|
}
|
|
426
460
|
|
|
@@ -436,6 +470,7 @@ fn persist_runtime_state_with_merge_options_and_expected(
|
|
|
436
470
|
exact_owner_seed_to_clear: Option<&Value>,
|
|
437
471
|
expected_owner_receiver: Option<(&Value, &Value)>,
|
|
438
472
|
exact_owner_receiver_to_clear: Option<(&str, &Value, &Value)>,
|
|
473
|
+
exact_owner_receiver_to_restore: Option<(&str, &Value, &Value, &Value, &Value)>,
|
|
439
474
|
) -> Result<(), StateError> {
|
|
440
475
|
let path = runtime_state_path(workspace);
|
|
441
476
|
// Python `state.py:497`:先对入参 state 跑 `_migrate_state_identity`(就地填缺失 leader uuid)。
|
|
@@ -457,9 +492,11 @@ fn persist_runtime_state_with_merge_options_and_expected(
|
|
|
457
492
|
// Exact-seed cleanup must observe lock-held disk, not a pre-lock cache or
|
|
458
493
|
// byte-equality hit. A matching in-memory document would otherwise no-op
|
|
459
494
|
// and leave the seed, or skip copying a concurrent owner.
|
|
460
|
-
let skip_pre_lock_fast_path =
|
|
495
|
+
let skip_pre_lock_fast_path = super::abnormal_watch::needs_compaction(&migrated)
|
|
496
|
+
|| exact_owner_seed_to_clear.is_some()
|
|
461
497
|
|| expected_owner_receiver.is_some()
|
|
462
|
-
|| exact_owner_receiver_to_clear.is_some()
|
|
498
|
+
|| exact_owner_receiver_to_clear.is_some()
|
|
499
|
+
|| exact_owner_receiver_to_restore.is_some();
|
|
463
500
|
if !skip_pre_lock_fast_path && cache_equals(&path, &migrated) {
|
|
464
501
|
return Ok(());
|
|
465
502
|
}
|
|
@@ -506,7 +543,7 @@ fn persist_runtime_state_with_merge_options_and_expected(
|
|
|
506
543
|
));
|
|
507
544
|
}
|
|
508
545
|
}
|
|
509
|
-
if let Some(latest) = latest {
|
|
546
|
+
if let Some(latest) = latest.as_ref() {
|
|
510
547
|
let deleted = deleted_agent_ids
|
|
511
548
|
.iter()
|
|
512
549
|
.copied()
|
|
@@ -536,7 +573,7 @@ fn persist_runtime_state_with_merge_options_and_expected(
|
|
|
536
573
|
.collect::<BTreeSet<_>>();
|
|
537
574
|
apply_persist_merge_contract(
|
|
538
575
|
&mut migrated,
|
|
539
|
-
|
|
576
|
+
latest,
|
|
540
577
|
&deleted,
|
|
541
578
|
skip_capture_backfill_team_key,
|
|
542
579
|
&skip_capture_backfill,
|
|
@@ -544,8 +581,10 @@ fn persist_runtime_state_with_merge_options_and_expected(
|
|
|
544
581
|
&receiver_updates,
|
|
545
582
|
receiver_update_team_key.zip(exact_owner_seed_to_clear),
|
|
546
583
|
exact_owner_receiver_to_clear,
|
|
584
|
+
exact_owner_receiver_to_restore,
|
|
547
585
|
)?;
|
|
548
586
|
}
|
|
587
|
+
super::abnormal_watch::compact_after_merge(&mut migrated, latest.as_ref(), deleted_agent_ids);
|
|
549
588
|
// Stage 3 save-output strip second pass (defence-in-depth): after the
|
|
550
589
|
// `preserve_latest_roster_entries` lock-held merge, a future addition
|
|
551
590
|
// to the preserve path could re-introduce root owner fields from the
|
|
@@ -613,6 +652,7 @@ fn apply_persist_merge_contract(
|
|
|
613
652
|
receiver_updates: &BTreeSet<String>,
|
|
614
653
|
exact_owner_seed_to_clear: Option<(&str, &Value)>,
|
|
615
654
|
exact_owner_receiver_to_clear: Option<(&str, &Value, &Value)>,
|
|
655
|
+
exact_owner_receiver_to_restore: Option<(&str, &Value, &Value, &Value, &Value)>,
|
|
616
656
|
) -> Result<(), StateError> {
|
|
617
657
|
// A0/R1: the projection gate only guards the TOP-LEVEL passes (top-level agents and
|
|
618
658
|
// the top-level<->active-team cross projections depend on which team is active); the
|
|
@@ -745,6 +785,25 @@ fn apply_persist_merge_contract(
|
|
|
745
785
|
);
|
|
746
786
|
}
|
|
747
787
|
}
|
|
788
|
+
if let Some((
|
|
789
|
+
restore_team,
|
|
790
|
+
expected_owner,
|
|
791
|
+
expected_receiver,
|
|
792
|
+
previous_owner,
|
|
793
|
+
previous_receiver,
|
|
794
|
+
)) = exact_owner_receiver_to_restore
|
|
795
|
+
{
|
|
796
|
+
if team == restore_team {
|
|
797
|
+
apply_lock_held_exact_owner_receiver_restore(
|
|
798
|
+
incoming_entry,
|
|
799
|
+
latest_entry,
|
|
800
|
+
expected_owner,
|
|
801
|
+
expected_receiver,
|
|
802
|
+
previous_owner,
|
|
803
|
+
previous_receiver,
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
748
807
|
preserve_latest_endpoint_convergence_fields(incoming_entry, latest_entry);
|
|
749
808
|
}
|
|
750
809
|
}
|
|
@@ -919,6 +978,43 @@ fn apply_lock_held_exact_owner_receiver_cleanup(
|
|
|
919
978
|
}
|
|
920
979
|
}
|
|
921
980
|
|
|
981
|
+
fn apply_lock_held_exact_owner_receiver_restore(
|
|
982
|
+
incoming: &mut Value,
|
|
983
|
+
latest: &Value,
|
|
984
|
+
expected_owner: &Value,
|
|
985
|
+
expected_receiver: &Value,
|
|
986
|
+
previous_owner: &Value,
|
|
987
|
+
previous_receiver: &Value,
|
|
988
|
+
) {
|
|
989
|
+
let latest_owner = latest.get("team_owner");
|
|
990
|
+
let latest_receiver = latest.get("leader_receiver");
|
|
991
|
+
let Some(incoming_obj) = incoming.as_object_mut() else {
|
|
992
|
+
return;
|
|
993
|
+
};
|
|
994
|
+
if latest_owner == Some(expected_owner) && latest_receiver == Some(expected_receiver) {
|
|
995
|
+
incoming_obj.insert("team_owner".to_string(), previous_owner.clone());
|
|
996
|
+
incoming_obj.insert("leader_receiver".to_string(), previous_receiver.clone());
|
|
997
|
+
if let Some(epoch) = previous_owner
|
|
998
|
+
.get("owner_epoch")
|
|
999
|
+
.cloned()
|
|
1000
|
+
.or_else(|| latest.get("owner_epoch").cloned())
|
|
1001
|
+
{
|
|
1002
|
+
incoming_obj.insert("owner_epoch".to_string(), epoch);
|
|
1003
|
+
}
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
for key in ["team_owner", "leader_receiver", "owner_epoch"] {
|
|
1007
|
+
match latest.get(key) {
|
|
1008
|
+
Some(value) => {
|
|
1009
|
+
incoming_obj.insert(key.to_string(), value.clone());
|
|
1010
|
+
}
|
|
1011
|
+
None => {
|
|
1012
|
+
incoming_obj.remove(key);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
922
1018
|
fn preserve_latest_endpoint_convergence_fields(incoming: &mut Value, latest: &Value) {
|
|
923
1019
|
if !latest_has_preferable_endpoint_convergence(incoming, latest) {
|
|
924
1020
|
return;
|
|
@@ -3113,6 +3209,7 @@ mod tests {
|
|
|
3113
3209
|
&BTreeSet::new(),
|
|
3114
3210
|
None,
|
|
3115
3211
|
None,
|
|
3212
|
+
None,
|
|
3116
3213
|
)
|
|
3117
3214
|
.unwrap();
|
|
3118
3215
|
|
|
@@ -3149,6 +3246,7 @@ mod tests {
|
|
|
3149
3246
|
&receiver_updates,
|
|
3150
3247
|
None,
|
|
3151
3248
|
None,
|
|
3249
|
+
None,
|
|
3152
3250
|
)
|
|
3153
3251
|
.unwrap();
|
|
3154
3252
|
|
|
@@ -486,7 +486,13 @@ pub fn project_top_level_view(state: &Value, team_key: &str) -> Value {
|
|
|
486
486
|
.is_some_and(|o| o.contains_key("coordinator"))
|
|
487
487
|
&& !p.contains_key("coordinator")
|
|
488
488
|
{
|
|
489
|
-
|
|
489
|
+
let mut coordinator = state["coordinator"].clone();
|
|
490
|
+
// Workspace legacy watch facts have no proven owner in this team.
|
|
491
|
+
// Preserve other coordinator fields, but never broadcast this cache.
|
|
492
|
+
if let Some(coordinator) = coordinator.as_object_mut() {
|
|
493
|
+
coordinator.remove("abnormal_exit_watch");
|
|
494
|
+
}
|
|
495
|
+
p.insert("coordinator".to_string(), coordinator);
|
|
490
496
|
}
|
|
491
497
|
Value::Object(p)
|
|
492
498
|
}
|
|
@@ -52,6 +52,8 @@ use super::persist::{
|
|
|
52
52
|
as helper_write_root_with_receiver_authority_and_expected,
|
|
53
53
|
save_runtime_state_with_exact_owner_receiver_cleanup
|
|
54
54
|
as helper_write_root_with_exact_owner_receiver_cleanup,
|
|
55
|
+
save_runtime_state_with_exact_owner_receiver_restore
|
|
56
|
+
as helper_write_root_with_exact_owner_receiver_restore,
|
|
55
57
|
save_runtime_state_with_team_tombstone_lifecycle_topology_authority as helper_write_root_with_team_tombstone_lifecycle_topology_authority,
|
|
56
58
|
save_runtime_state_with_team_tombstoned_agents as helper_write_root_with_team_tombstoned_agents,
|
|
57
59
|
save_runtime_state_without_migrations as helper_write_root_without_migrations,
|
|
@@ -224,9 +226,20 @@ pub enum StateWriteIntent<'a> {
|
|
|
224
226
|
seed: &'a Value,
|
|
225
227
|
expected_receiver: &'a Value,
|
|
226
228
|
},
|
|
229
|
+
RestoreExactTeamOwnerAndReceiver {
|
|
230
|
+
team_key: &'a str,
|
|
231
|
+
expected_owner: &'a Value,
|
|
232
|
+
expected_receiver: &'a Value,
|
|
233
|
+
previous_owner: &'a Value,
|
|
234
|
+
previous_receiver: &'a Value,
|
|
235
|
+
},
|
|
227
236
|
LeaderBindingRestoreNonTargetTeams {
|
|
228
237
|
target_team_key: &'a str,
|
|
229
238
|
},
|
|
239
|
+
/// Strip extra keys written by a failed or degraded quick-start bind attempt.
|
|
240
|
+
QuickStartBindingCleanup {
|
|
241
|
+
team_key: &'a str,
|
|
242
|
+
},
|
|
230
243
|
LeaderStartBinding {
|
|
231
244
|
team_key: &'a str,
|
|
232
245
|
transport_kind: &'a str,
|
|
@@ -428,6 +441,22 @@ fn route_direct(
|
|
|
428
441
|
seed,
|
|
429
442
|
expected_receiver,
|
|
430
443
|
),
|
|
444
|
+
StateWriteIntent::RestoreExactTeamOwnerAndReceiver {
|
|
445
|
+
team_key,
|
|
446
|
+
expected_owner,
|
|
447
|
+
expected_receiver,
|
|
448
|
+
previous_owner,
|
|
449
|
+
previous_receiver,
|
|
450
|
+
} => helper_write_root_with_exact_owner_receiver_restore(
|
|
451
|
+
workspace,
|
|
452
|
+
state,
|
|
453
|
+
team_key,
|
|
454
|
+
expected_owner,
|
|
455
|
+
expected_receiver,
|
|
456
|
+
previous_owner,
|
|
457
|
+
previous_receiver,
|
|
458
|
+
),
|
|
459
|
+
StateWriteIntent::QuickStartBindingCleanup { .. } => helper_write_root(workspace, state),
|
|
431
460
|
StateWriteIntent::LeaderBindingRestoreNonTargetTeams { .. } => {
|
|
432
461
|
helper_write_root_without_migrations(workspace, state)
|
|
433
462
|
}
|
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
use std::fs::OpenOptions;
|
|
2
|
-
use std::io::
|
|
3
|
-
use std::os::unix::net::{UnixListener, UnixStream};
|
|
2
|
+
use std::io::Write;
|
|
4
3
|
use std::path::{Path, PathBuf};
|
|
5
4
|
use std::process::{Command, Stdio};
|
|
6
|
-
use std::time::Duration;
|
|
5
|
+
use std::time::{Duration, Instant};
|
|
7
6
|
|
|
8
7
|
const CHILD_FLAG: &str = "--team-agent-models-timeout-child";
|
|
9
8
|
const LIST_MODELS_FLAG: &str = "--list-models";
|
|
10
9
|
const RECEIPT_ENV: &str = "TEAM_AGENT_MODELS_TIMEOUT_RECEIPT";
|
|
10
|
+
const STAGE_RECEIPT_ENV: &str = "TEAM_AGENT_MODELS_TIMEOUT_STAGE_RECEIPT";
|
|
11
11
|
const MODE_ENV: &str = "TEAM_AGENT_MODELS_TIMEOUT_MODE";
|
|
12
|
-
const READY_SOCKET_ENV: &str = "TEAM_AGENT_MODELS_TIMEOUT_READY_SOCKET";
|
|
13
12
|
const STDOUT_MARKER: &[u8] = b"__team_agent_models_timeout_descendant_stdout_v1__\n";
|
|
13
|
+
const PARENT_HANDOFF_TIMEOUT: Duration = Duration::from_secs(1);
|
|
14
|
+
|
|
15
|
+
// Stage values are UNIX epoch milliseconds so the test process and helper use
|
|
16
|
+
// one directly comparable clock. Stage writes are best-effort diagnostics and
|
|
17
|
+
// never change the fixture's production-facing exit semantics.
|
|
18
|
+
fn append_stage(key: &str) {
|
|
19
|
+
let Some(path) = std::env::var_os(STAGE_RECEIPT_ENV) else {
|
|
20
|
+
return;
|
|
21
|
+
};
|
|
22
|
+
let Some(milliseconds) = std::time::SystemTime::now()
|
|
23
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
24
|
+
.ok()
|
|
25
|
+
.map(|duration| duration.as_millis())
|
|
26
|
+
else {
|
|
27
|
+
return;
|
|
28
|
+
};
|
|
29
|
+
let _ = append_receipt(Path::new(&path), key, milliseconds);
|
|
30
|
+
}
|
|
14
31
|
|
|
15
32
|
fn receipt_path() -> PathBuf {
|
|
16
33
|
std::env::var_os(RECEIPT_ENV)
|
|
@@ -27,14 +44,29 @@ fn append_receipt(path: &Path, key: &str, value: impl std::fmt::Display) -> Resu
|
|
|
27
44
|
writeln!(receipt, "{key}={value}").map_err(|_| ())
|
|
28
45
|
}
|
|
29
46
|
|
|
47
|
+
fn wait_for_parent_handoff(receipt: &Path) {
|
|
48
|
+
let expected = format!("child_pid={}", std::process::id());
|
|
49
|
+
let deadline = Instant::now() + PARENT_HANDOFF_TIMEOUT;
|
|
50
|
+
loop {
|
|
51
|
+
if std::fs::read_to_string(receipt)
|
|
52
|
+
.ok()
|
|
53
|
+
.is_some_and(|text| text.lines().any(|line| line == expected))
|
|
54
|
+
{
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if Instant::now() >= deadline {
|
|
58
|
+
std::process::exit(5);
|
|
59
|
+
}
|
|
60
|
+
std::thread::sleep(Duration::from_millis(1));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
30
64
|
fn child_main(args: &[String]) {
|
|
31
65
|
if args.len() != 1 || args[0] != CHILD_FLAG {
|
|
32
66
|
std::process::exit(5);
|
|
33
67
|
}
|
|
34
68
|
let receipt = receipt_path();
|
|
35
|
-
|
|
36
|
-
.map(PathBuf::from)
|
|
37
|
-
.unwrap_or_else(|| std::process::exit(5));
|
|
69
|
+
wait_for_parent_handoff(&receipt);
|
|
38
70
|
|
|
39
71
|
let mut stdout = std::io::stdout();
|
|
40
72
|
if stdout.write_all(STDOUT_MARKER).is_err() || stdout.flush().is_err() {
|
|
@@ -48,10 +80,6 @@ fn child_main(args: &[String]) {
|
|
|
48
80
|
std::process::exit(5);
|
|
49
81
|
}
|
|
50
82
|
|
|
51
|
-
let mut ready = UnixStream::connect(socket).unwrap_or_else(|_| std::process::exit(5));
|
|
52
|
-
if ready.write_all(b"1").is_err() {
|
|
53
|
-
std::process::exit(5);
|
|
54
|
-
}
|
|
55
83
|
std::thread::sleep(Duration::from_secs(2));
|
|
56
84
|
}
|
|
57
85
|
|
|
@@ -86,10 +114,8 @@ fn main() {
|
|
|
86
114
|
Ok(_) | Err(std::env::VarError::NotUnicode(_)) => std::process::exit(2),
|
|
87
115
|
}
|
|
88
116
|
|
|
117
|
+
append_stage("parent_entry_ms");
|
|
89
118
|
let receipt = receipt_path();
|
|
90
|
-
let socket = PathBuf::from(format!("{}.sock", receipt.display()));
|
|
91
|
-
let _ = std::fs::remove_file(&socket);
|
|
92
|
-
let listener = UnixListener::bind(&socket).unwrap_or_else(|_| std::process::exit(4));
|
|
93
119
|
if append_receipt(&receipt, "parent_started_once", 1).is_err()
|
|
94
120
|
|| append_receipt(&receipt, "parent_argv_exact", 1).is_err()
|
|
95
121
|
|| append_receipt(&receipt, "parent_pid", std::process::id()).is_err()
|
|
@@ -99,24 +125,20 @@ fn main() {
|
|
|
99
125
|
}
|
|
100
126
|
|
|
101
127
|
let executable = std::env::current_exe().unwrap_or_else(|_| std::process::exit(4));
|
|
128
|
+
append_stage("parent_before_child_spawn_ms");
|
|
102
129
|
let child = Command::new(executable)
|
|
103
130
|
.arg(CHILD_FLAG)
|
|
104
131
|
.env(RECEIPT_ENV, &receipt)
|
|
105
|
-
.env(READY_SOCKET_ENV, &socket)
|
|
106
132
|
.stdin(Stdio::null())
|
|
107
133
|
.stdout(Stdio::inherit())
|
|
108
134
|
.stderr(Stdio::null())
|
|
109
135
|
.spawn()
|
|
110
136
|
.unwrap_or_else(|_| std::process::exit(4));
|
|
111
|
-
|
|
112
|
-
let mut byte = [0_u8; 1];
|
|
113
|
-
if ready.read_exact(&mut byte).is_err() || byte != [b'1'] {
|
|
114
|
-
std::process::exit(4);
|
|
115
|
-
}
|
|
137
|
+
append_stage("parent_after_child_spawn_ms");
|
|
116
138
|
if append_receipt(&receipt, "child_pid", child.id()).is_err() {
|
|
117
139
|
std::process::exit(4);
|
|
118
140
|
}
|
|
119
|
-
|
|
141
|
+
append_stage("parent_before_exit_ms");
|
|
120
142
|
// The parent exits successfully without waiting; the child retains the
|
|
121
143
|
// inherited stdout pipe and self-exits after the real reader deadline.
|
|
122
144
|
}
|