ZRainbow commited on
Commit
25aa943
·
1 Parent(s): c31d32e

fix: align protected seat rotation semantics

Browse files
.trellis/spec/backend/runtime-docker-hardening.md CHANGED
@@ -57,6 +57,8 @@
57
  - `cmd_check(include_standby=False, *, force_auth_repair=False, preserve_low_active=False, preserved_low_accounts=None) -> list`
58
  - `_historical_low_quota_info(acc, threshold, now=None) -> dict | None`
59
  - `_record_auth_repair_failure(email, error_type=None, error_detail=None, *, chatgpt_api=None) -> dict`
 
 
60
  - `_discard_auth_repair_failed_account_record(email, reason, *, status=STATUS_STANDBY, now=None) -> None`
61
  - `cmd_rotate(target_seats=3, force_auth_repair=False, background_post_sync=False)`
62
  - `IPv6Pool.start(active_emails: Iterable[str] | None = None) -> None`
@@ -114,6 +116,7 @@
114
  - `_record_auth_repair_failure()` must preserve current state-machine semantics: final auth-pending state is persisted as `STATUS_AUTH_INVALID`, and status writes may include `_reason` for transition logging.
115
  - `_record_auth_repair_failure()` may release a Team blocker after repeated `email_verification` exhausts the retry budget, and may release missing-auth `login_state_lost` / organization-selection blockers when they occupy a Team seat.
116
  - Protected local credential seats (`protect_team_seat=True` or a non-provider local auth file) must not be released by `login_state_lost`; they should pause repair and stay in the current auth-pending state.
 
117
  - When an auth-repair failure successfully releases a Team seat and `ROTATE_SKIP_REUSE=true`, the local row should be retained for evidence but marked `disabled=True`, `reuse_disabled=True`, and `retired_reason="auth_repair_failed:<type>"` so automated standby reuse cannot select it.
118
  - When add-phone soft retry is disabled, current capacity-first behavior treats `add_phone` as a hard managed-child blocker that may release the Team seat. Do not migrate target's pause-without-release behavior unless the operator policy is explicitly changed.
119
  - Target assertions that require persisted `"auth_pending"` or exact `update_account(email, {"status": ...})` calls are incompatible with the current account-state lifecycle and are not migration requirements.
@@ -171,6 +174,9 @@
171
  | repeated `email_verification` exhausts auth-repair retry budget | Pause repair, release the Team seat when present, and retire/disable the released local row under skip-reuse mode |
172
  | `login_state_lost` has no usable local auth file and blocks Team capacity | Pause repair, release the Team seat when present, and retire/disable the released local row under skip-reuse mode |
173
  | `login_state_lost` has a protected local credential auth file | Pause repair without releasing the Team seat or disabling the row |
 
 
 
174
  | `collect_runtime_resource_snapshot()` unexpectedly raises inside `/api/status` | Return a status response with a diagnostic `runtime_resources.error`; do not raise a 500 |
175
  | `/api/status?fast=true` is called while rotate/fill is running | Return account/resource snapshot without live quota probes and with bounded CLIProxy health metadata |
176
  | background task calls `bump_task_progress("stage")` repeatedly | Update task heartbeat and append/refresh a bounded stage history |
@@ -227,6 +233,8 @@
227
  - selected target `test_signup_flow_profiles.py` assertions may be used for lightweight OAuth helper parity, split-code target waits, session-bundle extraction, and explicit browser-login result wrapping. Full-file target parity is not required when the only failures are the rejected old positional `SignupProfile` constructor shape.
228
  - selected target `test_manager_auth_repair.py` assertions may be used for `_login_codex_with_result()` result-wrapper parity, `cmd_check(...force_auth_repair...)`, historical-low-quota network-error handling, and protected/released Team blocker behavior. Full-file target parity is not required while remaining failures describe target-only `"auth_pending"` persisted literals, exact `update_account()` call shape, or the rejected add-phone retry-disabled no-release policy.
229
  - current `tests/unit/test_round12_s3_cherry_pick.py::TestCmdCheckAuthRepairEntry` and `::TestRecordAuthRepairFailure` must cover the adapted current behavior.
 
 
230
  - Status endpoint integration: `tests/unit/test_api_status.py`
