ZRainbow commited on
Commit
05bf6da
·
1 Parent(s): ead7e93

fix: harden seat rotation and direct signup race

Browse files
.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. The effective direct-registration race is a budget value until the direct-signup race is explicitly wired into `manager.py`; do not claim single-account race behavior from the scheduler alone.
197
- - When owner workers call `cmd_fill`, they must set `post_sync=False` and `print_status=False`. 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,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
- logger.info(
3619
- "[巡检] auto-fill 冷却中且 Team 实际成员数=%d 已满;本轮只保留低额度替换检查",
3620
- actual_team_count,
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
- logger.info(
3671
- "[巡检] 本地 active=%d < %d,但 Team 实际成员数=%d 已满;先跳过 auto-fill,等待同步/对账稳定",
3672
- len(active),
3673
- sub_account_target,
3674
- actual_team_count,
3675
- )
3676
- continue
 
 
 
 
 
 
 
 
 
 
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 _is_main_account_email(acc.get("email")) or acc.get("status") != STATUS_ACTIVE:
353
- continue
354
- if require_auth and not _has_auth_file(acc):
 
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")) and acc.get("status") in seat_statuses
 
 
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 create_account_direct(mail_client=None, *, leave_workspace=False, out_outcome=None, acc=None, path_rotator=None):
3698
- """
3699
- 直接注册模式(域名已配置自动加入 workspace,不需要邀请)。
3700
- 流程:创建邮箱 注册 ChatGPT → 自动加入 workspace → Codex 登录
3701
- leave_workspace: 加入 workspace 后是否立即退出,转为 personal 模式跑 OAuth。
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
- Round 12 S4: `mail_client` 改为可选(默认 None) None 时按 `acc`(可选)走
3711
- `_get_mail_client_for_account(acc)` 路由;旧调用方显式传 mail_client 时完全
3712
- 保留旧行为(向后兼容)。register-level 多 provider rotation 由 `MAIL_PROVIDER_CHAIN`
3713
- env 驱动:配置 chain 时 `get_mail_client()` 返回 `FallbackMailProvider`,
3714
- 自动在 mail-API 级失败时降级;邮箱-粒度的 provider 切换通过 `RegisterPathRotator`
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
- # Round 12 wire-up M3 — 邮箱-粒度 provider 切换。
3724
- if path_rotator is not None:
3725
- return _create_account_direct_via_rotator(
3726
- path_rotator,
3727
- leave_workspace=leave_workspace,
3728
- out_outcome=out_outcome,
3729
- acc=acc,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 None
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 None
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 None
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 None
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
- mail_client,
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(path_rotator, *, leave_workspace=False, out_outcome=None, acc=None):
 
 
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(chatgpt_api, mail_client=None, *, leave_workspace=False, out_outcome=None, acc=None, path_rotator=None):
 
 
 
 
 
 
 
 
 
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 for a in all_accounts if a["status"] == STATUS_EXHAUSTED and not _is_main_account_email(a.get("email"))
 
 
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 个额度完的账号...", len(all_exhausted))
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 for a in all_accs if a["status"] == STATUS_ACTIVE and not _is_main_account_email(a.get("email"))
 
 
 
 
 
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
- candidates = list(standby_list)
 
 
 
 
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
- if create_new_account(chatgpt, ensure_mail()):
 
 
 
 
 
 
 
 
 
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
- standby_list = [
5381
- a
5382
- for a in get_standby_accounts()
5383
- if a.get("_quota_recovered") and not _is_main_account_email(a.get("email"))
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
- added = create_new_account(chatgpt, mail_client)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(target_seats, leave_workspace=False, post_sync=False, print_status=False)
 
 
 
 
 
 
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 主号 超员 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
- # 必须 1 over_cap_kicked 预测项
291
- assert len(result["over_cap_kicked"]) == 1, f"expected 1 prediction, 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"]
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,超员 1
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 1 个:必须挑 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"]) == 1
352
- assert result["over_cap_kicked"][0] in {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):
 
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):