@team-agent/installer 0.4.7 → 0.4.8

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 CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.4.7"
569
+ version = "0.4.8"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.4.7"
12
+ version = "0.4.8"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -226,6 +226,33 @@ pub fn cmd_status_for_team(args: &StatusArgs, team: Option<&str>) -> Result<CmdR
226
226
  "status --summary does not accept an agent argument".to_string(),
227
227
  ));
228
228
  }
229
+ // S4QR-001 (0.4.8): selected-team ambiguity gate. status is a selected-team
230
+ // command: when the workspace has 2+ alive teams and no --team was passed,
231
+ // refuse instead of silently defaulting to the active team. Uses the same
232
+ // CommandScope::resolve helper as refuse_if_multi_alive_team_missing_scope
233
+ // (cli/emit.rs:964-979) so the destructive-command ambiguity gate and the
234
+ // selected-team-read ambiguity gate share a single source of truth.
235
+ if team.is_none() {
236
+ let scope = crate::state::paths::CommandScope::resolve(&args.workspace, None);
237
+ if scope.is_ambiguous() {
238
+ let candidates: Vec<String> = scope.candidates().to_vec();
239
+ let message = format!(
240
+ "status: workspace has multiple alive teams ({}); pass `--team <key>` to choose one",
241
+ candidates.join(", ")
242
+ );
243
+ if args.json {
244
+ let payload = serde_json::json!({
245
+ "ok": false,
246
+ "status": "refused",
247
+ "reason": "team_target_ambiguous",
248
+ "candidates": candidates,
249
+ "message": message,
250
+ });
251
+ return Ok(CmdResult::from_json(payload, args.json));
252
+ }
253
+ return Err(CliError::Usage(message));
254
+ }
255
+ }
229
256
  let selected = match crate::state::selector::resolve_active_team(
230
257
  &args.workspace,
231
258
  team,
@@ -419,6 +419,30 @@ fn provider_env_unsets(provider: Provider, auth_mode: AuthMode) -> BTreeSet<Stri
419
419
  let mut unsets = BTreeSet::new();
420
420
  match provider {
421
421
  Provider::Claude | Provider::ClaudeCode => {
422
+ // E57 (0.3.27 P0): unset CLAUDE_CODE_SESSION_ID inherited from the
423
+ // leader's environment. When a Claude leader spawns a Claude worker,
424
+ // the worker inherits the leader's CLAUDE_CODE_SESSION_ID; Claude
425
+ // Code then routes the worker's transcript to the leader's session
426
+ // file (~/.claude/projects/<hash>/<leader-session-uuid>.jsonl),
427
+ // corrupting attribution and transcripts. Always unset so Claude
428
+ // Code generates a fresh session id per worker.
429
+ //
430
+ // S1-CAPTURE (0.4.x P0): also unset the rest of the Claude Code
431
+ // child/team env block. These variables make the spawned process
432
+ // believe it is a CHILD of the parent Claude (CLAUDE_CODE_CHILD_SESSION),
433
+ // a TEAM member (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS), or otherwise
434
+ // inherit the parent's invocation identity (ENTRYPOINT / EXECPATH /
435
+ // CLAUDECODE marker). Without unsetting these, a Claude worker
436
+ // spawned from a Claude leader is not an INDEPENDENT process — it
437
+ // joins the leader's session tree and its transcript / state /
438
+ // billing are attributed to the leader. CLAUDE_EFFORT is preserved
439
+ // (effort preference does not affect session attribution).
440
+ unsets.insert("CLAUDE_CODE_SESSION_ID".to_string());
441
+ unsets.insert("CLAUDE_CODE_CHILD_SESSION".to_string());
442
+ unsets.insert("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS".to_string());
443
+ unsets.insert("CLAUDE_CODE_ENTRYPOINT".to_string());
444
+ unsets.insert("CLAUDE_CODE_EXECPATH".to_string());
445
+ unsets.insert("CLAUDECODE".to_string());
422
446
  if auth_mode == AuthMode::CompatibleApi {
423
447
  unsets.insert("ANTHROPIC_API_KEY".to_string());
424
448
  }
@@ -208,7 +208,7 @@ pub(crate) fn start_agent_at_paths(
208
208
  None,
209
209
  Some(resolved_team_key.as_str()),
210
210
  )?;
211
- mark_agent_started(&mut state, agent_id, &spawn_window, &spawn, transport, &safety)?;
211
+ mark_agent_started(&mut state, agent_id, &spawn_window, &spawn, transport, &safety, start_mode)?;
212
212
  // **0.3.24 add-agent socket drift fix**: keep `state.tmux_endpoint` /
213
213
  // `state.tmux_socket` synchronized with the transport actually used for the
214
214
  // spawn. Without this, add-agent / fork-agent could spawn to a socket that
@@ -700,6 +700,7 @@ fn mark_agent_started(
700
700
  spawn: &SpawnedAgentWindow,
701
701
  transport: &dyn crate::transport::Transport,
702
702
  safety: &DangerousApproval,
703
+ start_mode: StartMode,
703
704
  ) -> Result<(), LifecycleError> {
704
705
  let Some(agent) = state
705
706
  .get_mut("agents")
@@ -712,6 +713,31 @@ fn mark_agent_started(
712
713
  agent_id
713
714
  )));
714
715
  };
716
+ // S1-CAPTURE-001 (0.4.8, CR M3 provider-agnostic): on a Fresh /
717
+ // FreshAfterMissingRollout start, the prior session's authoritative
718
+ // capture tuple MUST be cleared before persist_command_plan_state
719
+ // writes the new _pending_session_id. Otherwise old session_id +
720
+ // rollout_path coexist with new _pending_session_id and
721
+ // agent_session_complete returns true on the stale tuple — capture
722
+ // never re-binds to the new process, and any delivered token lands
723
+ // in the old transcript (the leader/unassigned mis-attribution seen
724
+ // in the gate evidence). This applies to all providers that resume:
725
+ // codex, claude, copilot. Reset_agent --discard-session already does
726
+ // this at common.rs:1144-1188; here we mirror it for start-agent /
727
+ // restart-agent fresh paths so the fresh-tuple invariant is global.
728
+ if matches!(start_mode, StartMode::Fresh | StartMode::FreshAfterMissingRollout) {
729
+ for field in [
730
+ "session_id",
731
+ "rollout_path",
732
+ "captured_at",
733
+ "captured_via",
734
+ "attribution_confidence",
735
+ "capture_state",
736
+ "attribution_ambiguous",
737
+ ] {
738
+ agent.remove(field);
739
+ }
740
+ }
715
741
  agent.insert("status".to_string(), serde_json::json!("running"));
716
742
  agent.insert("agent_id".to_string(), serde_json::json!(agent_id.as_str()));
717
743
  agent.insert("window".to_string(), serde_json::json!(window));
@@ -895,10 +895,25 @@ pub(crate) fn restart_required_missing_session_agent_ids(state: &serde_json::Val
895
895
  .get("status")
896
896
  .and_then(|value| value.as_str())
897
897
  .is_some_and(|status| status == "running");
898
- // E6 层2 (C2): required-missing 谓词只看 session_id 有无 + 是否在跑。
899
- // pane 绑定 / first_send_at gate 时刻天然可空(自启动 worker leader 从未发消息),
900
- // 不能作判据 —— 否则真丢上下文的 null-session worker 被漏判,走静默 fresh。
901
- missing_session_id && is_running
898
+ // E6 层2 (C2) + RESTART-RESUME-001 (0.4.8): required-missing
899
+ // predicate gates on session_id absence + running, but ALSO
900
+ // skips never-captured workers (no session_id AND no context
901
+ // signals at all). A never-captured worker has nothing to
902
+ // lose by fresh-start, so it must not block convergence and
903
+ // burn the capture deadline. This matches the selection-stage
904
+ // partial-resume semantic in
905
+ // selection.rs::classify_resume_decision (never_captured →
906
+ // FreshStart without --allow-fresh).
907
+ //
908
+ // The "has context to preserve" signal is the shared
909
+ // restart_agent_has_context_to_preserve helper: first_send_at
910
+ // (leader→worker delivery), last_result_at (MCP report path
911
+ // that may skip first_send_at), or task_prompt_delivered.
912
+ // Only context-bearing null-session workers continue to
913
+ // require convergence (so we never silently drop context).
914
+ missing_session_id
915
+ && is_running
916
+ && !super::selection::restart_agent_never_captured(agent, None)
902
917
  })