231
  - fast status skips live quota probes and uses bounded CLIProxy health reads.
232
  - active tasks record progress history from `bump_task_progress()`.
 
57
  - `cmd_check(include_standby=False, *, force_auth_repair=False, preserve_low_active=False, preserved_low_accounts=None) -> list`
58
  - `_historical_low_quota_info(acc, threshold, now=None) -> dict | None`
59
  - `_record_auth_repair_failure(email, error_type=None, error_detail=None, *, chatgpt_api=None) -> dict`
60
+ - `_can_replace_protected_managed_auth_failure(acc, *, error_type=None, reason=None, missing_auth=False) -> bool`
61
+ - `_should_aggressively_release_auth_failure(error_type, *, discard_failed_repair) -> bool`
62
  - `_discard_auth_repair_failed_account_record(email, reason, *, status=STATUS_STANDBY, now=None) -> None`
63
  - `cmd_rotate(target_seats=3, force_auth_repair=False, background_post_sync=False)`
64
  - `IPv6Pool.start(active_emails: Iterable[str] | None = None) -> None`
 
116
  - `_record_auth_repair_failure()` must preserve current state-machine semantics: final auth-pending state is persisted as `STATUS_AUTH_INVALID`, and status writes may include `_reason` for transition logging.
117
  - `_record_auth_repair_failure()` may release a Team blocker after repeated `email_verification` exhausts the retry budget, and may release missing-auth `login_state_lost` / organization-selection blockers when they occupy a Team seat.
118
  - Protected local credential seats (`protect_team_seat=True` or a non-provider local auth file) must not be released by `login_state_lost`; they should pause repair and stay in the current auth-pending state.
119
+ - Protected AutoTeam-managed child seats with a mail binding (`mail_account_id` or `cloudmail_account_id`) are different from manual/local credential seats. If concrete auth-failure evidence exists (`auth_error_discard`, `missing_auth_file`, `auth_retry_paused`, `auth_retry_after`, `non_team_plan`, `oauth_timeout`, `login_failed`, etc.), `_can_replace_protected_managed_auth_failure()` may override stale protection flags so rotation can release/retire the child and restore capacity.
120
  - When an auth-repair failure successfully releases a Team seat and `ROTATE_SKIP_REUSE=true`, the local row should be retained for evidence but marked `disabled=True`, `reuse_disabled=True`, and `retired_reason="auth_repair_failed:<type>"` so automated standby reuse cannot select it.
121
  - When add-phone soft retry is disabled, current capacity-first behavior treats `add_phone` as a hard managed-child blocker that may release the Team seat. Do not migrate target's pause-without-release behavior unless the operator policy is explicitly changed.
122
  - Target assertions that require persisted `"auth_pending"` or exact `update_account(email, {"status": ...})` calls are incompatible with the current account-state lifecycle and are not migration requirements.
 
174
  | repeated `email_verification` exhausts auth-repair retry budget | Pause repair, release the Team seat when present, and retire/disable the released local row under skip-reuse mode |
175
  | `login_state_lost` has no usable local auth file and blocks Team capacity | Pause repair, release the Team seat when present, and retire/disable the released local row under skip-reuse mode |
176
  | `login_state_lost` has a protected local credential auth file | Pause repair without releasing the Team seat or disabling the row |
177
+ | `auth_error_discard` or another concrete auth failure hits a protected AutoTeam-managed child with a mail binding | Allow the protected replacement override, release the Team seat when present, and retire/disable the row under skip-reuse mode |
178
+ | `auth_retry_paused` / `auth_retry_after` is present on a protected AutoTeam-managed child | Treat it as a replaceable pool blocker; do not let stale `protect_team_seat` block rotation |
179
+ | the same auth-failure signal hits a protected manual/local credential row without mail binding | Preserve the seat; do not release or retire the row automatically |
180
  | `collect_runtime_resource_snapshot()` unexpectedly raises inside `/api/status` | Return a status response with a diagnostic `runtime_resources.error`; do not raise a 500 |
181
  | `/api/status?fast=true` is called while rotate/fill is running | Return account/resource snapshot without live quota probes and with bounded CLIProxy health metadata |
