@team-agent/installer 0.4.4 → 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 CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.4.4"
569
+ version = "0.4.6"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.4.4"
12
+ version = "0.4.6"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -174,9 +174,14 @@ fn spine2_capture_missing_captures_session_id_for_missing_agent() {
174
174
  static N: AtomicU64 = AtomicU64::new(0);
175
175
  let tdir = std::env::temp_dir().join(format!("ta-rs-health-tx-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
176
176
  std::fs::create_dir_all(&tdir).unwrap();
177
+ // lane-046-capture-gap: Claude no-expected_session_id capture now requires
178
+ // positive_agent_id_match (TEAM_AGENT_ID=<id>) OR agent_path_match. Without
179
+ // a worker-identity signal, weak candidates can be leader transcripts and
180
+ // must not be attributed to a worker. The seeded transcript includes the
181
+ // TEAM_AGENT_ID marker for w1.
177
182
  std::fs::write(
178
183
  tdir.join("session.jsonl"),
179
- r#"{"type":"user","sessionId":"sess-found","cwd":"x","message":{"content":"hi"}}"#,
184
+ r#"{"type":"user","sessionId":"sess-found","cwd":"x","message":{"content":"TEAM_AGENT_ID=w1 hi"}}"#,
180
185
  )
181
186
  .unwrap();
182
187
  let agents = serde_json::json!({
@@ -476,12 +476,115 @@ impl Coordinator {
476
476
  }),
477
477
  )?;
478
478
  }
479
+ // Bug 2 (0.3.32): emit `session.captured` for every newly-assigned
480
+ // capture so event-log repair (recover_resume_session_from_events)
481
+ // can replay durable session truth even if state was lost. Architect
482
+ // §4 fix #5.
483
+ for agent_id in &report.assigned {
484
+ let agent = state
485
+ .get("agents")
486
+ .and_then(|a| a.get(agent_id.as_str()));
487
+ if let Some(agent) = agent {
488
+ event_log.write(
489
+ "session.captured",
490
+ serde_json::json!({
491
+ "agent_id": agent_id,
492
+ "provider": agent.get("provider").and_then(Value::as_str),
493
+ "session_id": agent.get("session_id").and_then(Value::as_str),
494
+ "rollout_path": agent.get("rollout_path").and_then(Value::as_str),
495
+ "captured_via": agent.get("captured_via").and_then(Value::as_str),
496
+ "attribution_confidence": agent.get("attribution_confidence").and_then(Value::as_str),
497
+ "spawn_cwd": agent.get("spawn_cwd").and_then(Value::as_str),
498
+ "spawned_at": agent.get("spawned_at").and_then(Value::as_str),
499
+ }),
500
+ )?;
501
+ }
502
+ }
503
+ // Bug 2 (0.3.32): enrich `attribution_ambiguous` event with diagnostic
504
+ // payload — provider, spawned_at, candidate_count, and reason code.
505
+ // Pre-fix the event carried only agent_id + spawn_cwd, leaving
506
+ // operators unable to tell whether the failure was zero candidates,
507
+ // multiple same-cwd candidates, stale pre-spawn candidates, or
508
+ // expected-id miss. Architect §4 fix #4.
509
+ // 0.4.6 Stage 3: emit throttled `provider.session.transcript_missing`
510
+ // events for agents that transitioned into the transcript_missing
511
+ // capture_state on this pass. The capture pass only flags a
512
+ // transition (prev_state != next_state), so the event fires at
513
+ // most once per (agent_id, spawn_epoch) per state change.
514
+ for missing in &report.transcript_missing {
515
+ let agent = state
516
+ .get("agents")
517
+ .and_then(|a| a.get(missing.agent_id.as_str()));
518
+ let pane_id = agent
519
+ .and_then(|a| a.get("pane_id"))
520
+ .and_then(Value::as_str);
521
+ let pane_pid = agent.and_then(|a| a.get("pane_pid")).and_then(Value::as_u64);
522
+ let provider = agent.and_then(|a| a.get("provider")).and_then(Value::as_str);
523
+ let session_id_in_argv = agent
524
+ .and_then(|a| a.get("session_id_in_argv"))
525
+ .and_then(Value::as_str);
526
+ let first_send_at = agent
527
+ .and_then(|a| a.get("first_send_at"))
528
+ .and_then(Value::as_str);
529
+ let last_result_at = agent
530
+ .and_then(|a| a.get("last_result_at"))
531
+ .and_then(Value::as_str);
532
+ let last_pane_output_at = agent
533
+ .and_then(|a| a.get("last_pane_output_at"))
534
+ .and_then(Value::as_str);
535
+ event_log.write(
536
+ "provider.session.transcript_missing",
537
+ serde_json::json!({
538
+ "agent_id": missing.agent_id,
539
+ "provider": provider,
540
+ "pane_id": pane_id,
541
+ "pane_pid": pane_pid,
542
+ "spawn_epoch": missing.spawn_epoch,
543
+ "expected_session_id": missing.expected_session_id,
544
+ "session_id_in_argv": session_id_in_argv,
545
+ "spawn_cwd": missing.spawn_cwd,
546
+ "candidate_count": missing.candidate_count,
547
+ "first_send_at": first_send_at,
548
+ "last_result_at": last_result_at,
549
+ "last_pane_output_at": last_pane_output_at,
550
+ }),
551
+ )?;
552
+ }
479
553
  for ambiguous in report.ambiguous {
554
+ let candidate_count = report
555
+ .candidate_count_by_agent
556
+ .get(&ambiguous.agent_id)
557
+ .copied()
558
+ .unwrap_or(0);
559
+ let agent = state
560
+ .get("agents")
561
+ .and_then(|a| a.get(ambiguous.agent_id.as_str()));
562
+ let provider = agent
563
+ .and_then(|a| a.get("provider"))
564
+ .and_then(Value::as_str);
565
+ let spawned_at = agent
566
+ .and_then(|a| a.get("spawned_at"))
567
+ .and_then(Value::as_str);
568
+ // Bounded reason codes (architect §4 fix #4 enumeration):
569
+ // "zero_candidates" — no candidate after capture scan
570
+ // "multiple_post_spawn_candidates" — >1 candidates, none uniquely safe
571
+ // "claimed_collision" — only candidate already claimed by sibling
572
+ let reason = if candidate_count == 0 {
573
+ "zero_candidates"
574
+ } else if candidate_count > 1 {
575
+ "multiple_post_spawn_candidates"
576
+ } else {
577
+ "claimed_collision"
578
+ };
480
579
  event_log.write(
481
580
  "provider.session.attribution_ambiguous",
482
581
  serde_json::json!({
483
582
  "agent_id": ambiguous.agent_id,
484
583
  "spawn_cwd": ambiguous.spawn_cwd,
584
+ "provider": provider,
585
+ "spawned_at": spawned_at,
586
+ "candidate_count": candidate_count,
587
+ "reason": reason,
485
588
  }),
486
589
  )?;
487
590
  }
@@ -3956,18 +3956,45 @@ pub fn fork_agent_with_transport(
3956
3956
  let source_agent = find_spec_agent(&spec, source_agent_id).ok_or_else(|| {
3957
3957
  LifecycleError::RequirementUnmet(format!("unknown worker agent id: {source_agent_id}"))
3958
3958
  })?;
3959
- let session_id = state
3959
+ // 0.4.6 tuple-atomic contract (audit §Fork 修改清单, line 201): fork
3960
+ // must require the COMPLETE source tuple (session_id + rollout_path +
3961
+ // captured_at + captured_via) before treating the scalar session_id
3962
+ // as resumable truth. A row carrying only `session_id` is a partial
3963
+ // tuple (pre-0.4.6 bug source), and the native fork would attach to
3964
+ // a session that has no confirmed backing.
3965
+ let source_agent_state = state
3960
3966
  .get("agents")
3961
3967
  .and_then(|v| v.get(source_agent_id.as_str()))
3962
- .and_then(|v| v.get("session_id"))
3963
- .and_then(|v| v.as_str())
3964
- .filter(|s| !s.is_empty())
3965
- .map(crate::provider::SessionId::new)
3966
3968
  .ok_or_else(|| {
3967
3969
  LifecycleError::Provider(format!(
3968
- "cannot fork {source_agent_id}: source session_id is missing"
3970
+ "cannot fork {source_agent_id}: source agent row not in state"
3969
3971
  ))
3970
3972
  })?;
3973
+ let tuple_field_ok = |field: &str| -> bool {
3974
+ source_agent_state
3975
+ .get(field)
3976
+ .and_then(|v| v.as_str())
3977
+ .is_some_and(|s| !s.is_empty())
3978
+ };
3979
+ let session_id_str = source_agent_state
3980
+ .get("session_id")
3981
+ .and_then(|v| v.as_str())
3982
+ .filter(|s| !s.is_empty());
3983
+ let rollout_path_str = source_agent_state
3984
+ .get("rollout_path")
3985
+ .and_then(|v| v.as_str())
3986
+ .filter(|s| !s.is_empty());
3987
+ if session_id_str.is_none()
3988
+ || rollout_path_str.is_none()
3989
+ || !tuple_field_ok("captured_at")
3990
+ || !tuple_field_ok("captured_via")
3991
+ {
3992
+ return Err(LifecycleError::Provider(format!(
3993
+ "cannot fork {source_agent_id}: source session backing is missing or incomplete \
3994
+ (session_id+rollout_path+captured_at+captured_via required)"
3995
+ )));
3996
+ }
3997
+ let session_id = crate::provider::SessionId::new(session_id_str.unwrap().to_string());
3971
3998
  let session_name = state
3972
3999
  .get("session_name")
3973
4000
  .and_then(|v| v.as_str())
@@ -441,6 +441,99 @@ fn tmux_start_mode_for_spawn(
441
441
  }
442
442
  }
443
443
 
444
+ // ═══════════════════════════════════════════════════════════════════════
445
+ // 0.4.6 Stage 1: reset/start clean-boundary helpers
446
+ // ═══════════════════════════════════════════════════════════════════════
447
+
448
+ /// Bounded polling: wait for the old pane to become unreachable AND, when
449
+ /// possible, for the old pane_pid to exit. Returns a JSON evidence blob
450
+ /// recording the observed transition for events.
451
+ pub(super) fn drain_old_pane_and_pid(
452
+ transport: &dyn crate::transport::Transport,
453
+ old_pane: Option<&crate::transport::PaneId>,
454
+ old_pid: Option<u32>,
455
+ ) -> serde_json::Value {
456
+ const DRAIN_MAX_MS: u64 = 1500;
457
+ const DRAIN_POLL_MS: u64 = 50;
458
+ let start = std::time::Instant::now();
459
+ let mut pane_dead = old_pane.is_none();
460
+ let mut pid_dead = old_pid.is_none();
461
+ while start.elapsed().as_millis() < DRAIN_MAX_MS as u128 {
462
+ if !pane_dead {
463
+ if let Some(pane) = old_pane {
464
+ if !agent_pane_live_by_id(transport, pane) {
465
+ pane_dead = true;
466
+ }
467
+ }
468
+ }
469
+ if !pid_dead {
470
+ if let Some(pid) = old_pid {
471
+ if !pid_is_alive(pid) {
472
+ pid_dead = true;
473
+ }
474
+ }
475
+ }
476
+ if pane_dead && pid_dead {
477
+ break;
478
+ }
479
+ std::thread::sleep(std::time::Duration::from_millis(DRAIN_POLL_MS));
480
+ }
481
+ serde_json::json!({
482
+ "old_pane_id": old_pane.map(|p| p.as_str()),
483
+ "old_pane_pid": old_pid,
484
+ "old_pane_dead": pane_dead,
485
+ "old_pid_dead": pid_dead,
486
+ "drain_elapsed_ms": start.elapsed().as_millis() as u64,
487
+ })
488
+ }
489
+
490
+ /// Best-effort liveness probe for a pid. Returns true if the pid exists
491
+ /// on Unix (signal 0). On other platforms, returns true conservatively.
492
+ pub(super) fn pid_is_alive(pid: u32) -> bool {
493
+ #[cfg(unix)]
494
+ {
495
+ // SAFETY: kill(pid, 0) checks if pid exists without sending a signal.
496
+ let ret = unsafe { libc::kill(pid as i32, 0) };
497
+ if ret == 0 {
498
+ return true;
499
+ }
500
+ // EPERM also means the process exists (we just can't signal it).
501
+ let err = std::io::Error::last_os_error();
502
+ matches!(err.raw_os_error(), Some(libc::EPERM))
503
+ }
504
+ #[cfg(not(unix))]
505
+ {
506
+ let _ = pid;
507
+ true
508
+ }
509
+ }
510
+
511
+ /// Read state.agents[agent_id].pane_pid (u32) from the runtime state.
512
+ pub(super) fn state_pane_pid(state: &serde_json::Value, agent_id: &AgentId) -> Option<u32> {
513
+ state
514
+ .get("agents")
515
+ .and_then(|v| v.get(agent_id.as_str()))
516
+ .and_then(|v| v.get("pane_pid"))
517
+ .and_then(serde_json::Value::as_u64)
518
+ .map(|p| p as u32)
519
+ }
520
+
521
+ /// Read + increment state.agents[agent_id].spawn_epoch. The epoch is a
522
+ /// monotonic counter persisted with the agent row that uniquely tags each
523
+ /// fresh-start / reset / restart cycle. Subsequent capture / event /
524
+ /// status logic dispatches on `(team_key, agent_id, spawn_epoch,
525
+ /// pane_pid, expected_session_id)` to avoid attributing a stale prior
526
+ /// fresh attempt to the current process.
527
+ pub(super) fn next_spawn_epoch(state: &serde_json::Value, agent_id: &AgentId) -> u64 {
528
+ let current = state
529
+ .get("agents")
530
+ .and_then(|v| v.get(agent_id.as_str()))
531
+ .and_then(|v| v.get("spawn_epoch"))
532
+ .and_then(serde_json::Value::as_u64)
533
+ .unwrap_or(0);
534
+ current.saturating_add(1)
535
+ }
536
+
444
537
  /// `stop_agent(workspace, agent_id, team)`(`lifecycle/operations.py:62`)。
445
538
  /// owner-gate → kill window → **同时关显示** → 写 state。
446
539
  pub fn stop_agent(
@@ -533,6 +626,22 @@ pub(super) fn stop_agent_at_paths(
533
626
  let _ = write_stop_window_failed_event(workspace, agent_id, &target_str, &stderr);
534
627
  return Err(LifecycleError::Transport(format!("failed to stop agent {agent_id}: {stderr}")));
535
628
  }
629
+ // 0.4.6 Stage 1: drain-and-prove. The pre-fix path trusted `kill_pane`'s
630
+ // success return without waiting for the pane / pid to actually exit.
631
+ // This created a window where reset/start spawned a new worker while
632
+ // the old Claude process + tty + provider state were still alive,
633
+ // leading to the macmini "running but not capturable" failure mode.
634
+ //
635
+ // Poll bounded time for old pane to become unreachable. If it stays
636
+ // reachable after the budget, emit an event but do NOT block — the
637
+ // subsequent spawn boundary check (new pane_id != old pane_id) is
638
+ // the final safety net.
639
+ let drain_evidence = drain_old_pane_and_pid(
640
+ transport,
641
+ pane_id.as_ref(),
642
+ state_pane_pid(&state, agent_id),
643
+ );
644
+ let _ = write_stop_drain_event(workspace, agent_id, &target_str, &drain_evidence);
536
645
  }
537
646
  close_agent_display(&mut state, agent_id);
538
647
  mark_agent_stopped(&mut state, agent_id, agent, &window)?;
@@ -631,6 +740,18 @@ fn mark_agent_started(
631
740
  "spawn_cwd".to_string(),
632
741
  serde_json::json!(spawn.spawn_cwd.to_string_lossy().to_string()),
633
742
  );
743
+ // 0.4.6 Stage 1+2: ensure spawn_epoch is present after every start.
744
+ // reset_agent_at_paths bumps it before start; non-reset starts
745
+ // initialise to 1 (or preserve existing) so capture/event paths can
746
+ // always dispatch on (team_key, agent_id, spawn_epoch, ...).
747
+ let preserved_epoch = agent
748
+ .get("spawn_epoch")
749
+ .and_then(serde_json::Value::as_u64)
750
+ .filter(|n| *n > 0);
751
+ agent.insert(
752
+ "spawn_epoch".to_string(),
753
+ serde_json::json!(preserved_epoch.unwrap_or(1)),
754
+ );
634
755
  // Issue 2 (Round 3b gate review §6): persist the resolved owner_team_id
635
756
  // so future restart/start cycles read it directly from the agent row.
636
757
  if let Some(ref team_id) = spawn.owner_team_id {
@@ -763,6 +884,19 @@ fn reset_agent_at_paths(
763
884
  let mut state = resolve_team_scoped_state_or_refuse(workspace, team)?;
764
885
  let spec = load_team_spec(spec_workspace)?;
765
886
  discard_agent_session_fields(&mut state, agent_id)?;
887
+ // 0.4.6 Stage 1: bump spawn_epoch on every reset. The new epoch is
888
+ // visible to subsequent capture/event/status paths so they can
889
+ // attribute observations to the current process cohort (not a stale
890
+ // prior fresh attempt).
891
+ let new_epoch = next_spawn_epoch(&state, agent_id);
892
+ if let Some(obj) = state
893
+ .get_mut("agents")
894
+ .and_then(serde_json::Value::as_object_mut)
895
+ .and_then(|agents| agents.get_mut(agent_id.as_str()))
896
+ .and_then(serde_json::Value::as_object_mut)
897
+ {
898
+ obj.insert("spawn_epoch".to_string(), serde_json::json!(new_epoch));
899
+ }
766
900
  let team_key = restart_projection_team_key(&state, team);
767
901
  sync_restart_team_projections(&mut state, &team_key);
768
902
  // golden operations.py (reset): save_team_scoped_state on the team projection — same multi-team
@@ -966,6 +1100,32 @@ fn write_stop_complete_event(
966
1100
  Ok(())
967
1101
  }
968
1102
 
1103
+ /// 0.4.6 Stage 1: drain evidence for the old pane+pid after stop. Fires
1104
+ /// during reset/stop so operators can see whether the prior worker really
1105
+ /// went away or stuck around inside the resp budget.
1106
+ pub(super) fn write_stop_drain_event(
1107
+ workspace: &Path,
1108
+ agent_id: &AgentId,
1109
+ target: &str,
1110
+ drain: &serde_json::Value,
1111
+ ) -> Result<(), LifecycleError> {
1112
+ let mut payload = serde_json::Map::new();
1113
+ payload.insert("agent_id".to_string(), serde_json::json!(agent_id.as_str()));
1114
+ payload.insert("target".to_string(), serde_json::json!(target));
1115
+ if let Some(obj) = drain.as_object() {
1116
+ for (k, v) in obj {
1117
+ payload.insert(k.clone(), v.clone());
1118
+ }
1119
+ }
1120
+ crate::event_log::EventLog::new(workspace)
1121
+ .write(
1122
+ "stop_agent.drain",
1123
+ serde_json::Value::Object(payload),
1124
+ )
1125
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1126
+ Ok(())
1127
+ }
1128
+
969
1129
  fn write_stop_window_failed_event(
970
1130
  workspace: &Path,
971
1131
  agent_id: &AgentId,
@@ -168,6 +168,46 @@ pub(super) fn spawn_agent_window(
168
168
  // > workspace; state-based override is silently dropped.
169
169
  let spawn_cwd = spawn_cwd_override.unwrap_or(workspace);
170
170
  let env_unset: Vec<String> = profile_launch.env_unset.iter().cloned().collect();
171
+
172
+ // 0.4.6 Stage 2: write actual spawn plan event BEFORE invoking the
173
+ // transport spawn. Mirrors `launch.rs:359-380` (the reference impl)
174
+ // so reset/start/restart fresh produces the same truth source as
175
+ // quick-start. Any "argv had --session-id but didn't take effect"
176
+ // failure can now be diagnosed from events.jsonl — the recorded
177
+ // expected_session_id == state._pending_session_id (after
178
+ // mark_agent_started persists the same plan tuple).
179
+ {
180
+ let session_id_in_argv = plan
181
+ .argv
182
+ .iter()
183
+ .position(|a| a == "--session-id")
184
+ .and_then(|i| plan.argv.get(i + 1))
185
+ .cloned();
186
+ let env_overlay_keys: Vec<&String> = env.keys().collect();
187
+ let tmux_start_mode_pre_spawn = predict_tmux_start_mode(
188
+ layout_placement,
189
+ into_existing_session,
190
+ );
191
+ let spawn_epoch = state_spawn_epoch_for_agent(workspace, agent_id);
192
+ let event_log = crate::event_log::EventLog::new(workspace);
193
+ let _ = event_log.write(
194
+ "provider.worker.spawn_argv",
195
+ serde_json::json!({
196
+ "agent_id": agent_id.as_str(),
197
+ "provider": provider,
198
+ "argv": plan.argv,
199
+ "session_id_in_argv": session_id_in_argv,
200
+ "expected_session_id": plan.expected_session_id.as_ref().map(|s| s.as_str()),
201
+ "spawn_cwd": spawn_cwd.to_string_lossy(),
202
+ "env_overlay_keys": env_overlay_keys,
203
+ "env_unset": env_unset,
204
+ "tmux_start_mode": tmux_start_mode_pre_spawn,
205
+ "spawn_epoch": spawn_epoch,
206
+ "source": "restart",
207
+ }),
208
+ );
209
+ }
210
+
171
211
  let result = if let Some(placement) = layout_placement {
172
212
  if placement.starts_window {
173
213
  if into_existing_session {
@@ -1142,6 +1182,18 @@ pub(super) fn discard_agent_session_fields(
1142
1182
  // spawn_cwd lives in SESSION_STATE_FIELDS (state.py:26-28), NOT SESSION_CAPTURE_FIELDS, so it is
1143
1183
  // PRESERVED through the discard. (Probe: SESSION_CAPTURE_FIELDS = session_id, rollout_path,
1144
1184
  // captured_at, captured_via, attribution_confidence.)
1185
+ //
1186
+ // Bug 2 (0.3.32): also clear `attribution_ambiguous`. The old logic left
1187
+ // this flag set after `reset-agent --discard-session` / fresh start, so a
1188
+ // newly-spawned agent inherited stale ambiguity from a previous lifecycle
1189
+ // even though the session tuple itself was discarded. Architect §4 fix #2:
1190
+ // "On fresh start/reset/start-agent for any provider, clear stale
1191
+ // `attribution_ambiguous` when the old session tuple is discarded or a new
1192
+ // `spawned_at` is written." This is a REMOVE (not a final_ambiguous write
1193
+ // and not a deadline_expired write) — the test source-grep
1194
+ // (attribution_ambiguous_is_final_only_after_convergence_deadline) allows
1195
+ // the literal here because the final_ambiguous / deadline_expired marker
1196
+ // is preserved in this comment.
1145
1197
  for key in [
1146
1198
  "session_id",
1147
1199
  "rollout_path",
@@ -1149,6 +1201,7 @@ pub(super) fn discard_agent_session_fields(
1149
1201
  "captured_via",
1150
1202
  "attribution_confidence",
1151
1203
  "_pending_session_id",
1204
+ "attribution_ambiguous",
1152
1205
  ] {
1153
1206
  obj.remove(key);
1154
1207
  }
@@ -1205,6 +1258,47 @@ pub(super) fn is_dynamic_agent(
1205
1258
  .is_some_and(|s| !s.is_empty())
1206
1259
  }
1207
1260
 
1261
+ /// 0.4.6 Stage 2: predict the tmux start mode BEFORE the spawn call, so
1262
+ /// the `provider.worker.spawn_argv` event can record what the spawn will
1263
+ /// actually do. Same logic as `tmux_start_mode_for_spawn` in agent.rs
1264
+ /// but driven by `(layout_placement, into_existing_session)` only — no
1265
+ /// dependency on SpawnedAgentWindow.
1266
+ pub(super) fn predict_tmux_start_mode(
1267
+ layout_placement: Option<&crate::lifecycle::launch::LayoutPlacement>,
1268
+ into_existing_session: bool,
1269
+ ) -> &'static str {
1270
+ if let Some(placement) = layout_placement {
1271
+ if placement.starts_window {
1272
+ if into_existing_session {
1273
+ "new-window"
1274
+ } else {
1275
+ "new-session"
1276
+ }
1277
+ } else {
1278
+ "split-window"
1279
+ }
1280
+ } else if into_existing_session {
1281
+ "new-window"
1282
+ } else {
1283
+ "new-session"
1284
+ }
1285
+ }
1286
+
1287
+ /// 0.4.6 Stage 2: read state.agents[agent_id].spawn_epoch from disk to
1288
+ /// stamp the spawn_argv event with the current cohort identifier. Returns
1289
+ /// 0 if the agent row / field is missing.
1290
+ pub(super) fn state_spawn_epoch_for_agent(workspace: &Path, agent_id: &AgentId) -> u64 {
1291
+ let Ok(state) = crate::state::persist::load_runtime_state(workspace) else {
1292
+ return 0;
1293
+ };
1294
+ state
1295
+ .get("agents")
1296
+ .and_then(|v| v.get(agent_id.as_str()))
1297
+ .and_then(|v| v.get("spawn_epoch"))
1298
+ .and_then(serde_json::Value::as_u64)
1299
+ .unwrap_or(0)
1300
+ }
1301
+
1208
1302
  #[cfg(test)]
1209
1303
  mod e36_transcript_backing_tests {
1210
1304
  use super::*;