@team-agent/installer 0.5.104 → 0.5.106
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/README.md +5 -3
- package/README.zh.md +4 -3
- package/crates/team-agent/src/cli/adapters.rs +17 -1
- package/crates/team-agent/src/cli/emit.rs +135 -18
- package/crates/team-agent/src/cli/mod.rs +156 -35
- package/crates/team-agent/src/cli/spec.rs +2 -2
- package/crates/team-agent/src/cli/status_port/brief.rs +62 -3
- package/crates/team-agent/src/cli/tests/run_delegation.rs +5 -2
- package/crates/team-agent/src/cli/tests/status_send.rs +2 -2
- package/crates/team-agent/src/cli/types.rs +3 -1
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +6 -2
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +6 -4
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +7 -2
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -3
- package/crates/team-agent/src/lifecycle/launch.rs +4 -1
- package/crates/team-agent/src/lifecycle/mod.rs +1 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +75 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +24 -17
- package/crates/team-agent/src/lifecycle/restart.rs +1 -1
- package/crates/team-agent/src/lifecycle/role_config.rs +1123 -0
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +22 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +43 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +71 -0
- package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +6 -2
- package/crates/team-agent/src/state/projection.rs +15 -0
- package/crates/team-agent/src/state/repository.rs +2 -1
- package/crates/team-agent/src/tmux_backend/tests.rs +137 -0
- package/crates/team-agent/src/tmux_backend.rs +110 -0
- package/crates/team-agent/src/transport/test_support.rs +35 -0
- package/crates/team-agent/src/transport/tests/wire.rs +32 -3
- package/crates/team-agent/src/transport.rs +26 -12
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +55 -20
- package/skills/team-agent/references/recovery-runbook.md +2 -2
- package/skills/team-agent/references/team-agent-operator.md +14 -6
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//!
|
|
1
|
+
//! Read-only status: native runtime samples plus accepted launch model/effort.
|
|
2
2
|
//!
|
|
3
3
|
//! This intentionally does not reuse RuntimeSnapshot: the legacy snapshot reads
|
|
4
4
|
//! coordinator/db/history and carries diagnostic fields that are outside the
|
|
@@ -82,6 +82,8 @@ static NODEPROBE_RESOLVER_ACTIVE: AtomicBool = AtomicBool::new(false);
|
|
|
82
82
|
struct RegisteredNode {
|
|
83
83
|
name: String,
|
|
84
84
|
provider: String,
|
|
85
|
+
model: Option<String>,
|
|
86
|
+
effort: Option<String>,
|
|
85
87
|
endpoint: Option<String>,
|
|
86
88
|
session: Option<String>,
|
|
87
89
|
window: Option<String>,
|
|
@@ -106,6 +108,8 @@ struct ProbeNode {
|
|
|
106
108
|
struct BriefNode {
|
|
107
109
|
name: String,
|
|
108
110
|
provider: String,
|
|
111
|
+
model: Option<String>,
|
|
112
|
+
effort: Option<String>,
|
|
109
113
|
runtime_status: String,
|
|
110
114
|
activity: String,
|
|
111
115
|
health: String,
|
|
@@ -157,7 +161,7 @@ pub(crate) fn status_brief_scoped(
|
|
|
157
161
|
})
|
|
158
162
|
}
|
|
159
163
|
|
|
160
|
-
/// Human output is the exact same
|
|
164
|
+
/// Human output is the exact same nine-field projection as JSON, rendered in
|
|
161
165
|
/// stable field order. Null values remain explicit rather than becoming a
|
|
162
166
|
/// diagnostic or an inferred value.
|
|
163
167
|
pub(crate) fn format_status_brief(
|
|
@@ -305,9 +309,11 @@ fn write_test_capability_receipt(path: &Path) {
|
|
|
305
309
|
|
|
306
310
|
fn format_brief_node(value: &Value) -> Option<String> {
|
|
307
311
|
Some(format!(
|
|
308
|
-
"name: {} provider: {} runtime_status: {} activity: {} health: {} session_name: {} tmux_command: {}",
|
|
312
|
+
"name: {} provider: {} model: {} effort: {} runtime_status: {} activity: {} health: {} session_name: {} tmux_command: {}",
|
|
309
313
|
value.get("name")?.as_str()?,
|
|
310
314
|
value.get("provider")?.as_str()?,
|
|
315
|
+
display_optional(value.get("model")?),
|
|
316
|
+
display_optional(value.get("effort")?),
|
|
311
317
|
value.get("runtime_status")?.as_str()?,
|
|
312
318
|
value.get("activity")?.as_str()?,
|
|
313
319
|
value.get("health")?.as_str()?,
|
|
@@ -369,6 +375,10 @@ fn registered_node(name: &str, value: &Value, state: &Value) -> RegisteredNode {
|
|
|
369
375
|
RegisteredNode {
|
|
370
376
|
name: name.to_string(),
|
|
371
377
|
provider,
|
|
378
|
+
// Accepted launch settings, not pending role-file edits or guessed
|
|
379
|
+
// provider defaults. Runtime liveness remains a separate signal.
|
|
380
|
+
model: string_at(value, &["model"]),
|
|
381
|
+
effort: string_at(value, &["effort"]),
|
|
372
382
|
endpoint: endpoint(value).or_else(|| endpoint(state)),
|
|
373
383
|
session: first_string(value, &["session_name", "session"])
|
|
374
384
|
.or_else(|| {
|
|
@@ -1052,6 +1062,8 @@ fn project_node(node: &RegisteredNode, probe: Option<&Option<ProbeResult>>) -> B
|
|
|
1052
1062
|
MatchResult::Missing => BriefNode {
|
|
1053
1063
|
name: node.name.clone(),
|
|
1054
1064
|
provider: node.provider.clone(),
|
|
1065
|
+
model: node.model.clone(),
|
|
1066
|
+
effort: node.effort.clone(),
|
|
1055
1067
|
runtime_status: "stopped".to_string(),
|
|
1056
1068
|
activity: "unknown".to_string(),
|
|
1057
1069
|
health: "unknown".to_string(),
|
|
@@ -1092,6 +1104,8 @@ fn project_node(node: &RegisteredNode, probe: Option<&Option<ProbeResult>>) -> B
|
|
|
1092
1104
|
} else {
|
|
1093
1105
|
node.provider.clone()
|
|
1094
1106
|
},
|
|
1107
|
+
model: node.model.clone(),
|
|
1108
|
+
effort: node.effort.clone(),
|
|
1095
1109
|
runtime_status: "running".to_string(),
|
|
1096
1110
|
activity: activity.to_string(),
|
|
1097
1111
|
health: health.to_string(),
|
|
@@ -1172,6 +1186,8 @@ impl BriefNode {
|
|
|
1172
1186
|
Self {
|
|
1173
1187
|
name: node.name.clone(),
|
|
1174
1188
|
provider: node.provider.clone(),
|
|
1189
|
+
model: node.model.clone(),
|
|
1190
|
+
effort: node.effort.clone(),
|
|
1175
1191
|
runtime_status: "unknown".to_string(),
|
|
1176
1192
|
activity: "unknown".to_string(),
|
|
1177
1193
|
health: "unknown".to_string(),
|
|
@@ -1184,6 +1200,8 @@ impl BriefNode {
|
|
|
1184
1200
|
let mut object = Map::new();
|
|
1185
1201
|
object.insert("name".to_string(), json!(self.name));
|
|
1186
1202
|
object.insert("provider".to_string(), json!(self.provider));
|
|
1203
|
+
object.insert("model".to_string(), json!(self.model));
|
|
1204
|
+
object.insert("effort".to_string(), json!(self.effort));
|
|
1187
1205
|
object.insert("runtime_status".to_string(), json!(self.runtime_status));
|
|
1188
1206
|
object.insert("activity".to_string(), json!(self.activity));
|
|
1189
1207
|
object.insert("health".to_string(), json!(self.health));
|
|
@@ -1208,6 +1226,8 @@ mod tests {
|
|
|
1208
1226
|
RegisteredNode {
|
|
1209
1227
|
name: "worker".to_string(),
|
|
1210
1228
|
provider: "pi".to_string(),
|
|
1229
|
+
model: None,
|
|
1230
|
+
effort: None,
|
|
1211
1231
|
endpoint: Some("/tmp/tmux socket/o'k".to_string()),
|
|
1212
1232
|
session: Some("sess".to_string()),
|
|
1213
1233
|
window: Some("win".to_string()),
|
|
@@ -1234,6 +1254,45 @@ mod tests {
|
|
|
1234
1254
|
br#"{"schema_version":1,"socket":"/tmp/sock","sampled_at":"2026-01-01T00:00:00Z","nodes":[],"error":{"kind":"tmux_inventory","message":"missing"}}"#
|
|
1235
1255
|
}
|
|
1236
1256
|
|
|
1257
|
+
#[test]
|
|
1258
|
+
fn status_model_effort_project_accepted_launch_settings_in_all_runtime_states() {
|
|
1259
|
+
for settings in [(None, None), (Some("openai-codex/gpt-6-luna"), Some("max"))] {
|
|
1260
|
+
let mut node = registered();
|
|
1261
|
+
node.model = settings.0.map(str::to_string);
|
|
1262
|
+
node.effort = settings.1.map(str::to_string);
|
|
1263
|
+
for lifecycle in [None, Some("stopped")] {
|
|
1264
|
+
node.lifecycle = lifecycle.map(str::to_string);
|
|
1265
|
+
for sample in [None, Some(ProbeResult { nodes: vec![probe("pi_activity_channel")] })] {
|
|
1266
|
+
let value = project_node(&node, Some(&sample)).into_json();
|
|
1267
|
+
assert_eq!(value["model"], json!(settings.0));
|
|
1268
|
+
assert_eq!(value["effort"], json!(settings.1));
|
|
1269
|
+
let human = format_brief_node(&value).unwrap();
|
|
1270
|
+
assert!(human.contains(&format!("model: {} effort: {}", settings.0.unwrap_or("null"), settings.1.unwrap_or("null"))));
|
|
1271
|
+
assert_eq!(value.as_object().unwrap().len(), 9);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
#[test]
|
|
1278
|
+
fn status_model_effort_use_canonical_runtime_roster_not_stale_projection() {
|
|
1279
|
+
let state = json!({
|
|
1280
|
+
"active_team_key": "selected",
|
|
1281
|
+
"agents": {"worker": {"model": "stale-model", "effort": "low"}},
|
|
1282
|
+
"teams": {"selected": {"agents": {
|
|
1283
|
+
"worker": {"model": "accepted-model", "effort": "max"},
|
|
1284
|
+
"plain": {}
|
|
1285
|
+
}}}
|
|
1286
|
+
});
|
|
1287
|
+
let selected = registered_nodes(&state, Some("worker"));
|
|
1288
|
+
assert_eq!(selected.len(), 1);
|
|
1289
|
+
assert_eq!(selected[0].model.as_deref(), Some("accepted-model"));
|
|
1290
|
+
assert_eq!(selected[0].effort.as_deref(), Some("max"));
|
|
1291
|
+
let plain = registered_nodes(&state, Some("plain"));
|
|
1292
|
+
assert!(plain[0].model.is_none());
|
|
1293
|
+
assert!(plain[0].effort.is_none());
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1237
1296
|
#[test]
|
|
1238
1297
|
fn pi_pane_title_is_unknown_not_idle() {
|
|
1239
1298
|
let node = registered();
|
|
@@ -317,6 +317,7 @@ fn cli_add_agent_duplicate_id_surfaces_real_error() {
|
|
|
317
317
|
let dup_role = team.join("dup-role.md");
|
|
318
318
|
std::fs::write(&dup_role, DELEG_VALID_ROLE).unwrap(); // role file must exist
|
|
319
319
|
let args = AddAgentArgs {
|
|
320
|
+
role_config: Default::default(),
|
|
320
321
|
agent: "implementer".to_string(),
|
|
321
322
|
workspace: team,
|
|
322
323
|
team: None,
|
|
@@ -332,12 +333,12 @@ fn cli_add_agent_duplicate_id_surfaces_real_error() {
|
|
|
332
333
|
);
|
|
333
334
|
}
|
|
334
335
|
|
|
335
|
-
// 5 [P1] — cmd_status --json emits the concise
|
|
336
|
+
// 5 [P1] — cmd_status --json emits the concise nine-field node projection.
|
|
336
337
|
// Status must not leak coordinator/db/history diagnostics; missing exact nodeprobe evidence remains
|
|
337
338
|
// an explicit unknown projection. The fixture still seeds a real store to ensure the status path
|
|
338
339
|
// does not accidentally depend on coordinator or mailbox reads.
|
|
339
340
|
#[test]
|
|
340
|
-
fn
|
|
341
|
+
fn cli_status_emits_nine_field_brief_without_diagnostics() {
|
|
341
342
|
let ws = seed_status_workspace(); // writes .team/runtime/state.json
|
|
342
343
|
let store = crate::message_store::MessageStore::open(&ws).unwrap();
|
|
343
344
|
let _ = store
|
|
@@ -398,6 +399,8 @@ fn cli_status_emits_seven_field_brief_without_diagnostics() {
|
|
|
398
399
|
[
|
|
399
400
|
"name",
|
|
400
401
|
"provider",
|
|
402
|
+
"model",
|
|
403
|
+
"effort",
|
|
401
404
|
"runtime_status",
|
|
402
405
|
"activity",
|
|
403
406
|
"health",
|
|
@@ -345,7 +345,7 @@ fn status_compact_reports_identity_slot_health_on_grok_identity_slot_ok() {
|
|
|
345
345
|
}
|
|
346
346
|
|
|
347
347
|
#[test]
|
|
348
|
-
fn
|
|
348
|
+
fn cmd_status_human_is_nine_field_brief_without_diagnostics() {
|
|
349
349
|
let ws = seed_status_workspace();
|
|
350
350
|
let args = StatusArgs {
|
|
351
351
|
agent: None,
|
|
@@ -364,7 +364,7 @@ fn cmd_status_human_is_seven_field_brief_without_diagnostics() {
|
|
|
364
364
|
|
|
365
365
|
assert!(
|
|
366
366
|
text.contains(
|
|
367
|
-
"name: a1 provider: unknown runtime_status: unknown activity: unknown health: unknown session_name: null tmux_command: null"
|
|
367
|
+
"name: a1 provider: unknown model: null effort: null runtime_status: unknown activity: unknown health: unknown session_name: null tmux_command: null"
|
|
368
368
|
),
|
|
369
369
|
"{text}"
|
|
370
370
|
);
|
|
@@ -475,6 +475,7 @@ pub struct RestartArgs {
|
|
|
475
475
|
/// `start-agent`(`parser.py:369`)。
|
|
476
476
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
477
477
|
pub struct StartAgentArgs {
|
|
478
|
+
pub role_config: crate::lifecycle::role_config::RoleConfigPatch,
|
|
478
479
|
pub agent: String,
|
|
479
480
|
pub workspace: PathBuf,
|
|
480
481
|
pub team: Option<String>,
|
|
@@ -502,9 +503,10 @@ pub struct ResetAgentArgs {
|
|
|
502
503
|
pub json: bool,
|
|
503
504
|
}
|
|
504
505
|
|
|
505
|
-
/// `add-agent
|
|
506
|
+
/// `add-agent`: provider and bypass are explicit CLI or role-file decisions.
|
|
506
507
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
507
508
|
pub struct AddAgentArgs {
|
|
509
|
+
pub role_config: crate::lifecycle::role_config::RoleConfigPatch,
|
|
508
510
|
pub agent: String,
|
|
509
511
|
pub workspace: PathBuf,
|
|
510
512
|
pub team: Option<String>,
|
|
@@ -418,7 +418,7 @@ pub(crate) fn add_agent_with_transport_force(
|
|
|
418
418
|
/// returns: 新席报告;成功后还要过快照的一致性校验
|
|
419
419
|
/// errors: 角色文件不存在先行返回 Compile;摘除、加回或一致性校验失败时按快照恢复并返回错误
|
|
420
420
|
/// ---
|
|
421
|
-
pub(
|
|
421
|
+
pub(crate) fn force_recreate_with_transport_locked(
|
|
422
422
|
run_workspace: &Path,
|
|
423
423
|
team_dir: &Path,
|
|
424
424
|
agent_id: &AgentId,
|
|
@@ -546,7 +546,7 @@ pub(super) fn add_agent_with_transport_at_paths(
|
|
|
546
546
|
}
|
|
547
547
|
|
|
548
548
|
#[allow(clippy::too_many_arguments)]
|
|
549
|
-
fn add_agent_with_transport_at_paths_locked(
|
|
549
|
+
pub(crate) fn add_agent_with_transport_at_paths_locked(
|
|
550
550
|
run_workspace: &Path,
|
|
551
551
|
team_dir: &Path,
|
|
552
552
|
agent_id: &AgentId,
|
|
@@ -683,6 +683,7 @@ fn add_agent_with_transport_at_paths_reserved(
|
|
|
683
683
|
&spec_path,
|
|
684
684
|
pre_spec_text.as_deref(),
|
|
685
685
|
None,
|
|
686
|
+
Some(&canonical_team_key),
|
|
686
687
|
agent_id,
|
|
687
688
|
"state_upsert_failed",
|
|
688
689
|
);
|
|
@@ -705,6 +706,7 @@ fn add_agent_with_transport_at_paths_reserved(
|
|
|
705
706
|
&spec_path,
|
|
706
707
|
pre_spec_text.as_deref(),
|
|
707
708
|
None,
|
|
709
|
+
Some(&canonical_team_key),
|
|
708
710
|
agent_id,
|
|
709
711
|
"start_agent_failed",
|
|
710
712
|
);
|
|
@@ -721,6 +723,7 @@ fn add_agent_with_transport_at_paths_reserved(
|
|
|
721
723
|
&spec_path,
|
|
722
724
|
pre_spec_text.as_deref(),
|
|
723
725
|
None,
|
|
726
|
+
Some(&canonical_team_key),
|
|
724
727
|
agent_id,
|
|
725
728
|
"start_agent_noop",
|
|
726
729
|
);
|
|
@@ -734,6 +737,7 @@ fn add_agent_with_transport_at_paths_reserved(
|
|
|
734
737
|
&spec_path,
|
|
735
738
|
pre_spec_text.as_deref(),
|
|
736
739
|
None,
|
|
740
|
+
Some(&canonical_team_key),
|
|
737
741
|
agent_id,
|
|
738
742
|
"added_agent_paused",
|
|
739
743
|
);
|
|
@@ -55,6 +55,7 @@ pub(super) fn rollback_add_agent_atomic(
|
|
|
55
55
|
spec_path: &Path,
|
|
56
56
|
pre_spec_text: Option<&str>,
|
|
57
57
|
pre_runtime_state: Option<&serde_json::Value>,
|
|
58
|
+
team_key: Option<&str>,
|
|
58
59
|
agent_id: &AgentId,
|
|
59
60
|
reason: &str,
|
|
60
61
|
) {
|
|
@@ -86,7 +87,7 @@ pub(super) fn rollback_add_agent_atomic(
|
|
|
86
87
|
crate::state::repository::StateRepository::new(run_workspace)
|
|
87
88
|
.save(
|
|
88
89
|
crate::state::repository::StateWriteIntent::AgentRollback {
|
|
89
|
-
team_key
|
|
90
|
+
team_key,
|
|
90
91
|
agent_id: agent_id.as_str(),
|
|
91
92
|
},
|
|
92
93
|
state,
|
|
@@ -94,7 +95,7 @@ pub(super) fn rollback_add_agent_atomic(
|
|
|
94
95
|
.is_ok()
|
|
95
96
|
} else {
|
|
96
97
|
// No prior runtime state — drop just the agent we added (load → strip → save).
|
|
97
|
-
if let Ok(mut state) = crate::state::
|
|
98
|
+
if let Ok(mut state) = crate::state::projection::select_runtime_state(run_workspace, team_key) {
|
|
98
99
|
if let Some(agents) = state
|
|
99
100
|
.get_mut("agents")
|
|
100
101
|
.and_then(serde_json::Value::as_object_mut)
|
|
@@ -105,7 +106,8 @@ pub(super) fn rollback_add_agent_atomic(
|
|
|
105
106
|
.get_mut("teams")
|
|
106
107
|
.and_then(serde_json::Value::as_object_mut)
|
|
107
108
|
{
|
|
108
|
-
for team in teams.
|
|
109
|
+
for (key, team) in teams.iter_mut() {
|
|
110
|
+
if team_key.is_some_and(|selected| selected != key) { continue; }
|
|
109
111
|
if let Some(agents) = team
|
|
110
112
|
.get_mut("agents")
|
|
111
113
|
.and_then(serde_json::Value::as_object_mut)
|
|
@@ -117,7 +119,7 @@ pub(super) fn rollback_add_agent_atomic(
|
|
|
117
119
|
crate::state::repository::StateRepository::new(run_workspace)
|
|
118
120
|
.save(
|
|
119
121
|
crate::state::repository::StateWriteIntent::AgentRollback {
|
|
120
|
-
team_key
|
|
122
|
+
team_key,
|
|
121
123
|
agent_id: agent_id.as_str(),
|
|
122
124
|
},
|
|
123
125
|
&state,
|
|
@@ -39,7 +39,7 @@ fn set_yaml_map_value(value: &mut Value, key: &str, next: Value) -> Result<(), L
|
|
|
39
39
|
Ok(())
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
fn dump_role_frontmatter(meta: &Value) -> String {
|
|
42
|
+
pub(crate) fn dump_role_frontmatter(meta: &Value) -> String {
|
|
43
43
|
let Value::Map(pairs) = meta else {
|
|
44
44
|
return yaml::dumps(meta);
|
|
45
45
|
};
|
|
@@ -327,8 +327,13 @@ pub(crate) fn write_spec_atomic(spec_path: &Path, spec: &Value) -> Result<(), Li
|
|
|
327
327
|
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
|
|
328
328
|
}
|
|
329
329
|
let tmp = spec_path.with_extension(format!("tmp-{}", std::process::id()));
|
|
330
|
-
std::fs::write(&tmp, yaml::dumps(spec))
|
|
331
|
-
|
|
330
|
+
if let Err(e) = std::fs::write(&tmp, yaml::dumps(spec)) {
|
|
331
|
+
let _ = std::fs::remove_file(&tmp);
|
|
332
|
+
return Err(LifecycleError::StatePersist(format!(
|
|
333
|
+
"{}: {e}",
|
|
334
|
+
tmp.display()
|
|
335
|
+
)));
|
|
336
|
+
}
|
|
332
337
|
if let Err(e) = std::fs::rename(&tmp, spec_path) {
|
|
333
338
|
let _ = std::fs::remove_file(&tmp);
|
|
334
339
|
return Err(LifecycleError::StatePersist(format!(
|
|
@@ -170,12 +170,17 @@ pub(super) fn persist_spawn_agent_state(
|
|
|
170
170
|
)?,
|
|
171
171
|
);
|
|
172
172
|
}
|
|
173
|
+
let generation = started
|
|
174
|
+
.first()
|
|
175
|
+
.map(|agent| agent.spawned_at.as_str())
|
|
176
|
+
.unwrap_or(session_name.as_str());
|
|
177
|
+
// Use the same birth identity as the native session owner marker. Worker
|
|
178
|
+
// spawned_at may change on start-agent; this session generation must not.
|
|
173
179
|
if let Some(obj) = state.as_object_mut() {
|
|
174
180
|
obj.insert("agents".to_string(), serde_json::Value::Object(agents));
|
|
181
|
+
obj.insert("generation".to_string(), serde_json::json!(generation));
|
|
175
182
|
} else {
|
|
176
|
-
|
|
177
|
-
obj.insert("agents".to_string(), serde_json::Value::Object(agents));
|
|
178
|
-
state = serde_json::Value::Object(obj);
|
|
183
|
+
state = serde_json::json!({"agents": agents, "generation": generation});
|
|
179
184
|
}
|
|
180
185
|
save_launched_team_state_for_key(workspace, &state, Some(&team_id), None)
|
|
181
186
|
}
|
|
@@ -402,7 +402,10 @@ pub(super) use readiness::*;
|
|
|
402
402
|
mod add_agent;
|
|
403
403
|
pub(super) use add_agent::*;
|
|
404
404
|
pub use add_agent::{add_agent, add_agent_force};
|
|
405
|
-
pub(crate) use add_agent::{
|
|
405
|
+
pub(crate) use add_agent::{
|
|
406
|
+
add_agent_with_transport, add_agent_with_transport_force,
|
|
407
|
+
add_agent_with_transport_at_paths_locked, force_recreate_with_transport_locked,
|
|
408
|
+
};
|
|
406
409
|
|
|
407
410
|
pub(crate) mod add_agent_state;
|
|
408
411
|
pub(super) use add_agent_state::*;
|
|
@@ -37,6 +37,37 @@ use super::team_state::write_team_state;
|
|
|
37
37
|
use super::*;
|
|
38
38
|
use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
|
|
39
39
|
|
|
40
|
+
/// CLI start is orthogonal to stop: refuse any live target before editing its role.
|
|
41
|
+
/// Transport uncertainty is a refusal, not permission to spawn a duplicate.
|
|
42
|
+
pub(crate) fn ensure_agent_not_running(
|
|
43
|
+
state: &serde_json::Value,
|
|
44
|
+
agent_id: &AgentId,
|
|
45
|
+
transport: &dyn crate::transport::Transport,
|
|
46
|
+
) -> Result<(), LifecycleError> {
|
|
47
|
+
let agent = state.get("agents").and_then(|agents| agents.get(agent_id.as_str()))
|
|
48
|
+
.ok_or_else(|| LifecycleError::RequirementUnmet(format!("agent {agent_id} not found")))?;
|
|
49
|
+
let session = state_session_name(state);
|
|
50
|
+
let window = agent_window(agent, agent_id);
|
|
51
|
+
let pane = agent.get("pane_id").and_then(serde_json::Value::as_str);
|
|
52
|
+
let targets = transport.list_targets()
|
|
53
|
+
.map_err(|error| LifecycleError::Transport(error.to_string()))?;
|
|
54
|
+
let live = targets.iter().find(|target| {
|
|
55
|
+
target.session == session
|
|
56
|
+
&& target.window_name.as_ref().is_some_and(|name| name.as_str() == window)
|
|
57
|
+
&& (pane == Some(target.pane_id.as_str())
|
|
58
|
+
|| !crate::lifecycle::launch::state_uses_adaptive_layout(state))
|
|
59
|
+
&& agent_pane_live_by_id(transport, &target.pane_id)
|
|
60
|
+
});
|
|
61
|
+
if let Some(target) = live {
|
|
62
|
+
Err(LifecycleError::RequirementUnmet(format!(
|
|
63
|
+
"agent {agent_id} is already running (duplicate / active cohort); use stop-agent first; cohort proof: session={} window={window} pane={}",
|
|
64
|
+
session.as_str(), target.pane_id.as_str(),
|
|
65
|
+
)))
|
|
66
|
+
} else {
|
|
67
|
+
Ok(())
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
40
71
|
/// ---
|
|
41
72
|
/// purpose: 起或复活一席的对外入口
|
|
42
73
|
/// params:
|
|
@@ -342,6 +373,8 @@ pub(crate) fn start_agent_at_paths(
|
|
|
342
373
|
};
|
|
343
374
|
let into_existing_session =
|
|
344
375
|
session_live_or_default(transport, &session_name, session_name_present(&state));
|
|
376
|
+
let inherited_generation =
|
|
377
|
+
crate::state::projection::session_generation(&state).map(str::to_string);
|
|
345
378
|
let safety = crate::lifecycle::launch::effective_runtime_config_for_worker_spawn_json(
|
|
346
379
|
&agent,
|
|
347
380
|
provider,
|
|
@@ -410,6 +443,24 @@ pub(crate) fn start_agent_at_paths(
|
|
|
410
443
|
&safety,
|
|
411
444
|
start_mode,
|
|
412
445
|
)?;
|
|
446
|
+
let generation = if into_existing_session {
|
|
447
|
+
inherited_generation.as_deref()
|
|
448
|
+
} else {
|
|
449
|
+
Some(spawn.spawned_at.as_str())
|
|
450
|
+
};
|
|
451
|
+
if let Some(generation) = generation {
|
|
452
|
+
state["generation"] = serde_json::json!(generation);
|
|
453
|
+
if !into_existing_session {
|
|
454
|
+
transport
|
|
455
|
+
.set_session_owner_with_generation(
|
|
456
|
+
&session_name,
|
|
457
|
+
workspace,
|
|
458
|
+
&resolved_team_key,
|
|
459
|
+
generation,
|
|
460
|
+
)
|
|
461
|
+
.map_err(|error| LifecycleError::Transport(error.to_string()))?;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
413
464
|
// 0.5.66 bypass 单源 §2.7:启动即带 bypass → 审计留痕。
|
|
414
465
|
if safety.enabled {
|
|
415
466
|
let state_agent = state
|
|
@@ -1125,6 +1176,30 @@ pub(super) fn stop_agent_at_paths(
|
|
|
1125
1176
|
|| !same_role_panes.is_empty()
|
|
1126
1177
|
|| (pane_id.is_none() && window_exists(transport, &session_name, &window));
|
|
1127
1178
|
if stopped {
|
|
1179
|
+
let stop_targets =
|
|
1180
|
+
if let Some(pane) = pane_id.as_ref().filter(|_| stored_pane_live) {
|
|
1181
|
+
vec![Target::Pane(pane.clone())]
|
|
1182
|
+
} else if !same_role_panes.is_empty() {
|
|
1183
|
+
same_role_panes
|
|
1184
|
+
.iter()
|
|
1185
|
+
.map(|pane| Target::Pane(pane.pane_id.clone()))
|
|
1186
|
+
.collect()
|
|
1187
|
+
} else {
|
|
1188
|
+
vec![Target::SessionWindow {
|
|
1189
|
+
session: session_name.clone(),
|
|
1190
|
+
window: WindowName::new(&window),
|
|
1191
|
+
}]
|
|
1192
|
+
};
|
|
1193
|
+
// Explicit kill-pane/kill-window ignores remain-on-exit on the worker.
|
|
1194
|
+
// Preserve the last window BEFORE killing it, or the coordinator sees
|
|
1195
|
+
// a missing session and stops while the seat is merely being stopped.
|
|
1196
|
+
transport
|
|
1197
|
+
.preserve_session_before_stop(&session_name, &stop_targets)
|
|
1198
|
+
.map_err(|error| {
|
|
1199
|
+
LifecycleError::Transport(format!(
|
|
1200
|
+
"failed to preserve session before stopping agent {agent_id}: {error}"
|
|
1201
|
+
))
|
|
1202
|
+
})?;
|
|
1128
1203
|
// golden operations.py:84-86: a non-zero kill-window raises
|
|
1129
1204
|
// RuntimeError(f"failed to stop agent {agent_id}: {proc.stderr.strip()}").
|
|
1130
1205
|
//
|
|
@@ -509,6 +509,8 @@ fn restart_with_selected_team_and_transport(
|
|
|
509
509
|
let live_worker_session_deferred_teardown =
|
|
510
510
|
session_live_or_default(transport, &session_name, false)
|
|
511
511
|
&& !collect_live_agents_from_state(&state).is_empty();
|
|
512
|
+
let inherited_generation =
|
|
513
|
+
crate::state::projection::session_generation(&state).map(str::to_string);
|
|
512
514
|
let same_role_cohort_targets =
|
|
513
515
|
same_role_cohort_targets_from_decisions(&state, spec_workspace, &plan.decisions);
|
|
514
516
|
let pre_spawn_pane_ids = if live_worker_session_deferred_teardown {
|
|
@@ -998,6 +1000,25 @@ fn restart_with_selected_team_and_transport(
|
|
|
998
1000
|
}
|
|
999
1001
|
}
|
|
1000
1002
|
}
|
|
1003
|
+
if !fatal_resume_failure && !successful_agents.is_empty() {
|
|
1004
|
+
let generation = if live_worker_session_deferred_teardown {
|
|
1005
|
+
inherited_generation
|
|
1006
|
+
} else {
|
|
1007
|
+
// A recreated session gets the first successful NEW worker's birth,
|
|
1008
|
+
// not an arbitrary old stopped worker still present in the roster.
|
|
1009
|
+
successful_agents.first().and_then(|agent| {
|
|
1010
|
+
state
|
|
1011
|
+
.get("agents")?
|
|
1012
|
+
.get(agent.agent_id.as_str())?
|
|
1013
|
+
.get("spawned_at")?
|
|
1014
|
+
.as_str()
|
|
1015
|
+
.map(str::to_string)
|
|
1016
|
+
})
|
|
1017
|
+
};
|
|
1018
|
+
if let Some(generation) = generation {
|
|
1019
|
+
state["generation"] = serde_json::json!(generation);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1001
1022
|
save_restart_state_with_lifecycle_topology_authority_and_capture_backfill_skip(
|
|
1002
1023
|
&selected.run_workspace,
|
|
1003
1024
|
&mut state,
|
|
@@ -1040,23 +1061,9 @@ fn restart_with_selected_team_and_transport(
|
|
|
1040
1061
|
attach_commands,
|
|
1041
1062
|
});
|
|
1042
1063
|
}
|
|
1043
|
-
//
|
|
1044
|
-
//
|
|
1045
|
-
|
|
1046
|
-
if let Some(generation) = state
|
|
1047
|
-
.get("generation")
|
|
1048
|
-
.and_then(serde_json::Value::as_str)
|
|
1049
|
-
.or_else(|| {
|
|
1050
|
-
state
|
|
1051
|
-
.get("agents")
|
|
1052
|
-
.and_then(serde_json::Value::as_object)
|
|
1053
|
-
.and_then(|agents| {
|
|
1054
|
-
agents.values().find_map(|agent| {
|
|
1055
|
-
agent.get("spawned_at").and_then(serde_json::Value::as_str)
|
|
1056
|
-
})
|
|
1057
|
-
})
|
|
1058
|
-
})
|
|
1059
|
-
{
|
|
1064
|
+
// Stamp the physical session birth after durable state. Replacing workers
|
|
1065
|
+
// in a retained session does not create a new session generation.
|
|
1066
|
+
if let Some(generation) = crate::state::projection::session_generation(&state) {
|
|
1060
1067
|
transport
|
|
1061
1068
|
.set_session_owner_with_generation(
|
|
1062
1069
|
&session_name,
|
|
@@ -77,7 +77,7 @@ pub(crate) mod remove;
|
|
|
77
77
|
mod selection;
|
|
78
78
|
mod team_state;
|
|
79
79
|
|
|
80
|
-
pub(crate) use agent::start_agent_at_paths;
|
|
80
|
+
pub(crate) use agent::{start_agent_at_paths, ensure_agent_not_running};
|
|
81
81
|
pub use agent::{reset_agent, start_agent, stop_agent};
|
|
82
82
|
pub(crate) use agent::{
|
|
83
83
|
reset_agent_with_transport, start_agent_with_transport, stop_agent_with_transport,
|