@team-agent/installer 0.5.72 → 0.5.74
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 +19 -13
- package/crates/team-agent/src/cli/emit.rs +6 -3
- package/crates/team-agent/src/cli/mod.rs +151 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +28 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +3 -0
- package/crates/team-agent/src/cli/types.rs +6 -0
- package/crates/team-agent/src/compiler.rs +14 -1
- package/crates/team-agent/src/leader/lease.rs +106 -21
- package/crates/team-agent/src/leader/registry.rs +0 -89
- package/crates/team-agent/src/leader/start.rs +8 -0
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +43 -7
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +457 -73
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +16 -6
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +5 -3
- package/crates/team-agent/src/lifecycle/restart/common.rs +1 -11
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +60 -0
- package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +71 -1
- package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +34 -1
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +25 -23
- package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +396 -3
- package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +3 -1
- package/crates/team-agent/src/messaging/delivery.rs +26 -8
- package/crates/team-agent/src/messaging/tests/runtime.rs +234 -0
- package/crates/team-agent/src/model/spec.rs +81 -7
- package/crates/team-agent/src/provider/adapters/pi.rs +26 -6
- package/crates/team-agent/src/state/persist.rs +272 -3
- package/crates/team-agent/src/state/repository.rs +9 -2
- package/crates/team-agent/src/tmux_backend/tests.rs +64 -0
- package/crates/team-agent/src/tmux_backend.rs +57 -0
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +4 -1
- package/skills/team-agent/command-coverage.json +21 -0
- package/skills/team-agent/references/team-agent-operator.md +9 -2
|
@@ -208,13 +208,24 @@ impl Drop for RuntimeLock {
|
|
|
208
208
|
/// `save_runtime_state`(bug-084)。`state` 是 state.json 的内存 Value(插入序保留)。
|
|
209
209
|
/// 注:Python 在此还调 `_migrate_state_identity`(identity slice 落地后接入;本 slice 不改 state 内容)。
|
|
210
210
|
pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateError> {
|
|
211
|
-
save_runtime_state_with_merge_options(
|
|
211
|
+
save_runtime_state_with_merge_options(
|
|
212
|
+
workspace,
|
|
213
|
+
state,
|
|
214
|
+
&[],
|
|
215
|
+
None,
|
|
216
|
+
&[],
|
|
217
|
+
None,
|
|
218
|
+
&[],
|
|
219
|
+
None,
|
|
220
|
+
None,
|
|
221
|
+
)
|
|
212
222
|
}
|
|
213
223
|
|
|
214
224
|
pub(crate) fn save_runtime_state_with_receiver_authority(
|
|
215
225
|
workspace: &Path,
|
|
216
226
|
state: &Value,
|
|
217
227
|
receiver_team_key: &str,
|
|
228
|
+
exact_owner_seed_to_clear: Option<&Value>,
|
|
218
229
|
) -> Result<(), StateError> {
|
|
219
230
|
save_runtime_state_with_merge_options(
|
|
220
231
|
workspace,
|
|
@@ -225,6 +236,7 @@ pub(crate) fn save_runtime_state_with_receiver_authority(
|
|
|
225
236
|
None,
|
|
226
237
|
&[],
|
|
227
238
|
Some(receiver_team_key),
|
|
239
|
+
exact_owner_seed_to_clear,
|
|
228
240
|
)
|
|
229
241
|
}
|
|
230
242
|
|
|
@@ -262,6 +274,7 @@ pub(crate) fn save_runtime_state_with_lifecycle_topology_authority(
|
|
|
262
274
|
Some(topology_team_key),
|
|
263
275
|
agent_ids,
|
|
264
276
|
None,
|
|
277
|
+
None,
|
|
265
278
|
)
|
|
266
279
|
}
|
|
267
280
|
|
|
@@ -281,6 +294,7 @@ pub(crate) fn save_runtime_state_with_lifecycle_topology_authority_and_capture_b
|
|
|
281
294
|
Some(skip_capture_backfill_team_key),
|
|
282
295
|
topology_agent_ids,
|
|
283
296
|
None,
|
|
297
|
+
None,
|
|
284
298
|
)
|
|
285
299
|
}
|
|
286
300
|
|
|
@@ -298,6 +312,7 @@ pub(crate) fn save_runtime_state_with_deleted_agents(
|
|
|
298
312
|
None,
|
|
299
313
|
&[],
|
|
300
314
|
None,
|
|
315
|
+
None,
|
|
301
316
|
)
|
|
302
317
|
}
|
|
303
318
|
|
|
@@ -316,6 +331,7 @@ pub(crate) fn save_runtime_state_with_team_tombstoned_agents(
|
|
|
316
331
|
None,
|
|
317
332
|
&[],
|
|
318
333
|
None,
|
|
334
|
+
None,
|
|
319
335
|
)
|
|
320
336
|
}
|
|
321
337
|
|
|
@@ -334,6 +350,7 @@ pub(crate) fn save_runtime_state_with_team_tombstone_lifecycle_topology_authorit
|
|
|
334
350
|
Some(tombstoned_team_key),
|
|
335
351
|
agent_ids,
|
|
336
352
|
None,
|
|
353
|
+
None,
|
|
337
354
|
)
|
|
338
355
|
}
|
|
339
356
|
|
|
@@ -346,6 +363,7 @@ fn save_runtime_state_with_merge_options(
|
|
|
346
363
|
topology_update_team_key: Option<&str>,
|
|
347
364
|
topology_update_agent_ids: &[&str],
|
|
348
365
|
receiver_update_team_key: Option<&str>,
|
|
366
|
+
exact_owner_seed_to_clear: Option<&Value>,
|
|
349
367
|
) -> Result<(), StateError> {
|
|
350
368
|
let path = runtime_state_path(workspace);
|
|
351
369
|
// Python `state.py:497`:先对入参 state 跑 `_migrate_state_identity`(就地填缺失 leader uuid)。
|
|
@@ -364,14 +382,18 @@ fn save_runtime_state_with_merge_options(
|
|
|
364
382
|
// entirely. Moving migrate+strip ahead of every cache_equals makes
|
|
365
383
|
// the canonical-only shape the cache invariant for new writes.
|
|
366
384
|
crate::state::ownership::strip_top_level_ownership_if_canonical_present(&mut migrated);
|
|
367
|
-
|
|
385
|
+
// Exact-seed cleanup must observe lock-held disk, not a pre-lock cache or
|
|
386
|
+
// byte-equality hit. A matching in-memory document would otherwise no-op
|
|
387
|
+
// and leave the seed, or skip copying a concurrent owner.
|
|
388
|
+
let skip_pre_lock_fast_path = exact_owner_seed_to_clear.is_some();
|
|
389
|
+
if !skip_pre_lock_fast_path && cache_equals(&path, &migrated) {
|
|
368
390
|
return Ok(());
|
|
369
391
|
}
|
|
370
392
|
// 与磁盘已有内容「迁移后」相同 → 更新缓存返回(避免无谓重写)。字节对拍 Python:对 `existing` 先
|
|
371
393
|
// `normalize_agent_session_state` + `_migrate_state_identity` 再比(读/迁移失败 try/except: pass →
|
|
372
394
|
// 落写路径)。**修对抗 P1**:此前比较 raw `existing` 漏了这两步,会把「磁盘已是迁移等价形」的 legacy
|
|
373
395
|
// 文件误判为不同而 spurious 重写,破坏 load+save 字节恒等。
|
|
374
|
-
if path.exists() {
|
|
396
|
+
if !skip_pre_lock_fast_path && path.exists() {
|
|
375
397
|
if let Ok(text) = std::fs::read_to_string(&path) {
|
|
376
398
|
if let Ok(mut existing) = serde_json::from_str::<Value>(&text) {
|
|
377
399
|
normalize_agent_session_state(&mut existing);
|
|
@@ -432,6 +454,7 @@ fn save_runtime_state_with_merge_options(
|
|
|
432
454
|
&skip_capture_backfill,
|
|
433
455
|
&topology_updates,
|
|
434
456
|
&receiver_updates,
|
|
457
|
+
receiver_update_team_key.zip(exact_owner_seed_to_clear),
|
|
435
458
|
)?;
|
|
436
459
|
}
|
|
437
460
|
// Stage 3 save-output strip second pass (defence-in-depth): after the
|
|
@@ -499,6 +522,7 @@ fn apply_persist_merge_contract(
|
|
|
499
522
|
skip_capture_backfill_agent_ids: &BTreeSet<String>,
|
|
500
523
|
topology_updates: &BTreeSet<(String, String)>,
|
|
501
524
|
receiver_updates: &BTreeSet<String>,
|
|
525
|
+
exact_owner_seed_to_clear: Option<(&str, &Value)>,
|
|
502
526
|
) -> Result<(), StateError> {
|
|
503
527
|
// A0/R1: the projection gate only guards the TOP-LEVEL passes (top-level agents and
|
|
504
528
|
// the top-level<->active-team cross projections depend on which team is active); the
|
|
@@ -616,6 +640,11 @@ fn apply_persist_merge_contract(
|
|
|
616
640
|
latest_entry,
|
|
617
641
|
receiver_update_authoritative,
|
|
618
642
|
);
|
|
643
|
+
if let Some((clear_team, seed)) = exact_owner_seed_to_clear {
|
|
644
|
+
if team == clear_team {
|
|
645
|
+
apply_lock_held_exact_owner_cleanup(incoming_entry, latest_entry, seed);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
619
648
|
preserve_latest_endpoint_convergence_fields(incoming_entry, latest_entry);
|
|
620
649
|
}
|
|
621
650
|
}
|
|
@@ -746,6 +775,38 @@ fn preserve_latest_ownership_fields(
|
|
|
746
775
|
}
|
|
747
776
|
}
|
|
748
777
|
|
|
778
|
+
fn apply_lock_held_exact_owner_cleanup(incoming: &mut Value, latest: &Value, seed: &Value) {
|
|
779
|
+
// Compare the lock-held disk owner to the attempt-local seed. Equal-epoch
|
|
780
|
+
// concurrent owners must survive; only the exact seed is cleared, and the
|
|
781
|
+
// epoch is not bumped so a same-epoch takeover cannot be overwritten.
|
|
782
|
+
let latest_owner = latest.get("team_owner");
|
|
783
|
+
let Some(incoming_obj) = incoming.as_object_mut() else {
|
|
784
|
+
return;
|
|
785
|
+
};
|
|
786
|
+
if latest_owner == Some(seed) {
|
|
787
|
+
incoming_obj.insert("team_owner".to_string(), Value::Null);
|
|
788
|
+
incoming_obj.insert("leader_receiver".to_string(), Value::Null);
|
|
789
|
+
if let Some(epoch) = latest
|
|
790
|
+
.get("owner_epoch")
|
|
791
|
+
.cloned()
|
|
792
|
+
.or_else(|| latest_owner.and_then(|owner| owner.get("owner_epoch")).cloned())
|
|
793
|
+
{
|
|
794
|
+
incoming_obj.insert("owner_epoch".to_string(), epoch);
|
|
795
|
+
}
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
for key in ["team_owner", "leader_receiver", "owner_epoch"] {
|
|
799
|
+
match latest.get(key) {
|
|
800
|
+
Some(value) => {
|
|
801
|
+
incoming_obj.insert(key.to_string(), value.clone());
|
|
802
|
+
}
|
|
803
|
+
None => {
|
|
804
|
+
incoming_obj.remove(key);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
749
810
|
fn preserve_latest_endpoint_convergence_fields(incoming: &mut Value, latest: &Value) {
|
|
750
811
|
if !latest_has_preferable_endpoint_convergence(incoming, latest) {
|
|
751
812
|
return;
|
|
@@ -2923,6 +2984,7 @@ mod tests {
|
|
|
2923
2984
|
&BTreeSet::new(),
|
|
2924
2985
|
&BTreeSet::new(),
|
|
2925
2986
|
&BTreeSet::new(),
|
|
2987
|
+
None,
|
|
2926
2988
|
)
|
|
2927
2989
|
.unwrap();
|
|
2928
2990
|
|
|
@@ -2957,6 +3019,7 @@ mod tests {
|
|
|
2957
3019
|
&BTreeSet::new(),
|
|
2958
3020
|
&BTreeSet::new(),
|
|
2959
3021
|
&receiver_updates,
|
|
3022
|
+
None,
|
|
2960
3023
|
)
|
|
2961
3024
|
.unwrap();
|
|
2962
3025
|
|
|
@@ -2979,4 +3042,210 @@ mod tests {
|
|
|
2979
3042
|
"team-scoped receiver authority must not overwrite a sibling's latest tuple"
|
|
2980
3043
|
);
|
|
2981
3044
|
}
|
|
3045
|
+
|
|
3046
|
+
fn seed_owner(epoch: u64) -> Value {
|
|
3047
|
+
json!({
|
|
3048
|
+
"pane_id": "%42",
|
|
3049
|
+
"provider": "pi",
|
|
3050
|
+
"leader_session_uuid": "uuid-seed",
|
|
3051
|
+
"owner_epoch": epoch,
|
|
3052
|
+
"claimed_via": "quick-start"
|
|
3053
|
+
})
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
fn seed_receiver(epoch: u64) -> Value {
|
|
3057
|
+
json!({
|
|
3058
|
+
"mode": "direct_tmux",
|
|
3059
|
+
"status": "attached",
|
|
3060
|
+
"pane_id": "%42",
|
|
3061
|
+
"provider": "pi",
|
|
3062
|
+
"leader_session_uuid": "uuid-seed",
|
|
3063
|
+
"owner_epoch": epoch
|
|
3064
|
+
})
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
fn takeover_owner(epoch: u64) -> Value {
|
|
3068
|
+
json!({
|
|
3069
|
+
"pane_id": "%99",
|
|
3070
|
+
"provider": "codex",
|
|
3071
|
+
"leader_session_uuid": "uuid-other",
|
|
3072
|
+
"owner_epoch": epoch,
|
|
3073
|
+
"claimed_via": "claim-leader"
|
|
3074
|
+
})
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
fn takeover_receiver(epoch: u64) -> Value {
|
|
3078
|
+
json!({
|
|
3079
|
+
"mode": "direct_tmux",
|
|
3080
|
+
"status": "attached",
|
|
3081
|
+
"pane_id": "%99",
|
|
3082
|
+
"provider": "codex",
|
|
3083
|
+
"leader_session_uuid": "uuid-other",
|
|
3084
|
+
"owner_epoch": epoch,
|
|
3085
|
+
"tmux_socket": "/tmp/other"
|
|
3086
|
+
})
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
fn team_state_with_binding(ws: &Path, owner: Value, receiver: Value, epoch: u64) -> Value {
|
|
3090
|
+
json!({
|
|
3091
|
+
"workspace": ws.display().to_string(),
|
|
3092
|
+
"active_team_key": "fresh",
|
|
3093
|
+
"team_key": "fresh",
|
|
3094
|
+
"session_name": "team-fresh",
|
|
3095
|
+
"agents": {"sol": {"status": "running"}},
|
|
3096
|
+
"teams": {
|
|
3097
|
+
"fresh": {
|
|
3098
|
+
"team_key": "fresh",
|
|
3099
|
+
"session_name": "team-fresh",
|
|
3100
|
+
"agents": {"sol": {"status": "running"}},
|
|
3101
|
+
"team_owner": owner,
|
|
3102
|
+
"leader_receiver": receiver,
|
|
3103
|
+
"owner_epoch": epoch
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
})
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
fn tombstone_incoming(ws: &Path, epoch: u64) -> Value {
|
|
3110
|
+
let mut incoming = team_state_with_binding(
|
|
3111
|
+
ws,
|
|
3112
|
+
seed_owner(epoch),
|
|
3113
|
+
seed_receiver(epoch),
|
|
3114
|
+
epoch,
|
|
3115
|
+
);
|
|
3116
|
+
incoming["teams"]["fresh"]["team_owner"] = Value::Null;
|
|
3117
|
+
incoming["teams"]["fresh"]["leader_receiver"] = Value::Null;
|
|
3118
|
+
incoming
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
fn assert_fresh_binding(persisted: &Value, owner: &Value, receiver: &Value, epoch: u64) {
|
|
3122
|
+
assert_eq!(&persisted["teams"]["fresh"]["team_owner"], owner);
|
|
3123
|
+
assert_eq!(&persisted["teams"]["fresh"]["leader_receiver"], receiver);
|
|
3124
|
+
assert_eq!(persisted["teams"]["fresh"]["owner_epoch"], json!(epoch));
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
fn persisted_fresh_owner(ws: &Path) -> Value {
|
|
3128
|
+
load_runtime_state(ws).unwrap()["teams"]["fresh"]["team_owner"].clone()
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
#[test]
|
|
3132
|
+
fn exact_seed_cleanup_ignores_pre_lock_cache_and_equality_fast_path() {
|
|
3133
|
+
let ws = temp_ws();
|
|
3134
|
+
save_runtime_state(
|
|
3135
|
+
&ws,
|
|
3136
|
+
&team_state_with_binding(&ws, seed_owner(1), seed_receiver(1), 1),
|
|
3137
|
+
)
|
|
3138
|
+
.unwrap();
|
|
3139
|
+
let incoming = load_runtime_state(&ws).unwrap();
|
|
3140
|
+
let seed = persisted_fresh_owner(&ws);
|
|
3141
|
+
save_runtime_state_with_receiver_authority(&ws, &incoming, "fresh", Some(&seed)).unwrap();
|
|
3142
|
+
let persisted = load_runtime_state(&ws).unwrap();
|
|
3143
|
+
assert!(
|
|
3144
|
+
persisted
|
|
3145
|
+
.pointer("/teams/fresh/team_owner")
|
|
3146
|
+
.is_some_and(Value::is_null),
|
|
3147
|
+
"cache/equality hit must not skip exact-seed cleanup; got {}",
|
|
3148
|
+
persisted["teams"]["fresh"]["team_owner"]
|
|
3149
|
+
);
|
|
3150
|
+
assert_eq!(persisted["teams"]["fresh"]["owner_epoch"], json!(1));
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
#[test]
|
|
3154
|
+
fn lock_held_exact_seed_cleanup_clears_matching_owner_without_epoch_bump() {
|
|
3155
|
+
let ws = temp_ws();
|
|
3156
|
+
save_runtime_state(
|
|
3157
|
+
&ws,
|
|
3158
|
+
&team_state_with_binding(&ws, seed_owner(1), seed_receiver(1), 1),
|
|
3159
|
+
)
|
|
3160
|
+
.unwrap();
|
|
3161
|
+
let seed = persisted_fresh_owner(&ws);
|
|
3162
|
+
save_runtime_state_with_receiver_authority(
|
|
3163
|
+
&ws,
|
|
3164
|
+
&tombstone_incoming(&ws, 1),
|
|
3165
|
+
"fresh",
|
|
3166
|
+
Some(&seed),
|
|
3167
|
+
)
|
|
3168
|
+
.unwrap();
|
|
3169
|
+
let persisted = load_runtime_state(&ws).unwrap();
|
|
3170
|
+
assert!(
|
|
3171
|
+
persisted
|
|
3172
|
+
.pointer("/teams/fresh/team_owner")
|
|
3173
|
+
.is_some_and(Value::is_null),
|
|
3174
|
+
"exact seed must be cleared; got {}",
|
|
3175
|
+
persisted["teams"]["fresh"]["team_owner"]
|
|
3176
|
+
);
|
|
3177
|
+
assert!(
|
|
3178
|
+
persisted
|
|
3179
|
+
.pointer("/teams/fresh/leader_receiver")
|
|
3180
|
+
.is_some_and(Value::is_null)
|
|
3181
|
+
);
|
|
3182
|
+
assert_eq!(
|
|
3183
|
+
persisted["teams"]["fresh"]["owner_epoch"],
|
|
3184
|
+
json!(1),
|
|
3185
|
+
"cleanup must not bump epoch past a same-epoch concurrent owner"
|
|
3186
|
+
);
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
#[test]
|
|
3190
|
+
fn lock_held_exact_seed_cleanup_preserves_equal_epoch_concurrent_binding() {
|
|
3191
|
+
let ws = temp_ws();
|
|
3192
|
+
save_runtime_state(
|
|
3193
|
+
&ws,
|
|
3194
|
+
&team_state_with_binding(&ws, seed_owner(1), seed_receiver(1), 1),
|
|
3195
|
+
)
|
|
3196
|
+
.unwrap();
|
|
3197
|
+
let seed = persisted_fresh_owner(&ws);
|
|
3198
|
+
save_runtime_state_with_receiver_authority(
|
|
3199
|
+
&ws,
|
|
3200
|
+
&team_state_with_binding(&ws, takeover_owner(1), takeover_receiver(1), 1),
|
|
3201
|
+
"fresh",
|
|
3202
|
+
None,
|
|
3203
|
+
)
|
|
3204
|
+
.unwrap();
|
|
3205
|
+
let expected = load_runtime_state(&ws).unwrap();
|
|
3206
|
+
assert_fresh_binding(&expected, &takeover_owner(1), &takeover_receiver(1), 1);
|
|
3207
|
+
save_runtime_state_with_receiver_authority(
|
|
3208
|
+
&ws,
|
|
3209
|
+
&tombstone_incoming(&ws, 1),
|
|
3210
|
+
"fresh",
|
|
3211
|
+
Some(&seed),
|
|
3212
|
+
)
|
|
3213
|
+
.unwrap();
|
|
3214
|
+
assert_fresh_binding(
|
|
3215
|
+
&load_runtime_state(&ws).unwrap(),
|
|
3216
|
+
&expected["teams"]["fresh"]["team_owner"],
|
|
3217
|
+
&expected["teams"]["fresh"]["leader_receiver"],
|
|
3218
|
+
1,
|
|
3219
|
+
);
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
#[test]
|
|
3223
|
+
fn lock_held_exact_seed_cleanup_preserves_epoch2_takeover_binding() {
|
|
3224
|
+
let ws = temp_ws();
|
|
3225
|
+
save_runtime_state(
|
|
3226
|
+
&ws,
|
|
3227
|
+
&team_state_with_binding(&ws, seed_owner(1), seed_receiver(1), 1),
|
|
3228
|
+
)
|
|
3229
|
+
.unwrap();
|
|
3230
|
+
let seed = persisted_fresh_owner(&ws);
|
|
3231
|
+
save_runtime_state(
|
|
3232
|
+
&ws,
|
|
3233
|
+
&team_state_with_binding(&ws, takeover_owner(2), takeover_receiver(2), 2),
|
|
3234
|
+
)
|
|
3235
|
+
.unwrap();
|
|
3236
|
+
let expected = load_runtime_state(&ws).unwrap();
|
|
3237
|
+
save_runtime_state_with_receiver_authority(
|
|
3238
|
+
&ws,
|
|
3239
|
+
&tombstone_incoming(&ws, 1),
|
|
3240
|
+
"fresh",
|
|
3241
|
+
Some(&seed),
|
|
3242
|
+
)
|
|
3243
|
+
.unwrap();
|
|
3244
|
+
assert_fresh_binding(
|
|
3245
|
+
&load_runtime_state(&ws).unwrap(),
|
|
3246
|
+
&expected["teams"]["fresh"]["team_owner"],
|
|
3247
|
+
&expected["teams"]["fresh"]["leader_receiver"],
|
|
3248
|
+
2,
|
|
3249
|
+
);
|
|
3250
|
+
}
|
|
2982
3251
|
}
|
|
@@ -206,6 +206,10 @@ pub enum StateWriteIntent<'a> {
|
|
|
206
206
|
ClaimLeader {
|
|
207
207
|
team_key: &'a str,
|
|
208
208
|
},
|
|
209
|
+
ClearExactTeamOwner {
|
|
210
|
+
team_key: &'a str,
|
|
211
|
+
seed: &'a Value,
|
|
212
|
+
},
|
|
209
213
|
LeaderBindingRestoreNonTargetTeams {
|
|
210
214
|
target_team_key: &'a str,
|
|
211
215
|
},
|
|
@@ -383,7 +387,10 @@ fn route_direct(
|
|
|
383
387
|
// scoped preserve-claim-fields variant at :1702 uses the team-tombstoned
|
|
384
388
|
// agents helper.
|
|
385
389
|
StateWriteIntent::ClaimLeader { team_key } => {
|
|
386
|
-
helper_write_root_with_receiver_authority(workspace, state, team_key)
|
|
390
|
+
helper_write_root_with_receiver_authority(workspace, state, team_key, None)
|
|
391
|
+
}
|
|
392
|
+
StateWriteIntent::ClearExactTeamOwner { team_key, seed } => {
|
|
393
|
+
helper_write_root_with_receiver_authority(workspace, state, team_key, Some(seed))
|
|
387
394
|
}
|
|
388
395
|
StateWriteIntent::LeaderBindingRestoreNonTargetTeams { .. } => {
|
|
389
396
|
helper_write_root_without_migrations(workspace, state)
|
|
@@ -391,7 +398,7 @@ fn route_direct(
|
|
|
391
398
|
// LeaderStartBinding -> managed/exec/external all root-save at
|
|
392
399
|
// leader/start.rs:795/903/946.
|
|
393
400
|
StateWriteIntent::LeaderStartBinding { team_key, .. } => {
|
|
394
|
-
helper_write_root_with_receiver_authority(workspace, state, team_key)
|
|
401
|
+
helper_write_root_with_receiver_authority(workspace, state, team_key, None)
|
|
395
402
|
}
|
|
396
403
|
// CoordinatorTick dispatches to the existing scoped helper
|
|
397
404
|
// (`save_team_scoped_state`) preserving coordinator/tick.rs:427.
|
|
@@ -1993,6 +1993,70 @@ fn set_pane_binding_nonce_is_socket_scoped_and_pane_local() {
|
|
|
1993
1993
|
);
|
|
1994
1994
|
}
|
|
1995
1995
|
|
|
1996
|
+
#[test]
|
|
1997
|
+
fn set_pane_binding_nonce_if_unset_reads_existing_winner() {
|
|
1998
|
+
let rec = Arc::new(Mutex::new(Vec::new()));
|
|
1999
|
+
let runner = MockCommandRunner {
|
|
2000
|
+
recorded: Arc::clone(&rec),
|
|
2001
|
+
stdin_recorded: Arc::new(Mutex::new(Vec::new())),
|
|
2002
|
+
queue: Mutex::new(
|
|
2003
|
+
vec![
|
|
2004
|
+
MockResp::Out(fail(1, "already set: @team_agent_pane_binding_nonce")),
|
|
2005
|
+
MockResp::Out(ok("winner-nonce\n")),
|
|
2006
|
+
]
|
|
2007
|
+
.into_iter()
|
|
2008
|
+
.collect(),
|
|
2009
|
+
),
|
|
2010
|
+
default: MockResp::Out(ok("")),
|
|
2011
|
+
};
|
|
2012
|
+
let be =
|
|
2013
|
+
TmuxBackend::with_runner_for_tmux_endpoint(Box::new(runner), "/tmp/ta-pane-binding.sock");
|
|
2014
|
+
assert_eq!(
|
|
2015
|
+
be.set_pane_binding_nonce_if_unset(&PaneId::new("%7"), "loser-nonce")
|
|
2016
|
+
.expect("read the conditional-write winner"),
|
|
2017
|
+
"winner-nonce"
|
|
2018
|
+
);
|
|
2019
|
+
assert_eq!(
|
|
2020
|
+
rec.lock().unwrap().as_slice(),
|
|
2021
|
+
vec![
|
|
2022
|
+
svec(&[
|
|
2023
|
+
"tmux",
|
|
2024
|
+
"-S",
|
|
2025
|
+
"/tmp/ta-pane-binding.sock",
|
|
2026
|
+
"set-option",
|
|
2027
|
+
"-p",
|
|
2028
|
+
"-o",
|
|
2029
|
+
"-t",
|
|
2030
|
+
"%7",
|
|
2031
|
+
"@team_agent_pane_binding_nonce",
|
|
2032
|
+
"loser-nonce",
|
|
2033
|
+
]),
|
|
2034
|
+
svec(&[
|
|
2035
|
+
"tmux",
|
|
2036
|
+
"-S",
|
|
2037
|
+
"/tmp/ta-pane-binding.sock",
|
|
2038
|
+
"show-options",
|
|
2039
|
+
"-p",
|
|
2040
|
+
"-v",
|
|
2041
|
+
"-t",
|
|
2042
|
+
"%7",
|
|
2043
|
+
"@team_agent_pane_binding_nonce",
|
|
2044
|
+
]),
|
|
2045
|
+
]
|
|
2046
|
+
);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
#[test]
|
|
2050
|
+
fn set_pane_binding_nonce_if_unset_rejects_write_or_read_failure() {
|
|
2051
|
+
let (be, _rec) = backend_with(
|
|
2052
|
+
MockResp::Out(fail(1, "already set: @team_agent_pane_binding_nonce")),
|
|
2053
|
+
vec![MockResp::Out(fail(1, "pane disappeared"))],
|
|
2054
|
+
);
|
|
2055
|
+
assert!(be
|
|
2056
|
+
.set_pane_binding_nonce_if_unset(&PaneId::new("%7"), "nonce-7")
|
|
2057
|
+
.is_err());
|
|
2058
|
+
}
|
|
2059
|
+
|
|
1996
2060
|
// ── 12. attach_session (TRANSPORT TRIO) — `tmux attach-session -t <s>` -> Attached ──────────────
|
|
1997
2061
|
// Golden tmux attach is `tmux attach-session -t <session>`; a successful attach -> AttachOutcome::
|
|
1998
2062
|
// Attached. RED today: attach_session is unimplemented!() -> PANIC. The in-process lock asserts the
|
|
@@ -378,6 +378,63 @@ impl TmuxBackend {
|
|
|
378
378
|
self.run_ok(&argv)
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
/// Set the pane-instance nonce only when the pane option is unset. tmux
|
|
382
|
+
/// serializes commands at the server, so concurrent first claims have one
|
|
383
|
+
/// winner; losers read back that winner instead of persisting a stale nonce.
|
|
384
|
+
pub(crate) fn set_pane_binding_nonce_if_unset(
|
|
385
|
+
&self,
|
|
386
|
+
pane: &PaneId,
|
|
387
|
+
nonce: &str,
|
|
388
|
+
) -> Result<String, TransportError> {
|
|
389
|
+
let argv = self.tmux_argv(&[
|
|
390
|
+
"tmux".to_string(),
|
|
391
|
+
"set-option".to_string(),
|
|
392
|
+
"-p".to_string(),
|
|
393
|
+
"-o".to_string(),
|
|
394
|
+
"-t".to_string(),
|
|
395
|
+
pane.as_str().to_string(),
|
|
396
|
+
TMUX_PANE_BINDING_NONCE_OPTION.to_string(),
|
|
397
|
+
nonce.to_string(),
|
|
398
|
+
]);
|
|
399
|
+
let output = self.runner.run(&argv)?;
|
|
400
|
+
if output.success {
|
|
401
|
+
return Ok(nonce.to_string());
|
|
402
|
+
}
|
|
403
|
+
if output
|
|
404
|
+
.stderr
|
|
405
|
+
.to_ascii_lowercase()
|
|
406
|
+
.contains("already set")
|
|
407
|
+
{
|
|
408
|
+
return self.read_pane_binding_nonce(pane);
|
|
409
|
+
}
|
|
410
|
+
Err(subprocess_error(argv, output))
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
fn read_pane_binding_nonce(&self, pane: &PaneId) -> Result<String, TransportError> {
|
|
414
|
+
let argv = self.tmux_argv(&[
|
|
415
|
+
"tmux".to_string(),
|
|
416
|
+
"show-options".to_string(),
|
|
417
|
+
"-p".to_string(),
|
|
418
|
+
"-v".to_string(),
|
|
419
|
+
"-t".to_string(),
|
|
420
|
+
pane.as_str().to_string(),
|
|
421
|
+
TMUX_PANE_BINDING_NONCE_OPTION.to_string(),
|
|
422
|
+
]);
|
|
423
|
+
let output = self.runner.run(&argv)?;
|
|
424
|
+
if !output.success {
|
|
425
|
+
return Err(subprocess_error(argv, output));
|
|
426
|
+
}
|
|
427
|
+
let nonce = output.stdout.trim();
|
|
428
|
+
if nonce.is_empty() {
|
|
429
|
+
return Err(TransportError::Subprocess {
|
|
430
|
+
argv,
|
|
431
|
+
code: output.code,
|
|
432
|
+
stderr: "pane binding nonce remained unset after conditional write".to_string(),
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
Ok(nonce.to_string())
|
|
436
|
+
}
|
|
437
|
+
|
|
381
438
|
/// Backend with an injected runner (tests: canned/recording tmux output). Shared default socket.
|
|
382
439
|
/// ---
|
|
383
440
|
/// purpose: 用注入的 runner 构造后端(测试用录制/罐装 tmux 输出),绑共享默认 socket
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.74",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.5.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.5.74",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.74",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.74"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: team-agent
|
|
3
3
|
description: Use only when the user explicitly asks to start, operate, inspect, shutdown, or restart a Team Agent team. Treat the team-agent CLI as a sealed appliance.
|
|
4
4
|
requires_team_agent: ">=0.5.0"
|
|
5
|
-
last_verified_against: "0.5.
|
|
5
|
+
last_verified_against: "0.5.73"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Team Agent
|
|
@@ -27,10 +27,13 @@ For a Pi worker, use this existing three-step flow:
|
|
|
27
27
|
|
|
28
28
|
Pi roles preserve the direct Pi login, model defaults, extensions/plugins, skills, context, trust, and tools. Team Agent adds only per-seat identity/session isolation. `model` and `effort` are optional explicit pass-throughs. Use the shared role schema, a block-style `tools` list containing `mcp_team`, and the required boolean `dangerously_skip_permissions`.
|
|
29
29
|
|
|
30
|
+
Pi tool contract: use only `mcp_team`, `fs_read`, `fs_list`, `fs_write`, and `execute_bash`. Do not copy `provider_builtin`, `git_diff`, or `network` from a generic Codex role; Pi cannot map those categories. `compile`/`validate` rejects unsupported Pi categories before quick-start persists runtime state.
|
|
31
|
+
|
|
30
32
|
## Operate
|
|
31
33
|
|
|
32
34
|
- Send: `team-agent send TO MESSAGE` using an in-team short name or fully qualified `<workspace>::<team>/<agent>`; these are co-equal positional TO forms. For example, `team-agent send reviewer "..."` uses an in-team short name. Do not poll after success.
|
|
33
35
|
- Inspect: `team-agent status` or `team-agent status --json`; `ok: true` with `ready: false` is not a crash.
|
|
36
|
+
- `quick-start` / `restart` / `claim-leader` JSON is compact by default (`ok`, `status`/`reason`, next action, attach/send, and `readiness.all_workers_spawned`); pass `--detail` only for internal diagnostics.
|
|
34
37
|
- On a structured failure with an `action`, run that action first, then stop. Never guess flags.
|
|
35
38
|
- Never rewrite a user-supplied model id or read `.env` files.
|
|
36
39
|
|
|
@@ -67,6 +67,13 @@
|
|
|
67
67
|
"owner": "runtime-owner:leader-channel",
|
|
68
68
|
"plan": "Execute in a hermetic leader fixture and bind assertions to owner/receiver persistence or a precise zero-write refusal."
|
|
69
69
|
},
|
|
70
|
+
{
|
|
71
|
+
"command": "team-agent claim-leader --detail",
|
|
72
|
+
"bucket": "declared_gap",
|
|
73
|
+
"covered": false,
|
|
74
|
+
"owner": "runtime-owner:leader-channel",
|
|
75
|
+
"plan": "Execute the exact argv and bind assertions to detail-only topology_convergence, owner_epoch, and checked_paths fields."
|
|
76
|
+
},
|
|
70
77
|
{
|
|
71
78
|
"command": "team-agent claude",
|
|
72
79
|
"bucket": "declared_gap",
|
|
@@ -233,6 +240,13 @@
|
|
|
233
240
|
}
|
|
234
241
|
}
|
|
235
242
|
},
|
|
243
|
+
{
|
|
244
|
+
"command": "team-agent quick-start --json --detail",
|
|
245
|
+
"bucket": "declared_gap",
|
|
246
|
+
"covered": false,
|
|
247
|
+
"owner": "runtime-owner:lifecycle",
|
|
248
|
+
"plan": "Execute the exact argv and bind assertions to detail-only receiver, topology, and coordinator diagnostics while keeping compact default JSON."
|
|
249
|
+
},
|
|
236
250
|
{
|
|
237
251
|
"command": "team-agent quick-start <dir>",
|
|
238
252
|
"bucket": "covered",
|
|
@@ -308,6 +322,13 @@
|
|
|
308
322
|
"owner": "runtime-owner:restart",
|
|
309
323
|
"plan": "Execute a representative team selector and bind assertions to selected-team restart and sibling preservation."
|
|
310
324
|
},
|
|
325
|
+
{
|
|
326
|
+
"command": "team-agent restart --json --detail",
|
|
327
|
+
"bucket": "declared_gap",
|
|
328
|
+
"covered": false,
|
|
329
|
+
"owner": "runtime-owner:restart",
|
|
330
|
+
"plan": "Execute the exact argv and bind assertions to detail-only coordinator diagnostics while keeping compact default JSON."
|
|
331
|
+
},
|
|
311
332
|
{
|
|
312
333
|
"command": "team-agent send --task task_initial \"Start\"",
|
|
313
334
|
"bucket": "declared_gap",
|