@team-agent/installer 0.4.5 → 0.4.7
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/cli/adapters.rs +6 -1
- package/crates/team-agent/src/cli/status_port.rs +119 -66
- package/crates/team-agent/src/cli/tests/status_send.rs +74 -48
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +6 -1
- package/crates/team-agent/src/coordinator/tick.rs +46 -21
- package/crates/team-agent/src/leader/helpers.rs +1 -22
- package/crates/team-agent/src/lifecycle/launch.rs +34 -17
- package/crates/team-agent/src/lifecycle/profile_launch.rs +1 -11
- package/crates/team-agent/src/lifecycle/restart/agent.rs +160 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +82 -22
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +108 -50
- 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 +217 -377
- package/crates/team-agent/src/provider/adapters/claude.rs +106 -0
- package/crates/team-agent/src/provider/adapters/codex.rs +117 -0
- package/crates/team-agent/src/provider/adapters/copilot.rs +124 -0
- package/crates/team-agent/src/provider/adapters/fake.rs +17 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +27 -0
- package/crates/team-agent/src/provider/mod.rs +6 -0
- package/crates/team-agent/src/provider/session/capture.rs +593 -55
- package/crates/team-agent/src/provider/wire.rs +139 -0
- package/crates/team-agent/src/state/persist.rs +225 -10
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +6 -0
|
@@ -2455,17 +2455,7 @@ fn spec_display_backend(spec: &Value) -> DisplayBackend {
|
|
|
2455
2455
|
crate::lifecycle::display::resolve_display_backend(requested, None).backend
|
|
2456
2456
|
}
|
|
2457
2457
|
|
|
2458
|
-
|
|
2459
|
-
match raw {
|
|
2460
|
-
"claude" => Some(Provider::Claude),
|
|
2461
|
-
"claude_code" => Some(Provider::ClaudeCode),
|
|
2462
|
-
"codex" => Some(Provider::Codex),
|
|
2463
|
-
"copilot" => Some(Provider::Copilot),
|
|
2464
|
-
"gemini_cli" => Some(Provider::GeminiCli),
|
|
2465
|
-
"fake" => Some(Provider::Fake),
|
|
2466
|
-
_ => None,
|
|
2467
|
-
}
|
|
2468
|
-
}
|
|
2458
|
+
use crate::provider::wire::parse_provider;
|
|
2469
2459
|
|
|
2470
2460
|
fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
|
|
2471
2461
|
match raw {
|
|
@@ -3956,18 +3946,45 @@ pub fn fork_agent_with_transport(
|
|
|
3956
3946
|
let source_agent = find_spec_agent(&spec, source_agent_id).ok_or_else(|| {
|
|
3957
3947
|
LifecycleError::RequirementUnmet(format!("unknown worker agent id: {source_agent_id}"))
|
|
3958
3948
|
})?;
|
|
3959
|
-
|
|
3949
|
+
// 0.4.6 tuple-atomic contract (audit §Fork 修改清单, line 201): fork
|
|
3950
|
+
// must require the COMPLETE source tuple (session_id + rollout_path +
|
|
3951
|
+
// captured_at + captured_via) before treating the scalar session_id
|
|
3952
|
+
// as resumable truth. A row carrying only `session_id` is a partial
|
|
3953
|
+
// tuple (pre-0.4.6 bug source), and the native fork would attach to
|
|
3954
|
+
// a session that has no confirmed backing.
|
|
3955
|
+
let source_agent_state = state
|
|
3960
3956
|
.get("agents")
|
|
3961
3957
|
.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
3958
|
.ok_or_else(|| {
|
|
3967
3959
|
LifecycleError::Provider(format!(
|
|
3968
|
-
"cannot fork {source_agent_id}: source
|
|
3960
|
+
"cannot fork {source_agent_id}: source agent row not in state"
|
|
3969
3961
|
))
|
|
3970
3962
|
})?;
|
|
3963
|
+
let tuple_field_ok = |field: &str| -> bool {
|
|
3964
|
+
source_agent_state
|
|
3965
|
+
.get(field)
|
|
3966
|
+
.and_then(|v| v.as_str())
|
|
3967
|
+
.is_some_and(|s| !s.is_empty())
|
|
3968
|
+
};
|
|
3969
|
+
let session_id_str = source_agent_state
|
|
3970
|
+
.get("session_id")
|
|
3971
|
+
.and_then(|v| v.as_str())
|
|
3972
|
+
.filter(|s| !s.is_empty());
|
|
3973
|
+
let rollout_path_str = source_agent_state
|
|
3974
|
+
.get("rollout_path")
|
|
3975
|
+
.and_then(|v| v.as_str())
|
|
3976
|
+
.filter(|s| !s.is_empty());
|
|
3977
|
+
if session_id_str.is_none()
|
|
3978
|
+
|| rollout_path_str.is_none()
|
|
3979
|
+
|| !tuple_field_ok("captured_at")
|
|
3980
|
+
|| !tuple_field_ok("captured_via")
|
|
3981
|
+
{
|
|
3982
|
+
return Err(LifecycleError::Provider(format!(
|
|
3983
|
+
"cannot fork {source_agent_id}: source session backing is missing or incomplete \
|
|
3984
|
+
(session_id+rollout_path+captured_at+captured_via required)"
|
|
3985
|
+
)));
|
|
3986
|
+
}
|
|
3987
|
+
let session_id = crate::provider::SessionId::new(session_id_str.unwrap().to_string());
|
|
3971
3988
|
let session_name = state
|
|
3972
3989
|
.get("session_name")
|
|
3973
3990
|
.and_then(|v| v.as_str())
|
|
@@ -894,17 +894,7 @@ fn effective_profile_or_agent_model<'a>(
|
|
|
894
894
|
agent.model.as_deref().or_else(|| profile_model(values))
|
|
895
895
|
}
|
|
896
896
|
|
|
897
|
-
pub(crate)
|
|
898
|
-
match raw {
|
|
899
|
-
"claude" => Some(Provider::Claude),
|
|
900
|
-
"claude_code" => Some(Provider::ClaudeCode),
|
|
901
|
-
"codex" => Some(Provider::Codex),
|
|
902
|
-
"copilot" => Some(Provider::Copilot),
|
|
903
|
-
"gemini_cli" => Some(Provider::GeminiCli),
|
|
904
|
-
"fake" => Some(Provider::Fake),
|
|
905
|
-
_ => None,
|
|
906
|
-
}
|
|
907
|
-
}
|
|
897
|
+
pub(crate) use crate::provider::wire::parse_provider;
|
|
908
898
|
|
|
909
899
|
pub(crate) fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
|
|
910
900
|
match raw {
|
|
@@ -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 {
|
|
@@ -873,28 +913,7 @@ pub(super) fn agent_window(agent: &serde_json::Value, agent_id: &AgentId) -> Str
|
|
|
873
913
|
.to_string()
|
|
874
914
|
}
|
|
875
915
|
|
|
876
|
-
pub(super)
|
|
877
|
-
match raw {
|
|
878
|
-
"claude" => Some(Provider::Claude),
|
|
879
|
-
"claude_code" => Some(Provider::ClaudeCode),
|
|
880
|
-
"codex" => Some(Provider::Codex),
|
|
881
|
-
"copilot" => Some(Provider::Copilot),
|
|
882
|
-
"gemini_cli" => Some(Provider::GeminiCli),
|
|
883
|
-
"fake" => Some(Provider::Fake),
|
|
884
|
-
_ => None,
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
pub(super) fn provider_wire(provider: Provider) -> &'static str {
|
|
889
|
-
match provider {
|
|
890
|
-
Provider::Claude => "claude",
|
|
891
|
-
Provider::ClaudeCode => "claude_code",
|
|
892
|
-
Provider::Codex => "codex",
|
|
893
|
-
Provider::Copilot => "copilot",
|
|
894
|
-
Provider::GeminiCli => "gemini_cli",
|
|
895
|
-
Provider::Fake => "fake",
|
|
896
|
-
}
|
|
897
|
-
}
|
|
916
|
+
pub(super) use crate::provider::wire::{parse_provider, provider_wire};
|
|
898
917
|
|
|
899
918
|
pub(super) fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
|
|
900
919
|
match raw {
|
|
@@ -1218,6 +1237,47 @@ pub(super) fn is_dynamic_agent(
|
|
|
1218
1237
|
.is_some_and(|s| !s.is_empty())
|
|
1219
1238
|
}
|
|
1220
1239
|
|
|
1240
|
+
/// 0.4.6 Stage 2: predict the tmux start mode BEFORE the spawn call, so
|
|
1241
|
+
/// the `provider.worker.spawn_argv` event can record what the spawn will
|
|
1242
|
+
/// actually do. Same logic as `tmux_start_mode_for_spawn` in agent.rs
|
|
1243
|
+
/// but driven by `(layout_placement, into_existing_session)` only — no
|
|
1244
|
+
/// dependency on SpawnedAgentWindow.
|
|
1245
|
+
pub(super) fn predict_tmux_start_mode(
|
|
1246
|
+
layout_placement: Option<&crate::lifecycle::launch::LayoutPlacement>,
|
|
1247
|
+
into_existing_session: bool,
|
|
1248
|
+
) -> &'static str {
|
|
1249
|
+
if let Some(placement) = layout_placement {
|
|
1250
|
+
if placement.starts_window {
|
|
1251
|
+
if into_existing_session {
|
|
1252
|
+
"new-window"
|
|
1253
|
+
} else {
|
|
1254
|
+
"new-session"
|
|
1255
|
+
}
|
|
1256
|
+
} else {
|
|
1257
|
+
"split-window"
|
|
1258
|
+
}
|
|
1259
|
+
} else if into_existing_session {
|
|
1260
|
+
"new-window"
|
|
1261
|
+
} else {
|
|
1262
|
+
"new-session"
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/// 0.4.6 Stage 2: read state.agents[agent_id].spawn_epoch from disk to
|
|
1267
|
+
/// stamp the spawn_argv event with the current cohort identifier. Returns
|
|
1268
|
+
/// 0 if the agent row / field is missing.
|
|
1269
|
+
pub(super) fn state_spawn_epoch_for_agent(workspace: &Path, agent_id: &AgentId) -> u64 {
|
|
1270
|
+
let Ok(state) = crate::state::persist::load_runtime_state(workspace) else {
|
|
1271
|
+
return 0;
|
|
1272
|
+
};
|
|
1273
|
+
state
|
|
1274
|
+
.get("agents")
|
|
1275
|
+
.and_then(|v| v.get(agent_id.as_str()))
|
|
1276
|
+
.and_then(|v| v.get("spawn_epoch"))
|
|
1277
|
+
.and_then(serde_json::Value::as_u64)
|
|
1278
|
+
.unwrap_or(0)
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1221
1281
|
#[cfg(test)]
|
|
1222
1282
|
mod e36_transcript_backing_tests {
|
|
1223
1283
|
use super::*;
|
|
@@ -538,9 +538,14 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
538
538
|
survivors.push(decision);
|
|
539
539
|
continue;
|
|
540
540
|
}
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
541
|
+
// 0.4.6 tuple-atomic contract: backing went missing between
|
|
542
|
+
// preflight and post-spawn. Clear the FULL authoritative tuple
|
|
543
|
+
// (including session_id) and persist the old provider id only
|
|
544
|
+
// in `_pending_session_id` as a capture hint. Pre-0.4.6 kept
|
|
545
|
+
// session_id alive while removing siblings — that left a
|
|
546
|
+
// partial tuple that persist-layer backfill could later
|
|
547
|
+
// resurrect into "looks captured" or that classify_restart
|
|
548
|
+
// would refuse with `session_backing_store_missing`.
|
|
544
549
|
if let Some(agents) = state
|
|
545
550
|
.pointer_mut("/agents")
|
|
546
551
|
.and_then(serde_json::Value::as_object_mut)
|
|
@@ -549,10 +554,15 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
549
554
|
.get_mut(decision.agent_id.as_str())
|
|
550
555
|
.and_then(serde_json::Value::as_object_mut)
|
|
551
556
|
{
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
557
|
+
for field in [
|
|
558
|
+
"session_id",
|
|
559
|
+
"rollout_path",
|
|
560
|
+
"captured_at",
|
|
561
|
+
"captured_via",
|
|
562
|
+
"attribution_confidence",
|
|
563
|
+
] {
|
|
564
|
+
agent_obj.remove(field);
|
|
565
|
+
}
|
|
556
566
|
agent_obj.insert(
|
|
557
567
|
"_pending_session_id".to_string(),
|
|
558
568
|
serde_json::json!(session_id.as_str()),
|
|
@@ -764,6 +774,35 @@ fn repair_resume_sessions_from_event_log(
|
|
|
764
774
|
.and_then(serde_json::Value::as_str)
|
|
765
775
|
.filter(|path| !path.is_empty())
|
|
766
776
|
.map(str::to_string);
|
|
777
|
+
// 0.4.6 tuple-atomic contract (audit §Restart 修改清单, line 175):
|
|
778
|
+
// capture-domain repair must yield a COMPLETE authoritative tuple
|
|
779
|
+
// (session_id + rollout_path + captured_at + captured_via) before
|
|
780
|
+
// restart writes it back into agent state. A partial repair would
|
|
781
|
+
// be persist-domain truth synthesis through the restart hook,
|
|
782
|
+
// exactly what the contract forbids. Reject incomplete repairs;
|
|
783
|
+
// capture stays Stage-1 pending on next coordinator tick.
|
|
784
|
+
let captured_at_ok = repaired
|
|
785
|
+
.get("captured_at")
|
|
786
|
+
.is_some_and(|v| !v.is_null() && v.as_str().is_some_and(|s| !s.is_empty()));
|
|
787
|
+
let captured_via_ok = repaired
|
|
788
|
+
.get("captured_via")
|
|
789
|
+
.is_some_and(|v| !v.is_null() && v.as_str().is_some_and(|s| !s.is_empty()));
|
|
790
|
+
if session_id.is_none() || rollout_path.is_none() || !captured_at_ok || !captured_via_ok {
|
|
791
|
+
crate::event_log::EventLog::new(workspace)
|
|
792
|
+
.write(
|
|
793
|
+
"resume.session_repair_refused_incomplete_tuple",
|
|
794
|
+
serde_json::json!({
|
|
795
|
+
"agent_id": agent_id,
|
|
796
|
+
"provider": provider_wire(provider),
|
|
797
|
+
"session_id": session_id,
|
|
798
|
+
"rollout_path": rollout_path,
|
|
799
|
+
"captured_at_ok": captured_at_ok,
|
|
800
|
+
"captured_via_ok": captured_via_ok,
|
|
801
|
+
}),
|
|
802
|
+
)
|
|
803
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
767
806
|
if let Some(agent) = state
|
|
768
807
|
.get_mut("agents")
|
|
769
808
|
.and_then(serde_json::Value::as_object_mut)
|
|
@@ -794,7 +833,7 @@ fn claimed_session_ids_except(
|
|
|
794
833
|
state: &serde_json::Value,
|
|
795
834
|
current_agent_id: &str,
|
|
796
835
|
) -> std::collections::BTreeSet<String> {
|
|
797
|
-
state
|
|
836
|
+
let mut keys: std::collections::BTreeSet<String> = state
|
|
798
837
|
.get("agents")
|
|
799
838
|
.and_then(serde_json::Value::as_object)
|
|
800
839
|
.map(|agents| {
|
|
@@ -810,7 +849,42 @@ fn claimed_session_ids_except(
|
|
|
810
849
|
})
|
|
811
850
|
.collect()
|
|
812
851
|
})
|
|
813
|
-
.unwrap_or_default()
|
|
852
|
+
.unwrap_or_default();
|
|
853
|
+
// E57 (lane-046-capture-gap): restart's resume_session_repair postflight
|
|
854
|
+
// path must NOT reassign the leader's own provider session to a worker.
|
|
855
|
+
// The capture-layer allocator (session_capture::claimed_provider_session_keys)
|
|
856
|
+
// already excludes leader_receiver/team_owner; mirror that here for the
|
|
857
|
+
// event-log recovery path, otherwise a fresh restart can pull the leader
|
|
858
|
+
// session_id out of events.jsonl and write it onto a worker
|
|
859
|
+
// (release-engineer / any worker with no captured session). Scan
|
|
860
|
+
// state.leader_receiver, state.team_owner, and the same fields under
|
|
861
|
+
// state.teams.<key>.
|
|
862
|
+
push_leader_session_ids(&mut keys, state);
|
|
863
|
+
if let Some(teams) = state.get("teams").and_then(serde_json::Value::as_object) {
|
|
864
|
+
for team_state in teams.values() {
|
|
865
|
+
push_leader_session_ids(&mut keys, team_state);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
keys
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
fn push_leader_session_ids(
|
|
872
|
+
keys: &mut std::collections::BTreeSet<String>,
|
|
873
|
+
scope: &serde_json::Value,
|
|
874
|
+
) {
|
|
875
|
+
for anchor in ["leader_receiver", "team_owner"] {
|
|
876
|
+
if let Some(node) = scope.get(anchor) {
|
|
877
|
+
for field in ["session_id", "provider_session_id"] {
|
|
878
|
+
if let Some(session_id) = node
|
|
879
|
+
.get(field)
|
|
880
|
+
.and_then(serde_json::Value::as_str)
|
|
881
|
+
.filter(|s| !s.is_empty())
|
|
882
|
+
{
|
|
883
|
+
keys.insert(session_id.to_string());
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
814
888
|
}
|
|
815
889
|
|
|
816
890
|
fn session_convergence_deadline(requested_ms: Option<u64>) -> std::time::Duration {
|
|
@@ -1109,13 +1183,17 @@ fn try_autobind_leader_after_restart(
|
|
|
1109
1183
|
.pointer("/leader_receiver/provider")
|
|
1110
1184
|
.and_then(serde_json::Value::as_str)
|
|
1111
1185
|
})
|
|
1112
|
-
.and_then(|s|
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1186
|
+
.and_then(|s| {
|
|
1187
|
+
// Legacy auto-attach: collapse any claude variant to ClaudeCode
|
|
1188
|
+
// (this site historically treated `Claude` and `ClaudeCode` as
|
|
1189
|
+
// the same attach target). Wire-format `parse_provider` keeps
|
|
1190
|
+
// them distinct everywhere else.
|
|
1191
|
+
crate::provider::wire::parse_provider(s).map(|p| match p {
|
|
1192
|
+
crate::model::enums::Provider::Claude => {
|
|
1193
|
+
crate::model::enums::Provider::ClaudeCode
|
|
1194
|
+
}
|
|
1195
|
+
other => other,
|
|
1196
|
+
})
|
|
1119
1197
|
})
|
|
1120
1198
|
.unwrap_or(crate::model::enums::Provider::ClaudeCode);
|
|
1121
1199
|
let team_str = team;
|
|
@@ -1270,44 +1348,24 @@ fn mark_agent_respawned(
|
|
|
1270
1348
|
// restart probes correctly refuse with `session_backing_store_missing`
|
|
1271
1349
|
// (macmini bug-044 truth source for Claude/ClaudeCode).
|
|
1272
1350
|
//
|
|
1273
|
-
//
|
|
1274
|
-
//
|
|
1275
|
-
//
|
|
1276
|
-
//
|
|
1277
|
-
//
|
|
1278
|
-
//
|
|
1279
|
-
//
|
|
1280
|
-
//
|
|
1281
|
-
//
|
|
1282
|
-
//
|
|
1283
|
-
//
|
|
1284
|
-
//
|
|
1285
|
-
//
|
|
1286
|
-
// command planning returns `None` (provider/adapter.rs:477-495,
|
|
1287
|
-
// 0.3.31 correction).
|
|
1351
|
+
// 0.4.6 tuple-atomic contract (restart-persist-capture-contract-audit.md):
|
|
1352
|
+
// ALL providers — restart never promotes a planned id into authoritative
|
|
1353
|
+
// `session_id`. Capture (provider scanner) is the only authoritative
|
|
1354
|
+
// writer. On Fresh / FreshAfterMissingRollout, clear the full tuple;
|
|
1355
|
+
// `persist_command_plan_state` below writes `_pending_session_id` as a
|
|
1356
|
+
// capture hint, and the provider scanner promotes that hint into the
|
|
1357
|
+
// tuple ONLY after it confirms backing (sqlite/transcript/.codex).
|
|
1358
|
+
//
|
|
1359
|
+
// This deletes the Copilot promotion that previously violated the
|
|
1360
|
+
// capture-only-writer rule (audit §Restart 当前违规 #1) and uses the
|
|
1361
|
+
// Claude family clear path uniformly. The Copilot scanner
|
|
1362
|
+
// (provider/adapter.rs:1217-1228) already gates expected-id with a
|
|
1363
|
+
// sqlite point-check, so no truth is lost.
|
|
1288
1364
|
if matches!(
|
|
1289
1365
|
restart_mode,
|
|
1290
1366
|
StartMode::Fresh | StartMode::FreshAfterMissingRollout
|
|
1291
1367
|
) {
|
|
1292
|
-
|
|
1293
|
-
.get("provider")
|
|
1294
|
-
.and_then(serde_json::Value::as_str)
|
|
1295
|
-
.map(str::to_ascii_lowercase);
|
|
1296
|
-
let is_claude_family = matches!(
|
|
1297
|
-
provider.as_deref(),
|
|
1298
|
-
Some("claude") | Some("claude_code") | Some("claude-code") | Some("claudecode")
|
|
1299
|
-
);
|
|
1300
|
-
if is_claude_family {
|
|
1301
|
-
// Claude family: do not promote. Clear stale session_id.
|
|
1302
|
-
agent.insert("session_id".to_string(), serde_json::Value::Null);
|
|
1303
|
-
} else if let Some(session_id) = spawn.plan.expected_session_id.as_ref() {
|
|
1304
|
-
// Copilot (and any future provider with provider-store backing
|
|
1305
|
-
// semantics): promote as before.
|
|
1306
|
-
agent.insert(
|
|
1307
|
-
"session_id".to_string(),
|
|
1308
|
-
serde_json::json!(session_id.as_str()),
|
|
1309
|
-
);
|
|
1310
|
-
}
|
|
1368
|
+
agent.insert("session_id".to_string(), serde_json::Value::Null);
|
|
1311
1369
|
}
|
|
1312
1370
|
crate::lifecycle::launch::persist_command_plan_state(agent, &spawn.plan, &spawn.profile_launch);
|
|
1313
1371
|
persist_effective_approval_policy_for_restart(agent, safety);
|
|
@@ -221,6 +221,25 @@ pub(crate) fn classify_restart_plan_with_resume_validation(
|
|
|
221
221
|
}
|
|
222
222
|
_ => (true, Vec::new()),
|
|
223
223
|
};
|
|
224
|
+
// 0.4.7 partial-resume: when a worker has NEVER been captured (no
|
|
225
|
+
// session_id AND first_send_at is Absent), it is structurally
|
|
226
|
+
// non-resumable — there is no context to lose, so auto-fresh is
|
|
227
|
+
// safe even without --allow-fresh. This prevents a single
|
|
228
|
+
// never-captured role (e.g. MCP-only worker that the leader never
|
|
229
|
+
// messaged) from blocking restart of the other 6 roles that DO
|
|
230
|
+
// have complete resume tuples.
|
|
231
|
+
//
|
|
232
|
+
// The "never_captured" predicate is the conjunction:
|
|
233
|
+
// * session_id is None (no provider session bound), AND
|
|
234
|
+
// * first_send_at is Absent (leader has never injected a
|
|
235
|
+
// message — so we never armed the capture path either).
|
|
236
|
+
//
|
|
237
|
+
// If session_id is None but first_send_at is Valid, that's the
|
|
238
|
+
// "received message but session not captured" bug state — keep
|
|
239
|
+
// the Refuse so we never silently drop context (architect rule:
|
|
240
|
+
// "绝不静默 fresh").
|
|
241
|
+
let never_captured =
|
|
242
|
+
session_id.is_none() && matches!(first_send_at_state, FirstSendAtState::Absent);
|
|
224
243
|
let decision = if session_id.is_some() && provider_can_resume && resume_backing_exists {
|
|
225
244
|
ResumeDecision::Resume
|
|
226
245
|
} else if session_id.is_some() && allow_fresh {
|
|
@@ -229,6 +248,10 @@ pub(crate) fn classify_restart_plan_with_resume_validation(
|
|
|
229
248
|
ResumeDecision::Refuse
|
|
230
249
|
} else if allow_fresh {
|
|
231
250
|
ResumeDecision::FreshStart
|
|
251
|
+
} else if never_captured {
|
|
252
|
+
// No session_id + never captured → safe to auto-fresh without
|
|
253
|
+
// --allow-fresh. No context to lose.
|
|
254
|
+
ResumeDecision::FreshStart
|
|
232
255
|
} else {
|
|
233
256
|
ResumeDecision::Refuse
|
|
234
257
|
};
|