@team-agent/installer 0.5.56 → 0.5.58

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.
@@ -172,6 +172,110 @@ pub(super) fn finalize_fork_state(input: ForkFinalizeInput<'_>) -> Result<(), Li
172
172
  .map_err(|error| LifecycleError::StatePersist(error.to_string()))
173
173
  }
174
174
 
175
+ pub(super) struct ForkPendingFinalizeInput<'a> {
176
+ pub workspace: &'a Path,
177
+ pub team_key: &'a str,
178
+ pub source_agent_id: &'a AgentId,
179
+ pub agent_id: &'a AgentId,
180
+ pub spec_agent: &'a Value,
181
+ pub safety: &'a DangerousApproval,
182
+ pub plan: &'a crate::provider::CommandPlan,
183
+ pub profile_launch: &'a crate::provider::ProviderProfileLaunch,
184
+ pub spawn: &'a crate::transport::SpawnResult,
185
+ pub profile_dir: &'a Path,
186
+ pub dynamic_role_file: &'a Path,
187
+ pub pending: &'a crate::provider::session::PendingContextFork,
188
+ pub spawn_epoch: u64,
189
+ }
190
+
191
+ pub(super) fn finalize_pending_fork_state(
192
+ input: ForkPendingFinalizeInput<'_>,
193
+ ) -> Result<(), LifecycleError> {
194
+ let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
195
+ workspace: input.workspace,
196
+ operation: "fork-agent-pending",
197
+ team: Some(input.team_key),
198
+ agent_id: Some(input.agent_id),
199
+ })?;
200
+ let mut next_state = crate::state::selector::resolve_active_team(
201
+ input.workspace,
202
+ Some(input.team_key),
203
+ crate::state::selector::SelectorMode::RequireSpec,
204
+ )
205
+ .map_err(|error| LifecycleError::TeamSelect(error.to_string()))?
206
+ .state;
207
+ upsert_pending_forked_agent_state(
208
+ &mut next_state,
209
+ input.source_agent_id,
210
+ input.agent_id,
211
+ input.spec_agent,
212
+ input.safety,
213
+ input.plan,
214
+ input.profile_launch,
215
+ input.spawn,
216
+ Some(input.profile_dir),
217
+ input.dynamic_role_file,
218
+ input.pending,
219
+ input.spawn_epoch,
220
+ )?;
221
+ maybe_fail_fork_after_spawn("save_runtime_state")?;
222
+ crate::state::repository::StateRepository::new(input.workspace)
223
+ .save(
224
+ crate::state::repository::StateWriteIntent::ForkAgent {
225
+ team_key: input.team_key,
226
+ agent_id: input.agent_id.as_str(),
227
+ },
228
+ &next_state,
229
+ )
230
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))
231
+ }
232
+
233
+ pub(crate) fn finalize_pending_fork_capture(
234
+ agent: &mut serde_json::Map<String, serde_json::Value>,
235
+ captured: &crate::provider::CapturedSession,
236
+ ) -> bool {
237
+ let Some(session_id) = captured.session_id.as_ref() else {
238
+ return false;
239
+ };
240
+ let Some(rollout_path) = captured.rollout_path.as_ref() else {
241
+ return false;
242
+ };
243
+ if agent
244
+ .get("fork_source_session_id")
245
+ .and_then(serde_json::Value::as_str)
246
+ == Some(session_id.as_str())
247
+ {
248
+ return false;
249
+ }
250
+ agent.insert(
251
+ "session_id".to_string(),
252
+ serde_json::json!(session_id.as_str()),
253
+ );
254
+ agent.insert(
255
+ "rollout_path".to_string(),
256
+ serde_json::json!(rollout_path.as_path().to_string_lossy()),
257
+ );
258
+ agent.insert(
259
+ "captured_at".to_string(),
260
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
261
+ );
262
+ agent.insert(
263
+ "captured_via".to_string(),
264
+ serde_json::to_value(captured.captured_via).unwrap_or(serde_json::Value::Null),
265
+ );
266
+ agent.insert(
267
+ "attribution_confidence".to_string(),
268
+ serde_json::to_value(captured.attribution_confidence).unwrap_or(serde_json::Value::Null),
269
+ );
270
+ agent.remove("_pending_session_id");
271
+ agent.remove("attribution_ambiguous");
272
+ agent.remove("fork_source_session_id");
273
+ agent.remove("pending_target_agent");
274
+ agent.remove("pending_grace_secs");
275
+ agent.insert("capture_state".to_string(), serde_json::json!("captured"));
276
+ true
277
+ }
278
+
175
279
  pub(super) fn verify_fork_registration(
176
280
  workspace: &Path,
177
281
  team_key: &str,
@@ -0,0 +1,109 @@
1
+ use super::*;
2
+
3
+ #[allow(clippy::too_many_arguments)]
4
+ pub(crate) fn upsert_pending_forked_agent_state(
5
+ state: &mut serde_json::Value,
6
+ source_agent_id: &AgentId,
7
+ as_agent_id: &AgentId,
8
+ spec_agent: &Value,
9
+ safety: &DangerousApproval,
10
+ plan: &crate::provider::CommandPlan,
11
+ profile_launch: &crate::provider::ProviderProfileLaunch,
12
+ spawn: &crate::transport::SpawnResult,
13
+ profile_dir: Option<&Path>,
14
+ dynamic_role_file: &Path,
15
+ pending: &crate::provider::session::PendingContextFork,
16
+ spawn_epoch: u64,
17
+ ) -> Result<(), LifecycleError> {
18
+ let root = state.as_object_mut().ok_or_else(|| {
19
+ LifecycleError::StatePersist("runtime state root is not an object".to_string())
20
+ })?;
21
+ let agents = root
22
+ .entry("agents".to_string())
23
+ .or_insert_with(|| serde_json::json!({}))
24
+ .as_object_mut()
25
+ .ok_or_else(|| {
26
+ LifecycleError::StatePersist("runtime state agents is not an object".to_string())
27
+ })?;
28
+ let mut entry = serde_json::Map::new();
29
+ entry.insert("status".to_string(), serde_json::json!("running"));
30
+ entry.insert(
31
+ "capture_state".to_string(),
32
+ serde_json::json!("pending_context_fork"),
33
+ );
34
+ entry.insert(
35
+ "agent_id".to_string(),
36
+ serde_json::json!(as_agent_id.as_str()),
37
+ );
38
+ entry.insert(
39
+ "window".to_string(),
40
+ serde_json::json!(as_agent_id.as_str()),
41
+ );
42
+ entry.insert(
43
+ "forked_from".to_string(),
44
+ serde_json::json!(source_agent_id.as_str()),
45
+ );
46
+ entry.insert(
47
+ "fork_source_session_id".to_string(),
48
+ serde_json::json!(pending.source_session_id.as_str()),
49
+ );
50
+ entry.insert(
51
+ "pending_target_agent".to_string(),
52
+ serde_json::json!(pending.target_agent),
53
+ );
54
+ entry.insert(
55
+ "dynamic_role_file".to_string(),
56
+ serde_json::json!(dynamic_role_file.to_string_lossy().to_string()),
57
+ );
58
+ entry.insert(
59
+ "role_source_ownership".to_string(),
60
+ serde_json::json!("managed"),
61
+ );
62
+ entry.insert(
63
+ "spawn_cwd".to_string(),
64
+ serde_json::json!(pending
65
+ .scanner_context
66
+ .spawn_cwd
67
+ .to_string_lossy()
68
+ .to_string()),
69
+ );
70
+ entry.insert(
71
+ "pane_id".to_string(),
72
+ serde_json::json!(spawn.pane_id.as_str()),
73
+ );
74
+ entry.insert(
75
+ "spawned_at".to_string(),
76
+ serde_json::json!(pending.spawned_at),
77
+ );
78
+ entry.insert("spawn_epoch".to_string(), serde_json::json!(spawn_epoch));
79
+ if let Some(pid) = spawn.child_pid {
80
+ entry.insert("pane_pid".to_string(), serde_json::json!(pid));
81
+ }
82
+ for key in [
83
+ "provider",
84
+ "auth_mode",
85
+ "model",
86
+ "profile",
87
+ "role",
88
+ "effort",
89
+ ] {
90
+ if let Some(value) = spec_agent.get(key) {
91
+ entry.insert(key.to_string(), yaml_value_to_json(value));
92
+ }
93
+ }
94
+ if spec_agent.get("profile").is_some() {
95
+ if let Some(profile_dir) = profile_dir {
96
+ entry.insert(
97
+ "_profile_dir".to_string(),
98
+ serde_json::json!(profile_dir.to_string_lossy().to_string()),
99
+ );
100
+ }
101
+ }
102
+ persist_command_plan_state(&mut entry, plan, profile_launch);
103
+ persist_effective_approval_policy(&mut entry, safety);
104
+ agents.insert(
105
+ as_agent_id.as_str().to_string(),
106
+ serde_json::Value::Object(entry),
107
+ );
108
+ Ok(())
109
+ }
@@ -14,6 +14,10 @@ use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest}
14
14
 
