@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.
@@ -519,28 +519,62 @@ fn preserve_missing_agents(
519
519
  }
520
520
  }
521
521
 
522
- /// A0/R2: session-capture fields are written by another process (capture/update_state)
523
- /// between a writer's load and save; a stale incoming row must not regress them to null.
524
- /// Monotonic backfill ONLY for the capture field family (no generic deep-merge, so a
525
- /// deliberate field clear elsewhere is not masked).
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 CAPTURE_FIELDS: [&str; 5] = [
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 session_changed = incoming_session.is_some() && incoming_session != latest_session;
540
- for field in CAPTURE_FIELDS {
541
- if session_changed && field != "session_id" {
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.5",
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.5",
24
- "@team-agent/cli-darwin-x64": "0.4.5",
25
- "@team-agent/cli-linux-x64": "0.4.5"
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",