@team-agent/installer 0.4.1 → 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/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
|
|
@@ -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
|
|
@@ -1031,6 +1031,17 @@ fn scan_agents_for_pane(agents: Option<&Value>, caller_pane: &PaneId) -> Option<
|
|
|
1031
1031
|
None
|
|
1032
1032
|
}
|
|
1033
1033
|
|
|
1034
|
+
/// Stage 3a (identity-boundary unified plan, architect direction 2026-06-23)
|
|
1035
|
+
/// + Stage 3 owner persist fix (architect direction 2026-06-24): route the
|
|
1036
|
+
/// in-memory binding write through the `state::ownership` API. The
|
|
1037
|
+
/// canonical team_key is derived from `active_team_key` first (the explicit
|
|
1038
|
+
/// scope set by `project_top_level_view` when an explicit team was passed
|
|
1039
|
+
/// to claim-leader) and only falls back to `team_state_key(state)` for the
|
|
1040
|
+
/// legacy unscoped flow. Without the `active_team_key` preference, an
|
|
1041
|
+
/// explicit `claim-leader --team alpha` whose projected state was emptied
|
|
1042
|
+
/// of team_dir/session_name would derive "current" and write the owner to
|
|
1043
|
+
/// `teams.current`, while `save_claim_team_scoped_state` expected
|
|
1044
|
+
/// `teams.alpha` — the S3-OWNER-001 persistence loss shape.
|
|
1034
1045
|
fn write_binding_to_state(
|
|
1035
1046
|
state: &mut Value,
|
|
1036
1047
|
receiver: &LeaderReceiver,
|
|
@@ -1039,12 +1050,15 @@ fn write_binding_to_state(
|
|
|
1039
1050
|
if !state.is_object() {
|
|
1040
1051
|
*state = json!({});
|
|
1041
1052
|
}
|
|
1042
|
-
|
|
1053
|
+
if !state.is_object() {
|
|
1043
1054
|
return Err(LeaderError::Validation("state root is not an object".to_string()));
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1055
|
+
}
|
|
1056
|
+
let team_key = canonical_owner_write_key(state);
|
|
1057
|
+
let record = crate::state::ownership::OwnershipWrite::new()
|
|
1058
|
+
.with_leader_receiver(serde_json::to_value(receiver)?)
|
|
1059
|
+
.with_team_owner(serde_json::to_value(owner)?)
|
|
1060
|
+
.with_owner_epoch(owner.owner_epoch.0);
|
|
1061
|
+
crate::state::ownership::write_owner(state, &team_key, record);
|
|
1048
1062
|
Ok(())
|
|
1049
1063
|
}
|
|
1050
1064
|
|
|
@@ -1055,13 +1069,34 @@ fn write_receiver_to_state(
|
|
|
1055
1069
|
if !state.is_object() {
|
|
1056
1070
|
*state = json!({});
|
|
1057
1071
|
}
|
|
1058
|
-
|
|
1072
|
+
if !state.is_object() {
|
|
1059
1073
|
return Err(LeaderError::Validation("state root is not an object".to_string()));
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1074
|
+
}
|
|
1075
|
+
let team_key = canonical_owner_write_key(state);
|
|
1076
|
+
let record = crate::state::ownership::OwnershipWrite::new()
|
|
1077
|
+
.with_leader_receiver(serde_json::to_value(receiver)?);
|
|
1078
|
+
crate::state::ownership::write_owner(state, &team_key, record);
|
|
1062
1079
|
Ok(())
|
|
1063
1080
|
}
|
|
1064
1081
|
|
|
1082
|
+
/// Stage 3 owner persist fix (architect direction 2026-06-24): determine
|
|
1083
|
+
/// the canonical team_key for an in-memory ownership write. When the state
|
|
1084
|
+
/// carries an `active_team_key` (set by `project_top_level_view` whenever
|
|
1085
|
+
/// an explicit team was scoped into the claim path), trust it — that's the
|
|
1086
|
+
/// requested team that `save_claim_team_scoped_state` will read back. Only
|
|
1087
|
+
/// fall back to `team_state_key(state)` for the legacy unscoped flow where
|
|
1088
|
+
/// the writer must derive team identity from session/dir/spec_path fields.
|
|
1089
|
+
fn canonical_owner_write_key(state: &Value) -> String {
|
|
1090
|
+
if let Some(active) = state
|
|
1091
|
+
.get("active_team_key")
|
|
1092
|
+
.and_then(Value::as_str)
|
|
1093
|
+
.filter(|key| !key.is_empty())
|
|
1094
|
+
{
|
|
1095
|
+
return active.to_string();
|
|
1096
|
+
}
|
|
1097
|
+
crate::state::projection::team_state_key(state)
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1065
1100
|
fn state_receiver(state: &Value) -> Option<LeaderReceiver> {
|
|
1066
1101
|
state
|
|
1067
1102
|
.get("leader_receiver")
|
|
@@ -1139,9 +1174,16 @@ fn save_claim_team_scoped_state(workspace: &Path, state: &Value, target_key: &st
|
|
|
1139
1174
|
.entry(existing_key)
|
|
1140
1175
|
.or_insert_with(|| crate::state::projection::compact_team_state(&existing));
|
|
1141
1176
|
}
|
|
1177
|
+
// Stage 3 owner persist fix (architect direction 2026-06-24,
|
|
1178
|
+
// .team/artifacts/stage3-owner-persist-fix.md): after Stage 3d removed
|
|
1179
|
+
// top-level dual-write, the canonical ownership record lives ONLY at
|
|
1180
|
+
// `state.teams[target_key].{team_owner, leader_receiver, owner_epoch}`.
|
|
1181
|
+
// `compact_team_state(state)` strips the `teams` key entirely — that
|
|
1182
|
+
// dropped the just-written nested ownership record on disk. Use the
|
|
1183
|
+
// preserving helper so the canonical fields survive the compaction.
|
|
1142
1184
|
teams.insert(
|
|
1143
1185
|
target_key.to_string(),
|
|
1144
|
-
|
|
1186
|
+
compact_team_state_preserving_ownership(state, target_key),
|
|
1145
1187
|
);
|
|
1146
1188
|
let existing_primary_key = existing
|
|
1147
1189
|
.get("session_name")
|
|
@@ -1154,16 +1196,18 @@ fn save_claim_team_scoped_state(workspace: &Path, state: &Value, target_key: &st
|
|
|
1154
1196
|
} else {
|
|
1155
1197
|
value_object(&existing)
|
|
1156
1198
|
};
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1199
|
+
// Stage 3 top-level cleanup (architect direction 2026-06-24,
|
|
1200
|
+
// .team/artifacts/stage3-toplevel-cleanup-fix.md + stage3-save-strip-fix.md):
|
|
1201
|
+
// the legacy top-level owner copy loop is gone; the canonical-aware
|
|
1202
|
+
// strip is now performed at the persistence boundary
|
|
1203
|
+
// (`state::persist::save_runtime_state_with_merge_exceptions`), which
|
|
1204
|
+
// every save_runtime_state* path funnels through. This per-call-site
|
|
1205
|
+
// strip call has been removed — the save output handles cleanup
|
|
1206
|
+
// uniformly for claim / restart / shutdown / start-agent / stop-agent
|
|
1207
|
+
// / coordinator tick / promote sibling. `had_existing_teams` /
|
|
1208
|
+
// primary/active key computations are retained as dead writes only
|
|
1209
|
+
// via _ binding — they no longer gate any owner promote.
|
|
1210
|
+
let _ = (had_existing_teams, existing_primary_key, existing_active_key);
|
|
1167
1211
|
merged.insert("teams".to_string(), Value::Object(teams));
|
|
1168
1212
|
crate::state::persist::save_runtime_state(workspace, &Value::Object(merged))?;
|
|
1169
1213
|
Ok(())
|
|
@@ -1173,6 +1217,41 @@ fn value_object(value: &Value) -> serde_json::Map<String, Value> {
|
|
|
1173
1217
|
value.as_object().cloned().unwrap_or_default()
|
|
1174
1218
|
}
|
|
1175
1219
|
|
|
1220
|
+
/// Stage 3 owner persist fix (architect direction 2026-06-24,
|
|
1221
|
+
/// .team/artifacts/stage3-owner-persist-fix.md): build the per-team
|
|
1222
|
+
/// snapshot used by `save_claim_team_scoped_state` so the canonical
|
|
1223
|
+
/// `teams.<target_key>.{team_owner, leader_receiver, owner_epoch}` record
|
|
1224
|
+
/// survives the `compact_team_state` strip. Without this helper the
|
|
1225
|
+
/// claim-leader save loses the just-written nested ownership record (the
|
|
1226
|
+
/// S3-OWNER-001 evidence shape: `persisted_owner_locations=[]`).
|
|
1227
|
+
///
|
|
1228
|
+
/// Behaviour:
|
|
1229
|
+
/// - Start from the existing `compact_team_state(state)` (strips `teams`).
|
|
1230
|
+
/// - Look up `state.teams[target_key].{team_owner, leader_receiver, owner_epoch}`
|
|
1231
|
+
/// and copy each present field into the compacted entry.
|
|
1232
|
+
/// - Top-level owner fields stay UNTOUCHED — Stage 3d's canonical-only
|
|
1233
|
+
/// shape is preserved on disk.
|
|
1234
|
+
fn compact_team_state_preserving_ownership(state: &Value, target_key: &str) -> Value {
|
|
1235
|
+
let mut entry = crate::state::projection::compact_team_state(state);
|
|
1236
|
+
let Some(entry_obj) = entry.as_object_mut() else {
|
|
1237
|
+
return entry;
|
|
1238
|
+
};
|
|
1239
|
+
let Some(owner_obj) = state
|
|
1240
|
+
.get("teams")
|
|
1241
|
+
.and_then(Value::as_object)
|
|
1242
|
+
.and_then(|teams| teams.get(target_key))
|
|
1243
|
+
.and_then(Value::as_object)
|
|
1244
|
+
else {
|
|
1245
|
+
return entry;
|
|
1246
|
+
};
|
|
1247
|
+
for key in ["team_owner", "leader_receiver", "owner_epoch"] {
|
|
1248
|
+
if let Some(value) = owner_obj.get(key) {
|
|
1249
|
+
entry_obj.insert(key.to_string(), value.clone());
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
entry
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1176
1255
|
/// `_detect_dual_state_divergence`(card §85 C18;`__init__.py:556`)。workspace-level 与
|
|
1177
1256
|
/// team-level snapshot 在 owner_uuid/receiver_pane_id/owner_epoch 上是否分叉 → `Some(详情)`。
|
|
1178
1257
|
/// **CROSS-LANE**:snapshot 读经 step 13 restart。
|