@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
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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::*;
|