903
918
  .collect::<Vec<_>>();
904
919
  missing.sort();
@@ -1361,11 +1361,29 @@ fn mark_agent_respawned(
1361
1361
  // Claude family clear path uniformly. The Copilot scanner
1362
1362
  // (provider/adapter.rs:1217-1228) already gates expected-id with a
1363
1363
  // sqlite point-check, so no truth is lost.
1364
+ // S1-CAPTURE-001 (0.4.8): on Fresh / FreshAfterMissingRollout, clear the
1365
+ // FULL prior-session authoritative capture tuple — not just session_id.
1366
+ // Mirrors mark_agent_started (restart/agent.rs:728-740) so the rebuild path
1367
+ // (multi-agent restart) and the single-agent start path enforce the same
1368
+ // fresh-tuple invariant. Without this, save_restart_state's persist
1369
+ // backfill can revive the stale rollout_path/captured_at/capture_state
1370
+ // tuple from latest, defeating the fresh-tuple guarantee and leaving
1371
+ // delivered tokens in the old transcript (leader/unassigned mis-attrib).
1364
1372
  if matches!(
1365
1373
  restart_mode,
1366
1374
  StartMode::Fresh | StartMode::FreshAfterMissingRollout
1367
1375
  ) {
1368
- agent.insert("session_id".to_string(), serde_json::Value::Null);
1376
+ for field in [
1377
+ "session_id",
1378
+ "rollout_path",
1379
+ "captured_at",
1380
+ "captured_via",
1381
+ "attribution_confidence",
1382
+ "capture_state",
1383
+ "attribution_ambiguous",
1384
+ ] {
1385
+ agent.remove(field);
1386
+ }
1369
1387
  }
1370
1388
  crate::lifecycle::launch::persist_command_plan_state(agent, &spawn.plan, &spawn.profile_launch);
1371
1389
  persist_effective_approval_policy_for_restart(agent, safety);
@@ -53,6 +53,57 @@ pub fn classify_first_send_at(raw: &serde_json::Value) -> FirstSendAtState {
53
53
  }
54
54
  }
