@team-agent/installer 0.4.10 → 0.5.0
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 +1 -0
- package/crates/team-agent/src/cli/diagnose.rs +2 -15
- package/crates/team-agent/src/cli/emit.rs +62 -4
- package/crates/team-agent/src/compiler.rs +11 -20
- package/crates/team-agent/src/coordinator/orphan.rs +2 -2
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
- package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
- package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
- package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
- package/crates/team-agent/src/coordinator/tick.rs +31 -932
- package/crates/team-agent/src/diagnose/orphans.rs +66 -8
- package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
- package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
- package/crates/team-agent/src/leader/rediscover.rs +1 -11
- package/crates/team-agent/src/leader/start.rs +169 -25
- package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
- package/crates/team-agent/src/lifecycle/launch.rs +135 -58
- package/crates/team-agent/src/lifecycle/lock.rs +302 -0
- package/crates/team-agent/src/lifecycle/mod.rs +1 -0
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
- package/crates/team-agent/src/lifecycle/restart/agent.rs +436 -121
- package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
- package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
- package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +193 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +54 -15
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +648 -0
- package/crates/team-agent/src/lifecycle/tests.rs +3 -0
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +24 -34
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
- package/crates/team-agent/src/mcp_server/tools.rs +157 -68
- package/crates/team-agent/src/messaging/activity.rs +43 -18
- package/crates/team-agent/src/messaging/delivery.rs +161 -97
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
- package/crates/team-agent/src/messaging/results.rs +34 -9
- package/crates/team-agent/src/messaging/scheduler.rs +52 -9
- package/crates/team-agent/src/model/spec.rs +10 -6
- package/crates/team-agent/src/provider/adapter.rs +10 -1038
- package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
- package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
- package/crates/team-agent/src/provider/classify.rs +23 -48
- package/crates/team-agent/src/provider/command.rs +16 -7
- package/crates/team-agent/src/provider/faults.rs +93 -53
- package/crates/team-agent/src/provider/session/capture.rs +4 -15
- package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
- package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
- package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
- package/crates/team-agent/src/provider/session_scan.rs +65 -27
- package/crates/team-agent/src/provider/tests/faults.rs +80 -0
- package/crates/team-agent/src/provider/types.rs +33 -1
- package/crates/team-agent/src/provider/wire.rs +171 -1
- package/crates/team-agent/src/state/identity.rs +242 -57
- package/crates/team-agent/src/state/identity_keys.rs +9 -12
- package/crates/team-agent/src/state/mod.rs +5 -1
- package/crates/team-agent/src/state/owner_gate.rs +59 -15
- package/crates/team-agent/src/state/ownership.rs +12 -11
- package/crates/team-agent/src/state/paths.rs +13 -6
- package/crates/team-agent/src/state/persist.rs +671 -128
- package/crates/team-agent/src/state/projection.rs +240 -49
- package/crates/team-agent/src/state/selector.rs +11 -5
- package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
- package/crates/team-agent/src/tmux_backend.rs +42 -5
- package/crates/team-agent/src/transport/test_support.rs +55 -6
- package/package.json +4 -4
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
use super::*;
|
|
2
1
|
use super::common::*;
|
|
3
2
|
use super::selection::decide_start_mode;
|
|
4
3
|
use super::team_state::write_team_state;
|
|
4
|
+
use super::*;
|
|
5
|
+
use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
|
|
5
6
|
|
|
6
7
|
/// `start_agent(workspace, agent_id, force, open_display, allow_fresh, team)`
|
|
7
8
|
/// (`lifecycle/start.py:72`)。`_runtime_lock("start-agent")` 下串行:resume-or-fresh
|
|
@@ -17,8 +18,13 @@ pub fn start_agent(
|
|
|
17
18
|
team: Option<&str>,
|
|
18
19
|
) -> Result<StartAgentOutcome, LifecycleError> {
|
|
19
20
|
let paths = lifecycle_paths(workspace, team)?;
|
|
20
|
-
let
|
|
21
|
-
|
|
21
|
+
let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
|
|
22
|
+
workspace: &paths.run_workspace,
|
|
23
|
+
operation: "start-agent",
|
|
24
|
+
team,
|
|
25
|
+
agent_id: Some(agent_id),
|
|
26
|
+
})?;
|
|
27
|
+
let transport = lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
|
|
22
28
|
start_agent_at_paths(
|
|
23
29
|
&paths.run_workspace,
|
|
24
30
|
&paths.spec_workspace,
|
|
@@ -43,9 +49,23 @@ pub fn start_agent_with_transport(
|
|
|
43
49
|
team: Option<&str>,
|
|
44
50
|
transport: &dyn crate::transport::Transport,
|
|
45
51
|
) -> Result<StartAgentOutcome, LifecycleError> {
|
|
52
|
+
let paths = match lifecycle_paths(workspace, team) {
|
|
53
|
+
Ok(paths) => paths,
|
|
54
|
+
Err(_) if team.is_none() => LifecyclePaths {
|
|
55
|
+
run_workspace: workspace.to_path_buf(),
|
|
56
|
+
spec_workspace: workspace.to_path_buf(),
|
|
57
|
+
},
|
|
58
|
+
Err(error) => return Err(error),
|
|
59
|
+
};
|
|
60
|
+
let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
|
|
61
|
+
workspace: &paths.run_workspace,
|
|
62
|
+
operation: "start-agent",
|
|
63
|
+
team,
|
|
64
|
+
agent_id: Some(agent_id),
|
|
65
|
+
})?;
|
|
46
66
|
start_agent_at_paths(
|
|
47
|
-
|
|
48
|
-
|
|
67
|
+
&paths.run_workspace,
|
|
68
|
+
&paths.spec_workspace,
|
|
49
69
|
agent_id,
|
|
50
70
|
force,
|
|
51
71
|
open_display,
|
|
@@ -74,24 +94,27 @@ pub(crate) fn start_agent_at_paths(
|
|
|
74
94
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?
|
|
75
95
|
};
|
|
76
96
|
crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, Some(agent_id))?;
|
|
77
|
-
let
|
|
97
|
+
let raw_agent = state
|
|
78
98
|
.get("agents")
|
|
79
99
|
.and_then(|v| v.get(agent_id.as_str()))
|
|
80
100
|
.ok_or_else(|| LifecycleError::RequirementUnmet(format!("agent {agent_id} not found")))?
|
|
81
101
|
.clone();
|
|
82
|
-
|
|
102
|
+
let agent = rehydrate_agent_command_context_from_spec(spec_workspace, agent_id, &raw_agent);
|
|
103
|
+
if raw_agent
|
|
83
104
|
.get("paused")
|
|
84
105
|
.and_then(serde_json::Value::as_bool)
|
|
85
106
|
.unwrap_or(false)
|
|
86
107
|
{
|
|
87
|
-
return Ok(StartAgentOutcome::Paused {
|
|
108
|
+
return Ok(StartAgentOutcome::Paused {
|
|
109
|
+
agent_id: agent_id.clone(),
|
|
110
|
+
});
|
|
88
111
|
}
|
|
89
112
|
let session_name = state_session_name(&state);
|
|
90
113
|
let window = agent_window(&agent, agent_id);
|
|
91
114
|
let adaptive_layout =
|
|
92
115
|
open_display && crate::lifecycle::launch::state_uses_adaptive_layout(&state);
|
|
93
116
|
let agent_live = if adaptive_layout {
|
|
94
|
-
agent_pane_live(transport, &
|
|
117
|
+
agent_pane_live(transport, &raw_agent)
|
|
95
118
|
} else {
|
|
96
119
|
window_exists(transport, &session_name, &window)
|
|
97
120
|
};
|
|
@@ -103,7 +126,7 @@ pub(crate) fn start_agent_at_paths(
|
|
|
103
126
|
// spawn (defensive). The check itself remains a no-op on healthy
|
|
104
127
|
// state — assert_topology_invariants from Step 1 catches the
|
|
105
128
|
// upstream corruption.
|
|
106
|
-
let has_collision = pane_conflicts_with_leader_or_other(&state, agent_id, &
|
|
129
|
+
let has_collision = pane_conflicts_with_leader_or_other(&state, agent_id, &raw_agent);
|
|
107
130
|
if has_collision {
|
|
108
131
|
eprintln!(
|
|
109
132
|
"team_agent::layout e51_collision_post_step2 agent_id=`{agent_id}` \
|
|
@@ -116,7 +139,7 @@ pub(crate) fn start_agent_at_paths(
|
|
|
116
139
|
if !force && agent_live {
|
|
117
140
|
mark_agent_running_noop(&mut state, agent_id, &session_name, &window)?;
|
|
118
141
|
let team_key = restart_projection_team_key(&state, team);
|
|
119
|
-
save_restart_projected_state(workspace, &mut state, &team_key)?;
|
|
142
|
+
save_restart_projected_state(workspace, &mut state, &team_key, &[agent_id.as_str()])?;
|
|
120
143
|
if let Ok(spec) = load_team_spec(spec_workspace) {
|
|
121
144
|
write_team_state(spec_workspace, &spec, &state)?;
|
|
122
145
|
}
|
|
@@ -208,7 +231,22 @@ pub(crate) fn start_agent_at_paths(
|
|
|
208
231
|
None,
|
|
209
232
|
Some(resolved_team_key.as_str()),
|
|
210
233
|
)?;
|
|
211
|
-
|
|
234
|
+
verify_spawned_pane_matches_target(
|
|
235
|
+
transport,
|
|
236
|
+
&spawn.spawn.pane_id,
|
|
237
|
+
&session_name,
|
|
238
|
+
&spawn.spawn.window,
|
|
239
|
+
)?;
|
|
240
|
+
let actual_spawn_window = spawn.spawn.window.as_str().to_string();
|
|
241
|
+
mark_agent_started(
|
|
242
|
+
&mut state,
|
|
243
|
+
agent_id,
|
|
244
|
+
&actual_spawn_window,
|
|
245
|
+
&spawn,
|
|
246
|
+
transport,
|
|
247
|
+
&safety,
|
|
248
|
+
start_mode,
|
|
249
|
+
)?;
|
|
212
250
|
// **0.3.24 add-agent socket drift fix**: keep `state.tmux_endpoint` /
|
|
213
251
|
// `state.tmux_socket` synchronized with the transport actually used for the
|
|
214
252
|
// spawn. Without this, add-agent / fork-agent could spawn to a socket that
|
|
@@ -218,24 +256,7 @@ pub(crate) fn start_agent_at_paths(
|
|
|
218
256
|
// after spawn" race with coordinator and no double source of truth.
|
|
219
257
|
crate::lifecycle::launch::annotate_runtime_tmux_endpoint(&mut state, transport, workspace);
|
|
220
258
|
let team_key = restart_projection_team_key(&state, team);
|
|
221
|
-
save_restart_projected_state(workspace, &mut state, &team_key)?;
|
|
222
|
-
// **0.3.24 reachability gate (调整 4)** — strict, non-capture verification
|
|
223
|
-
// that the spawn actually produced an addressable window/pane on the
|
|
224
|
-
// transport's socket. Catches the macmini假绿 (`ok=true` returned by
|
|
225
|
-
// `add_agent` while the spawned `claude` process orphaned on a different
|
|
226
|
-
// socket and the leader never registered it). We use `has_pane` /
|
|
227
|
-
// `liveness` / `list_targets` (structural addressing only) — never
|
|
228
|
-
// `capture` — to avoid contention with E31's pane-readback gate timing.
|
|
229
|
-
if !spawned_pane_is_reachable(transport, &spawn.spawn.pane_id) {
|
|
230
|
-
return Err(LifecycleError::RequirementUnmet(format!(
|
|
231
|
-
"add-agent spawn unreachable: pane {} not addressable on transport \
|
|
232
|
-
socket {:?} (likely socket drift — see 0.3.24 fix); the agent \
|
|
233
|
-
process may have orphaned on a different tmux socket. Re-run after \
|
|
234
|
-
confirming the team's tmux_endpoint persisted via `team-agent status`.",
|
|
235
|
-
spawn.spawn.pane_id.as_str(),
|
|
236
|
-
transport.tmux_endpoint().unwrap_or_default()
|
|
237
|
-
)));
|
|
238
|
-
}
|
|
259
|
+
save_restart_projected_state(workspace, &mut state, &team_key, &[agent_id.as_str()])?;
|
|
239
260
|
write_start_agent_start_event(
|
|
240
261
|
workspace,
|
|
241
262
|
agent_id,
|
|
@@ -243,7 +264,7 @@ pub(crate) fn start_agent_at_paths(
|
|
|
243
264
|
provider,
|
|
244
265
|
start_mode,
|
|
245
266
|
&session_name,
|
|
246
|
-
&
|
|
267
|
+
&actual_spawn_window,
|
|
247
268
|
spawn_session_id,
|
|
248
269
|
tmux_start_mode_for_spawn(&spawn, into_existing_session),
|
|
249
270
|
)?;
|
|
@@ -262,40 +283,51 @@ pub(crate) fn start_agent_at_paths(
|
|
|
262
283
|
})
|
|
263
284
|
}
|
|
264
285
|
|
|
265
|
-
|
|
266
|
-
/// pane is currently addressable on the transport's tmux socket. Uses structural
|
|
267
|
-
/// addressing only (`has_pane` → `liveness` → `list_targets`) — **never**
|
|
268
|
-
/// `capture` — so it does not race with E31's pane-readback gate timing
|
|
269
|
-
/// (E31 reads pane contents after submit; running our own capture here would
|
|
270
|
-
/// either consume the first-paste tokens E31 looks for, or vice versa).
|
|
271
|
-
///
|
|
272
|
-
/// The gate fires after `mark_agent_started` + `save_restart_projected_state`,
|
|
273
|
-
/// so an unreachable pane returns a structured `LifecycleError` that the CLI
|
|
274
|
-
/// surface translates to `ok=false`, replacing the macmini假绿
|
|
275
|
-
/// (`ok=true` with orphaned-on-wrong-socket spawn).
|
|
276
|
-
fn spawned_pane_is_reachable(
|
|
286
|
+
fn verify_spawned_pane_matches_target(
|
|
277
287
|
transport: &dyn crate::transport::Transport,
|
|
278
288
|
pane: &crate::transport::PaneId,
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
289
|
+
session: &crate::transport::SessionName,
|
|
290
|
+
window: &crate::transport::WindowName,
|
|
291
|
+
) -> Result<(), LifecycleError> {
|
|
292
|
+
let targets = transport
|
|
293
|
+
.list_targets()
|
|
294
|
+
.map_err(|e| LifecycleError::Transport(e.to_string()))?;
|
|
295
|
+
let observed = targets.iter().find(|target| target.pane_id == *pane);
|
|
296
|
+
let Some(observed) = observed else {
|
|
297
|
+
return Err(LifecycleError::RequirementUnmet(format!(
|
|
298
|
+
"start refused: spawned pane not addressable on transport socket (possible socket drift); window disappeared or spawned pane not owned by requested agent window; requested={}:{} pane={} observed=<missing>",
|
|
299
|
+
session.as_str(),
|
|
300
|
+
window.as_str(),
|
|
301
|
+
pane.as_str()
|
|
302
|
+
)));
|
|
303
|
+
};
|
|
304
|
+
let observed_window = observed
|
|
305
|
+
.window_name
|
|
306
|
+
.as_ref()
|
|
307
|
+
.map(crate::transport::WindowName::as_str)
|
|
308
|
+
.unwrap_or("<unknown>");
|
|
309
|
+
if observed.session.as_str() != session.as_str() || observed_window != window.as_str() {
|
|
310
|
+
return Err(LifecycleError::RequirementUnmet(format!(
|
|
311
|
+
"start refused: spawned pane not owned by requested agent window; requested={}:{} pane={} observed={}:{}",
|
|
312
|
+
session.as_str(),
|
|
313
|
+
window.as_str(),
|
|
314
|
+
pane.as_str(),
|
|
315
|
+
observed.session.as_str(),
|
|
316
|
+
observed_window
|
|
317
|
+
)));
|
|
283
318
|
}
|
|
284
|
-
// (b) liveness probe: not-Dead → addressable. Dead → unreachable.
|
|
285
319
|
if matches!(
|
|
286
320
|
transport.liveness(pane),
|
|
287
|
-
Ok(crate::model::enums::PaneLiveness::
|
|
321
|
+
Ok(crate::model::enums::PaneLiveness::Dead)
|
|
288
322
|
) {
|
|
289
|
-
return
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
return true;
|
|
296
|
-
}
|
|
323
|
+
return Err(LifecycleError::RequirementUnmet(format!(
|
|
324
|
+
"start refused: spawned pane not addressable on transport socket; pane is dead after spawn; requested={}:{} pane={}",
|
|
325
|
+
session.as_str(),
|
|
326
|
+
window.as_str(),
|
|
327
|
+
pane.as_str()
|
|
328
|
+
)));
|
|
297
329
|
}
|
|
298
|
-
|
|
330
|
+
Ok(())
|
|
299
331
|
}
|
|
300
332
|
|
|
301
333
|
/// E51 (0.3.26 P0, restart self-heal): returns `true` when the agent's pane_id
|
|
@@ -318,9 +350,11 @@ fn pane_conflicts_with_leader_or_other(
|
|
|
318
350
|
let agent_socket = tmux_socket_field(agent).or(state_socket);
|
|
319
351
|
// Check leader anchor.
|
|
320
352
|
for key in ["leader_receiver", "team_owner"] {
|
|
321
|
-
if state
|
|
322
|
-
|
|
323
|
-
|
|
353
|
+
if state
|
|
354
|
+
.get(key)
|
|
355
|
+
.and_then(pane_socket_binding)
|
|
356
|
+
.is_some_and(|leader| pane_conflicts_on_same_socket(pane_id, agent_socket, leader))
|
|
357
|
+
{
|
|
324
358
|
return true;
|
|
325
359
|
}
|
|
326
360
|
}
|
|
@@ -392,10 +426,7 @@ fn pane_socket_binding(value: &serde_json::Value) -> Option<PaneSocketBinding<'_
|
|
|
392
426
|
})
|
|
393
427
|
}
|
|
394
428
|
|
|
395
|
-
fn agent_pane_live(
|
|
396
|
-
transport: &dyn crate::transport::Transport,
|
|
397
|
-
agent: &serde_json::Value,
|
|
398
|
-
) -> bool {
|
|
429
|
+
fn agent_pane_live(transport: &dyn crate::transport::Transport, agent: &serde_json::Value) -> bool {
|
|
399
430
|
let Some(pane) = agent
|
|
400
431
|
.get("pane_id")
|
|
401
432
|
.and_then(serde_json::Value::as_str)
|
|
@@ -420,6 +451,47 @@ fn agent_pane_live_by_id(
|
|
|
420
451
|
}
|
|
421
452
|
}
|
|
422
453
|
|
|
454
|
+
/// 0.4.10+ reset duplicate-window fix (CR-approved, plan §1).
|
|
455
|
+
///
|
|
456
|
+
/// Enumerate live panes whose `(session, window_name)` match the given pair.
|
|
457
|
+
/// Used by `stop_agent_at_paths` (when the stored pane_id is stale/dead but a
|
|
458
|
+
/// same-role window survives) and by the reset hard gate (to prove no
|
|
459
|
+
/// duplicate window residue remains before `start_agent_at_paths`).
|
|
460
|
+
///
|
|
461
|
+
/// `list_targets()` is a point-in-time tmux snapshot — duplicates ARE
|
|
462
|
+
/// preserved in the result so callers can see the full set.
|
|
463
|
+
///
|
|
464
|
+
/// Caller MUST also check `is_per_agent_window(window, agent_id)` before
|
|
465
|
+
/// using this list to kill panes (plan §2 safety constraint): a shared
|
|
466
|
+
/// layout window may host co-tenants.
|
|
467
|
+
fn list_same_role_panes(
|
|
468
|
+
transport: &dyn crate::transport::Transport,
|
|
469
|
+
session: &crate::transport::SessionName,
|
|
470
|
+
window: &str,
|
|
471
|
+
) -> Vec<crate::transport::PaneInfo> {
|
|
472
|
+
transport
|
|
473
|
+
.list_targets()
|
|
474
|
+
.unwrap_or_default()
|
|
475
|
+
.into_iter()
|
|
476
|
+
.filter(|pane| {
|
|
477
|
+
pane.session.as_str() == session.as_str()
|
|
478
|
+
&& pane.window_name.as_ref().map(WindowName::as_str) == Some(window)
|
|
479
|
+
})
|
|
480
|
+
.collect()
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/// 0.4.10+ reset duplicate-window fix (plan §2 safety constraint).
|
|
484
|
+
///
|
|
485
|
+
/// Returns true only when `window == agent_id`, i.e. the canonical
|
|
486
|
+
/// per-agent window. Adaptive/shared layout windows (`workers`, `team`,
|
|
487
|
+
/// or any layout window name produced by `is_adaptive_layout_window_pub`)
|
|
488
|
+
/// MUST NOT be broad-killed by name — they may host co-tenants. The
|
|
489
|
+
/// caller falls back to safer behavior (refuse stop, surface
|
|
490
|
+
/// RequirementUnmet/transport error) when this returns false.
|
|
491
|
+
fn is_per_agent_window(window: &str, agent_id: &AgentId) -> bool {
|
|
492
|
+
window == agent_id.as_str() && !crate::lifecycle::launch::is_adaptive_layout_window_pub(window)
|
|
493
|
+
}
|
|
494
|
+
|
|
423
495
|
fn tmux_start_mode_for_spawn(
|
|
424
496
|
spawn: &SpawnedAgentWindow,
|
|
425
497
|
into_existing_session: bool,
|
|
@@ -542,8 +614,13 @@ pub fn stop_agent(
|
|
|
542
614
|
team: Option<&str>,
|
|
543
615
|
) -> Result<StopAgentReport, LifecycleError> {
|
|
544
616
|
let paths = lifecycle_paths(workspace, team)?;
|
|
545
|
-
let
|
|
546
|
-
|
|
617
|
+
let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
|
|
618
|
+
workspace: &paths.run_workspace,
|
|
619
|
+
operation: "stop-agent",
|
|
620
|
+
team,
|
|
621
|
+
agent_id: Some(agent_id),
|
|
622
|
+
})?;
|
|
623
|
+
let transport = lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
|
|
547
624
|
stop_agent_at_paths(
|
|
548
625
|
&paths.run_workspace,
|
|
549
626
|
&paths.spec_workspace,
|
|
@@ -560,6 +637,12 @@ pub fn stop_agent_with_transport(
|
|
|
560
637
|
transport: &dyn crate::transport::Transport,
|
|
561
638
|
) -> Result<StopAgentReport, LifecycleError> {
|
|
562
639
|
let paths = lifecycle_paths(workspace, team)?;
|
|
640
|
+
let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
|
|
641
|
+
workspace: &paths.run_workspace,
|
|
642
|
+
operation: "stop-agent",
|
|
643
|
+
team,
|
|
644
|
+
agent_id: Some(agent_id),
|
|
645
|
+
})?;
|
|
563
646
|
stop_agent_at_paths(
|
|
564
647
|
&paths.run_workspace,
|
|
565
648
|
&paths.spec_workspace,
|
|
@@ -580,8 +663,7 @@ pub(super) fn stop_agent_at_paths(
|
|
|
580
663
|
let mut state = resolve_team_scoped_state_or_refuse(workspace, team)?;
|
|
581
664
|
crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, Some(agent_id))?;
|
|
582
665
|
let spec = load_team_spec(spec_workspace)?;
|
|
583
|
-
let agent = find_spec_agent(&spec, agent_id)
|
|
584
|
-
.ok_or_else(|| unknown_worker(agent_id))?;
|
|
666
|
+
let agent = find_spec_agent(&spec, agent_id).ok_or_else(|| unknown_worker(agent_id))?;
|
|
585
667
|
let session_name = state_session_name_from_spec(&state, &spec);
|
|
586
668
|
let window = state
|
|
587
669
|
.get("agents")
|
|
@@ -602,29 +684,73 @@ pub(super) fn stop_agent_at_paths(
|
|
|
602
684
|
.as_ref()
|
|
603
685
|
.map(|pane| pane.as_str().to_string())
|
|
604
686
|
.unwrap_or_else(|| format!("{}:{window}", session_name.as_str()));
|
|
605
|
-
|
|
687
|
+
// 0.4.10+ reset duplicate-window fix (plan §2): stop must resolve a
|
|
688
|
+
// STALE stored pane_id to live same-role panes by `(session, window)`
|
|
689
|
+
// enumeration BEFORE concluding the worker is absent. Pre-fix logic
|
|
690
|
+
// returned stopped=false when pane_id was dead even if a residual
|
|
691
|
+
// duplicate window survived — that residue then collided with
|
|
692
|
+
// reset's unconditional start, producing the observed
|
|
693
|
+
// `stopped=false, started=true` duplicate-window pattern.
|
|
694
|
+
//
|
|
695
|
+
// Only the STALE-pane-id branch enumerates same-role panes. When
|
|
696
|
+
// pane_id was never set in state (legacy state shape, never observed
|
|
697
|
+
// a real spawn), the existing window-based fallback is preserved —
|
|
698
|
+
// the duplicate-window bug requires a stored-but-stale pane_id as
|
|
699
|
+
// the trigger.
|
|
700
|
+
let stored_pane_live = pane_id
|
|
606
701
|
.as_ref()
|
|
607
702
|
.map(|pane| agent_pane_live_by_id(transport, pane))
|
|
608
|
-
.
|
|
703
|
+
.unwrap_or(false);
|
|
704
|
+
let stored_pane_stale = pane_id.is_some() && !stored_pane_live;
|
|
705
|
+
let same_role_panes: Vec<crate::transport::PaneInfo> =
|
|
706
|
+
if stored_pane_stale && is_per_agent_window(&window, agent_id) {
|
|
707
|
+
list_same_role_panes(transport, &session_name, &window)
|
|
708
|
+
} else {
|
|
709
|
+
Vec::new()
|
|
710
|
+
};
|
|
711
|
+
let stopped = stored_pane_live
|
|
712
|
+
|| !same_role_panes.is_empty()
|
|
713
|
+
|| (pane_id.is_none() && window_exists(transport, &session_name, &window));
|
|
609
714
|
if stopped {
|
|
610
715
|
// golden operations.py:84-86: a non-zero kill-window raises
|
|
611
716
|
// RuntimeError(f"failed to stop agent {agent_id}: {proc.stderr.strip()}").
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
717
|
+
//
|
|
718
|
+
// 0.4.10+ kill resolution order (plan §2):
|
|
719
|
+
// 1. stored pane_id is live → kill it by pane_id.
|
|
720
|
+
// 2. stored pane_id is stale BUT same-role panes survive →
|
|
721
|
+
// kill each by pane_id (duplicate window names make
|
|
722
|
+
// kill-window -t session:window ambiguous).
|
|
723
|
+
// 3. no pane_id at all but window exists → kill_window as
|
|
724
|
+
// before (legacy compat for state without pane_id field).
|
|
725
|
+
let kill_result: Result<(), crate::transport::TransportError> =
|
|
726
|
+
if let Some(pane) = pane_id.as_ref().filter(|_| stored_pane_live) {
|
|
727
|
+
transport.kill_pane(pane)
|
|
728
|
+
} else if !same_role_panes.is_empty() {
|
|
729
|
+
let mut last_err: Option<crate::transport::TransportError> = None;
|
|
730
|
+
for residual in &same_role_panes {
|
|
731
|
+
if let Err(e) = transport.kill_pane(&residual.pane_id) {
|
|
732
|
+
last_err = Some(e);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
last_err.map(Err).unwrap_or(Ok(()))
|
|
736
|
+
} else {
|
|
737
|
+
let target = Target::SessionWindow {
|
|
738
|
+
session: session_name.clone(),
|
|
739
|
+
window: WindowName::new(&window),
|
|
740
|
+
};
|
|
741
|
+
transport.kill_window(&target)
|
|
618
742
|
};
|
|
619
|
-
transport.kill_window(&target)
|
|
620
|
-
};
|
|
621
743
|
if let Err(e) = kill_result {
|
|
622
744
|
let stderr = match &e {
|
|
623
|
-
crate::transport::TransportError::Subprocess { stderr, .. } =>
|
|
745
|
+
crate::transport::TransportError::Subprocess { stderr, .. } => {
|
|
746
|
+
stderr.trim().to_string()
|
|
747
|
+
}
|
|
624
748
|
other => other.to_string(),
|
|
625
749
|
};
|
|
626
750
|
let _ = write_stop_window_failed_event(workspace, agent_id, &target_str, &stderr);
|
|
627
|
-
return Err(LifecycleError::Transport(format!(
|
|
751
|
+
return Err(LifecycleError::Transport(format!(
|
|
752
|
+
"failed to stop agent {agent_id}: {stderr}"
|
|
753
|
+
)));
|
|
628
754
|
}
|
|
629
755
|
// 0.4.6 Stage 1: drain-and-prove. The pre-fix path trusted `kill_pane`'s
|
|
630
756
|
// success return without waiting for the pane / pid to actually exit.
|
|
@@ -647,8 +773,12 @@ pub(super) fn stop_agent_at_paths(
|
|
|
647
773
|
mark_agent_stopped(&mut state, agent_id, agent, &window)?;
|
|
648
774
|
// golden operations.py:95: save_team_scoped_state (team projection) — NOT a raw save, so a
|
|
649
775
|
// multi-team workspace keeps the other teams' persisted runtime state instead of being clobbered.
|
|
650
|
-
crate::state::projection::
|
|
651
|
-
|
|
776
|
+
crate::state::projection::save_team_scoped_state_with_lifecycle_topology_authority(
|
|
777
|
+
workspace,
|
|
778
|
+
&state,
|
|
779
|
+
&[agent_id.as_str()],
|
|
780
|
+
)
|
|
781
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
652
782
|
// golden operations.py:96-99: snapshot (side-effect), then state_file = write_team_state path.
|
|
653
783
|
// snapshot.py:19-21 returns None silently when session_name is falsy — mirror that no-op here so
|
|
654
784
|
// a workspace whose persisted state lacks session_name proceeds to write team_state and return ok
|
|
@@ -690,7 +820,9 @@ pub(super) fn resolve_team_scoped_state_or_refuse(
|
|
|
690
820
|
.unwrap_or("");
|
|
691
821
|
return Err(LifecycleError::TeamSelect(format!("{reason}: {detail}")));
|
|
692
822
|
}
|
|
693
|
-
state.ok_or_else(||
|
|
823
|
+
state.ok_or_else(|| {
|
|
824
|
+
LifecycleError::StatePersist("resolve_team_scoped_state returned no state".to_string())
|
|
825
|
+
})
|
|
694
826
|
}
|
|
695
827
|
|
|
696
828
|
fn mark_agent_started(
|
|
@@ -725,7 +857,10 @@ fn mark_agent_started(
|
|
|
725
857
|
// codex, claude, copilot. Reset_agent --discard-session already does
|
|
726
858
|
// this at common.rs:1144-1188; here we mirror it for start-agent /
|
|
727
859
|
// restart-agent fresh paths so the fresh-tuple invariant is global.
|
|
728
|
-
if matches!(
|
|
860
|
+
if matches!(
|
|
861
|
+
start_mode,
|
|
862
|
+
StartMode::Fresh | StartMode::FreshAfterMissingRollout
|
|
863
|
+
) {
|
|
729
864
|
for field in [
|
|
730
865
|
"session_id",
|
|
731
866
|
"rollout_path",
|
|
@@ -782,17 +917,10 @@ fn mark_agent_started(
|
|
|
782
917
|
// so future restart/start cycles read it directly from the agent row.
|
|
783
918
|
if let Some(ref team_id) = spawn.owner_team_id {
|
|
784
919
|
if !team_id.is_empty() {
|
|
785
|
-
agent.insert(
|
|
786
|
-
"owner_team_id".to_string(),
|
|
787
|
-
serde_json::json!(team_id),
|
|
788
|
-
);
|
|
920
|
+
agent.insert("owner_team_id".to_string(), serde_json::json!(team_id));
|
|
789
921
|
}
|
|
790
922
|
}
|
|
791
|
-
crate::lifecycle::launch::persist_command_plan_state(
|
|
792
|
-
agent,
|
|
793
|
-
&spawn.plan,
|
|
794
|
-
&spawn.profile_launch,
|
|
795
|
-
);
|
|
923
|
+
crate::lifecycle::launch::persist_command_plan_state(agent, &spawn.plan, &spawn.profile_launch);
|
|
796
924
|
crate::lifecycle::launch::persist_effective_approval_policy(agent, safety);
|
|
797
925
|
if let Some(placement) = spawn.layout_placement.as_ref() {
|
|
798
926
|
agent.insert(
|
|
@@ -803,7 +931,10 @@ fn mark_agent_started(
|
|
|
803
931
|
"layout_index".to_string(),
|
|
804
932
|
serde_json::json!(placement.layout_index),
|
|
805
933
|
);
|
|
806
|
-
agent.insert(
|
|
934
|
+
agent.insert(
|
|
935
|
+
"pane_index".to_string(),
|
|
936
|
+
serde_json::json!(placement.pane_index),
|
|
937
|
+
);
|
|
807
938
|
agent.insert(
|
|
808
939
|
"display".to_string(),
|
|
809
940
|
serde_json::json!({
|
|
@@ -844,8 +975,13 @@ pub fn reset_agent(
|
|
|
844
975
|
});
|
|
845
976
|
}
|
|
846
977
|
let paths = lifecycle_paths(workspace, team)?;
|
|
847
|
-
let
|
|
848
|
-
|
|
978
|
+
let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
|
|
979
|
+
workspace: &paths.run_workspace,
|
|
980
|
+
operation: "reset-agent",
|
|
981
|
+
team,
|
|
982
|
+
agent_id: Some(agent_id),
|
|
983
|
+
})?;
|
|
984
|
+
let transport = lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
|
|
849
985
|
reset_agent_at_paths(
|
|
850
986
|
&paths.run_workspace,
|
|
851
987
|
&paths.spec_workspace,
|
|
@@ -871,6 +1007,12 @@ pub fn reset_agent_with_transport(
|
|
|
871
1007
|
});
|
|
872
1008
|
}
|
|
873
1009
|
let paths = lifecycle_paths(workspace, team)?;
|
|
1010
|
+
let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
|
|
1011
|
+
workspace: &paths.run_workspace,
|
|
1012
|
+
operation: "reset-agent",
|
|
1013
|
+
team,
|
|
1014
|
+
agent_id: Some(agent_id),
|
|
1015
|
+
})?;
|
|
874
1016
|
reset_agent_at_paths(
|
|
875
1017
|
&paths.run_workspace,
|
|
876
1018
|
&paths.spec_workspace,
|
|
@@ -906,7 +1048,158 @@ fn reset_agent_at_paths(
|
|
|
906
1048
|
.filter(|session| !session.is_empty())
|
|
907
1049
|
.map(SessionId::new);
|
|
908
1050
|
crate::lifecycle::launch::ensure_owner_allowed_for_state(&state_before_stop, Some(agent_id))?;
|
|
1051
|
+
// Capture old pane_id / pane_pid / window BEFORE stop, so the hard gate
|
|
1052
|
+
// below can prove the same prior instance is gone (or refuse start).
|
|
1053
|
+
let old_pane_id_before = state_before_stop
|
|
1054
|
+
.get("agents")
|
|
1055
|
+
.and_then(|v| v.get(agent_id.as_str()))
|
|
1056
|
+
.and_then(|v| v.get("pane_id"))
|
|
1057
|
+
.and_then(|v| v.as_str())
|
|
1058
|
+
.filter(|p| !p.is_empty())
|
|
1059
|
+
.map(crate::transport::PaneId::new);
|
|
1060
|
+
let old_pane_pid_before = state_pane_pid(&state_before_stop, agent_id);
|
|
1061
|
+
// CR C-2: take ONE pre-stop snapshot of the team session's panes so
|
|
1062
|
+
// the gate below can compute "what survived stop" by set difference,
|
|
1063
|
+
// not "what panes exist at all" (which would refuse legitimate
|
|
1064
|
+
// reset flows where stop killed the pane and the post-stop snapshot
|
|
1065
|
+
// includes that same pane id in a transport mock that does not
|
|
1066
|
+
// reflect kill removal).
|
|
1067
|
+
let pre_stop_window = state_before_stop
|
|
1068
|
+
.get("agents")
|
|
1069
|
+
.and_then(|v| v.get(agent_id.as_str()))
|
|
1070
|
+
.and_then(|v| v.get("window"))
|
|
1071
|
+
.and_then(|v| v.as_str())
|
|
1072
|
+
.filter(|s| !s.is_empty())
|
|
1073
|
+
.unwrap_or_else(|| agent_id.as_str())
|
|
1074
|
+
.to_string();
|
|
1075
|
+
let pre_stop_spec = load_team_spec(spec_workspace).ok();
|
|
1076
|
+
let pre_stop_session = pre_stop_spec
|
|
1077
|
+
.as_ref()
|
|
1078
|
+
.map(|spec| state_session_name_from_spec(&state_before_stop, spec));
|
|
1079
|
+
let pre_stop_pane_ids: std::collections::BTreeSet<String> =
|
|
1080
|
+
if let Some(session) = pre_stop_session.as_ref() {
|
|
1081
|
+
if is_per_agent_window(&pre_stop_window, agent_id) {
|
|
1082
|
+
list_same_role_panes(transport, session, &pre_stop_window)
|
|
1083
|
+
.iter()
|
|
1084
|
+
.map(|p| p.pane_id.as_str().to_string())
|
|
1085
|
+
.collect()
|
|
1086
|
+
} else {
|
|
1087
|
+
std::collections::BTreeSet::new()
|
|
1088
|
+
}
|
|
1089
|
+
} else {
|
|
1090
|
+
std::collections::BTreeSet::new()
|
|
1091
|
+
};
|
|
909
1092
|
let stop = stop_agent_at_paths(workspace, spec_workspace, agent_id, team, transport)?;
|
|
1093
|
+
|
|
1094
|
+
// 0.4.10+ paused agent skip: a paused agent's start path returns
|
|
1095
|
+
// StartAgentOutcome::Paused (no spawn). There is no duplicate-window
|
|
1096
|
+
// hazard, so the gate is a no-op for paused agents.
|
|
1097
|
+
let agent_is_paused = state_before_stop
|
|
1098
|
+
.get("agents")
|
|
1099
|
+
.and_then(|v| v.get(agent_id.as_str()))
|
|
1100
|
+
.and_then(|v| v.get("paused"))
|
|
1101
|
+
.and_then(|v| v.as_bool())
|
|
1102
|
+
.unwrap_or(false);
|
|
1103
|
+
// 0.4.10+ reset duplicate-window fix (plan §3): HARD GATE before start.
|
|
1104
|
+
// After stop_agent_at_paths returns, prove the old instance is gone OR
|
|
1105
|
+
// refuse to spawn. The pre-fix path unconditionally proceeded to
|
|
1106
|
+
// discard/save/start even when stop returned stopped=false (stop's
|
|
1107
|
+
// pane_id was stale), creating the observed duplicate-window pattern.
|
|
1108
|
+
//
|
|
1109
|
+
// Residue definition (correct: differential, not absolute):
|
|
1110
|
+
// A pane is RESIDUE iff it appears in BOTH the pre-stop snapshot
|
|
1111
|
+
// AND the post-stop snapshot. The pre-fix attempt used "any
|
|
1112
|
+
// matching pane exists post-stop" which broke legitimate flows
|
|
1113
|
+
// where the transport mock does not model kill_pane removal.
|
|
1114
|
+
//
|
|
1115
|
+
// Old pane id / pid checks:
|
|
1116
|
+
// The OLD stored pane_id / pane_pid must be gone (not just
|
|
1117
|
+
// reachable but actually killed). For real tmux this is the
|
|
1118
|
+
// structural truth source; for mocks the differential approach
|
|
1119
|
+
// above covers the post-stop visibility.
|
|
1120
|
+
//
|
|
1121
|
+
// CR C-5: gate is reset-specific; standalone stop-agent path keeps
|
|
1122
|
+
// existing "already absent is ok" behavior.
|
|
1123
|
+
//
|
|
1124
|
+
// Gate scope refinement: when stop.stopped == true, the kill_pane
|
|
1125
|
+
// call already succeeded (and drain_old_pane_and_pid polled for
|
|
1126
|
+
// the pane to become unreachable). Treat that as the authoritative
|
|
1127
|
+
// signal — running the gate again post-stop introduces a race
|
|
1128
|
+
// window where tmux's has_pane lag can spuriously report Live.
|
|
1129
|
+
// Only gate the dangerous case: stop reported stopped == false
|
|
1130
|
+
// (state's stale pane_id pointed at nothing kill-able), which is
|
|
1131
|
+
// exactly the duplicate-window bug pattern from the macmini
|
|
1132
|
+
// evidence: `stop_agent.complete stopped=false` followed by an
|
|
1133
|
+
// unconditional `start_agent.agent_start`.
|
|
1134
|
+
if !agent_is_paused && !stop.stopped {
|
|
1135
|
+
let spec_for_gate = load_team_spec(spec_workspace)?;
|
|
1136
|
+
let gate_state = resolve_team_scoped_state_or_refuse(workspace, team)?;
|
|
1137
|
+
let session_name_gate = state_session_name_from_spec(&gate_state, &spec_for_gate);
|
|
1138
|
+
let gate_window = gate_state
|
|
1139
|
+
.get("agents")
|
|
1140
|
+
.and_then(|v| v.get(agent_id.as_str()))
|
|
1141
|
+
.and_then(|v| v.get("window"))
|
|
1142
|
+
.and_then(|v| v.as_str())
|
|
1143
|
+
.filter(|s| !s.is_empty())
|
|
1144
|
+
.unwrap_or_else(|| agent_id.as_str())
|
|
1145
|
+
.to_string();
|
|
1146
|
+
let old_pane_still_live = old_pane_id_before
|
|
1147
|
+
.as_ref()
|
|
1148
|
+
.map(|p| agent_pane_live_by_id(transport, p))
|
|
1149
|
+
.unwrap_or(false);
|
|
1150
|
+
// Take a SECOND snapshot post-stop and compute the differential.
|
|
1151
|
+
// Only panes present in BOTH snapshots are residue (stop did not
|
|
1152
|
+
// remove them).
|
|
1153
|
+
let post_stop_panes: Vec<crate::transport::PaneInfo> =
|
|
1154
|
+
if is_per_agent_window(&gate_window, agent_id) {
|
|
1155
|
+
list_same_role_panes(transport, &session_name_gate, &gate_window)
|
|
1156
|
+
} else {
|
|
1157
|
+
Vec::new()
|
|
1158
|
+
};
|
|
1159
|
+
let remaining_panes: Vec<crate::transport::PaneInfo> = post_stop_panes
|
|
1160
|
+
.into_iter()
|
|
1161
|
+
.filter(|p| pre_stop_pane_ids.contains(p.pane_id.as_str()))
|
|
1162
|
+
.collect();
|
|
1163
|
+
// Pid-alone aliveness is secondary evidence and noisy under fixtures
|
|
1164
|
+
// (synthetic pids may by chance be live on the test machine). Block
|
|
1165
|
+
// ONLY on tmux-visible residue: old pane still live OR same-role
|
|
1166
|
+
// panes survived stop. The pid is still recorded in the event for
|
|
1167
|
+
// diagnostics.
|
|
1168
|
+
let old_pid_still_live = old_pane_pid_before
|
|
1169
|
+
.filter(|_| old_pane_still_live)
|
|
1170
|
+
.map(|pid| pid_is_alive(pid))
|
|
1171
|
+
.unwrap_or(false);
|
|
1172
|
+
if old_pane_still_live || !remaining_panes.is_empty() {
|
|
1173
|
+
let remaining_pane_ids: Vec<String> = remaining_panes
|
|
1174
|
+
.iter()
|
|
1175
|
+
.map(|p| p.pane_id.as_str().to_string())
|
|
1176
|
+
.collect();
|
|
1177
|
+
let old_pane_str = old_pane_id_before
|
|
1178
|
+
.as_ref()
|
|
1179
|
+
.map(|p| p.as_str().to_string())
|
|
1180
|
+
.unwrap_or_default();
|
|
1181
|
+
let old_pid_val = old_pane_pid_before.unwrap_or(0);
|
|
1182
|
+
let _ = write_reset_stop_not_proven_event(
|
|
1183
|
+
workspace,
|
|
1184
|
+
agent_id,
|
|
1185
|
+
&old_pane_str,
|
|
1186
|
+
old_pid_val,
|
|
1187
|
+
&remaining_pane_ids,
|
|
1188
|
+
);
|
|
1189
|
+
// CR C-1 N38 three-line error: error / action / log_hint.
|
|
1190
|
+
let _ = stop; // silence unused on the refusal path
|
|
1191
|
+
return Err(LifecycleError::RequirementUnmet(format!(
|
|
1192
|
+
"reset refused: old agent instance still live for {agent_id}\n\
|
|
1193
|
+
action: stop the worker manually with `team-agent stop-agent {agent_id} --team <team>` then retry reset, or kill the residual tmux panes [{ids}]\n\
|
|
1194
|
+
log_hint: see reset_agent.stop_not_proven event (old_pane_id={old}, old_pane_pid={pid}, remaining_panes=[{ids}])",
|
|
1195
|
+
agent_id = agent_id.as_str(),
|
|
1196
|
+
ids = remaining_pane_ids.join(", "),
|
|
1197
|
+
old = old_pane_str,
|
|
1198
|
+
pid = old_pid_val,
|
|
1199
|
+
)));
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
910
1203
|
let mut state = resolve_team_scoped_state_or_refuse(workspace, team)?;
|
|
911
1204
|
let spec = load_team_spec(spec_workspace)?;
|
|
912
1205
|
discard_agent_session_fields(&mut state, agent_id)?;
|
|
@@ -927,12 +1220,12 @@ fn reset_agent_at_paths(
|
|
|
927
1220
|
sync_restart_team_projections(&mut state, &team_key);
|
|
928
1221
|
// golden operations.py (reset): save_team_scoped_state on the team projection — same multi-team
|
|
929
1222
|
// preservation as stop, not a raw save_runtime_state.
|
|
930
|
-
crate::state::projection::
|
|
1223
|
+
crate::state::projection::save_team_scoped_state_with_tombstone_lifecycle_topology_authority(
|
|
931
1224
|
workspace,
|
|
932
1225
|
&state,
|
|
933
1226
|
&[agent_id.as_str()],
|
|
934
1227
|
)
|
|
935
|
-
|
|
1228
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
936
1229
|
// golden operations.py:125: write_team_state after the discard-save (the intermediate stopped snapshot).
|
|
937
1230
|
write_team_state(spec_workspace, &spec, &state)?;
|
|
938
1231
|
write_reset_tombstone_event(
|
|
@@ -1018,12 +1311,11 @@ fn write_start_agent_start_event(
|
|
|
1018
1311
|
// Contract C / F6.4: event log must record the same context-aware argv that the
|
|
1019
1312
|
// actual spawn used — so the role/tools/MCP context appears in `start_agent.agent_start`.
|
|
1020
1313
|
let safety = crate::lifecycle::launch::effective_runtime_config_for_worker_spawn()?;
|
|
1021
|
-
let command_agent =
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
);
|
|
1314
|
+
let command_agent = crate::lifecycle::worker_command_context::WorkerCommandAgent::from_json(
|
|
1315
|
+
agent,
|
|
1316
|
+
Some(agent_id.as_str()),
|
|
1317
|
+
provider,
|
|
1318
|
+
);
|
|
1027
1319
|
let system_prompt =
|
|
1028
1320
|
crate::lifecycle::worker_command_context::compile_worker_system_prompt(&command_agent)?;
|
|
1029
1321
|
let tools = crate::lifecycle::worker_command_context::resolved_tool_strings_for_command(
|
|
@@ -1035,17 +1327,18 @@ fn write_start_agent_start_event(
|
|
|
1035
1327
|
let mcp_config = adapter
|
|
1036
1328
|
.mcp_config(auth_mode)
|
|
1037
1329
|
.map_err(|e| LifecycleError::Provider(e.to_string()))?;
|
|
1038
|
-
let team_id = agent
|
|
1039
|
-
.get("owner_team_id")
|
|
1040
|
-
.and_then(|v| v.as_str());
|
|
1330
|
+
let team_id = agent.get("owner_team_id").and_then(|v| v.as_str());
|
|
1041
1331
|
let mcp_config = crate::lifecycle::launch::resolve_mcp_config(
|
|
1042
1332
|
mcp_config,
|
|
1043
1333
|
workspace,
|
|
1044
1334
|
agent_id.as_str(),
|
|
1045
1335
|
team_id.unwrap_or(""),
|
|
1046
1336
|
);
|
|
1047
|
-
let mcp_config_path =
|
|
1048
|
-
|
|
1337
|
+
let mcp_config_path = crate::lifecycle::launch::write_worker_mcp_config(
|
|
1338
|
+
workspace,
|
|
1339
|
+
agent_id.as_str(),
|
|
1340
|
+
&mcp_config,
|
|
1341
|
+
)?;
|
|
1049
1342
|
let profile_launch =
|
|
1050
1343
|
crate::lifecycle::profile_launch::prepare_provider_profile_launch_from_json(
|
|
1051
1344
|
workspace,
|
|
@@ -1053,16 +1346,15 @@ fn write_start_agent_start_event(
|
|
|
1053
1346
|
agent,
|
|
1054
1347
|
Some(&mcp_config),
|
|
1055
1348
|
)?;
|
|
1056
|
-
let command_model = profile_launch
|
|
1057
|
-
.command_overrides
|
|
1058
|
-
.model
|
|
1059
|
-
.as_deref()
|
|
1060
|
-
.or(model);
|
|
1349
|
+
let command_model = profile_launch.command_overrides.model.as_deref().or(model);
|
|
1061
1350
|
// 0.4.x provider effort MVP: start_agent path preserves effort from the
|
|
1062
1351
|
// persisted agent JSON.
|
|
1063
|
-
let start_agent_effort =
|
|
1352
|
+
let start_agent_effort =
|
|
1353
|
+
crate::lifecycle::launch::provider_effort_for_spawn_json(&agent, provider);
|
|
1064
1354
|
if let Some(event_value) = crate::lifecycle::launch::provider_effort_event_if_dropped_json(
|
|
1065
|
-
&agent,
|
|
1355
|
+
&agent,
|
|
1356
|
+
provider,
|
|
1357
|
+
agent_id.as_str(),
|
|
1066
1358
|
) {
|
|
1067
1359
|
let _ = crate::event_log::EventLog::new(workspace)
|
|
1068
1360
|
.write("provider.effort_unsupported", event_value);
|
|
@@ -1154,10 +1446,7 @@ pub(super) fn write_stop_drain_event(
|
|
|
1154
1446
|
}
|
|
1155
1447
|
}
|
|
1156
1448
|
crate::event_log::EventLog::new(workspace)
|
|
1157
|
-
.write(
|
|
1158
|
-
"stop_agent.drain",
|
|
1159
|
-
serde_json::Value::Object(payload),
|
|
1160
|
-
)
|
|
1449
|
+
.write("stop_agent.drain", serde_json::Value::Object(payload))
|
|
1161
1450
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
1162
1451
|
Ok(())
|
|
1163
1452
|
}
|
|
@@ -1198,6 +1487,32 @@ fn write_reset_tombstone_event(
|
|
|
1198
1487
|
Ok(())
|
|
1199
1488
|
}
|
|
1200
1489
|
|
|
1490
|
+
/// 0.4.10+ reset duplicate-window fix (plan §3): emit a structured event
|
|
1491
|
+
/// when the reset hard gate refuses to start because the old instance is
|
|
1492
|
+
/// proven still live (old pane id reachable, old pane pid alive, or
|
|
1493
|
+
/// same-role panes remain in the team session).
|
|
1494
|
+
fn write_reset_stop_not_proven_event(
|
|
1495
|
+
workspace: &Path,
|
|
1496
|
+
agent_id: &AgentId,
|
|
1497
|
+
old_pane_id: &str,
|
|
1498
|
+
old_pane_pid: u32,
|
|
1499
|
+
remaining_panes: &[String],
|
|
1500
|
+
) -> Result<(), LifecycleError> {
|
|
1501
|
+
crate::event_log::EventLog::new(workspace)
|
|
1502
|
+
.write(
|
|
1503
|
+
"reset_agent.stop_not_proven",
|
|
1504
|
+
serde_json::json!({
|
|
1505
|
+
"agent_id": agent_id.as_str(),
|
|
1506
|
+
"old_pane_id": old_pane_id,
|
|
1507
|
+
"old_pane_pid": old_pane_pid,
|
|
1508
|
+
"remaining_panes": remaining_panes,
|
|
1509
|
+
"action": "stop the worker manually then retry reset, or kill the residual tmux panes",
|
|
1510
|
+
}),
|
|
1511
|
+
)
|
|
1512
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
1513
|
+
Ok(())
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1201
1516
|
fn write_reset_complete_event(
|
|
1202
1517
|
workspace: &Path,
|
|
1203
1518
|
agent_id: &AgentId,
|