@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.
Files changed (45) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +82 -22
  4. package/crates/team-agent/src/cli/diagnose.rs +6 -2
  5. package/crates/team-agent/src/cli/emit.rs +301 -38
  6. package/crates/team-agent/src/cli/mod.rs +7 -14
  7. package/crates/team-agent/src/cli/status_port.rs +12 -1
  8. package/crates/team-agent/src/cli/tests/base.rs +6 -2
  9. package/crates/team-agent/src/cli/tests/lane_c.rs +1 -1
  10. package/crates/team-agent/src/cli/tests/leader_watch.rs +5 -3
  11. package/crates/team-agent/src/cli/tests/main_preserved.rs +28 -2
  12. package/crates/team-agent/src/cli/tests/peer_allow.rs +19 -0
  13. package/crates/team-agent/src/cli/tests/repair_state_byte_lock.rs +72 -0
  14. package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -3
  15. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  16. package/crates/team-agent/src/cli/types.rs +35 -2
  17. package/crates/team-agent/src/coordinator/runtime_detectors.rs +7 -2
  18. package/crates/team-agent/src/coordinator/tick.rs +86 -16
  19. package/crates/team-agent/src/diagnose/comms.rs +10 -2
  20. package/crates/team-agent/src/leader/lease.rs +98 -19
  21. package/crates/team-agent/src/leader/rediscover/tests.rs +21 -7
  22. package/crates/team-agent/src/leader/rediscover.rs +16 -7
  23. package/crates/team-agent/src/leader/start.rs +25 -12
  24. package/crates/team-agent/src/leader/tests/byte_findings.rs +11 -2
  25. package/crates/team-agent/src/leader/tests/lease_claim.rs +248 -7
  26. package/crates/team-agent/src/lifecycle/launch.rs +116 -100
  27. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +5 -12
  28. package/crates/team-agent/src/lifecycle/tests/core.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +16 -15
  30. package/crates/team-agent/src/lifecycle/types.rs +1 -1
  31. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -8
  32. package/crates/team-agent/src/messaging/send.rs +21 -4
  33. package/crates/team-agent/src/provider/adapter.rs +22 -0
  34. package/crates/team-agent/src/provider/session/capture.rs +228 -0
  35. package/crates/team-agent/src/state/identity.rs +42 -12
  36. package/crates/team-agent/src/state/identity_keys.rs +134 -0
  37. package/crates/team-agent/src/state/mod.rs +8 -0
  38. package/crates/team-agent/src/state/owner_gate.rs +11 -1
  39. package/crates/team-agent/src/state/ownership.rs +556 -0
  40. package/crates/team-agent/src/state/paths.rs +358 -0
  41. package/crates/team-agent/src/state/persist.rs +43 -5
  42. package/crates/team-agent/src/state/projection.rs +13 -5
  43. package/crates/team-agent/src/tmux_backend.rs +12 -0
  44. package/package.json +4 -4
  45. 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
- assert_eq!(state["leader_receiver"]["pane_id"], json!("%5"));
171
- assert_eq!(state["team_owner"]["pane_id"], json!("%5"));
172
- assert_eq!(state["team_owner"]["owner_epoch"], json!(3));
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,
@@ -268,6 +268,7 @@ use super::*;
268
268
  detail: false,
269
269
  summary: false,
270
270
  json: false,
271
+ team: None,
271
272
  };
272
273
 
273
274
  let r = cmd_status_for_team(&args, None).expect("status");
@@ -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` (NOT --fresh, which discards context). 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."
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
- let leader_session_uuid = state
220
- .get("team_owner")
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
- let log_text = std::fs::read_to_string(&rollout_path).ok()?;
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 result = crate::provider::classify(provider, &log_text, process, 0.0).ok()?;
2650
- use crate::messaging::{ActivityStatus, AgentActivity};
2651
- use crate::provider::types::TurnState;
2652
- let status = match result.state {
2653
- TurnState::Idle => ActivityStatus::Idle,
2654
- TurnState::IdleInterrupted => ActivityStatus::Idle,
2655
- TurnState::Working => ActivityStatus::Working,
2656
- TurnState::BlockedOnHuman | TurnState::Abnormal => ActivityStatus::Uncertain,
2657
- TurnState::Unknown => return None,
2658
- };
2659
- Some(AgentActivity {
2660
- status,
2661
- confidence: 0.95,
2662
- rationale: format!("provider_jsonl:{}", result.reason),
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