55
55
 
56
+ /// RESTART-RESUME-001 (0.4.8): true if the agent has any signal of prior
57
+ /// interaction whose context would be lost by silent fresh-start. Checks
58
+ /// `first_send_at` (leader→worker delivery), `last_result_at` (worker
59
+ /// report_result), and `task_prompt_delivered` (MCP-only worker first task).
60
+ /// Used by both the selection-stage never-captured decision and the
61
+ /// pre-selection convergence missing-set predicate so the two layers share
62
+ /// a single "needs context preservation" semantic.
63
+ ///
64
+ /// CR M2 callers (must stay in sync):
65
+ /// * lifecycle/restart/selection.rs (never_captured branch, classify_resume_decision)
66
+ /// * lifecycle/restart/common.rs::restart_required_missing_session_agent_ids
67
+ pub(crate) fn restart_agent_has_context_to_preserve(agent: &serde_json::Value) -> bool {
68
+ let has_valid_first_send_at = matches!(
69
+ classify_first_send_at(agent.get("first_send_at").unwrap_or(&serde_json::Value::Null)),
70
+ FirstSendAtState::Valid
71
+ );
72
+ if has_valid_first_send_at {
73
+ return true;
74
+ }
75
+ let has_last_result_at = agent
76
+ .get("last_result_at")
77
+ .and_then(serde_json::Value::as_str)
78
+ .is_some_and(|s| !s.is_empty());
79
+ if has_last_result_at {
80
+ return true;
81
+ }
82
+ agent
83
+ .get("task_prompt_delivered")
84
+ .and_then(serde_json::Value::as_bool)
85
+ .unwrap_or(false)
86
+ }
87
+
88
+ /// RESTART-RESUME-001: an agent is "never-captured" when its session_id is
89
+ /// absent AND there is no context to preserve. Such an agent is safe to
90
+ /// auto-fresh without `--allow-fresh` and should NOT be required to capture
91
+ /// a transcript before restart proceeds.
92
+ pub(crate) fn restart_agent_never_captured(
93
+ agent: &serde_json::Value,
94
+ session_id: Option<&str>,
95
+ ) -> bool {
96
+ let session_present = session_id.is_some_and(|s| !s.is_empty())
97
+ || agent
98
+ .get("session_id")
99
+ .and_then(serde_json::Value::as_str)
100
+ .is_some_and(|s| !s.is_empty());
101
+ if session_present {
102
+ return false;
103
+ }
104
+ !restart_agent_has_context_to_preserve(agent)
105
+ }
106
+
56
107
  fn is_python_fromisoformat_like(raw: &str) -> bool {
57
108
  if raw.is_empty() {
58
109
  return false;
@@ -221,25 +272,25 @@ pub(crate) fn classify_restart_plan_with_resume_validation(
221
272
  }
222
273
  _ => (true, Vec::new()),
223
274
  };
224
- // 0.4.7 partial-resume: when a worker has NEVER been captured (no
225
- // session_id AND first_send_at is Absent), it is structurally
226
- // non-resumable — there is no context to lose, so auto-fresh is
227
- // safe even without --allow-fresh. This prevents a single
228
- // never-captured role (e.g. MCP-only worker that the leader never
229
- // messaged) from blocking restart of the other 6 roles that DO
230
- // have complete resume tuples.
275
+ // 0.4.7 partial-resume + RESTART-RESUME-001 (0.4.8): when a worker
276
+ // has NEVER been captured (no session_id AND no context-bearing
277
+ // signal at all), it is structurally non-resumable — there is no
278
+ // context to lose, so auto-fresh is safe even without --allow-fresh.
231
279
  //
232
- // The "never_captured" predicate is the conjunction:
233
- // * session_id is None (no provider session bound), AND
234
- // * first_send_at is Absent (leader has never injected a
235
- // message so we never armed the capture path either).
280
+ // The "never_captured" predicate is now the shared
281
+ // restart_agent_never_captured(): session_id absent AND none of
282
+ // first_send_at(Valid) / last_result_at / task_prompt_delivered.
283
+ // This matches the pre-selection convergence semantic in
284
+ // common.rs::restart_required_missing_session_agent_ids so both
285
+ // layers refuse / auto-fresh in unison.
236
286
  //
237
- // If session_id is None but first_send_at is Valid, that's the
238
- // "received message but session not captured" bug state — keep
239
- // the Refuse so we never silently drop context (architect rule:
240
- // "绝不静默 fresh").
287
+ // If session_id is None but ANY context signal exists, that's the
288
+ // "received message/result but session not captured" bug state —
289
+ // keep the Refuse so we never silently drop context (architect
290
+ // rule: "绝不静默 fresh").
291
+ let _ = first_send_at_state; // retained for the Corrupt branch above
241
292
  let never_captured =
242
- session_id.is_none() && matches!(first_send_at_state, FirstSendAtState::Absent);
293
+ restart_agent_never_captured(agent, session_id.as_ref().map(|s| s.as_str()));
243
294
  let decision = if session_id.is_some() && provider_can_resume && resume_backing_exists {
244
295
  ResumeDecision::Resume
245
296
  } else if session_id.is_some() && allow_fresh {
@@ -35,6 +35,7 @@ mod team_state;
35
35
  pub use agent::{reset_agent, reset_agent_with_transport, start_agent, start_agent_with_transport, stop_agent, stop_agent_with_transport};
36
36
  pub(crate) use agent::start_agent_at_paths;
37
37
  pub(crate) use common::refresh_missing_provider_sessions;
38
+ pub(crate) use common::restart_required_missing_session_agent_ids;
38
39
  // 0.3.24 add-agent socket drift fix: state-aware tmux resolver shared with
39
40
  // `lifecycle::launch::add_agent` / `fork_agent` so all three (restart / add / fork)
40
41
  // route to the SAME tmux socket the live team uses.
@@ -636,6 +636,105 @@ fn classify_restart_plan_never_captured_null_session_auto_fresh_partial_resume()
636
636
  );
637
637
  }
