@team-agent/installer 0.5.2 → 0.5.4

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.
Files changed (51) hide show
  1. package/Cargo.lock +3 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +20 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +191 -31
  5. package/crates/team-agent/src/cli/emit.rs +5 -0
  6. package/crates/team-agent/src/cli/mod.rs +213 -89
  7. package/crates/team-agent/src/cli/tests/named_address.rs +4 -0
  8. package/crates/team-agent/src/codex_app_server.rs +62 -1
  9. package/crates/team-agent/src/conpty/backend.rs +120 -8
  10. package/crates/team-agent/src/coordinator/backoff.rs +88 -2
  11. package/crates/team-agent/src/coordinator/conpty_shim.rs +730 -0
  12. package/crates/team-agent/src/coordinator/health.rs +97 -11
  13. package/crates/team-agent/src/coordinator/mod.rs +8 -0
  14. package/crates/team-agent/src/coordinator/tests/daemon.rs +2 -1
  15. package/crates/team-agent/src/coordinator/tests/tick_core.rs +6 -0
  16. package/crates/team-agent/src/coordinator/tick.rs +13 -0
  17. package/crates/team-agent/src/diagnose/orphans.rs +18 -7
  18. package/crates/team-agent/src/leader/provider_attribution.rs +19 -34
  19. package/crates/team-agent/src/leader/tests/lease_api.rs +7 -0
  20. package/crates/team-agent/src/lib.rs +14 -1
  21. package/crates/team-agent/src/lifecycle/launch.rs +89 -92
  22. package/crates/team-agent/src/lifecycle/lock.rs +40 -33
  23. package/crates/team-agent/src/lifecycle/restart/agent.rs +25 -19
  24. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -2
  25. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +108 -6
  26. package/crates/team-agent/src/lifecycle/restart/selection.rs +47 -18
  27. package/crates/team-agent/src/lifecycle/restart.rs +16 -6
  28. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -0
  29. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +287 -53
  30. package/crates/team-agent/src/lifecycle/tests/restart.rs +144 -5
  31. package/crates/team-agent/src/lifecycle/types.rs +1 -0
  32. package/crates/team-agent/src/mcp_server/wire.rs +6 -7
  33. package/crates/team-agent/src/messaging/tests/runtime.rs +5 -0
  34. package/crates/team-agent/src/packaging/tests.rs +41 -6
  35. package/crates/team-agent/src/packaging/types.rs +31 -3
  36. package/crates/team-agent/src/platform/argv.rs +324 -0
  37. package/crates/team-agent/src/platform/errors.rs +95 -0
  38. package/crates/team-agent/src/platform/file_lock.rs +418 -0
  39. package/crates/team-agent/src/platform/mod.rs +66 -0
  40. package/crates/team-agent/src/platform/process.rs +555 -0
  41. package/crates/team-agent/src/provider/adapter.rs +103 -41
  42. package/crates/team-agent/src/provider/session/capture.rs +328 -66
  43. package/crates/team-agent/src/provider/session/resume.rs +30 -28
  44. package/crates/team-agent/src/provider/session_scan/common.rs +117 -1
  45. package/crates/team-agent/src/provider/session_scan/copilot.rs +1 -0
  46. package/crates/team-agent/src/provider/session_scan.rs +1 -0
  47. package/crates/team-agent/src/state/persist.rs +222 -25
  48. package/crates/team-agent/src/state/projection.rs +59 -4
  49. package/crates/team-agent/src/tmux_backend.rs +63 -13
  50. package/crates/team-agent/src/transport_factory.rs +124 -5
  51. package/package.json +4 -4
