Spaces:
Paused
Paused
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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 |
):
|