638
638
 
639
+ #[test]
640
+ fn restart_required_missing_skips_never_captured_workers() {
641
+ // RESTART-RESUME-001 (0.4.8): the pre-selection convergence missing-set
642
+ // predicate must skip never-captured workers (null session_id, no
643
+ // first_send_at/last_result_at/task_prompt_delivered) so a single
644
+ // never-captured role doesn't burn the capture deadline and trigger
645
+ // resume_not_ready when the selection stage would auto-fresh it anyway.
646
+ use crate::lifecycle::restart::restart_required_missing_session_agent_ids;
647
+ let state = json!({
648
+ "agents": {
649
+ "w1": { "provider": "claude", "session_id": null, "status": "running" }
650
+ }
651
+ });
652
+ let missing = restart_required_missing_session_agent_ids(&state);
653
+ assert!(
654
+ missing.is_empty(),
655
+ "never-captured null-session worker must NOT appear in required-missing; \
656
+ got {missing:?}"
657
+ );
658
+ }
659
+
660
+ #[test]
661
+ fn restart_required_missing_keeps_null_session_with_first_send_at() {
662
+ // RESTART-RESUME-001: null session_id + valid first_send_at means the
663
+ // leader DID send a message; that context must be preserved → still
664
+ // missing → restart refuses without --allow-fresh.
665
+ use crate::lifecycle::restart::restart_required_missing_session_agent_ids;
666
+ let state = json!({
667
+ "agents": {
668
+ "w1": {
669
+ "provider": "claude",
670
+ "session_id": null,
671
+ "status": "running",
672
+ "spawn_cwd": "/tmp/ws",
673
+ "first_send_at": "2026-01-01T00:00:00+00:00",
674
+ }
675
+ }
676
+ });
677
+ let missing = restart_required_missing_session_agent_ids(&state);
678
+ assert_eq!(
679
+ missing,
680
+ vec!["w1".to_string()],
681
+ "null-session + valid first_send_at (context bearing) must remain in \
682
+ required-missing; got {missing:?}"
683
+ );
684
+ }
685
+
686
+ #[test]
687
+ fn restart_required_missing_keeps_null_session_with_last_result_at() {
688
+ // RESTART-RESUME-001: MCP / report_result paths may not have
689
+ // first_send_at, but a non-empty last_result_at means the worker DID
690
+ // produce a result; that context must be preserved.
691
+ use crate::lifecycle::restart::restart_required_missing_session_agent_ids;
692
+ let state = json!({
693
+ "agents": {
694
+ "w1": {
695
+ "provider": "claude",
696
+ "session_id": null,
697
+ "status": "running",
698
+ "spawn_cwd": "/tmp/ws",
699
+ "last_result_at": "2026-01-01T00:00:00+00:00",
700
+ }
701
+ }
702
+ });
703
+ let missing = restart_required_missing_session_agent_ids(&state);
704
+ assert_eq!(
705
+ missing,
706
+ vec!["w1".to_string()],
707
+ "null-session + last_result_at must remain in required-missing; \
708
+ got {missing:?}"
709
+ );
710
+ }
711
+
712
+ #[test]
713
+ fn classify_restart_plan_null_session_with_last_result_at_refuses_without_allow_fresh() {
714
+ // RESTART-RESUME-001: selection stage must also refuse null-session +
715
+ // last_result_at (no first_send_at) without --allow-fresh. Otherwise the
716
+ // pre-selection convergence and selection-stage refuse semantics drift
717
+ // and the gate fails for MCP-only report paths.
718
+ let state = json!({
719
+ "agents": {
720
+ "w1": {
721
+ "provider": "claude",
722
+ "session_id": null,
723
+ "last_result_at": "2026-01-01T00:00:00+00:00",
724
+ }
725
+ }
726
+ });
727
+ let plan = classify_restart_plan(&state, false).expect("纯验证不应 Err");
728
+ assert_eq!(plan.decisions.len(), 1);
729
+ assert_eq!(
730
+ plan.decisions[0].decision,
731
+ ResumeDecision::Refuse,
732
+ "null-session + last_result_at must Refuse without --allow-fresh \
733
+ (context to preserve); got {:?}",
734
+ plan.decisions[0].decision
735
+ );
736
+ }
737
+
639
738
  #[test]