182
  | background task calls `bump_task_progress("stage")` repeatedly | Update task heartbeat and append/refresh a bounded stage history |
 
233
  - selected target `test_signup_flow_profiles.py` assertions may be used for lightweight OAuth helper parity, split-code target waits, session-bundle extraction, and explicit browser-login result wrapping. Full-file target parity is not required when the only failures are the rejected old positional `SignupProfile` constructor shape.
234
  - selected target `test_manager_auth_repair.py` assertions may be used for `_login_codex_with_result()` result-wrapper parity, `cmd_check(...force_auth_repair...)`, historical-low-quota network-error handling, and protected/released Team blocker behavior. Full-file target parity is not required while remaining failures describe target-only `"auth_pending"` persisted literals, exact `update_account()` call shape, or the rejected add-phone retry-disabled no-release policy.
235
  - current `tests/unit/test_round12_s3_cherry_pick.py::TestCmdCheckAuthRepairEntry` and `::TestRecordAuthRepairFailure` must cover the adapted current behavior.
236
+ - `tests/unit/test_round12_s3_cherry_pick.py::TestRecordAuthRepairFailure` must cover protected managed-child release and manual protected credential preservation.
237
+ - `tests/unit/test_manager_rotate.py::test_replaceable_pool_blocker_reason_reports_concrete_evidence` must cover protected managed-child blocker classification and manual protected credential exclusion.
238
  - Status endpoint integration: `tests/unit/test_api_status.py`
239
  - fast status skips live quota probes and uses bounded CLIProxy health reads.
240
  - active tasks record progress history from `bump_task_progress()`.
src/autoteam/api.py CHANGED
@@ -3450,7 +3450,12 @@ def post_fill(params: TaskParams = TaskParams()):
3450
  则直接返回 409,不启动后台任务(队列化拒绝,Solution C)。本地状态足够用,无需启动
3451
  Playwright 远程查询,避免给前端按错按钮带来额外开销。
3452
  """
3453
- from autoteam.manager import TEAM_SUB_ACCOUNT_HARD_CAP, _count_local_team_seat_accounts, cmd_fill
 
 
 
 
 
3454
 
3455
  if params.leave_workspace:
3456
  from autoteam.accounts import load_accounts
@@ -3511,10 +3516,17 @@ def post_fill(params: TaskParams = TaskParams()):
3511
  # probe 异常按 spec §6.1 不阻塞 — M-T1 / M-T2 在 _run_post_register_oauth 兜底
3512
  pass
3513
 
 
 
 
 
 
 
 
3514
  command = "fill-personal" if params.leave_workspace else "fill"
3515
  task = _start_task(
3516
  command,
3517
- cmd_fill,
3518
  {"target": params.target, "leave_workspace": params.leave_workspace},
3519
  params.target,
3520
  leave_workspace=params.leave_workspace,
 
3450
  则直接返回 409,不启动后台任务(队列化拒绝,Solution C)。本地状态足够用,无需启动
3451
  Playwright 远程查询,避免给前端按错按钮带来额外开销。
3452
  """
3453
+ from autoteam.manager import (
3454
+ TEAM_SUB_ACCOUNT_HARD_CAP,
3455
+ _count_local_team_seat_accounts,
3456
+ _schedule_post_task_sync,
3457
+ cmd_fill,
3458
+ )
3459
 
3460
  if params.leave_workspace:
3461
  from autoteam.accounts import load_accounts
 
3516
  # probe 异常按 spec §6.1 不阻塞 — M-T1 / M-T2 在 _run_post_register_oauth 兜底
3517
  pass
3518
 
3519
+ def _cmd_fill_api_task(target: int, *, leave_workspace: bool = False):
3520
+ if leave_workspace:
3521
+ return cmd_fill(target, leave_workspace=True)
3522
+ result = cmd_fill(target, leave_workspace=False, post_sync=False, print_status=False)
3523
+ _schedule_post_task_sync("[填充]")
3524
+ return result
3525
+
3526
  command = "fill-personal" if params.leave_workspace else "fill"
