Spaces:
Paused
Paused
fix: harden seat rotation and direct signup race
Browse files- .env.example +2 -0
- .trellis/spec/backend/free-registration-hardening.md +99 -3
- .trellis/spec/backend/runtime-docker-hardening.md +6 -4
- .trellis/tasks/05-19-autoteam1-seat-rotation-hardening-migration/check.jsonl +5 -0
- .trellis/tasks/05-19-autoteam1-seat-rotation-hardening-migration/implement.jsonl +6 -0
- .trellis/tasks/05-19-autoteam1-seat-rotation-hardening-migration/prd.md +147 -0
- .trellis/tasks/05-19-autoteam1-seat-rotation-hardening-migration/research/current-vs-autoteam1-seat-rotation.md +113 -0
- .trellis/tasks/05-19-autoteam1-seat-rotation-hardening-migration/task.json +26 -0
- src/autoteam/api.py +47 -11
- src/autoteam/config.py +4 -0
- src/autoteam/manager.py +576 -65
- src/autoteam/multi_master.py +7 -1
- tests/unit/test_api_status.py +42 -0
- tests/unit/test_free_registration_hardening.py +75 -0
- tests/unit/test_manager_fill.py +44 -0
- tests/unit/test_manager_rotate.py +76 -0
- tests/unit/test_multi_master.py +4 -3
- tests/unit/test_reconcile_anomalies.py +8 -8
.env.example
CHANGED
|
@@ -86,6 +86,8 @@ AUTO_CHECK_THRESHOLD=10 # 额度低于此百分比触发轮转,默认 10%
|
|
| 86 |
AUTO_CHECK_MIN_LOW=2 # 至少几个账号低于阈值才触发,默认 2
|
| 87 |
AUTO_CHECK_RETRY_ADD_PHONE=true # 是否自动重试 add_phone(手机号验证)
|
| 88 |
AUTO_CHECK_ADD_PHONE_MAX_RETRIES=3 # add_phone 最大自动重试次数
|
|
|
|
|
|
|
| 89 |
|
| 90 |
# 多 Team 母号并行调度(每个 Team 仍保持 1 母 + 最多 2 子)
|
| 91 |
MULTI_MASTER_MAX_OWNER_WORKERS=2 # 同时处理几个母号
|
|
|
|
| 86 |
AUTO_CHECK_MIN_LOW=2 # 至少几个账号低于阈值才触发,默认 2
|
| 87 |
AUTO_CHECK_RETRY_ADD_PHONE=true # 是否自动重试 add_phone(手机号验证)
|
| 88 |
AUTO_CHECK_ADD_PHONE_MAX_RETRIES=3 # add_phone 最大自动重试次数
|
| 89 |
+
ROTATE_SKIP_REUSE=true # 轮换默认不复用旧/失败子号,满员时先移旧号再创建新号
|
| 90 |
+
ROTATE_MAX_DURATION=1500 # 单次 rotate 最长运行秒数,超时优雅退出交给下轮巡检
|
| 91 |
|
| 92 |
# 多 Team 母号并行调度(每个 Team 仍保持 1 母 + 最多 2 子)
|
| 93 |
MULTI_MASTER_MAX_OWNER_WORKERS=2 # 同时处理几个母号
|
.trellis/spec/backend/free-registration-hardening.md
CHANGED
|
@@ -13,10 +13,10 @@
|
|
| 13 |
- `TaskParams.leave_workspace: bool = False`
|
| 14 |
- `TaskParams.target: int = 3`
|
| 15 |
- `post_fill(params: TaskParams = TaskParams())`
|
| 16 |
-
- `cmd_fill(target=3, leave_workspace=False)`
|
| 17 |
- `_cmd_fill_personal(count)`
|
| 18 |
-
- `create_new_account(chatgpt_api, mail_client=None, *, leave_workspace=False, out_outcome=None, acc=None, path_rotator=None)`
|
| 19 |
-
- `create_account_direct(mail_client=None, *, leave_workspace=False, out_outcome=None, acc=None, path_rotator=None)`
|
| 20 |
- `_run_post_register_oauth(email, password, mail_client, leave_workspace=False, out_outcome=None, chatgpt_session_token=None, signup_profile=None)`
|
| 21 |
- `generate_signup_profile(*, today: date | None = None, rng: random.Random | random.SystemRandom | None = None) -> SignupProfile`
|
| 22 |
|
|
@@ -105,3 +105,99 @@ in_team_local = _count_local_team_seat_accounts(load_accounts())
|
|
| 105 |
```
|
| 106 |
|
| 107 |
Keep the API entrypoint and manager entrypoint aligned so unsafe fill-personal work is rejected before starting browser or mail-provider operations.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
- `TaskParams.leave_workspace: bool = False`
|
| 14 |
- `TaskParams.target: int = 3`
|
| 15 |
- `post_fill(params: TaskParams = TaskParams())`
|
| 16 |
+
- `cmd_fill(target=3, leave_workspace=False, *, post_sync=True, print_status=True, direct_parallel=None)`
|
| 17 |
- `_cmd_fill_personal(count)`
|
| 18 |
+
- `create_new_account(chatgpt_api, mail_client=None, *, leave_workspace=False, out_outcome=None, acc=None, path_rotator=None, parallel=None)`
|
| 19 |
+
- `create_account_direct(mail_client=None, *, leave_workspace=False, out_outcome=None, acc=None, path_rotator=None, parallel=None)`
|
| 20 |
- `_run_post_register_oauth(email, password, mail_client, leave_workspace=False, out_outcome=None, chatgpt_session_token=None, signup_profile=None)`
|
| 21 |
- `generate_signup_profile(*, today: date | None = None, rng: random.Random | random.SystemRandom | None = None) -> SignupProfile`
|
| 22 |
|
|
|
|
| 105 |
```
|
| 106 |
|
| 107 |
Keep the API entrypoint and manager entrypoint aligned so unsafe fill-personal work is rejected before starting browser or mail-provider operations.
|
| 108 |
+
|
| 109 |
+
## Scenario: Direct Signup Race and Managed Child Validation
|
| 110 |
+
|
| 111 |
+
### 1. Scope / Trigger
|
| 112 |
+
|
| 113 |
+
- Trigger: any change to direct signup, `DIRECT_REGISTER_PARALLEL`, multi-master owner fill budgets, Team fill/rotate child creation, or auto-check decisions when local usable children are below target.
|
| 114 |
+
- Goal: improve throughput without breaking the `1 owner + 2 managed children = 3 seats` contract or accepting a newly created child before remote/auth/quota validation.
|
| 115 |
+
|
| 116 |
+
### 2. Signatures
|
| 117 |
+
|
| 118 |
+
- `DIRECT_REGISTER_PARALLEL: int`, clamped to `1..4`.
|
| 119 |
+
- `AUTOTEAM_REGISTER_PARALLEL_MEMORY_WARN_RATIO`, default `0.72`.
|
| 120 |
+
- `AUTOTEAM_REGISTER_PARALLEL_MAX_BROWSER_LIVE`, default `4`.
|
| 121 |
+
- `_direct_register_parallel_size() -> int`.
|
| 122 |
+
- `_cap_direct_register_parallel(requested: int) -> int`.
|
| 123 |
+
- `_attempt_chatgpt_signup_only(mail_client, *, acc=None, out_outcome=None) -> dict`.
|
| 124 |
+
- `_race_chatgpt_signup(mail_client_factory, *, parallel: int, acc=None, out_outcome=None) -> dict`.
|
| 125 |
+
- `create_account_direct(..., parallel=None)`.
|
| 126 |
+
- `create_new_account(..., parallel=None)`.
|
| 127 |
+
- `cmd_fill(..., direct_parallel=None)`.
|
| 128 |
+
- `_validate_managed_account_operational(email, *, threshold: int, stage_label="[轮转验收]", chatgpt_api=None) -> bool`.
|
| 129 |
+
|
| 130 |
+
### 3. Contracts
|
| 131 |
+
|
| 132 |
+
- `parallel=None` means read `DIRECT_REGISTER_PARALLEL` and then apply local runtime downgrades.
|
| 133 |
+
- Direct signup race must run independent signup-only workers. Only the winner is persisted with `add_account()` and passed into `_run_post_register_oauth()`.
|
| 134 |
+
- Loser workers that successfully reached Team must be removed from Team, have their temporary mailbox discarded, and have account-scoped IPv6 proxy state released.
|
| 135 |
+
- The same `SignupProfile` used by a winning signup worker must be passed into post-registration OAuth.
|
| 136 |
+
- `cmd_fill(..., direct_parallel=N)` must pass `N` into `create_new_account(..., parallel=N)`. Multi-master worker budgets must not remain display-only metadata.
|
| 137 |
+
- New managed children created by fill/rotate must pass remote member presence, local auth file, and Codex quota checks before counting as filled.
|
| 138 |
+
- If a new child fails validation, release the remote Team seat and mark the local row back to standby with a diagnostic reason.
|
| 139 |
+
- `ROTATE_SKIP_REUSE=true` means pending invites and standby reuse are skipped for automated fill/rotate; replacement must be remove-before-create under full Team.
|
| 140 |
+
- Auto-check cooldown/full-Team logic must still trigger `auto-fill` when local usable active children are below target and replaceable blockers are present.
|
| 141 |
+
|
| 142 |
+
### 4. Validation & Error Matrix
|
| 143 |
+
|
| 144 |
+
| Condition | Required behavior |
|
| 145 |
+
| --- | --- |
|
| 146 |
+
| `DIRECT_REGISTER_PARALLEL <= 1` | Run the existing serial direct signup path |
|
| 147 |
+
| Runtime memory ratio exceeds warn threshold | Downgrade direct signup race to `1` |
|
| 148 |
+
| Browser process count exceeds max live threshold | Downgrade direct signup race to `1` |
|
| 149 |
+
| A race worker fails registration | Clean up its temporary mailbox through the existing failure path |
|
| 150 |
+
| A non-winning race worker succeeds | Remove it from Team, delete its mailbox, and release its proxy |
|
| 151 |
+
| Winning child lacks local auth file | Do not count it as filled; release/mark standby |
|
| 152 |
+
| Winning child is absent from remote Team member list | Do not count it as filled; release/mark standby |
|
| 153 |
+
| Winning child quota is below threshold or auth fails | Do not count it as filled; release/mark standby |
|
| 154 |
+
| Team is full and a replaceable blocker exists | Remove the blocker first, wait for observed capacity, then create the replacement |
|
| 155 |
+
| Team is full and no blocker exists | Do not create before remove |
|
| 156 |
+
|
| 157 |
+
### 5. Good/Base/Bad Cases
|
| 158 |
+
|
| 159 |
+
- Good: `DIRECT_REGISTER_PARALLEL=3` starts three signup-only attempts, persists one winner, and reports race counts in `out_outcome`.
|
| 160 |
+
- Good: multi-master owner fill computes a direct parallel budget and the worker calls `cmd_fill(..., direct_parallel=budget)`.
|
| 161 |
+
- Base: serial `parallel=1` keeps the previous duplicate-swap and add-phone behavior.
|
| 162 |
+
- Bad: creating a second child before removing an unusable full-Team blocker.
|
| 163 |
+
- Bad: counting a newly created child as successful before remote/auth/quota validation.
|
| 164 |
+
- Bad: only displaying `direct_register_parallel` in task metadata while `create_account_direct()` still runs serially.
|
| 165 |
+
|
| 166 |
+
### 6. Tests Required
|
| 167 |
+
|
| 168 |
+
- `tests/unit/test_free_registration_hardening.py`
|
| 169 |
+
- direct signup race starts multiple signup workers and persists only the winner.
|
| 170 |
+
- high memory or high browser-live runtime snapshots downgrade parallel to `1`.
|
| 171 |
+
- `tests/unit/test_multi_master.py`
|
| 172 |
+
- owner worker passes `direct_parallel` into `cmd_fill`.
|
| 173 |
+
- `tests/unit/test_manager_fill.py`
|
| 174 |
+
- `cmd_fill` passes direct parallel into `create_new_account` and releases a child that fails validation.
|
| 175 |
+
- `tests/unit/test_manager_rotate.py`
|
| 176 |
+
- replaceable blocker reasons are concrete.
|
| 177 |
+
- full-Team replacement removes the blocker before creating a child.
|
| 178 |
+
- `tests/unit/test_api_status.py`
|
| 179 |
+
- auto-check cooldown/full-Team logic still starts `auto-fill` when replaceable blockers exist.
|
| 180 |
+
|
| 181 |
+
### 7. Wrong vs Correct
|
| 182 |
+
|
| 183 |
+
#### Wrong
|
| 184 |
+
|
| 185 |
+
```python
|
| 186 |
+
result = create_new_account(chatgpt, mail_client)
|
| 187 |
+
if result:
|
| 188 |
+
current_count += 1
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
This treats "created" as "operational" and can leave a dead child occupying one of the two managed seats.
|
| 192 |
+
|
| 193 |
+
#### Correct
|
| 194 |
+
|
| 195 |
+
```python
|
| 196 |
+
created_email = create_new_account(chatgpt, mail_client, parallel=direct_parallel)
|
| 197 |
+
if created_email and _validate_managed_account_operational(created_email, threshold=threshold, chatgpt_api=chatgpt):
|
| 198 |
+
current_count += 1
|
| 199 |
+
else:
|
| 200 |
+
remove_from_team(chatgpt, created_email, return_status=True)
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
Creation, Team membership, local auth, and quota are separate facts. A child becomes usable only after all are validated.
|
.trellis/spec/backend/runtime-docker-hardening.md
CHANGED
|
@@ -182,7 +182,7 @@ Use `require_browser=True` for any path that depends on a real browser context.
|
|
| 182 |
- `build_multi_master_status(accounts=None, pool=None) -> dict`
|
| 183 |
- `resolve_worker_budget(owner_count, *, requested_owner_workers=None, requested_direct_parallel=None, runtime_snapshot=None) -> dict`
|
| 184 |
- `run_multi_master_fill(target_seats=3, *, owner_workers=None, direct_parallel=None, workspace_ids=None, dry_run=False, post_sync=True, pool=None, worker=None) -> dict`
|
| 185 |
-
- `cmd_fill(target=3, leave_workspace=False, *, post_sync=True, print_status=True)`
|
| 186 |
- `POST /api/tasks/multi-master/fill`
|
| 187 |
|
| 188 |
### 3. Contracts
|
|
@@ -193,8 +193,8 @@ Use `require_browser=True` for any path that depends on a real browser context.
|
|
| 193 |
- Per-owner workers must use `temporary_admin_state(...)` so `ChatGPTTeamAPI.start()` reads the owner-local session/account/workspace data without mutating global `state.json`.
|
| 194 |
- Owner worker failures are isolated. One failed owner becomes one failed result row and must not prevent other submitted owners from completing.
|
| 195 |
- `session_token` may be persisted in the local workspace pool for imported owners, but API status and task results must expose only `session_present`, never the raw token.
|
| 196 |
-
- `MULTI_MASTER_MAX_OWNER_WORKERS`, `MULTI_MASTER_BROWSER_BUDGET`, `MULTI_MASTER_MEMORY_DOWNGRADE_RATIO`, and `DIRECT_REGISTER_PARALLEL` are the scheduling budget inputs.
|
| 197 |
-
- When owner workers call `cmd_fill`, they must set `post_sync=False`
|
| 198 |
- `/api/status.multi_master` is additive. It must not remove or rename existing status fields and must not raise a 500 if multi-master diagnostics fail.
|
| 199 |
|
| 200 |
### 4. Validation & Error Matrix
|
|
@@ -206,6 +206,7 @@ Use `require_browser=True` for any path that depends on a real browser context.
|
|
| 206 |
| Owner row has no session token | Mark it non-runnable in status; execution may fail only that owner |
|
| 207 |
| Runtime memory ratio >= `MULTI_MASTER_MEMORY_DOWNGRADE_RATIO` | Force `owner_workers=1` and `direct_register_parallel=1` with reason `memory_high` |
|
| 208 |
| `owner_workers * direct_register_parallel` exceeds `MULTI_MASTER_BROWSER_BUDGET` | Clip both values to stay within the global browser budget |
|
|
|
|
| 209 |
| One owner worker raises | Record `last_error` for that workspace and return overall `partial_failed` when other owners complete |
|
| 210 |
| All owner workers raise | Return overall `failed` |
|
| 211 |
| Parent post-sync raises | Return `post_sync.ok=false`; keep per-owner results intact |
|
|
@@ -214,6 +215,7 @@ Use `require_browser=True` for any path that depends on a real browser context.
|
|
| 214 |
### 5. Good/Base/Bad Cases
|
| 215 |
|
| 216 |
- Good: two imported owners marked `parallel=true` are filled inside one `multi-master-fill` task, each owner uses its own temporary admin state, and final `post_sync` runs once.
|
|
|
|
| 217 |
- Base: a single-owner install with no parallel rows still reports compatible status and can dry-run against the active workspace.
|
| 218 |
- Bad: increasing `target` above `3` to gain throughput, because this breaks the Team-seat contract.
|
| 219 |
- Bad: calling `cmd_fill()` concurrently with its default `post_sync=True`, because each worker would race remote CPA sync and make delete-guard behavior harder to reason about.
|
|
@@ -226,7 +228,7 @@ Use `require_browser=True` for any path that depends on a real browser context.
|
|
| 226 |
- `WorkspacePool.upsert` persists owner metadata and keeps the active-workspace invariant.
|
| 227 |
- `build_multi_master_status` groups accounts by `workspace_account_id` and omits session tokens.
|
| 228 |
- `resolve_worker_budget` downgrades on high memory and clips by browser budget.
|
| 229 |
-
- `run_multi_master_fill` isolates owner failures, records `last_error`, suppresses worker-local sync/status, and reports parent `post_sync`.
|
| 230 |
- API dry-run returns a plan and passes request parameters through.
|
| 231 |
- Existing single-owner regressions must still pass:
|
| 232 |
- `tests/unit/test_round12_s7_workspace_pool.py`
|
|
|
|
| 182 |
- `build_multi_master_status(accounts=None, pool=None) -> dict`
|
| 183 |
- `resolve_worker_budget(owner_count, *, requested_owner_workers=None, requested_direct_parallel=None, runtime_snapshot=None) -> dict`
|
| 184 |
- `run_multi_master_fill(target_seats=3, *, owner_workers=None, direct_parallel=None, workspace_ids=None, dry_run=False, post_sync=True, pool=None, worker=None) -> dict`
|
| 185 |
+
- `cmd_fill(target=3, leave_workspace=False, *, post_sync=True, print_status=True, direct_parallel=None)`
|
| 186 |
- `POST /api/tasks/multi-master/fill`
|
| 187 |
|
| 188 |
### 3. Contracts
|
|
|
|
| 193 |
- Per-owner workers must use `temporary_admin_state(...)` so `ChatGPTTeamAPI.start()` reads the owner-local session/account/workspace data without mutating global `state.json`.
|
| 194 |
- Owner worker failures are isolated. One failed owner becomes one failed result row and must not prevent other submitted owners from completing.
|
| 195 |
- `session_token` may be persisted in the local workspace pool for imported owners, but API status and task results must expose only `session_present`, never the raw token.
|
| 196 |
+
- `MULTI_MASTER_MAX_OWNER_WORKERS`, `MULTI_MASTER_BROWSER_BUDGET`, `MULTI_MASTER_MEMORY_DOWNGRADE_RATIO`, and `DIRECT_REGISTER_PARALLEL` are the scheduling budget inputs. `run_multi_master_fill()` must pass the resolved `direct_register_parallel` into `cmd_fill(..., direct_parallel=...)`, and `cmd_fill()` must pass it into `create_new_account(..., parallel=...)`.
|
| 197 |
+
- When owner workers call `cmd_fill`, they must set `post_sync=False`, `print_status=False`, and the budgeted `direct_parallel` value. The parent multi-master task may run CPA sync once after all owners finish; a post-sync failure should be reported in `post_sync` without converting completed owner work into failed owner work.
|
| 198 |
- `/api/status.multi_master` is additive. It must not remove or rename existing status fields and must not raise a 500 if multi-master diagnostics fail.
|
| 199 |
|
| 200 |
### 4. Validation & Error Matrix
|
|
|
|
| 206 |
| Owner row has no session token | Mark it non-runnable in status; execution may fail only that owner |
|
| 207 |
| Runtime memory ratio >= `MULTI_MASTER_MEMORY_DOWNGRADE_RATIO` | Force `owner_workers=1` and `direct_register_parallel=1` with reason `memory_high` |
|
| 208 |
| `owner_workers * direct_register_parallel` exceeds `MULTI_MASTER_BROWSER_BUDGET` | Clip both values to stay within the global browser budget |
|
| 209 |
+
| Owner worker starts `cmd_fill` | Pass the resolved `direct_parallel`; do not leave it as display-only metadata |
|
| 210 |
| One owner worker raises | Record `last_error` for that workspace and return overall `partial_failed` when other owners complete |
|
| 211 |
| All owner workers raise | Return overall `failed` |
|
| 212 |
| Parent post-sync raises | Return `post_sync.ok=false`; keep per-owner results intact |
|
|
|
|
| 215 |
### 5. Good/Base/Bad Cases
|
| 216 |
|
| 217 |
- Good: two imported owners marked `parallel=true` are filled inside one `multi-master-fill` task, each owner uses its own temporary admin state, and final `post_sync` runs once.
|
| 218 |
+
- Good: a multi-master dry-run/result reports the same `direct_register_parallel` that execution passes into `cmd_fill`.
|
| 219 |
- Base: a single-owner install with no parallel rows still reports compatible status and can dry-run against the active workspace.
|
| 220 |
- Bad: increasing `target` above `3` to gain throughput, because this breaks the Team-seat contract.
|
| 221 |
- Bad: calling `cmd_fill()` concurrently with its default `post_sync=True`, because each worker would race remote CPA sync and make delete-guard behavior harder to reason about.
|
|
|
|
| 228 |
- `WorkspacePool.upsert` persists owner metadata and keeps the active-workspace invariant.
|
| 229 |
- `build_multi_master_status` groups accounts by `workspace_account_id` and omits session tokens.
|
| 230 |
- `resolve_worker_budget` downgrades on high memory and clips by browser budget.
|
| 231 |
+
- `run_multi_master_fill` isolates owner failures, records `last_error`, suppresses worker-local sync/status, passes `direct_parallel` into `cmd_fill`, and reports parent `post_sync`.
|
| 232 |
- API dry-run returns a plan and passes request parameters through.
|
| 233 |
- Existing single-owner regressions must still pass:
|
| 234 |
- `tests/unit/test_round12_s7_workspace_pool.py`
|
.trellis/tasks/05-19-autoteam1-seat-rotation-hardening-migration/check.jsonl
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"file": ".trellis/spec/backend/index.md", "reason": "backend quality verification entrypoint"}
|
| 2 |
+
{"file": ".trellis/spec/backend/free-registration-hardening.md", "reason": "verify direct signup race, child validation, and Team seat contracts"}
|
| 3 |
+
{"file": ".trellis/spec/backend/runtime-docker-hardening.md", "reason": "verify multi-master budget propagation and runtime downgrade behavior"}
|
| 4 |
+
{"file": ".trellis/spec/backend/account-disable-cpa-sync.md", "reason": "verify disabled/protected credential and CPA guard behavior was not weakened"}
|
| 5 |
+
{"file": ".trellis/tasks/05-19-autoteam1-seat-rotation-hardening-migration/research/current-vs-autoteam1-seat-rotation.md", "reason": "verify implementation matches the migration slice and avoids whole-file copying"}
|
.trellis/tasks/05-19-autoteam1-seat-rotation-hardening-migration/implement.jsonl
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"file": ".trellis/spec/backend/index.md", "reason": "backend task entrypoint and applicable spec index"}
|
| 2 |
+
{"file": ".trellis/spec/backend/free-registration-hardening.md", "reason": "direct signup race, fill-personal, Team seat, SignupProfile, and managed child validation contracts"}
|
| 3 |
+
{"file": ".trellis/spec/backend/runtime-docker-hardening.md", "reason": "multi-master direct parallel budget, runtime downgrade, Playwright/browser budget, and status contracts"}
|
| 4 |
+
{"file": ".trellis/spec/backend/account-disable-cpa-sync.md", "reason": "disabled accounts and CPA delete guard must remain excluded from automated rotation/reuse/sync"}
|
| 5 |
+
{"file": ".trellis/spec/guides/index.md", "reason": "shared code-reuse and cross-layer thinking triggers for config/API/manager changes"}
|
| 6 |
+
{"file": ".trellis/tasks/05-19-autoteam1-seat-rotation-hardening-migration/research/current-vs-autoteam1-seat-rotation.md", "reason": "current vs autoteam-1 migration comparison and selected Approach B context"}
|
.trellis/tasks/05-19-autoteam1-seat-rotation-hardening-migration/prd.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# autoteam-1 seat rotation hardening migration
|
| 2 |
+
|
| 3 |
+
## Goal
|
| 4 |
+
|
| 5 |
+
将 `D:\Desktop\autoteam-1\AutoTeam` 中围绕 Team 席位轮换、注册链路、运行态校验、CPA 同步防护和资源治理的一系列优化,经过差异审计后迁移到当前项目 `D:\Desktop\AutoTeam`。目标不是机械照搬,而是吸收目标仓库已验证有效的设计,并按当前项目现有状态机、注册栈、多母号调度和运行约束做适配性改善。
|
| 6 |
+
|
| 7 |
+
## What I Already Know
|
| 8 |
+
|
| 9 |
+
* 用户明确要求走 `$trellis-brainstorm`,本轮先规划和收敛 PRD,不直接改业务代码。
|
| 10 |
+
* 当前项目已有大量相关 in-progress Trellis 任务;本任务必须避免把无关 WIP 混入实现。
|
| 11 |
+
* 当前仓库已吸收不少目标仓能力:3-seat clamp、runtime validation、`runtime_resources`、IPv6 pool/proxy、CLIProxy health、Docker/runtime hardening、multi-master scaffold。
|
| 12 |
+
* 目标仓最新值得迁移的剩余能力集中在:`ROTATE_SKIP_REUSE` 默认跳过旧号复用、replaceable pool blocker 判定、满员时 remove-before-create、每个新 child 的 operational validation、更细的 auto-check action 分流、rotate heartbeat / duration fuse、以及 direct signup race 的真实接入。
|
| 13 |
+
* 当前项目和目标项目已经明显分叉。`api.py`、`manager.py`、`cpa_sync.py` 和 `test_api_status.py` 仍有大差异,不能整文件覆盖。
|
| 14 |
+
|
| 15 |
+
## Requirements
|
| 16 |
+
|
| 17 |
+
* 以当前项目为主线迁移,不覆盖现有 `RegisterPathRotator`、多 provider mail fallback、SignupProfile 复用、Playwright cleanup、multi-master scaffold、CPA delete guard 和前端状态面板。
|
| 18 |
+
* 保持单 Team 硬约束:`1 owner + 2 managed children = 3 seats`,不得通过 create-before-remove 或临时超席位绕过。
|
| 19 |
+
* 将目标仓的“不可用但占席 child”概念适配到当前 `STATUS_AUTH_INVALID` / account-state 语义,识别 missing-auth、auth-error、auth-retry paused、exhausted、auth-invalid 等 blocker。
|
| 20 |
+
* 当 Team 已满但存在可替换 blocker 或确认不可用 child 时,必须先远端移除旧 child,确认 capacity 后再创建 replacement。
|
| 21 |
+
* 新建 child 不能只以“创建成功”算成功;需验证远端 member 存在、本地 auth 文件存在、Codex quota/auth 可用,再计入可用 pool。
|
| 22 |
+
* 自动巡检需要区分至少四类动作:低额度轮换、真实 seat shortage 补位、auth repair、超员 cleanup;cooldown 只限制低额度抖动,不能阻止真实缺席补位或确认无额度占席替换。
|
| 23 |
+
* 长任务需要可观测进度:rotate/fill 关键阶段应有 heartbeat 或等价 progress 标记;单次 rotate 需要 duration fuse,避免和 watchdog/recovery 周期互相撞车。
|
| 24 |
+
* CPA/CLIProxyAPI 同步必须继续采用 delete guard。degraded pool、active list 不完整、远端列表读取失败时不得删除远端 auth。
|
| 25 |
+
* 若纳入 direct signup race,必须受当前 multi-master browser budget 约束,并保留当前注册路径轮换与 provider fallback。
|
| 26 |
+
|
| 27 |
+
## Acceptance Criteria
|
| 28 |
+
|
| 29 |
+
* [x] 研究记录明确标注当前已吸收、仍缺失、需适配、不能覆盖的目标仓能力。
|
| 30 |
+
* [x] 实现前选定 MVP slice,并把不进入本轮的迁移项列入 Out of Scope。
|
| 31 |
+
* [x] 若进入实现,新增/更新测试覆盖:3-seat cap、remove-before-create、protected local credential guard、replaceable blocker reason、auto-check shortage/auth-repair/cleanup/cooldown 分流、runtime validation degraded/failed 语义。
|
| 32 |
+
* [x] 若实现 child validation,测试必须证明新 child 未通过远端/auth/quota 验收时不会被计入完成,并会释放或标记为待处理。
|
| 33 |
+
* [x] 若实现 direct signup race,测试必须证明 `DIRECT_REGISTER_PARALLEL` 被实际传入注册路径,且高内存/低 budget 时自动降级。
|
| 34 |
+
* [x] 运行 `ruff`、相关 `pytest`,并在需要时用本地 API/status/task 证据确认运行态字段。
|
| 35 |
+
* [x] 不修改、提交或回滚与本任务无关的既有未提交 WIP。
|
| 36 |
+
|
| 37 |
+
## Research References
|
| 38 |
+
|
| 39 |
+
* [`research/current-vs-autoteam1-seat-rotation.md`](research/current-vs-autoteam1-seat-rotation.md) — re-verified current vs target migration notes and recommended slices.
|
| 40 |
+
|
| 41 |
+
## Technical Approach
|
| 42 |
+
|
| 43 |
+
### Approach A: Safe rotation core (Recommended)
|
| 44 |
+
|
| 45 |
+
先迁移目标仓的轮换安全核心:blocker 分类、remove-before-create、managed child validation、auto-check action 分流、cooldown 语义、heartbeat 和 duration fuse。保留现有注册栈和 multi-master scaffold,不在同一 slice 接入 direct signup race。
|
| 46 |
+
|
| 47 |
+
Pros:
|
| 48 |
+
|
| 49 |
+
* 最贴近用户说的“轮换席位等一系列优化”。
|
| 50 |
+
* 风险集中在后端轮换/巡检,可用单元测试和局部 API 验证闭环。
|
| 51 |
+
* 避免同时引入 direct registration 并发导致 Playwright 资源风险放大。
|
| 52 |
+
|
| 53 |
+
Cons:
|
| 54 |
+
|
| 55 |
+
* 注册成功率/速度提升主要来自更准确的轮换和验证,不会立即获得目标仓 direct signup race 的吞吐收益。
|
| 56 |
+
|
| 57 |
+
### Approach B: Rotation core + direct signup race
|
| 58 |
+
|
| 59 |
+
在 Approach A 基础上同时迁移目标仓 `_race_chatgpt_signup` / `DIRECT_REGISTER_PARALLEL`,并接入当前 `RegisterPathRotator`、`cmd_fill` 和 multi-master browser budget。
|
| 60 |
+
|
| 61 |
+
Pros:
|
| 62 |
+
|
| 63 |
+
* 一次性覆盖轮换准确性和单账号注册吞吐。
|
| 64 |
+
* 与多母号并行调度的最终方向更接近。
|
| 65 |
+
|
| 66 |
+
Cons:
|
| 67 |
+
|
| 68 |
+
* 风险显著更高:Playwright 并发、邮箱 provider 限速、注册失败分类、全局任务锁都要一起验证。
|
| 69 |
+
* 更容易和现有 `05-18-multi-master-parallel-registration` 任务边界重叠。
|
| 70 |
+
|
| 71 |
+
### Approach C: Audit-only decomposition
|
| 72 |
+
|
| 73 |
+
本任务只产出迁移审计和拆分子任务,不进入实现。随后把 rotation core、direct signup race、multi-master parity 分别建子任务执行。
|
| 74 |
+
|
| 75 |
+
Pros:
|
| 76 |
+
|
| 77 |
+
* 最稳,不碰当前脏工作区。
|
| 78 |
+
* 适合用户希望先梳理多个 in-progress 任务关系时使用。
|
| 79 |
+
|
| 80 |
+
Cons:
|
| 81 |
+
|
| 82 |
+
* 不能立即改善当前运行态轮换效率和准确性。
|
| 83 |
+
|
| 84 |
+
## Decision (ADR-lite)
|
| 85 |
+
|
| 86 |
+
Context: 当前项目已经吸收了目标仓的一批低耦合能力,但仍缺目标仓最新的“占席 blocker → 先移后补 → 子号验收 → 细分巡检动作”闭环。整文件复制会破坏当前 Round 11/12 注册栈和多母号架构。
|
| 87 |
+
|
| 88 |
+
Decision: 选择 Approach B:Rotation core + direct signup race。
|
| 89 |
+
|
| 90 |
+
Consequences: 本轮同时解决席位轮换准确性和单账号注册吞吐。实现必须把 direct signup race 接入当前 `RegisterPathRotator` / `cmd_fill` / multi-master browser budget,而不能只暴露配置或结果字段。风险集中在 Playwright 并发、邮箱 provider 限速、注册失败归因和测试矩阵扩大,需要更宽的单元测试和至少一次资源预算降级验证。
|
| 91 |
+
|
| 92 |
+
## Out of Scope
|
| 93 |
+
|
| 94 |
+
* 本 brainstorm 阶段不直接修改业务代码。
|
| 95 |
+
* 不提高单 Team seat cap,不做 create-before-remove。
|
| 96 |
+
* 不整文件覆盖目标仓 `api.py`、`manager.py`、`cpa_sync.py`。
|
| 97 |
+
* 不使用会上传或删除远端 auth 文件的 `/api/sync` 作为 smoke test。
|
| 98 |
+
* 不重启或扰动当前 live container,除非后续实现阶段明确需要并经过验证窗口。
|
| 99 |
+
* 不自动创建新的 Team owner 母号;多母号并行仅复用当前已有/imported owner scaffold。
|
| 100 |
+
|
| 101 |
+
## Expansion Sweep
|
| 102 |
+
|
| 103 |
+
Future evolution:
|
| 104 |
+
|
| 105 |
+
* 本轮 rotation core 后,可以把 direct signup race 作为独立 slice 接到 multi-master browser budget。
|
| 106 |
+
* 后续多母号并行可复用同一 blocker/validation contract,但按 owner 分组运行。
|
| 107 |
+
|
| 108 |
+
Related scenarios:
|
| 109 |
+
|
| 110 |
+
* API `/api/status`、任务 `validation`、前端 PoolPage/TeamPage 需要继续展示同一套健康语义。
|
| 111 |
+
* CPA/CLIProxyAPI sync 和 account cleanup 必须共享 protected credential / delete guard 规则。
|
| 112 |
+
|
| 113 |
+
Failure and edge cases:
|
| 114 |
+
|
| 115 |
+
* Team count probe unknown、remote remove delayed、new child auth missing、quota API network_error、auth repair throttled、protected local credential、disabled account、external remote member。
|
| 116 |
+
* Windows 本地环境与 Docker runtime 对 Playwright/browser 资源限制不同,实现需保留降级和可观测日志。
|
| 117 |
+
|
| 118 |
+
## Technical Notes
|
| 119 |
+
|
| 120 |
+
* Task dir: `.trellis/tasks/05-19-autoteam1-seat-rotation-hardening-migration`
|
| 121 |
+
* Current repo: `D:\Desktop\AutoTeam`
|
| 122 |
+
* Target repo: `D:\Desktop\autoteam-1\AutoTeam`
|
| 123 |
+
* GitNexus 当前未索引 AutoTeam;Serena MCP 在本会话返回参数错误,本轮已记录回退到本地只读搜索。
|
| 124 |
+
* External search was used only to validate general SaaS lifecycle direction: remove/deprovision before replacement, preflight quota, idempotent retry, background reconciliation, audit logging.
|
| 125 |
+
|
| 126 |
+
## Implementation Notes
|
| 127 |
+
|
| 128 |
+
Date: 2026-05-19
|
| 129 |
+
|
| 130 |
+
Implemented Approach B in the current repo without whole-file copying from the target repo:
|
| 131 |
+
|
| 132 |
+
* Added `ROTATE_SKIP_REUSE` and `ROTATE_MAX_DURATION` config/env examples.
|
| 133 |
+
* Added replaceable pool blocker classification for `missing_auth`, `auth_error`, `auth_retry_*`, `auth_invalid`, `quota_exhausted`, and `orphan`, while preserving disabled/protected local credential seats.
|
| 134 |
+
* Wired remove-before-create semantics into `cmd_rotate`: replaceable blockers are removed first, remote capacity is polled, then replacements are created.
|
| 135 |
+
* Added managed child operational validation after fill/rotate creation. A new child must have local auth, remote Team membership, and Codex quota above threshold before it counts as filled; failed validation releases/marks the child standby.
|
| 136 |
+
* Wired direct signup race through `create_account_direct(..., parallel=...)`, `create_new_account(..., parallel=...)`, `cmd_fill(..., direct_parallel=...)`, and multi-master owner workers. Race workers run signup-only attempts; only the winner is persisted/OAuth'd.
|
| 137 |
+
* Added runtime downgrade for direct race based on memory ratio and live browser process count.
|
| 138 |
+
* Updated auto-check so cooldown/full-Team observations do not block rotation when replaceable local blockers exist.
|
| 139 |
+
* Updated backend specs for the direct signup race, child validation, and multi-master budget propagation contracts.
|
| 140 |
+
|
| 141 |
+
Verification:
|
| 142 |
+
|
| 143 |
+
* `python -m py_compile src/autoteam/manager.py src/autoteam/multi_master.py src/autoteam/config.py`
|
| 144 |
+
* `python -m ruff check src/autoteam/api.py src/autoteam/manager.py src/autoteam/multi_master.py src/autoteam/config.py tests/unit/test_api_status.py tests/unit/test_manager_rotate.py tests/unit/test_manager_fill.py tests/unit/test_free_registration_hardening.py tests/unit/test_multi_master.py tests/unit/test_reconcile_anomalies.py`
|
| 145 |
+
* `pytest -q tests/unit` -> `826 passed, 1 warning`
|
| 146 |
+
|
| 147 |
+
Runtime note: no live Docker container was restarted and `/api/sync` was not used as a smoke test.
|
.trellis/tasks/05-19-autoteam1-seat-rotation-hardening-migration/research/current-vs-autoteam1-seat-rotation.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Current vs autoteam-1 seat rotation migration notes
|
| 2 |
+
|
| 3 |
+
Date: 2026-05-19
|
| 4 |
+
|
| 5 |
+
## Scope
|
| 6 |
+
|
| 7 |
+
This note re-checks the current `D:\Desktop\AutoTeam` tree against the target `D:\Desktop\autoteam-1\AutoTeam` tree for the user's requested migration around seat rotation and related hardening. Older Trellis audit files are useful history but some of their conclusions are stale because current `main` has since absorbed IPv6 pool, CLIProxy health, runtime resources, multi-master scaffolding, and other features.
|
| 8 |
+
|
| 9 |
+
## Re-verified current baseline
|
| 10 |
+
|
| 11 |
+
* Current repo recent commits include `ead7e93 feat: add multi-master Team fill scheduler`, `5aff1af fix: 对齐注册轮转链路和 CloudMail 配置提示`, `8543971 feat: add ipv6 proxy pool and status surface`, and `a011d67 fix: harden Playwright and HTTP transport cleanup`.
|
| 12 |
+
* Target repo recent commits include `276ed0b fix: harden quota blocker rotation`, `f120570 fix rotate post sync and cliproxy health`, `6f4d106 fix invite blank page recovery`, `15e50d6 Improve registration credential sync and diagnostics`, `d952ce6 fix rotation cooldown safety boundary`, `2fb3031 fix rotation validation and ipv6 pool status`, and `7d46b46 fix autoteam seat rotation runtime hardening`.
|
| 13 |
+
* `git diff --no-index --stat` still shows large divergence in `api.py`, `manager.py`, `cpa_sync.py`, and `tests/unit/test_api_status.py`, so broad copying would be unsafe.
|
| 14 |
+
* GitNexus does not currently index either AutoTeam tree. Serena MCP returned parameter errors in this session. Code evidence here comes from local read-only searches and targeted file reads.
|
| 15 |
+
|
| 16 |
+
## Already absorbed in current repo
|
| 17 |
+
|
| 18 |
+
* 3-seat clamp exists: current `src/autoteam/config.py` clamps `AUTO_CHECK_TARGET_SEATS` to `1..3`; current `manager.py` has `TEAM_SEATS_MAX = 3` semantics via `_clamp_team_target_seats`.
|
| 19 |
+
* Runtime validation and status exposure exist: current `api.py` has `_log_task_runtime_validation`, `_rotation_validation_cooldown`, `/api/status.runtime_resources`, `/api/status.ipv6_pool`, and `/api/status.cliproxy`.
|
| 20 |
+
* IPv6 pool/proxy exists in current repo: `src/autoteam/ipv6_pool.py`, `src/autoteam/ipv6_proxy.py`, `manager._ensure_account_ipv6_proxy`, `chatgpt_api` integration, `cpa_sync` proxy refresh, `tests/unit/test_ipv6_pool.py`.
|
| 21 |
+
* CLIProxyAPI read-only health exists: `src/autoteam/cliproxy_health.py`, `tests/unit/test_cliproxy_health.py`, and `web/src/components/PoolPage.vue` references `status.cliproxy`.
|
| 22 |
+
* Docker/runtime hardening has a completed local audit under `.trellis/tasks/05-15-autoteam1-hardening-docker-apply/completion-audit-2026-05-15.md`.
|
| 23 |
+
* Multi-master scaffolding exists in current repo: `src/autoteam/multi_master.py`, `tests/unit/test_multi_master.py`, and a dry-run API path. It groups owners and budgets owner workers, but it does not by itself solve the single-owner rotate blocker semantics below.
|
| 24 |
+
|
| 25 |
+
## Remaining target behaviors not fully applied
|
| 26 |
+
|
| 27 |
+
### 1. Default skip-reuse / new-account replacement policy
|
| 28 |
+
|
| 29 |
+
Target repo has `ROTATE_SKIP_REUSE=true` in config and uses it throughout `manager.cmd_rotate`, `cmd_check`, and `create_new_account` to avoid reusing old/disabled/retired child accounts. Current repo does not define `ROTATE_SKIP_REUSE`; current `cmd_rotate` still prioritizes standby reuse and describes itself as "尽量少创建新账号". This is a real policy difference.
|
| 30 |
+
|
| 31 |
+
Migration risk: current repo also has Round 12 provider fallback and `RegisterPathRotator`. A target-style skip-reuse policy should be added as an opt-in/defaulted config and threaded through current reuse paths rather than replacing the current manager wholesale.
|
| 32 |
+
|
| 33 |
+
### 2. Replaceable pool blocker semantics
|
| 34 |
+
|
| 35 |
+
Target repo has:
|
| 36 |
+
|
| 37 |
+
* `manager._account_auth_state_blocks_pool_use`
|
| 38 |
+
* `manager._is_pool_active_account_usable`
|
| 39 |
+
* `manager._replaceable_pool_blocker_reason`
|
| 40 |
+
* `manager._is_replaceable_pool_blocker`
|
| 41 |
+
* `api._collect_auto_check_state` that classifies active/auth-pending/missing-auth/auth-error/low-quota candidates before choosing rotate, auth repair, cleanup, or no-op.
|
| 42 |
+
|
| 43 |
+
Current repo search found no equivalent symbols. Current auto-check mostly counts `STATUS_ACTIVE` rows with existing auth files, handles basic cooldown, and triggers `auto-fill`; it does not classify local occupied-but-unusable Team seats as replaceable blockers.
|
| 44 |
+
|
| 45 |
+
Migration value: this is likely the most important remaining target improvement for rotation accuracy. It prevents "Team full but local usable pool short" from being treated as healthy.
|
| 46 |
+
|
| 47 |
+
### 3. Remove-before-create replacement under full Team
|
| 48 |
+
|
| 49 |
+
Target `cmd_rotate` contains `attempt_remove_then_create(...)`, `_wait_for_remote_capacity_after_removal(...)`, and managed-account post-create validation. When the Team is already full and a child must be replaced, it removes a concrete old child, waits for remote capacity, then creates the replacement. Current `cmd_rotate` removes exhausted rows before creating, but lacks target's explicit full-Team remove-first transaction for local blockers and confirmed unusable low-quota seats.
|
| 50 |
+
|
| 51 |
+
Migration value: aligns with the hard 3-seat cap and avoids create-before-remove behavior. This also matches external SaaS lifecycle guidance: deprovision/confirm capacity before provisioning replacements, keep operations idempotent, and record audit outcomes.
|
| 52 |
+
|
| 53 |
+
### 4. Managed account operational validation
|
| 54 |
+
|
| 55 |
+
Target repo has `manager._validate_managed_account_operational(...)` and tests requiring a new managed child to be present remotely and have usable Codex auth/quota. Current repo has post-task validation in `api.py`, but current `cmd_rotate` does not validate each replacement child before treating the vacancy as filled.
|
| 56 |
+
|
| 57 |
+
Migration value: catches degraded successes earlier and allows immediate release/discard of a newly created but unusable child.
|
| 58 |
+
|
| 59 |
+
### 5. More nuanced auto-check actions
|
| 60 |
+
|
| 61 |
+
Target auto-check can trigger:
|
| 62 |
+
|
| 63 |
+
* `auto-rotate` for real seat shortage, confirmed low/exhausted blockers, or local_pool_blocker.
|
| 64 |
+
* `auto-auth-repair` when remote Team is full but local auth is missing/invalid and retry is not throttled.
|
| 65 |
+
* `auto-cleanup` when remote Team exceeds target.
|
| 66 |
+
* Cooldown bypass for real Team shortage or confirmed exhausted remote blocker, but cooldown delay for low-quota churn when capacity is otherwise healthy.
|
| 67 |
+
|
| 68 |
+
Current tests cover only two cooldown cases around real shortage vs full Team. Target `tests/unit/test_api_status.py` has many more cases around degraded cooldown, unknown probes, auth repair, cleanup, and blocker rotation.
|
| 69 |
+
|
| 70 |
+
Migration value: improves both efficiency and accuracy without changing the 3-seat contract.
|
| 71 |
+
|
| 72 |
+
### 6. Task progress heartbeat and rotate duration fuse
|
| 73 |
+
|
| 74 |
+
Target `api.py` exposes `bump_task_progress(...)`, and target `cmd_rotate` uses `ROTATE_MAX_DURATION` plus `_deadline_exceeded(...)`. Current repo search found no `bump_task_progress`, `ROTATE_MAX_DURATION`, or deadline helper.
|
| 75 |
+
|
| 76 |
+
Migration value: long registration/rotation jobs can show healthy forward progress to watchdog-style observers and stop before colliding with the next recovery loop.
|
| 77 |
+
|
| 78 |
+
### 7. Direct registration race is still not wired into single-owner registration
|
| 79 |
+
|
| 80 |
+
Current `.env.example` contains `DIRECT_REGISTER_PARALLEL=1`, and `multi_master.resolve_worker_budget(...)` budgets `direct_register_parallel`, but current `manager.py` does not contain target's `_direct_register_parallel_size`, `_cap_direct_register_parallel`, or `_race_chatgpt_signup`. The current multi-master worker passes the direct-parallel value in result metadata but calls `cmd_fill(...)` without passing it into actual account creation.
|
| 81 |
+
|
| 82 |
+
Migration value: if the project wants target's direct signup race, it must be wired into current `create_account_direct` / `cmd_fill` path while preserving `RegisterPathRotator`, provider fallback, SignupProfile reuse, Playwright cleanup, and global browser budget.
|
| 83 |
+
|
| 84 |
+
## Areas that should not be blindly overwritten
|
| 85 |
+
|
| 86 |
+
* Current repo uses `STATUS_AUTH_INVALID = "auth_invalid"` and an account-state machine where AUTH_PENDING maps to auth invalid semantics. Target repo uses `STATUS_AUTH_PENDING = "auth_pending"`. Migration must map concepts, not copy constants.
|
| 87 |
+
* Current repo has newer `RegisterPathRotator`, multi-provider mail fallback, multi-master scaffolding, frontend status display, and stronger CPA live quota decision logic. Target `manager.py`, `api.py`, or `cpa_sync.py` should not be copied wholesale.
|
| 88 |
+
* CPA sync in current repo already has a local `_active_auth_publish_decision` with live quota checks, disabled-account skip, personal credential handling, IPv6 proxy refresh, and delete guard. Target ideas should be compared at behavior level only.
|
| 89 |
+
|
| 90 |
+
## External practice check
|
| 91 |
+
|
| 92 |
+
Grok Search query on SaaS seat rotation and lifecycle automation returned consistent general guidance:
|
| 93 |
+
|
| 94 |
+
* Deprovision or deactivate the old user/seat first, confirm capacity, then provision the replacement.
|
| 95 |
+
* Preflight current seat count and quota before adding.
|
| 96 |
+
* Make operations retry-safe and idempotent.
|
| 97 |
+
* Use background jobs plus full/incremental reconciliation, with audit logs and failure alerts.
|
| 98 |
+
|
| 99 |
+
These are compatible with the target repo's remove-before-create and validation direction, but current implementation details must still be governed by local code.
|
| 100 |
+
|
| 101 |
+
## Recommended migration slices
|
| 102 |
+
|
| 103 |
+
### Slice A: Safe rotation core (recommended MVP)
|
| 104 |
+
|
| 105 |
+
Implement target-equivalent blocker classification, remove-before-create, child validation, cooldown semantics, rotate heartbeat, and duration fuse in current code. Preserve current registration provider/fallback stack and multi-master scaffolding.
|
| 106 |
+
|
| 107 |
+
### Slice B: Direct signup race integration
|
| 108 |
+
|
| 109 |
+
After Slice A, wire target direct registration race into current `create_account_direct` / `RegisterPathRotator` path and multi-master browser budget. This has higher Playwright resource risk.
|
| 110 |
+
|
| 111 |
+
### Slice C: Broad target parity
|
| 112 |
+
|
| 113 |
+
Attempt to converge all target repo rotation/registration behaviors in one larger implementation. This is highest risk because current repo has newer divergent architecture and many active Trellis tasks.
|
.trellis/tasks/05-19-autoteam1-seat-rotation-hardening-migration/task.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "autoteam1-seat-rotation-hardening-migration",
|
| 3 |
+
"name": "autoteam1-seat-rotation-hardening-migration",
|
| 4 |
+
"title": "brainstorm: migrate autoteam-1 seat rotation hardening",
|
| 5 |
+
"description": "",
|
| 6 |
+
"status": "in_progress",
|
| 7 |
+
"dev_type": null,
|
| 8 |
+
"scope": null,
|
| 9 |
+
"package": null,
|
| 10 |
+
"priority": "P2",
|
| 11 |
+
"creator": "ZRainbow",
|
| 12 |
+
"assignee": "ZRainbow",
|
| 13 |
+
"createdAt": "2026-05-19",
|
| 14 |
+
"completedAt": null,
|
| 15 |
+
"branch": null,
|
| 16 |
+
"base_branch": "main",
|
| 17 |
+
"worktree_path": null,
|
| 18 |
+
"commit": null,
|
| 19 |
+
"pr_url": null,
|
| 20 |
+
"subtasks": [],
|
| 21 |
+
"children": [],
|
| 22 |
+
"parent": null,
|
| 23 |
+
"relatedFiles": [],
|
| 24 |
+
"notes": "",
|
| 25 |
+
"meta": {}
|
| 26 |
+
}
|
src/autoteam/api.py
CHANGED
|
@@ -3592,6 +3592,22 @@ def _auto_check_loop():
|
|
| 3592 |
and a.get("auth_file")
|
| 3593 |
and Path(a["auth_file"]).exists()
|
| 3594 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3595 |
|
| 3596 |
# Watchdog:active 账号数 < 子号目标时自动补位。
|
| 3597 |
# 之前的 `if not active: continue` 在 active 全 kick 进 standby
|
|
@@ -3615,10 +3631,20 @@ def _auto_check_loop():
|
|
| 3615 |
# 否则 cooldown 会把实际席位缺口拖到下一轮甚至 4h backoff 之后。
|
| 3616 |
actual_team_count = _auto_check_team_member_count(timeout_seconds=30, retries=2)
|
| 3617 |
if actual_team_count >= target_seats:
|
| 3618 |
-
|
| 3619 |
-
|
| 3620 |
-
|
| 3621 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3622 |
elif actual_team_count >= 0:
|
| 3623 |
logger.info(
|
| 3624 |
"[巡检] auto-fill 冷却中但 Team 实际成员不足(%d/%d),继续补位",
|
|
@@ -3667,13 +3693,23 @@ def _auto_check_loop():
|
|
| 3667 |
|
| 3668 |
actual_team_count = _auto_check_team_member_count(timeout_seconds=30, retries=2)
|
| 3669 |
if actual_team_count >= target_seats:
|
| 3670 |
-
|
| 3671 |
-
|
| 3672 |
-
|
| 3673 |
-
|
| 3674 |
-
|
| 3675 |
-
|
| 3676 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3677 |
should_start_auto_fill = True
|
| 3678 |
|
| 3679 |
if should_start_auto_fill:
|
|
|
|
| 3592 |
and a.get("auth_file")
|
| 3593 |
and Path(a["auth_file"]).exists()
|
| 3594 |
]
|
| 3595 |
+
try:
|
| 3596 |
+
from autoteam.manager import _replaceable_pool_blocker_reason
|
| 3597 |
+
|
| 3598 |
+
replaceable_blockers = [
|
| 3599 |
+
{
|
| 3600 |
+
"email": a.get("email"),
|
| 3601 |
+
"reason": reason,
|
| 3602 |
+
}
|
| 3603 |
+
for a in accounts
|
| 3604 |
+
if not _is_main_account_email(a.get("email"))
|
| 3605 |
+
and not is_account_disabled(a)
|
| 3606 |
+
and (reason := _replaceable_pool_blocker_reason(a))
|
| 3607 |
+
]
|
| 3608 |
+
except Exception as exc:
|
| 3609 |
+
logger.warning("[巡检] 本地占席 blocker 分类失败: %s", exc)
|
| 3610 |
+
replaceable_blockers = []
|
| 3611 |
|
| 3612 |
# Watchdog:active 账号数 < 子号目标时自动补位。
|
| 3613 |
# 之前的 `if not active: continue` 在 active 全 kick 进 standby
|
|
|
|
| 3631 |
# 否则 cooldown 会把实际席位缺口拖到下一轮甚至 4h backoff 之后。
|
| 3632 |
actual_team_count = _auto_check_team_member_count(timeout_seconds=30, retries=2)
|
| 3633 |
if actual_team_count >= target_seats:
|
| 3634 |
+
if replaceable_blockers:
|
| 3635 |
+
logger.warning(
|
| 3636 |
+
"[巡检] auto-fill 冷却中但 Team 已满且存在 %d 个不可用占席子号,继续触发轮转: %s",
|
| 3637 |
+
len(replaceable_blockers),
|
| 3638 |
+
", ".join(
|
| 3639 |
+
f"{item['email']}({item['reason']})" for item in replaceable_blockers[:5]
|
| 3640 |
+
),
|
| 3641 |
+
)
|
| 3642 |
+
should_start_auto_fill = True
|
| 3643 |
+
else:
|
| 3644 |
+
logger.info(
|
| 3645 |
+
"[巡检] auto-fill 冷却中且 Team 实际成员数=%d 已满;本轮只保留低额度替换检查",
|
| 3646 |
+
actual_team_count,
|
| 3647 |
+
)
|
| 3648 |
elif actual_team_count >= 0:
|
| 3649 |
logger.info(
|
| 3650 |
"[巡检] auto-fill 冷却中但 Team 实际成员不足(%d/%d),继续补位",
|
|
|
|
| 3693 |
|
| 3694 |
actual_team_count = _auto_check_team_member_count(timeout_seconds=30, retries=2)
|
| 3695 |
if actual_team_count >= target_seats:
|
| 3696 |
+
if replaceable_blockers:
|
| 3697 |
+
logger.warning(
|
| 3698 |
+
"[巡检] Team 已满但本地 active=%d/%d 且存在不可用占席子号,触发 auto-fill 修复: %s",
|
| 3699 |
+
len(active),
|
| 3700 |
+
sub_account_target,
|
| 3701 |
+
", ".join(
|
| 3702 |
+
f"{item['email']}({item['reason']})" for item in replaceable_blockers[:5]
|
| 3703 |
+
),
|
| 3704 |
+
)
|
| 3705 |
+
else:
|
| 3706 |
+
logger.info(
|
| 3707 |
+
"[巡检] 本地 active=%d < %d,但 Team 实际成员数=%d 已满;先跳过 auto-fill,等待同步/对账稳定",
|
| 3708 |
+
len(active),
|
| 3709 |
+
sub_account_target,
|
| 3710 |
+
actual_team_count,
|
| 3711 |
+
)
|
| 3712 |
+
continue
|
| 3713 |
should_start_auto_fill = True
|
| 3714 |
|
| 3715 |
if should_start_auto_fill:
|
src/autoteam/config.py
CHANGED
|
@@ -106,6 +106,10 @@ SUB2API_OVERWRITE_ACCOUNT_SETTINGS = _get_bool_env("SUB2API_OVERWRITE_ACCOUNT_SE
|
|
| 106 |
AUTO_CHECK_RETRY_ADD_PHONE = _get_bool_env("AUTO_CHECK_RETRY_ADD_PHONE", True)
|
| 107 |
AUTO_CHECK_ADD_PHONE_MAX_RETRIES = _get_int_env("AUTO_CHECK_ADD_PHONE_MAX_RETRIES", 3)
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
# Round 12 S5 — 预测式抢先替换配置.
|
| 111 |
# PREDICTIVE_ENABLED=false(默认 安全): cmd_rotate 不做预测式 preempt,
|
|
|
|
| 106 |
AUTO_CHECK_RETRY_ADD_PHONE = _get_bool_env("AUTO_CHECK_RETRY_ADD_PHONE", True)
|
| 107 |
AUTO_CHECK_ADD_PHONE_MAX_RETRIES = _get_int_env("AUTO_CHECK_ADD_PHONE_MAX_RETRIES", 3)
|
| 108 |
|
| 109 |
+
# 默认不复用旧/失败/退役子号。Team 满员需要替换时必须先移出旧 child,再创建新 child。
|
| 110 |
+
ROTATE_SKIP_REUSE = _get_bool_env("ROTATE_SKIP_REUSE", True)
|
| 111 |
+
ROTATE_MAX_DURATION = max(60, _get_int_env("ROTATE_MAX_DURATION", 1500))
|
| 112 |
+
|
| 113 |
|
| 114 |
# Round 12 S5 — 预测式抢先替换配置.
|
| 115 |
# PREDICTIVE_ENABLED=false(默认 安全): cmd_rotate 不做预测式 preempt,
|
src/autoteam/manager.py
CHANGED
|
@@ -42,6 +42,7 @@ from autoteam.accounts import (
|
|
| 42 |
delete_account,
|
| 43 |
find_account,
|
| 44 |
get_standby_accounts,
|
|
|
|
| 45 |
is_supported_plan,
|
| 46 |
load_accounts,
|
| 47 |
save_accounts,
|
|
@@ -349,14 +350,83 @@ def _count_pool_active_accounts(accounts: list[dict] | None = None, *, require_a
|
|
| 349 |
accounts = accounts if accounts is not None else load_accounts()
|
| 350 |
count = 0
|
| 351 |
for acc in accounts:
|
| 352 |
-
if
|
| 353 |
-
|
| 354 |
-
|
|
|
|
| 355 |
continue
|
| 356 |
count += 1
|
| 357 |
return count
|
| 358 |
|
| 359 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
def _count_local_team_seat_accounts(accounts: list[dict] | None = None) -> int:
|
| 361 |
"""统计本地"占着 Team 席位"的非主号账号(上游 `.upstream/manager.py:175`).
|
| 362 |
|
|
@@ -368,7 +438,9 @@ def _count_local_team_seat_accounts(accounts: list[dict] | None = None) -> int:
|
|
| 368 |
return sum(
|
| 369 |
1
|
| 370 |
for acc in accounts
|
| 371 |
-
if not _is_main_account_email(acc.get("email"))
|
|
|
|
|
|
|
| 372 |
)
|
| 373 |
|
| 374 |
|
|
@@ -2216,6 +2288,112 @@ def remove_from_team(chatgpt_api, email, *, return_status=False, lookup_retries=
|
|
| 2216 |
return "failed" if return_status else False
|
| 2217 |
|
| 2218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2219 |
def _kick_team_seat_after_oauth_failure(email: str, *, reason: str) -> None:
|
| 2220 |
"""OAuth 失败时同步 KICK ws,消除"workspace 有 + 本地 auth 缺失"残废延迟。
|
| 2221 |
|
|
@@ -3694,59 +3872,97 @@ def _extract_session_token_from_context(context):
|
|
| 3694 |
return session_token or None
|
| 3695 |
|
| 3696 |
|
| 3697 |
-
def
|
| 3698 |
-
|
| 3699 |
-
|
| 3700 |
-
|
| 3701 |
-
|
| 3702 |
-
out_outcome: 可选 dict,函数会把最终结局(success/phone_blocked/duplicate_exhausted/register_failed/...)
|
| 3703 |
-
+ 统计信息(register_attempts / duplicate_swaps / last_email / reason)写入,供上游汇总。
|
| 3704 |
|
| 3705 |
-
捕获 RegisterBlocked:
|
| 3706 |
-
- is_phone=True: 当前邮箱已暴露给 OpenAI,立即删邮箱、整个账号放弃(return None)
|
| 3707 |
-
- is_duplicate=True: 换个临时邮箱继续尝试,独立计数不消耗 register_attempts
|
| 3708 |
-
- 其他异常: 归入现有 retry 计数
|
| 3709 |
|
| 3710 |
-
|
| 3711 |
-
|
| 3712 |
-
|
| 3713 |
-
|
| 3714 |
-
|
| 3715 |
-
包装(参见 `autoteam.mail.register_dual_path`),业务调用方可自行编排。
|
| 3716 |
|
| 3717 |
-
Round 12 wire-up (M3): `path_rotator` 可选 — 传入 `RegisterPathRotator` 实例时
|
| 3718 |
-
启用邮箱-粒度的 provider 切换(OTP_TIMEOUT / DOMAIN_REJECTED / INVITE_LINK_MISSING
|
| 3719 |
-
自动切下一 provider 重试整个注册流程);None 时走旧逻辑(单 provider + retry 3 次).
|
| 3720 |
-
"""
|
| 3721 |
-
from autoteam.invite import RegisterBlocked
|
| 3722 |
|
| 3723 |
-
|
| 3724 |
-
|
| 3725 |
-
|
| 3726 |
-
|
| 3727 |
-
|
| 3728 |
-
|
| 3729 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3730 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3731 |
|
| 3732 |
-
mail_client = _resolve_mail_client_or_default(mail_client, acc=acc)
|
| 3733 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3734 |
account_id, email = mail_client.create_temp_email()
|
| 3735 |
password = random_password()
|
| 3736 |
signup_profile = generate_signup_profile()
|
| 3737 |
auth_proxy_url = ""
|
| 3738 |
playwright_proxy_url = ""
|
|
|
|
| 3739 |
|
| 3740 |
def _record_outcome(status, **extra):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3741 |
if out_outcome is not None:
|
| 3742 |
out_outcome.clear()
|
| 3743 |
-
out_outcome.update(
|
| 3744 |
-
status=status,
|
| 3745 |
-
last_email=email,
|
| 3746 |
-
register_attempts=register_attempts,
|
| 3747 |
-
duplicate_swaps=duplicate_swaps,
|
| 3748 |
-
**extra,
|
| 3749 |
-
)
|
| 3750 |
|
| 3751 |
def _discard_email(reason):
|
| 3752 |
try:
|
|
@@ -3773,6 +3989,20 @@ def create_account_direct(mail_client=None, *, leave_workspace=False, out_outcom
|
|
| 3773 |
_record_outcome("ipv6_proxy_unavailable", reason=str(exc))
|
| 3774 |
return False
|
| 3775 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3776 |
# 注册失败(非 duplicate)最多重试 3 次;duplicate 额外独立上限,防止 CloudMail 异常导致无限换邮箱
|
| 3777 |
success = False
|
| 3778 |
session_token = None # Round 11 四轮 — 注册成功后从 chatgpt.com 抽出���,透给 personal OAuth 跳过 /log-in
|
|
@@ -3781,7 +4011,7 @@ def create_account_direct(mail_client=None, *, leave_workspace=False, out_outcom
|
|
| 3781 |
register_attempts = 0
|
| 3782 |
duplicate_swaps = 0
|
| 3783 |
if not _assign_proxy_or_fail("ipv6_proxy_required_unavailable"):
|
| 3784 |
-
return
|
| 3785 |
|
| 3786 |
while register_attempts < MAX_REGISTER_ATTEMPTS:
|
| 3787 |
logger.info(
|
|
@@ -3826,7 +4056,7 @@ def create_account_direct(mail_client=None, *, leave_workspace=False, out_outcom
|
|
| 3826 |
duplicate_swaps=duplicate_swaps,
|
| 3827 |
)
|
| 3828 |
_record_outcome("phone_blocked", reason=f"add-phone 手机验证 step={blocked.step}", step=blocked.step)
|
| 3829 |
-
return
|
| 3830 |
if blocked.is_duplicate:
|
| 3831 |
# 邮箱重复 → 换一个全新的临时邮箱再来,不计入 register_attempts
|
| 3832 |
duplicate_swaps += 1
|
|
@@ -3843,14 +4073,14 @@ def create_account_direct(mail_client=None, *, leave_workspace=False, out_outcom
|
|
| 3843 |
"duplicate_exhausted",
|
| 3844 |
reason=f"duplicate 换邮箱 {duplicate_swaps} 次仍失败",
|
| 3845 |
)
|
| 3846 |
-
return
|
| 3847 |
_discard_email("duplicate")
|
| 3848 |
account_id, email = mail_client.create_temp_email()
|
| 3849 |
password = random_password()
|
| 3850 |
signup_profile = generate_signup_profile()
|
| 3851 |
logger.info("[直接注册] 已换新临时邮箱: %s", email)
|
| 3852 |
if not _assign_proxy_or_fail("ipv6_proxy_required_unavailable_after_duplicate"):
|
| 3853 |
-
return
|
| 3854 |
continue
|
| 3855 |
# 其他阻断按普通失败处理
|
| 3856 |
success = False
|
|
@@ -3904,14 +4134,182 @@ def create_account_direct(mail_client=None, *, leave_workspace=False, out_outcom
|
|
| 3904 |
duplicate_swaps=duplicate_swaps,
|
| 3905 |
)
|
| 3906 |
_record_outcome("register_failed", reason=f"注册 {register_attempts} 次均未进入 Team")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3907 |
return None
|
| 3908 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3909 |
add_account(email, password, cloudmail_account_id=account_id, workspace_account_id=get_chatgpt_account_id() or None)
|
| 3910 |
|
| 3911 |
post_result = _run_post_register_oauth(
|
| 3912 |
email,
|
| 3913 |
password,
|
| 3914 |
-
|
| 3915 |
leave_workspace=leave_workspace,
|
| 3916 |
out_outcome=out_outcome,
|
| 3917 |
chatgpt_session_token=session_token,
|
|
@@ -3924,7 +4322,9 @@ def create_account_direct(mail_client=None, *, leave_workspace=False, out_outcom
|
|
| 3924 |
return post_result
|
| 3925 |
|
| 3926 |
|
| 3927 |
-
def _create_account_direct_via_rotator(
|
|
|
|
|
|
|
| 3928 |
"""Round 12 wire-up (M3) — 用 RegisterPathRotator 编排邮箱-粒度的 provider 切换。
|
| 3929 |
|
| 3930 |
流程:
|
|
@@ -3955,6 +4355,7 @@ def _create_account_direct_via_rotator(path_rotator, *, leave_workspace=False, o
|
|
| 3955 |
out_outcome=local_outcome,
|
| 3956 |
acc=acc,
|
| 3957 |
path_rotator=None, # 避免递归
|
|
|
|
| 3958 |
)
|
| 3959 |
if result_email is None:
|
| 3960 |
status = local_outcome.get("status") or "register_failed"
|
|
@@ -4003,7 +4404,16 @@ def _create_account_direct_via_rotator(path_rotator, *, leave_workspace=False, o
|
|
| 4003 |
return None
|
| 4004 |
|
| 4005 |
|
| 4006 |
-
def create_new_account(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4007 |
"""
|
| 4008 |
创建新账号。优先用直接注册模式(域名自动加入 workspace)。
|
| 4009 |
chatgpt_api 可为 None(直接注册不需要)。
|
|
@@ -4014,8 +4424,12 @@ def create_new_account(chatgpt_api, mail_client=None, *, leave_workspace=False,
|
|
| 4014 |
"""
|
| 4015 |
# 先检查 pending invites
|
| 4016 |
mail_client = _resolve_mail_client_or_default(mail_client, acc=acc)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4017 |
|
| 4018 |
-
if chatgpt_api and _chatgpt_session_ready(chatgpt_api):
|
| 4019 |
logger.info("[创建] 先检查 pending invites...")
|
| 4020 |
completed = _check_pending_invites(
|
| 4021 |
chatgpt_api,
|
|
@@ -4026,6 +4440,8 @@ def create_new_account(chatgpt_api, mail_client=None, *, leave_workspace=False,
|
|
| 4026 |
if completed:
|
| 4027 |
logger.info("[创建] 从 pending invites 完成了 %d 个账号", len(completed))
|
| 4028 |
return completed[0]
|
|
|
|
|
|
|
| 4029 |
|
| 4030 |
# 直接注册模式(不需要邀请)
|
| 4031 |
logger.info("[创建] 使用直接注册模式...")
|
|
@@ -4037,6 +4453,7 @@ def create_new_account(chatgpt_api, mail_client=None, *, leave_workspace=False,
|
|
| 4037 |
out_outcome=out_outcome,
|
| 4038 |
acc=acc,
|
| 4039 |
path_rotator=path_rotator,
|
|
|
|
| 4040 |
)
|
| 4041 |
|
| 4042 |
|
|
@@ -4689,8 +5106,10 @@ def cmd_rotate(target_seats=3, force_auth_repair=False, background_post_sync=Fal
|
|
| 4689 |
"""
|
| 4690 |
TARGET = _clamp_team_target_seats(target_seats)
|
| 4691 |
ACTIVE_TARGET = _pool_active_target(TARGET)
|
|
|
|
| 4692 |
|
| 4693 |
-
from autoteam.config import AUTO_CHECK_THRESHOLD
|
|
|
|
| 4694 |
|
| 4695 |
try:
|
| 4696 |
from autoteam.api import _auto_check_config
|
|
@@ -4699,6 +5118,28 @@ def cmd_rotate(target_seats=3, force_auth_repair=False, background_post_sync=Fal
|
|
| 4699 |
except ImportError:
|
| 4700 |
threshold = AUTO_CHECK_THRESHOLD
|
| 4701 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4702 |
chatgpt = None
|
| 4703 |
mail_client = None
|
| 4704 |
# 按账号 mail_provider 复用 mail client(同 provider 只 login 一次,避免 N 个号 N 次握手)
|
|
@@ -4766,9 +5207,11 @@ def cmd_rotate(target_seats=3, force_auth_repair=False, background_post_sync=Fal
|
|
| 4766 |
return ensure_mail()
|
| 4767 |
|
| 4768 |
logger.info("[1/5] 同步 Team 状态...")
|
|
|
|
| 4769 |
sync_account_states()
|
| 4770 |
|
| 4771 |
logger.info("[2/5] 检查额度...")
|
|
|
|
| 4772 |
cmd_check()
|
| 4773 |
|
| 4774 |
# Round 12 S5 — 预测式抢先替换(可选,默认关).
|
|
@@ -4814,29 +5257,41 @@ def cmd_rotate(target_seats=3, force_auth_repair=False, background_post_sync=Fal
|
|
| 4814 |
# 移出所有 exhausted 账号(包括之前已标记的)
|
| 4815 |
all_accounts = load_accounts()
|
| 4816 |
all_exhausted = [
|
| 4817 |
-
a
|
|
|
|
|
|
|
| 4818 |
]
|
| 4819 |
initial_api_count = -1
|
| 4820 |
removed_now = 0
|
| 4821 |
already_absent_count = 0
|
| 4822 |
|
| 4823 |
if all_exhausted:
|
| 4824 |
-
logger.info("[3/5] 移出 %d 个
|
| 4825 |
ensure_chatgpt()
|
| 4826 |
initial_api_count = get_team_member_count(chatgpt)
|
| 4827 |
for acc in all_exhausted:
|
|
|
|
|
|
|
| 4828 |
email = acc["email"]
|
|
|
|
| 4829 |
if not _chatgpt_session_ready(chatgpt):
|
| 4830 |
chatgpt.start()
|
| 4831 |
remove_status = remove_from_team(chatgpt, email, return_status=True)
|
| 4832 |
if remove_status in ("removed", "already_absent"):
|
| 4833 |
-
update_account(email, status=STATUS_STANDBY)
|
| 4834 |
if remove_status == "removed":
|
| 4835 |
removed_now += 1
|
| 4836 |
-
logger.info("[3/5] %s → standby(已从 Team 移出)", email)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4837 |
else:
|
| 4838 |
already_absent_count += 1
|
| 4839 |
-
logger.info("[3/5] %s → standby(远端已不存在)", email)
|
| 4840 |
else:
|
| 4841 |
logger.info("[3/5] 无需移出账号")
|
| 4842 |
if not chatgpt or not _chatgpt_session_ready(chatgpt):
|
|
@@ -4880,7 +5335,12 @@ def cmd_rotate(target_seats=3, force_auth_repair=False, background_post_sync=Fal
|
|
| 4880 |
# 只移除本地管理的账号,优先移除额度最低的
|
| 4881 |
all_accs = load_accounts()
|
| 4882 |
local_active = [
|
| 4883 |
-
a
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4884 |
]
|
| 4885 |
# 按额度排序,额度低的优先移除
|
| 4886 |
local_active.sort(key=lambda a: 100 - (a.get("last_quota") or {}).get("primary_pct", 0))
|
|
@@ -4916,7 +5376,11 @@ def cmd_rotate(target_seats=3, force_auth_repair=False, background_post_sync=Fal
|
|
| 4916 |
# 注: 不截断到 vacancies — 旧行为会迭代全部 standby,遇到不可复用的(skipped_auto/
|
| 4917 |
# skipped_quota)就继续看下一个,直到 filled >= vacancies 才 break. 截断会导致
|
| 4918 |
# 第一个候选若 skipped 则放弃后续可用候选,破坏 test_cmd_rotate_skips_google_accounts.
|
| 4919 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4920 |
|
| 4921 |
def _chatgpt_provider():
|
| 4922 |
if not chatgpt or not _chatgpt_session_ready(chatgpt):
|
|
@@ -4958,6 +5422,8 @@ def cmd_rotate(target_seats=3, force_auth_repair=False, background_post_sync=Fal
|
|
| 4958 |
if cancel_signal.is_cancelled():
|
| 4959 |
logger.warning("[轮转] 收到取消请求,中止 standby 复用阶段")
|
| 4960 |
break
|
|
|
|
|
|
|
| 4961 |
if filled >= vacancies:
|
| 4962 |
break
|
| 4963 |
result = _process(acc)
|
|
@@ -5038,11 +5504,29 @@ def cmd_rotate(target_seats=3, force_auth_repair=False, background_post_sync=Fal
|
|
| 5038 |
if cancel_signal.is_cancelled():
|
| 5039 |
logger.warning("[轮转] 收到取消请求,已创建 %d/%d 个新号", i, remaining)
|
| 5040 |
break
|
|
|
|
|
|
|
| 5041 |
logger.info("[5/5] 创建第 %d/%d 个...", i + 1, remaining)
|
| 5042 |
if not chatgpt or not _chatgpt_session_ready(chatgpt):
|
| 5043 |
ensure_chatgpt()
|
| 5044 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5045 |
current_count += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5046 |
|
| 5047 |
if not chatgpt or not _chatgpt_session_ready(chatgpt):
|
| 5048 |
ensure_chatgpt()
|
|
@@ -5346,7 +5830,7 @@ def get_team_member_count(chatgpt_api):
|
|
| 5346 |
return len(members)
|
| 5347 |
|
| 5348 |
|
| 5349 |
-
def cmd_fill(target=3, leave_workspace=False, *, post_sync=True, print_status=True):
|
| 5350 |
"""
|
| 5351 |
补位流程。
|
| 5352 |
leave_workspace=False: 补满 Team 席位到 target(原行为),优先复用 standby 旧号
|
|
@@ -5357,6 +5841,7 @@ def cmd_fill(target=3, leave_workspace=False, *, post_sync=True, print_status=Tr
|
|
| 5357 |
if leave_workspace:
|
| 5358 |
return _cmd_fill_personal(target)
|
| 5359 |
target = _clamp_team_target_seats(target)
|
|
|
|
| 5360 |
|
| 5361 |
chatgpt = ChatGPTTeamAPI()
|
| 5362 |
chatgpt.start()
|
|
@@ -5377,11 +5862,17 @@ def cmd_fill(target=3, leave_workspace=False, *, post_sync=True, print_status=Tr
|
|
| 5377 |
return
|
| 5378 |
|
| 5379 |
logger.info("[填充] 需要添加 %d 个账号", need)
|
| 5380 |
-
|
| 5381 |
-
|
| 5382 |
-
|
| 5383 |
-
|
| 5384 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5385 |
standby_index = 0
|
| 5386 |
|
| 5387 |
from autoteam import cancel_signal
|
|
@@ -5416,7 +5907,27 @@ def cmd_fill(target=3, leave_workspace=False, *, post_sync=True, print_status=Tr
|
|
| 5416 |
logger.info("[填充] 创建新账号...")
|
| 5417 |
if not _chatgpt_session_ready(chatgpt):
|
| 5418 |
chatgpt.start()
|
| 5419 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5420 |
|
| 5421 |
if not added:
|
| 5422 |
logger.warning("[填充] 本轮补位失败,第 %d/%d 个空缺仍未填上", i + 1, need)
|
|
|
|
| 42 |
delete_account,
|
| 43 |
find_account,
|
| 44 |
get_standby_accounts,
|
| 45 |
+
is_account_disabled,
|
| 46 |
is_supported_plan,
|
| 47 |
load_accounts,
|
| 48 |
save_accounts,
|
|
|
|
| 350 |
accounts = accounts if accounts is not None else load_accounts()
|
| 351 |
count = 0
|
| 352 |
for acc in accounts:
|
| 353 |
+
if require_auth:
|
| 354 |
+
if not _is_pool_active_account_usable(acc, require_auth=True):
|
| 355 |
+
continue
|
| 356 |
+
elif _is_main_account_email(acc.get("email")) or is_account_disabled(acc) or acc.get("status") != STATUS_ACTIVE:
|
| 357 |
continue
|
| 358 |
count += 1
|
| 359 |
return count
|
| 360 |
|
| 361 |
|
| 362 |
+
def _account_auth_state_blocks_pool_use(acc: dict | None, *, now: float | None = None) -> bool:
|
| 363 |
+
"""Return True when saved Codex auth is known or likely unusable."""
|
| 364 |
+
acc = acc or {}
|
| 365 |
+
if acc.get("auth_retry_paused"):
|
| 366 |
+
return True
|
| 367 |
+
if acc.get("auth_last_error"):
|
| 368 |
+
return True
|
| 369 |
+
retry_after = acc.get("auth_retry_after")
|
| 370 |
+
if retry_after:
|
| 371 |
+
try:
|
| 372 |
+
return float(retry_after) > (time.time() if now is None else now)
|
| 373 |
+
except (TypeError, ValueError):
|
| 374 |
+
return True
|
| 375 |
+
return False
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def _is_pool_active_account_usable(acc: dict | None, *, require_auth: bool = True) -> bool:
|
| 379 |
+
"""Check whether a child account should count as an actually usable pool seat."""
|
| 380 |
+
acc = acc or {}
|
| 381 |
+
if _is_main_account_email(acc.get("email")) or is_account_disabled(acc):
|
| 382 |
+
return False
|
| 383 |
+
if acc.get("status") != STATUS_ACTIVE:
|
| 384 |
+
return False
|
| 385 |
+
if require_auth and not _has_auth_file(acc):
|
| 386 |
+
return False
|
| 387 |
+
if require_auth and _account_auth_state_blocks_pool_use(acc):
|
| 388 |
+
return False
|
| 389 |
+
return True
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def _replaceable_pool_blocker_reason(acc: dict | None, *, missing_auth: bool = False) -> str | None:
|
| 393 |
+
"""Return the concrete reason a child seat blocks the usable pool but may be replaced."""
|
| 394 |
+
acc = acc or {}
|
| 395 |
+
if _is_main_account_email(acc.get("email")) or is_account_disabled(acc):
|
| 396 |
+
return None
|
| 397 |
+
if _is_protected_local_credential_seat(acc):
|
| 398 |
+
return None
|
| 399 |
+
status = acc.get("status")
|
| 400 |
+
if status == STATUS_EXHAUSTED:
|
| 401 |
+
return "quota_exhausted"
|
| 402 |
+
if status == STATUS_AUTH_INVALID:
|
| 403 |
+
return "auth_invalid"
|
| 404 |
+
if status == STATUS_ORPHAN:
|
| 405 |
+
return "orphan"
|
| 406 |
+
if status != STATUS_ACTIVE:
|
| 407 |
+
return None
|
| 408 |
+
if missing_auth:
|
| 409 |
+
return "auth_error"
|
| 410 |
+
if not _has_auth_file(acc):
|
| 411 |
+
return "missing_auth"
|
| 412 |
+
if acc.get("auth_retry_paused"):
|
| 413 |
+
return "auth_retry_paused"
|
| 414 |
+
if acc.get("auth_last_error"):
|
| 415 |
+
return "auth_error"
|
| 416 |
+
retry_after = acc.get("auth_retry_after")
|
| 417 |
+
if retry_after:
|
| 418 |
+
try:
|
| 419 |
+
if float(retry_after) > time.time():
|
| 420 |
+
return "auth_retry_after"
|
| 421 |
+
except (TypeError, ValueError):
|
| 422 |
+
return "auth_retry_after_invalid"
|
| 423 |
+
return None
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def _is_replaceable_pool_blocker(acc: dict | None, *, missing_auth: bool = False) -> bool:
|
| 427 |
+
return _replaceable_pool_blocker_reason(acc, missing_auth=missing_auth) is not None
|
| 428 |
+
|
| 429 |
+
|
| 430 |
def _count_local_team_seat_accounts(accounts: list[dict] | None = None) -> int:
|
| 431 |
"""统计本地"占着 Team 席位"的非主号账号(上游 `.upstream/manager.py:175`).
|
| 432 |
|
|
|
|
| 438 |
return sum(
|
| 439 |
1
|
| 440 |
for acc in accounts
|
| 441 |
+
if not _is_main_account_email(acc.get("email"))
|
| 442 |
+
and not is_account_disabled(acc)
|
| 443 |
+
and acc.get("status") in seat_statuses
|
| 444 |
)
|
| 445 |
|
| 446 |
|
|
|
|
| 2288 |
return "failed" if return_status else False
|
| 2289 |
|
| 2290 |
|
| 2291 |
+
def _wait_for_remote_capacity_after_removal(
|
| 2292 |
+
chatgpt_api,
|
| 2293 |
+
*,
|
| 2294 |
+
target: int,
|
| 2295 |
+
removed_email: str,
|
| 2296 |
+
timeout: int = 24,
|
| 2297 |
+
poll_interval: float = 3.0,
|
| 2298 |
+
stage_label: str = "[Team]",
|
| 2299 |
+
) -> tuple[int, bool]:
|
| 2300 |
+
"""Poll Team member count after a removal before creating a replacement."""
|
| 2301 |
+
deadline = time.time() + max(1, int(timeout))
|
| 2302 |
+
latest_count = -1
|
| 2303 |
+
while time.time() < deadline:
|
| 2304 |
+
latest_count = get_team_member_count(chatgpt_api)
|
| 2305 |
+
if latest_count >= 0 and latest_count < target:
|
| 2306 |
+
logger.info("%s 已释放 %s 的席位,当前成员 %d/%d", stage_label, removed_email, latest_count, target)
|
| 2307 |
+
return latest_count, True
|
| 2308 |
+
logger.info(
|
| 2309 |
+
"%s 等待 %s 的远端席位释放,当前成员=%s/%d",
|
| 2310 |
+
stage_label,
|
| 2311 |
+
removed_email,
|
| 2312 |
+
latest_count if latest_count >= 0 else "unknown",
|
| 2313 |
+
target,
|
| 2314 |
+
)
|
| 2315 |
+
time.sleep(max(0.5, float(poll_interval)))
|
| 2316 |
+
return latest_count, False
|
| 2317 |
+
|
| 2318 |
+
|
| 2319 |
+
def _get_remote_team_member(email: str | None, chatgpt_api=None) -> dict | None:
|
| 2320 |
+
"""Return remote Team member by email, or None when absent/unknown."""
|
| 2321 |
+
email_l = _normalized_email(email)
|
| 2322 |
+
if not email_l:
|
| 2323 |
+
return None
|
| 2324 |
+
owns_api = chatgpt_api is None
|
| 2325 |
+
api = chatgpt_api or ChatGPTTeamAPI()
|
| 2326 |
+
try:
|
| 2327 |
+
if owns_api or not _chatgpt_session_ready(api):
|
| 2328 |
+
api.start()
|
| 2329 |
+
members, _invites = fetch_team_state(api)
|
| 2330 |
+
for member in members:
|
| 2331 |
+
if _normalized_email(member.get("email")) == email_l:
|
| 2332 |
+
return member
|
| 2333 |
+
return None
|
| 2334 |
+
finally:
|
| 2335 |
+
if owns_api:
|
| 2336 |
+
try:
|
| 2337 |
+
api.stop()
|
| 2338 |
+
except Exception:
|
| 2339 |
+
pass
|
| 2340 |
+
|
| 2341 |
+
|
| 2342 |
+
def _validate_managed_account_operational(
|
| 2343 |
+
email: str,
|
| 2344 |
+
*,
|
| 2345 |
+
threshold: int,
|
| 2346 |
+
stage_label: str = "[轮转验收]",
|
| 2347 |
+
chatgpt_api=None,
|
| 2348 |
+
) -> bool:
|
| 2349 |
+
"""Validate a newly managed child is remotely present and has usable Codex auth/quota."""
|
| 2350 |
+
acc = find_account(load_accounts(), email)
|
| 2351 |
+
if not acc:
|
| 2352 |
+
logger.warning("%s %s 本地账号不存在", stage_label, email)
|
| 2353 |
+
return False
|
| 2354 |
+
if is_account_disabled(acc):
|
| 2355 |
+
logger.warning("%s %s 已禁用,不能计入可用 child", stage_label, email)
|
| 2356 |
+
return False
|
| 2357 |
+
if acc.get("status") != STATUS_ACTIVE:
|
| 2358 |
+
logger.warning("%s %s status=%s,不能计入可用 child", stage_label, email, acc.get("status"))
|
| 2359 |
+
return False
|
| 2360 |
+
if not _has_auth_file(acc):
|
| 2361 |
+
logger.warning("%s %s 缺少可用 auth_file", stage_label, email)
|
| 2362 |
+
return False
|
| 2363 |
+
|
| 2364 |
+
if chatgpt_api is not None:
|
| 2365 |
+
try:
|
| 2366 |
+
member = _get_remote_team_member(email, chatgpt_api=chatgpt_api)
|
| 2367 |
+
except Exception as exc:
|
| 2368 |
+
logger.warning("%s 查询远端成员失败: %s (%s)", stage_label, email, exc)
|
| 2369 |
+
member = None
|
| 2370 |
+
if not member:
|
| 2371 |
+
logger.warning("%s %s 未出现在远端 Team 成员列表", stage_label, email)
|
| 2372 |
+
return False
|
| 2373 |
+
|
| 2374 |
+
try:
|
| 2375 |
+
auth_data = json.loads(read_text(Path(acc["auth_file"])))
|
| 2376 |
+
access_token = auth_data.get("access_token")
|
| 2377 |
+
if not access_token:
|
| 2378 |
+
logger.warning("%s %s auth_file 缺少 access_token", stage_label, email)
|
| 2379 |
+
return False
|
| 2380 |
+
status_str, info = check_codex_quota(access_token)
|
| 2381 |
+
except Exception as exc:
|
| 2382 |
+
logger.warning("%s %s quota 验证异常: %s", stage_label, email, exc)
|
| 2383 |
+
return False
|
| 2384 |
+
|
| 2385 |
+
if status_str != "ok" or not isinstance(info, dict):
|
| 2386 |
+
logger.warning("%s %s quota 验证失败: %s", stage_label, email, status_str)
|
| 2387 |
+
return False
|
| 2388 |
+
primary_remaining = 100 - int(info.get("primary_pct", 100) or 100)
|
| 2389 |
+
if primary_remaining < int(threshold):
|
| 2390 |
+
logger.warning("%s %s quota 剩余 %d%% < %d%%", stage_label, email, primary_remaining, threshold)
|
| 2391 |
+
return False
|
| 2392 |
+
update_account(email, last_quota=info, last_active_at=time.time())
|
| 2393 |
+
logger.info("%s %s 运行态验收通过,5h 剩余 %d%%", stage_label, email, primary_remaining)
|
| 2394 |
+
return True
|
| 2395 |
+
|
| 2396 |
+
|
| 2397 |
def _kick_team_seat_after_oauth_failure(email: str, *, reason: str) -> None:
|
| 2398 |
"""OAuth 失败时同步 KICK ws,消除"workspace 有 + 本地 auth 缺失"残废延迟。
|
| 2399 |
|
|
|
|
| 3872 |
return session_token or None
|
| 3873 |
|
| 3874 |
|
| 3875 |
+
def _direct_register_float_env(name: str, default: float) -> float:
|
| 3876 |
+
try:
|
| 3877 |
+
return float(os.environ.get(name, str(default)))
|
| 3878 |
+
except (TypeError, ValueError):
|
| 3879 |
+
return default
|
|
|
|
|
|
|
| 3880 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3881 |
|
| 3882 |
+
def _direct_register_int_env(name: str, default: int) -> int:
|
| 3883 |
+
try:
|
| 3884 |
+
return int(os.environ.get(name, str(default)))
|
| 3885 |
+
except (TypeError, ValueError):
|
| 3886 |
+
return default
|
|
|
|
| 3887 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3888 |
|
| 3889 |
+
def _cap_direct_register_parallel(requested: int) -> int:
|
| 3890 |
+
requested = max(1, min(4, int(requested or 1)))
|
| 3891 |
+
if requested <= 1:
|
| 3892 |
+
return 1
|
| 3893 |
+
|
| 3894 |
+
try:
|
| 3895 |
+
from autoteam.runtime_resources import collect_runtime_resource_snapshot
|
| 3896 |
+
|
| 3897 |
+
snapshot = collect_runtime_resource_snapshot()
|
| 3898 |
+
except Exception as exc:
|
| 3899 |
+
logger.debug("[直接注册] 读取资源快照失败,保留并行=%d: %s", requested, exc)
|
| 3900 |
+
return requested
|
| 3901 |
+
|
| 3902 |
+
memory_ratio = snapshot.get("cgroup_memory_usage_ratio")
|
| 3903 |
+
browser_live = int(snapshot.get("browser_process_live") or 0)
|
| 3904 |
+
warn_ratio = _direct_register_float_env("AUTOTEAM_REGISTER_PARALLEL_MEMORY_WARN_RATIO", 0.72)
|
| 3905 |
+
max_browser_live = _direct_register_int_env("AUTOTEAM_REGISTER_PARALLEL_MAX_BROWSER_LIVE", 4)
|
| 3906 |
+
|
| 3907 |
+
if memory_ratio is not None and float(memory_ratio) >= warn_ratio:
|
| 3908 |
+
logger.warning(
|
| 3909 |
+
"[直接注册] 内存占用 %.1f%% >= %.1f%%,并行注册从 %d 降级为 1,避免 OOM",
|
| 3910 |
+
float(memory_ratio) * 100,
|
| 3911 |
+
warn_ratio * 100,
|
| 3912 |
+
requested,
|
| 3913 |
)
|
| 3914 |
+
return 1
|
| 3915 |
+
|
| 3916 |
+
if browser_live >= max_browser_live:
|
| 3917 |
+
logger.warning(
|
| 3918 |
+
"[直接注册] 浏览器进程数 %d >= %d,并行注册从 %d 降级为 1,避免进程堆积",
|
| 3919 |
+
browser_live,
|
| 3920 |
+
max_browser_live,
|
| 3921 |
+
requested,
|
| 3922 |
+
)
|
| 3923 |
+
return 1
|
| 3924 |
+
|
| 3925 |
+
return requested
|
| 3926 |
+
|
| 3927 |
+
|
| 3928 |
+
def _direct_register_parallel_size() -> int:
|
| 3929 |
+
"""读取并行尝试数(DIRECT_REGISTER_PARALLEL),范围 [1, 4],再按本机资源降级。"""
|
| 3930 |
+
try:
|
| 3931 |
+
from autoteam.config import DIRECT_REGISTER_PARALLEL
|
| 3932 |
+
|
| 3933 |
+
raw = int(DIRECT_REGISTER_PARALLEL)
|
| 3934 |
+
except Exception:
|
| 3935 |
+
try:
|
| 3936 |
+
raw = int(os.environ.get("DIRECT_REGISTER_PARALLEL", "1"))
|
| 3937 |
+
except Exception:
|
| 3938 |
+
raw = 1
|
| 3939 |
+
return _cap_direct_register_parallel(raw)
|
| 3940 |
|
|
|
|
| 3941 |
|
| 3942 |
+
def _attempt_chatgpt_signup_only(mail_client, *, acc=None, out_outcome=None) -> dict:
|
| 3943 |
+
"""Run direct ChatGPT signup only; caller decides whether to persist/OAuth the winner."""
|
| 3944 |
+
from autoteam.invite import RegisterBlocked
|
| 3945 |
+
|
| 3946 |
+
mail_client = _resolve_mail_client_or_default(mail_client, acc=acc)
|
| 3947 |
account_id, email = mail_client.create_temp_email()
|
| 3948 |
password = random_password()
|
| 3949 |
signup_profile = generate_signup_profile()
|
| 3950 |
auth_proxy_url = ""
|
| 3951 |
playwright_proxy_url = ""
|
| 3952 |
+
local_outcome: dict = {}
|
| 3953 |
|
| 3954 |
def _record_outcome(status, **extra):
|
| 3955 |
+
local_outcome.clear()
|
| 3956 |
+
local_outcome.update(
|
| 3957 |
+
status=status,
|
| 3958 |
+
last_email=email,
|
| 3959 |
+
register_attempts=register_attempts,
|
| 3960 |
+
duplicate_swaps=duplicate_swaps,
|
| 3961 |
+
**extra,
|
| 3962 |
+
)
|
| 3963 |
if out_outcome is not None:
|
| 3964 |
out_outcome.clear()
|
| 3965 |
+
out_outcome.update(local_outcome)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3966 |
|
| 3967 |
def _discard_email(reason):
|
| 3968 |
try:
|
|
|
|
| 3989 |
_record_outcome("ipv6_proxy_unavailable", reason=str(exc))
|
| 3990 |
return False
|
| 3991 |
|
| 3992 |
+
def _build_result(success_value: bool) -> dict:
|
| 3993 |
+
return {
|
| 3994 |
+
"success": bool(success_value),
|
| 3995 |
+
"email": email,
|
| 3996 |
+
"password": password,
|
| 3997 |
+
"account_id": account_id,
|
| 3998 |
+
"session_token": session_token,
|
| 3999 |
+
"signup_profile": signup_profile,
|
| 4000 |
+
"auth_proxy_url": auth_proxy_url,
|
| 4001 |
+
"playwright_proxy_url": playwright_proxy_url,
|
| 4002 |
+
"mail_client": mail_client,
|
| 4003 |
+
"outcome": dict(local_outcome),
|
| 4004 |
+
}
|
| 4005 |
+
|
| 4006 |
# 注册失败(非 duplicate)最多重试 3 次;duplicate 额外独立上限,防止 CloudMail 异常导致无限换邮箱
|
| 4007 |
success = False
|
| 4008 |
session_token = None # Round 11 四轮 — 注册成功后从 chatgpt.com 抽出���,透给 personal OAuth 跳过 /log-in
|
|
|
|
| 4011 |
register_attempts = 0
|
| 4012 |
duplicate_swaps = 0
|
| 4013 |
if not _assign_proxy_or_fail("ipv6_proxy_required_unavailable"):
|
| 4014 |
+
return _build_result(False)
|
| 4015 |
|
| 4016 |
while register_attempts < MAX_REGISTER_ATTEMPTS:
|
| 4017 |
logger.info(
|
|
|
|
| 4056 |
duplicate_swaps=duplicate_swaps,
|
| 4057 |
)
|
| 4058 |
_record_outcome("phone_blocked", reason=f"add-phone 手机验证 step={blocked.step}", step=blocked.step)
|
| 4059 |
+
return _build_result(False)
|
| 4060 |
if blocked.is_duplicate:
|
| 4061 |
# 邮箱重复 → 换一个全新的临时邮箱再来,不计入 register_attempts
|
| 4062 |
duplicate_swaps += 1
|
|
|
|
| 4073 |
"duplicate_exhausted",
|
| 4074 |
reason=f"duplicate 换邮箱 {duplicate_swaps} 次仍失败",
|
| 4075 |
)
|
| 4076 |
+
return _build_result(False)
|
| 4077 |
_discard_email("duplicate")
|
| 4078 |
account_id, email = mail_client.create_temp_email()
|
| 4079 |
password = random_password()
|
| 4080 |
signup_profile = generate_signup_profile()
|
| 4081 |
logger.info("[直接注册] 已换新临时邮箱: %s", email)
|
| 4082 |
if not _assign_proxy_or_fail("ipv6_proxy_required_unavailable_after_duplicate"):
|
| 4083 |
+
return _build_result(False)
|
| 4084 |
continue
|
| 4085 |
# 其他阻断按普通失败处理
|
| 4086 |
success = False
|
|
|
|
| 4134 |
duplicate_swaps=duplicate_swaps,
|
| 4135 |
)
|
| 4136 |
_record_outcome("register_failed", reason=f"注册 {register_attempts} 次均未进入 Team")
|
| 4137 |
+
return _build_result(False)
|
| 4138 |
+
|
| 4139 |
+
_record_outcome("success", reason="")
|
| 4140 |
+
return _build_result(True)
|
| 4141 |
+
|
| 4142 |
+
|
| 4143 |
+
def _direct_signup_mail_client_factory(base_mail_client, acc=None):
|
| 4144 |
+
def _factory(idx: int):
|
| 4145 |
+
if idx <= 0:
|
| 4146 |
+
return base_mail_client
|
| 4147 |
+
try:
|
| 4148 |
+
client = type(base_mail_client)()
|
| 4149 |
+
except Exception:
|
| 4150 |
+
client = _resolve_mail_client_or_default(None, acc=acc)
|
| 4151 |
+
login = getattr(client, "login", None)
|
| 4152 |
+
if callable(login):
|
| 4153 |
+
login()
|
| 4154 |
+
return client
|
| 4155 |
+
|
| 4156 |
+
return _factory
|
| 4157 |
+
|
| 4158 |
+
|
| 4159 |
+
def _discard_direct_signup_loser(outcome: dict) -> None:
|
| 4160 |
+
email = outcome.get("email")
|
| 4161 |
+
account_id = outcome.get("account_id")
|
| 4162 |
+
mail_client = outcome.get("mail_client")
|
| 4163 |
+
if email:
|
| 4164 |
+
chatgpt = None
|
| 4165 |
+
try:
|
| 4166 |
+
chatgpt = ChatGPTTeamAPI()
|
| 4167 |
+
chatgpt.start()
|
| 4168 |
+
remove_from_team(chatgpt, email, return_status=True)
|
| 4169 |
+
except Exception as exc:
|
| 4170 |
+
logger.warning("[直接注册] 清理并行 loser Team 席位失败: %s (%s)", email, exc)
|
| 4171 |
+
finally:
|
| 4172 |
+
if chatgpt is not None:
|
| 4173 |
+
try:
|
| 4174 |
+
chatgpt.stop()
|
| 4175 |
+
except Exception:
|
| 4176 |
+
pass
|
| 4177 |
+
_release_account_ipv6_proxy(email)
|
| 4178 |
+
if account_id is not None and mail_client is not None:
|
| 4179 |
+
try:
|
| 4180 |
+
mail_client.delete_account(account_id)
|
| 4181 |
+
logger.info("[直接注册] 已丢弃并行 loser 临时邮箱: %s", email)
|
| 4182 |
+
except Exception as exc:
|
| 4183 |
+
logger.warning("[直接注册] 丢弃并行 loser 临时邮箱失败: %s (%s)", email, exc)
|
| 4184 |
+
|
| 4185 |
+
|
| 4186 |
+
def _race_chatgpt_signup(mail_client_factory, *, parallel: int, acc=None, out_outcome=None) -> dict:
|
| 4187 |
+
"""Run direct signup race and return the first successful signup-only outcome."""
|
| 4188 |
+
import concurrent.futures
|
| 4189 |
+
|
| 4190 |
+
parallel = _cap_direct_register_parallel(parallel)
|
| 4191 |
+
if parallel <= 1:
|
| 4192 |
+
return _attempt_chatgpt_signup_only(mail_client_factory(0), acc=acc, out_outcome=out_outcome)
|
| 4193 |
+
|
| 4194 |
+
logger.info("[直接注册] 启动 %d 个并行注册尝试", parallel)
|
| 4195 |
+
winner: dict | None = None
|
| 4196 |
+
failures: list[dict] = []
|
| 4197 |
+
losers: list[dict] = []
|
| 4198 |
+
|
| 4199 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=parallel, thread_name_prefix="direct-signup") as pool:
|
| 4200 |
+
future_map = {
|
| 4201 |
+
pool.submit(
|
| 4202 |
+
_attempt_chatgpt_signup_only,
|
| 4203 |
+
mail_client_factory(idx),
|
| 4204 |
+
acc=acc,
|
| 4205 |
+
): idx
|
| 4206 |
+
for idx in range(parallel)
|
| 4207 |
+
}
|
| 4208 |
+
for future in concurrent.futures.as_completed(future_map):
|
| 4209 |
+
try:
|
| 4210 |
+
outcome = future.result()
|
| 4211 |
+
except Exception as exc:
|
| 4212 |
+
logger.warning("[直接注册] 并行 worker 异常: %s", exc)
|
| 4213 |
+
failures.append({"success": False, "outcome": {"status": "exception", "reason": str(exc)}})
|
| 4214 |
+
continue
|
| 4215 |
+
if outcome.get("success") and winner is None:
|
| 4216 |
+
winner = outcome
|
| 4217 |
+
logger.info("[直接注册] 并行获胜: %s", outcome.get("email"))
|
| 4218 |
+
elif outcome.get("success"):
|
| 4219 |
+
losers.append(outcome)
|
| 4220 |
+
else:
|
| 4221 |
+
failures.append(outcome)
|
| 4222 |
+
|
| 4223 |
+
for loser in losers:
|
| 4224 |
+
_discard_direct_signup_loser(loser)
|
| 4225 |
+
|
| 4226 |
+
selected = winner or (failures[-1] if failures else {"success": False, "outcome": {"status": "register_failed"}})
|
| 4227 |
+
if out_outcome is not None:
|
| 4228 |
+
out_outcome.clear()
|
| 4229 |
+
out_outcome.update(selected.get("outcome") or {})
|
| 4230 |
+
out_outcome["direct_register_parallel"] = parallel
|
| 4231 |
+
out_outcome["direct_register_failures"] = len(failures)
|
| 4232 |
+
out_outcome["direct_register_losers"] = len(losers)
|
| 4233 |
+
return selected
|
| 4234 |
+
|
| 4235 |
+
|
| 4236 |
+
def create_account_direct(
|
| 4237 |
+
mail_client=None,
|
| 4238 |
+
*,
|
| 4239 |
+
leave_workspace=False,
|
| 4240 |
+
out_outcome=None,
|
| 4241 |
+
acc=None,
|
| 4242 |
+
path_rotator=None,
|
| 4243 |
+
parallel: int | None = None,
|
| 4244 |
+
):
|
| 4245 |
+
"""
|
| 4246 |
+
直接注册模式(域名已配置自动加入 workspace,不需要邀请)。
|
| 4247 |
+
流程:创建邮箱 → 注册 ChatGPT → 自动加入 workspace → Codex 登录
|
| 4248 |
+
leave_workspace: 加入 workspace 后是否立即退出,转为 personal 模式跑 OAuth。
|
| 4249 |
+
out_outcome: 可选 dict,函数会把最终结局(success/phone_blocked/duplicate_exhausted/register_failed/...)
|
| 4250 |
+
+ 统计信息(register_attempts / duplicate_swaps / last_email / reason)写入,供上游汇总。
|
| 4251 |
+
parallel: direct signup race 并行度;None 时读取 DIRECT_REGISTER_PARALLEL 并按资源预算降级。
|
| 4252 |
+
|
| 4253 |
+
捕获 RegisterBlocked:
|
| 4254 |
+
- is_phone=True: 当前邮箱已暴露给 OpenAI,立即删邮箱、整个账号放弃(return None)
|
| 4255 |
+
- is_duplicate=True: 换个临时邮箱继续尝试,独立计数不消耗 register_attempts
|
| 4256 |
+
- 其他异常: 归入现有 retry 计数
|
| 4257 |
+
|
| 4258 |
+
Round 12 S4: `mail_client` 改为可选(默认 None) — None 时按 `acc`(可选)走
|
| 4259 |
+
`_get_mail_client_for_account(acc)` 路由;旧调用方显式传 mail_client 时完全
|
| 4260 |
+
保留旧行为(向后兼容)。register-level 多 provider rotation 由 `MAIL_PROVIDER_CHAIN`
|
| 4261 |
+
env 驱动:配置 chain 时 `get_mail_client()` 返回 `FallbackMailProvider`,
|
| 4262 |
+
自动在 mail-API 级失败时降级;邮箱-粒度的 provider 切换通过 `RegisterPathRotator`
|
| 4263 |
+
包装(参见 `autoteam.mail.register_dual_path`),业务调用方可自行编排。
|
| 4264 |
+
|
| 4265 |
+
Round 12 wire-up (M3): `path_rotator` 可选 — 传入 `RegisterPathRotator` 实例时
|
| 4266 |
+
启用邮箱-粒度的 provider 切换(OTP_TIMEOUT / DOMAIN_REJECTED / INVITE_LINK_MISSING
|
| 4267 |
+
自动切下一 provider 重试整个注册流程);None 时走旧逻辑(单 provider + retry 3 次).
|
| 4268 |
+
"""
|
| 4269 |
+
# Round 12 wire-up M3 — 邮箱-粒度 provider 切换。
|
| 4270 |
+
if path_rotator is not None:
|
| 4271 |
+
return _create_account_direct_via_rotator(
|
| 4272 |
+
path_rotator,
|
| 4273 |
+
leave_workspace=leave_workspace,
|
| 4274 |
+
out_outcome=out_outcome,
|
| 4275 |
+
acc=acc,
|
| 4276 |
+
parallel=parallel,
|
| 4277 |
+
)
|
| 4278 |
+
|
| 4279 |
+
mail_client = _resolve_mail_client_or_default(mail_client, acc=acc)
|
| 4280 |
+
if parallel is None:
|
| 4281 |
+
parallel = _direct_register_parallel_size()
|
| 4282 |
+
else:
|
| 4283 |
+
parallel = _cap_direct_register_parallel(parallel)
|
| 4284 |
+
|
| 4285 |
+
if parallel <= 1:
|
| 4286 |
+
signup = _attempt_chatgpt_signup_only(mail_client, acc=acc, out_outcome=out_outcome)
|
| 4287 |
+
else:
|
| 4288 |
+
signup = _race_chatgpt_signup(
|
| 4289 |
+
_direct_signup_mail_client_factory(mail_client, acc=acc),
|
| 4290 |
+
parallel=parallel,
|
| 4291 |
+
acc=acc,
|
| 4292 |
+
out_outcome=out_outcome,
|
| 4293 |
+
)
|
| 4294 |
+
|
| 4295 |
+
if not signup.get("success"):
|
| 4296 |
return None
|
| 4297 |
|
| 4298 |
+
email = signup["email"]
|
| 4299 |
+
password = signup["password"]
|
| 4300 |
+
account_id = signup["account_id"]
|
| 4301 |
+
session_token = signup.get("session_token")
|
| 4302 |
+
signup_profile = signup.get("signup_profile")
|
| 4303 |
+
auth_proxy_url = signup.get("auth_proxy_url") or ""
|
| 4304 |
+
playwright_proxy_url = signup.get("playwright_proxy_url") or ""
|
| 4305 |
+
winner_mail_client = signup.get("mail_client") or mail_client
|
| 4306 |
+
|
| 4307 |
add_account(email, password, cloudmail_account_id=account_id, workspace_account_id=get_chatgpt_account_id() or None)
|
| 4308 |
|
| 4309 |
post_result = _run_post_register_oauth(
|
| 4310 |
email,
|
| 4311 |
password,
|
| 4312 |
+
winner_mail_client,
|
| 4313 |
leave_workspace=leave_workspace,
|
| 4314 |
out_outcome=out_outcome,
|
| 4315 |
chatgpt_session_token=session_token,
|
|
|
|
| 4322 |
return post_result
|
| 4323 |
|
| 4324 |
|
| 4325 |
+
def _create_account_direct_via_rotator(
|
| 4326 |
+
path_rotator, *, leave_workspace=False, out_outcome=None, acc=None, parallel: int | None = None
|
| 4327 |
+
):
|
| 4328 |
"""Round 12 wire-up (M3) — 用 RegisterPathRotator 编排邮箱-粒度的 provider 切换。
|
| 4329 |
|
| 4330 |
流程:
|
|
|
|
| 4355 |
out_outcome=local_outcome,
|
| 4356 |
acc=acc,
|
| 4357 |
path_rotator=None, # 避免递归
|
| 4358 |
+
parallel=parallel,
|
| 4359 |
)
|
| 4360 |
if result_email is None:
|
| 4361 |
status = local_outcome.get("status") or "register_failed"
|
|
|
|
| 4404 |
return None
|
| 4405 |
|
| 4406 |
|
| 4407 |
+
def create_new_account(
|
| 4408 |
+
chatgpt_api,
|
| 4409 |
+
mail_client=None,
|
| 4410 |
+
*,
|
| 4411 |
+
leave_workspace=False,
|
| 4412 |
+
out_outcome=None,
|
| 4413 |
+
acc=None,
|
| 4414 |
+
path_rotator=None,
|
| 4415 |
+
parallel: int | None = None,
|
| 4416 |
+
):
|
| 4417 |
"""
|
| 4418 |
创建新账号。优先用直接注册模式(域名自动加入 workspace)。
|
| 4419 |
chatgpt_api 可为 None(直接注册不需要)。
|
|
|
|
| 4424 |
"""
|
| 4425 |
# 先检查 pending invites
|
| 4426 |
mail_client = _resolve_mail_client_or_default(mail_client, acc=acc)
|
| 4427 |
+
try:
|
| 4428 |
+
from autoteam.config import ROTATE_SKIP_REUSE
|
| 4429 |
+
except Exception:
|
| 4430 |
+
ROTATE_SKIP_REUSE = False
|
| 4431 |
|
| 4432 |
+
if chatgpt_api and _chatgpt_session_ready(chatgpt_api) and not ROTATE_SKIP_REUSE:
|
| 4433 |
logger.info("[创建] 先检查 pending invites...")
|
| 4434 |
completed = _check_pending_invites(
|
| 4435 |
chatgpt_api,
|
|
|
|
| 4440 |
if completed:
|
| 4441 |
logger.info("[创建] 从 pending invites 完成了 %d 个账号", len(completed))
|
| 4442 |
return completed[0]
|
| 4443 |
+
elif chatgpt_api and _chatgpt_session_ready(chatgpt_api):
|
| 4444 |
+
logger.info("[创建] ROTATE_SKIP_REUSE 启用:跳过历史 pending invite,只创建新账号")
|
| 4445 |
|
| 4446 |
# 直接注册模式(不需要邀请)
|
| 4447 |
logger.info("[创建] 使用直接注册模式...")
|
|
|
|
| 4453 |
out_outcome=out_outcome,
|
| 4454 |
acc=acc,
|
| 4455 |
path_rotator=path_rotator,
|
| 4456 |
+
parallel=parallel,
|
| 4457 |
)
|
| 4458 |
|
| 4459 |
|
|
|
|
| 5106 |
"""
|
| 5107 |
TARGET = _clamp_team_target_seats(target_seats)
|
| 5108 |
ACTIVE_TARGET = _pool_active_target(TARGET)
|
| 5109 |
+
started_at = time.time()
|
| 5110 |
|
| 5111 |
+
from autoteam.config import AUTO_CHECK_THRESHOLD, ROTATE_MAX_DURATION, ROTATE_SKIP_REUSE
|
| 5112 |
+
rotate_deadline = started_at + float(ROTATE_MAX_DURATION)
|
| 5113 |
|
| 5114 |
try:
|
| 5115 |
from autoteam.api import _auto_check_config
|
|
|
|
| 5118 |
except ImportError:
|
| 5119 |
threshold = AUTO_CHECK_THRESHOLD
|
| 5120 |
|
| 5121 |
+
def _bump(stage: str) -> None:
|
| 5122 |
+
try:
|
| 5123 |
+
from autoteam.api import bump_task_progress
|
| 5124 |
+
|
| 5125 |
+
bump_task_progress(stage)
|
| 5126 |
+
except Exception:
|
| 5127 |
+
pass
|
| 5128 |
+
|
| 5129 |
+
def _deadline_exceeded(stage: str) -> bool:
|
| 5130 |
+
if time.time() < rotate_deadline:
|
| 5131 |
+
return False
|
| 5132 |
+
logger.warning(
|
| 5133 |
+
"[轮转] 总时长熔断触发 (%s),已用 %ds,优雅退出本轮 rotate",
|
| 5134 |
+
stage,
|
| 5135 |
+
int(time.time() - started_at),
|
| 5136 |
+
)
|
| 5137 |
+
return True
|
| 5138 |
+
|
| 5139 |
+
skip_reuse = bool(ROTATE_SKIP_REUSE)
|
| 5140 |
+
if skip_reuse:
|
| 5141 |
+
logger.info("[轮转] ROTATE_SKIP_REUSE 启用:跳过旧号复用,优先释放不可用占席子号后创建新号")
|
| 5142 |
+
|
| 5143 |
chatgpt = None
|
| 5144 |
mail_client = None
|
| 5145 |
# 按账号 mail_provider 复用 mail client(同 provider 只 login 一次,避免 N 个号 N 次握手)
|
|
|
|
| 5207 |
return ensure_mail()
|
| 5208 |
|
| 5209 |
logger.info("[1/5] 同步 Team 状态...")
|
| 5210 |
+
_bump("rotate:sync_team")
|
| 5211 |
sync_account_states()
|
| 5212 |
|
| 5213 |
logger.info("[2/5] 检查额度...")
|
| 5214 |
+
_bump("rotate:check_quota")
|
| 5215 |
cmd_check()
|
| 5216 |
|
| 5217 |
# Round 12 S5 — 预测式抢先替换(可选,默认关).
|
|
|
|
| 5257 |
# 移出所有 exhausted 账号(包括之前已标记的)
|
| 5258 |
all_accounts = load_accounts()
|
| 5259 |
all_exhausted = [
|
| 5260 |
+
a
|
| 5261 |
+
for a in all_accounts
|
| 5262 |
+
if _is_replaceable_pool_blocker(a)
|
| 5263 |
]
|
| 5264 |
initial_api_count = -1
|
| 5265 |
removed_now = 0
|
| 5266 |
already_absent_count = 0
|
| 5267 |
|
| 5268 |
if all_exhausted:
|
| 5269 |
+
logger.info("[3/5] 移出 %d 个不可用占席账号...", len(all_exhausted))
|
| 5270 |
ensure_chatgpt()
|
| 5271 |
initial_api_count = get_team_member_count(chatgpt)
|
| 5272 |
for acc in all_exhausted:
|
| 5273 |
+
if _deadline_exceeded("rotate:remove_blockers"):
|
| 5274 |
+
break
|
| 5275 |
email = acc["email"]
|
| 5276 |
+
reason = _replaceable_pool_blocker_reason(acc) or "replaceable_pool_blocker"
|
| 5277 |
if not _chatgpt_session_ready(chatgpt):
|
| 5278 |
chatgpt.start()
|
| 5279 |
remove_status = remove_from_team(chatgpt, email, return_status=True)
|
| 5280 |
if remove_status in ("removed", "already_absent"):
|
| 5281 |
+
update_account(email, status=STATUS_STANDBY, _reason=reason)
|
| 5282 |
if remove_status == "removed":
|
| 5283 |
removed_now += 1
|
| 5284 |
+
logger.info("[3/5] %s → standby(已从 Team 移出)| reason=%s", email, reason)
|
| 5285 |
+
_wait_for_remote_capacity_after_removal(
|
| 5286 |
+
chatgpt,
|
| 5287 |
+
target=TARGET,
|
| 5288 |
+
removed_email=email,
|
| 5289 |
+
timeout=24,
|
| 5290 |
+
stage_label="[3/5]",
|
| 5291 |
+
)
|
| 5292 |
else:
|
| 5293 |
already_absent_count += 1
|
| 5294 |
+
logger.info("[3/5] %s → standby(远端已不存在)| reason=%s", email, reason)
|
| 5295 |
else:
|
| 5296 |
logger.info("[3/5] 无需移出账号")
|
| 5297 |
if not chatgpt or not _chatgpt_session_ready(chatgpt):
|
|
|
|
| 5335 |
# 只移除本地管理的账号,优先移除额度最低的
|
| 5336 |
all_accs = load_accounts()
|
| 5337 |
local_active = [
|
| 5338 |
+
a
|
| 5339 |
+
for a in all_accs
|
| 5340 |
+
if a["status"] == STATUS_ACTIVE
|
| 5341 |
+
and not _is_main_account_email(a.get("email"))
|
| 5342 |
+
and not is_account_disabled(a)
|
| 5343 |
+
and not _is_protected_local_credential_seat(a)
|
| 5344 |
]
|
| 5345 |
# 按额度排序,额度低的优先移除
|
| 5346 |
local_active.sort(key=lambda a: 100 - (a.get("last_quota") or {}).get("primary_pct", 0))
|
|
|
|
| 5376 |
# 注: 不截断到 vacancies — 旧行为会迭代全部 standby,遇到不可复用的(skipped_auto/
|
| 5377 |
# skipped_quota)就继续看下一个,直到 filled >= vacancies 才 break. 截断会导致
|
| 5378 |
# 第一个候选若 skipped 则放弃后续可用候选,破坏 test_cmd_rotate_skips_google_accounts.
|
| 5379 |
+
if skip_reuse:
|
| 5380 |
+
logger.info("[4/5] ROTATE_SKIP_REUSE 启用:跳过 standby 复用,直接创建新账号补位")
|
| 5381 |
+
candidates = []
|
| 5382 |
+
else:
|
| 5383 |
+
candidates = list(standby_list)
|
| 5384 |
|
| 5385 |
def _chatgpt_provider():
|
| 5386 |
if not chatgpt or not _chatgpt_session_ready(chatgpt):
|
|
|
|
| 5422 |
if cancel_signal.is_cancelled():
|
| 5423 |
logger.warning("[轮转] 收到取消请求,中止 standby 复用阶段")
|
| 5424 |
break
|
| 5425 |
+
if _deadline_exceeded("rotate:standby_reuse"):
|
| 5426 |
+
break
|
| 5427 |
if filled >= vacancies:
|
| 5428 |
break
|
| 5429 |
result = _process(acc)
|
|
|
|
| 5504 |
if cancel_signal.is_cancelled():
|
| 5505 |
logger.warning("[轮转] 收到取消请求,已创建 %d/%d 个新号", i, remaining)
|
| 5506 |
break
|
| 5507 |
+
if _deadline_exceeded("rotate:create_new"):
|
| 5508 |
+
break
|
| 5509 |
logger.info("[5/5] 创建第 %d/%d 个...", i + 1, remaining)
|
| 5510 |
if not chatgpt or not _chatgpt_session_ready(chatgpt):
|
| 5511 |
ensure_chatgpt()
|
| 5512 |
+
created_email = create_new_account(chatgpt, ensure_mail())
|
| 5513 |
+
if created_email and (
|
| 5514 |
+
not isinstance(created_email, str)
|
| 5515 |
+
or _validate_managed_account_operational(
|
| 5516 |
+
created_email,
|
| 5517 |
+
threshold=threshold,
|
| 5518 |
+
stage_label="[轮转验收]",
|
| 5519 |
+
chatgpt_api=chatgpt,
|
| 5520 |
+
)
|
| 5521 |
+
):
|
| 5522 |
current_count += 1
|
| 5523 |
+
elif created_email:
|
| 5524 |
+
logger.warning("[5/5] 新���号未通过运行验收,释放席位并丢弃: %s", created_email)
|
| 5525 |
+
if not _chatgpt_session_ready(chatgpt):
|
| 5526 |
+
chatgpt.start()
|
| 5527 |
+
remove_status = remove_from_team(chatgpt, created_email, return_status=True)
|
| 5528 |
+
if remove_status in ("removed", "already_absent"):
|
| 5529 |
+
update_account(created_email, status=STATUS_STANDBY, _reason="new_account_not_ready")
|
| 5530 |
|
| 5531 |
if not chatgpt or not _chatgpt_session_ready(chatgpt):
|
| 5532 |
ensure_chatgpt()
|
|
|
|
| 5830 |
return len(members)
|
| 5831 |
|
| 5832 |
|
| 5833 |
+
def cmd_fill(target=3, leave_workspace=False, *, post_sync=True, print_status=True, direct_parallel: int | None = None):
|
| 5834 |
"""
|
| 5835 |
补位流程。
|
| 5836 |
leave_workspace=False: 补满 Team 席位到 target(原行为),优先复用 standby 旧号
|
|
|
|
| 5841 |
if leave_workspace:
|
| 5842 |
return _cmd_fill_personal(target)
|
| 5843 |
target = _clamp_team_target_seats(target)
|
| 5844 |
+
from autoteam.config import AUTO_CHECK_THRESHOLD, ROTATE_SKIP_REUSE
|
| 5845 |
|
| 5846 |
chatgpt = ChatGPTTeamAPI()
|
| 5847 |
chatgpt.start()
|
|
|
|
| 5862 |
return
|
| 5863 |
|
| 5864 |
logger.info("[填充] 需要添加 %d 个账号", need)
|
| 5865 |
+
if ROTATE_SKIP_REUSE:
|
| 5866 |
+
logger.info("[填充] ROTATE_SKIP_REUSE 启用:不复用旧账号,只创建新账号补位")
|
| 5867 |
+
standby_list = []
|
| 5868 |
+
else:
|
| 5869 |
+
standby_list = [
|
| 5870 |
+
a
|
| 5871 |
+
for a in get_standby_accounts()
|
| 5872 |
+
if a.get("_quota_recovered")
|
| 5873 |
+
and not _is_main_account_email(a.get("email"))
|
| 5874 |
+
and not is_account_disabled(a)
|
| 5875 |
+
]
|
| 5876 |
standby_index = 0
|
| 5877 |
|
| 5878 |
from autoteam import cancel_signal
|
|
|
|
| 5907 |
logger.info("[填充] 创建新账号...")
|
| 5908 |
if not _chatgpt_session_ready(chatgpt):
|
| 5909 |
chatgpt.start()
|
| 5910 |
+
if direct_parallel is None:
|
| 5911 |
+
created_email = create_new_account(chatgpt, mail_client)
|
| 5912 |
+
else:
|
| 5913 |
+
created_email = create_new_account(chatgpt, mail_client, parallel=direct_parallel)
|
| 5914 |
+
if created_email and (
|
| 5915 |
+
not isinstance(created_email, str)
|
| 5916 |
+
or _validate_managed_account_operational(
|
| 5917 |
+
created_email,
|
| 5918 |
+
threshold=AUTO_CHECK_THRESHOLD,
|
| 5919 |
+
stage_label="[填充验收]",
|
| 5920 |
+
chatgpt_api=chatgpt,
|
| 5921 |
+
)
|
| 5922 |
+
):
|
| 5923 |
+
added = created_email
|
| 5924 |
+
elif created_email:
|
| 5925 |
+
logger.warning("[填充] 新账号未通过运行验收,释放席位并丢弃: %s", created_email)
|
| 5926 |
+
if not _chatgpt_session_ready(chatgpt):
|
| 5927 |
+
chatgpt.start()
|
| 5928 |
+
remove_status = remove_from_team(chatgpt, created_email, return_status=True)
|
| 5929 |
+
if remove_status in ("removed", "already_absent"):
|
| 5930 |
+
update_account(created_email, status=STATUS_STANDBY, _reason="fill_new_account_not_ready")
|
| 5931 |
|
| 5932 |
if not added:
|
| 5933 |
logger.warning("[填充] 本轮补位失败,第 %d/%d 个空缺仍未填上", i + 1, need)
|
src/autoteam/multi_master.py
CHANGED
|
@@ -240,7 +240,13 @@ def _run_fill_for_owner(owner: dict[str, Any], target_seats: int, direct_paralle
|
|
| 240 |
account_id=owner.get("account_id") or "",
|
| 241 |
workspace_name=owner.get("workspace_name") or "",
|
| 242 |
):
|
| 243 |
-
cmd_fill(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
return {
|
| 245 |
"workspace_id": owner.get("id"),
|
| 246 |
"admin_email": owner.get("admin_email"),
|
|
|
|
| 240 |
account_id=owner.get("account_id") or "",
|
| 241 |
workspace_name=owner.get("workspace_name") or "",
|
| 242 |
):
|
| 243 |
+
cmd_fill(
|
| 244 |
+
target_seats,
|
| 245 |
+
leave_workspace=False,
|
| 246 |
+
post_sync=False,
|
| 247 |
+
print_status=False,
|
| 248 |
+
direct_parallel=direct_parallel,
|
| 249 |
+
)
|
| 250 |
return {
|
| 251 |
"workspace_id": owner.get("id"),
|
| 252 |
"admin_email": owner.get("admin_email"),
|
tests/unit/test_api_status.py
CHANGED
|
@@ -249,6 +249,48 @@ def test_auto_check_cooldown_keeps_full_team_from_refilling(tmp_path, monkeypatc
|
|
| 249 |
assert started == []
|
| 250 |
|
| 251 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
def test_sanitize_account_keeps_exportable_main_account_active_without_live_quota(tmp_path, monkeypatch):
|
| 253 |
main_email = "owner@example.com"
|
| 254 |
auth_file = tmp_path / "codex-main.json"
|
|
|
|
| 249 |
assert started == []
|
| 250 |
|
| 251 |
|
| 252 |
+
def test_auto_check_cooldown_allows_full_team_blocker_replacement(monkeypatch):
|
| 253 |
+
started = []
|
| 254 |
+
|
| 255 |
+
def fake_start_task(command, func, params, *args, **kwargs):
|
| 256 |
+
started.append((command, params, args, kwargs))
|
| 257 |
+
|
| 258 |
+
monkeypatch.setattr(api, "_auto_fill_last_trigger_ts", time.time())
|
| 259 |
+
monkeypatch.setattr(api, "_auto_check_config", {"interval": 0, "target_seats": 3, "threshold": 10, "min_low": 1})
|
| 260 |
+
monkeypatch.setattr(api, "log_runtime_resource_snapshot", lambda *args, **kwargs: {})
|
| 261 |
+
monkeypatch.setattr(api, "_is_main_account_email", lambda _email: False)
|
| 262 |
+
monkeypatch.setattr(api, "_auto_check_team_member_count", lambda *args, **kwargs: 3)
|
| 263 |
+
monkeypatch.setattr(api, "_start_task", fake_start_task)
|
| 264 |
+
monkeypatch.setattr(
|
| 265 |
+
"autoteam.accounts.load_accounts",
|
| 266 |
+
lambda: [
|
| 267 |
+
{"email": "healthy@example.com", "status": "active", "auth_file": ""},
|
| 268 |
+
{"email": "blocked@example.com", "status": "auth_invalid", "auth_file": ""},
|
| 269 |
+
],
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
stop_event = threading.Event()
|
| 273 |
+
restart_event = threading.Event()
|
| 274 |
+
wait_calls = {"count": 0}
|
| 275 |
+
|
| 276 |
+
def fake_wait(_seconds):
|
| 277 |
+
wait_calls["count"] += 1
|
| 278 |
+
return wait_calls["count"] > 1
|
| 279 |
+
|
| 280 |
+
monkeypatch.setattr(stop_event, "wait", fake_wait)
|
| 281 |
+
monkeypatch.setattr(api, "_auto_check_stop", stop_event)
|
| 282 |
+
monkeypatch.setattr(api, "_auto_check_restart", restart_event)
|
| 283 |
+
|
| 284 |
+
api._auto_check_loop()
|
| 285 |
+
|
| 286 |
+
assert len(started) == 1
|
| 287 |
+
command, params, args, kwargs = started[0]
|
| 288 |
+
assert command == "auto-fill"
|
| 289 |
+
assert params == {"target_seats": 3}
|
| 290 |
+
assert args == (3,)
|
| 291 |
+
assert kwargs == {"background_post_sync": True}
|
| 292 |
+
|
| 293 |
+
|
| 294 |
def test_sanitize_account_keeps_exportable_main_account_active_without_live_quota(tmp_path, monkeypatch):
|
| 295 |
main_email = "owner@example.com"
|
| 296 |
auth_file = tmp_path / "codex-main.json"
|
tests/unit/test_free_registration_hardening.py
CHANGED
|
@@ -226,6 +226,81 @@ def test_create_account_direct_reuses_signup_profile_for_register_and_oauth(monk
|
|
| 226 |
assert captured["oauth"]["leave_workspace"] is True
|
| 227 |
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
def test_run_post_register_oauth_passes_signup_profile_to_team_oauth(monkeypatch):
|
| 230 |
profile = SignupProfile(
|
| 231 |
full_name="Mia Wilson",
|
|
|
|
| 226 |
assert captured["oauth"]["leave_workspace"] is True
|
| 227 |
|
| 228 |
|
| 229 |
+
def test_create_account_direct_races_signup_workers_and_uses_winner(monkeypatch):
|
| 230 |
+
calls = []
|
| 231 |
+
added = []
|
| 232 |
+
out = {}
|
| 233 |
+
|
| 234 |
+
class _RaceMailClient:
|
| 235 |
+
counter = 0
|
| 236 |
+
provider_name = "race"
|
| 237 |
+
|
| 238 |
+
def __init__(self):
|
| 239 |
+
type(self).counter += 1
|
| 240 |
+
self.idx = type(self).counter
|
| 241 |
+
|
| 242 |
+
def login(self):
|
| 243 |
+
return None
|
| 244 |
+
|
| 245 |
+
def create_temp_email(self):
|
| 246 |
+
return f"mail-{self.idx}", f"user-{self.idx}@example.com"
|
| 247 |
+
|
| 248 |
+
def delete_account(self, *_args, **_kwargs):
|
| 249 |
+
return None
|
| 250 |
+
|
| 251 |
+
def fake_register_once(_mail_client, email, password, *, cloudmail_account_id=None, signup_profile=None):
|
| 252 |
+
calls.append(
|
| 253 |
+
{
|
| 254 |
+
"email": email,
|
| 255 |
+
"password": password,
|
| 256 |
+
"cloudmail_account_id": cloudmail_account_id,
|
| 257 |
+
"signup_profile": signup_profile,
|
| 258 |
+
}
|
| 259 |
+
)
|
| 260 |
+
return email == "user-2@example.com", f"token-for-{email}"
|
| 261 |
+
|
| 262 |
+
monkeypatch.setattr(manager_mod, "_ensure_account_ipv6_proxy", lambda _email: ("", ""))
|
| 263 |
+
monkeypatch.setattr(manager_mod, "_release_account_ipv6_proxy", lambda _email: None)
|
| 264 |
+
monkeypatch.setattr(manager_mod, "_register_direct_once", fake_register_once)
|
| 265 |
+
monkeypatch.setattr(manager_mod, "_is_email_in_team", lambda _email: False)
|
| 266 |
+
monkeypatch.setattr(manager_mod, "time", manager_mod.time)
|
| 267 |
+
monkeypatch.setattr(manager_mod.time, "sleep", lambda *_args, **_kwargs: None)
|
| 268 |
+
monkeypatch.setattr(manager_mod, "add_account", lambda *args, **kwargs: added.append((args, kwargs)))
|
| 269 |
+
monkeypatch.setattr(manager_mod, "get_chatgpt_account_id", lambda: "workspace-account")
|
| 270 |
+
monkeypatch.setattr(
|
| 271 |
+
manager_mod,
|
| 272 |
+
"_run_post_register_oauth",
|
| 273 |
+
lambda email, *_args, **_kwargs: email,
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
result = manager_mod.create_account_direct(
|
| 277 |
+
mail_client=_RaceMailClient(),
|
| 278 |
+
out_outcome=out,
|
| 279 |
+
parallel=3,
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
assert result == "user-2@example.com"
|
| 283 |
+
assert sorted({call["email"] for call in calls}) == [
|
| 284 |
+
"user-1@example.com",
|
| 285 |
+
"user-2@example.com",
|
| 286 |
+
"user-3@example.com",
|
| 287 |
+
]
|
| 288 |
+
assert sum(1 for call in calls if call["email"] == "user-2@example.com") == 1
|
| 289 |
+
assert added[0][0][0] == "user-2@example.com"
|
| 290 |
+
assert out["direct_register_parallel"] == 3
|
| 291 |
+
assert out["direct_register_failures"] == 2
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def test_direct_register_parallel_downgrades_when_memory_high(monkeypatch):
|
| 295 |
+
monkeypatch.setenv("AUTOTEAM_REGISTER_PARALLEL_MEMORY_WARN_RATIO", "0.70")
|
| 296 |
+
monkeypatch.setattr(
|
| 297 |
+
"autoteam.runtime_resources.collect_runtime_resource_snapshot",
|
| 298 |
+
lambda: {"cgroup_memory_usage_ratio": 0.95, "browser_process_live": 0},
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
assert manager_mod._cap_direct_register_parallel(4) == 1
|
| 302 |
+
|
| 303 |
+
|
| 304 |
def test_run_post_register_oauth_passes_signup_profile_to_team_oauth(monkeypatch):
|
| 305 |
profile = SignupProfile(
|
| 306 |
full_name="Mia Wilson",
|
tests/unit/test_manager_fill.py
CHANGED
|
@@ -22,10 +22,13 @@ class _FakeMailClient:
|
|
| 22 |
|
| 23 |
|
| 24 |
def test_cmd_fill_tries_other_reusable_accounts_before_creating_new(monkeypatch):
|
|
|
|
|
|
|
| 25 |
chatgpt = _FakeChatGPT()
|
| 26 |
count_values = iter([2, 3])
|
| 27 |
events = []
|
| 28 |
|
|
|
|
| 29 |
monkeypatch.setattr(manager, "ChatGPTTeamAPI", lambda: chatgpt)
|
| 30 |
monkeypatch.setattr(manager, "CloudMailClient", lambda: _FakeMailClient())
|
| 31 |
monkeypatch.setattr(manager, "get_team_member_count", lambda _chatgpt: next(count_values))
|
|
@@ -63,10 +66,13 @@ def test_cmd_fill_tries_other_reusable_accounts_before_creating_new(monkeypatch)
|
|
| 63 |
|
| 64 |
|
| 65 |
def test_cmd_fill_skips_google_accounts_during_auto_reuse(monkeypatch):
|
|
|
|
|
|
|
| 66 |
chatgpt = _FakeChatGPT()
|
| 67 |
count_values = iter([2, 3])
|
| 68 |
events = []
|
| 69 |
|
|
|
|
| 70 |
monkeypatch.setattr(manager, "ChatGPTTeamAPI", lambda: chatgpt)
|
| 71 |
monkeypatch.setattr(manager, "CloudMailClient", lambda: _FakeMailClient())
|
| 72 |
monkeypatch.setattr(manager, "get_team_member_count", lambda _chatgpt: next(count_values))
|
|
@@ -109,3 +115,41 @@ def test_auto_reuse_skip_reason_detects_google_provider_and_gmail():
|
|
| 109 |
== "Google 登录账号暂不支持自动复用"
|
| 110 |
)
|
| 111 |
assert manager._auto_reuse_skip_reason({"email": "user@example.com"}) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
def test_cmd_fill_tries_other_reusable_accounts_before_creating_new(monkeypatch):
|
| 25 |
+
import autoteam.config as config
|
| 26 |
+
|
| 27 |
chatgpt = _FakeChatGPT()
|
| 28 |
count_values = iter([2, 3])
|
| 29 |
events = []
|
| 30 |
|
| 31 |
+
monkeypatch.setattr(config, "ROTATE_SKIP_REUSE", False)
|
| 32 |
monkeypatch.setattr(manager, "ChatGPTTeamAPI", lambda: chatgpt)
|
| 33 |
monkeypatch.setattr(manager, "CloudMailClient", lambda: _FakeMailClient())
|
| 34 |
monkeypatch.setattr(manager, "get_team_member_count", lambda _chatgpt: next(count_values))
|
|
|
|
| 66 |
|
| 67 |
|
| 68 |
def test_cmd_fill_skips_google_accounts_during_auto_reuse(monkeypatch):
|
| 69 |
+
import autoteam.config as config
|
| 70 |
+
|
| 71 |
chatgpt = _FakeChatGPT()
|
| 72 |
count_values = iter([2, 3])
|
| 73 |
events = []
|
| 74 |
|
| 75 |
+
monkeypatch.setattr(config, "ROTATE_SKIP_REUSE", False)
|
| 76 |
monkeypatch.setattr(manager, "ChatGPTTeamAPI", lambda: chatgpt)
|
| 77 |
monkeypatch.setattr(manager, "CloudMailClient", lambda: _FakeMailClient())
|
| 78 |
monkeypatch.setattr(manager, "get_team_member_count", lambda _chatgpt: next(count_values))
|
|
|
|
| 115 |
== "Google 登录账号暂不支持自动复用"
|
| 116 |
)
|
| 117 |
assert manager._auto_reuse_skip_reason({"email": "user@example.com"}) is None
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_cmd_fill_passes_direct_parallel_and_releases_failed_validation(monkeypatch):
|
| 121 |
+
import autoteam.config as config
|
| 122 |
+
|
| 123 |
+
chatgpt = _FakeChatGPT()
|
| 124 |
+
counts = iter([2, 2])
|
| 125 |
+
events = []
|
| 126 |
+
|
| 127 |
+
monkeypatch.setattr(config, "ROTATE_SKIP_REUSE", True)
|
| 128 |
+
monkeypatch.setattr(manager, "ChatGPTTeamAPI", lambda: chatgpt)
|
| 129 |
+
monkeypatch.setattr(manager, "CloudMailClient", lambda: _FakeMailClient())
|
| 130 |
+
monkeypatch.setattr(manager, "get_team_member_count", lambda _chatgpt: next(counts))
|
| 131 |
+
monkeypatch.setattr(manager, "get_standby_accounts", lambda: [])
|
| 132 |
+
monkeypatch.setattr(
|
| 133 |
+
manager,
|
| 134 |
+
"create_new_account",
|
| 135 |
+
lambda _chatgpt, _mail, *, parallel=None: events.append(("create", parallel)) or "new@example.com",
|
| 136 |
+
)
|
| 137 |
+
monkeypatch.setattr(manager, "_validate_managed_account_operational", lambda *_args, **_kwargs: False)
|
| 138 |
+
monkeypatch.setattr(
|
| 139 |
+
manager,
|
| 140 |
+
"remove_from_team",
|
| 141 |
+
lambda _chatgpt, email, *, return_status=False, **_kwargs: events.append(("remove", email)) or "removed",
|
| 142 |
+
)
|
| 143 |
+
monkeypatch.setattr(
|
| 144 |
+
manager,
|
| 145 |
+
"update_account",
|
| 146 |
+
lambda email, **kwargs: events.append(("update", email, kwargs.get("status"), kwargs.get("_reason"))),
|
| 147 |
+
)
|
| 148 |
+
monkeypatch.setattr(manager, "sync_to_cpa", lambda: events.append(("sync", None)))
|
| 149 |
+
monkeypatch.setattr(manager, "cmd_status", lambda: events.append(("status", None)))
|
| 150 |
+
|
| 151 |
+
manager.cmd_fill(target=3, direct_parallel=3)
|
| 152 |
+
|
| 153 |
+
assert ("create", 3) in events
|
| 154 |
+
assert ("remove", "new@example.com") in events
|
| 155 |
+
assert ("update", "new@example.com", manager.STATUS_STANDBY, "fill_new_account_not_ready") in events
|
tests/unit/test_manager_rotate.py
CHANGED
|
@@ -22,10 +22,13 @@ class _FakeMailClient:
|
|
| 22 |
|
| 23 |
|
| 24 |
def test_cmd_rotate_skips_google_accounts_during_auto_reuse(monkeypatch):
|
|
|
|
|
|
|
| 25 |
chatgpt = _FakeChatGPT()
|
| 26 |
count_values = iter([2, 3])
|
| 27 |
events = []
|
| 28 |
|
|
|
|
| 29 |
monkeypatch.setattr(manager, "sync_account_states", lambda: events.append(("sync_account_states", None)))
|
| 30 |
monkeypatch.setattr(manager, "cmd_check", lambda: events.append(("cmd_check", None)))
|
| 31 |
monkeypatch.setattr(manager, "ChatGPTTeamAPI", lambda: chatgpt)
|
|
@@ -98,3 +101,76 @@ def test_cmd_rotate_can_defer_final_sync_when_running_as_api_task(monkeypatch):
|
|
| 98 |
("cmd_check", None),
|
| 99 |
("schedule_post_sync", "[轮转]"),
|
| 100 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
def test_cmd_rotate_skips_google_accounts_during_auto_reuse(monkeypatch):
|
| 25 |
+
import autoteam.config as config
|
| 26 |
+
|
| 27 |
chatgpt = _FakeChatGPT()
|
| 28 |
count_values = iter([2, 3])
|
| 29 |
events = []
|
| 30 |
|
| 31 |
+
monkeypatch.setattr(config, "ROTATE_SKIP_REUSE", False)
|
| 32 |
monkeypatch.setattr(manager, "sync_account_states", lambda: events.append(("sync_account_states", None)))
|
| 33 |
monkeypatch.setattr(manager, "cmd_check", lambda: events.append(("cmd_check", None)))
|
| 34 |
monkeypatch.setattr(manager, "ChatGPTTeamAPI", lambda: chatgpt)
|
|
|
|
| 101 |
("cmd_check", None),
|
| 102 |
("schedule_post_sync", "[轮转]"),
|
| 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": ""}
|
| 110 |
+
)
|
| 111 |
+
== "missing_auth"
|
| 112 |
+
)
|
| 113 |
+
assert (
|
| 114 |
+
manager._replaceable_pool_blocker_reason(
|
| 115 |
+
{"email": "invalid@example.com", "status": manager.STATUS_AUTH_INVALID}
|
| 116 |
+
)
|
| 117 |
+
== "auth_invalid"
|
| 118 |
+
)
|
| 119 |
+
assert (
|
| 120 |
+
manager._replaceable_pool_blocker_reason(
|
| 121 |
+
{"email": "exhausted@example.com", "status": manager.STATUS_EXHAUSTED}
|
| 122 |
+
)
|
| 123 |
+
== "quota_exhausted"
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_cmd_rotate_removes_replaceable_blocker_before_creating_replacement(monkeypatch):
|
| 128 |
+
import autoteam.config as config
|
| 129 |
+
|
| 130 |
+
chatgpt = _FakeChatGPT()
|
| 131 |
+
accounts = [{"email": "blocked@example.com", "status": manager.STATUS_ACTIVE, "auth_file": ""}]
|
| 132 |
+
counts = iter([3, 2, 2, 3])
|
| 133 |
+
events = []
|
| 134 |
+
|
| 135 |
+
monkeypatch.setattr(config, "ROTATE_SKIP_REUSE", True)
|
| 136 |
+
monkeypatch.setattr(manager, "sync_account_states", lambda: events.append(("sync_account_states", None)))
|
| 137 |
+
monkeypatch.setattr(manager, "cmd_check", lambda: events.append(("cmd_check", None)))
|
| 138 |
+
monkeypatch.setattr(manager, "ChatGPTTeamAPI", lambda: chatgpt)
|
| 139 |
+
monkeypatch.setattr(manager, "CloudMailClient", lambda: _FakeMailClient())
|
| 140 |
+
monkeypatch.setattr(manager, "load_accounts", lambda: accounts)
|
| 141 |
+
monkeypatch.setattr(manager, "get_team_member_count", lambda _chatgpt: next(counts))
|
| 142 |
+
monkeypatch.setattr(manager, "get_standby_accounts", lambda: [])
|
| 143 |
+
monkeypatch.setattr(manager, "time", manager.time)
|
| 144 |
+
monkeypatch.setattr(manager.time, "sleep", lambda *_args, **_kwargs: None)
|
| 145 |
+
|
| 146 |
+
def fake_update(email, **kwargs):
|
| 147 |
+
events.append(("update", email, kwargs.get("status"), kwargs.get("_reason")))
|
| 148 |
+
for acc in accounts:
|
| 149 |
+
if acc["email"] == email:
|
| 150 |
+
acc.update(kwargs)
|
| 151 |
+
|
| 152 |
+
def fake_remove(_chatgpt, email, *, return_status=False, **_kwargs):
|
| 153 |
+
events.append(("remove", email))
|
| 154 |
+
return "removed" if return_status else True
|
| 155 |
+
|
| 156 |
+
def fake_create(_chatgpt, _mail):
|
| 157 |
+
events.append(("create", None))
|
| 158 |
+
accounts.append(
|
| 159 |
+
{
|
| 160 |
+
"email": "new@example.com",
|
| 161 |
+
"status": manager.STATUS_ACTIVE,
|
| 162 |
+
"auth_file": "auth.json",
|
| 163 |
+
}
|
| 164 |
+
)
|
| 165 |
+
return "new@example.com"
|
| 166 |
+
|
| 167 |
+
monkeypatch.setattr(manager, "update_account", fake_update)
|
| 168 |
+
monkeypatch.setattr(manager, "remove_from_team", fake_remove)
|
| 169 |
+
monkeypatch.setattr(manager, "create_new_account", fake_create)
|
| 170 |
+
monkeypatch.setattr(manager, "_validate_managed_account_operational", lambda *_args, **_kwargs: True)
|
| 171 |
+
monkeypatch.setattr(manager, "sync_to_cpa", lambda: events.append(("sync_to_cpa", None)))
|
| 172 |
+
|
| 173 |
+
manager.cmd_rotate(target_seats=3)
|
| 174 |
+
|
| 175 |
+
assert events.index(("remove", "blocked@example.com")) < events.index(("create", None))
|
| 176 |
+
assert ("update", "blocked@example.com", manager.STATUS_STANDBY, "missing_auth") in events
|
tests/unit/test_multi_master.py
CHANGED
|
@@ -129,7 +129,7 @@ def test_run_multi_master_fill_isolates_owner_failures(tmp_path, monkeypatch):
|
|
| 129 |
seen = []
|
| 130 |
lock = threading.Lock()
|
| 131 |
|
| 132 |
-
def fake_cmd_fill(target, leave_workspace=False, post_sync=True, print_status=True):
|
| 133 |
with lock:
|
| 134 |
seen.append(
|
| 135 |
(
|
|
@@ -139,6 +139,7 @@ def test_run_multi_master_fill_isolates_owner_failures(tmp_path, monkeypatch):
|
|
| 139 |
leave_workspace,
|
| 140 |
post_sync,
|
| 141 |
print_status,
|
|
|
|
| 142 |
)
|
| 143 |
)
|
| 144 |
if admin_state.get_admin_email() == "owner-b@example.com":
|
|
@@ -157,8 +158,8 @@ def test_run_multi_master_fill_isolates_owner_failures(tmp_path, monkeypatch):
|
|
| 157 |
|
| 158 |
assert result["status"] == "partial_failed"
|
| 159 |
assert {row[0] for row in seen} == {"owner-a@example.com", "owner-b@example.com"}
|
| 160 |
-
assert ("owner-a@example.com", UUID_A, 3, False, False, False) in seen
|
| 161 |
-
assert ("owner-b@example.com", UUID_B, 3, False, False, False) in seen
|
| 162 |
assert result["post_sync"] == {"skipped": True}
|
| 163 |
owner_b = next(owner for owner in result["owners"] if owner["admin_email"] == "owner-b@example.com")
|
| 164 |
assert owner_b["status"] == "failed"
|
|
|
|
| 129 |
seen = []
|
| 130 |
lock = threading.Lock()
|
| 131 |
|
| 132 |
+
def fake_cmd_fill(target, leave_workspace=False, post_sync=True, print_status=True, direct_parallel=None):
|
| 133 |
with lock:
|
| 134 |
seen.append(
|
| 135 |
(
|
|
|
|
| 139 |
leave_workspace,
|
| 140 |
post_sync,
|
| 141 |
print_status,
|
| 142 |
+
direct_parallel,
|
| 143 |
)
|
| 144 |
)
|
| 145 |
if admin_state.get_admin_email() == "owner-b@example.com":
|
|
|
|
| 158 |
|
| 159 |
assert result["status"] == "partial_failed"
|
| 160 |
assert {row[0] for row in seen} == {"owner-a@example.com", "owner-b@example.com"}
|
| 161 |
+
assert ("owner-a@example.com", UUID_A, 3, False, False, False, 1) in seen
|
| 162 |
+
assert ("owner-b@example.com", UUID_B, 3, False, False, False, 1) in seen
|
| 163 |
assert result["post_sync"] == {"skipped": True}
|
| 164 |
owner_b = next(owner for owner in result["owners"] if owner["admin_email"] == "owner-b@example.com")
|
| 165 |
assert owner_b["status"] == "failed"
|
tests/unit/test_reconcile_anomalies.py
CHANGED
|
@@ -242,7 +242,7 @@ def test_reconcile_dry_run_includes_over_cap_predictions(tmp_path, monkeypatch):
|
|
| 242 |
"""HIGH-1:dry_run 必须**预测**第二轮 over-cap 受害者填进 over_cap_kicked,
|
| 243 |
但不能 GET /users 第二次,也不能调 remove_from_team / update_account。
|
| 244 |
"""
|
| 245 |
-
# 5 子号 + 1 主号
|
| 246 |
auth_ok = tmp_path / "codex-good@example.com-team-1.json"
|
| 247 |
auth_ok.write_text("{}", encoding="utf-8")
|
| 248 |
|
|
@@ -287,11 +287,11 @@ def test_reconcile_dry_run_includes_over_cap_predictions(tmp_path, monkeypatch):
|
|
| 287 |
user_get_calls = [c for c in fetch_calls if c[0] == "GET" and c[1].endswith("/users")]
|
| 288 |
assert len(user_get_calls) == 1, f"dry_run should not refetch /users, got {fetch_calls}"
|
| 289 |
|
| 290 |
-
# 必须
|
| 291 |
-
assert len(result["over_cap_kicked"]) ==
|
| 292 |
# 受害者按 _priority 升序,active 按 p_remain (100-primary_pct) 升序 → primary_pct 最高的先 kick
|
| 293 |
# u5 primary_pct=50 → p_remain=50 是最低 remain → 第一个被 kick
|
| 294 |
-
assert result["over_cap_kicked"] == ["u5@example.com"]
|
| 295 |
|
| 296 |
|
| 297 |
def test_reconcile_priority_keeps_ghost_when_kick_disabled(tmp_path, monkeypatch):
|
|
@@ -301,7 +301,7 @@ def test_reconcile_priority_keeps_ghost_when_kick_disabled(tmp_path, monkeypatch
|
|
| 301 |
auth_ok = tmp_path / "codex-keep@example.com-team-1.json"
|
| 302 |
auth_ok.write_text("{}", encoding="utf-8")
|
| 303 |
|
| 304 |
-
# 4 个 active 账号 + 1 个 ghost(本地无记录) = 5
|
| 305 |
accounts = [
|
| 306 |
{
|
| 307 |
"email": f"u{i}@example.com",
|
|
@@ -343,13 +343,13 @@ def test_reconcile_priority_keeps_ghost_when_kick_disabled(tmp_path, monkeypatch
|
|
| 343 |
assert "ghost-extra@example.com" in result["ghost_seen"]
|
| 344 |
assert "ghost-extra@example.com" not in result["ghost_kicked"]
|
| 345 |
|
| 346 |
-
# 第二轮 over-cap
|
| 347 |
# 而不是 ghost-extra(被强制排到 (99, 0) 末尾)
|
| 348 |
assert "ghost-extra@example.com" not in result["over_cap_kicked"], (
|
| 349 |
f"ghost must not be over-cap kicked when RECONCILE_KICK_GHOST=False: {result['over_cap_kicked']}"
|
| 350 |
)
|
| 351 |
-
assert len(result["over_cap_kicked"]) ==
|
| 352 |
-
assert result["over_cap_kicked"]
|
| 353 |
|
| 354 |
|
| 355 |
def test_reconcile_find_team_auth_file_rejects_personal_plan(tmp_path, monkeypatch):
|
|
|
|
| 242 |
"""HIGH-1:dry_run 必须**预测**第二轮 over-cap 受害者填进 over_cap_kicked,
|
| 243 |
但不能 GET /users 第二次,也不能调 remove_from_team / update_account。
|
| 244 |
"""
|
| 245 |
+
# 5 子号 + 1 主号;当前硬约束为 1 母 + 2 子,因此超员 3。
|
| 246 |
auth_ok = tmp_path / "codex-good@example.com-team-1.json"
|
| 247 |
auth_ok.write_text("{}", encoding="utf-8")
|
| 248 |
|
|
|
|
| 287 |
user_get_calls = [c for c in fetch_calls if c[0] == "GET" and c[1].endswith("/users")]
|
| 288 |
assert len(user_get_calls) == 1, f"dry_run should not refetch /users, got {fetch_calls}"
|
| 289 |
|
| 290 |
+
# 必须预测踢到 2 个子号硬上限以内。
|
| 291 |
+
assert len(result["over_cap_kicked"]) == 3, f"expected 3 predictions, got {result['over_cap_kicked']}"
|
| 292 |
# 受害者按 _priority 升序,active 按 p_remain (100-primary_pct) 升序 → primary_pct 最高的先 kick
|
| 293 |
# u5 primary_pct=50 → p_remain=50 是最低 remain → 第一个被 kick
|
| 294 |
+
assert result["over_cap_kicked"] == ["u5@example.com", "u4@example.com", "u3@example.com"]
|
| 295 |
|
| 296 |
|
| 297 |
def test_reconcile_priority_keeps_ghost_when_kick_disabled(tmp_path, monkeypatch):
|
|
|
|
| 301 |
auth_ok = tmp_path / "codex-keep@example.com-team-1.json"
|
| 302 |
auth_ok.write_text("{}", encoding="utf-8")
|
| 303 |
|
| 304 |
+
# 4 个 active 账号 + 1 个 ghost(本地无记录) = 5;ghost 保留时需从本地 active 中踢 3 个。
|
| 305 |
accounts = [
|
| 306 |
{
|
| 307 |
"email": f"u{i}@example.com",
|
|
|
|
| 343 |
assert "ghost-extra@example.com" in result["ghost_seen"]
|
| 344 |
assert "ghost-extra@example.com" not in result["ghost_kicked"]
|
| 345 |
|
| 346 |
+
# 第二轮 over-cap:必须挑 u1-u4 中的 active(p_remain=50, priority=(5,50))
|
| 347 |
# 而不是 ghost-extra(被强制排到 (99, 0) 末尾)
|
| 348 |
assert "ghost-extra@example.com" not in result["over_cap_kicked"], (
|
| 349 |
f"ghost must not be over-cap kicked when RECONCILE_KICK_GHOST=False: {result['over_cap_kicked']}"
|
| 350 |
)
|
| 351 |
+
assert len(result["over_cap_kicked"]) == 3
|
| 352 |
+
assert set(result["over_cap_kicked"]).issubset({f"u{i}@example.com" for i in range(1, 5)})
|
| 353 |
|
| 354 |
|
| 355 |
def test_reconcile_find_team_auth_file_rejects_personal_plan(tmp_path, monkeypatch):
|