@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 +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 +103 -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 +94 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +109 -15
- 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
|
@@ -21,6 +21,11 @@ pub struct CapturePassReport {
|
|
|
21
21
|
pub ambiguous: Vec<AmbiguousSessionCapture>,
|
|
22
22
|
pub capture_failures: Vec<SessionCaptureFailure>,
|
|
23
23
|
pub candidate_count_by_agent: BTreeMap<String, usize>,
|
|
24
|
+
/// 0.4.6 Stage 3: agents that transitioned into the
|
|
25
|
+
/// `transcript_missing` capture_state during this pass. The caller
|
|
26
|
+
/// (coordinator tick) emits a throttled
|
|
27
|
+
/// `provider.session.transcript_missing` event for each entry.
|
|
28
|
+
pub transcript_missing: Vec<TranscriptMissing>,
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
@@ -35,6 +40,19 @@ pub struct AmbiguousSessionCapture {
|
|
|
35
40
|
pub spawn_cwd: String,
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
/// 0.4.6 Stage 3: information emitted when a pending agent transitions
|
|
44
|
+
/// into the `transcript_missing` capture_state. Carries the diagnostic
|
|
45
|
+
/// fields the caller uses to write the
|
|
46
|
+
/// `provider.session.transcript_missing` event.
|
|
47
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
48
|
+
pub struct TranscriptMissing {
|
|
49
|
+
pub agent_id: String,
|
|
50
|
+
pub spawn_cwd: String,
|
|
51
|
+
pub spawn_epoch: u64,
|
|
52
|
+
pub expected_session_id: Option<String>,
|
|
53
|
+
pub candidate_count: usize,
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
39
57
|
pub struct SessionConvergence {
|
|
40
58
|
pub converged: bool,
|
|
@@ -173,7 +191,7 @@ where
|
|
|
173
191
|
.iter()
|
|
174
192
|
.map(|item| item.agent_id.clone())
|
|
175
193
|
.collect::<BTreeSet<_>>();
|
|
176
|
-
let mut claimed = claimed_provider_session_keys(agent_map, &pending_ids);
|
|
194
|
+
let mut claimed = claimed_provider_session_keys(state, agent_map, &pending_ids);
|
|
177
195
|
let (assignments, ambiguous_ids) =
|
|
178
196
|
allocate_session_candidates(&pending, &candidates_by_agent, &mut claimed);
|
|
179
197
|
|
|
@@ -220,17 +238,27 @@ where
|
|
|
220
238
|
"attribution_ambiguous".to_string(),
|
|
221
239
|
serde_json::json!(true),
|
|
222
240
|
);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
241
|
+
// 0.4.6 tuple-atomic contract (audit:93): do NOT write
|
|
242
|
+
// `captured_at` for ambiguity diagnostics. `captured_at`
|
|
243
|
+
// is part of the authoritative tuple and may only be
|
|
244
|
+
// set together with `session_id + rollout_path +
|
|
245
|
+
// captured_via`. Overloading it as both capture
|
|
246
|
+
// timestamp and ambiguity timestamp created false
|
|
247
|
+
// "looks captured" rows. Ambiguity diagnostics live in
|
|
248
|
+
// the `attribution_ambiguous` flag + events; persist
|
|
249
|
+
// event log for the timestamp.
|
|
227
250
|
report.changed = true;
|
|
228
251
|
}
|
|
229
252
|
continue;
|
|
230
253
|
}
|
|
231
|
-
apply_captured_session
|
|
232
|
-
|
|
233
|
-
|
|
254
|
+
// 0.4.6 tuple-atomic contract: apply_captured_session refuses
|
|
255
|
+
// partial candidates (no session_id or no rollout_path). When
|
|
256
|
+
// it returns false, the row stays unchanged; capture re-runs
|
|
257
|
+
// on the next coordinator tick.
|
|
258
|
+
if apply_captured_session(agent_obj, &candidate.captured) {
|
|
259
|
+
report.changed = true;
|
|
260
|
+
report.assigned.push(item.agent_id);
|
|
261
|
+
}
|
|
234
262
|
continue;
|
|
235
263
|
}
|
|
236
264
|
if ambiguous_ids.contains(&item.agent_id) {
|
|
@@ -241,16 +269,151 @@ where
|
|
|
241
269
|
if finalize_ambiguous {
|
|
242
270
|
agent_obj.insert("attribution_ambiguous".to_string(), serde_json::json!(true));
|
|
243
271
|
agent_obj.insert(
|
|
244
|
-
"
|
|
245
|
-
serde_json::json!(
|
|
272
|
+
"capture_state".to_string(),
|
|
273
|
+
serde_json::json!("attribution_ambiguous"),
|
|
246
274
|
);
|
|
275
|
+
// 0.4.6 tuple-atomic contract: see comment above. Removed
|
|
276
|
+
// `captured_at` ambiguity write.
|
|
247
277
|
report.changed = true;
|
|
248
278
|
}
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
// 0.4.6 Stage 3: transcript-ready handshake. For pending agents that
|
|
282
|
+
// are NOT ambiguous and were NOT captured this pass, classify into:
|
|
283
|
+
// * `captured` — session_id + rollout_path already on row
|
|
284
|
+
// * `transcript_missing` — trigger event has fired (report_result /
|
|
285
|
+
// first_send_at / pane_output_advanced) AND elapsed > threshold
|
|
286
|
+
// AND zero candidates / no backing
|
|
287
|
+
// * `waiting_for_transcript` — still in grace period
|
|
288
|
+
// The state field surfaces in status/diagnose without changing the
|
|
289
|
+
// authoritative tuple contract (we never write session_id without
|
|
290
|
+
// backing). Throttled event emission is done by the caller (tick).
|
|
291
|
+
let has_session = agent_obj
|
|
292
|
+
.get("session_id")
|
|
293
|
+
.and_then(Value::as_str)
|
|
294
|
+
.is_some_and(|s| !s.is_empty())
|
|
295
|
+
&& agent_obj
|
|
296
|
+
.get("rollout_path")
|
|
297
|
+
.and_then(Value::as_str)
|
|
298
|
+
.is_some_and(|s| !s.is_empty());
|
|
299
|
+
if has_session {
|
|
300
|
+
// Captured already — fix state field if drifted.
|
|
301
|
+
if agent_obj
|
|
302
|
+
.get("capture_state")
|
|
303
|
+
.and_then(Value::as_str)
|
|
304
|
+
!= Some("captured")
|
|
305
|
+
{
|
|
306
|
+
agent_obj.insert("capture_state".to_string(), serde_json::json!("captured"));
|
|
307
|
+
report.changed = true;
|
|
308
|
+
}
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
let candidate_count = report
|
|
312
|
+
.candidate_count_by_agent
|
|
313
|
+
.get(&item.agent_id)
|
|
314
|
+
.copied()
|
|
315
|
+
.unwrap_or(0);
|
|
316
|
+
let next_state = classify_pending_capture_state(agent_obj, candidate_count);
|
|
317
|
+
let prev_state = agent_obj
|
|
318
|
+
.get("capture_state")
|
|
319
|
+
.and_then(Value::as_str)
|
|
320
|
+
.map(str::to_string);
|
|
321
|
+
if prev_state.as_deref() != Some(next_state) {
|
|
322
|
+
agent_obj.insert(
|
|
323
|
+
"capture_state".to_string(),
|
|
324
|
+
serde_json::json!(next_state),
|
|
325
|
+
);
|
|
326
|
+
report.changed = true;
|
|
327
|
+
if next_state == "transcript_missing" {
|
|
328
|
+
report.transcript_missing.push(TranscriptMissing {
|
|
329
|
+
agent_id: item.agent_id.clone(),
|
|
330
|
+
spawn_cwd: item.context.spawn_cwd.to_string_lossy().to_string(),
|
|
331
|
+
spawn_epoch: agent_obj
|
|
332
|
+
.get("spawn_epoch")
|
|
333
|
+
.and_then(Value::as_u64)
|
|
334
|
+
.unwrap_or(0),
|
|
335
|
+
expected_session_id: item
|
|
336
|
+
.context
|
|
337
|
+
.expected_session_id
|
|
338
|
+
.as_ref()
|
|
339
|
+
.map(|s| s.as_str().to_string()),
|
|
340
|
+
candidate_count,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
249
343
|
}
|
|
250
344
|
}
|
|
251
345
|
Ok(report)
|
|
252
346
|
}
|
|
253
347
|
|
|
348
|
+
/// 0.4.6 Stage 3: classify a pending agent's capture state into one of:
|
|
349
|
+
/// * `transcript_missing` — has had a strong trigger event AND elapsed
|
|
350
|
+
/// beyond grace threshold AND still no backing
|
|
351
|
+
/// * `waiting_for_transcript` — still in grace period or no trigger yet
|
|
352
|
+
///
|
|
353
|
+
/// Trigger events (CR M2): any of `first_send_at`, `last_result_at`, or a
|
|
354
|
+
/// recent `last_pane_output_at` indicates the worker has been interactively
|
|
355
|
+
/// used / done something. Without ANY trigger, we stay in waiting state
|
|
356
|
+
/// even past the grace window (silent worker is not a failure to capture).
|
|
357
|
+
///
|
|
358
|
+
/// Threshold: 30 seconds from spawned_at OR 15 seconds from the first
|
|
359
|
+
/// trigger event, whichever is later. This avoids spurious
|
|
360
|
+
/// transcript_missing on slow first-paint while catching the
|
|
361
|
+
/// release-engineer scenario (report_result fired but no jsonl).
|
|
362
|
+
fn classify_pending_capture_state(
|
|
363
|
+
agent_obj: &serde_json::Map<String, Value>,
|
|
364
|
+
candidate_count: usize,
|
|
365
|
+
) -> &'static str {
|
|
366
|
+
// Already-captured agents don't reach here (caller skips). We only
|
|
367
|
+
// classify pending agents.
|
|
368
|
+
let now = chrono::Utc::now();
|
|
369
|
+
let spawned_at = agent_obj
|
|
370
|
+
.get("spawned_at")
|
|
371
|
+
.and_then(Value::as_str)
|
|
372
|
+
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
|
373
|
+
.map(|dt| dt.with_timezone(&chrono::Utc));
|
|
374
|
+
let elapsed_since_spawn = spawned_at
|
|
375
|
+
.map(|t| now.signed_duration_since(t).num_seconds())
|
|
376
|
+
.unwrap_or(0);
|
|
377
|
+
// Trigger event detection — any strong signal that the worker has
|
|
378
|
+
// actually been used. Pre-conditions (CR M2 in architect doc).
|
|
379
|
+
let has_trigger = ["first_send_at", "last_result_at", "last_pane_output_at"]
|
|
380
|
+
.iter()
|
|
381
|
+
.any(|key| {
|
|
382
|
+
agent_obj
|
|
383
|
+
.get(*key)
|
|
384
|
+
.and_then(Value::as_str)
|
|
385
|
+
.is_some_and(|s| !s.is_empty())
|
|
386
|
+
});
|
|
387
|
+
// Earliest trigger timestamp for the secondary threshold.
|
|
388
|
+
let earliest_trigger = ["first_send_at", "last_result_at", "last_pane_output_at"]
|
|
389
|
+
.iter()
|
|
390
|
+
.filter_map(|key| {
|
|
391
|
+
agent_obj
|
|
392
|
+
.get(*key)
|
|
393
|
+
.and_then(Value::as_str)
|
|
394
|
+
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
|
395
|
+
.map(|dt| dt.with_timezone(&chrono::Utc))
|
|
396
|
+
})
|
|
397
|
+
.min();
|
|
398
|
+
let elapsed_since_trigger = earliest_trigger
|
|
399
|
+
.map(|t| now.signed_duration_since(t).num_seconds())
|
|
400
|
+
.unwrap_or(0);
|
|
401
|
+
const SPAWN_GRACE_SECS: i64 = 30;
|
|
402
|
+
const TRIGGER_GRACE_SECS: i64 = 15;
|
|
403
|
+
let past_spawn_grace = elapsed_since_spawn >= SPAWN_GRACE_SECS;
|
|
404
|
+
let past_trigger_grace = has_trigger && elapsed_since_trigger >= TRIGGER_GRACE_SECS;
|
|
405
|
+
// transcript_missing requires BOTH: a trigger occurred AND we've
|
|
406
|
+
// waited past the spawn grace (so we're not blamed for slow startup).
|
|
407
|
+
// candidate_count == 0 is the "no backing" signal; non-zero
|
|
408
|
+
// candidates that didn't satisfy assignment go through the ambiguous
|
|
409
|
+
// path above and don't reach here.
|
|
410
|
+
if has_trigger && past_spawn_grace && past_trigger_grace && candidate_count == 0 {
|
|
411
|
+
"transcript_missing"
|
|
412
|
+
} else {
|
|
413
|
+
"waiting_for_transcript"
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
254
417
|
pub fn incomplete_resumable_agent_ids(state: &Value) -> Vec<String> {
|
|
255
418
|
let Some(agents) = state.get("agents").and_then(Value::as_object) else {
|
|
256
419
|
return Vec::new();
|
|
@@ -281,6 +444,13 @@ pub fn recover_resume_session_from_events(
|
|
|
281
444
|
auth_mode: crate::provider::AuthMode,
|
|
282
445
|
exclude_session_ids: &BTreeSet<String>,
|
|
283
446
|
) -> Result<Option<Value>, ProviderError> {
|
|
447
|
+
// E57 postflight: read the agent's provider once; needed for the Claude
|
|
448
|
+
// leader-marker filter below. Falls back to None on non-string / missing,
|
|
449
|
+
// in which case the marker filter is skipped (only meaningful for Claude).
|
|
450
|
+
let provider = previous
|
|
451
|
+
.get("provider")
|
|
452
|
+
.and_then(Value::as_str)
|
|
453
|
+
.and_then(parse_provider);
|
|
284
454
|
let events = crate::event_log::EventLog::new(workspace)
|
|
285
455
|
.tail(0)
|
|
286
456
|
.map_err(|e| ProviderError::Io(e.to_string()))?;
|
|
@@ -310,6 +480,20 @@ pub fn recover_resume_session_from_events(
|
|
|
310
480
|
let Some(rollout_path) = event_rollout_path(event).filter(|path| path.exists()) else {
|
|
311
481
|
continue;
|
|
312
482
|
};
|
|
483
|
+
// E57 postflight (lane-046-capture-gap): the capture allocator already
|
|
484
|
+
// refuses Claude leader transcripts (P0 d39b5104,
|
|
485
|
+
// claude_records_have_leader_marker in provider/adapter.rs). Event-log
|
|
486
|
+
// repair must apply the SAME filter — otherwise a stale `session.captured`
|
|
487
|
+
// event from a pre-fix run (or from a window where the allocator was
|
|
488
|
+
// bypassed) still pulls the 590MB leader transcript onto a worker on the
|
|
489
|
+
// next restart (Mac mini repro: session_id=ea059b82 reassigned to
|
|
490
|
+
// release-engineer via captured_via=event_log_repair). The check is
|
|
491
|
+
// a no-op for non-Claude providers.
|
|
492
|
+
if let Some(p) = provider {
|
|
493
|
+
if crate::provider::adapter::rollout_path_has_claude_leader_marker(p, &rollout_path) {
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
313
497
|
let session = SessionId::new(session_id.to_string());
|
|
314
498
|
if !adapter.session_is_resumable(Some(&session), auth_mode)? {
|
|
315
499
|
continue;
|
|
@@ -321,14 +505,22 @@ pub fn recover_resume_session_from_events(
|
|
|
321
505
|
let Some(obj) = repaired.as_object_mut() else {
|
|
322
506
|
continue;
|
|
323
507
|
};
|
|
508
|
+
// 0.4.6 tuple-atomic contract (audit §Capture 修改清单, line 113):
|
|
509
|
+
// repair must yield a COMPLETE tuple. session_id + rollout_path are
|
|
510
|
+
// already validated above; ensure captured_at + captured_via are
|
|
511
|
+
// also always written (fall back to now() if the event has no ts).
|
|
324
512
|
obj.insert("session_id".to_string(), serde_json::json!(session_id));
|
|
325
513
|
obj.insert(
|
|
326
514
|
"rollout_path".to_string(),
|
|
327
515
|
serde_json::json!(rollout_path.to_string_lossy().to_string()),
|
|
328
516
|
);
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
517
|
+
let captured_at = event
|
|
518
|
+
.get("ts")
|
|
519
|
+
.and_then(Value::as_str)
|
|
520
|
+
.filter(|ts| !ts.is_empty())
|
|
521
|
+
.map(str::to_string)
|
|
522
|
+
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
|
|
523
|
+
obj.insert("captured_at".to_string(), serde_json::json!(captured_at));
|
|
332
524
|
obj.insert(
|
|
333
525
|
"captured_via".to_string(),
|
|
334
526
|
serde_json::json!("event_log_repair"),
|
|
@@ -435,6 +627,13 @@ where
|
|
|
435
627
|
// — that would trigger the Stage 1 mismatch guard against Codex's
|
|
436
628
|
// real session_meta.payload.id and permanently reject the correct
|
|
437
629
|
// transcript. Codex capture anchors purely on (cwd, spawned_at).
|
|
630
|
+
// 0.4.7 (B1 verified, partial-resume revert of 9feafc31):
|
|
631
|
+
// Claude --session-id was restored in adapter.rs build_command_plan
|
|
632
|
+
// because Claude ≥ 2.1.185 DOES honour framework-supplied session
|
|
633
|
+
// id and DOES create a transcript at that id. So `_pending_session_id`
|
|
634
|
+
// is once again valid for Claude — re-enable the Stage 1 pre-pass
|
|
635
|
+
// for Claude/ClaudeCode. Codex remains excluded (Codex CLI ignores
|
|
636
|
+
// --session-id, capture must anchor purely on cwd+spawned_at).
|
|
438
637
|
expected_session_id: if matches!(provider, Provider::Codex) {
|
|
439
638
|
None
|
|
440
639
|
} else {
|
|
@@ -493,6 +692,21 @@ fn agent_session_complete(agent: &Value) -> bool {
|
|
|
493
692
|
Some(path) => path,
|
|
494
693
|
None => return false,
|
|
495
694
|
};
|
|
695
|
+
// 0.4.6 tuple-atomic contract (audit §Capture 修改清单, line 110):
|
|
696
|
+
// a "complete" agent session requires the FULL authoritative tuple —
|
|
697
|
+
// session_id + rollout_path + captured_at + captured_via. Without these
|
|
698
|
+
// last two, a row is a partial tuple and capture must re-run.
|
|
699
|
+
let captured_at_ok = agent
|
|
700
|
+
.get("captured_at")
|
|
701
|
+
.and_then(Value::as_str)
|
|
702
|
+
.is_some_and(|ts| !ts.is_empty());
|
|
703
|
+
let captured_via_ok = agent
|
|
704
|
+
.get("captured_via")
|
|
705
|
+
.and_then(Value::as_str)
|
|
706
|
+
.is_some_and(|via| !via.is_empty());
|
|
707
|
+
if !captured_at_ok || !captured_via_ok {
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
496
710
|
let path = std::path::Path::new(rollout_path);
|
|
497
711
|
if !path.exists() {
|
|
498
712
|
return false;
|
|
@@ -619,6 +833,27 @@ fn allocate_session_candidates(
|
|
|
619
833
|
if assignments.contains_key(&item.agent_id) {
|
|
620
834
|
continue;
|
|
621
835
|
}
|
|
836
|
+
// P0 (lane-046-capture-gap): Claude/ClaudeCode with no
|
|
837
|
+
// expected_session_id (natural fresh — Stage 1 expected-id miss
|
|
838
|
+
// guard cannot fire) must NOT accept the weak `Any` fallback.
|
|
839
|
+
// Without this guard, a same-cwd leader transcript (no positive
|
|
840
|
+
// agent-id match, no path match) becomes the sole candidate via
|
|
841
|
+
// the time window and gets attributed to a worker. Only
|
|
842
|
+
// positive-agent-id / path-agent-id can authoritatively bind a
|
|
843
|
+
// Claude no-expected worker session.
|
|
844
|
+
let claude_no_expected = matches!(
|
|
845
|
+
item.provider,
|
|
846
|
+
Provider::Claude | Provider::ClaudeCode
|
|
847
|
+
) && item.context.expected_session_id.is_none();
|
|
848
|
+
if claude_no_expected {
|
|
849
|
+
if candidates_by_agent
|
|
850
|
+
.get(&item.agent_id)
|
|
851
|
+
.is_some_and(|candidates| !candidates.is_empty())
|
|
852
|
+
{
|
|
853
|
+
ambiguous.insert(item.agent_id.clone());
|
|
854
|
+
}
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
622
857
|
match unique_available_candidate(
|
|
623
858
|
candidates_by_agent.get(&item.agent_id),
|
|
624
859
|
claimed,
|
|
@@ -647,9 +882,16 @@ fn allocate_global_one_to_one(
|
|
|
647
882
|
claimed: &mut BTreeSet<String>,
|
|
648
883
|
assignments: &mut BTreeMap<String, CapturedSessionCandidate>,
|
|
649
884
|
) {
|
|
885
|
+
// P0 (lane-046-capture-gap): exclude Claude no-expected agents from the
|
|
886
|
+
// global one-to-one weak allocator. They must use PositiveAgentId or
|
|
887
|
+
// PathAgentId only — see allocate_session_candidates Any-block.
|
|
650
888
|
let remaining_agents = pending
|
|
651
889
|
.iter()
|
|
652
890
|
.filter(|item| !assignments.contains_key(&item.agent_id))
|
|
891
|
+
.filter(|item| {
|
|
892
|
+
!(matches!(item.provider, Provider::Claude | Provider::ClaudeCode)
|
|
893
|
+
&& item.context.expected_session_id.is_none())
|
|
894
|
+
})
|
|
653
895
|
.map(|item| item.agent_id.clone())
|
|
654
896
|
.collect::<Vec<_>>();
|
|
655
897
|
if remaining_agents.is_empty() {
|
|
@@ -722,16 +964,31 @@ fn candidate_key(candidate: &CapturedSessionCandidate) -> String {
|
|
|
722
964
|
.join("|")
|
|
723
965
|
}
|
|
724
966
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
967
|
+
/// 0.4.6 tuple-atomic contract (audit §Capture 修改清单, line 111): writes
|
|
968
|
+
/// the authoritative session tuple if and only if the candidate carries
|
|
969
|
+
/// BOTH `session_id` and `rollout_path`. A partial candidate (e.g.
|
|
970
|
+
/// scanner returned ambiguous attribution with no session_id) MUST NOT
|
|
971
|
+
/// stamp `captured_at` / `captured_via` onto the row, because that
|
|
972
|
+
/// produces a partial tuple that downstream readers treat as authority.
|
|
973
|
+
/// Returns true iff the full tuple was written.
|
|
974
|
+
fn apply_captured_session(
|
|
975
|
+
agent_obj: &mut serde_json::Map<String, Value>,
|
|
976
|
+
captured: &CapturedSession,
|
|
977
|
+
) -> bool {
|
|
978
|
+
let Some(session_id) = captured.session_id.as_ref() else {
|
|
979
|
+
return false;
|
|
980
|
+
};
|
|
981
|
+
let Some(rollout_path) = captured.rollout_path.as_ref() else {
|
|
982
|
+
return false;
|
|
983
|
+
};
|
|
984
|
+
agent_obj.insert(
|
|
985
|
+
"session_id".to_string(),
|
|
986
|
+
serde_json::json!(session_id.as_str()),
|
|
987
|
+
);
|
|
988
|
+
agent_obj.insert(
|
|
989
|
+
"rollout_path".to_string(),
|
|
990
|
+
serde_json::json!(rollout_path.as_path().to_string_lossy()),
|
|
991
|
+
);
|
|
735
992
|
agent_obj.insert(
|
|
736
993
|
"captured_at".to_string(),
|
|
737
994
|
serde_json::json!(chrono::Utc::now().to_rfc3339()),
|
|
@@ -745,33 +1002,64 @@ fn apply_captured_session(agent_obj: &mut serde_json::Map<String, Value>, captur
|
|
|
745
1002
|
serde_json::to_value(captured.attribution_confidence).unwrap_or(Value::Null),
|
|
746
1003
|
);
|
|
747
1004
|
agent_obj.remove("attribution_ambiguous");
|
|
1005
|
+
true
|
|
748
1006
|
}
|
|
749
1007
|
|
|
750
1008
|
fn claimed_provider_session_keys(
|
|
1009
|
+
state: &Value,
|
|
751
1010
|
agents: &serde_json::Map<String, Value>,
|
|
752
1011
|
pending_ids: &BTreeSet<String>,
|
|
753
1012
|
) -> BTreeSet<String> {
|
|
754
1013
|
let mut keys = BTreeSet::new();
|
|
1014
|
+
// 1. Non-pending worker sessions (existing behaviour).
|
|
755
1015
|
for (agent_id, agent) in agents {
|
|
756
1016
|
if pending_ids.contains(agent_id) {
|
|
757
1017
|
continue;
|
|
758
1018
|
}
|
|
759
|
-
|
|
760
|
-
|
|
1019
|
+
push_provider_session_keys(&mut keys, agent);
|
|
1020
|
+
}
|
|
1021
|
+
// 2. P0 (lane-046-capture-gap): leader anchor sessions. The leader's
|
|
1022
|
+
// own provider transcript must never be attributed to a worker. Scan
|
|
1023
|
+
// state.leader_receiver, state.team_owner, and the same fields under
|
|
1024
|
+
// state.teams.<key>. Cover all known provider session field names so
|
|
1025
|
+
// a present transcript path/id excludes that session from worker
|
|
1026
|
+
// allocation.
|
|
1027
|
+
push_leader_provider_session_keys(&mut keys, state);
|
|
1028
|
+
if let Some(teams) = state.get("teams").and_then(Value::as_object) {
|
|
1029
|
+
for team_state in teams.values() {
|
|
1030
|
+
push_leader_provider_session_keys(&mut keys, team_state);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
keys
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
fn push_provider_session_keys(keys: &mut BTreeSet<String>, value: &Value) {
|
|
1037
|
+
for field in ["session_id", "provider_session_id"] {
|
|
1038
|
+
if let Some(session_id) = value
|
|
1039
|
+
.get(field)
|
|
761
1040
|
.and_then(Value::as_str)
|
|
762
1041
|
.filter(|s| !s.is_empty())
|
|
763
1042
|
{
|
|
764
1043
|
keys.insert(format!("session:{session_id}"));
|
|
765
1044
|
}
|
|
766
|
-
|
|
767
|
-
|
|
1045
|
+
}
|
|
1046
|
+
for field in ["rollout_path", "transcript_path"] {
|
|
1047
|
+
if let Some(path) = value
|
|
1048
|
+
.get(field)
|
|
768
1049
|
.and_then(Value::as_str)
|
|
769
1050
|
.filter(|s| !s.is_empty())
|
|
770
1051
|
{
|
|
771
|
-
keys.insert(format!("rollout:{
|
|
1052
|
+
keys.insert(format!("rollout:{path}"));
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
fn push_leader_provider_session_keys(keys: &mut BTreeSet<String>, scope: &Value) {
|
|
1058
|
+
for anchor in ["leader_receiver", "team_owner"] {
|
|
1059
|
+
if let Some(node) = scope.get(anchor) {
|
|
1060
|
+
push_provider_session_keys(keys, node);
|
|
772
1061
|
}
|
|
773
1062
|
}
|
|
774
|
-
keys
|
|
775
1063
|
}
|
|
776
1064
|
|
|
777
1065
|
fn captured_provider_session_keys(captured: &CapturedSession) -> BTreeSet<String> {
|
|
@@ -1018,6 +1306,171 @@ pub(crate) mod test_support {
|
|
|
1018
1306
|
mod u1_tests {
|
|
1019
1307
|
use super::*;
|
|
1020
1308
|
|
|
1309
|
+
use crate::provider::{CaptureVia, CapturedSession, Confidence, RolloutPath, SessionId};
|
|
1310
|
+
use std::path::PathBuf;
|
|
1311
|
+
|
|
1312
|
+
fn leader_like_candidate(session_id: &str, path: &str) -> CapturedSessionCandidate {
|
|
1313
|
+
CapturedSessionCandidate {
|
|
1314
|
+
captured: CapturedSession {
|
|
1315
|
+
session_id: Some(SessionId::new(session_id)),
|
|
1316
|
+
rollout_path: Some(RolloutPath::new(PathBuf::from(path))),
|
|
1317
|
+
captured_via: CaptureVia::FsWatch,
|
|
1318
|
+
attribution_confidence: Confidence::High,
|
|
1319
|
+
spawn_cwd: PathBuf::from("/tmp/u1-cwd"),
|
|
1320
|
+
},
|
|
1321
|
+
positive_agent_id_match: false,
|
|
1322
|
+
agent_path_match: false,
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
fn worker_owned_candidate(session_id: &str, path: &str) -> CapturedSessionCandidate {
|
|
1327
|
+
CapturedSessionCandidate {
|
|
1328
|
+
captured: CapturedSession {
|
|
1329
|
+
session_id: Some(SessionId::new(session_id)),
|
|
1330
|
+
rollout_path: Some(RolloutPath::new(PathBuf::from(path))),
|
|
1331
|
+
captured_via: CaptureVia::FsWatch,
|
|
1332
|
+
attribution_confidence: Confidence::High,
|
|
1333
|
+
spawn_cwd: PathBuf::from("/tmp/u1-cwd"),
|
|
1334
|
+
},
|
|
1335
|
+
positive_agent_id_match: true,
|
|
1336
|
+
agent_path_match: false,
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/// P0 RED-1 (lane-046-capture-gap): a Claude worker with
|
|
1341
|
+
/// expected_session_id=None must NOT capture a candidate that has neither
|
|
1342
|
+
/// positive_agent_id_match nor agent_path_match — even when it is the
|
|
1343
|
+
/// only candidate (the leader-transcript-as-only-candidate failure mode
|
|
1344
|
+
/// from the macmini repro). Must end up ambiguous, not assigned.
|
|
1345
|
+
#[test]
|
|
1346
|
+
fn claude_no_expected_single_leader_candidate_is_not_assigned() {
|
|
1347
|
+
let mut state = serde_json::json!({
|
|
1348
|
+
"agents": {
|
|
1349
|
+
"release_engineer": {
|
|
1350
|
+
"provider": "claude",
|
|
1351
|
+
"status": "running",
|
|
1352
|
+
"spawn_cwd": "/tmp/u1-cwd"
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
});
|
|
1356
|
+
let mut canned = BTreeMap::new();
|
|
1357
|
+
canned.insert(
|
|
1358
|
+
"release_engineer".to_string(),
|
|
1359
|
+
vec![leader_like_candidate(
|
|
1360
|
+
"ea059b82-c53e-4654-9590-9f3e6d46f0ca",
|
|
1361
|
+
"/Users/alauda/.claude/projects/-Users-alauda-team/ea059b82.jsonl",
|
|
1362
|
+
)],
|
|
1363
|
+
);
|
|
1364
|
+
let canned_for_adapter = canned.clone();
|
|
1365
|
+
let mut adapter_for = move |provider| {
|
|
1366
|
+
Box::new(
|
|
1367
|
+
test_support::CaptureCandidatesAdapter::new(provider, None, "")
|
|
1368
|
+
.with_candidates(canned_for_adapter.clone()),
|
|
1369
|
+
) as Box<dyn ProviderAdapter>
|
|
1370
|
+
};
|
|
1371
|
+
let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
|
|
1372
|
+
.expect("capture pass succeeds");
|
|
1373
|
+
assert!(
|
|
1374
|
+
report.assigned.is_empty(),
|
|
1375
|
+
"Claude no-expected + weak (no positive/path) candidate must NOT be assigned; got {:?}",
|
|
1376
|
+
report.assigned
|
|
1377
|
+
);
|
|
1378
|
+
let agent = state
|
|
1379
|
+
.get("agents")
|
|
1380
|
+
.and_then(|a| a.get("release_engineer"))
|
|
1381
|
+
.expect("agent present");
|
|
1382
|
+
assert!(
|
|
1383
|
+
agent.get("session_id").is_none(),
|
|
1384
|
+
"agent.session_id must remain empty when capture is refused; got {agent:?}"
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/// P0 RED-2: same shape but with a positive_agent_id_match candidate →
|
|
1389
|
+
/// must capture (positive agent id is authoritative for Claude
|
|
1390
|
+
/// no-expected).
|
|
1391
|
+
#[test]
|
|
1392
|
+
fn claude_no_expected_positive_worker_candidate_still_captures() {
|
|
1393
|
+
let mut state = serde_json::json!({
|
|
1394
|
+
"agents": {
|
|
1395
|
+
"release_engineer": {
|
|
1396
|
+
"provider": "claude",
|
|
1397
|
+
"status": "running",
|
|
1398
|
+
"spawn_cwd": "/tmp/u1-cwd"
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
});
|
|
1402
|
+
let mut canned = BTreeMap::new();
|
|
1403
|
+
canned.insert(
|
|
1404
|
+
"release_engineer".to_string(),
|
|
1405
|
+
vec![worker_owned_candidate(
|
|
1406
|
+
"abc12345-worker-owned",
|
|
1407
|
+
"/Users/alauda/.claude/projects/-Users-alauda-team/abc12345.jsonl",
|
|
1408
|
+
)],
|
|
1409
|
+
);
|
|
1410
|
+
let canned_for_adapter = canned.clone();
|
|
1411
|
+
let mut adapter_for = move |provider| {
|
|
1412
|
+
Box::new(
|
|
1413
|
+
test_support::CaptureCandidatesAdapter::new(provider, None, "")
|
|
1414
|
+
.with_candidates(canned_for_adapter.clone()),
|
|
1415
|
+
) as Box<dyn ProviderAdapter>
|
|
1416
|
+
};
|
|
1417
|
+
let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
|
|
1418
|
+
.expect("capture pass succeeds");
|
|
1419
|
+
assert_eq!(
|
|
1420
|
+
report.assigned,
|
|
1421
|
+
vec!["release_engineer".to_string()],
|
|
1422
|
+
"Claude no-expected + positive_agent_id candidate MUST capture; got {:?}",
|
|
1423
|
+
report.assigned
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/// P0 RED-3: leader_receiver under teams.<key> with provider session
|
|
1428
|
+
/// fields must exclude that session from worker capture even if the
|
|
1429
|
+
/// candidate would otherwise match.
|
|
1430
|
+
#[test]
|
|
1431
|
+
fn claimed_session_keys_include_team_scoped_leader_receiver() {
|
|
1432
|
+
let mut state = serde_json::json!({
|
|
1433
|
+
"agents": {
|
|
1434
|
+
"release_engineer": {
|
|
1435
|
+
"provider": "claude",
|
|
1436
|
+
"status": "running",
|
|
1437
|
+
"spawn_cwd": "/tmp/u1-cwd"
|
|
1438
|
+
}
|
|
1439
|
+
},
|
|
1440
|
+
"teams": {
|
|
1441
|
+
"teamA": {
|
|
1442
|
+
"leader_receiver": {
|
|
1443
|
+
"session_id": "leader-session-id-zzz",
|
|
1444
|
+
"rollout_path": "/Users/alauda/.claude/projects/-Users-alauda-team/zzz.jsonl"
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
});
|
|
1449
|
+
let mut canned = BTreeMap::new();
|
|
1450
|
+
canned.insert(
|
|
1451
|
+
"release_engineer".to_string(),
|
|
1452
|
+
vec![worker_owned_candidate(
|
|
1453
|
+
"leader-session-id-zzz",
|
|
1454
|
+
"/Users/alauda/.claude/projects/-Users-alauda-team/zzz.jsonl",
|
|
1455
|
+
)],
|
|
1456
|
+
);
|
|
1457
|
+
let canned_for_adapter = canned.clone();
|
|
1458
|
+
let mut adapter_for = move |provider| {
|
|
1459
|
+
Box::new(
|
|
1460
|
+
test_support::CaptureCandidatesAdapter::new(provider, None, "")
|
|
1461
|
+
.with_candidates(canned_for_adapter.clone()),
|
|
1462
|
+
) as Box<dyn ProviderAdapter>
|
|
1463
|
+
};
|
|
1464
|
+
let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
|
|
1465
|
+
.expect("capture pass succeeds");
|
|
1466
|
+
assert!(
|
|
1467
|
+
report.assigned.is_empty(),
|
|
1468
|
+
"worker candidate that collides with team-scoped leader_receiver \
|
|
1469
|
+
session MUST be excluded by claimed_provider_session_keys; got {:?}",
|
|
1470
|
+
report.assigned
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1021
1474
|
#[test]
|
|
1022
1475
|
fn capture_pass_keeps_pending_agent_when_one_adapter_capture_fails() {
|
|
1023
1476
|
let mut state = serde_json::json!({
|
|
@@ -1096,13 +1549,19 @@ mod u1_tests {
|
|
|
1096
1549
|
n
|
|
1097
1550
|
));
|
|
1098
1551
|
std::fs::write(&existing, b"{}\n").expect("write fixture rollout file");
|
|
1552
|
+
// 0.4.6 tuple-atomic contract: `agent_session_complete` now requires
|
|
1553
|
+
// the full authoritative tuple (session_id + rollout_path +
|
|
1554
|
+
// captured_at + captured_via), not just session_id + rollout_path.
|
|
1555
|
+
// Pre-0.4.6 partial tuples are no longer treated as complete.
|
|
1099
1556
|
let agent_complete = serde_json::json!({
|
|
1100
1557
|
"session_id": "sess-real",
|
|
1101
1558
|
"rollout_path": existing.to_string_lossy(),
|
|
1559
|
+
"captured_at": "2026-06-25T10:00:00+00:00",
|
|
1560
|
+
"captured_via": "session.captured",
|
|
1102
1561
|
});
|
|
1103
1562
|
assert!(
|
|
1104
1563
|
agent_session_complete(&agent_complete),
|
|
1105
|
-
"
|
|
1564
|
+
"0.4.6: agent with full tuple (session_id + rollout_path + captured_at + captured_via) + existing rollout file is complete"
|
|
1106
1565
|
);
|
|
1107
1566
|
let _ = std::fs::remove_file(&existing);
|
|
1108
1567
|
|
|
@@ -1134,6 +1593,12 @@ mod u1_tests {
|
|
|
1134
1593
|
/// global one-to-one runs.
|
|
1135
1594
|
#[test]
|
|
1136
1595
|
fn capture_allocator_expected_session_id_binds_each_worker_to_its_own_transcript() {
|
|
1596
|
+
// 0.4.6 P0 amendment: Claude no longer carries _pending_session_id
|
|
1597
|
+
// (fresh spawn doesn't inject --session-id; capture anchors on
|
|
1598
|
+
// cwd+spawned_at+identity). The ExpectedSessionId pre-pass remains
|
|
1599
|
+
// valid for providers that DO use expected_session_id (Copilot is the
|
|
1600
|
+
// only one left that honors framework-supplied --session-id). Switch
|
|
1601
|
+
// this test to Copilot to preserve the pre-pass invariant coverage.
|
|
1137
1602
|
use crate::provider::{CaptureVia, Confidence, RolloutPath};
|
|
1138
1603
|
let dir = std::env::temp_dir().join(format!(
|
|
1139
1604
|
"ta-stage1-allocator-{}",
|
|
@@ -1154,8 +1619,6 @@ mod u1_tests {
|
|
|
1154
1619
|
attribution_confidence: Confidence::High,
|
|
1155
1620
|
spawn_cwd: dir.clone(),
|
|
1156
1621
|
},
|
|
1157
|
-
// Critical: no positive worker identity hint. Pre-fix this is
|
|
1158
|
-
// exactly the shape that fell through to global one-to-one.
|
|
1159
1622
|
positive_agent_id_match: false,
|
|
1160
1623
|
agent_path_match: false,
|
|
1161
1624
|
};
|
|
@@ -1174,27 +1637,27 @@ mod u1_tests {
|
|
|
1174
1637
|
};
|
|
1175
1638
|
let mut candidates_by_agent: BTreeMap<String, Vec<CapturedSessionCandidate>> =
|
|
1176
1639
|
BTreeMap::new();
|
|
1177
|
-
candidates_by_agent.insert("
|
|
1178
|
-
candidates_by_agent.insert("
|
|
1640
|
+
candidates_by_agent.insert("copilot-a".to_string(), vec![candidate_a.clone()]);
|
|
1641
|
+
candidates_by_agent.insert("copilot-b".to_string(), vec![candidate_b.clone()]);
|
|
1179
1642
|
|
|
1180
1643
|
let cwd_str = dir.to_string_lossy().to_string();
|
|
1181
1644
|
let mut state = serde_json::json!({
|
|
1182
1645
|
"agents": {
|
|
1183
|
-
"
|
|
1184
|
-
"provider": "
|
|
1646
|
+
"copilot-a": {
|
|
1647
|
+
"provider": "copilot",
|
|
1185
1648
|
"status": "running",
|
|
1186
1649
|
"spawn_cwd": cwd_str,
|
|
1187
1650
|
"_pending_session_id": "9a2d1668-8987-4c36-8bde-a5135b10da02"
|
|
1188
1651
|
},
|
|
1189
|
-
"
|
|
1190
|
-
"provider": "
|
|
1652
|
+
"copilot-b": {
|
|
1653
|
+
"provider": "copilot",
|
|
1191
1654
|
"status": "running",
|
|
1192
1655
|
"spawn_cwd": cwd_str,
|
|
1193
1656
|
"_pending_session_id": "3e824e89-25ac-4b3f-b272-b4f733f6403c"
|
|
1194
1657
|
}
|
|
1195
1658
|
}
|
|
1196
1659
|
});
|
|
1197
|
-
let adapter = test_support::CaptureCandidatesAdapter::new(Provider::
|
|
1660
|
+
let adapter = test_support::CaptureCandidatesAdapter::new(Provider::Copilot, None, "")
|
|
1198
1661
|
.with_candidates(candidates_by_agent);
|
|
1199
1662
|
let mut adapter_for = move |_provider| {
|
|
1200
1663
|
Box::new(adapter.clone()) as Box<dyn ProviderAdapter>
|
|
@@ -1207,34 +1670,34 @@ mod u1_tests {
|
|
|
1207
1670
|
// expected transcript. No cross-assignment, no ambiguous mark.
|
|
1208
1671
|
let agents = state["agents"].as_object().expect("agents object");
|
|
1209
1672
|
assert_eq!(
|
|
1210
|
-
agents["
|
|
1673
|
+
agents["copilot-a"]["session_id"].as_str(),
|
|
1211
1674
|
Some("9a2d1668-8987-4c36-8bde-a5135b10da02"),
|
|
1212
|
-
"Stage 1 fix:
|
|
1213
|
-
state.
|
|
1214
|
-
agents["
|
|
1675
|
+
"Stage 1 fix: copilot-a must bind to its own expected transcript; \
|
|
1676
|
+
state.copilot-a={}",
|
|
1677
|
+
agents["copilot-a"]
|
|
1215
1678
|
);
|
|
1216
1679
|
assert_eq!(
|
|
1217
|
-
agents["
|
|
1680
|
+
agents["copilot-b"]["session_id"].as_str(),
|
|
1218
1681
|
Some("3e824e89-25ac-4b3f-b272-b4f733f6403c"),
|
|
1219
|
-
"Stage 1 fix:
|
|
1220
|
-
state.
|
|
1221
|
-
agents["
|
|
1682
|
+
"Stage 1 fix: copilot-b must bind to its own expected transcript; \
|
|
1683
|
+
state.copilot-b={}",
|
|
1684
|
+
agents["copilot-b"]
|
|
1222
1685
|
);
|
|
1223
1686
|
assert!(
|
|
1224
|
-
agents["
|
|
1687
|
+
agents["copilot-a"]
|
|
1225
1688
|
.get("attribution_ambiguous")
|
|
1226
1689
|
.is_none_or(|v| v.as_bool() != Some(true)),
|
|
1227
|
-
"Stage 1 fix:
|
|
1228
|
-
expected-id pre-pass bound it; state.
|
|
1229
|
-
agents["
|
|
1690
|
+
"Stage 1 fix: copilot-a must NOT be flagged ambiguous after the \
|
|
1691
|
+
expected-id pre-pass bound it; state.copilot-a={}",
|
|
1692
|
+
agents["copilot-a"]
|
|
1230
1693
|
);
|
|
1231
1694
|
assert!(
|
|
1232
|
-
agents["
|
|
1695
|
+
agents["copilot-b"]
|
|
1233
1696
|
.get("attribution_ambiguous")
|
|
1234
1697
|
.is_none_or(|v| v.as_bool() != Some(true)),
|
|
1235
|
-
"Stage 1 fix:
|
|
1236
|
-
state.
|
|
1237
|
-
agents["
|
|
1698
|
+
"Stage 1 fix: copilot-b must NOT be flagged ambiguous; \
|
|
1699
|
+
state.copilot-b={}",
|
|
1700
|
+
agents["copilot-b"]
|
|
1238
1701
|
);
|
|
1239
1702
|
assert_eq!(
|
|
1240
1703
|
report.ambiguous.len(),
|
|
@@ -1246,4 +1709,79 @@ mod u1_tests {
|
|
|
1246
1709
|
let _ = std::fs::remove_file(&rollout_b);
|
|
1247
1710
|
let _ = std::fs::remove_dir(&dir);
|
|
1248
1711
|
}
|
|
1712
|
+
|
|
1713
|
+
/// E57 RED postflight (lane-046-capture-gap): `recover_resume_session_from_events`
|
|
1714
|
+
/// must refuse to repair a worker's session from a stale `session.captured`
|
|
1715
|
+
/// event whose rollout file is a Claude LEADER transcript
|
|
1716
|
+
/// (`customTitle == "claude leader"`). Without this filter, a fresh restart
|
|
1717
|
+
/// after a pre-fix run still pulls the leader transcript onto a worker via
|
|
1718
|
+
/// captured_via=event_log_repair (Mac mini evidence:
|
|
1719
|
+
/// resume.session_repaired session_id=ea059b82 → release-engineer).
|
|
1720
|
+
#[test]
|
|
1721
|
+
fn e57_recover_resume_refuses_claude_leader_marker_rollout() {
|
|
1722
|
+
use std::io::Write;
|
|
1723
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
1724
|
+
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
1725
|
+
let uniq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
1726
|
+
let workspace = std::env::temp_dir().join(format!("ta-e57-recover-{}-{}", std::process::id(), uniq));
|
|
1727
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
1728
|
+
std::fs::create_dir_all(&workspace).expect("workspace");
|
|
1729
|
+
|
|
1730
|
+
// Write a leader-marker rollout file (claude TUI leader transcript).
|
|
1731
|
+
let rollout = workspace.join("ea059b82-leader.jsonl");
|
|
1732
|
+
let mut f = std::fs::File::create(&rollout).expect("rollout");
|
|
1733
|
+
writeln!(
|
|
1734
|
+
f,
|
|
1735
|
+
"{{\"customTitle\":\"claude leader\",\"sessionId\":\"ea059b82-c53e-4654-9590-9f3e6d46f0ca\",\"cwd\":\"/tmp/e57\"}}"
|
|
1736
|
+
)
|
|
1737
|
+
.unwrap();
|
|
1738
|
+
writeln!(f, "{{\"role\":\"user\",\"content\":\"hi\"}}").unwrap();
|
|
1739
|
+
drop(f);
|
|
1740
|
+
|
|
1741
|
+
// Write a `session.captured` event into events.jsonl pointing
|
|
1742
|
+
// release-engineer at the leader rollout — simulates the stale
|
|
1743
|
+
// pre-fix event that triggered the bug.
|
|
1744
|
+
let event_log = crate::event_log::EventLog::new(&workspace);
|
|
1745
|
+
event_log
|
|
1746
|
+
.write(
|
|
1747
|
+
"session.captured",
|
|
1748
|
+
serde_json::json!({
|
|
1749
|
+
"agent_id": "release-engineer",
|
|
1750
|
+
"provider": "claude",
|
|
1751
|
+
"session_id": "ea059b82-c53e-4654-9590-9f3e6d46f0ca",
|
|
1752
|
+
"rollout_path": rollout.to_string_lossy().to_string(),
|
|
1753
|
+
"captured_via": "fs_watch",
|
|
1754
|
+
}),
|
|
1755
|
+
)
|
|
1756
|
+
.expect("event write");
|
|
1757
|
+
|
|
1758
|
+
let previous = serde_json::json!({
|
|
1759
|
+
"provider": "claude",
|
|
1760
|
+
"session_id": null,
|
|
1761
|
+
"rollout_path": null,
|
|
1762
|
+
});
|
|
1763
|
+
let adapter = test_support::CaptureCandidatesAdapter::new(Provider::Claude, None, "");
|
|
1764
|
+
let exclude = BTreeSet::new();
|
|
1765
|
+
let result = recover_resume_session_from_events(
|
|
1766
|
+
&workspace,
|
|
1767
|
+
"release-engineer",
|
|
1768
|
+
&previous,
|
|
1769
|
+
&adapter,
|
|
1770
|
+
crate::provider::AuthMode::Subscription,
|
|
1771
|
+
&exclude,
|
|
1772
|
+
)
|
|
1773
|
+
.expect("recover call ok");
|
|
1774
|
+
|
|
1775
|
+
assert!(
|
|
1776
|
+
result.is_none(),
|
|
1777
|
+
"E57 postflight: recover_resume_session_from_events must REFUSE \
|
|
1778
|
+
to repair from a Claude leader-marker rollout (event_log_repair \
|
|
1779
|
+
cannot resurrect a stale leader-to-worker assignment); got {result:?}"
|
|
1780
|
+
);
|
|
1781
|
+
|
|
1782
|
+
let _ = std::fs::remove_file(&rollout);
|
|
1783
|
+
let _ = std::fs::remove_file(workspace.join(".team/logs/events.jsonl"));
|
|
1784
|
+
let _ = std::fs::remove_dir_all(workspace.join(".team"));
|
|
1785
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
1786
|
+
}
|
|
1249
1787
|
}
|