15
15
  use super::*;
16
16
 
17
+ #[path = "fork_pending.rs"]
18
+ mod fork_pending;
19
+ pub(super) use fork_pending::upsert_pending_forked_agent_state;
20
+
17
21
  pub(super) fn reserve_forked_agent_state(
18
22
  state: &mut serde_json::Value,
19
23
  source_agent_id: &AgentId,
@@ -134,6 +138,51 @@ pub(super) fn find_spec_agent<'a>(spec: &'a Value, agent_id: &AgentId) -> Option
134
138
  })
135
139
  }
136
140
 
141
+ pub(super) fn fork_spec_agent<'a>(spec: &'a Value, agent_id: &AgentId) -> Option<&'a Value> {
142
+ find_spec_agent(spec, agent_id)
143
+ }
144
+
145
+ pub(super) fn fork_source_tuple(
146
+ state: &serde_json::Value,
147
+ source_agent_id: &AgentId,
148
+ ) -> Result<(crate::provider::SessionId, PathBuf), LifecycleError> {
149
+ let source = state
150
+ .get("agents")
151
+ .and_then(|agents| agents.get(source_agent_id.as_str()))
152
+ .ok_or_else(|| {
153
+ LifecycleError::Provider(format!("unknown worker agent id: {source_agent_id}"))
154
+ })?;
155
+ let field = |name: &str| {
156
+ source
157
+ .get(name)
158
+ .and_then(serde_json::Value::as_str)
159
+ .filter(|value| !value.is_empty())
160
+ };
161
+ let (Some(session_id), Some(backing)) = (field("session_id"), field("rollout_path")) else {
162
+ return Err(LifecycleError::Provider(format!(
163
+ "cannot fork {source_agent_id}: source session backing is missing or incomplete \
164
+ (session_id+rollout_path+captured_at+captured_via required)"
165
+ )));
166
+ };
167
+ if field("captured_at").is_none() || field("captured_via").is_none() {
168
+ return Err(LifecycleError::Provider(format!(
169
+ "cannot fork {source_agent_id}: source session backing is missing or incomplete \
170
+ (session_id+rollout_path+captured_at+captured_via required)"
171
+ )));
172
+ }
173
+ let backing = PathBuf::from(backing);
174
+ if !backing.is_file() {
175
+ return Err(LifecycleError::Provider(format!(
176
+ "cannot fork {source_agent_id}: source session backing is not readable: {}",
177
+ backing.display()
178
+ )));
179
+ }
180
+ Ok((
181
+ crate::provider::SessionId::new(session_id.to_string()),
182
+ backing,
183
+ ))
184
+ }
185
+
137
186
  pub(super) fn append_forked_agent(
138
187
  spec: &Value,
139
188
  source_agent: &Value,
@@ -295,6 +295,7 @@ mod fork_state;
295
295
  pub(super) use fork_state::*;
296
296
 
297
297
  mod fork_finalize;
298
+ pub(crate) use fork_finalize::finalize_pending_fork_capture;
298
299
  pub(super) use fork_finalize::*;
299
300
 
300
301
  mod role_source;
@@ -664,6 +664,14 @@ pub struct ForkAgentReport {
664
664
  pub new_agent_id: AgentId,
665
665
  pub env: AgentActionEnvelope,
666
666
  pub session_id: Option<SessionId>,
667
+ pub backing_state: ForkBackingState,
668
+ }
669
+
670
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
671
+ #[serde(rename_all = "snake_case")]
672
+ pub enum ForkBackingState {
673
+ Verified,
674
+ PendingContextFork,
667
675
  }
668
676
 
669
677
  #[derive(Debug, Clone, PartialEq, Eq)]
@@ -130,6 +130,7 @@ pub(crate) fn fork_agent(
130
130
  "state_file": report.env.state_file.to_string_lossy().to_string(),
131
131
  "coordinator_started": report.env.coordinator_started,
132
132
  "session_id": report.session_id.as_ref().map(|session| session.as_str()),
133
+ "backing_state": report.backing_state,
133
134
  })),
