@team-agent/installer 0.4.5 → 0.4.6
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/coordinator/tests/health_sync.rs +6 -1
- package/crates/team-agent/src/coordinator/tick.rs +44 -0
- package/crates/team-agent/src/lifecycle/launch.rs +33 -6
- package/crates/team-agent/src/lifecycle/restart/agent.rs +160 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +81 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +97 -43
- package/crates/team-agent/src/lifecycle/restart/selection.rs +23 -0
- package/crates/team-agent/src/lifecycle/tests/core.rs +19 -11
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +23 -1
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +163 -15
- package/crates/team-agent/src/provider/adapter.rs +199 -5
- package/crates/team-agent/src/provider/session/capture.rs +593 -55
- package/crates/team-agent/src/state/persist.rs +225 -10
- package/package.json +4 -4
|
@@ -519,28 +519,62 @@ fn preserve_missing_agents(
|
|
|
519
519
|
}
|
|
520
520
|
}
|
|
521
521
|
|
|
522
|
-
///
|
|
523
|
-
///
|
|
524
|
-
///
|
|
525
|
-
///
|
|
522
|
+
/// 0.4.6 tuple-atomic backfill (restart-persist-capture-contract-audit.md):
|
|
523
|
+
/// the authoritative session tuple is `session_id + rollout_path +
|
|
524
|
+
/// captured_at + captured_via`. `attribution_confidence` is metadata on
|
|
525
|
+
/// that tuple. Persist may preserve an already-complete tuple across stale
|
|
526
|
+
/// saves, but it must NEVER:
|
|
527
|
+
/// * synthesise a partial tuple (e.g. copy `session_id` while the other
|
|
528
|
+
/// three are absent — this caused bug-045 by resurrecting an
|
|
529
|
+
/// intentionally cleared session id);
|
|
530
|
+
/// * mix tuple fields across two different session_ids (one writer's
|
|
531
|
+
/// `session_id` glued to another writer's `rollout_path`);
|
|
532
|
+
/// * create non-null session truth where none existed.
|
|
533
|
+
///
|
|
534
|
+
/// Rules:
|
|
535
|
+
/// 1. If LATEST has a complete tuple AND INCOMING has the same `session_id`
|
|
536
|
+
/// (or null), backfill the full tuple together — concurrent capture
|
|
537
|
+
/// protection (A0/R2).
|
|
538
|
+
/// 2. If LATEST has a complete tuple AND INCOMING has a DIFFERENT non-null
|
|
539
|
+
/// `session_id`, copy nothing (incoming is a different worker session
|
|
540
|
+
/// identity; the latest tuple belongs to the previous identity).
|
|
541
|
+
/// 3. If LATEST tuple is INCOMPLETE (any of the 4 fields null), copy
|
|
542
|
+
/// nothing for the tuple. An incomplete latest tuple is not
|
|
543
|
+
/// authoritative truth, so persist cannot use it as backfill source.
|
|
544
|
+
/// 4. `attribution_confidence` rides with the tuple (copied iff tuple
|
|
545
|
+
/// backfill fires).
|
|
526
546
|
fn backfill_capture_fields(incoming_agent: &mut Value, latest_agent: &Value) {
|
|
527
|
-
const
|
|
547
|
+
const TUPLE_FIELDS: [&str; 4] = [
|
|
528
548
|
"session_id",
|
|
529
549
|
"rollout_path",
|
|
530
550
|
"captured_at",
|
|
531
551
|
"captured_via",
|
|
532
|
-
"attribution_confidence",
|
|
533
552
|
];
|
|
534
553
|
let Some(incoming_row) = incoming_agent.as_object_mut() else {
|
|
535
554
|
return;
|
|
536
555
|
};
|
|
556
|
+
// Rule 3: latest tuple must be COMPLETE to be a backfill source.
|
|
557
|
+
let latest_complete = TUPLE_FIELDS
|
|
558
|
+
.iter()
|
|
559
|
+
.all(|field| latest_agent.get(field).is_some_and(|v| !v.is_null()));
|
|
560
|
+
if !latest_complete {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
// Rule 2: incoming carries a DIFFERENT non-null session_id → do not mix.
|
|
537
564
|
let incoming_session = incoming_row.get("session_id").and_then(Value::as_str);
|
|
538
565
|
let latest_session = latest_agent.get("session_id").and_then(Value::as_str);
|
|
539
|
-
let
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
continue;
|
|
566
|
+
if let Some(inc) = incoming_session {
|
|
567
|
+
if Some(inc) != latest_session {
|
|
568
|
+
return;
|
|
543
569
|
}
|
|
570
|
+
}
|
|
571
|
+
// Rule 1 + 4: copy the full tuple together (only fields incoming has as
|
|
572
|
+
// null/missing) plus attribution_confidence.
|
|
573
|
+
for field in TUPLE_FIELDS
|
|
574
|
+
.iter()
|
|
575
|
+
.copied()
|
|
576
|
+
.chain(std::iter::once("attribution_confidence"))
|
|
577
|
+
{
|
|
544
578
|
if incoming_row.get(field).is_none_or(Value::is_null) {
|
|
545
579
|
if let Some(value) = latest_agent.get(field).filter(|value| !value.is_null()) {
|
|
546
580
|
incoming_row.insert(field.to_string(), value.clone());
|
|
@@ -1313,4 +1347,185 @@ mod tests {
|
|
|
1313
1347
|
"migration must be persisted to disk so subsequent processes see it"
|
|
1314
1348
|
);
|
|
1315
1349
|
}
|
|
1350
|
+
|
|
1351
|
+
/// 0.4.6 tuple-atomic contract test #1 (audit §Required Tests, line 264):
|
|
1352
|
+
/// latest has `session_id` only (PARTIAL TUPLE), incoming has null
|
|
1353
|
+
/// tuple → no backfill. This is bug-045's core failure mode: persist
|
|
1354
|
+
/// must not resurrect a scalar session_id when the latest row's tuple
|
|
1355
|
+
/// is incomplete.
|
|
1356
|
+
#[test]
|
|
1357
|
+
fn backfill_capture_skips_when_latest_tuple_is_partial() {
|
|
1358
|
+
let mut incoming = json!({
|
|
1359
|
+
"session_id": Value::Null,
|
|
1360
|
+
"rollout_path": Value::Null,
|
|
1361
|
+
"captured_at": Value::Null,
|
|
1362
|
+
"captured_via": Value::Null,
|
|
1363
|
+
});
|
|
1364
|
+
let latest = json!({
|
|
1365
|
+
"session_id": "partial-only-uuid",
|
|
1366
|
+
"rollout_path": Value::Null,
|
|
1367
|
+
"captured_at": Value::Null,
|
|
1368
|
+
"captured_via": Value::Null,
|
|
1369
|
+
});
|
|
1370
|
+
backfill_capture_fields(&mut incoming, &latest);
|
|
1371
|
+
assert_eq!(
|
|
1372
|
+
incoming.get("session_id"),
|
|
1373
|
+
Some(&Value::Null),
|
|
1374
|
+
"0.4.6: partial latest tuple must NOT backfill session_id; got {incoming}"
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/// 0.4.6 tuple-atomic contract test #2 (audit, line 265):
|
|
1379
|
+
/// latest has complete tuple, incoming has null tuple → all 4 fields
|
|
1380
|
+
/// backfilled together. This is the concurrent-capture protection
|
|
1381
|
+
/// (A0/R2) that must still work after the tuple-atomic rewrite.
|
|
1382
|
+
#[test]
|
|
1383
|
+
fn backfill_capture_atomically_copies_complete_tuple() {
|
|
1384
|
+
let mut incoming = json!({
|
|
1385
|
+
"session_id": Value::Null,
|
|
1386
|
+
"rollout_path": Value::Null,
|
|
1387
|
+
"captured_at": Value::Null,
|
|
1388
|
+
"captured_via": Value::Null,
|
|
1389
|
+
});
|
|
1390
|
+
let latest = json!({
|
|
1391
|
+
"session_id": "real-uuid",
|
|
1392
|
+
"rollout_path": "/tmp/real.jsonl",
|
|
1393
|
+
"captured_at": "2026-06-25T10:00:00+00:00",
|
|
1394
|
+
"captured_via": "session.captured",
|
|
1395
|
+
"attribution_confidence": "high",
|
|
1396
|
+
});
|
|
1397
|
+
backfill_capture_fields(&mut incoming, &latest);
|
|
1398
|
+
assert_eq!(
|
|
1399
|
+
incoming.get("session_id").and_then(Value::as_str),
|
|
1400
|
+
Some("real-uuid")
|
|
1401
|
+
);
|
|
1402
|
+
assert_eq!(
|
|
1403
|
+
incoming.get("rollout_path").and_then(Value::as_str),
|
|
1404
|
+
Some("/tmp/real.jsonl")
|
|
1405
|
+
);
|
|
1406
|
+
assert_eq!(
|
|
1407
|
+
incoming.get("captured_at").and_then(Value::as_str),
|
|
1408
|
+
Some("2026-06-25T10:00:00+00:00")
|
|
1409
|
+
);
|
|
1410
|
+
assert_eq!(
|
|
1411
|
+
incoming.get("captured_via").and_then(Value::as_str),
|
|
1412
|
+
Some("session.captured")
|
|
1413
|
+
);
|
|
1414
|
+
assert_eq!(
|
|
1415
|
+
incoming.get("attribution_confidence").and_then(Value::as_str),
|
|
1416
|
+
Some("high"),
|
|
1417
|
+
"attribution_confidence rides with the tuple"
|
|
1418
|
+
);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/// 0.4.6 tuple-atomic contract test #3 (audit, line 266):
|
|
1422
|
+
/// latest has complete tuple for one session_id, incoming has non-null
|
|
1423
|
+
/// DIFFERENT session_id → no mixed tuple. Prevents one writer's
|
|
1424
|
+
/// session_id getting glued to another writer's rollout_path/captured_at.
|
|
1425
|
+
#[test]
|
|
1426
|
+
fn backfill_capture_refuses_mixing_different_session_ids() {
|
|
1427
|
+
let mut incoming = json!({
|
|
1428
|
+
"session_id": "incoming-different-uuid",
|
|
1429
|
+
"rollout_path": Value::Null,
|
|
1430
|
+
"captured_at": Value::Null,
|
|
1431
|
+
"captured_via": Value::Null,
|
|
1432
|
+
});
|
|
1433
|
+
let latest = json!({
|
|
1434
|
+
"session_id": "latest-uuid",
|
|
1435
|
+
"rollout_path": "/tmp/latest.jsonl",
|
|
1436
|
+
"captured_at": "2026-06-25T10:00:00+00:00",
|
|
1437
|
+
"captured_via": "session.captured",
|
|
1438
|
+
});
|
|
1439
|
+
backfill_capture_fields(&mut incoming, &latest);
|
|
1440
|
+
// session_id stays as incoming (not overwritten).
|
|
1441
|
+
assert_eq!(
|
|
1442
|
+
incoming.get("session_id").and_then(Value::as_str),
|
|
1443
|
+
Some("incoming-different-uuid"),
|
|
1444
|
+
"0.4.6: incoming session_id must not be overwritten by a different latest"
|
|
1445
|
+
);
|
|
1446
|
+
// Sibling fields stay null (no cross-session mixing).
|
|
1447
|
+
assert_eq!(
|
|
1448
|
+
incoming.get("rollout_path"),
|
|
1449
|
+
Some(&Value::Null),
|
|
1450
|
+
"0.4.6: must not copy latest rollout_path onto a different session_id"
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/// 0.4.6 tuple-atomic contract test #4 (audit, line 267):
|
|
1455
|
+
/// `normalize_agent_session_state` may only insert null defaults — it
|
|
1456
|
+
/// must never create non-null session truth.
|
|
1457
|
+
#[test]
|
|
1458
|
+
fn normalize_agent_session_state_only_inserts_null_defaults() {
|
|
1459
|
+
let mut state = json!({
|
|
1460
|
+
"agents": {
|
|
1461
|
+
"alpha": {}
|
|
1462
|
+
}
|
|
1463
|
+
});
|
|
1464
|
+
normalize_agent_session_state(&mut state);
|
|
1465
|
+
let alpha = state.pointer("/agents/alpha").expect("alpha");
|
|
1466
|
+
for field in [
|
|
1467
|
+
"session_id",
|
|
1468
|
+
"rollout_path",
|
|
1469
|
+
"captured_at",
|
|
1470
|
+
"captured_via",
|
|
1471
|
+
"attribution_confidence",
|
|
1472
|
+
"spawn_cwd",
|
|
1473
|
+
] {
|
|
1474
|
+
assert_eq!(
|
|
1475
|
+
alpha.get(field),
|
|
1476
|
+
Some(&Value::Null),
|
|
1477
|
+
"normalize must insert null for {field}; got {alpha}"
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/// Bug 045 end-to-end persist regression: poisoned latest (session_id
|
|
1483
|
+
/// but no captured_at/captured_via) cannot resurrect a cleared incoming
|
|
1484
|
+
/// row through a real save → reload cycle.
|
|
1485
|
+
#[test]
|
|
1486
|
+
fn poisoned_partial_latest_does_not_resurrect_on_save_reload() {
|
|
1487
|
+
let ws = temp_ws();
|
|
1488
|
+
// Seed disk with the exact bug-045 poisoned shape: session_id set,
|
|
1489
|
+
// no captured_at / captured_via / rollout_path.
|
|
1490
|
+
let poisoned = json!({
|
|
1491
|
+
"session_name": "team-bug045-poisoned",
|
|
1492
|
+
"team_key": "teamP",
|
|
1493
|
+
"agents": {
|
|
1494
|
+
"alpha": {
|
|
1495
|
+
"status": "stopped",
|
|
1496
|
+
"provider": "claude",
|
|
1497
|
+
"session_id": "stale-poisoned-uuid",
|
|
1498
|
+
"rollout_path": Value::Null,
|
|
1499
|
+
"captured_at": Value::Null,
|
|
1500
|
+
"captured_via": Value::Null
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
});
|
|
1504
|
+
save_runtime_state(&ws, &poisoned).unwrap();
|
|
1505
|
+
|
|
1506
|
+
// Restart-fresh writes a cleared row.
|
|
1507
|
+
let cleared = json!({
|
|
1508
|
+
"session_name": "team-bug045-poisoned",
|
|
1509
|
+
"team_key": "teamP",
|
|
1510
|
+
"agents": {
|
|
1511
|
+
"alpha": {
|
|
1512
|
+
"status": "running",
|
|
1513
|
+
"provider": "claude",
|
|
1514
|
+
"session_id": Value::Null,
|
|
1515
|
+
"rollout_path": Value::Null,
|
|
1516
|
+
"captured_at": Value::Null,
|
|
1517
|
+
"captured_via": Value::Null
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
});
|
|
1521
|
+
save_runtime_state(&ws, &cleared).unwrap();
|
|
1522
|
+
|
|
1523
|
+
let on_disk = read_state(&ws);
|
|
1524
|
+
let alpha = on_disk.pointer("/agents/alpha").expect("alpha");
|
|
1525
|
+
assert_eq!(
|
|
1526
|
+
alpha.get("session_id"),
|
|
1527
|
+
Some(&Value::Null),
|
|
1528
|
+
"0.4.6: partial poisoned latest must not resurrect cleared session_id; got {alpha}"
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1316
1531
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
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.4.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.4.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.4.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.4.6",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.4.6",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.4.6"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|