@team-agent/installer 0.4.0 → 0.4.2
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 +82 -22
- package/crates/team-agent/src/cli/diagnose.rs +6 -2
- package/crates/team-agent/src/cli/emit.rs +301 -38
- package/crates/team-agent/src/cli/mod.rs +7 -14
- package/crates/team-agent/src/cli/status_port.rs +12 -1
- package/crates/team-agent/src/cli/tests/base.rs +6 -2
- package/crates/team-agent/src/cli/tests/lane_c.rs +1 -1
- package/crates/team-agent/src/cli/tests/leader_watch.rs +5 -3
- package/crates/team-agent/src/cli/tests/main_preserved.rs +28 -2
- package/crates/team-agent/src/cli/tests/peer_allow.rs +19 -0
- package/crates/team-agent/src/cli/tests/repair_state_byte_lock.rs +72 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -3
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +35 -2
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +7 -2
- package/crates/team-agent/src/coordinator/tick.rs +86 -16
- package/crates/team-agent/src/diagnose/comms.rs +10 -2
- package/crates/team-agent/src/leader/lease.rs +98 -19
- package/crates/team-agent/src/leader/rediscover/tests.rs +21 -7
- package/crates/team-agent/src/leader/rediscover.rs +16 -7
- package/crates/team-agent/src/leader/start.rs +25 -12
- package/crates/team-agent/src/leader/tests/byte_findings.rs +11 -2
- package/crates/team-agent/src/leader/tests/lease_claim.rs +248 -7
- package/crates/team-agent/src/lifecycle/launch.rs +116 -100
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +5 -12
- package/crates/team-agent/src/lifecycle/tests/core.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +16 -15
- package/crates/team-agent/src/lifecycle/types.rs +1 -1
- package/crates/team-agent/src/messaging/leader_receiver.rs +22 -8
- package/crates/team-agent/src/messaging/send.rs +21 -4
- package/crates/team-agent/src/provider/adapter.rs +22 -0
- package/crates/team-agent/src/provider/session/capture.rs +228 -0
- package/crates/team-agent/src/state/identity.rs +42 -12
- package/crates/team-agent/src/state/identity_keys.rs +134 -0
- package/crates/team-agent/src/state/mod.rs +8 -0
- package/crates/team-agent/src/state/owner_gate.rs +11 -1
- package/crates/team-agent/src/state/ownership.rs +556 -0
- package/crates/team-agent/src/state/paths.rs +358 -0
- package/crates/team-agent/src/state/persist.rs +43 -5
- package/crates/team-agent/src/state/projection.rs +13 -5
- package/crates/team-agent/src/tmux_backend.rs +12 -0
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +3 -3
|
@@ -196,7 +196,7 @@ use super::*;
|
|
|
196
196
|
#[test]
|
|
197
197
|
fn inbox_golden_shape_is_agent_id_and_since_not_agent_limit() {
|
|
198
198
|
let ws = tmp_workspace();
|
|
199
|
-
let v = status_port::inbox(&ws, "alpha", 20, None, true).expect("inbox");
|
|
199
|
+
let v = status_port::inbox(&ws, "alpha", 20, None, true, None).expect("inbox");
|
|
200
200
|
let obj = v.as_object().expect("inbox dict");
|
|
201
201
|
let order: Vec<&str> = obj.keys().map(String::as_str).collect();
|
|
202
202
|
assert_eq!(
|
|
@@ -167,9 +167,11 @@ use super::*;
|
|
|
167
167
|
assert_eq!(r.status, crate::leader::LeaseStatus::Claimed);
|
|
168
168
|
assert_eq!(r.owner_epoch, Some(crate::model::ids::OwnerEpoch(3)));
|
|
169
169
|
assert_eq!(r.bound_pane_id, Some(caller.clone()));
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
assert_eq!(state["
|
|
170
|
+
// Stage 3d: canonical owner/receiver at teams.<team_key>.
|
|
171
|
+
let team_key = crate::state::projection::team_state_key(&state);
|
|
172
|
+
assert_eq!(state["teams"][&team_key]["leader_receiver"]["pane_id"], json!("%5"));
|
|
173
|
+
assert_eq!(state["teams"][&team_key]["team_owner"]["pane_id"], json!("%5"));
|
|
174
|
+
assert_eq!(state["teams"][&team_key]["team_owner"]["owner_epoch"], json!(3));
|
|
173
175
|
}
|
|
174
176
|
|
|
175
177
|
struct LeaderPortSeededLiveness {
|
|
@@ -186,7 +186,7 @@ fn seed_team_spec(ws: &std::path::Path) {
|
|
|
186
186
|
let mid = store
|
|
187
187
|
.create_message(None, "leader", "w1", "hello w1", None, true, None)
|
|
188
188
|
.unwrap();
|
|
189
|
-
let v = status_port::inbox(&ws, "w1", 20, None, true).expect("inbox");
|
|
189
|
+
let v = status_port::inbox(&ws, "w1", 20, None, true, None).expect("inbox");
|
|
190
190
|
let messages = v["messages"].as_array().expect("messages array");
|
|
191
191
|
assert_eq!(
|
|
192
192
|
messages.len(),
|
|
@@ -217,7 +217,7 @@ fn seed_team_spec(ws: &std::path::Path) {
|
|
|
217
217
|
store.create_message(None, "leader", "w1", "to w1", None, true, None).unwrap();
|
|
218
218
|
store.create_message(None, "w1", "leader", "from w1", None, true, None).unwrap();
|
|
219
219
|
store.create_message(None, "leader", "w2", "unrelated to w2", None, true, None).unwrap();
|
|
220
|
-
let v = status_port::inbox(&ws, "w1", 20, None, true).expect("inbox");
|
|
220
|
+
let v = status_port::inbox(&ws, "w1", 20, None, true, None).expect("inbox");
|
|
221
221
|
let messages = v["messages"].as_array().expect("messages array");
|
|
222
222
|
let mut contents: Vec<String> =
|
|
223
223
|
messages.iter().map(|m| m["content"].as_str().unwrap().to_string()).collect();
|
|
@@ -343,6 +343,7 @@ fn seed_team_spec(ws: &std::path::Path) {
|
|
|
343
343
|
workspace: ws.clone(),
|
|
344
344
|
timeout: 0.0,
|
|
345
345
|
json: true,
|
|
346
|
+
team: None,
|
|
346
347
|
}).expect("wait-ready"));
|
|
347
348
|
assert_eq!(value["ok"], json!(false), "wait-ready must not fake success before workers are ready");
|
|
348
349
|
assert_eq!(value.pointer("/readiness/ready"), Some(&json!(false)));
|
|
@@ -386,6 +387,7 @@ fn seed_team_spec(ws: &std::path::Path) {
|
|
|
386
387
|
workspace: ws.clone(),
|
|
387
388
|
timeout: 0.0,
|
|
388
389
|
json: true,
|
|
390
|
+
team: None,
|
|
389
391
|
}).expect("wait-ready"));
|
|
390
392
|
assert_eq!(
|
|
391
393
|
value.pointer("/readiness/mcp_ready"),
|
|
@@ -571,6 +573,7 @@ fn seed_team_spec(ws: &std::path::Path) {
|
|
|
571
573
|
workspace: ws.clone(),
|
|
572
574
|
result_file: None,
|
|
573
575
|
json: true,
|
|
576
|
+
team: None,
|
|
574
577
|
})
|
|
575
578
|
.unwrap(),
|
|
576
579
|
);
|
|
@@ -611,6 +614,7 @@ fn seed_team_spec(ws: &std::path::Path) {
|
|
|
611
614
|
workspace: ws.clone(),
|
|
612
615
|
alert_type: None,
|
|
613
616
|
json: true,
|
|
617
|
+
team: None,
|
|
614
618
|
})
|
|
615
619
|
.unwrap(),
|
|
616
620
|
);
|
|
@@ -637,6 +641,7 @@ fn seed_team_spec(ws: &std::path::Path) {
|
|
|
637
641
|
cmd_stuck_list(&StuckListArgs {
|
|
638
642
|
workspace: ws.clone(),
|
|
639
643
|
json: true,
|
|
644
|
+
team: None,
|
|
640
645
|
})
|
|
641
646
|
.unwrap(),
|
|
642
647
|
);
|
|
@@ -646,6 +651,26 @@ fn seed_team_spec(ws: &std::path::Path) {
|
|
|
646
651
|
"stuck-list must read the persisted state mirror, not return a hard-coded empty list"
|
|
647
652
|
);
|
|
648
653
|
}
|
|
654
|
+
|
|
655
|
+
#[test]
|
|
656
|
+
fn stuck_cancel_explicit_team_is_rejected_until_backend_is_scoped() {
|
|
657
|
+
let ws = tmp_workspace();
|
|
658
|
+
seed_collect_state(&ws);
|
|
659
|
+
let err = cmd_stuck_cancel(&StuckCancelArgs {
|
|
660
|
+
agent: "fake_impl".to_string(),
|
|
661
|
+
workspace: ws.clone(),
|
|
662
|
+
alert_type: None,
|
|
663
|
+
json: true,
|
|
664
|
+
team: Some("current".to_string()),
|
|
665
|
+
})
|
|
666
|
+
.expect_err("explicit --team must not silently write global stuck suppression");
|
|
667
|
+
assert!(
|
|
668
|
+
err.to_string().contains("not supported yet"),
|
|
669
|
+
"stuck-cancel --team must be an explicit refusal until backend supports scoped writes; got {err}"
|
|
670
|
+
);
|
|
671
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
672
|
+
}
|
|
673
|
+
|
|
649
674
|
#[test]
|
|
650
675
|
fn stuck_cancel_invalid_alert_type_is_rejected() {
|
|
651
676
|
let ws = tmp_workspace();
|
|
@@ -717,6 +742,7 @@ fn seed_team_spec(ws: &std::path::Path) {
|
|
|
717
742
|
status: "done".to_string(),
|
|
718
743
|
summary: Some("manual repair accepted".to_string()),
|
|
719
744
|
json: true,
|
|
745
|
+
team: None,
|
|
720
746
|
})
|
|
721
747
|
.expect("repair-state should not fail on a valid runtime state"),
|
|
722
748
|
);
|
|
@@ -43,6 +43,7 @@ fn app_call(ws: &std::path::Path, a: &str, b: &str) -> CmdResult {
|
|
|
43
43
|
b: b.to_string(),
|
|
44
44
|
workspace: ws.to_path_buf(),
|
|
45
45
|
json: false,
|
|
46
|
+
team: None,
|
|
46
47
|
})
|
|
47
48
|
.expect("allow-peer-talk is a compat-noop and must not error")
|
|
48
49
|
}
|
|
@@ -135,3 +136,21 @@ fn allow_peer_talk_unknown_agents_succeed_no_validation() {
|
|
|
135
136
|
);
|
|
136
137
|
let _ = std::fs::remove_dir_all(&ws);
|
|
137
138
|
}
|
|
139
|
+
|
|
140
|
+
#[test]
|
|
141
|
+
fn allow_peer_talk_explicit_team_is_rejected_until_backend_is_scoped() {
|
|
142
|
+
let ws = tmp_workspace();
|
|
143
|
+
let err = cmd_allow_peer_talk(&AllowPeerTalkArgs {
|
|
144
|
+
a: "alpha".to_string(),
|
|
145
|
+
b: "bravo".to_string(),
|
|
146
|
+
workspace: ws.clone(),
|
|
147
|
+
json: true,
|
|
148
|
+
team: Some("current".to_string()),
|
|
149
|
+
})
|
|
150
|
+
.expect_err("explicit --team must not silently write the global peer allowlist");
|
|
151
|
+
assert!(
|
|
152
|
+
err.to_string().contains("not supported yet"),
|
|
153
|
+
"allow-peer-talk --team must be an explicit refusal until backend supports scoped writes; got {err}"
|
|
154
|
+
);
|
|
155
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
156
|
+
}
|
|
@@ -130,9 +130,54 @@ fn repair_args(
|
|
|
130
130
|
status: status.to_string(),
|
|
131
131
|
summary: summary.map(str::to_string),
|
|
132
132
|
json,
|
|
133
|
+
team: None,
|
|
133
134
|
}
|
|
134
135
|
}
|
|
135
136
|
|
|
137
|
+
fn team_repair_state(ws: &std::path::Path, team: &str) -> serde_json::Value {
|
|
138
|
+
json!({
|
|
139
|
+
"active_team_key": team,
|
|
140
|
+
"status": "alive",
|
|
141
|
+
"spec_path": ws.join(".team").join("runtime").join(team).join("team.spec.yaml").to_string_lossy(),
|
|
142
|
+
"session_name": format!("team-agent-{team}"),
|
|
143
|
+
"leader": {"id": "leader"},
|
|
144
|
+
"agents": {"fake_impl": {"status": "stopped", "provider": "fake"}},
|
|
145
|
+
"tasks": [{
|
|
146
|
+
"id": "task_impl",
|
|
147
|
+
"title": format!("{team} implementation"),
|
|
148
|
+
"type": "implementation",
|
|
149
|
+
"assignee": null,
|
|
150
|
+
"deps": [],
|
|
151
|
+
"acceptance": ["fake result collected"],
|
|
152
|
+
"status": "pending"
|
|
153
|
+
}]
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
fn seed_two_team_repair_workspace(ws: &std::path::Path) {
|
|
158
|
+
std::fs::create_dir_all(ws.join(".team").join("logs")).unwrap();
|
|
159
|
+
for team in ["alpha", "beta"] {
|
|
160
|
+
let spec_dir = ws.join(".team").join("runtime").join(team);
|
|
161
|
+
std::fs::create_dir_all(&spec_dir).unwrap();
|
|
162
|
+
std::fs::write(
|
|
163
|
+
spec_dir.join("team.spec.yaml"),
|
|
164
|
+
REPAIR_SPEC_TEMPLATE.replace("__WS__", &ws.to_string_lossy()),
|
|
165
|
+
)
|
|
166
|
+
.unwrap();
|
|
167
|
+
}
|
|
168
|
+
crate::state::persist::save_runtime_state(
|
|
169
|
+
ws,
|
|
170
|
+
&json!({
|
|
171
|
+
"active_team_key": "alpha",
|
|
172
|
+
"teams": {
|
|
173
|
+
"alpha": team_repair_state(ws, "alpha"),
|
|
174
|
+
"beta": team_repair_state(ws, "beta"),
|
|
175
|
+
}
|
|
176
|
+
}),
|
|
177
|
+
)
|
|
178
|
+
.unwrap();
|
|
179
|
+
}
|
|
180
|
+
|
|
136
181
|
// Golden source:
|
|
137
182
|
// - cli/parser.py:303-310 registers `repair-state --workspace --task --assignee --status --summary --json`.
|
|
138
183
|
// - cli/commands.py:196-203 delegates all five args to `runtime.repair_state`.
|
|
@@ -248,6 +293,33 @@ fn repair_state_rejects_status_outside_task_statuses() {
|
|
|
248
293
|
let _ = std::fs::remove_dir_all(&ws);
|
|
249
294
|
}
|
|
250
295
|
|
|
296
|
+
#[test]
|
|
297
|
+
fn repair_state_explicit_team_updates_selected_team_not_active_team() {
|
|
298
|
+
let ws = tmp_workspace();
|
|
299
|
+
seed_two_team_repair_workspace(&ws);
|
|
300
|
+
let mut args = repair_args(&ws, "done", Some("patched beta"), true);
|
|
301
|
+
args.team = Some("beta".to_string());
|
|
302
|
+
|
|
303
|
+
let cmd = cmd_repair_state(&args).expect("repair-state --team beta should update beta");
|
|
304
|
+
assert_eq!(cmd.exit, ExitCode::Ok);
|
|
305
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
306
|
+
assert_eq!(
|
|
307
|
+
state["teams"]["alpha"]["tasks"][0]["status"],
|
|
308
|
+
json!("pending"),
|
|
309
|
+
"repair-state --team beta must not mutate active/default alpha"
|
|
310
|
+
);
|
|
311
|
+
assert_eq!(
|
|
312
|
+
state["teams"]["beta"]["tasks"][0]["status"],
|
|
313
|
+
json!("done"),
|
|
314
|
+
"repair-state must consume args.team and update beta"
|
|
315
|
+
);
|
|
316
|
+
assert_eq!(
|
|
317
|
+
state["teams"]["beta"]["tasks"][0]["last_result_summary"],
|
|
318
|
+
json!("patched beta")
|
|
319
|
+
);
|
|
320
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
321
|
+
}
|
|
322
|
+
|
|
251
323
|
// CONTRACT (real-machine repair_state product FAIL): rt-host-d ran `repair-state --task rp1 --status done`
|
|
252
324
|
// in a quick-start workspace whose spec lives in a teamdir (state.spec_path -> <ws>/teamdir/team.spec.yaml),
|
|
253
325
|
// NOT at <ws>/team.spec.yaml. Rust cmd_repair_state calls load_team_spec(workspace) which HARDCODES
|
|
@@ -205,7 +205,6 @@ fn current_uid() -> Option<String> {
|
|
|
205
205
|
name: None,
|
|
206
206
|
team_id: None,
|
|
207
207
|
yes: true,
|
|
208
|
-
fresh: false,
|
|
209
208
|
no_display: false,
|
|
210
209
|
json: true,
|
|
211
210
|
};
|
|
@@ -240,7 +239,6 @@ fn current_uid() -> Option<String> {
|
|
|
240
239
|
name: None,
|
|
241
240
|
team_id: None,
|
|
242
241
|
yes: false,
|
|
243
|
-
fresh: false,
|
|
244
242
|
no_display: false,
|
|
245
243
|
json: true,
|
|
246
244
|
};
|
|
@@ -339,7 +337,7 @@ fn current_uid() -> Option<String> {
|
|
|
339
337
|
crate::coordinator::write_coordinator_metadata(&wp, me, crate::coordinator::MetadataSource::Boot).unwrap();
|
|
340
338
|
std::fs::write(crate::coordinator::coordinator_pid_path(&wp), me.to_string()).unwrap();
|
|
341
339
|
|
|
342
|
-
let args = StatusArgs { agent: None, workspace: ws, detail: true, summary: false, json: true };
|
|
340
|
+
let args = StatusArgs { agent: None, workspace: ws, detail: true, summary: false, json: true, team: None };
|
|
343
341
|
let r = cmd_status(&args).expect("cmd_status");
|
|
344
342
|
let value = match r.output {
|
|
345
343
|
CmdOutput::Json(v) => v,
|
|
@@ -86,7 +86,7 @@ impl CliError {
|
|
|
86
86
|
payload.session_name = Some(session.clone());
|
|
87
87
|
if command == "quick-start" {
|
|
88
88
|
payload.action = format!(
|
|
89
|
-
"tmux session `{session}` already exists. It may be your own existing team. To resume it use `team-agent restart`
|
|
89
|
+
"tmux session `{session}` already exists. It may be your own existing team. To resume it use `team-agent restart`. If recovery is impossible, use `team-agent restart --allow-fresh` only after explicit context-loss consent. Only if you want a separate team, change `name:` in TEAM.md and run quick-start again. Never terminate existing tmux sessions from quick-start."
|
|
90
90
|
);
|
|
91
91
|
payload.next_actions = Some(vec![
|
|
92
92
|
"If this is your existing team, resume it with `team-agent restart`.".to_string(),
|
|
@@ -270,9 +270,13 @@ pub struct QuickStartArgs {
|
|
|
270
270
|
pub name: Option<String>,
|
|
271
271
|
pub team_id: Option<String>,
|
|
272
272
|
pub yes: bool,
|
|
273
|
-
pub fresh: bool,
|
|
274
273
|
pub no_display: bool,
|
|
275
274
|
pub json: bool,
|
|
275
|
+
// Stage QR (quick-start/restart separation, design doc
|
|
276
|
+
// .team/artifacts/quickstart-restart-separation-design.md): the
|
|
277
|
+
// `fresh` field is intentionally REMOVED. Reset semantics now live
|
|
278
|
+
// exclusively on restart (`restart --allow-fresh`) and must be
|
|
279
|
+
// user-confirmed. quick-start is now an initial-creation-only verb.
|
|
276
280
|
}
|
|
277
281
|
|
|
278
282
|
/// `init`(`parser.py` bootstrap verb)。
|
|
@@ -355,6 +359,9 @@ pub struct AllowPeerTalkArgs {
|
|
|
355
359
|
pub b: String,
|
|
356
360
|
pub workspace: PathBuf,
|
|
357
361
|
pub json: bool,
|
|
362
|
+
/// Parsed but rejected until peer allowlist storage grows a team-scoped
|
|
363
|
+
/// backend column.
|
|
364
|
+
pub team: Option<String>,
|
|
358
365
|
}
|
|
359
366
|
|
|
360
367
|
/// `status`(`parser.py:182`)。`--summary`/`--json`/`--detail` 三态(summary xor json,见 `cmd_status`)。
|
|
@@ -365,6 +372,9 @@ pub struct StatusArgs {
|
|
|
365
372
|
pub detail: bool,
|
|
366
373
|
pub summary: bool,
|
|
367
374
|
pub json: bool,
|
|
375
|
+
/// Stage 4: explicit `--team` scope (read-only command; multi-team
|
|
376
|
+
/// workspaces use this for filtering, not for refusal).
|
|
377
|
+
pub team: Option<String>,
|
|
368
378
|
}
|
|
369
379
|
|
|
370
380
|
/// `watch`(`parser.py:190`)。无 `--json`(纯 stream 到 SystemExit)。
|
|
@@ -380,6 +390,8 @@ pub struct ApprovalsArgs {
|
|
|
380
390
|
pub agent: Option<String>,
|
|
381
391
|
pub workspace: PathBuf,
|
|
382
392
|
pub json: bool,
|
|
393
|
+
/// Stage 4: explicit `--team` scope.
|
|
394
|
+
pub team: Option<String>,
|
|
383
395
|
}
|
|
384
396
|
|
|
385
397
|
/// `inbox`(`parser.py:217`)。`--since` ISO8601(claim-leader inbox_hint 复用)。
|
|
@@ -390,6 +402,8 @@ pub struct InboxArgs {
|
|
|
390
402
|
pub limit: usize,
|
|
391
403
|
pub since: Option<String>,
|
|
392
404
|
pub json: bool,
|
|
405
|
+
/// Stage 4: explicit `--team` scope.
|
|
406
|
+
pub team: Option<String>,
|
|
393
407
|
}
|
|
394
408
|
|
|
395
409
|
/// `takeover`(`parser.py:242`)。`--confirm` 必需(覆写 recorded team_owner)。
|
|
@@ -521,6 +535,8 @@ pub struct RemoveAgentArgs {
|
|
|
521
535
|
pub struct StuckListArgs {
|
|
522
536
|
pub workspace: PathBuf,
|
|
523
537
|
pub json: bool,
|
|
538
|
+
/// Stage 4: explicit `--team` scope (read-only enumeration).
|
|
539
|
+
pub team: Option<String>,
|
|
524
540
|
}
|
|
525
541
|
|
|
526
542
|
/// `stuck-cancel`(`parser.py:429`)。`--alert-type` ∈ {stuck, idle_fallback, all},默认 stuck。
|
|
@@ -531,6 +547,9 @@ pub struct StuckCancelArgs {
|
|
|
531
547
|
/// `None` 表 `all`(展开全集);`Some(AlertType)` 表单类型。
|
|
532
548
|
pub alert_type: Option<AlertType>,
|
|
533
549
|
pub json: bool,
|
|
550
|
+
/// Parsed but rejected until stuck suppression storage grows a team-scoped
|
|
551
|
+
/// backend selector.
|
|
552
|
+
pub team: Option<String>,
|
|
534
553
|
}
|
|
535
554
|
|
|
536
555
|
/// `acknowledge-idle`(`parser.py:436`)。
|
|
@@ -573,6 +592,8 @@ pub enum DoctorGate {
|
|
|
573
592
|
pub struct SessionsArgs {
|
|
574
593
|
pub workspace: PathBuf,
|
|
575
594
|
pub json: bool,
|
|
595
|
+
/// Stage 4: explicit `--team` scope (read-only enumeration).
|
|
596
|
+
pub team: Option<String>,
|
|
576
597
|
}
|
|
577
598
|
|
|
578
599
|
/// `validate [spec=team.spec.yaml] --json`(`parser.py:120`)。
|
|
@@ -608,6 +629,10 @@ pub struct CollectArgs {
|
|
|
608
629
|
pub workspace: PathBuf,
|
|
609
630
|
pub result_file: Option<PathBuf>,
|
|
610
631
|
pub json: bool,
|
|
632
|
+
/// Stage 4: explicit `--team` scope. Destructive (consumes result
|
|
633
|
+
/// envelopes); CLI dispatch refuses bare invocation in a
|
|
634
|
+
/// multi-alive-team workspace.
|
|
635
|
+
pub team: Option<String>,
|
|
611
636
|
}
|
|
612
637
|
|
|
613
638
|
/// `settle`(`parser.py:177`)。
|
|
@@ -631,6 +656,10 @@ pub struct RepairStateArgs {
|
|
|
631
656
|
pub status: String,
|
|
632
657
|
pub summary: Option<String>,
|
|
633
658
|
pub json: bool,
|
|
659
|
+
/// Stage 4: explicit `--team` scope. Destructive (rewrites task state);
|
|
660
|
+
/// CLI dispatch refuses bare invocation in a multi-alive-team
|
|
661
|
+
/// workspace.
|
|
662
|
+
pub team: Option<String>,
|
|
634
663
|
}
|
|
635
664
|
|
|
636
665
|
/// `diagnose`(`parser.py:298`) runtime health report, distinct from `doctor`.
|
|
@@ -638,6 +667,8 @@ pub struct RepairStateArgs {
|
|
|
638
667
|
pub struct DiagnoseArgs {
|
|
639
668
|
pub workspace: PathBuf,
|
|
640
669
|
pub json: bool,
|
|
670
|
+
/// Stage 4: explicit `--team` scope (read-only health report).
|
|
671
|
+
pub team: Option<String>,
|
|
641
672
|
}
|
|
642
673
|
|
|
643
674
|
/// `preflight`(`parser.py:160`)。
|
|
@@ -653,6 +684,8 @@ pub struct WaitReadyArgs {
|
|
|
653
684
|
pub workspace: PathBuf,
|
|
654
685
|
pub timeout: f64,
|
|
655
686
|
pub json: bool,
|
|
687
|
+
/// Stage 4: explicit `--team` scope (read-only readiness probe).
|
|
688
|
+
pub team: Option<String>,
|
|
656
689
|
}
|
|
657
690
|
|
|
658
691
|
/// `e2e`(`parser.py:449`)。
|
|
@@ -216,8 +216,13 @@ fn detect_leader_api_error(
|
|
|
216
216
|
.and_then(Value::as_str)
|
|
217
217
|
.map(ToString::to_string)
|
|
218
218
|
});
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
// Stage 3a (identity-boundary unified plan, architect direction
|
|
220
|
+
// 2026-06-23): route owner read through the ownership repository.
|
|
221
|
+
// The coordinator state here is the workspace runtime state — Stage 5
|
|
222
|
+
// will swap to per-team state and this read follows automatically.
|
|
223
|
+
// capture.leader_receiver fallback is kept as evidence, not owner truth
|
|
224
|
+
// (architect §4 cross-module risk: capture fallback is evidence-only).
|
|
225
|
+
let leader_session_uuid = crate::state::ownership::read_owner_value(state, "")
|
|
221
226
|
.and_then(|owner| owner.get("leader_session_uuid"))
|
|
222
227
|
.or_else(|| {
|
|
223
228
|
capture
|
|
@@ -2629,6 +2629,33 @@ fn agent_rollout_path(agent: &Value) -> Option<PathBuf> {
|
|
|
2629
2629
|
.map(PathBuf::from)
|
|
2630
2630
|
}
|
|
2631
2631
|
|
|
2632
|
+
/// Memory-growth fix (architect §5): bounded tail cap for JSONL activity reads.
|
|
2633
|
+
/// Matches `ABNORMAL_TAIL_BYTES` (131_072 bytes) — the abnormal-exit path
|
|
2634
|
+
/// already proved this size is sufficient to capture the latest lifecycle
|
|
2635
|
+
/// records across all providers (claude / codex / copilot).
|
|
2636
|
+
const JSONL_ACTIVITY_TAIL_BYTES: u64 = 131_072;
|
|
2637
|
+
|
|
2638
|
+
/// Memory-growth fix (architect §5): per-process `(path, size, mtime_ns) →
|
|
2639
|
+
/// activity` cache. When a rollout file hasn't changed since the previous
|
|
2640
|
+
/// tick, we skip the file read AND the classification entirely. This is the
|
|
2641
|
+
/// dominant savings: a 538MB Claude transcript that updates every few seconds
|
|
2642
|
+
/// is touched only when its size or mtime actually moves. Stored values are
|
|
2643
|
+
/// small (Option<AgentActivity> = enum + short rationale string); we never
|
|
2644
|
+
/// cache the transcript text or parsed JSON.
|
|
2645
|
+
struct JsonlActivityCacheEntry {
|
|
2646
|
+
size: u64,
|
|
2647
|
+
mtime_ns: u64,
|
|
2648
|
+
activity: Option<crate::messaging::AgentActivity>,
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
fn jsonl_activity_cache(
|
|
2652
|
+
) -> &'static std::sync::Mutex<std::collections::HashMap<PathBuf, JsonlActivityCacheEntry>> {
|
|
2653
|
+
static CACHE: std::sync::OnceLock<
|
|
2654
|
+
std::sync::Mutex<std::collections::HashMap<PathBuf, JsonlActivityCacheEntry>>,
|
|
2655
|
+
> = std::sync::OnceLock::new();
|
|
2656
|
+
CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2632
2659
|
/// E47 (0.3.24 P0, idle/busy 假阳): consult the authoritative provider JSONL
|
|
2633
2660
|
/// classifier and map to neutral `AgentActivity`. Returns `None` when the
|
|
2634
2661
|
/// classifier reports `TurnState::Unknown` (unreadable JSONL / no lifecycle
|
|
@@ -2638,29 +2665,72 @@ fn agent_rollout_path(agent: &Value) -> Option<PathBuf> {
|
|
|
2638
2665
|
/// so we hand off to the TUI scanner which has its OWN no-signal → Uncertain
|
|
2639
2666
|
/// path. Copilot/Gemini/Fake providers (which don't have JSONL — classify.rs
|
|
2640
2667
|
/// returns Unknown for them) thus keep using TUI scanning unchanged.
|
|
2668
|
+
///
|
|
2669
|
+
/// Memory-growth fix (architect analysis 2026-06-23): bounded tail read +
|
|
2670
|
+
/// metadata cache. Pre-fix `std::fs::read_to_string` on a 538MB Claude
|
|
2671
|
+
/// transcript every 5s caused 200MB+ coordinator RSS plateaus from allocator
|
|
2672
|
+
/// fragmentation. Now bounded to 128KiB tail and skipped entirely when
|
|
2673
|
+
/// (size, mtime_ns) is unchanged.
|
|
2641
2674
|
fn jsonl_activity_for_agent(agent: &Value) -> Option<crate::messaging::AgentActivity> {
|
|
2642
2675
|
let rollout_path = agent_rollout_path(agent)?;
|
|
2643
2676
|
let provider = agent
|
|
2644
2677
|
.get("provider")
|
|
2645
2678
|
.and_then(Value::as_str)
|
|
2646
2679
|
.and_then(parse_provider)?;
|
|
2647
|
-
|
|
2680
|
+
|
|
2681
|
+
// Metadata check + cache lookup. Cache hit when the rollout file has
|
|
2682
|
+
// not changed since the previous tick: return the cached classification
|
|
2683
|
+
// without re-reading the file. Truncation (size shrink with stable mtime)
|
|
2684
|
+
// still forces re-read because size is part of the cache key.
|
|
2685
|
+
let metadata = std::fs::metadata(&rollout_path).ok()?;
|
|
2686
|
+
let size = metadata.len();
|
|
2687
|
+
let mtime_ns = metadata_mtime_ns(&metadata)?;
|
|
2688
|
+
if let Ok(cache) = jsonl_activity_cache().lock() {
|
|
2689
|
+
if let Some(entry) = cache.get(&rollout_path) {
|
|
2690
|
+
if entry.size == size && entry.mtime_ns == mtime_ns {
|
|
2691
|
+
return entry.activity.clone();
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
// Cache miss: bounded tail read + classify. The classifier only needs the
|
|
2697
|
+
// latest lifecycle records to determine idle/working state; the
|
|
2698
|
+
// abnormal-exit path uses the same 128KiB tail and is sufficient for
|
|
2699
|
+
// claude / codex / copilot lifecycle markers.
|
|
2700
|
+
let log_text = read_tail_text(&rollout_path, JSONL_ACTIVITY_TAIL_BYTES).ok()?;
|
|
2648
2701
|
let process = explicit_process_liveness(agent).unwrap_or(ProcessLiveness::Unverifiable);
|
|
2649
|
-
let
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2702
|
+
let activity = crate::provider::classify(provider, &log_text, process, 0.0)
|
|
2703
|
+
.ok()
|
|
2704
|
+
.and_then(|result| {
|
|
2705
|
+
use crate::messaging::{ActivityStatus, AgentActivity};
|
|
2706
|
+
use crate::provider::types::TurnState;
|
|
2707
|
+
let status = match result.state {
|
|
2708
|
+
TurnState::Idle => ActivityStatus::Idle,
|
|
2709
|
+
TurnState::IdleInterrupted => ActivityStatus::Idle,
|
|
2710
|
+
TurnState::Working => ActivityStatus::Working,
|
|
2711
|
+
TurnState::BlockedOnHuman | TurnState::Abnormal => ActivityStatus::Uncertain,
|
|
2712
|
+
TurnState::Unknown => return None,
|
|
2713
|
+
};
|
|
2714
|
+
Some(AgentActivity {
|
|
2715
|
+
status,
|
|
2716
|
+
confidence: 0.95,
|
|
2717
|
+
rationale: format!("provider_jsonl:{}", result.reason),
|
|
2718
|
+
})
|
|
2719
|
+
});
|
|
2720
|
+
|
|
2721
|
+
// Store the classification (including None / Unknown) so the next tick
|
|
2722
|
+
// can short-circuit when the file is unchanged.
|
|
2723
|
+
if let Ok(mut cache) = jsonl_activity_cache().lock() {
|
|
2724
|
+
cache.insert(
|
|
2725
|
+
rollout_path,
|
|
2726
|
+
JsonlActivityCacheEntry {
|
|
2727
|
+
size,
|
|
2728
|
+
mtime_ns,
|
|
2729
|
+
activity: activity.clone(),
|
|
2730
|
+
},
|
|
2731
|
+
);
|
|
2732
|
+
}
|
|
2733
|
+
activity
|
|
2664
2734
|
}
|
|
2665
2735
|
|
|
2666
2736
|
fn runtime_approval_target(
|
|
@@ -82,11 +82,19 @@ fn receiver_binding_snapshot(workspace: &Path, team: Option<&TeamKey>) -> Value
|
|
|
82
82
|
};
|
|
83
83
|
let state = selected.state;
|
|
84
84
|
let receiver = state.get("leader_receiver").and_then(Value::as_object);
|
|
85
|
+
// Stage 2 (identity-boundary unified plan, architect direction
|
|
86
|
+
// 2026-06-23): route owner pane lookup through the ownership repository.
|
|
87
|
+
// `selected.state` is already team-projected by the selector, so the
|
|
88
|
+
// empty-team-key path returns the same top-level owner the legacy direct
|
|
89
|
+
// read produced — but Stage 5 will swap the data source under the
|
|
90
|
+
// repository and this diagnose surface follows automatically. The
|
|
91
|
+
// `owner` key fallback is retained for callers that pre-date the
|
|
92
|
+
// `team_owner` rename.
|
|
85
93
|
let owner_pane_id = state
|
|
86
94
|
.get("owner")
|
|
87
|
-
.or_else(|| state.get("team_owner"))
|
|
88
|
-
.and_then(|v| v.get("pane_id"))
|
|
89
95
|
.cloned()
|
|
96
|
+
.or_else(|| crate::state::ownership::read_owner_value(&state, "").cloned())
|
|
97
|
+
.and_then(|v| v.get("pane_id").cloned())
|
|
90
98
|
.unwrap_or(Value::Null);
|
|
91
99
|
let caller_pane_id = std::env::var("TMUX_PANE").ok().map(Value::String).unwrap_or(Value::Null);
|
|
92
100
|
let pane_id = receiver
|