@team-agent/installer 0.5.44 → 0.5.46
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/emit.rs +44 -33
- package/crates/team-agent/src/cli/mod.rs +6 -0
- package/crates/team-agent/src/cli/named_address.rs +206 -4
- package/crates/team-agent/src/cli/send.rs +105 -2
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/status_port.rs +70 -2
- package/crates/team-agent/src/cli/tests/run_delegation.rs +7 -4
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +14 -22
- package/crates/team-agent/src/lifecycle/restart/agent.rs +103 -27
- package/crates/team-agent/src/lifecycle/restart/common.rs +136 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +168 -0
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -7
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +3 -0
- package/crates/team-agent/src/lifecycle/types.rs +3 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +10 -0
- package/crates/team-agent/src/mcp_server/tools.rs +67 -8
- package/crates/team-agent/src/model/mod.rs +6 -0
- package/crates/team-agent/src/model/name_similarity.rs +266 -0
- package/crates/team-agent/src/provider/session/capture.rs +113 -22
- package/crates/team-agent/src/provider/session_scan/codex.rs +51 -4
- package/crates/team-agent/src/tmux_backend/tests.rs +116 -10
- package/crates/team-agent/src/tmux_backend.rs +5 -16
- package/crates/team-agent/src/transport/tests/wire.rs +6 -0
- package/crates/team-agent/src/transport.rs +8 -2
- package/package.json +4 -4
|
@@ -3,6 +3,7 @@ use super::*;
|
|
|
3
3
|
use crate::state::projection::OwnerTeamResolution;
|
|
4
4
|
use crate::transport::Transport;
|
|
5
5
|
use rusqlite::params;
|
|
6
|
+
use std::path::PathBuf;
|
|
6
7
|
|
|
7
8
|
/// `status.status(workspace, as_json, compact)`(`queries.py:33`,**有副作用**:capture→refresh→save)。
|
|
8
9
|
pub fn status(workspace: &Path, compact: bool, detail: bool) -> Result<Value, CliError> {
|
|
@@ -48,7 +49,7 @@ pub fn status_scoped(
|
|
|
48
49
|
!tmux_present,
|
|
49
50
|
&freshness,
|
|
50
51
|
);
|
|
51
|
-
let agents = enrich_agents(state
|
|
52
|
+
let agents = enrich_agents(workspace, state, tmux_present, &freshness);
|
|
52
53
|
let tasks = state.get("tasks").cloned().unwrap_or_else(|| json!([]));
|
|
53
54
|
let leader_receiver = state
|
|
54
55
|
.get("leader_receiver")
|
|
@@ -548,18 +549,27 @@ pub(crate) fn compute_runtime_freshness(
|
|
|
548
549
|
}
|
|
549
550
|
|
|
550
551
|
fn enrich_agents(
|
|
551
|
-
|
|
552
|
+
workspace: &Path,
|
|
553
|
+
state: &Value,
|
|
552
554
|
tmux_session_present: bool,
|
|
553
555
|
freshness: &RuntimeFreshness,
|
|
554
556
|
) -> Value {
|
|
557
|
+
let agents = state.get("agents");
|
|
555
558
|
let Some(Value::Object(input)) = agents else {
|
|
556
559
|
return json!({});
|
|
557
560
|
};
|
|
561
|
+
let team_dir = state
|
|
562
|
+
.get("team_dir")
|
|
563
|
+
.and_then(Value::as_str)
|
|
564
|
+
.filter(|value| !value.is_empty())
|
|
565
|
+
.map(PathBuf::from)
|
|
566
|
+
.unwrap_or_else(|| workspace.to_path_buf());
|
|
558
567
|
let mut out = Map::new();
|
|
559
568
|
for (agent_id, value) in input {
|
|
560
569
|
match value {
|
|
561
570
|
Value::Object(obj) => {
|
|
562
571
|
let mut enriched = obj.clone();
|
|
572
|
+
apply_effective_role_projection(&team_dir, agent_id, &mut enriched);
|
|
563
573
|
enriched.insert(
|
|
564
574
|
"interacted".to_string(),
|
|
565
575
|
Value::String(interacted_marker(obj.get("first_send_at"))),
|
|
@@ -640,6 +650,64 @@ fn enrich_agents(
|
|
|
640
650
|
Value::Object(out)
|
|
641
651
|
}
|
|
642
652
|
|
|
653
|
+
fn apply_effective_role_projection(
|
|
654
|
+
team_dir: &Path,
|
|
655
|
+
agent_id: &str,
|
|
656
|
+
agent: &mut Map<String, Value>,
|
|
657
|
+
) {
|
|
658
|
+
let role_file = agent
|
|
659
|
+
.get("dynamic_role_file")
|
|
660
|
+
.and_then(Value::as_str)
|
|
661
|
+
.filter(|value| !value.is_empty())
|
|
662
|
+
.map(PathBuf::from)
|
|
663
|
+
.unwrap_or_else(|| team_dir.join("agents").join(format!("{agent_id}.md")));
|
|
664
|
+
let Ok((meta, _)) = crate::compiler::read_front_matter(&role_file) else {
|
|
665
|
+
return;
|
|
666
|
+
};
|
|
667
|
+
let Some(meta) = yaml_map(&meta) else {
|
|
668
|
+
return;
|
|
669
|
+
};
|
|
670
|
+
if let Some(provider) = yaml_str(meta, "provider").filter(|value| !value.is_empty()) {
|
|
671
|
+
agent.insert("provider".to_string(), Value::String(provider.to_string()));
|
|
672
|
+
agent.insert(
|
|
673
|
+
"provider_source".to_string(),
|
|
674
|
+
Value::String("role".to_string()),
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
if let Some(model) = yaml_str(meta, "model").filter(|value| !value.is_empty()) {
|
|
678
|
+
if agent.get("model").and_then(Value::as_str) != Some(model) {
|
|
679
|
+
agent.insert("model_stale".to_string(), Value::Bool(true));
|
|
680
|
+
}
|
|
681
|
+
agent.insert("model".to_string(), Value::String(model.to_string()));
|
|
682
|
+
agent.insert(
|
|
683
|
+
"model_source".to_string(),
|
|
684
|
+
Value::String("role".to_string()),
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
fn yaml_map(
|
|
690
|
+
value: &crate::model::yaml::Value,
|
|
691
|
+
) -> Option<&Vec<(String, crate::model::yaml::Value)>> {
|
|
692
|
+
match value {
|
|
693
|
+
crate::model::yaml::Value::Map(items) => Some(items),
|
|
694
|
+
_ => None,
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
fn yaml_str<'a>(items: &'a [(String, crate::model::yaml::Value)], key: &str) -> Option<&'a str> {
|
|
699
|
+
items.iter().find_map(|(name, value)| {
|
|
700
|
+
if name == key {
|
|
701
|
+
match value {
|
|
702
|
+
crate::model::yaml::Value::Str(value) => Some(value.as_str()),
|
|
703
|
+
_ => None,
|
|
704
|
+
}
|
|
705
|
+
} else {
|
|
706
|
+
None
|
|
707
|
+
}
|
|
708
|
+
})
|
|
709
|
+
}
|
|
710
|
+
|
|
643
711
|
fn downgrade_stale_agent(agent: &mut Map<String, Value>) {
|
|
644
712
|
let raw = agent
|
|
645
713
|
.get("status")
|
|
@@ -125,12 +125,15 @@ fn run_dispatches_send_to_handler_returns_ok() {
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
#[test]
|
|
128
|
-
fn
|
|
129
|
-
//
|
|
128
|
+
fn run_unknown_subcommand_is_error_not_usage() {
|
|
129
|
+
// 0.5.45 naming-addressing (RED-6): unknown subcommand exits 1
|
|
130
|
+
// (Error), aligned with the shared refusal shape used by
|
|
131
|
+
// `send`/`--to-name` typos. Pre-0.5.45 argparse-style Usage (2)
|
|
132
|
+
// was internal drift.
|
|
130
133
|
assert_eq!(
|
|
131
134
|
run(&["totally-not-a-subcommand".to_string()], Path::new(".")),
|
|
132
|
-
ExitCode::
|
|
133
|
-
"
|
|
135
|
+
ExitCode::Error,
|
|
136
|
+
"unknown subcommand must map to ExitCode::Error (aligned with send/--to-name typo family)"
|
|
134
137
|
);
|
|
135
138
|
}
|
|
136
139
|
|
|
@@ -101,7 +101,7 @@ pub(crate) fn detect_abnormal_exits(
|
|
|
101
101
|
let fact = crate::provider::latest_explicit_error_fact(agent.provider, &text);
|
|
102
102
|
let error_observation_key = fact
|
|
103
103
|
.as_ref()
|
|
104
|
-
.map(|fact| abnormal_error_observation_key(&agent, fact
|
|
104
|
+
.map(|fact| abnormal_error_observation_key(&agent, fact));
|
|
105
105
|
let error_observation_cohort = fact.as_ref().map(|_| abnormal_error_cohort_key(&agent));
|
|
106
106
|
let error_recency = abnormal_error_recency(
|
|
107
107
|
&snapshot,
|
|
@@ -116,7 +116,6 @@ pub(crate) fn detect_abnormal_exits(
|
|
|
116
116
|
fact.as_ref(),
|
|
117
117
|
error_recency,
|
|
118
118
|
error_observation_key.as_deref(),
|
|
119
|
-
size,
|
|
120
119
|
);
|
|
121
120
|
upsert_abnormal_watch(
|
|
122
121
|
state,
|
|
@@ -155,7 +154,7 @@ pub(crate) fn detect_abnormal_exits(
|
|
|
155
154
|
let fact = match (decision, fact) {
|
|
156
155
|
(AbnormalExitDecision::Notify, Some(fact)) => fact,
|
|
157
156
|
(AbnormalExitDecision::Suppress(reason), _) => {
|
|
158
|
-
let suppress_key = abnormal_suppression_key(&agent, &liveness, reason
|
|
157
|
+
let suppress_key = abnormal_suppression_key(&agent, &liveness, reason);
|
|
159
158
|
if abnormal_last_suppressed_key(state, &agent.agent_id).as_deref()
|
|
160
159
|
!= Some(suppress_key.as_str())
|
|
161
160
|
{
|
|
@@ -167,7 +166,7 @@ pub(crate) fn detect_abnormal_exits(
|
|
|
167
166
|
(AbnormalExitDecision::NoSignal, _) => continue,
|
|
168
167
|
(AbnormalExitDecision::Notify, None) => continue,
|
|
169
168
|
};
|
|
170
|
-
let dedupe_key = abnormal_dedupe_key(&agent, &fact
|
|
169
|
+
let dedupe_key = abnormal_dedupe_key(&agent, &fact);
|
|
171
170
|
if abnormal_last_notified_key(state, &agent.agent_id).as_deref()
|
|
172
171
|
== Some(dedupe_key.as_str())
|
|
173
172
|
{
|
|
@@ -555,7 +554,7 @@ fn agent_process_liveness(
|
|
|
555
554
|
if let Some(command) = agent.current_command.as_deref() {
|
|
556
555
|
return command_process_check_with_marker(agent, transport, command);
|
|
557
556
|
}
|
|
558
|
-
if
|
|
557
|
+
if agent.pane_id.is_some() {
|
|
559
558
|
// Even without pane current_command / matching target, try the
|
|
560
559
|
// marker probe directly — the wrapper's printf leaves the marker
|
|
561
560
|
// in the pane's capture tail whether or not the transport
|
|
@@ -1135,16 +1134,13 @@ fn write_abnormal_suppressed(
|
|
|
1135
1134
|
Ok(())
|
|
1136
1135
|
}
|
|
1137
1136
|
|
|
1138
|
-
fn abnormal_dedupe_key(
|
|
1139
|
-
agent: &AbnormalWatchAgent,
|
|
1140
|
-
fact: &crate::provider::FaultFact,
|
|
1141
|
-
size: u64,
|
|
1142
|
-
) -> String {
|
|
1137
|
+
fn abnormal_dedupe_key(agent: &AbnormalWatchAgent, fact: &crate::provider::FaultFact) -> String {
|
|
1143
1138
|
let bucket = fact
|
|
1144
1139
|
.turn_id
|
|
1145
1140
|
.as_ref()
|
|
1146
1141
|
.map(|id| id.as_str().to_string())
|
|
1147
|
-
.
|
|
1142
|
+
.or_else(|| abnormal_error_fact_identity(fact))
|
|
1143
|
+
.unwrap_or_else(|| "no_error_identity".to_string());
|
|
1148
1144
|
format!(
|
|
1149
1145
|
"worker.abnormal_exit:{}:{}:{}:{}",
|
|
1150
1146
|
agent.agent_id,
|
|
@@ -1174,9 +1170,9 @@ fn abnormal_error_cohort_key(agent: &AbnormalWatchAgent) -> String {
|
|
|
1174
1170
|
fn abnormal_error_observation_key(
|
|
1175
1171
|
agent: &AbnormalWatchAgent,
|
|
1176
1172
|
fact: &crate::provider::FaultFact,
|
|
1177
|
-
size: u64,
|
|
1178
1173
|
) -> String {
|
|
1179
|
-
let bucket =
|
|
1174
|
+
let bucket =
|
|
1175
|
+
abnormal_error_fact_identity(fact).unwrap_or_else(|| "no_error_identity".to_string());
|
|
1180
1176
|
format!(
|
|
1181
1177
|
"worker.abnormal_exit.error:{}:{}:{}:{}",
|
|
1182
1178
|
agent.agent_id,
|
|
@@ -1217,15 +1213,13 @@ fn abnormal_suppression_key(
|
|
|
1217
1213
|
agent: &AbnormalWatchAgent,
|
|
1218
1214
|
liveness: &ProcessCheck,
|
|
1219
1215
|
reason: &str,
|
|
1220
|
-
size: u64,
|
|
1221
1216
|
) -> String {
|
|
1222
1217
|
format!(
|
|
1223
|
-
"abnormal_exit.single_signal_suppressed:{}:{}:{}:{}
|
|
1218
|
+
"abnormal_exit.single_signal_suppressed:{}:{}:{}:{}",
|
|
1224
1219
|
agent.agent_id,
|
|
1225
1220
|
agent.rollout_path_display,
|
|
1226
1221
|
reason,
|
|
1227
|
-
process_liveness_wire(liveness.state)
|
|
1228
|
-
size
|
|
1222
|
+
process_liveness_wire(liveness.state)
|
|
1229
1223
|
)
|
|
1230
1224
|
}
|
|
1231
1225
|
|
|
@@ -1235,17 +1229,15 @@ fn abnormal_check_key(
|
|
|
1235
1229
|
fact: Option<&crate::provider::FaultFact>,
|
|
1236
1230
|
error_recency: ErrorRecency,
|
|
1237
1231
|
error_observation_key: Option<&str>,
|
|
1238
|
-
size: u64,
|
|
1239
1232
|
) -> String {
|
|
1240
1233
|
format!(
|
|
1241
|
-
"worker.abnormal_exit.check:{}:{}:{}:{}:{}:{}
|
|
1234
|
+
"worker.abnormal_exit.check:{}:{}:{}:{}:{}:{}",
|
|
1242
1235
|
agent.agent_id,
|
|
1243
1236
|
agent.rollout_path_display,
|
|
1244
1237
|
process_liveness_wire(liveness.state),
|
|
1245
1238
|
fact.map(|fact| fact.signature.as_str()).unwrap_or("-"),
|
|
1246
1239
|
error_recency.as_str(),
|
|
1247
|
-
error_observation_key.unwrap_or("-")
|
|
1248
|
-
size
|
|
1240
|
+
error_observation_key.unwrap_or("-")
|
|
1249
1241
|
)
|
|
1250
1242
|
}
|
|
1251
1243
|
|
|
@@ -2526,7 +2518,7 @@ mod tests {
|
|
|
2526
2518
|
"{\"method\":\"turn/completed\",\"params\":{\"turn\":{\"id\":\"t1\",\"status\":\"failed\"}}}\n",
|
|
2527
2519
|
)
|
|
2528
2520
|
.unwrap();
|
|
2529
|
-
let observation_key = abnormal_error_observation_key(&agent, &fact
|
|
2521
|
+
let observation_key = abnormal_error_observation_key(&agent, &fact);
|
|
2530
2522
|
let state = serde_json::json!({
|
|
2531
2523
|
"coordinator": {
|
|
2532
2524
|
"abnormal_exit_watch": {
|
|
@@ -113,6 +113,23 @@ pub(crate) fn start_agent_at_paths(
|
|
|
113
113
|
let window = agent_window(&agent, agent_id);
|
|
114
114
|
let adaptive_layout =
|
|
115
115
|
open_display && crate::lifecycle::launch::state_uses_adaptive_layout(&state);
|
|
116
|
+
let fake_provider = raw_agent
|
|
117
|
+
.get("provider")
|
|
118
|
+
.and_then(serde_json::Value::as_str)
|
|
119
|
+
.is_some_and(|provider| provider.eq_ignore_ascii_case("fake"));
|
|
120
|
+
if force && !fake_provider && is_per_agent_window(&window, agent_id) {
|
|
121
|
+
let expected_pane_id = raw_agent
|
|
122
|
+
.get("pane_id")
|
|
123
|
+
.and_then(serde_json::Value::as_str)
|
|
124
|
+
.filter(|value| !value.is_empty());
|
|
125
|
+
let target =
|
|
126
|
+
SameRoleCohortTarget::new(agent_id, &window).with_expected_pane_id(expected_pane_id);
|
|
127
|
+
if let Some(error) =
|
|
128
|
+
same_role_cohort_pre_spawn_error(transport, &session_name, "start-agent", &[target])
|
|
129
|
+
{
|
|
130
|
+
return Err(LifecycleError::RequirementUnmet(error));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
116
133
|
let agent_live = if adaptive_layout {
|
|
117
134
|
agent_pane_live(transport, &raw_agent)
|
|
118
135
|
} else {
|
|
@@ -640,7 +657,7 @@ fn list_same_role_panes(
|
|
|
640
657
|
/// caller falls back to safer behavior (refuse stop, surface
|
|
641
658
|
/// RequirementUnmet/transport error) when this returns false.
|
|
642
659
|
fn is_per_agent_window(window: &str, agent_id: &AgentId) -> bool {
|
|
643
|
-
window
|
|
660
|
+
is_per_agent_cohort_window(window, agent_id)
|
|
644
661
|
}
|
|
645
662
|
|
|
646
663
|
fn tmux_start_mode_for_spawn(
|
|
@@ -1264,6 +1281,10 @@ fn reset_agent_at_paths(
|
|
|
1264
1281
|
.filter(|p| !p.is_empty())
|
|
1265
1282
|
.map(crate::transport::PaneId::new);
|
|
1266
1283
|
let old_pane_pid_before = state_pane_pid(&state_before_stop, agent_id);
|
|
1284
|
+
let old_pane_live_before = old_pane_id_before
|
|
1285
|
+
.as_ref()
|
|
1286
|
+
.map(|pane| agent_pane_live_by_id(transport, pane))
|
|
1287
|
+
.unwrap_or(false);
|
|
1267
1288
|
// CR C-2: take ONE pre-stop snapshot of the team session's panes so
|
|
1268
1289
|
// the gate below can compute "what survived stop" by set difference,
|
|
1269
1290
|
// not "what panes exist at all" (which would refuse legitimate
|
|
@@ -1286,7 +1307,18 @@ fn reset_agent_at_paths(
|
|
|
1286
1307
|
if let Some(session) = pre_stop_session.as_ref() {
|
|
1287
1308
|
if is_per_agent_window(&pre_stop_window, agent_id) {
|
|
1288
1309
|
list_same_role_panes(transport, session, &pre_stop_window)
|
|
1289
|
-
.
|
|
1310
|
+
.into_iter()
|
|
1311
|
+
.filter(|pane| {
|
|
1312
|
+
if !old_pane_live_before {
|
|
1313
|
+
return true;
|
|
1314
|
+
}
|
|
1315
|
+
let same_old_pane = old_pane_id_before
|
|
1316
|
+
.as_ref()
|
|
1317
|
+
.is_some_and(|old| pane.pane_id.as_str() == old.as_str());
|
|
1318
|
+
let same_old_pid = old_pane_pid_before
|
|
1319
|
+
.is_some_and(|old_pid| pane.pane_pid == Some(old_pid));
|
|
1320
|
+
same_old_pane || same_old_pid
|
|
1321
|
+
})
|
|
1290
1322
|
.map(|p| p.pane_id.as_str().to_string())
|
|
1291
1323
|
.collect()
|
|
1292
1324
|
} else {
|
|
@@ -1327,17 +1359,12 @@ fn reset_agent_at_paths(
|
|
|
1327
1359
|
// CR C-5: gate is reset-specific; standalone stop-agent path keeps
|
|
1328
1360
|
// existing "already absent is ok" behavior.
|
|
1329
1361
|
//
|
|
1330
|
-
//
|
|
1331
|
-
//
|
|
1332
|
-
//
|
|
1333
|
-
//
|
|
1334
|
-
//
|
|
1335
|
-
|
|
1336
|
-
// (state's stale pane_id pointed at nothing kill-able), which is
|
|
1337
|
-
// exactly the duplicate-window bug pattern from the macmini
|
|
1338
|
-
// evidence: `stop_agent.complete stopped=false` followed by an
|
|
1339
|
-
// unconditional `start_agent.agent_start`.
|
|
1340
|
-
if !agent_is_paused && !stop.stopped {
|
|
1362
|
+
// P0 cohort proof: every non-paused reset takes a post-stop
|
|
1363
|
+
// same-role snapshot. Refuse only on tmux-visible residue; a
|
|
1364
|
+
// standalone old-pane liveness probe can be stale in mocks and
|
|
1365
|
+
// must not mask the later spawn ownership/window-disappeared
|
|
1366
|
+
// verifier.
|
|
1367
|
+
if !agent_is_paused {
|
|
1341
1368
|
let spec_for_gate = load_team_spec(spec_workspace)?;
|
|
1342
1369
|
let gate_state = resolve_team_scoped_state_or_refuse(workspace, team)?;
|
|
1343
1370
|
let session_name_gate = state_session_name_from_spec(&gate_state, &spec_for_gate);
|
|
@@ -1349,10 +1376,6 @@ fn reset_agent_at_paths(
|
|
|
1349
1376
|
.filter(|s| !s.is_empty())
|
|
1350
1377
|
.unwrap_or_else(|| agent_id.as_str())
|
|
1351
1378
|
.to_string();
|
|
1352
|
-
let old_pane_still_live = old_pane_id_before
|
|
1353
|
-
.as_ref()
|
|
1354
|
-
.map(|p| agent_pane_live_by_id(transport, p))
|
|
1355
|
-
.unwrap_or(false);
|
|
1356
1379
|
// Take a SECOND snapshot post-stop and compute the differential.
|
|
1357
1380
|
// Only panes present in BOTH snapshots are residue (stop did not
|
|
1358
1381
|
// remove them).
|
|
@@ -1366,16 +1389,10 @@ fn reset_agent_at_paths(
|
|
|
1366
1389
|
.into_iter()
|
|
1367
1390
|
.filter(|p| pre_stop_pane_ids.contains(p.pane_id.as_str()))
|
|
1368
1391
|
.collect();
|
|
1369
|
-
// Pid-alone aliveness is secondary evidence and noisy under
|
|
1370
|
-
//
|
|
1371
|
-
//
|
|
1372
|
-
|
|
1373
|
-
// diagnostics.
|
|
1374
|
-
let old_pid_still_live = old_pane_pid_before
|
|
1375
|
-
.filter(|_| old_pane_still_live)
|
|
1376
|
-
.map(|pid| pid_is_alive(pid))
|
|
1377
|
-
.unwrap_or(false);
|
|
1378
|
-
if old_pane_still_live || !remaining_panes.is_empty() {
|
|
1392
|
+
// Pid-alone aliveness is secondary evidence and noisy under
|
|
1393
|
+
// fixtures. Block only on tmux-visible same-role residue; record
|
|
1394
|
+
// the old pid in the event for diagnostics.
|
|
1395
|
+
if !remaining_panes.is_empty() {
|
|
1379
1396
|
let remaining_pane_ids: Vec<String> = remaining_panes
|
|
1380
1397
|
.iter()
|
|
1381
1398
|
.map(|p| p.pane_id.as_str().to_string())
|
|
@@ -1454,6 +1471,8 @@ fn reset_agent_at_paths(
|
|
|
1454
1471
|
)?;
|
|
1455
1472
|
let started = matches!(start, StartAgentOutcome::Running { .. });
|
|
1456
1473
|
write_reset_complete_event(workspace, agent_id, stop.stopped, started)?;
|
|
1474
|
+
let (capture_state, reset_proof, weak_reset_warning) =
|
|
1475
|
+
reset_capture_proof(workspace, agent_id, discarded_session_id.as_ref());
|
|
1457
1476
|
match start {
|
|
1458
1477
|
StartAgentOutcome::Running {
|
|
1459
1478
|
env,
|
|
@@ -1476,6 +1495,9 @@ fn reset_agent_at_paths(
|
|
|
1476
1495
|
discarded_session_id,
|
|
1477
1496
|
session_id: output_session_id,
|
|
1478
1497
|
new_session_id,
|
|
1498
|
+
capture_state,
|
|
1499
|
+
reset_proof,
|
|
1500
|
+
weak_reset_warning,
|
|
1479
1501
|
})
|
|
1480
1502
|
}
|
|
1481
1503
|
StartAgentOutcome::Noop { env, .. } => Ok(ResetAgentOutcome::Reset {
|
|
@@ -1484,6 +1506,9 @@ fn reset_agent_at_paths(
|
|
|
1484
1506
|
discarded_session_id,
|
|
1485
1507
|
session_id: None,
|
|
1486
1508
|
new_session_id: None,
|
|
1509
|
+
capture_state,
|
|
1510
|
+
reset_proof,
|
|
1511
|
+
weak_reset_warning,
|
|
1487
1512
|
}),
|
|
1488
1513
|
StartAgentOutcome::Paused { .. } => Ok(ResetAgentOutcome::Reset {
|
|
1489
1514
|
env: AgentActionEnvelope {
|
|
@@ -1495,10 +1520,61 @@ fn reset_agent_at_paths(
|
|
|
1495
1520
|
discarded_session_id,
|
|
1496
1521
|
session_id: None,
|
|
1497
1522
|
new_session_id: None,
|
|
1523
|
+
capture_state,
|
|
1524
|
+
reset_proof,
|
|
1525
|
+
weak_reset_warning,
|
|
1498
1526
|
}),
|
|
1499
1527
|
}
|
|
1500
1528
|
}
|
|
1501
1529
|
|
|
1530
|
+
fn reset_capture_proof(
|
|
1531
|
+
workspace: &Path,
|
|
1532
|
+
agent_id: &AgentId,
|
|
1533
|
+
discarded_session_id: Option<&SessionId>,
|
|
1534
|
+
) -> (String, String, Option<String>) {
|
|
1535
|
+
let state = crate::state::persist::load_runtime_state(workspace)
|
|
1536
|
+
.unwrap_or_else(|_| serde_json::json!({}));
|
|
1537
|
+
let agent = state
|
|
1538
|
+
.get("agents")
|
|
1539
|
+
.and_then(|agents| agents.get(agent_id.as_str()));
|
|
1540
|
+
let capture_state = agent
|
|
1541
|
+
.and_then(|agent| agent.get("capture_state"))
|
|
1542
|
+
.and_then(serde_json::Value::as_str)
|
|
1543
|
+
.filter(|value| !value.is_empty())
|
|
1544
|
+
.or_else(|| {
|
|
1545
|
+
agent
|
|
1546
|
+
.and_then(|agent| agent.get("attribution_ambiguous"))
|
|
1547
|
+
.and_then(serde_json::Value::as_bool)
|
|
1548
|
+
.filter(|ambiguous| *ambiguous)
|
|
1549
|
+
.map(|_| "attribution_ambiguous")
|
|
1550
|
+
})
|
|
1551
|
+
.or_else(|| {
|
|
1552
|
+
let has_session = agent
|
|
1553
|
+
.and_then(|agent| agent.get("session_id"))
|
|
1554
|
+
.and_then(serde_json::Value::as_str)
|
|
1555
|
+
.is_some_and(|value| !value.is_empty());
|
|
1556
|
+
let has_rollout = agent
|
|
1557
|
+
.and_then(|agent| agent.get("rollout_path"))
|
|
1558
|
+
.and_then(serde_json::Value::as_str)
|
|
1559
|
+
.is_some_and(|value| !value.is_empty());
|
|
1560
|
+
(has_session && has_rollout).then_some("captured")
|
|
1561
|
+
})
|
|
1562
|
+
.unwrap_or("transcript_missing")
|
|
1563
|
+
.to_string();
|
|
1564
|
+
let weak = discarded_session_id.is_none()
|
|
1565
|
+
|| matches!(
|
|
1566
|
+
capture_state.as_str(),
|
|
1567
|
+
"transcript_missing" | "attribution_ambiguous"
|
|
1568
|
+
);
|
|
1569
|
+
let reset_proof = if weak { "weak" } else { "strong" }.to_string();
|
|
1570
|
+
let weak_reset_warning = weak.then(|| {
|
|
1571
|
+
format!(
|
|
1572
|
+
"weak reset proof: capture_state={capture_state}; lifecycle restarted but attribution did not prove a fresh transcript"
|
|
1573
|
+
)
|
|
1574
|
+
});
|
|
1575
|
+
(capture_state, reset_proof, weak_reset_warning)
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1502
1578
|
#[allow(clippy::too_many_arguments)]
|
|
1503
1579
|
fn write_start_agent_start_event(
|
|
1504
1580
|
workspace: &Path,
|
|
@@ -14,6 +14,142 @@ pub(super) struct SpawnedAgentWindow {
|
|
|
14
14
|
pub owner_team_id: Option<String>,
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
#[derive(Clone)]
|
|
18
|
+
pub(super) struct SameRoleCohortTarget {
|
|
19
|
+
pub agent_id: String,
|
|
20
|
+
pub window: String,
|
|
21
|
+
pub expected_pane_id: Option<String>,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
impl SameRoleCohortTarget {
|
|
25
|
+
pub(super) fn new(agent_id: &AgentId, window: &str) -> Self {
|
|
26
|
+
Self {
|
|
27
|
+
agent_id: agent_id.as_str().to_string(),
|
|
28
|
+
window: window.to_string(),
|
|
29
|
+
expected_pane_id: None,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
pub(super) fn with_expected_pane_id(mut self, pane_id: Option<&str>) -> Self {
|
|
34
|
+
self.expected_pane_id = pane_id.map(ToString::to_string);
|
|
35
|
+
self
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
pub(super) fn is_per_agent_cohort_window(window: &str, agent_id: &AgentId) -> bool {
|
|
40
|
+
window == agent_id.as_str() && !crate::lifecycle::launch::is_adaptive_layout_window_pub(window)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
pub(super) fn same_role_cohort_pre_spawn_error(
|
|
44
|
+
transport: &dyn crate::transport::Transport,
|
|
45
|
+
session_name: &SessionName,
|
|
46
|
+
operation: &str,
|
|
47
|
+
targets: &[SameRoleCohortTarget],
|
|
48
|
+
) -> Option<String> {
|
|
49
|
+
same_role_cohort_error(transport, session_name, operation, targets, 0, true)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
pub(super) fn same_role_cohort_exactly_one_error(
|
|
53
|
+
transport: &dyn crate::transport::Transport,
|
|
54
|
+
session_name: &SessionName,
|
|
55
|
+
operation: &str,
|
|
56
|
+
targets: &[SameRoleCohortTarget],
|
|
57
|
+
) -> Option<String> {
|
|
58
|
+
same_role_cohort_error(transport, session_name, operation, targets, 1, false)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
pub(super) fn retire_expected_same_role_cohorts(
|
|
62
|
+
transport: &dyn crate::transport::Transport,
|
|
63
|
+
operation: &str,
|
|
64
|
+
targets: &[SameRoleCohortTarget],
|
|
65
|
+
) -> Result<(), String> {
|
|
66
|
+
for target in targets {
|
|
67
|
+
let Some(pane_id) = target.expected_pane_id.as_deref() else {
|
|
68
|
+
continue;
|
|
69
|
+
};
|
|
70
|
+
let pane = crate::transport::PaneId::new(pane_id);
|
|
71
|
+
if transport.has_pane(&pane).ok().flatten() == Some(false) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
transport.kill_pane(&pane).map_err(|error| {
|
|
75
|
+
format!(
|
|
76
|
+
"{operation} failed to retire old same-role cohort {}:window={}:pane={pane_id}: {error}",
|
|
77
|
+
target.agent_id, target.window
|
|
78
|
+
)
|
|
79
|
+
})?;
|
|
80
|
+
}
|
|
81
|
+
Ok(())
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fn same_role_cohort_error(
|
|
85
|
+
transport: &dyn crate::transport::Transport,
|
|
86
|
+
session_name: &SessionName,
|
|
87
|
+
operation: &str,
|
|
88
|
+
targets: &[SameRoleCohortTarget],
|
|
89
|
+
expected_live: usize,
|
|
90
|
+
expected_old_only: bool,
|
|
91
|
+
) -> Option<String> {
|
|
92
|
+
let panes = transport.list_targets().ok()?;
|
|
93
|
+
let mut cardinality_proofs = Vec::new();
|
|
94
|
+
let mut binding_proofs = Vec::new();
|
|
95
|
+
for target in targets {
|
|
96
|
+
let live_panes = panes
|
|
97
|
+
.iter()
|
|
98
|
+
.filter(|pane| pane.session.as_str() == session_name.as_str())
|
|
99
|
+
.filter(|pane| {
|
|
100
|
+
pane.window_name
|
|
101
|
+
.as_ref()
|
|
102
|
+
.is_some_and(|window| window.as_str() == target.window)
|
|
103
|
+
})
|
|
104
|
+
.filter(|pane| {
|
|
105
|
+
!expected_old_only
|
|
106
|
+
|| target
|
|
107
|
+
.expected_pane_id
|
|
108
|
+
.as_deref()
|
|
109
|
+
.is_some_and(|expected| pane.pane_id.as_str() == expected)
|
|
110
|
+
})
|
|
111
|
+
.filter(|pane| {
|
|
112
|
+
transport
|
|
113
|
+
.liveness(&pane.pane_id)
|
|
114
|
+
.is_ok_and(|live| live == crate::transport::PaneLiveness::Live)
|
|
115
|
+
})
|
|
116
|
+
.map(|pane| pane.pane_id.as_str().to_string())
|
|
117
|
+
.collect::<Vec<_>>();
|
|
118
|
+
if live_panes.len() != expected_live {
|
|
119
|
+
cardinality_proofs.push(format!(
|
|
120
|
+
"{}:window={}:live_panes=[{}]",
|
|
121
|
+
target.agent_id,
|
|
122
|
+
target.window,
|
|
123
|
+
live_panes.join(",")
|
|
124
|
+
));
|
|
125
|
+
} else if !expected_old_only {
|
|
126
|
+
let observed = live_panes.first().map(String::as_str);
|
|
127
|
+
if target.expected_pane_id.as_deref() != observed {
|
|
128
|
+
binding_proofs.push(format!(
|
|
129
|
+
"{}:window={}:expected_pane={}:observed_pane={}",
|
|
130
|
+
target.agent_id,
|
|
131
|
+
target.window,
|
|
132
|
+
target.expected_pane_id.as_deref().unwrap_or("<missing>"),
|
|
133
|
+
observed.unwrap_or("<missing>")
|
|
134
|
+
));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if !binding_proofs.is_empty() {
|
|
139
|
+
return Some(format!(
|
|
140
|
+
"{operation} refused: spawn identity/binding mismatch; {}",
|
|
141
|
+
binding_proofs.join("; ")
|
|
142
|
+
));
|
|
143
|
+
}
|
|
144
|
+
if !cardinality_proofs.is_empty() {
|
|
145
|
+
return Some(format!(
|
|
146
|
+
"{operation} refused: same-role cohort duplicate proof failed; {}",
|
|
147
|
+
cardinality_proofs.join("; ")
|
|
148
|
+
));
|
|
149
|
+
}
|
|
150
|
+
None
|
|
151
|
+
}
|
|
152
|
+
|
|
17
153
|
#[allow(clippy::too_many_arguments)]
|
|
18
154
|
pub(super) fn spawn_agent_window(
|
|
19
155
|
workspace: &Path,
|