@team-agent/installer 0.4.11 → 0.5.1
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 +1 -0
- package/crates/team-agent/src/cli/diagnose.rs +2 -15
- package/crates/team-agent/src/cli/emit.rs +98 -5
- package/crates/team-agent/src/cli/mod.rs +2 -0
- package/crates/team-agent/src/cli/named_address.rs +864 -0
- package/crates/team-agent/src/cli/send.rs +104 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +455 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +4 -0
- package/crates/team-agent/src/compiler.rs +11 -20
- package/crates/team-agent/src/coordinator/orphan.rs +2 -2
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
- package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
- package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
- package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
- package/crates/team-agent/src/coordinator/tick.rs +31 -932
- package/crates/team-agent/src/diagnose/orphans.rs +66 -8
- package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
- package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
- package/crates/team-agent/src/leader/rediscover.rs +1 -11
- package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
- package/crates/team-agent/src/lifecycle/launch.rs +135 -58
- package/crates/team-agent/src/lifecycle/lock.rs +302 -0
- package/crates/team-agent/src/lifecycle/mod.rs +1 -0
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
- package/crates/team-agent/src/lifecycle/restart/agent.rs +169 -113
- package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
- package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
- package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +6 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +35 -5
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +675 -0
- package/crates/team-agent/src/lifecycle/tests.rs +3 -0
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
- package/crates/team-agent/src/mcp_server/tools.rs +157 -68
- package/crates/team-agent/src/messaging/activity.rs +43 -18
- package/crates/team-agent/src/messaging/delivery.rs +161 -97
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
- package/crates/team-agent/src/messaging/results.rs +34 -9
- package/crates/team-agent/src/messaging/scheduler.rs +52 -9
- package/crates/team-agent/src/model/spec.rs +10 -6
- package/crates/team-agent/src/provider/adapter.rs +10 -1038
- package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
- package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
- package/crates/team-agent/src/provider/classify.rs +23 -48
- package/crates/team-agent/src/provider/command.rs +16 -7
- package/crates/team-agent/src/provider/faults.rs +93 -53
- package/crates/team-agent/src/provider/session/capture.rs +4 -15
- package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
- package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
- package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
- package/crates/team-agent/src/provider/session_scan.rs +65 -27
- package/crates/team-agent/src/provider/tests/faults.rs +80 -0
- package/crates/team-agent/src/provider/types.rs +33 -1
- package/crates/team-agent/src/provider/wire.rs +171 -1
- package/crates/team-agent/src/state/identity.rs +242 -57
- package/crates/team-agent/src/state/identity_keys.rs +9 -12
- package/crates/team-agent/src/state/mod.rs +5 -1
- package/crates/team-agent/src/state/owner_gate.rs +59 -15
- package/crates/team-agent/src/state/ownership.rs +12 -11
- package/crates/team-agent/src/state/paths.rs +13 -6
- package/crates/team-agent/src/state/persist.rs +671 -128
- package/crates/team-agent/src/state/projection.rs +240 -49
- package/crates/team-agent/src/state/selector.rs +11 -5
- package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
- package/crates/team-agent/src/tmux_backend.rs +42 -5
- package/crates/team-agent/src/transport/test_support.rs +55 -6
- package/package.json +4 -4
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
use std::collections::{BTreeSet, HashMap};
|
|
24
24
|
use std::io;
|
|
25
25
|
use std::path::{Path, PathBuf};
|
|
26
|
-
use std::sync::{LazyLock, Mutex};
|
|
27
26
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
27
|
+
use std::sync::{LazyLock, Mutex};
|
|
28
28
|
use std::time::{Duration, Instant};
|
|
29
29
|
|
|
30
30
|
use serde_json::{json, Value};
|
|
@@ -45,6 +45,25 @@ const SESSION_STATE_FIELDS: [&str; 6] = [
|
|
|
45
45
|
"attribution_confidence",
|
|
46
46
|
"spawn_cwd",
|
|
47
47
|
];
|
|
48
|
+
const LIVE_TOPOLOGY_FIELDS: [&str; 5] =
|
|
49
|
+
["pane_id", "pane_pid", "window", "spawned_at", "spawn_epoch"];
|
|
50
|
+
const ROSTER_STUB_ALLOWLIST: [&str; 15] = [
|
|
51
|
+
"agent_id",
|
|
52
|
+
"provider",
|
|
53
|
+
"auth_mode",
|
|
54
|
+
"role",
|
|
55
|
+
"model",
|
|
56
|
+
"model_source",
|
|
57
|
+
"profile",
|
|
58
|
+
"_profile_dir",
|
|
59
|
+
"dynamic_role_file",
|
|
60
|
+
"effort",
|
|
61
|
+
"forked_from",
|
|
62
|
+
"managed_mcp_config",
|
|
63
|
+
"claude_config_dir",
|
|
64
|
+
"claude_projects_root",
|
|
65
|
+
"profile_launch",
|
|
66
|
+
];
|
|
48
67
|
|
|
49
68
|
/// `state.py:_RUNTIME_STATE_CACHE`:进程级 path→state 缓存(deep-equal 早返回)。
|
|
50
69
|
static RUNTIME_STATE_CACHE: LazyLock<Mutex<HashMap<PathBuf, Value>>> =
|
|
@@ -57,7 +76,9 @@ pub fn runtime_state_path(workspace: &Path) -> PathBuf {
|
|
|
57
76
|
}
|
|
58
77
|
|
|
59
78
|
fn cache_equals(path: &Path, state: &Value) -> bool {
|
|
60
|
-
RUNTIME_STATE_CACHE
|
|
79
|
+
RUNTIME_STATE_CACHE
|
|
80
|
+
.lock()
|
|
81
|
+
.is_ok_and(|c| c.get(path) == Some(state))
|
|
61
82
|
}
|
|
62
83
|
fn cache_set(path: &Path, state: &Value) {
|
|
63
84
|
if let Ok(mut c) = RUNTIME_STATE_CACHE.lock() {
|
|
@@ -66,11 +87,16 @@ fn cache_set(path: &Path, state: &Value) {
|
|
|
66
87
|
}
|
|
67
88
|
/// `_RUNTIME_STATE_CACHE.get(...)` → `copy.deepcopy(cached)`(clone = deepcopy)。
|
|
68
89
|
fn cache_get(path: &Path) -> Option<Value> {
|
|
69
|
-
RUNTIME_STATE_CACHE
|
|
90
|
+
RUNTIME_STATE_CACHE
|
|
91
|
+
.lock()
|
|
92
|
+
.ok()
|
|
93
|
+
.and_then(|c| c.get(path).cloned())
|
|
70
94
|
}
|
|
71
95
|
|
|
72
96
|
fn unique_tmp(path: &Path, suffix: &str) -> PathBuf {
|
|
73
|
-
let name = path
|
|
97
|
+
let name = path
|
|
98
|
+
.file_name()
|
|
99
|
+
.map_or_else(String::new, |n| n.to_string_lossy().into_owned());
|
|
74
100
|
let seq = TMP_SEQ.fetch_add(1, Ordering::Relaxed);
|
|
75
101
|
path.with_file_name(format!("{name}.{}.{seq}.{suffix}", std::process::id()))
|
|
76
102
|
}
|
|
@@ -132,7 +158,11 @@ impl RuntimeLock {
|
|
|
132
158
|
if let Some(parent) = lock_path.parent() {
|
|
133
159
|
std::fs::create_dir_all(parent)?;
|
|
134
160
|
}
|
|
135
|
-
let file = std::fs::OpenOptions::new()
|
|
161
|
+
let file = std::fs::OpenOptions::new()
|
|
162
|
+
.create(true)
|
|
163
|
+
.write(true)
|
|
164
|
+
.truncate(false)
|
|
165
|
+
.open(&lock_path)?;
|
|
136
166
|
#[cfg(unix)]
|
|
137
167
|
{
|
|
138
168
|
use std::os::unix::io::AsRawFd;
|
|
@@ -153,7 +183,9 @@ impl RuntimeLock {
|
|
|
153
183
|
#[cfg(not(unix))]
|
|
154
184
|
{
|
|
155
185
|
let _ = timeout;
|
|
156
|
-
Err(StateError::Locked(format!(
|
|
186
|
+
Err(StateError::Locked(format!(
|
|
187
|
+
"{name} (runtime lock not yet implemented on non-unix)"
|
|
188
|
+
)))
|
|
157
189
|
}
|
|
158
190
|
}
|
|
159
191
|
}
|
|
@@ -170,7 +202,34 @@ impl Drop for RuntimeLock {
|
|
|
170
202
|
/// `save_runtime_state`(bug-084)。`state` 是 state.json 的内存 Value(插入序保留)。
|
|
171
203
|
/// 注:Python 在此还调 `_migrate_state_identity`(identity slice 落地后接入;本 slice 不改 state 内容)。
|
|
172
204
|
pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateError> {
|
|
173
|
-
|
|
205
|
+
save_runtime_state_with_merge_options(workspace, state, &[], None, &[], &[])
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
pub(crate) fn save_runtime_state_reapplying_after_conflict<F>(
|
|
209
|
+
workspace: &Path,
|
|
210
|
+
state: &Value,
|
|
211
|
+
reapply: F,
|
|
212
|
+
) -> Result<(), StateError>
|
|
213
|
+
where
|
|
214
|
+
F: FnOnce(&mut Value),
|
|
215
|
+
{
|
|
216
|
+
match save_runtime_state(workspace, state) {
|
|
217
|
+
Ok(()) => Ok(()),
|
|
218
|
+
Err(StateError::SaveConflict(_)) => {
|
|
219
|
+
let mut latest = load_runtime_state(workspace)?;
|
|
220
|
+
reapply(&mut latest);
|
|
221
|
+
save_runtime_state(workspace, &latest)
|
|
222
|
+
}
|
|
223
|
+
Err(error) => Err(error),
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
pub(crate) fn save_runtime_state_with_lifecycle_topology_authority(
|
|
228
|
+
workspace: &Path,
|
|
229
|
+
state: &Value,
|
|
230
|
+
agent_ids: &[&str],
|
|
231
|
+
) -> Result<(), StateError> {
|
|
232
|
+
save_runtime_state_with_merge_options(workspace, state, &[], None, &[], agent_ids)
|
|
174
233
|
}
|
|
175
234
|
|
|
176
235
|
pub(crate) fn save_runtime_state_with_deleted_agents(
|
|
@@ -178,7 +237,7 @@ pub(crate) fn save_runtime_state_with_deleted_agents(
|
|
|
178
237
|
state: &Value,
|
|
179
238
|
deleted_agent_ids: &[&str],
|
|
180
239
|
) -> Result<(), StateError> {
|
|
181
|
-
|
|
240
|
+
save_runtime_state_with_merge_options(workspace, state, deleted_agent_ids, None, &[], &[])
|
|
182
241
|
}
|
|
183
242
|
|
|
184
243
|
pub(crate) fn save_runtime_state_with_team_tombstoned_agents(
|
|
@@ -187,21 +246,39 @@ pub(crate) fn save_runtime_state_with_team_tombstoned_agents(
|
|
|
187
246
|
tombstoned_team_key: &str,
|
|
188
247
|
tombstoned_agent_ids: &[&str],
|
|
189
248
|
) -> Result<(), StateError> {
|
|
190
|
-
|
|
249
|
+
save_runtime_state_with_merge_options(
|
|
191
250
|
workspace,
|
|
192
251
|
state,
|
|
193
252
|
&[],
|
|
194
253
|
Some(tombstoned_team_key),
|
|
195
254
|
tombstoned_agent_ids,
|
|
255
|
+
&[],
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
pub(crate) fn save_runtime_state_with_team_tombstone_lifecycle_topology_authority(
|
|
260
|
+
workspace: &Path,
|
|
261
|
+
state: &Value,
|
|
262
|
+
tombstoned_team_key: &str,
|
|
263
|
+
agent_ids: &[&str],
|
|
264
|
+
) -> Result<(), StateError> {
|
|
265
|
+
save_runtime_state_with_merge_options(
|
|
266
|
+
workspace,
|
|
267
|
+
state,
|
|
268
|
+
&[],
|
|
269
|
+
Some(tombstoned_team_key),
|
|
270
|
+
agent_ids,
|
|
271
|
+
agent_ids,
|
|
196
272
|
)
|
|
197
273
|
}
|
|
198
274
|
|
|
199
|
-
fn
|
|
275
|
+
fn save_runtime_state_with_merge_options(
|
|
200
276
|
workspace: &Path,
|
|
201
277
|
state: &Value,
|
|
202
278
|
deleted_agent_ids: &[&str],
|
|
203
279
|
skip_capture_backfill_team_key: Option<&str>,
|
|
204
280
|
skip_capture_backfill_agent_ids: &[&str],
|
|
281
|
+
topology_update_agent_ids: &[&str],
|
|
205
282
|
) -> Result<(), StateError> {
|
|
206
283
|
let path = runtime_state_path(workspace);
|
|
207
284
|
// Python `state.py:497`:先对入参 state 跑 `_migrate_state_identity`(就地填缺失 leader uuid)。
|
|
@@ -265,13 +342,20 @@ fn save_runtime_state_with_merge_exceptions(
|
|
|
265
342
|
.filter(|id| !id.is_empty())
|
|
266
343
|
.map(str::to_string)
|
|
267
344
|
.collect::<BTreeSet<_>>();
|
|
268
|
-
|
|
345
|
+
let topology_updates = topology_update_agent_ids
|
|
346
|
+
.iter()
|
|
347
|
+
.copied()
|
|
348
|
+
.filter(|id| !id.is_empty())
|
|
349
|
+
.map(str::to_string)
|
|
350
|
+
.collect::<BTreeSet<_>>();
|
|
351
|
+
apply_persist_merge_contract(
|
|
269
352
|
&mut migrated,
|
|
270
353
|
&latest,
|
|
271
354
|
&deleted,
|
|
272
355
|
skip_capture_backfill_team_key,
|
|
273
356
|
&skip_capture_backfill,
|
|
274
|
-
|
|
357
|
+
&topology_updates,
|
|
358
|
+
)?;
|
|
275
359
|
}
|
|
276
360
|
// Stage 3 save-output strip second pass (defence-in-depth): after the
|
|
277
361
|
// `preserve_latest_roster_entries` lock-held merge, a future addition
|
|
@@ -316,7 +400,9 @@ fn save_runtime_state_with_merge_exceptions(
|
|
|
316
400
|
}
|
|
317
401
|
}
|
|
318
402
|
}
|
|
319
|
-
Err(StateError::SaveFailed(
|
|
403
|
+
Err(StateError::SaveFailed(
|
|
404
|
+
"retry loop exhausted without return".to_string(),
|
|
405
|
+
))
|
|
320
406
|
}
|
|
321
407
|
|
|
322
408
|
fn read_latest_state_under_lock(workspace: &Path, path: &Path) -> Option<Value> {
|
|
@@ -328,31 +414,33 @@ fn read_latest_state_under_lock(workspace: &Path, path: &Path) -> Option<Value>
|
|
|
328
414
|
Some(latest)
|
|
329
415
|
}
|
|
330
416
|
|
|
331
|
-
fn
|
|
417
|
+
fn apply_persist_merge_contract(
|
|
332
418
|
incoming: &mut Value,
|
|
333
419
|
latest: &Value,
|
|
334
420
|
deleted_agent_ids: &BTreeSet<String>,
|
|
335
421
|
skip_capture_backfill_team_key: Option<&str>,
|
|
336
422
|
skip_capture_backfill_agent_ids: &BTreeSet<String>,
|
|
337
|
-
|
|
423
|
+
topology_update_agent_ids: &BTreeSet<String>,
|
|
424
|
+
) -> Result<(), StateError> {
|
|
338
425
|
// A0/R1: the projection gate only guards the TOP-LEVEL passes (top-level agents and
|
|
339
426
|
// the top-level<->active-team cross projections depend on which team is active); the
|
|
340
427
|
// per-team `teams.<k>.agents` merge below is team-key self-identifying and must run
|
|
341
428
|
// even when another process flipped session_name/active_team_key between this
|
|
342
429
|
// writer's load and save.
|
|
343
430
|
let projection_matches = same_runtime_projection(incoming, latest);
|
|
344
|
-
let active_team = active_team_key(incoming).or_else(|| active_team_key(latest));
|
|
345
431
|
let top_level_team = Some(team_state_key(incoming)).or_else(|| Some(team_state_key(latest)));
|
|
346
432
|
let skip_top_level_capture_backfill =
|
|
347
433
|
should_skip_capture_backfill(top_level_team.as_deref(), skip_capture_backfill_team_key);
|
|
348
434
|
if projection_matches {
|
|
349
|
-
|
|
435
|
+
merge_agent_projection(
|
|
436
|
+
"agents",
|
|
350
437
|
incoming.get_mut("agents"),
|
|
351
438
|
latest.get("agents"),
|
|
352
439
|
deleted_agent_ids,
|
|
353
440
|
skip_top_level_capture_backfill,
|
|
354
441
|
skip_capture_backfill_agent_ids,
|
|
355
|
-
|
|
442
|
+
topology_update_agent_ids,
|
|
443
|
+
)?;
|
|
356
444
|
// Stage 3c (identity-boundary unified plan, architect direction
|
|
357
445
|
// 2026-06-23): top-level owner copy-back removed. Pre-3c this
|
|
358
446
|
// copied legacy `state.{team_owner, leader_receiver, owner_epoch}`
|
|
@@ -365,67 +453,35 @@ fn preserve_latest_roster_entries(
|
|
|
365
453
|
// them.
|
|
366
454
|
}
|
|
367
455
|
|
|
368
|
-
if projection_matches {
|
|
369
|
-
if let Some(active_team) = active_team.as_deref() {
|
|
370
|
-
let latest_active_agents = latest
|
|
371
|
-
.get("teams")
|
|
372
|
-
.and_then(Value::as_object)
|
|
373
|
-
.and_then(|teams| teams.get(active_team))
|
|
374
|
-
.and_then(|entry| entry.get("agents"));
|
|
375
|
-
preserve_missing_agents(
|
|
376
|
-
incoming.get_mut("agents"),
|
|
377
|
-
latest_active_agents,
|
|
378
|
-
deleted_agent_ids,
|
|
379
|
-
skip_top_level_capture_backfill,
|
|
380
|
-
skip_capture_backfill_agent_ids,
|
|
381
|
-
);
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
456
|
let latest_teams = latest.get("teams").and_then(Value::as_object);
|
|
386
457
|
let Some(incoming_teams) = incoming.get_mut("teams").and_then(Value::as_object_mut) else {
|
|
387
|
-
return;
|
|
458
|
+
return Ok(());
|
|
388
459
|
};
|
|
389
460
|
if let Some(latest_teams) = latest_teams {
|
|
390
461
|
for (team, latest_entry) in latest_teams {
|
|
391
462
|
let Some(incoming_entry) = incoming_teams.get_mut(team) else {
|
|
392
463
|
continue;
|
|
393
464
|
};
|
|
394
|
-
|
|
465
|
+
let projection = format!("teams.{team}.agents");
|
|
466
|
+
merge_agent_projection(
|
|
467
|
+
&projection,
|
|
395
468
|
incoming_entry.get_mut("agents"),
|
|
396
469
|
latest_entry.get("agents"),
|
|
397
470
|
deleted_agent_ids,
|
|
398
471
|
should_skip_capture_backfill(Some(team), skip_capture_backfill_team_key),
|
|
399
472
|
skip_capture_backfill_agent_ids,
|
|
400
|
-
|
|
473
|
+
topology_update_agent_ids,
|
|
474
|
+
)?;
|
|
401
475
|
preserve_latest_ownership_fields(incoming_entry, latest_entry);
|
|
402
476
|
}
|
|
403
477
|
}
|
|
404
|
-
|
|
405
|
-
if let Some(active_team) = active_team.as_deref() {
|
|
406
|
-
let latest_top_agents = latest.get("agents");
|
|
407
|
-
if let Some(incoming_entry) = incoming_teams.get_mut(active_team) {
|
|
408
|
-
preserve_missing_agents(
|
|
409
|
-
incoming_entry.get_mut("agents"),
|
|
410
|
-
latest_top_agents,
|
|
411
|
-
deleted_agent_ids,
|
|
412
|
-
should_skip_capture_backfill(Some(active_team), skip_capture_backfill_team_key),
|
|
413
|
-
skip_capture_backfill_agent_ids,
|
|
414
|
-
);
|
|
415
|
-
// Stage 3c (identity-boundary unified plan, architect direction
|
|
416
|
-
// 2026-06-23): top-level → teams.<active> owner cross-promote
|
|
417
|
-
// removed. This was the persist-side mirror of the projection
|
|
418
|
-
// copy-back (3b) and a quiet migration path that let stale
|
|
419
|
-
// top-level owner re-seed teams.<active>. With write_owner
|
|
420
|
-
// (3a) producing both top-level and teams.<key> directly,
|
|
421
|
-
// this auto-promotion is redundant and reintroduces the dual
|
|
422
|
-
// source the unified plan eliminates.
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
}
|
|
478
|
+
Ok(())
|
|
426
479
|
}
|
|
427
480
|
|
|
428
|
-
fn should_skip_capture_backfill(
|
|
481
|
+
fn should_skip_capture_backfill(
|
|
482
|
+
current_team_key: Option<&str>,
|
|
483
|
+
skip_team_key: Option<&str>,
|
|
484
|
+
) -> bool {
|
|
429
485
|
match skip_team_key {
|
|
430
486
|
Some(skip_team_key) => current_team_key == Some(skip_team_key),
|
|
431
487
|
None => true,
|
|
@@ -452,9 +508,7 @@ fn latest_has_preferable_ownership(incoming: &Value, latest: &Value) -> bool {
|
|
|
452
508
|
if latest_epoch > incoming_epoch {
|
|
453
509
|
return true;
|
|
454
510
|
}
|
|
455
|
-
latest_epoch == incoming_epoch
|
|
456
|
-
&& !ownership_attached(incoming)
|
|
457
|
-
&& ownership_attached(latest)
|
|
511
|
+
latest_epoch == incoming_epoch && !ownership_attached(incoming) && ownership_attached(latest)
|
|
458
512
|
}
|
|
459
513
|
|
|
460
514
|
fn ownership_epoch(state: &Value) -> u64 {
|
|
@@ -486,37 +540,103 @@ fn ownership_attached(state: &Value) -> bool {
|
|
|
486
540
|
})
|
|
487
541
|
}
|
|
488
542
|
|
|
489
|
-
fn
|
|
543
|
+
fn merge_agent_projection(
|
|
544
|
+
projection: &str,
|
|
490
545
|
incoming_agents: Option<&mut Value>,
|
|
491
546
|
latest_agents: Option<&Value>,
|
|
492
547
|
deleted_agent_ids: &BTreeSet<String>,
|
|
493
548
|
skip_capture_backfill: bool,
|
|
494
549
|
skip_capture_backfill_agent_ids: &BTreeSet<String>,
|
|
495
|
-
|
|
550
|
+
topology_update_agent_ids: &BTreeSet<String>,
|
|
551
|
+
) -> Result<(), StateError> {
|
|
496
552
|
let Some(incoming_agents) = incoming_agents else {
|
|
497
|
-
return;
|
|
553
|
+
return Ok(());
|
|
498
554
|
};
|
|
499
555
|
let Some(incoming_map) = incoming_agents.as_object_mut() else {
|
|
500
|
-
return;
|
|
556
|
+
return Ok(());
|
|
501
557
|
};
|
|
502
558
|
let Some(latest_map) = latest_agents.and_then(Value::as_object) else {
|
|
503
|
-
return;
|
|
559
|
+
return Ok(());
|
|
504
560
|
};
|
|
505
561
|
for (agent_id, latest_agent) in latest_map {
|
|
506
562
|
if deleted_agent_ids.contains(agent_id) {
|
|
507
563
|
continue;
|
|
508
564
|
}
|
|
565
|
+
let tombstoned_for_projection =
|
|
566
|
+
skip_capture_backfill && skip_capture_backfill_agent_ids.contains(agent_id);
|
|
509
567
|
match incoming_map.entry(agent_id.clone()) {
|
|
510
568
|
serde_json::map::Entry::Vacant(slot) => {
|
|
511
|
-
|
|
569
|
+
if topology_update_agent_ids.contains(agent_id) {
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if !tombstoned_for_projection && latest_has_live_topology(latest_agent) {
|
|
573
|
+
return Err(save_conflict(
|
|
574
|
+
projection,
|
|
575
|
+
agent_id,
|
|
576
|
+
live_topology_fields(latest_agent),
|
|
577
|
+
));
|
|
578
|
+
}
|
|
579
|
+
if let Some(stub) = roster_stub(latest_agent) {
|
|
580
|
+
slot.insert(stub);
|
|
581
|
+
}
|
|
512
582
|
}
|
|
513
583
|
serde_json::map::Entry::Occupied(mut existing) => {
|
|
584
|
+
if !tombstoned_for_projection && !topology_update_agent_ids.contains(agent_id) {
|
|
585
|
+
let fields = topology_conflict_fields(existing.get(), latest_agent);
|
|
586
|
+
if !fields.is_empty() {
|
|
587
|
+
return Err(save_conflict(projection, agent_id, fields));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
514
590
|
if !skip_capture_backfill || !skip_capture_backfill_agent_ids.contains(agent_id) {
|
|
515
591
|
backfill_capture_fields(existing.get_mut(), latest_agent);
|
|
516
592
|
}
|
|
517
593
|
}
|
|
518
594
|
}
|
|
519
595
|
}
|
|
596
|
+
Ok(())
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
fn save_conflict(projection: &str, agent_id: &str, fields: Vec<&'static str>) -> StateError {
|
|
600
|
+
StateError::SaveConflict(format!(
|
|
601
|
+
"agent_id={agent_id} projection={projection} conflicting_fields={}",
|
|
602
|
+
fields.join(",")
|
|
603
|
+
))
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
fn latest_has_live_topology(agent: &Value) -> bool {
|
|
607
|
+
LIVE_TOPOLOGY_FIELDS
|
|
608
|
+
.iter()
|
|
609
|
+
.any(|field| agent.get(field).is_some_and(json_truthy))
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
fn live_topology_fields(agent: &Value) -> Vec<&'static str> {
|
|
613
|
+
LIVE_TOPOLOGY_FIELDS
|
|
614
|
+
.iter()
|
|
615
|
+
.copied()
|
|
616
|
+
.filter(|field| agent.get(*field).is_some_and(json_truthy))
|
|
617
|
+
.collect()
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
fn topology_conflict_fields(incoming_agent: &Value, latest_agent: &Value) -> Vec<&'static str> {
|
|
621
|
+
let latest_live = live_topology_fields(latest_agent);
|
|
622
|
+
if latest_live.is_empty() {
|
|
623
|
+
return Vec::new();
|
|
624
|
+
}
|
|
625
|
+
latest_live
|
|
626
|
+
.into_iter()
|
|
627
|
+
.filter(|field| incoming_agent.get(*field) != latest_agent.get(*field))
|
|
628
|
+
.collect()
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
fn roster_stub(latest_agent: &Value) -> Option<Value> {
|
|
632
|
+
let latest = latest_agent.as_object()?;
|
|
633
|
+
let mut stub = serde_json::Map::new();
|
|
634
|
+
for field in ROSTER_STUB_ALLOWLIST {
|
|
635
|
+
if let Some(value) = latest.get(field).filter(|value| !value.is_null()) {
|
|
636
|
+
stub.insert(field.to_string(), value.clone());
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
(!stub.is_empty()).then(|| Value::Object(stub))
|
|
520
640
|
}
|
|
521
641
|
|
|
522
642
|
/// 0.4.6 tuple-atomic backfill (restart-persist-capture-contract-audit.md):
|
|
@@ -544,12 +664,7 @@ fn preserve_missing_agents(
|
|
|
544
664
|
/// 4. `attribution_confidence` rides with the tuple (copied iff tuple
|
|
545
665
|
/// backfill fires).
|
|
546
666
|
fn backfill_capture_fields(incoming_agent: &mut Value, latest_agent: &Value) {
|
|
547
|
-
const TUPLE_FIELDS: [&str; 4] = [
|
|
548
|
-
"session_id",
|
|
549
|
-
"rollout_path",
|
|
550
|
-
"captured_at",
|
|
551
|
-
"captured_via",
|
|
552
|
-
];
|
|
667
|
+
const TUPLE_FIELDS: [&str; 4] = ["session_id", "rollout_path", "captured_at", "captured_via"];
|
|
553
668
|
let Some(incoming_row) = incoming_agent.as_object_mut() else {
|
|
554
669
|
return;
|
|
555
670
|
};
|
|
@@ -633,7 +748,9 @@ fn self_heal(
|
|
|
633
748
|
) -> Result<(), StateError> {
|
|
634
749
|
let event_log = EventLog::new(workspace);
|
|
635
750
|
let heal_tmp = unique_tmp(path, "heal.tmp");
|
|
636
|
-
let name = path
|
|
751
|
+
let name = path
|
|
752
|
+
.file_name()
|
|
753
|
+
.map_or_else(String::new, |n| n.to_string_lossy().into_owned());
|
|
637
754
|
let backup = path.with_file_name(format!("{name}.bak.{}", std::process::id()));
|
|
638
755
|
let mut backup_created = false;
|
|
639
756
|
|
|
@@ -706,11 +823,17 @@ pub fn normalize_agent_session_state(state: &mut Value) {
|
|
|
706
823
|
/// 注:Python `seed if seed in teams or not teams else seed` 两支均为 `seed`(死三元),
|
|
707
824
|
/// 此处直接 `= seed`,与可观测行为一致。
|
|
708
825
|
pub fn migrate_active_team_key(state: &mut Value) -> bool {
|
|
709
|
-
if state
|
|
826
|
+
if state
|
|
827
|
+
.as_object()
|
|
828
|
+
.is_some_and(|o| o.contains_key("active_team_key"))
|
|
829
|
+
{
|
|
710
830
|
return false;
|
|
711
831
|
}
|
|
712
832
|
let teams_is_dict = state.get("teams").is_some_and(Value::is_object);
|
|
713
|
-
let teams_len = state
|
|
833
|
+
let teams_len = state
|
|
834
|
+
.get("teams")
|
|
835
|
+
.and_then(Value::as_object)
|
|
836
|
+
.map_or(0, serde_json::Map::len);
|
|
714
837
|
if state.get("session_name").is_some_and(json_truthy) {
|
|
715
838
|
let seed = team_state_key(state);
|
|
716
839
|
if let Some(o) = state.as_object_mut() {
|
|
@@ -719,7 +842,10 @@ pub fn migrate_active_team_key(state: &mut Value) -> bool {
|
|
|
719
842
|
return true;
|
|
720
843
|
}
|
|
721
844
|
if teams_is_dict && teams_len == 1 {
|
|
722
|
-
let first = state
|
|
845
|
+
let first = state
|
|
846
|
+
.get("teams")
|
|
847
|
+
.and_then(Value::as_object)
|
|
848
|
+
.and_then(|t| t.keys().next().cloned());
|
|
723
849
|
if let (Some(k), Some(o)) = (first, state.as_object_mut()) {
|
|
724
850
|
o.insert("active_team_key".to_string(), Value::String(k));
|
|
725
851
|
}
|
|
@@ -803,7 +929,9 @@ pub fn load_runtime_state(workspace: &Path) -> Result<Value, StateError> {
|
|
|
803
929
|
if let Some(cached) = cache_get(&path) {
|
|
804
930
|
return Ok(cached);
|
|
805
931
|
}
|
|
806
|
-
return Ok(
|
|
932
|
+
return Ok(
|
|
933
|
+
json!({"agents": {}, "tasks": [], "session_name": null, "active_team_key": null}),
|
|
934
|
+
);
|
|
807
935
|
}
|
|
808
936
|
let text = std::fs::read_to_string(&path)?;
|
|
809
937
|
let mut state: Value = serde_json::from_str(&text)?;
|
|
@@ -843,7 +971,10 @@ mod tests {
|
|
|
843
971
|
EventLog::new(ws).tail(50).unwrap()
|
|
844
972
|
}
|
|
845
973
|
fn count_event(ws: &Path, name: &str) -> usize {
|
|
846
|
-
read_events(ws)
|
|
974
|
+
read_events(ws)
|
|
975
|
+
.iter()
|
|
976
|
+
.filter(|e| e["event"] == json!(name))
|
|
977
|
+
.count()
|
|
847
978
|
}
|
|
848
979
|
// per-call-index 故障计划:plan[i] = 第 i 次 atomic_replace 的 errno(0=成功)。
|
|
849
980
|
fn set_fault_plan(plan: &[i32]) {
|
|
@@ -854,18 +985,32 @@ mod tests {
|
|
|
854
985
|
set_fault_plan(&[]);
|
|
855
986
|
}
|
|
856
987
|
fn get_event(ws: &Path, name: &str) -> Value {
|
|
857
|
-
read_events(ws)
|
|
988
|
+
read_events(ws)
|
|
989
|
+
.into_iter()
|
|
990
|
+
.find(|e| e["event"] == json!(name))
|
|
991
|
+
.unwrap_or(Value::Null)
|
|
858
992
|
}
|
|
859
993
|
fn read_state(ws: &Path) -> Value {
|
|
860
994
|
serde_json::from_str(&std::fs::read_to_string(runtime_state_path(ws)).unwrap()).unwrap()
|
|
861
995
|
}
|
|
996
|
+
fn write_state(ws: &Path, state: &Value) {
|
|
997
|
+
std::fs::create_dir_all(runtime_dir(ws)).unwrap();
|
|
998
|
+
std::fs::write(
|
|
999
|
+
runtime_state_path(ws),
|
|
1000
|
+
serde_json::to_string_pretty(state).unwrap(),
|
|
1001
|
+
)
|
|
1002
|
+
.unwrap();
|
|
1003
|
+
}
|
|
862
1004
|
fn bak_files(ws: &Path) -> Vec<PathBuf> {
|
|
863
1005
|
let dir = runtime_dir(ws);
|
|
864
1006
|
std::fs::read_dir(&dir)
|
|
865
1007
|
.map(|rd| {
|
|
866
1008
|
rd.filter_map(std::result::Result::ok)
|
|
867
1009
|
.map(|e| e.path())
|
|
868
|
-
.filter(|p|
|
|
1010
|
+
.filter(|p| {
|
|
1011
|
+
p.file_name()
|
|
1012
|
+
.is_some_and(|n| n.to_string_lossy().contains(".bak."))
|
|
1013
|
+
})
|
|
869
1014
|
.collect()
|
|
870
1015
|
})
|
|
871
1016
|
.unwrap_or_default()
|
|
@@ -880,13 +1025,18 @@ mod tests {
|
|
|
880
1025
|
));
|
|
881
1026
|
let canonical = include_str!("testdata/state-rich.canonical.json");
|
|
882
1027
|
let v: Value = serde_json::from_str(fixture).unwrap();
|
|
883
|
-
assert_eq!(
|
|
1028
|
+
assert_eq!(
|
|
1029
|
+
serde_json::to_string_pretty(&v).unwrap(),
|
|
1030
|
+
canonical,
|
|
1031
|
+
"state.json 序列化未字节对齐 Python indent=2"
|
|
1032
|
+
);
|
|
884
1033
|
}
|
|
885
1034
|
|
|
886
1035
|
#[test]
|
|
887
1036
|
fn save_writes_atomically_and_caches() {
|
|
888
1037
|
let ws = temp_ws();
|
|
889
|
-
let state =
|
|
1038
|
+
let state =
|
|
1039
|
+
json!({"session_name":"t","agents":{"a":{"agent_id":"a"}},"active_team_key":"t"});
|
|
890
1040
|
save_runtime_state(&ws, &state).unwrap();
|
|
891
1041
|
let on_disk = std::fs::read_to_string(runtime_state_path(&ws)).unwrap();
|
|
892
1042
|
assert_eq!(on_disk, serde_json::to_string_pretty(&state).unwrap());
|
|
@@ -903,7 +1053,10 @@ mod tests {
|
|
|
903
1053
|
// 删掉文件;若缓存早返回生效,save 相同 state 不会重建文件。
|
|
904
1054
|
std::fs::remove_file(runtime_state_path(&ws)).unwrap();
|
|
905
1055
|
save_runtime_state(&ws, &state).unwrap();
|
|
906
|
-
assert!(
|
|
1056
|
+
assert!(
|
|
1057
|
+
!runtime_state_path(&ws).exists(),
|
|
1058
|
+
"deep-equal 命中缓存 → 未重写(文件仍不存在)"
|
|
1059
|
+
);
|
|
907
1060
|
}
|
|
908
1061
|
|
|
909
1062
|
// bug-084 核心:EACCES 重试 3 次(有界退避)→ self-heal 成功 + 事件**字段精确**。
|
|
@@ -917,7 +1070,10 @@ mod tests {
|
|
|
917
1070
|
clear_fault();
|
|
918
1071
|
assert_eq!(read_state(&ws), s2, "inode 重建,文件为 s2");
|
|
919
1072
|
// 事件序列 + 字段精确。
|
|
920
|
-
let retries: Vec<_> = read_events(&ws)
|
|
1073
|
+
let retries: Vec<_> = read_events(&ws)
|
|
1074
|
+
.into_iter()
|
|
1075
|
+
.filter(|e| e["event"] == json!("runtime.state.save_retry"))
|
|
1076
|
+
.collect();
|
|
921
1077
|
assert_eq!(retries.len(), 3, "3 次重试");
|
|
922
1078
|
assert_eq!(retries[0]["attempt"], json!(1));
|
|
923
1079
|
assert_eq!(retries[0]["errno_name"], json!("EACCES"));
|
|
@@ -939,7 +1095,10 @@ mod tests {
|
|
|
939
1095
|
clear_fault();
|
|
940
1096
|
assert_eq!(read_state(&ws), json!({"v":2}));
|
|
941
1097
|
assert_eq!(count_event(&ws, "runtime.state.self_healed"), 1);
|
|
942
|
-
assert_eq!(
|
|
1098
|
+
assert_eq!(
|
|
1099
|
+
get_event(&ws, "runtime.state.save_retry")["errno_name"],
|
|
1100
|
+
json!(name)
|
|
1101
|
+
);
|
|
943
1102
|
}
|
|
944
1103
|
}
|
|
945
1104
|
|
|
@@ -964,7 +1123,11 @@ mod tests {
|
|
|
964
1123
|
clear_fault();
|
|
965
1124
|
assert_eq!(read_state(&ws), json!({"v":7}));
|
|
966
1125
|
assert_eq!(count_event(&ws, "runtime.state.save_retry"), 3);
|
|
967
|
-
assert_eq!(
|
|
1126
|
+
assert_eq!(
|
|
1127
|
+
count_event(&ws, "runtime.state.self_healed"),
|
|
1128
|
+
0,
|
|
1129
|
+
"未触发 self-heal"
|
|
1130
|
+
);
|
|
968
1131
|
}
|
|
969
1132
|
|
|
970
1133
|
// 崩溃安全不变量①:self-heal 中途失败但 restore 成功 → 原 state 复位 + 0 restore_failed + 1 save_failed。
|
|
@@ -979,8 +1142,15 @@ mod tests {
|
|
|
979
1142
|
let r = save_runtime_state(&ws, &json!({"keep":"new"}));
|
|
980
1143
|
clear_fault();
|
|
981
1144
|
assert!(matches!(r, Err(StateError::SaveFailed(_))));
|
|
982
|
-
assert_eq!(
|
|
983
|
-
|
|
1145
|
+
assert_eq!(
|
|
1146
|
+
read_state(&ws),
|
|
1147
|
+
original,
|
|
1148
|
+
"restore 成功:原 state 复位到 state.json"
|
|
1149
|
+
);
|
|
1150
|
+
assert_eq!(
|
|
1151
|
+
count_event(&ws, "runtime.state.self_heal_restore_failed"),
|
|
1152
|
+
0
|
|
1153
|
+
);
|
|
984
1154
|
let failed = get_event(&ws, "runtime.state.save_failed");
|
|
985
1155
|
assert_eq!(failed["phase"], json!("save_runtime_state"));
|
|
986
1156
|
assert_eq!(failed["retries_used"], json!(3));
|
|
@@ -997,7 +1167,10 @@ mod tests {
|
|
|
997
1167
|
let r = save_runtime_state(&ws, &json!({"keep":"new"}));
|
|
998
1168
|
clear_fault();
|
|
999
1169
|
assert!(matches!(r, Err(StateError::SaveFailed(_))));
|
|
1000
|
-
assert_eq!(
|
|
1170
|
+
assert_eq!(
|
|
1171
|
+
count_event(&ws, "runtime.state.self_heal_restore_failed"),
|
|
1172
|
+
1
|
|
1173
|
+
);
|
|
1001
1174
|
assert_eq!(count_event(&ws, "runtime.state.save_failed"), 1);
|
|
1002
1175
|
// state.json 已被 rename 到 backup(restore 失败),原 state 在 .bak 里完好(绝不丢失)。
|
|
1003
1176
|
let baks = bak_files(&ws);
|
|
@@ -1036,8 +1209,11 @@ mod tests {
|
|
|
1036
1209
|
let state = json!({"v":1});
|
|
1037
1210
|
save_runtime_state(&ws, &state).unwrap(); // 填充缓存
|
|
1038
1211
|
let _held = RuntimeLock::acquire(&ws, "state-save", 2.0).unwrap(); // 占锁
|
|
1039
|
-
|
|
1040
|
-
assert!(
|
|
1212
|
+
// 若 deep-equal 不早返回,会去抢已被占的锁 → 2s timeout → Locked。Ok 即证早返回。
|
|
1213
|
+
assert!(
|
|
1214
|
+
save_runtime_state(&ws, &state).is_ok(),
|
|
1215
|
+
"deep-equal 应在取锁前返回"
|
|
1216
|
+
);
|
|
1041
1217
|
}
|
|
1042
1218
|
|
|
1043
1219
|
// 并发全流程 save(非仅 lock acquire):多线程存不同 state → 全 Ok + 最终文件合法 JSON + 无 tmp 残留。
|
|
@@ -1075,8 +1251,13 @@ mod tests {
|
|
|
1075
1251
|
let held = RuntimeLock::acquire(&ws, "state-save", 2.0).unwrap();
|
|
1076
1252
|
// 另一线程在短 timeout 内尝试 → 应 Locked(flock 进程内/跨 fd 互斥)。
|
|
1077
1253
|
let ws2 = ws.clone();
|
|
1078
|
-
let r = std::thread::spawn(move || RuntimeLock::acquire(&ws2, "state-save", 0.2))
|
|
1079
|
-
|
|
1254
|
+
let r = std::thread::spawn(move || RuntimeLock::acquire(&ws2, "state-save", 0.2))
|
|
1255
|
+
.join()
|
|
1256
|
+
.unwrap();
|
|
1257
|
+
assert!(
|
|
1258
|
+
matches!(r, Err(StateError::Locked(_))),
|
|
1259
|
+
"持锁时第二者应 Locked"
|
|
1260
|
+
);
|
|
1080
1261
|
drop(held);
|
|
1081
1262
|
}
|
|
1082
1263
|
|
|
@@ -1090,7 +1271,10 @@ mod tests {
|
|
|
1090
1271
|
"session_id": "keep", "rollout_path": null, "captured_at": null,
|
|
1091
1272
|
"captured_via": null, "attribution_confidence": null, "spawn_cwd": null,
|
|
1092
1273
|
}}});
|
|
1093
|
-
assert_eq!(
|
|
1274
|
+
assert_eq!(
|
|
1275
|
+
serde_json::to_string(&state).unwrap(),
|
|
1276
|
+
serde_json::to_string(&expected).unwrap()
|
|
1277
|
+
);
|
|
1094
1278
|
}
|
|
1095
1279
|
|
|
1096
1280
|
#[test]
|
|
@@ -1116,7 +1300,10 @@ mod tests {
|
|
|
1116
1300
|
fn load_runtime_state_missing_returns_default() {
|
|
1117
1301
|
let ws = temp_ws();
|
|
1118
1302
|
let s = load_runtime_state(&ws).unwrap();
|
|
1119
|
-
assert_eq!(
|
|
1303
|
+
assert_eq!(
|
|
1304
|
+
s,
|
|
1305
|
+
json!({"agents": {}, "tasks": [], "session_name": null, "active_team_key": null})
|
|
1306
|
+
);
|
|
1120
1307
|
}
|
|
1121
1308
|
|
|
1122
1309
|
#[test]
|
|
@@ -1130,18 +1317,31 @@ mod tests {
|
|
|
1130
1317
|
"agents": {"w1": {"agent_id": "w1"}},
|
|
1131
1318
|
"team_owner": {"pane_id": "%1", "machine_fingerprint": "fp"},
|
|
1132
1319
|
});
|
|
1133
|
-
std::fs::write(
|
|
1320
|
+
std::fs::write(
|
|
1321
|
+
runtime_state_path(&ws),
|
|
1322
|
+
serde_json::to_string(&legacy).unwrap(),
|
|
1323
|
+
)
|
|
1324
|
+
.unwrap();
|
|
1134
1325
|
let s = load_runtime_state(&ws).unwrap();
|
|
1135
1326
|
// active_team_key seed = team_state_key = "tk"。
|
|
1136
1327
|
assert_eq!(s["active_team_key"], json!("tk"));
|
|
1137
1328
|
// agent session 字段补 None。
|
|
1138
1329
|
assert_eq!(s["agents"]["w1"]["spawn_cwd"], json!(null));
|
|
1139
1330
|
// team_owner 补 leader_session_uuid。
|
|
1140
|
-
assert_eq!(
|
|
1331
|
+
assert_eq!(
|
|
1332
|
+
s["team_owner"]["leader_session_uuid"]
|
|
1333
|
+
.as_str()
|
|
1334
|
+
.unwrap()
|
|
1335
|
+
.len(),
|
|
1336
|
+
32
|
|
1337
|
+
);
|
|
1141
1338
|
// 迁移已回写磁盘(再 load 不再变;active_team_key 已在)。
|
|
1142
1339
|
let on_disk = read_state(&ws);
|
|
1143
1340
|
assert_eq!(on_disk["active_team_key"], json!("tk"));
|
|
1144
|
-
assert_eq!(
|
|
1341
|
+
assert_eq!(
|
|
1342
|
+
on_disk["team_owner"]["leader_session_uuid"],
|
|
1343
|
+
s["team_owner"]["leader_session_uuid"]
|
|
1344
|
+
);
|
|
1145
1345
|
}
|
|
1146
1346
|
|
|
1147
1347
|
// 对抗 P1:legacy 文件**已有 active_team_key** 但缺 leader_session_uuid。load 内存补 uuid,
|
|
@@ -1156,50 +1356,392 @@ mod tests {
|
|
|
1156
1356
|
let before = std::fs::read_to_string(runtime_state_path(&ws)).unwrap();
|
|
1157
1357
|
let loaded = load_runtime_state(&ws).unwrap();
|
|
1158
1358
|
// 内存态补了 uuid(证明确实需要迁移)。
|
|
1159
|
-
assert_eq!(
|
|
1359
|
+
assert_eq!(
|
|
1360
|
+
loaded["team_owner"]["leader_session_uuid"]
|
|
1361
|
+
.as_str()
|
|
1362
|
+
.unwrap()
|
|
1363
|
+
.len(),
|
|
1364
|
+
32
|
|
1365
|
+
);
|
|
1160
1366
|
// 但磁盘未被重写(字节恒等)。
|
|
1161
1367
|
let after = std::fs::read_to_string(runtime_state_path(&ws)).unwrap();
|
|
1162
|
-
assert_eq!(
|
|
1368
|
+
assert_eq!(
|
|
1369
|
+
after, before,
|
|
1370
|
+
"已是迁移等价形的 legacy 文件不得 spurious 重写"
|
|
1371
|
+
);
|
|
1163
1372
|
}
|
|
1164
1373
|
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
//
|
|
1374
|
+
// Phase C: persist no longer repairs stale topology by cloning missing rows.
|
|
1375
|
+
// A stale non-lifecycle save that would remove a live row must surface a
|
|
1376
|
+
// typed conflict and leave disk unchanged.
|
|
1168
1377
|
#[test]
|
|
1169
|
-
fn
|
|
1378
|
+
fn stale_snapshot_cannot_overwrite_new_topology() {
|
|
1170
1379
|
let ws = temp_ws();
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1380
|
+
let latest = json!({
|
|
1381
|
+
"session_name": "team-a",
|
|
1382
|
+
"active_team_key": "team-a",
|
|
1383
|
+
"agents": {
|
|
1384
|
+
"w1": {
|
|
1385
|
+
"status": "running",
|
|
1386
|
+
"provider": "codex",
|
|
1387
|
+
"agent_id": "w1",
|
|
1388
|
+
"window": "w1-new",
|
|
1389
|
+
"pane_id": "%2",
|
|
1390
|
+
"pane_pid": 222,
|
|
1391
|
+
"spawned_at": "2026-06-01T00:00:00Z",
|
|
1392
|
+
"spawn_epoch": 2
|
|
1393
|
+
}
|
|
1394
|
+
},
|
|
1395
|
+
});
|
|
1396
|
+
write_state(&ws, &latest);
|
|
1397
|
+
let incoming = json!({
|
|
1398
|
+
"session_name": "team-a",
|
|
1399
|
+
"active_team_key": "team-a",
|
|
1400
|
+
"agents": {
|
|
1401
|
+
"w1": {
|
|
1402
|
+
"status": "running",
|
|
1403
|
+
"provider": "codex",
|
|
1404
|
+
"agent_id": "w1",
|
|
1405
|
+
"window": "w1-old",
|
|
1406
|
+
"pane_id": "%1",
|
|
1407
|
+
"pane_pid": 111,
|
|
1408
|
+
"spawned_at": "2026-05-31T00:00:00Z",
|
|
1409
|
+
"spawn_epoch": 1
|
|
1410
|
+
}
|
|
1411
|
+
},
|
|
1412
|
+
});
|
|
1413
|
+
let err = save_runtime_state(&ws, &incoming).expect_err("stale topology must conflict");
|
|
1414
|
+
assert!(matches!(err, StateError::SaveConflict(_)));
|
|
1415
|
+
let message = err.to_string();
|
|
1416
|
+
assert!(message.contains("agent_id=w1"), "message={message}");
|
|
1417
|
+
assert!(message.contains("projection=agents"), "message={message}");
|
|
1418
|
+
assert!(message.contains("pane_id"), "message={message}");
|
|
1419
|
+
assert_eq!(
|
|
1420
|
+
read_state(&ws),
|
|
1421
|
+
latest,
|
|
1422
|
+
"conflict must leave disk unchanged"
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
#[test]
|
|
1427
|
+
fn vacant_roster_preserve_cannot_resurrect_live_topology() {
|
|
1428
|
+
let ws = temp_ws();
|
|
1429
|
+
let latest = json!({
|
|
1430
|
+
"session_name": "team-a",
|
|
1431
|
+
"active_team_key": "team-a",
|
|
1432
|
+
"agents": {
|
|
1433
|
+
"w1": {"status": "running", "provider": "codex", "agent_id": "w1"},
|
|
1434
|
+
"joiner": {
|
|
1435
|
+
"status": "running",
|
|
1436
|
+
"provider": "codex",
|
|
1437
|
+
"agent_id": "joiner",
|
|
1438
|
+
"window": "joiner",
|
|
1439
|
+
"pane_id": "%9",
|
|
1440
|
+
"spawned_at": "2026-06-01T00:00:00Z",
|
|
1441
|
+
"spawn_epoch": 1
|
|
1442
|
+
}
|
|
1443
|
+
},
|
|
1444
|
+
});
|
|
1445
|
+
write_state(&ws, &latest);
|
|
1446
|
+
let incoming = json!({
|
|
1447
|
+
"session_name": "team-a",
|
|
1448
|
+
"active_team_key": "team-a",
|
|
1449
|
+
"agents": { "w1": {"status": "running", "provider": "codex", "agent_id": "w1"} },
|
|
1450
|
+
});
|
|
1451
|
+
let err = save_runtime_state(&ws, &incoming).expect_err("missing live row must conflict");
|
|
1452
|
+
assert!(matches!(err, StateError::SaveConflict(_)));
|
|
1453
|
+
assert!(err.to_string().contains("agent_id=joiner"));
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
#[test]
|
|
1457
|
+
fn vacant_non_live_roster_preserve_is_allow_list_only() {
|
|
1458
|
+
let ws = temp_ws();
|
|
1459
|
+
write_state(
|
|
1460
|
+
&ws,
|
|
1461
|
+
&json!({
|
|
1175
1462
|
"session_name": "team-a",
|
|
1176
1463
|
"active_team_key": "team-a",
|
|
1177
1464
|
"agents": {
|
|
1178
|
-
"
|
|
1179
|
-
|
|
1180
|
-
|
|
1465
|
+
"typed": {
|
|
1466
|
+
"agent_id": "typed",
|
|
1467
|
+
"provider": "codex",
|
|
1468
|
+
"role": "Developer",
|
|
1469
|
+
"model": "gpt-5.5",
|
|
1470
|
+
"status": "running",
|
|
1471
|
+
"spawn_cwd": "/tmp/old",
|
|
1472
|
+
"session_id": "old-session",
|
|
1473
|
+
"rollout_path": "/tmp/old.jsonl"
|
|
1474
|
+
}
|
|
1181
1475
|
},
|
|
1182
|
-
})
|
|
1183
|
-
|
|
1184
|
-
|
|
1476
|
+
}),
|
|
1477
|
+
);
|
|
1478
|
+
let incoming = json!({
|
|
1479
|
+
"session_name": "team-a",
|
|
1480
|
+
"active_team_key": "team-a",
|
|
1481
|
+
"agents": {},
|
|
1482
|
+
});
|
|
1483
|
+
save_runtime_state(&ws, &incoming).unwrap();
|
|
1484
|
+
let saved = read_state(&ws);
|
|
1485
|
+
let typed = saved.pointer("/agents/typed").expect("typed stub");
|
|
1486
|
+
assert_eq!(typed.get("provider").and_then(Value::as_str), Some("codex"));
|
|
1487
|
+
assert_eq!(typed.get("role").and_then(Value::as_str), Some("Developer"));
|
|
1488
|
+
assert_eq!(typed.get("model").and_then(Value::as_str), Some("gpt-5.5"));
|
|
1489
|
+
for forbidden in ["status", "spawn_cwd", "session_id", "rollout_path"] {
|
|
1490
|
+
assert!(
|
|
1491
|
+
typed.get(forbidden).is_none(),
|
|
1492
|
+
"typed roster stub must not copy {forbidden}; typed={typed}"
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
#[test]
|
|
1498
|
+
fn reapplying_after_conflict_retries_without_losing_delta() {
|
|
1499
|
+
let ws = temp_ws();
|
|
1500
|
+
write_state(
|
|
1501
|
+
&ws,
|
|
1502
|
+
&json!({
|
|
1503
|
+
"session_name": "team-a",
|
|
1504
|
+
"active_team_key": "team-a",
|
|
1505
|
+
"agents": {
|
|
1506
|
+
"w1": {
|
|
1507
|
+
"agent_id": "w1",
|
|
1508
|
+
"provider": "codex",
|
|
1509
|
+
"window": "w1-new",
|
|
1510
|
+
"pane_id": "%2",
|
|
1511
|
+
"pane_pid": 222,
|
|
1512
|
+
"spawn_epoch": 2
|
|
1513
|
+
}
|
|
1514
|
+
},
|
|
1515
|
+
"delivery": {}
|
|
1516
|
+
}),
|
|
1517
|
+
);
|
|
1518
|
+
let stale_with_delta = json!({
|
|
1519
|
+
"session_name": "team-a",
|
|
1520
|
+
"active_team_key": "team-a",
|
|
1521
|
+
"agents": {
|
|
1522
|
+
"w1": {
|
|
1523
|
+
"agent_id": "w1",
|
|
1524
|
+
"provider": "codex",
|
|
1525
|
+
"window": "w1-old",
|
|
1526
|
+
"pane_id": "%1",
|
|
1527
|
+
"pane_pid": 111,
|
|
1528
|
+
"spawn_epoch": 1
|
|
1529
|
+
}
|
|
1530
|
+
},
|
|
1531
|
+
"delivery": {"msg-1": {"status": "delivered"}}
|
|
1532
|
+
});
|
|
1533
|
+
let mut reapplied = false;
|
|
1534
|
+
save_runtime_state_reapplying_after_conflict(&ws, &stale_with_delta, |latest| {
|
|
1535
|
+
reapplied = true;
|
|
1536
|
+
latest.as_object_mut().unwrap().insert(
|
|
1537
|
+
"delivery".to_string(),
|
|
1538
|
+
json!({"msg-1": {"status": "delivered"}}),
|
|
1539
|
+
);
|
|
1540
|
+
})
|
|
1185
1541
|
.unwrap();
|
|
1542
|
+
|
|
1543
|
+
let saved = read_state(&ws);
|
|
1544
|
+
assert!(reapplied, "SaveConflict path must reload and reapply once");
|
|
1545
|
+
assert_eq!(saved.pointer("/agents/w1/pane_id"), Some(&json!("%2")));
|
|
1546
|
+
assert_eq!(saved.pointer("/agents/w1/window"), Some(&json!("w1-new")));
|
|
1547
|
+
assert_eq!(
|
|
1548
|
+
saved.pointer("/delivery/msg-1/status"),
|
|
1549
|
+
Some(&json!("delivered")),
|
|
1550
|
+
"non-topology delta must survive retry; saved={saved}"
|
|
1551
|
+
);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
#[test]
|
|
1555
|
+
fn deleted_ids_stay_dead_even_when_latest_has_live_topology() {
|
|
1556
|
+
let ws = temp_ws();
|
|
1557
|
+
write_state(
|
|
1558
|
+
&ws,
|
|
1559
|
+
&json!({
|
|
1560
|
+
"session_name": "team-a",
|
|
1561
|
+
"active_team_key": "team-a",
|
|
1562
|
+
"agents": {
|
|
1563
|
+
"gone": {
|
|
1564
|
+
"status": "running",
|
|
1565
|
+
"provider": "codex",
|
|
1566
|
+
"agent_id": "gone",
|
|
1567
|
+
"window": "gone",
|
|
1568
|
+
"pane_id": "%7",
|
|
1569
|
+
"spawn_epoch": 1
|
|
1570
|
+
},
|
|
1571
|
+
},
|
|
1572
|
+
}),
|
|
1573
|
+
);
|
|
1186
1574
|
let incoming = json!({
|
|
1187
1575
|
"session_name": "team-a",
|
|
1188
1576
|
"active_team_key": "team-a",
|
|
1189
|
-
"agents": {
|
|
1577
|
+
"agents": {},
|
|
1190
1578
|
});
|
|
1191
1579
|
save_runtime_state_with_deleted_agents(&ws, &incoming, &["gone"]).unwrap();
|
|
1192
1580
|
let saved = read_state(&ws);
|
|
1193
|
-
assert!(
|
|
1194
|
-
saved.pointer("/agents/kept").is_some_and(Value::is_object),
|
|
1195
|
-
"锁内 preserve 必须把磁盘 latest 多出的 `kept` 补回 stale incoming;saved={saved}"
|
|
1196
|
-
);
|
|
1197
1581
|
assert!(
|
|
1198
1582
|
saved.pointer("/agents/gone").is_none(),
|
|
1199
1583
|
"deleted_agent_ids 豁免:被 remove 的 `gone` 不得被 preserve 复活;saved={saved}"
|
|
1200
1584
|
);
|
|
1201
1585
|
}
|
|
1202
1586
|
|
|
1587
|
+
#[test]
|
|
1588
|
+
fn lifecycle_topology_authority_only_applies_to_target_agent() {
|
|
1589
|
+
let ws = temp_ws();
|
|
1590
|
+
let latest = json!({
|
|
1591
|
+
"session_name": "team-a",
|
|
1592
|
+
"active_team_key": "team-a",
|
|
1593
|
+
"agents": {
|
|
1594
|
+
"target": {"agent_id": "target", "provider": "codex", "window": "target", "pane_id": "%1", "spawn_epoch": 1},
|
|
1595
|
+
"other": {"agent_id": "other", "provider": "codex", "window": "other", "pane_id": "%2", "spawn_epoch": 1}
|
|
1596
|
+
},
|
|
1597
|
+
});
|
|
1598
|
+
write_state(&ws, &latest);
|
|
1599
|
+
let incoming = json!({
|
|
1600
|
+
"session_name": "team-a",
|
|
1601
|
+
"active_team_key": "team-a",
|
|
1602
|
+
"agents": {
|
|
1603
|
+
"target": {"agent_id": "target", "provider": "codex", "status": "stopped"},
|
|
1604
|
+
"other": {"agent_id": "other", "provider": "codex", "window": "old-other", "pane_id": "%old", "spawn_epoch": 1}
|
|
1605
|
+
},
|
|
1606
|
+
});
|
|
1607
|
+
let err = save_runtime_state_with_lifecycle_topology_authority(&ws, &incoming, &["target"])
|
|
1608
|
+
.expect_err("authorized target must not hide other agent conflict");
|
|
1609
|
+
assert!(matches!(err, StateError::SaveConflict(_)));
|
|
1610
|
+
assert!(err.to_string().contains("agent_id=other"));
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
#[test]
|
|
1614
|
+
fn lifecycle_topology_authority_can_clear_target_topology() {
|
|
1615
|
+
let ws = temp_ws();
|
|
1616
|
+
write_state(
|
|
1617
|
+
&ws,
|
|
1618
|
+
&json!({
|
|
1619
|
+
"session_name": "team-a",
|
|
1620
|
+
"active_team_key": "team-a",
|
|
1621
|
+
"agents": {
|
|
1622
|
+
"target": {"agent_id": "target", "provider": "codex", "window": "target", "pane_id": "%1", "spawn_epoch": 1}
|
|
1623
|
+
},
|
|
1624
|
+
}),
|
|
1625
|
+
);
|
|
1626
|
+
let incoming = json!({
|
|
1627
|
+
"session_name": "team-a",
|
|
1628
|
+
"active_team_key": "team-a",
|
|
1629
|
+
"agents": {
|
|
1630
|
+
"target": {"agent_id": "target", "provider": "codex", "status": "stopped"}
|
|
1631
|
+
},
|
|
1632
|
+
});
|
|
1633
|
+
save_runtime_state_with_lifecycle_topology_authority(&ws, &incoming, &["target"]).unwrap();
|
|
1634
|
+
let target = read_state(&ws).pointer("/agents/target").cloned().unwrap();
|
|
1635
|
+
assert!(
|
|
1636
|
+
target.get("pane_id").is_none(),
|
|
1637
|
+
"target topology was intentionally cleared: {target}"
|
|
1638
|
+
);
|
|
1639
|
+
assert!(
|
|
1640
|
+
target.get("window").is_none(),
|
|
1641
|
+
"target window was intentionally cleared: {target}"
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
#[test]
|
|
1646
|
+
fn owner_epoch_preserve_is_explicit_and_bounded_to_team_entry() {
|
|
1647
|
+
let ws = temp_ws();
|
|
1648
|
+
write_state(
|
|
1649
|
+
&ws,
|
|
1650
|
+
&json!({
|
|
1651
|
+
"session_name": "team-a",
|
|
1652
|
+
"active_team_key": "team-a",
|
|
1653
|
+
"teams": {
|
|
1654
|
+
"team-a": {
|
|
1655
|
+
"agents": {},
|
|
1656
|
+
"team_owner": {"pane_id": "%7", "owner_epoch": 7},
|
|
1657
|
+
"leader_receiver": {"pane_id": "%7", "owner_epoch": 7},
|
|
1658
|
+
"owner_epoch": 7
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
}),
|
|
1662
|
+
);
|
|
1663
|
+
let incoming = json!({
|
|
1664
|
+
"session_name": "team-a",
|
|
1665
|
+
"active_team_key": "team-a",
|
|
1666
|
+
"teams": {
|
|
1667
|
+
"team-a": {
|
|
1668
|
+
"agents": {},
|
|
1669
|
+
"team_owner": {"pane_id": "%3", "owner_epoch": 3},
|
|
1670
|
+
"leader_receiver": {"pane_id": "%3", "owner_epoch": 3},
|
|
1671
|
+
"owner_epoch": 3
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
});
|
|
1675
|
+
save_runtime_state(&ws, &incoming).unwrap();
|
|
1676
|
+
let saved = read_state(&ws);
|
|
1677
|
+
assert_eq!(
|
|
1678
|
+
saved
|
|
1679
|
+
.pointer("/teams/team-a/owner_epoch")
|
|
1680
|
+
.and_then(Value::as_u64),
|
|
1681
|
+
Some(7)
|
|
1682
|
+
);
|
|
1683
|
+
assert_eq!(
|
|
1684
|
+
saved
|
|
1685
|
+
.pointer("/teams/team-a/team_owner/pane_id")
|
|
1686
|
+
.and_then(Value::as_str),
|
|
1687
|
+
Some("%7")
|
|
1688
|
+
);
|
|
1689
|
+
assert!(
|
|
1690
|
+
saved.get("team_owner").is_none(),
|
|
1691
|
+
"top-level owner must not be recreated: {saved}"
|
|
1692
|
+
);
|
|
1693
|
+
assert!(
|
|
1694
|
+
saved.get("owner_epoch").is_none(),
|
|
1695
|
+
"top-level owner_epoch must not be recreated: {saved}"
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
#[test]
|
|
1700
|
+
fn projection_paths_do_not_cross_backfill_topology() {
|
|
1701
|
+
let ws = temp_ws();
|
|
1702
|
+
write_state(
|
|
1703
|
+
&ws,
|
|
1704
|
+
&json!({
|
|
1705
|
+
"session_name": "team-a",
|
|
1706
|
+
"active_team_key": "team-a",
|
|
1707
|
+
"agents": {
|
|
1708
|
+
"top-only": {"agent_id": "top-only", "provider": "codex", "window": "top-only", "pane_id": "%1", "spawn_epoch": 1}
|
|
1709
|
+
},
|
|
1710
|
+
"teams": {
|
|
1711
|
+
"team-a": {
|
|
1712
|
+
"agents": {
|
|
1713
|
+
"team-only": {"agent_id": "team-only", "provider": "codex", "window": "team-only", "pane_id": "%2", "spawn_epoch": 1}
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
}),
|
|
1718
|
+
);
|
|
1719
|
+
let incoming = json!({
|
|
1720
|
+
"session_name": "team-a",
|
|
1721
|
+
"active_team_key": "team-a",
|
|
1722
|
+
"agents": {
|
|
1723
|
+
"top-only": {"agent_id": "top-only", "provider": "codex", "window": "top-only", "pane_id": "%1", "spawn_epoch": 1}
|
|
1724
|
+
},
|
|
1725
|
+
"teams": {
|
|
1726
|
+
"team-a": {
|
|
1727
|
+
"agents": {
|
|
1728
|
+
"team-only": {"agent_id": "team-only", "provider": "codex", "window": "team-only", "pane_id": "%2", "spawn_epoch": 1}
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
});
|
|
1733
|
+
save_runtime_state(&ws, &incoming).unwrap();
|
|
1734
|
+
let saved = read_state(&ws);
|
|
1735
|
+
assert!(
|
|
1736
|
+
saved.pointer("/agents/team-only").is_none(),
|
|
1737
|
+
"teams.<key> topology must not cross-fill top-level agents; saved={saved}"
|
|
1738
|
+
);
|
|
1739
|
+
assert!(
|
|
1740
|
+
saved.pointer("/teams/team-a/agents/top-only").is_none(),
|
|
1741
|
+
"top-level topology must not cross-fill teams.<key>.agents; saved={saved}"
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1203
1745
|
/// RM-039-STAT-001 second-round regression (architect verdict
|
|
1204
1746
|
/// 2026-06-22): `migrate_team_key_to_match_active_team` must
|
|
1205
1747
|
/// promote `team_key = active_team_key` when root `team_key` is
|
|
@@ -1354,10 +1896,9 @@ mod tests {
|
|
|
1354
1896
|
);
|
|
1355
1897
|
|
|
1356
1898
|
// Re-read from disk (not cache) to confirm the migration was persisted.
|
|
1357
|
-
let on_disk: Value =
|
|
1358
|
-
&std::fs::read_to_string(runtime_dir.join("state.json")).unwrap()
|
|
1359
|
-
|
|
1360
|
-
.unwrap();
|
|
1899
|
+
let on_disk: Value =
|
|
1900
|
+
serde_json::from_str(&std::fs::read_to_string(runtime_dir.join("state.json")).unwrap())
|
|
1901
|
+
.unwrap();
|
|
1361
1902
|
assert_eq!(
|
|
1362
1903
|
on_disk.get("team_key").and_then(Value::as_str),
|
|
1363
1904
|
Some(active),
|
|
@@ -1429,7 +1970,9 @@ mod tests {
|
|
|
1429
1970
|
Some("session.captured")
|
|
1430
1971
|
);
|
|
1431
1972
|
assert_eq!(
|
|
1432
|
-
incoming
|
|
1973
|
+
incoming
|
|
1974
|
+
.get("attribution_confidence")
|
|
1975
|
+
.and_then(Value::as_str),
|
|
1433
1976
|
Some("high"),
|
|
1434
1977
|
"attribution_confidence rides with the tuple"
|
|
1435
1978
|
);
|