134
135
  })
135
136
  }
@@ -848,35 +848,7 @@ pub fn requeue_worker_target_missing_messages(
848
848
  recipient: &str,
849
849
  owner_team_id: Option<&str>,
850
850
  ) -> Result<Vec<String>, MessagingError> {
851
- let conn = crate::db::schema::open_db(store.db_path())?;
852
- let now = chrono::Utc::now().to_rfc3339();
853
- let mut stmt = conn.prepare(
854
- "select message_id from messages
855
- where recipient = ?1
856
- and status = 'queued_pane_missing'
857
- and error = 'tmux_target_missing'
858
- and (
859
- (?2 is null and owner_team_id is null)
860
- or owner_team_id = ?2
861
- )
862
- order by created_at, message_id",
863
- )?;
864
- let ids = stmt
865
- .query_map(params![recipient, owner_team_id], |row| {
866
- row.get::<_, String>(0)
867
- })?
868
- .collect::<Result<Vec<_>, _>>()?;
869
- drop(stmt);
870
- for message_id in &ids {
871
- conn.execute(
872
- "update messages
873
- set status = 'accepted',
874
- error = null,
875
- updated_at = ?2
876
- where message_id = ?1",
877
- params![message_id, now.as_str()],
878
- )?;
879
- }
851
+ let ids = store.recover_worker_pane_available(recipient, owner_team_id)?;
880
852
  if !ids.is_empty() {
881
853
  event_log.write(
882
854
  "worker_receiver.blocked_messages_requeued",
@@ -2446,10 +2418,8 @@ fn save_scoped_state(
2446
2418
  state: &serde_json::Value,
2447
2419
  owner_team_id: Option<&str>,
2448
2420
  ) -> Result<(), MessagingError> {
2449
- let scoped_owner_team_id = owner_team_id
2450
- .filter(|team| !team.is_empty())
2451
- .filter(|_| {
2452
- state
2421
+ let scoped_owner_team_id = owner_team_id.filter(|team| !team.is_empty()).filter(|_| {
2422
+ state
2453
2423
  .get("teams")
2454
2424
  .and_then(serde_json::Value::as_object)
2455
2425
  .is_some_and(|teams| {
@@ -2461,7 +2431,7 @@ fn save_scoped_state(
2461
2431
  })
2462
2432
  .is_some_and(|team| teams.contains_key(&team))
2463
2433
  })
2464
- });
2434
+ });
2465
2435
  crate::state::repository::StateRepository::new(workspace).save(
2466
2436
  crate::state::repository::StateWriteIntent::MessagingDeliveryState {
2467
2437
  owner_team_id: scoped_owner_team_id,
@@ -2480,10 +2450,8 @@ fn save_scoped_state_reapplying_after_conflict<F>(
2480
2450
  where
2481
2451
  F: FnOnce(&mut serde_json::Value),
2482
2452
  {
2483
- let scoped_owner_team_id = owner_team_id
2484
- .filter(|team| !team.is_empty())
2485
- .filter(|_| {
2486
- state
2453
+ let scoped_owner_team_id = owner_team_id.filter(|team| !team.is_empty()).filter(|_| {
2454
+ state
2487
2455
  .get("teams")
2488
2456
  .and_then(serde_json::Value::as_object)
2489
2457
  .is_some_and(|teams| {
@@ -2495,7 +2463,7 @@ where
2495
2463
  })
2496
2464
  .is_some_and(|team| teams.contains_key(&team))
2497
2465
  })
2498
- });
2466
+ });
2499
2467
  crate::state::repository::StateRepository::new(workspace).save_reapplying(
2500
2468
  crate::state::repository::StateWriteIntent::MessagingDeliveryState {
2501
2469
  owner_team_id: scoped_owner_team_id,
@@ -23,6 +23,7 @@ fn stuck_cancel_snapshot_delivered_message_ids_uses_golden_status_set() {
23
23
  let m_ack = store
24
24
  .create_message(None, "leader", "w1", "ack-me", None, true, Some("teamX"))
25
25
  .unwrap();
26
+ store.mark(&m_ack, "delivered", None).unwrap();
26
27
  store.mark(&m_ack, "acknowledged", None).unwrap();
27
28
  let m_vis = store
28
29
  .create_message(None, "leader", "w1", "vis", None, true, Some("teamX"))
@@ -9,6 +9,12 @@ use crate::message_store::{MessageStore, NotificationClaimParams};
9
9
  use crate::model::ids::{TaskId, TeamKey};
10
10
  use crate::transport::PaneId;
11
11
 
12
+ #[derive(Debug, Clone, PartialEq, Eq)]
13
+ pub struct RecoveryIncident {
14
+ pub incident_id: String,
15
+ pub occurred_at: String,
16
+ }
17
+
12
18
  use super::{MessagingError, WatcherNotice, RESULT_DELIVERY_MAX_ATTEMPTS};
13
19
 
14
20
  /// `notify_result_watchers` (`result_delivery.py:38`):匹配 + 去重 + (有界) 投递 result 给 leader
@@ -460,13 +466,31 @@ pub fn requeue_after_claim_leader(
460
466
  )?;
461
467
  }
462
468
  let requeued_blocked =
463
- requeue_blocked_leader_messages(&conn, event_log, owner_team_id, claimed_pane_id)?;
469
+ requeue_blocked_leader_messages(store, event_log, owner_team_id, claimed_pane_id)?;
464
470
  if !out.is_empty() || requeued_blocked > 0 {
465
471
  let _ = retry_result_deliveries(workspace, event_log)?;
466
472
  }
467
473
  Ok(out)
468
474
  }
469
475
 
476
+ pub fn recover_watchers_for_incident(
477
+ workspace: &Path,
478
+ store: &MessageStore,
479
+ event_log: &EventLog,
480
+ owner_team_id: &TeamKey,
481
+ claimed_pane_id: &PaneId,
482
+ incident: &RecoveryIncident,
483
+ ) -> Result<Vec<WatcherNotice>, MessagingError> {
484
+ requeue_after_claim_leader(
485
+ workspace,
486
+ store,
487
+ event_log,
488
+ owner_team_id,
489
+ claimed_pane_id,
490
+ Some(&incident.occurred_at),
491
+ )
492
+ }
493
+
470
494
  /// 0.5.5 gate054 round-2: attach-leader (and claim-leader) requeue for leader messages
471
495
  /// that were refused with `rebind_required` while no leader pane was attached.
472
496
  ///
@@ -477,7 +501,7 @@ pub fn requeue_after_claim_leader(
477
501
  /// already crossed the transport boundary and is claim-immutable. Any future
478
502
  /// retry belongs to a separate typed recovery arm, not attach/claim convergence.
479
503
  pub(crate) fn requeue_blocked_leader_messages(
480
- conn: &rusqlite::Connection,
504
+ store: &MessageStore,
481
505
  event_log: &EventLog,
482
506
  owner_team_id: &TeamKey,
483
507
  claimed_pane_id: &PaneId,
@@ -492,19 +516,8 @@ pub(crate) fn requeue_blocked_leader_messages(
492
516
  // could not have churned while the leader was unattached.
493
517
  // `submitted_pending_acceptance` remains parked and claim-immutable; a
494
518
  // future typed recovery arm must own any deliberate retry.
495
- let requeued = conn.execute(
496
- "update messages
497
- set status = 'accepted',
498
- error = null,
499
- updated_at = ?2
500
- where recipient = 'leader'
501
- and owner_team_id = ?1
502
- and (
503
- (status = 'failed' and error = 'leader_not_attached')
504
- or status = 'queued_until_leader_attach'
505
- )",
506
- params![owner_team_id.as_str(), chrono::Utc::now().to_rfc3339()],
507
- )?;
519
+ let counts = store.requeue_blocked_leader_messages(owner_team_id.as_str())?;
520
+ let requeued = counts.total();
508
521
  if requeued > 0 {
509
522
  event_log.write(
510
523
  "leader_receiver.blocked_messages_requeued",
@@ -512,6 +525,10 @@ pub(crate) fn requeue_blocked_leader_messages(
512
525
  "team_id": owner_team_id.as_str(),
513
526
  "claimed_pane_id": claimed_pane_id.as_str(),
514
527
  "count": requeued,
528
+ "by_prior_state": {
529
+ "blocked_leader_unbound": counts.blocked_leader_unbound,
530
+ "queued_until_leader_attach": counts.queued_until_leader_attach,
531
+ },
515
532
  }),
516
533
  )?;
517
534
  }
@@ -573,7 +590,7 @@ pub fn requeue_delivery_exhausted_watchers(
573
590
  )?;
574
591
  }
575
592
  drop(stmt);
576
- let _ = requeue_blocked_leader_messages(&conn, event_log, owner_team_id, claimed_pane_id)?;
593
+ let _ = requeue_blocked_leader_messages(store, event_log, owner_team_id, claimed_pane_id)?;
577
594
  Ok(out)
578
595
  }
579
596
 
@@ -469,13 +469,25 @@ fn classify_pending_capture_state(
469
469
  const TRIGGER_GRACE_SECS: i64 = 15;
470
470
  let past_spawn_grace = elapsed_since_spawn >= SPAWN_GRACE_SECS;
471
471
  let past_trigger_grace = has_trigger && elapsed_since_trigger >= TRIGGER_GRACE_SECS;
472
+ let fork_pending_expired = agent_obj.get("capture_state").and_then(Value::as_str)
473
+ == Some("pending_context_fork")
474
+ && crate::provider::session::transition_pending_context_fork(
475
+ has_trigger,
476
+ past_spawn_grace && past_trigger_grace,
477
+ )
478
+ .is_some();
472
479
  // transcript_missing requires BOTH: a trigger occurred AND we've
473
480
  // waited past the spawn grace (so we're not blamed for slow startup).
474
481
  // candidate_count == 0 is the "no backing" signal; non-zero
475
482
  // candidates that didn't satisfy assignment go through the ambiguous
476
483
  // path above and don't reach here.
477
- if has_trigger && past_spawn_grace && past_trigger_grace && candidate_count == 0 {
484
+ if (fork_pending_expired || has_trigger && past_spawn_grace && past_trigger_grace)
485
+ && candidate_count == 0
486
+ {
478
487
  "transcript_missing"
488
+ } else if agent_obj.get("capture_state").and_then(Value::as_str) == Some("pending_context_fork")
489
+ {
490
+ "pending_context_fork"
479
491
  } else {
480
492
  "pending_first_turn"
481
493
  }
@@ -1143,6 +1155,9 @@ fn apply_captured_session(
1143
1155
  agent_obj: &mut serde_json::Map<String, Value>,
1144
1156
  captured: &CapturedSession,
1145
1157
  ) -> bool {
1158
+ if agent_obj.get("capture_state").and_then(Value::as_str) == Some("pending_context_fork") {
1159
+ return crate::lifecycle::finalize_pending_fork_capture(agent_obj, captured);
1160
+ }
1146
1161
  let Some(session_id) = captured.session_id.as_ref() else {
1147
1162
  return false;
1148
1163
  };
@@ -1984,6 +1999,27 @@ mod u1_tests {
1984
1999
  );
1985
2000
  }
1986
2001
 
2002
+ #[test]
2003
+ fn pending_context_fork_keeps_typed_state_until_trigger_grace_expires() {
2004
+ let old = (chrono::Utc::now() - chrono::Duration::seconds(60)).to_rfc3339();
2005
+ let mut agent = serde_json::Map::new();
2006
+ agent.insert("spawned_at".to_string(), serde_json::json!(old));
2007
+ agent.insert(
2008
+ "capture_state".to_string(),
2009
+ serde_json::json!("pending_context_fork"),
2010
+ );
2011
+ assert_eq!(
2012
+ super::classify_pending_capture_state(&agent, 0),
2013
+ "pending_context_fork"
2014
+ );
2015
+ let trigger = (chrono::Utc::now() - chrono::Duration::seconds(20)).to_rfc3339();
2016
+ agent.insert("first_send_at".to_string(), serde_json::json!(trigger));
2017
+ assert_eq!(
2018
+ super::classify_pending_capture_state(&agent, 0),
2019
+ "transcript_missing"
2020
+ );
2021
+ }
2022
+
1987
2023
  #[test]
1988
2024
  fn canonical_pending_capture_preserves_legacy_missing_boundary() {
1989
2025
  let agent = serde_json::json!({
@@ -0,0 +1,85 @@
1
+ use super::*;
2
+
3
+ pub(super) fn verify_claude_fork(
4
+ provider: Provider,
5
+ source_session_id: &SessionId,
6
+ plan: &CommandPlan,
7
+ before: &ContextBackingSnapshot,
8
+ expected_backing_path: Option<&Path>,
9
+ spawn_cwd: &Path,
10
+ deadline: Duration,
11
+ ) -> Result<ContextForkProof, ContextForkTermination> {
12
+ let expected = plan.expected_session_id.as_ref().ok_or_else(|| {
13
+ ProviderError::CaptureFailed(
14
+ "context_fork_unverified: Claude plan has no expected session id".to_string(),
15
+ )
16
+ })?;
17
+ let path = expected_backing_path.ok_or_else(|| {
18
+ ProviderError::CaptureFailed(
19
+ "context_fork_unverified: Claude plan has no exact snapshot backing".to_string(),
20
+ )
21
+ })?;
22
+ let expected_name = format!("{}.jsonl", expected.as_str());
23
+ if path.file_name().and_then(|name| name.to_str()) != Some(expected_name.as_str()) {
24
+ return Err(ProviderError::CaptureFailed(format!(
25
+ "context_fork_unverified: Claude snapshot backing does not match expected session {}",
26
+ expected.as_str()
27
+ ))
28
+ .into());
29
+ }
30
+ let started = std::time::Instant::now();
31
+ loop {
32
+ if let Some(stamp) = file_stamp(path) {
33
+ let changed = before.files.get(path).is_none_or(|old| *old != stamp);
34
+ let observed_matches =
35
+ session_id_from_jsonl(path).is_none_or(|observed| observed == expected.as_str());
36
+ if changed && readable_jsonl(path) && observed_matches && expected != source_session_id
37
+ {
38
+ return Ok(ContextForkProof {
39
+ provider,
40
+ source_session_id: source_session_id.clone(),
41
+ new_session_id: expected.clone(),
42
+ backing_path: path.to_path_buf(),
43
+ captured_via: "context_fork_verified".to_string(),
44
+ attribution_confidence: "high".to_string(),
45
+ managed_backing_root: None,
46
+ });
47
+ }
48
+ }
49
+ if let Some(provider_path) = std::env::var_os("HOME")
50
+ .map(PathBuf::from)
51
+ .and_then(|home| {
52
+ crate::provider::session_scan::claude::projects_dir_for_cwd(&home, spawn_cwd)
53
+ })
54
+ .map(|root| root.join(&expected_name))
55
+ {
56
+ let observed_matches = session_id_from_jsonl(&provider_path)
57
+ .is_some_and(|observed| observed == expected.as_str());
58
+ let changed = file_stamp(&provider_path)
59
+ .is_some_and(|stamp| before.files.get(&provider_path) != Some(&stamp));
60
+ if changed
61
+ && readable_jsonl(&provider_path)
62
+ && observed_matches
63
+ && expected != source_session_id
64
+ {
65
+ return Ok(ContextForkProof {
66
+ provider,
67
+ source_session_id: source_session_id.clone(),
68
+ new_session_id: expected.clone(),
69
+ backing_path: provider_path,
70
+ captured_via: "context_fork_verified".to_string(),
71
+ attribution_confidence: "high".to_string(),
72
+ managed_backing_root: None,
73
+ });
74
+ }
75
+ }
76
+ if started.elapsed() >= deadline {
77
+ break;
78
+ }
79
+ std::thread::sleep(Duration::from_millis(50));
80
+ }
81
+ Err(ContextForkTermination::Timeout {
82
+ provider,
83
+ deadline_ms: deadline.as_millis(),
84
+ })
85
+ }