640
739
  fn classify_restart_plan_never_interacted_null_session_with_allow_fresh_marks_forced_fresh() {
641
740
  // E6 层2: 同上自启动 null-session worker,但显式 --allow-fresh → 用户主动认账丢上下文 → FreshStart。
@@ -452,6 +452,71 @@ pub fn deliver_pending_message(
452
452
  channel: None,
453
453
  });
454
454
  }
455
+ // S1-CAPTURE-001 (0.4.8, CR M4 Claude phase-1): for Claude/ClaudeCode
456
+ // recipients with a known authoritative rollout_path, verify the message
457
+ // token actually reached the worker's transcript before marking
458
+ // delivered. This catches the gate's mis-attribution: pane inject
459
+ // succeeded but the token landed in the leader/unassigned transcript,
460
+ // not the worker's. Budget per architect plan: 64KB tail / single
461
+ // per-delivery check / short grace window. Phase-1 Claude only —
462
+ // codex/copilot keep the pre-fix behaviour (will be addressed in
463
+ // phase-2 once Claude phase-1 is field-validated).
464
+ if let Some((rollout_path, provider_wire_str)) = claude_recipient_rollout(state, &message.recipient) {
465
+ let token_marker = format!("[team-agent-token:{message_id}]");
466
+ let grace = std::time::Duration::from_millis(200);
467
+ let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
468
+ let mut transcript_has_token = false;
469
+ loop {
470
+ transcript_has_token =
471
+ rollout_tail_contains(&rollout_path, &token_marker, 64 * 1024);
472
+ if transcript_has_token || std::time::Instant::now() >= deadline {
473
+ break;
474
+ }
475
+ std::thread::sleep(grace);
476
+ }
477
+ if !transcript_has_token {
478
+ // Loud but non-fatal: emit a mismatch event for diagnose/status
479
+ // observability. Do NOT mark delivered — degrade to
480
+ // submitted_unverified so capture is forced to re-attribute.
481
+ let reason = format!(
482
+ "transcript_missing:provider={provider_wire_str},rollout={}",
483
+ rollout_path.display()
484
+ );
485
+ event_log.write(
486
+ "provider.session.transcript_mismatch",
487
+ serde_json::json!({
488
+ "message_id": message_id,
489
+ "recipient": message.recipient,
490
+ "provider": provider_wire_str,
491
+ "rollout_path": rollout_path.to_string_lossy(),
492
+ "pane_id": state
493
+ .get("agents")
494
+ .and_then(|a| a.get(&message.recipient))
495
+ .and_then(|a| a.get("pane_id"))
496
+ .and_then(serde_json::Value::as_str)
497
+ .unwrap_or(""),
498
+ "spawn_epoch": state
499
+ .get("agents")
500
+ .and_then(|a| a.get(&message.recipient))
501
+ .and_then(|a| a.get("spawn_epoch"))
502
+ .and_then(serde_json::Value::as_u64)
503
+ .unwrap_or(0),
504
+ "reason": "transcript_missing",
505
+ }),
506
+ )?;
507
+ store.mark(message_id, "submitted_unverified", Some(&reason))?;
508
+ return Ok(DeliveryOutcome {
509
+ ok: false,
510
+ status: DeliveryStatus::Failed,
511
+ message_status: MessageStatusShadow("submitted_unverified".to_string()),
512
+ message_id: Some(message_id.to_string()),
513
+ verification: Some(reason),
514
+ stage: Some(DeliveryStage::Submit),
515
+ reason: None,
516
+ channel: None,
517
+ });
518
+ }
519
+ }
455
520
  store.mark(message_id, "delivered", None)?;