3527
  task = _start_task(
3528
  command,
3529
+ _cmd_fill_api_task,
3530
  {"target": params.target, "leave_workspace": params.leave_workspace},
3531
  params.target,
3532
  leave_workspace=params.leave_workspace,
src/autoteam/manager.py CHANGED
@@ -265,6 +265,27 @@ AUTH_REPAIR_RELEASE_TEAM_BLOCKER_TYPES = frozenset(
265
  "auth_error_discard",
266
  }
267
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
 
269
 
270
  def _chatgpt_session_ready(chatgpt_api) -> bool:
@@ -426,6 +447,46 @@ def _is_protected_local_credential_seat(acc: dict | None) -> bool:
426
  return not _has_account_mail_binding(acc)
427
 
428
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  def _is_auth_repair_pending_status(status: str | None) -> bool:
430
  """Accept current persisted auth-invalid alias and legacy target auth_pending literal."""
431
  return status in {STATUS_AUTH_PENDING, "auth_pending"}
@@ -601,7 +662,10 @@ def _replaceable_pool_blocker_reason(acc: dict | None, *, missing_auth: bool = F
601
  acc = acc or {}
602
  if _is_main_account_email(acc.get("email")) or is_account_disabled(acc):
603
  return None
604
- if _is_protected_local_credential_seat(acc):
 
 
 
605
  return None
606
  status = acc.get("status")
607
  if status == STATUS_EXHAUSTED:
@@ -873,6 +937,16 @@ def _auth_repair_skip_reason(acc: dict | None, *, force: bool = False, now: floa
873
  return None
874
 
875
 
 
 
 
 
 
 
 
 
 
 
876
  def _record_auth_repair_failure(
877
  email: str,
878
  error_type: str | None = None,
@@ -907,7 +981,18 @@ def _record_auth_repair_failure(
907
  except Exception:
908
  discard_failed_repair = True
909
 
910
- if error_type == "add_phone" and _auth_repair_retry_add_phone_enabled():
 
 
 
 
 
 
 
 
 
 
 
911
  prev_count = int(acc.get("auth_retry_count") or 0) if acc.get("auth_last_error") == "add_phone" else 0
912
  next_count = prev_count + 1
913
  max_retries = _auth_repair_add_phone_max_retries()
@@ -974,7 +1059,8 @@ def _record_auth_repair_failure(
974
  state["auth_retry_paused"] = True
975
  release_team_seat = True
976
 
977
- if release_team_seat and protected_local_credential:
 
978
  logger.warning("[认证修复] 保留受保护的本地凭证席位: %s", email)
979
  release_team_seat = False
980
 
@@ -996,7 +1082,9 @@ def _record_auth_repair_failure(
996
  # 走 default_machine.transition 时映射到 AccountState.AUTH_PENDING.
997
  final_status = STATUS_STANDBY if seat_released or not is_team_member else STATUS_AUTH_INVALID
998
  update_account(email, status=final_status, _reason=f"auth_repair:{error_type}")
999
- if discard_failed_repair and seat_released and not protected_local_credential:
 
 
1000
  _discard_auth_repair_failed_account_record(
1001
  email,
1002
  f"auth_repair_failed:{error_type}",
@@ -1011,6 +1099,7 @@ def _record_auth_repair_failure(
1011
  "release_attempted": release_attempted,
1012
  "remove_status": remove_status,
1013
  "protected_local_credential": protected_local_credential,
 
1014
  }
1015
 
1016
 
 
265
  "auth_error_discard",
266
  }
267
  )
268
+ AUTH_REPAIR_AGGRESSIVE_RELEASE_TYPES = frozenset(
269
+ {
270
+ "add_phone",
271
+ "human_verification",
272
+ "email_verification",
273
+ "workspace_selection",
274
+ "account_selection",
275
+ "login_state_lost",
276
+ "no_valid_organizations",
277
+ "missing_auth_file",
278
+ "auth_error_discard",
279
+ "unsupported_region",
280
+ "oauth_timeout",
281
+ "site_unavailable",
282
+ "token_exchange_failed",
283
+ "non_team_plan",
284
+ "auth_code_missing",
285
+ "login_failed",
286
+ "exception",
287
+ }
288
+ )
289
 
290
 
291
  def _chatgpt_session_ready(chatgpt_api) -> bool:
 
447
  return not _has_account_mail_binding(acc)
448
 
449
 
450
+ def _can_replace_protected_managed_auth_failure(
451
+ acc: dict | None,
452
+ *,
453
+ error_type: str | None = None,
454
+ reason: str | None = None,
455
+ missing_auth: bool = False,
456
+ ) -> bool:
457
+ """Allow AutoTeam-managed child seats to be evicted despite stale protection flags."""
458
+ acc = acc or {}
459
+ if _is_main_account_email(acc.get("email")) or not _has_account_mail_binding(acc):
460
+ return False
461
+ signals = {
462
+ str(value or "").strip()
463
+ for value in (
464
+ error_type,
465
+ reason,
466
+ acc.get("auth_last_error"),
467
+ "missing_auth_file" if missing_auth else None,
468
+ )
469
+ if str(value or "").strip()
470
+ }
471
+ if acc.get("auth_retry_paused"):
472
+ signals.add("auth_retry_paused")
473
+ if acc.get("auth_retry_after"):
474
+ signals.add("auth_retry_after")
475
+ return bool(
476
+ signals
477
+ & (
478
+ AUTH_REPAIR_AGGRESSIVE_RELEASE_TYPES
479
+ | AUTH_REPAIR_RELEASE_TEAM_BLOCKER_TYPES
480
+ | {
481
+ "auth_error",
482
+ "auth_retry_paused",
483
+ "auth_retry_after",
484
+ "auth_retry_after_invalid",
485
+ }
486
+ )
487
+ )
488
+
489
+
490
  def _is_auth_repair_pending_status(status: str | None) -> bool:
491
  """Accept current persisted auth-invalid alias and legacy target auth_pending literal."""
492
  return status in {STATUS_AUTH_PENDING, "auth_pending"}
 
662
  acc = acc or {}
663
  if _is_main_account_email(acc.get("email")) or is_account_disabled(acc):
664
  return None
665
+ if _is_protected_local_credential_seat(acc) and not _can_replace_protected_managed_auth_failure(
666
+ acc,
667
+ missing_auth=missing_auth,
668
+ ):
669
  return None
670
  status = acc.get("status")
671
  if status == STATUS_EXHAUSTED:
 
937
  return None
938
 
939
 
940
+ def _should_aggressively_release_auth_failure(error_type: str | None, *, discard_failed_repair: bool) -> bool:
941
+ """Return whether one-shot rotation should release a child seat for this login/link failure."""
942
+ if not discard_failed_repair:
943
+ return False
944
+ normalized = str(error_type or "").strip()
945
+ if not normalized:
946
+ normalized = "login_failed"
947
+ return normalized in AUTH_REPAIR_AGGRESSIVE_RELEASE_TYPES
948
+
949
+
950
  def _record_auth_repair_failure(
951
  email: str,
952
  error_type: str | None = None,
 
981
  except Exception:
982
  discard_failed_repair = True
983
 
984
+ if _should_aggressively_release_auth_failure(error_type, discard_failed_repair=discard_failed_repair):
985
+ prev_count = int(acc.get("auth_retry_count") or 0)
986
+ state = {
987
+ "auth_retry_count": prev_count + 1,
988
+ "auth_last_error": error_type,
989
+ "auth_last_error_detail": error_detail,
990
+ "auth_last_failed_at": now,
991
+ "auth_retry_after": None,
992
+ "auth_retry_paused": True,
993
+ }
994
+ release_team_seat = True
995
+ elif error_type == "add_phone" and _auth_repair_retry_add_phone_enabled():
996
  prev_count = int(acc.get("auth_retry_count") or 0) if acc.get("auth_last_error") == "add_phone" else 0
997
  next_count = prev_count + 1
998
  max_retries = _auth_repair_add_phone_max_retries()
 
1059
  state["auth_retry_paused"] = True
1060
  release_team_seat = True
1061
 
1062
+ protected_replacement_override = _can_replace_protected_managed_auth_failure(acc, error_type=error_type)
1063
+ if release_team_seat and protected_local_credential and not protected_replacement_override:
1064
  logger.warning("[认证修复] 保留受保护的本地凭证席位: %s", email)
1065
  release_team_seat = False
1066
 
 
1082
  # 走 default_machine.transition 时映射到 AccountState.AUTH_PENDING.
1083
  final_status = STATUS_STANDBY if seat_released or not is_team_member else STATUS_AUTH_INVALID
1084
  update_account(email, status=final_status, _reason=f"auth_repair:{error_type}")
1085
+ if discard_failed_repair and final_status == STATUS_STANDBY and (
1086
+ not protected_local_credential or protected_replacement_override
1087
+ ):
1088
  _discard_auth_repair_failed_account_record(
1089
  email,
1090
  f"auth_repair_failed:{error_type}",
 
1099
  "release_attempted": release_attempted,
1100
  "remove_status": remove_status,
1101
  "protected_local_credential": protected_local_credential,
1102
+ "protected_replacement_override": protected_replacement_override,
1103
  }
1104
 
1105
 
tests/unit/test_api_status.py CHANGED
@@ -767,6 +767,43 @@ def test_post_rotate_runs_final_sync_in_background(monkeypatch):
767
  assert rotate_calls == [((3,), {"force_auth_repair": True, "background_post_sync": True})]
768
 
769
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
770
  def test_disable_and_enable_account_toggle_local_flag(tmp_path, monkeypatch):
771
  accounts_file = tmp_path / "accounts.json"
772
  monkeypatch.setattr(accounts, "ACCOUNTS_FILE", accounts_file)
 
767
  assert rotate_calls == [((3,), {"force_auth_repair": True, "background_post_sync": True})]
768
 
769
 
770
+ def test_post_fill_defers_sync_and_status_to_background(monkeypatch):
771
+ started = []
772
+ fill_calls = []
773
+ sync_calls = []
774
+
775
+ def fake_start_task(command, func, params, *args, **kwargs):
776
+ started.append((command, func, params, args, kwargs))
777
+ return {"task_id": "fill-task", "command": command, "params": params}
778
+
779
+ monkeypatch.setattr(api, "_start_task", fake_start_task)
780
+ monkeypatch.setattr(api._pw_executor, "run", lambda fn: (True, "ok", {}))
781
+ monkeypatch.setattr(
782
+ "autoteam.manager.cmd_fill",
783
+ lambda *args, **kwargs: fill_calls.append((args, kwargs)) or {"filled": True},
784
+ )
785
+ monkeypatch.setattr(
786
+ "autoteam.manager._schedule_post_task_sync",
787
+ lambda stage_label: sync_calls.append(stage_label),
788
+ )
789
+
790
+ result = api.post_fill(api.TaskParams(target=3))
791
+
792
+ assert result["task_id"] == "fill-task"
793
+ assert len(started) == 1
794
+ command, func, params, args, kwargs = started[0]
795
+ assert command == "fill"
796
+ assert params == {"target": 3, "leave_workspace": False}
797
+ assert args == (3,)
798
+ assert kwargs == {"leave_workspace": False}
799
+
800
+ assert func(3, leave_workspace=False) == {"filled": True}
801
+ assert fill_calls == [
802
+ ((3,), {"leave_workspace": False, "post_sync": False, "print_status": False})
803
+ ]
804
+ assert sync_calls == ["[填充]"]
805
+
806
+
807
  def test_disable_and_enable_account_toggle_local_flag(tmp_path, monkeypatch):
808
  accounts_file = tmp_path / "accounts.json"
809
  monkeypatch.setattr(accounts, "ACCOUNTS_FILE", accounts_file)
tests/unit/test_manager_rotate.py CHANGED
@@ -103,7 +103,9 @@ def test_cmd_rotate_can_defer_final_sync_when_running_as_api_task(monkeypatch):
103
  ]
104
 
105
 
106
- def test_replaceable_pool_blocker_reason_reports_concrete_evidence():
 
 
107
  assert (
108
  manager._replaceable_pool_blocker_reason(
109
  {"email": "missing@example.com", "status": manager.STATUS_ACTIVE, "auth_file": ""}
@@ -122,6 +124,31 @@ def test_replaceable_pool_blocker_reason_reports_concrete_evidence():
122
  )
123
  == "quota_exhausted"
124
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
 
127
  def test_create_new_account_uses_domain_auto_join_before_invite(monkeypatch):
 
103
  ]
104
 
105
 
106
+ def test_replaceable_pool_blocker_reason_reports_concrete_evidence(tmp_path):
107
+ auth_file = tmp_path / "codex-auth.json"
108
+ auth_file.write_text("{}", encoding="utf-8")
109
  assert (
110
  manager._replaceable_pool_blocker_reason(
111
  {"email": "missing@example.com", "status": manager.STATUS_ACTIVE, "auth_file": ""}
 
124
  )
125
  == "quota_exhausted"
126
  )
127
+ assert (
128
+ manager._replaceable_pool_blocker_reason(
129
+ {
130
+ "email": "managed-protected@example.com",
131
+ "status": manager.STATUS_ACTIVE,
132
+ "auth_file": str(auth_file),
133
+ "mail_account_id": 1,
134
+ "auth_retry_paused": True,
135
+ "protect_team_seat": True,
136
+ }
137
+ )
138
+ == "auth_retry_paused"
139
+ )
140
+ assert (
141
+ manager._replaceable_pool_blocker_reason(
142
+ {
143
+ "email": "manual-protected@example.com",
144
+ "status": manager.STATUS_ACTIVE,
145
+ "auth_file": str(auth_file),
146
+ "auth_retry_paused": True,
147
+ "protect_team_seat": True,
148
+ }
149
+ )
150
+ is None
151
+ )
152
 
153
 
154
  def test_create_new_account_uses_domain_auto_join_before_invite(monkeypatch):
tests/unit/test_round12_s3_cherry_pick.py CHANGED
@@ -588,6 +588,7 @@ class TestRecordAuthRepairFailure:
588
  ):
589
  """普通错误 → 衰退式 retry_after, paused=False, status=AUTH_INVALID(留 team)。"""
590
  # Account is in team → final_status should be AUTH_INVALID
 
591
  monkeypatch.setattr(manager_mod, "_is_email_in_team", lambda email: True)
592
 
593
  result = manager_mod._record_auth_repair_failure(
@@ -636,6 +637,7 @@ class TestRecordAuthRepairFailure:
636
  self, seeded_account, monkeypatch
637
  ):
638
  """add_phone (软重试开 + 未超限) → paused=False + retry_after."""
 
639
  monkeypatch.setattr(manager_mod, "_is_email_in_team", lambda email: True)
640
  monkeypatch.setenv("AUTO_CHECK_RETRY_ADD_PHONE", "1")
641
  monkeypatch.setenv("AUTO_CHECK_ADD_PHONE_MAX_RETRIES", "3")
@@ -739,6 +741,40 @@ class TestRecordAuthRepairFailure:
739
  assert acc["disabled"] is True
740
  assert acc["retired_reason"] == "auth_repair_failed:login_state_lost"
741
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
742
  def test_login_state_lost_preserves_protected_local_credential(
743
  self, isolated_accounts, tmp_path, monkeypatch
744
  ):
@@ -776,6 +812,48 @@ class TestRecordAuthRepairFailure:
776
  assert acc["disabled"] is False
777
  assert acc.get("retired_reason") is None
778
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
779
  def test_failure_when_not_in_team_lands_standby(
780
  self, seeded_account, monkeypatch
781
  ):
 
588
  ):
589
  """普通错误 → 衰退式 retry_after, paused=False, status=AUTH_INVALID(留 team)。"""
590
  # Account is in team → final_status should be AUTH_INVALID
591
+ monkeypatch.setattr("autoteam.config.ROTATE_SKIP_REUSE", False)
592
  monkeypatch.setattr(manager_mod, "_is_email_in_team", lambda email: True)
593
 
594
  result = manager_mod._record_auth_repair_failure(
 
637
  self, seeded_account, monkeypatch
638
  ):
639
  """add_phone (软重试开 + 未超限) → paused=False + retry_after."""
640
+ monkeypatch.setattr("autoteam.config.ROTATE_SKIP_REUSE", False)
641
  monkeypatch.setattr(manager_mod, "_is_email_in_team", lambda email: True)
642
  monkeypatch.setenv("AUTO_CHECK_RETRY_ADD_PHONE", "1")
643
  monkeypatch.setenv("AUTO_CHECK_ADD_PHONE_MAX_RETRIES", "3")
 
741
  assert acc["disabled"] is True
742
  assert acc["retired_reason"] == "auth_repair_failed:login_state_lost"
743
 
744
+ @pytest.mark.parametrize(
745
+ "error_type",
746
+ ["non_team_plan", "oauth_timeout", "auth_code_missing"],
747
+ )
748
+ def test_aggressive_login_link_failures_release_child(
749
+ self, seeded_account, monkeypatch, error_type
750
+ ):
751
+ """One-shot login/link failures should release managed child capacity under skip-reuse."""
752
+ monkeypatch.setattr("autoteam.config.ROTATE_SKIP_REUSE", True)
753
+ monkeypatch.setattr(manager_mod, "_is_email_in_team", lambda email: True)
754
+ monkeypatch.setattr(manager_mod.time, "time", lambda: 1_700_000_000)
755
+ monkeypatch.setattr(
756
+ manager_mod,
757
+ "_release_auth_repair_team_seat",
758
+ lambda email, **kw: "removed",
759
+ )
760
+
761
+ result = manager_mod._record_auth_repair_failure(
762
+ seeded_account,
763
+ error_type=error_type,
764
+ error_detail="login/link failure",
765
+ )
766
+
767
+ assert result["auth_retry_count"] == 1
768
+ assert result["auth_retry_paused"] is True
769
+ assert result["auth_retry_after"] is None
770
+ assert result["release_attempted"] is True
771
+ assert result["seat_released"] is True
772
+ assert result["status"] == accounts_mod.STATUS_STANDBY
773
+ acc = accounts_mod.find_account(accounts_mod.load_accounts(), seeded_account)
774
+ assert acc["disabled"] is True
775
+ assert acc["reuse_disabled"] is True
776
+ assert acc["retired_reason"] == f"auth_repair_failed:{error_type}"
777
+
778
  def test_login_state_lost_preserves_protected_local_credential(
779
  self, isolated_accounts, tmp_path, monkeypatch
780
  ):
 
812
  assert acc["disabled"] is False
813
  assert acc.get("retired_reason") is None
814
 
815
+ def test_auth_error_releases_protected_managed_child(
816
+ self, isolated_accounts, tmp_path, monkeypatch
817
+ ):
818
+ """Managed child seats may be released even when stale protection flags remain."""
819
+ auth_file = tmp_path / "managed-seat.json"
820
+ auth_file.write_text("{}", encoding="utf-8")
821
+ accounts_mod.add_account("managed@example.com", "pwd")
822
+ accounts_mod.update_account(
823
+ "managed@example.com",
824
+ status=accounts_mod.STATUS_ACTIVE,
825
+ auth_file=str(auth_file),
826
+ mail_account_id=123,
827
+ protect_team_seat=True,
828
+ )
829
+ monkeypatch.setattr("autoteam.config.ROTATE_SKIP_REUSE", True)
830
+ monkeypatch.setattr(manager_mod, "_is_email_in_team", lambda email: True)
831
+ monkeypatch.setattr(manager_mod.time, "time", lambda: 1_700_000_000)
832
+ monkeypatch.setattr(
833
+ manager_mod,
834
+ "_release_auth_repair_team_seat",
835
+ lambda email, **kw: "removed",
836
+ )
837
+
838
+ result = manager_mod._record_auth_repair_failure(
839
+ "managed@example.com",
840
+ error_type="auth_error_discard",
841
+ error_detail="token invalid",
842
+ )
843
+
844
+ assert result["auth_retry_paused"] is True
845
+ assert result["protected_local_credential"] is True
846
+ assert result["protected_replacement_override"] is True
847
+ assert result["release_attempted"] is True
848
+ assert result["seat_released"] is True
849
+ assert result["status"] == accounts_mod.STATUS_STANDBY
850
+ acc = accounts_mod.find_account(accounts_mod.load_accounts(), "managed@example.com")
851
+ assert acc["status"] == accounts_mod.STATUS_STANDBY
852
+ assert acc["disabled"] is True
853
+ assert acc["reuse_disabled"] is True
854
+ assert acc["retired_at"] == 1_700_000_000
855
+ assert acc["retired_reason"] == "auth_repair_failed:auth_error_discard"
856
+
857
  def test_failure_when_not_in_team_lands_standby(
858
  self, seeded_account, monkeypatch
859
  ):