@@ -95,9 +95,14 @@ pub enum ResumeRefusalReason {
95
95
  SessionCaptureIncomplete,
96
96
  /// State session_id differs from the provider's observed session
97
97
  /// (T6 L5.5 drift). Caller should reconcile before resuming.
98
- SessionDrift {
99
- expected: String,
100
- actual: String,
98
+ SessionDrift { expected: String, actual: String },
99
+ /// The persisted provider backing exists, but the transcript itself
100
+ /// declares a different Team Agent worker identity.
101
+ SessionIdentityMismatch {
102
+ expected_agent_id: String,
103
+ embedded_agent_id: String,
104
+ session_id: String,
105
+ rollout_path: Option<PathBuf>,
101
106
  },
102
107
  /// Catch-all for refusals the structured shape hasn't taxonomized
103
108
  /// yet. Carries the legacy free-form string. This variant exists so
@@ -118,11 +123,10 @@ impl ResumeRefusalReason {
118
123
  ResumeRefusalReason::SessionBackingStoreMissing { .. } => {
119
124
  "session_backing_store_missing"
120
125
  }
121
- ResumeRefusalReason::ProviderResumeUnsupported { .. } => {
122
- "provider_resume_unsupported"
123
- }
126
+ ResumeRefusalReason::ProviderResumeUnsupported { .. } => "provider_resume_unsupported",
124
127
  ResumeRefusalReason::SessionCaptureIncomplete => "session_capture_incomplete",
125
128
  ResumeRefusalReason::SessionDrift { .. } => "session_drift",
129
+ ResumeRefusalReason::SessionIdentityMismatch { .. } => "session_identity_mismatch",
126
130
  // For Other we still report the legacy wire so the existing
127
131
  // `session_unresumable` JSON shape is preserved end-to-end.
128
132
  ResumeRefusalReason::Other { .. } => "session_unresumable",
@@ -134,16 +138,20 @@ impl ResumeRefusalReason {
134
138
  pub fn from_legacy(reason: &str) -> Self {
135
139
  match reason {
136
140
  "no_persisted_session_id" => ResumeRefusalReason::NoSessionId,
137
- "session_backing_store_missing" => {
138
- ResumeRefusalReason::SessionBackingStoreMissing {
139
- checked_paths: Vec::new(),
140
- recovery_hint: None,
141
- }
142
- }
141
+ "session_backing_store_missing" => ResumeRefusalReason::SessionBackingStoreMissing {
142
+ checked_paths: Vec::new(),
143
+ recovery_hint: None,
144
+ },
143
145
  "provider_resume_unsupported" => ResumeRefusalReason::ProviderResumeUnsupported {
144
146
  provider: String::new(),
145
147
  },
146
148
  "session_capture_incomplete" => ResumeRefusalReason::SessionCaptureIncomplete,
149
+ "session_identity_mismatch" => ResumeRefusalReason::SessionIdentityMismatch {
150
+ expected_agent_id: String::new(),
151
+ embedded_agent_id: String::new(),
152
+ session_id: String::new(),
153
+ rollout_path: None,
154
+ },
147
155
  other => ResumeRefusalReason::Other {
148
156
  legacy_reason: other.to_string(),
149
157
  },
@@ -155,15 +163,11 @@ impl ResumeRefusalReason {
155
163
  #[derive(Debug, Clone, PartialEq, Eq)]
156
164
  pub enum ResumePreflightOutcome {
157
165
  /// Worker can resume from the named session.
158
- Resume {
159
- session_id: String,
160
- },
166
+ Resume { session_id: String },
161
167
  /// `--allow-fresh` was set; worker will start fresh.
162
168
  FreshStart,
163
169
  /// Resume refused. Caller MUST NOT proceed with teardown/spawn.
164
- Refuse {
165
- reason: ResumeRefusalReason,
166
- },
170
+ Refuse { reason: ResumeRefusalReason },
167
171
  }
168
172
 
169
173
  /// Information about whether a provider backing file is present on disk.
@@ -239,9 +243,7 @@ impl ResumePreflight {
239
243
  } else {
240
244
  ResumePreflightOutcome::Refuse {
241
245
  reason: ResumeRefusalReason::SessionBackingStoreMissing {
242
- checked_paths: backing
243
- .map(|b| b.paths.clone())
244
- .unwrap_or_default(),
246
+ checked_paths: backing.map(|b| b.paths.clone()).unwrap_or_default(),
245
247
  recovery_hint,
246
248
  },
247
249
  }
@@ -272,6 +274,7 @@ mod tests {
272
274
  "session_backing_store_missing",
273
275
  "provider_resume_unsupported",
274
276
  "session_capture_incomplete",
277
+ "session_identity_mismatch",
275
278
  ] {
276
279
  assert_eq!(ResumeRefusalReason::from_legacy(wire).wire(), wire);
277
280
  }
@@ -383,10 +386,11 @@ mod tests {
383
386
  );
384
387
  match out {
385
388
  ResumePreflightOutcome::Refuse {
386
- reason: ResumeRefusalReason::SessionBackingStoreMissing {
387
- recovery_hint: Some(h),
388
- ..
389
- },
389
+ reason:
390
+ ResumeRefusalReason::SessionBackingStoreMissing {
391
+ recovery_hint: Some(h),
392
+ ..
393
+ },
390
394
  } => {
391
395
  assert_eq!(h, hint);
392
396
  }
@@ -405,9 +409,7 @@ mod tests {
405
409
  let out = ResumePreflight::check(Some("sess-x"), true, Some(&backing), "codex", false);
406
410
  match out {
407
411
  ResumePreflightOutcome::Refuse {
408
- reason: ResumeRefusalReason::SessionBackingStoreMissing {
409
- recovery_hint, ..
410
- },
412
+ reason: ResumeRefusalReason::SessionBackingStoreMissing { recovery_hint, .. },
411
413
  } => {
412
414
  assert!(recovery_hint.is_none());
413
415
  }
@@ -98,7 +98,15 @@ pub(super) fn parse_candidate_files(
98
98
  } else {
99
99
  Confidence::Low
100
100
  };
101
- let positive_agent_id_match = candidate_text_has_team_agent_id(&text, context);
101
+ let embedded_agent_id = embedded_team_agent_worker_id_from_text(&text);
102
+ if embedded_agent_id
103
+ .as_deref()
104
+ .is_some_and(|id| id != context.agent_id.as_str())
105
+ {
106
+ continue;
107
+ }
108
+ let positive_agent_id_match = candidate_text_has_team_agent_id(&text, context)
109
+ || embedded_agent_id.as_deref() == Some(context.agent_id.as_str());
102
110
  let agent_path_match = candidate_path_matches_agent_id(&path, context);
103
111
  if matches!(provider, Provider::Claude | Provider::ClaudeCode)
104
112
  && super::claude::records_have_leader_marker(&records)
@@ -113,6 +121,7 @@ pub(super) fn parse_candidate_files(
113
121
  attribution_confidence,
114
122
  spawn_cwd: context.spawn_cwd.clone(),
115
123
  },
124
+ embedded_agent_id,
116
125
  positive_agent_id_match,
117
126
  agent_path_match,
118
127
  });
@@ -308,6 +317,28 @@ fn candidate_text_has_team_agent_id(text: &str, context: &CaptureSessionContext)
308
317
  .any(|needle| text.contains(needle))
309
318
  }
310
319
 
320
+ pub(crate) fn embedded_team_agent_worker_id_from_text(text: &str) -> Option<String> {
321
+ const PREFIX: &str = "You are Team Agent worker `";
322
+ let start = text.find(PREFIX)? + PREFIX.len();
323
+ let rest = &text[start..];
324
+ let end = rest.find('`')?;
325
+ let id = &rest[..end];
326
+ if id.is_empty()
327
+ || !id
328
+ .chars()
329
+ .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
330
+ {
331
+ return None;
332
+ }
333
+ Some(id.to_string())
334
+ }
335
+
336
+ pub(crate) fn rollout_path_embedded_team_agent_worker_id(path: &Path) -> Option<String> {
337
+ read_head_text(path, CAPTURE_HEAD_BYTES)
338
+ .ok()
339
+ .and_then(|text| embedded_team_agent_worker_id_from_text(&text))
340
+ }
341
+
311
342
  fn candidate_path_matches_agent_id(path: &Path, context: &CaptureSessionContext) -> bool {
312
343
  let id = context.agent_id.as_str();
313
344
  if id.is_empty() {
@@ -348,3 +379,88 @@ fn path_is_under_team_runtime(path: &Path) -> bool {
348
379
  path.components()
349
380
  .any(|c| c.as_os_str() == std::ffi::OsStr::new(".team"))
350
381
  }
382
+
383
+ #[cfg(test)]
384
+ mod tests {
385
+ use super::*;
386
+
387
+ fn temp_dir(name: &str) -> PathBuf {
388
+ static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
389
+ let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
390
+ let dir =
391
+ std::env::temp_dir().join(format!("ta-session-scan-{name}-{}-{n}", std::process::id()));
392
+ std::fs::create_dir_all(&dir).expect("create temp dir");
393
+ dir
394
+ }
395
+
396
+ fn write_codex_rollout(path: &Path, cwd: &Path, session_id: &str, embedded_agent_id: &str) {
397
+ let text = format!(
398
+ "{{\"session_meta\":{{\"payload\":{{\"id\":\"{session_id}\",\"cwd\":\"{}\"}}}}}}\n\
399
+ {{\"type\":\"turn_context\",\"payload\":{{}}}}\n\
400
+ {{\"type\":\"response_item\",\"payload\":{{\"content\":[{{\"type\":\"input_text\",\"text\":\"You are Team Agent worker `{embedded_agent_id}` with role `fixture`.\"}}]}}}}\n",
401
+ cwd.to_string_lossy()
402
+ );
403
+ std::fs::write(path, text).expect("write codex rollout");
404
+ }
405
+
406
+ #[test]
407
+ fn codex_prompt_worker_identity_is_a_positive_match() {
408
+ let dir = temp_dir("positive");
409
+ let rollout = dir.join("rollout-frontend.jsonl");
410
+ write_codex_rollout(&rollout, &dir, "sess-frontend", "frontend");
411
+ let context = CaptureSessionContext {
412
+ agent_id: "frontend".to_string(),
413
+ spawn_cwd: dir.clone(),
414
+ pane_id: None,
415
+ pane_pid: None,
416
+ spawned_at: None,
417
+ expected_session_id: None,
418
+ provider_projects_root: None,
419
+ };
420
+ let candidates = parse_candidate_files(
421
+ Provider::Codex,
422
+ &context,
423
+ vec![SessionCandidate {
424
+ path: rollout.clone(),
425
+ requires_cwd_match: false,
426
+ }],
427
+ );
428
+ assert_eq!(candidates.len(), 1);
429
+ assert!(
430
+ candidates[0].positive_agent_id_match,
431
+ "Codex transcript prompt identity must be treated as a positive worker id source"
432
+ );
433
+ let _ = std::fs::remove_file(&rollout);
434
+ let _ = std::fs::remove_dir_all(&dir);
435
+ }
436
+
437
+ #[test]
438
+ fn codex_prompt_worker_identity_mismatch_is_rejected_for_that_agent() {
439
+ let dir = temp_dir("mismatch");
440
+ let rollout = dir.join("rollout-ios-dev.jsonl");
441
+ write_codex_rollout(&rollout, &dir, "sess-ios-dev", "ios-dev");
442
+ let context = CaptureSessionContext {
443
+ agent_id: "frontend".to_string(),
444
+ spawn_cwd: dir.clone(),
445
+ pane_id: None,
446
+ pane_pid: None,
447
+ spawned_at: None,
448
+ expected_session_id: None,
449
+ provider_projects_root: None,
450
+ };
451
+ let candidates = parse_candidate_files(
452
+ Provider::Codex,
453
+ &context,
454
+ vec![SessionCandidate {
455
+ path: rollout.clone(),
456
+ requires_cwd_match: false,
457
+ }],
458
+ );
459
+ assert!(
460
+ candidates.is_empty(),
461
+ "state agent=frontend must not accept a Codex rollout whose prompt says ios-dev"
462
+ );
463
+ let _ = std::fs::remove_file(&rollout);
464
+ let _ = std::fs::remove_dir_all(&dir);
465
+ }
466
+ }
@@ -48,6 +48,7 @@ fn copilot_candidate(
48
48
  attribution_confidence: Confidence::High,
49
49
  spawn_cwd: context.spawn_cwd.clone(),
50
50
  },
51
+ embedded_agent_id: None,
51
52
  positive_agent_id_match: false,
52
53
  agent_path_match: false,
53
54
  }
@@ -49,6 +49,7 @@ pub struct CaptureSessionContext {
49
49
  #[derive(Debug, Clone, PartialEq, Eq)]
50
50
  pub struct CapturedSessionCandidate {
51
51
  pub captured: CapturedSession,
52
+ pub embedded_agent_id: Option<String>,
52
53
  pub positive_agent_id_match: bool,
53
54
  pub agent_path_match: bool,
54
55
  }
@@ -146,7 +146,14 @@ fn errno_name(errno: Option<i32>) -> Option<&'static str> {
146
146
  }
147
147
 
148
148
  /// `runtime.py:_runtime_lock` 的 flock 版(RAII;Drop 释放)。state-save 不发锁事件。
149
- /// POSIX flock(unix);Windows 锁(LockFileEx)延平台层(step 9+)。
149
+ ///
150
+ /// 0.5.x Windows portability Batch 2: migrated to
151
+ /// `crate::platform::file_lock::{try_lock_once_nonblocking, unlock}` so
152
+ /// the same polling loop + timeout + `StateError::Locked(name)` shape
153
+ /// works on both Unix (`flock`) and Windows (`LockFileEx`) — 1:1
154
+ /// semantic mapping. The Batch 0 non-Unix `not_yet_implemented`
155
+ /// fallback is now removed (CR C-2 fallback burn-down; grep guard
156
+ /// `platform_fallback_burndown_batch0.rs` flipped in this batch).
150
157
  struct RuntimeLock {
151
158
  #[allow(dead_code)]
152
159
  file: std::fs::File,
@@ -160,42 +167,37 @@ impl RuntimeLock {
160
167
  }
161
168
  let file = std::fs::OpenOptions::new()
162
169
  .create(true)
170
+ .read(true)
163
171
  .write(true)
164
172
  .truncate(false)
165
173
  .open(&lock_path)?;
166
- #[cfg(unix)]
167
- {
168
- use std::os::unix::io::AsRawFd;
169
- let fd = file.as_raw_fd();
170
- let start = Instant::now();
171
- loop {
172
- // SAFETY: fd 来自打开的 lock_file,LOCK_EX|LOCK_NB 非阻塞。
173
- let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
174
- if rc == 0 {
175
- return Ok(Self { file });
174
+ let start = Instant::now();
175
+ loop {
176
+ match crate::platform::file_lock::try_lock_once_nonblocking(&file) {
177
+ Ok(true) => return Ok(Self { file }),
178
+ Ok(false) => {
179
+ if start.elapsed().as_secs_f64() >= timeout {
180
+ return Err(StateError::Locked(name.to_string()));
181
+ }
182
+ std::thread::sleep(Duration::from_millis(50));
176
183
  }
177
- if start.elapsed().as_secs_f64() >= timeout {
178
- return Err(StateError::Locked(name.to_string()));
184
+ Err(e) => {
185
+ // Byte-preserving: a real I/O error surfaces as a
186
+ // `StateError::Io` (via `From<io::Error>`), same as
187
+ // the current inline code would if `file.open()`
188
+ // failed. The lock-would-block case took the
189
+ // Ok(false) arm above.
190
+ return Err(StateError::from(e));
179
191
  }
180
- std::thread::sleep(Duration::from_millis(50));
181
192
  }
182
193
  }
183
- #[cfg(not(unix))]
184
- {
185
- let _ = timeout;
186
- Err(StateError::Locked(format!(
187
- "{name} (runtime lock not yet implemented on non-unix)"
188
- )))
189
- }
190
194
  }
191
195
  }
192
196
 
193
- #[cfg(unix)]
194
197
  impl Drop for RuntimeLock {
195
198
  fn drop(&mut self) {
196
- use std::os::unix::io::AsRawFd;
197
- // SAFETY: 释放本进程持有的 flock。
198
- unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) };
199
+ // Best-effort unlock. OS releases on handle close if this fails.
200
+ let _ = crate::platform::file_lock::unlock(&self.file);
199
201
  }
200
202
  }
201
203
 
@@ -232,6 +234,23 @@ pub(crate) fn save_runtime_state_with_lifecycle_topology_authority(
232
234
  save_runtime_state_with_merge_options(workspace, state, &[], None, &[], agent_ids)
233
235
  }
234
236
 
237
+ pub(crate) fn save_runtime_state_with_lifecycle_topology_authority_and_capture_backfill_skip(
238
+ workspace: &Path,
239
+ state: &Value,
240
+ skip_capture_backfill_team_key: &str,
241
+ skip_capture_backfill_agent_ids: &[&str],
242
+ topology_agent_ids: &[&str],
243
+ ) -> Result<(), StateError> {
244
+ save_runtime_state_with_merge_options(
245
+ workspace,
246
+ state,
247
+ &[],
248
+ Some(skip_capture_backfill_team_key),
249
+ skip_capture_backfill_agent_ids,
250
+ topology_agent_ids,
251
+ )
252
+ }
253
+
235
254
  pub(crate) fn save_runtime_state_with_deleted_agents(
236
255
  workspace: &Path,
237
256
  state: &Value,
@@ -451,6 +470,18 @@ fn apply_persist_merge_contract(
451
470
  // teams.<key> entry-scoped preservation at :370 remains so legacy
452
471
  // teams.<key>.team_owner entries still survive a save that omits
453
472
  // them.
473
+
474
+ // 0.5.x Windows portability Batch 7 F6 (leader msg_700a016675d0):
475
+ // preserve `state.transport.shim` across saves. The coordinator
476
+ // writes this block when it spawns `windows-shim.exe` during
477
+ // quick-start (see `coordinator::conpty_shim::finalize`).
478
+ // Downstream saves inside the same quick-start (leader-persist,
479
+ // worker-persist) construct their `incoming` state from an
480
+ // in-memory copy that predates the shim-spawn, so without this
481
+ // preserve step the shim block is silently dropped. That
482
+ // clobber broke Batch 6 CHECK 6 (`shutdown` couldn't route to
483
+ // the shim pid because `state.transport.shim.pid` was gone).
484
+ preserve_transport_shim(incoming, latest);
454
485
  }
455
486
 
456
487
  let latest_teams = latest.get("teams").and_then(Value::as_object);
@@ -478,6 +509,47 @@ fn apply_persist_merge_contract(
478
509
  Ok(())
479
510
  }
480
511
 
512
+ /// 0.5.x Windows portability Batch 7 F6: copy `latest.transport.shim`
513
+ /// into `incoming` if the incoming save omitted it. The coordinator's
514
+ /// `conpty_shim::finalize` writes `state.transport.shim = {pid,
515
+ /// pipe_name, pipe_ready}` after Hello succeeds. If a subsequent save
516
+ /// in the same quick-start starts from a stale in-memory `Value` that
517
+ /// predates the finalize, this preserve step keeps the on-disk shim
518
+ /// block alive.
519
+ ///
520
+ /// Only runs when the top-level projection matches (same active team
521
+ /// key + same session name) — no risk of leaking a shim block into
522
+ /// an unrelated projection.
523
+ ///
524
+ /// Windows-relevant behavior; on Unix `transport.shim` is never
525
+ /// written, so this fn is a no-op (both branches see `None`).
526
+ fn preserve_transport_shim(incoming: &mut Value, latest: &Value) {
527
+ // The shim block only matters when `latest` has one AND
528
+ // `incoming` doesn't (or `incoming.transport` doesn't exist).
529
+ let Some(latest_shim) = latest
530
+ .get("transport")
531
+ .and_then(|t| t.get("shim"))
532
+ .cloned()
533
+ else {
534
+ return;
535
+ };
536
+ let Some(incoming_obj) = incoming.as_object_mut() else {
537
+ return;
538
+ };
539
+ let transport = incoming_obj
540
+ .entry("transport".to_string())
541
+ .or_insert_with(|| serde_json::json!({}));
542
+ let Some(transport_obj) = transport.as_object_mut() else {
543
+ return;
544
+ };
545
+ // If the incoming already has a shim block, respect it (the
546
+ // caller has authoritative newer state — e.g. shim was rotated).
547
+ if transport_obj.contains_key("shim") {
548
+ return;
549
+ }
550
+ transport_obj.insert("shim".to_string(), latest_shim);
551
+ }
552
+
481
553
  fn should_skip_capture_backfill(
482
554
  current_team_key: Option<&str>,
483
555
  skip_team_key: Option<&str>,
@@ -1696,6 +1768,131 @@ mod tests {
1696
1768
  );
1697
1769
  }
1698
1770
 
1771
+ // 0.5.x Windows portability Batch 7 F6 (leader msg_700a016675d0):
1772
+ // `state.transport.shim` must survive a downstream save whose
1773
+ // `incoming` was constructed from a stale in-memory Value.
1774
+ #[test]
1775
+ fn transport_shim_block_is_preserved_across_merge() {
1776
+ let ws = temp_ws();
1777
+ // Disk state has the shim block (coordinator wrote it after
1778
+ // Hello succeeded).
1779
+ write_state(
1780
+ &ws,
1781
+ &json!({
1782
+ "session_name": "team-a",
1783
+ "active_team_key": "team-a",
1784
+ "agents": {},
1785
+ "transport": {
1786
+ "kind": "conpty",
1787
+ "shim": { "pid": 4242, "pipe_name": r"\\.\pipe\team-agent-conpty-abc-team-a", "pipe_ready": true }
1788
+ }
1789
+ }),
1790
+ );
1791
+ // Incoming save (leader-persist or worker-persist) doesn't
1792
+ // know about the shim block.
1793
+ let incoming = json!({
1794
+ "session_name": "team-a",
1795
+ "active_team_key": "team-a",
1796
+ "agents": {},
1797
+ "transport": {
1798
+ "kind": "conpty"
1799
+ }
1800
+ });
1801
+ save_runtime_state(&ws, &incoming).unwrap();
1802
+ let saved = read_state(&ws);
1803
+ // Shim block must survive the merge.
1804
+ assert_eq!(
1805
+ saved.pointer("/transport/shim/pid").and_then(Value::as_u64),
1806
+ Some(4242),
1807
+ "shim.pid must be preserved: {saved}"
1808
+ );
1809
+ assert_eq!(
1810
+ saved
1811
+ .pointer("/transport/shim/pipe_name")
1812
+ .and_then(Value::as_str),
1813
+ Some(r"\\.\pipe\team-agent-conpty-abc-team-a"),
1814
+ );
1815
+ assert_eq!(
1816
+ saved
1817
+ .pointer("/transport/shim/pipe_ready")
1818
+ .and_then(Value::as_bool),
1819
+ Some(true),
1820
+ );
1821
+ // And the incoming's `transport.kind` still wins.
1822
+ assert_eq!(
1823
+ saved.pointer("/transport/kind").and_then(Value::as_str),
1824
+ Some("conpty"),
1825
+ );
1826
+ }
1827
+
1828
+ #[test]
1829
+ fn transport_shim_incoming_authoritative_overrides_latest() {
1830
+ // If the caller has a NEWER shim block (e.g. rotation), it
1831
+ // wins — preserve_transport_shim only fills in the gap.
1832
+ let ws = temp_ws();
1833
+ write_state(
1834
+ &ws,
1835
+ &json!({
1836
+ "session_name": "team-a",
1837
+ "active_team_key": "team-a",
1838
+ "agents": {},
1839
+ "transport": {
1840
+ "kind": "conpty",
1841
+ "shim": { "pid": 1000, "pipe_name": "old", "pipe_ready": true }
1842
+ }
1843
+ }),
1844
+ );
1845
+ let incoming = json!({
1846
+ "session_name": "team-a",
1847
+ "active_team_key": "team-a",
1848
+ "agents": {},
1849
+ "transport": {
1850
+ "kind": "conpty",
1851
+ "shim": { "pid": 9999, "pipe_name": "new", "pipe_ready": true }
1852
+ }
1853
+ });
1854
+ save_runtime_state(&ws, &incoming).unwrap();
1855
+ let saved = read_state(&ws);
1856
+ assert_eq!(
1857
+ saved.pointer("/transport/shim/pid").and_then(Value::as_u64),
1858
+ Some(9999),
1859
+ "incoming shim block must win when both sides have one"
1860
+ );
1861
+ assert_eq!(
1862
+ saved
1863
+ .pointer("/transport/shim/pipe_name")
1864
+ .and_then(Value::as_str),
1865
+ Some("new"),
1866
+ );
1867
+ }
1868
+
1869
+ #[test]
1870
+ fn transport_shim_no_leak_when_latest_lacks_block() {
1871
+ // If disk has no shim block, save must not synthesize one.
1872
+ let ws = temp_ws();
1873
+ write_state(
1874
+ &ws,
1875
+ &json!({
1876
+ "session_name": "team-a",
1877
+ "active_team_key": "team-a",
1878
+ "agents": {},
1879
+ "transport": {"kind": "tmux"}
1880
+ }),
1881
+ );
1882
+ let incoming = json!({
1883
+ "session_name": "team-a",
1884
+ "active_team_key": "team-a",
1885
+ "agents": {},
1886
+ "transport": {"kind": "tmux"}
1887
+ });
1888
+ save_runtime_state(&ws, &incoming).unwrap();
1889
+ let saved = read_state(&ws);
1890
+ assert!(
1891
+ saved.pointer("/transport/shim").is_none(),
1892
+ "shim must not appear when neither side had one: {saved}"
1893
+ );
1894
+ }
1895
+
1699
1896
  #[test]
1700
1897
  fn projection_paths_do_not_cross_backfill_topology() {
1701
1898
  let ws = temp_ws();