456
521
  event_log.write(
457
522
  "message.delivered",
@@ -1690,3 +1755,51 @@ pub fn retry_injection_after_trust_auto_answer(
1690
1755
  channel: None,
1691
1756
  })
1692
1757
  }
1758
+
1759
+ /// S1-CAPTURE-001 (0.4.8 phase-1): returns (rollout_path, provider_wire) when
1760
+ /// the recipient agent is Claude/ClaudeCode with a known authoritative
1761
+ /// rollout_path; otherwise None (skip transcript verify). Phase-1 Claude only —
1762
+ /// codex/copilot return None and keep pre-fix delivery semantics.
1763
+ fn claude_recipient_rollout(
1764
+ state: &serde_json::Value,
1765
+ recipient: &str,
1766
+ ) -> Option<(std::path::PathBuf, &'static str)> {
1767
+ let agent = state.get("agents")?.get(recipient)?;
1768
+ let provider = agent.get("provider").and_then(serde_json::Value::as_str)?;
1769
+ let provider_wire_str = match provider {
1770
+ "claude" => "claude",
1771
+ "claude_code" | "claude-code" => "claude_code",
1772
+ _ => return None,
1773
+ };
1774
+ let rollout = agent
1775
+ .get("rollout_path")
1776
+ .and_then(serde_json::Value::as_str)
1777
+ .filter(|s| !s.is_empty())?;
1778
+ Some((std::path::PathBuf::from(rollout), provider_wire_str))
1779
+ }
1780
+
1781
+ /// S1-CAPTURE-001 (0.4.8 phase-1): bounded tail read of the rollout file
1782
+ /// searching for `needle`. Reads up to `tail_bytes` from the end of the file
1783
+ /// (default 64KB budget). Returns true iff the needle appears in the tail.
1784
+ /// Silent on read errors — callers treat missing/unreadable as "token not
1785
+ /// present" which forces the unverified path.
1786
+ fn rollout_tail_contains(path: &std::path::Path, needle: &str, tail_bytes: u64) -> bool {
1787
+ use std::io::{Read, Seek, SeekFrom};
1788
+ let Ok(mut file) = std::fs::File::open(path) else {
1789
+ return false;
1790
+ };
1791
+ let Ok(metadata) = file.metadata() else {
1792
+ return false;
1793
+ };
1794
+ let len = metadata.len();
1795
+ let start = len.saturating_sub(tail_bytes);
1796
+ if file.seek(SeekFrom::Start(start)).is_err() {
1797
+ return false;
1798
+ }
1799
+ let mut buf = Vec::with_capacity(tail_bytes.min(len) as usize);
1800
+ if file.take(tail_bytes).read_to_end(&mut buf).is_err() {
1801
+ return false;
1802
+ }
1803
+ let haystack = String::from_utf8_lossy(&buf);
1804
+ haystack.contains(needle)
1805
+ }
@@ -296,7 +296,24 @@ where
296
296
  .get("rollout_path")
297
297
  .and_then(Value::as_str)
298
298
  .is_some_and(|s| !s.is_empty());
299
- if has_session {
299
+ // S1-CAPTURE-001 (0.4.8): a pending_session_id that differs from the
300
+ // on-row session_id means a fresh respawn is in flight — the prior
301
+ // worker's session_id may still be on the row (concurrent capture race,
302
+ // delayed persist, or persist-backfill replay) but the NEW worker has
303
+ // not yet been bound. Stamping capture_state=captured here would
304
+ // falsely declare the OLD session authoritative for the NEW worker
305
+ // pane — the leader/unassigned mis-attribution surfaced in S1-CAPTURE-001
306
+ // gate evidence. Only stamp `captured` when session_id agrees with
307
+ // _pending_session_id (or _pending is absent, meaning no rebind in
308
+ // flight).
309
+ let pending_mismatch = match (
310
+ agent_obj.get("_pending_session_id").and_then(Value::as_str).filter(|s| !s.is_empty()),
311
+ agent_obj.get("session_id").and_then(Value::as_str).filter(|s| !s.is_empty()),
312
+ ) {
313
+ (Some(pending), Some(current)) => pending != current,
314
+ _ => false,
315
+ };
316
+ if has_session && !pending_mismatch {
300
317
  // Captured already — fix state field if drifted.
301
318
  if agent_obj
302
319
  .get("capture_state")
@@ -684,6 +701,29 @@ fn agent_session_complete(agent: &Value) -> bool {
684
701
  if !session_id_ok {
685
702
  return false;
686
703
  }
704
+ // S1-CAPTURE-001 (0.4.8, CR M3 provider-agnostic): pending mismatch guard.
705
+ // When fresh-start has written a new `_pending_session_id` but the old
706
+ // authoritative `session_id` is still present and differs, the tuple is
707
+ // STALE — the new process has a different session that has not been
708
+ // captured yet. Returning true here would let capture skip this agent
709
+ // and keep treating the old transcript as authoritative (the gate's
710
+ // observed mis-attribution). Force re-capture by reporting incomplete.
711
+ // The fresh-tuple clear in mark_agent_started (lifecycle/restart/agent.rs)
712
+ // is the primary fix; this guard handles historical poison state where
713
+ // both fields already coexist from a prior version.
714
+ let pending = agent
715
+ .get("_pending_session_id")
716
+ .and_then(Value::as_str)
717
+ .filter(|s| !s.is_empty());
718
+ let current = agent
719
+ .get("session_id")
720
+ .and_then(Value::as_str)
721
+ .filter(|s| !s.is_empty());
722
+ if let (Some(pending), Some(current)) = (pending, current) {
723
+ if pending != current {
724
+ return false;
725
+ }
726
+ }
687
727
  let rollout_path = match agent
688
728
  .get("rollout_path")
689
729
  .and_then(Value::as_str)
@@ -1002,6 +1042,16 @@ fn apply_captured_session(
1002
1042
  serde_json::to_value(captured.attribution_confidence).unwrap_or(Value::Null),
1003
1043
  );
1004
1044
  agent_obj.remove("attribution_ambiguous");
1045
+ // S1-CAPTURE-001 (0.4.8): after writing the authoritative tuple, the
1046
+ // `_pending_session_id` placeholder is no longer needed — remove it so
1047
+ // future reads don't trip the pending-mismatch guard in
1048
+ // agent_session_complete. Also stamp capture_state="captured" for
1049
+ // diagnose/status observability.
1050
+ agent_obj.remove("_pending_session_id");
1051
+ agent_obj.insert(
1052
+ "capture_state".to_string(),
1053
+ serde_json::json!("captured"),
1054
+ );
1005
1055
  true
1006
1056
  }
1007
1057
 
@@ -1784,4 +1834,84 @@ mod u1_tests {
1784
1834
  let _ = std::fs::remove_dir_all(workspace.join(".team"));
1785
1835
  let _ = std::fs::remove_dir_all(&workspace);
1786
1836
  }
1837
+
1838
+ /// S1-CAPTURE-001 (0.4.8): pending mismatch guard. When fresh-start has
1839
+ /// written a new `_pending_session_id` but the old authoritative
1840
+ /// `session_id` is still present and differs, `agent_session_complete`
1841
+ /// must return false so capture re-runs and re-binds to the new process.
1842
+ /// Without this, the historical poison state (old + new coexist) lets
1843
+ /// the stale rollout look authoritative.
1844
+ #[test]
1845
+ fn agent_session_complete_returns_false_on_pending_mismatch() {
1846
+ let dir = std::env::temp_dir().join(format!(
1847
+ "ta-pending-mismatch-{}",
1848
+ std::process::id()
1849
+ ));
1850
+ let _ = std::fs::create_dir_all(&dir);
1851
+ let rollout = dir.join("old.jsonl");
1852
+ std::fs::write(&rollout, "{\"type\":\"assistant\"}\n").unwrap();
1853
+
1854
+ let agent = serde_json::json!({
1855
+ "provider": "claude",
1856
+ "session_id": "old",
1857
+ "rollout_path": rollout.to_string_lossy(),
1858
+ "captured_at": "2026-01-01T00:00:00+00:00",
1859
+ "captured_via": "fs_watch",
1860
+ "_pending_session_id": "new",
1861
+ });
1862
+
1863
+ assert!(
1864
+ !super::agent_session_complete(&agent),
1865
+ "S1-CAPTURE-001: pending != session_id must force incomplete \
1866
+ (capture must re-attribute to new session)"
1867
+ );
1868
+
1869
+ // Sanity: when they match, complete is true.
1870
+ let agent_match = serde_json::json!({
1871
+ "provider": "claude",
1872
+ "session_id": "new",
1873
+ "rollout_path": rollout.to_string_lossy(),
1874
+ "captured_at": "2026-01-01T00:00:00+00:00",
1875
+ "captured_via": "fs_watch",
1876
+ "_pending_session_id": "new",
1877
+ });
1878
+ assert!(
1879
+ super::agent_session_complete(&agent_match),
1880
+ "pending == session_id (in-progress capture confirmed): must be complete"
1881
+ );
1882
+
1883
+ let _ = std::fs::remove_file(&rollout);
1884
+ let _ = std::fs::remove_dir_all(&dir);
1885
+ }
1886
+
1887
+ /// S1-CAPTURE-001 (0.4.8): apply_captured_session clears
1888
+ /// `_pending_session_id` and stamps `capture_state="captured"` so the
1889
+ /// pending-mismatch guard doesn't fire on subsequent reads.
1890
+ #[test]
1891
+ fn apply_captured_session_clears_pending_and_stamps_capture_state() {
1892
+ let mut agent = serde_json::Map::new();
1893
+ agent.insert(
1894
+ "_pending_session_id".to_string(),
1895
+ serde_json::json!("new-sess"),
1896
+ );
1897
+ let captured = CapturedSession {
1898
+ session_id: Some(SessionId::new("new-sess")),
1899
+ rollout_path: Some(RolloutPath::new(PathBuf::from("/tmp/new.jsonl"))),
1900
+ captured_via: CaptureVia::FsWatch,
1901
+ attribution_confidence: Confidence::High,
1902
+ spawn_cwd: PathBuf::from("/tmp/cwd"),
1903
+ };
1904
+ let written = super::apply_captured_session(&mut agent, &captured);
1905
+ assert!(written, "apply must return true for valid captured");
1906
+ assert!(
1907
+ agent.get("_pending_session_id").is_none(),
1908
+ "S1-CAPTURE-001: _pending_session_id must be removed after capture \
1909
+ (agent={agent:?})"
1910
+ );
1911
+ assert_eq!(
1912
+ agent.get("capture_state").and_then(serde_json::Value::as_str),
1913
+ Some("captured"),
1914
+ "S1-CAPTURE-001: capture_state must be stamped 'captured'"
1915
+ );
1916
+ }
1787
1917
  }
@@ -160,10 +160,14 @@ pub enum CommandScope {
160
160
  /// Caller passed `--team X` explicitly (or there's only one alive team
161
161
  /// and we resolved it). Carries the canonical team_key.
162
162
  Resolved(String),
163
- /// No `--team` and multiple alive teams. Destructive commands MUST
164
- /// refuse with this list of candidates so the operator chooses
165
- /// explicitly. Read-only commands may still proceed (their scope is
166
- /// "all teams" by default).
163
+ /// No `--team` and multiple alive teams. Destructive commands AND
164
+ /// selected-team commands (status, etc.) MUST refuse with this list
165
+ /// of candidates so the operator chooses explicitly. Only true
166
+ /// all-team aggregation commands may proceed without --team.
167
+ /// (S4QR-001 0.4.8: status was previously documented as a read-only
168
+ /// command that could proceed; in fact it projects a single team's
169
+ /// view and silently defaulting to the active team caused gate
170
+ /// failures when multiple alive teams existed.)
167
171
  Ambiguous(Vec<String>),
168
172
  /// No `--team` and no teams alive yet (fresh workspace) — bare
169
173
  /// commands fall through to legacy single-team behaviour.
@@ -560,6 +560,23 @@ fn backfill_capture_fields(incoming_agent: &mut Value, latest_agent: &Value) {
560
560
  if !latest_complete {
561
561
  return;
562
562
  }
563
+ // S1-CAPTURE-001 (0.4.8) Rule 5: a fresh restart marker (`_pending_session_id`
564
+ // present on incoming) signals the worker just respawned and the prior
565
+ // tuple was intentionally cleared. The capture scanner is the only path
566
+ // permitted to promote `_pending_session_id` into the authoritative tuple
567
+ // after it confirms backing. Persist-side backfill MUST NOT revive the
568
+ // latest tuple in this state — doing so resurrects the previous worker's
569
+ // session/rollout pair and delivered tokens land in the OLD transcript
570
+ // (the leader/unassigned mis-attribution surfaced in S1-CAPTURE-001 gate
571
+ // evidence). Refuse backfill regardless of whether incoming session_id
572
+ // is null vs latest's value: the pending marker is authoritative intent.
573
+ let incoming_pending = incoming_row
574
+ .get("_pending_session_id")
575
+ .and_then(Value::as_str)
576
+ .filter(|s| !s.is_empty());
577
+ if incoming_pending.is_some() {
578
+ return;
579
+ }
563
580
  // Rule 2: incoming carries a DIFFERENT non-null session_id → do not mix.
564
581
  let incoming_session = incoming_row.get("session_id").and_then(Value::as_str);
565
582
  let latest_session = latest_agent.get("session_id").and_then(Value::as_str);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.4.7",
24
- "@team-agent/cli-darwin-x64": "0.4.7",
25
- "@team-agent/cli-linux-x64": "0.4.7"
23
+ "@team-agent/cli-darwin-arm64": "0.4.8",
24
+ "@team-agent/cli-darwin-x64": "0.4.8",
25
+ "@team-agent/cli-linux-x64": "0.4.8"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",