From 34493b12666a5fd69d69d730b13eabb5ec9d7235 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 28 Jul 2026 02:34:28 +0900 Subject: [PATCH 01/43] release: v2.7.42 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f02953c8f..735df8a60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.7.41", + "version": "2.7.42", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 89837d81dc45cdf9123d90971900baea33821f63 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 28 Jul 2026 03:30:34 +0900 Subject: [PATCH 02/43] fix(ci): let the PR labeler actually write labels (#566) * fix(ci): let the PR labeler actually write labels * test(ci): pin the labeler to the permissions the API demands --- .github/scripts/pr-labeler.test.cjs | 11 +++++++++-- .github/workflows/pr-labeler.yml | 10 ++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/scripts/pr-labeler.test.cjs b/.github/scripts/pr-labeler.test.cjs index 71afd7836..1c6193754 100644 --- a/.github/scripts/pr-labeler.test.cjs +++ b/.github/scripts/pr-labeler.test.cjs @@ -165,8 +165,15 @@ describe("pr-labeler workflow", () => { it("keeps trusted default-branch checkout, concurrency cancel, and minimal permissions", () => { assert.match(workflow, /ref:\s*\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/); assert.match(workflow, /cancel-in-progress:\s*true/); - assert.match(workflow, /pull-requests:\s*read/); assert.match(workflow, /issues:\s*write/); - assert.doesNotMatch(workflow, /pull-requests:\s*write/); + // The issues label endpoints are shared with pull requests: writing a label + // onto a PR number needs pull_requests=write alongside issues=write, which + // GitHub reports as `issues=write; pull_requests=write`. Pinning this to + // read made the workflow fail closed on the first PR that actually needed a + // label applied (#565), so write is the minimum here, not an escalation. + assert.match(workflow, /pull-requests:\s*write/); + // contents stays read — the labeler never pushes. + assert.match(workflow, /contents:\s*read/); + assert.doesNotMatch(workflow, /contents:\s*write/); }); }); diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 8bc529b51..348620fca 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -17,8 +17,14 @@ concurrency: permissions: contents: read - # pulls.get only needs read; label mutations use the issues API. - pull-requests: read + # pulls.get only needs read, but the issues label endpoints are shared with + # pull requests: adding or removing a label on a PR number is rejected with + # "Resource not accessible by integration" unless the token also carries + # pull_requests=write (the API reports `issues=write; pull_requests=write` + # in x-accepted-github-permissions). Read-only here silently worked while + # every run happened to be a no-op sync, and failed on the first PR that + # actually needed a label written. + pull-requests: write issues: write jobs: From 7cb15bff4efd7beafc49d0a125432230c6ec9461 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 28 Jul 2026 04:08:58 +0900 Subject: [PATCH 03/43] chore: stop tracking local agent session state (#567) The .codexclaw/ goalplans and ledgers are per-machine agent state. They were committed with 'git add -f' despite the ignore rule, and once tracked the rule stopped applying, so they rode along into main and preview. Untrack them (files stay on disk), drop two .DS_Store files that got in the same way, and add tests/repo-hygiene.test.ts so a forced add fails CI instead of landing silently. Also drop a registry.ts comment pointing at a .codexclaw evidence file that was never committed and cannot be resolved by any reader. --- .../goalplan.json | 356 ----------------- .../ledger.jsonl | 21 - .../goalplan.json | 365 ------------------ .../ledger.jsonl | 24 -- .gitignore | 6 + devlog/.DS_Store | Bin 6148 -> 0 bytes devlog/_plan/.DS_Store | Bin 6148 -> 0 bytes src/providers/registry.ts | 1 - tests/repo-hygiene.test.ts | 57 +++ 9 files changed, 63 insertions(+), 767 deletions(-) delete mode 100644 .codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/goalplan.json delete mode 100644 .codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/ledger.jsonl delete mode 100644 .codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/goalplan.json delete mode 100644 .codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/ledger.jsonl delete mode 100644 devlog/.DS_Store delete mode 100644 devlog/_plan/.DS_Store create mode 100644 tests/repo-hygiene.test.ts diff --git a/.codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/goalplan.json b/.codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/goalplan.json deleted file mode 100644 index 937dba65c..000000000 --- a/.codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/goalplan.json +++ /dev/null @@ -1,356 +0,0 @@ -{ - "objective": "opencodex 저장소 거버넌스 및 기여 정책 4건을 PABCD 다중 work-phase 루프로 처리한다. WP1 문서화 우선 사이클, WP2 dev2-go 기반 PR 및 포팅/리베이스 PR 허용 정책, WP3 대상 PR 두 개로 분할, WP4 Wibias 메인테이너 추가. 검증은 실제 파일과 명령 출력으로만. 서브에이전트는 gpt-5.6-terra medium 일반 티어 활용.", - "slug": "opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr", - "createdAt": "2026-07-27T00:41:29.352Z", - "updatedAt": "2026-07-27T04:58:54.547Z", - "activeWorkPhaseId": "wp8", - "workPhases": [ - { - "id": "wp1", - "title": "docs-only 로드맵 사이클: devlog/_plan/260727_governance_intake/ 000/010/020/030 작성", - "status": "done", - "tasks": [ - { - "id": "wp1-t1", - "title": "000 현황 조사: MAINTAINERS.md/CODEOWNERS/AGENTS.md/CONTRIBUTING 및 브랜치 실측", - "status": "done" - }, - { - "id": "wp1-t2", - "title": "010 dev2-go 기반 PR + 포팅/리베이스 PR 허용 정책 diff-level 안", - "status": "done" - }, - { - "id": "wp1-t3", - "title": "020 대상 PR 분할 안 (어떤 PR을 어떤 경계로 두 개로)", - "status": "done" - }, - { - "id": "wp1-t4", - "title": "030 Wibias 메인테이너 추가 diff-level 안", - "status": "done" - } - ], - "criteriaIds": [ - "c1" - ] - }, - { - "id": "wp2", - "title": "브랜치 정책 문서화 (문서 전용): dev2-go 통합선 + 포팅/리베이스 PR 환영", - "status": "done", - "tasks": [ - { - "id": "wp2-t1", - "title": "AGENTS.md Branch policy + Review guidelines", - "status": "done" - }, - { - "id": "wp2-t2", - "title": "CONTRIBUTING.md Branches 절", - "status": "done" - }, - { - "id": "wp2-t3", - "title": "MAINTAINERS.md 리뷰/머지 정책", - "status": "done" - } - ], - "criteriaIds": [ - "c2" - ] - }, - { - "id": "wp4", - "title": "Wibias 메인테이너 추가 (MAINTAINERS.md + CODEOWNERS)", - "status": "done", - "tasks": [ - { - "id": "wp4-t1", - "title": "MAINTAINERS.md Current maintainers 표에 @Wibias 추가", - "status": "done" - }, - { - "id": "wp4-t2", - "title": ".github/CODEOWNERS 기본 리뷰어 및 보안 경로 갱신", - "status": "done" - }, - { - "id": "wp4-t3", - "title": "Maintainer changes 절차 이행 기록", - "status": "done" - } - ], - "criteriaIds": [ - "c4" - ] - }, - { - "id": "wp3", - "title": "대상 PR 두 개로 분할 (WP4 이후 — 분할 대상 #518은 Wibias 소유이므로 메인테이너 지위가 선행)", - "status": "done", - "tasks": [ - { - "id": "wp3-t1", - "title": "020 문서가 지정한 분할 대상 및 경계 확정", - "status": "done" - }, - { - "id": "wp3-t2", - "title": "분할 브랜치 생성 및 커밋 분리", - "status": "done" - }, - { - "id": "wp3-t3", - "title": "typecheck + 관련 테스트 통과 확인", - "status": "done" - } - ], - "criteriaIds": [ - "c3" - ] - }, - { - "id": "wp5", - "title": "PR 타깃 게이트 재설계 (enforce-pr-target.yml) — 사용자 승인 완료, allow-list 방식 채택", - "status": "done", - "tasks": [ - { - "id": "wp5-t1", - "title": "040 문서에 승인 결과와 채택안(c: allow-list) 확정 기록", - "status": "done" - }, - { - "id": "wp5-t2", - "title": "enforce-pr-target.yml: EXPECTED_BASE -> ALLOWED_BASES allow-list", - "status": "done" - }, - { - "id": "wp5-t3", - "title": "tests/ci-workflows.test.ts: dev2-go 통과 / 그 외 차단 / 복원 시나리오", - "status": "done" - }, - { - "id": "wp5-t4", - "title": "AGENTS.md/CONTRIBUTING.md/MAINTAINERS.md 서술을 실제 동작에 동기화", - "status": "done" - }, - { - "id": "wp5-t5", - "title": "독립 감사 + 변이 회귀 재실행 (신규 allow-list 우회 변이 포함)", - "status": "pending" - }, - { - "id": "wp5-t6", - "title": "CI 트리거 갭 해소: ci.yml/service-lifecycle.yml pull_request에 dev2-go 추가 + 드리프트 테스트", - "status": "done" - } - ], - "criteriaIds": [ - "c5" - ] - }, - { - "id": "wp6", - "title": "enforce-pr-target.yml 특성화 테스트 — 현재 동작을 고정 (승인 불필요)", - "status": "done", - "tasks": [ - { - "id": "wp6-t1", - "title": "현재 워크플로 계약을 tests/ci-workflows.test.ts에 고정", - "status": "done" - }, - { - "id": "wp6-t2", - "title": "050에서 관측한 상태 마커 정합성 결함을 테스트로 표현", - "status": "done" - }, - { - "id": "wp6-t3", - "title": "bun test tests/ci-workflows.test.ts 통과 확인", - "status": "done" - } - ], - "criteriaIds": [ - "c6" - ] - }, - { - "id": "wp7", - "title": "특성화 테스트 하드닝 — 감사 2/3라운드가 찾은 우회 봉쇄 (wp6 조기 종료 후속)", - "status": "done", - "tasks": [ - { - "id": "wp7-t1", - "title": "감사 2라운드가 찾은 6가지 우회 봉쇄 (잡 인벤토리/잡 권한/스텝 수/블록 주석/복원 분기/pull_number 바인딩)", - "status": "done" - }, - { - "id": "wp7-t2", - "title": "알려진 변이 10종 재주입 검증, 워크플로 원복 확인", - "status": "done" - }, - { - "id": "wp7-t3", - "title": "감사 3라운드 (독립 서브에이전트, 새 각도)", - "status": "done" - }, - { - "id": "wp7-t4", - "title": "3라운드 지적 반영 후 커밋 및 dev 푸시", - "status": "done" - }, - { - "id": "wp7-t5", - "title": "감사 3라운드 FAIL(14건) → 부정 목록을 허용 목록으로 반전", - "status": "done" - }, - { - "id": "wp7-t6", - "title": "감사 4라운드 FAIL(12건, 전부 스크립트 내부) → 실행 하네스 도입", - "status": "done" - }, - { - "id": "wp7-t7", - "title": "감사 5라운드 FAIL(6건, 하네스 충실도) → 하네스를 실제 러너에 맞춤 + 시나리오 3개", - "status": "done" - }, - { - "id": "wp7-t8", - "title": "감사 6~10라운드 대응 (하네스 충실도 + 계약 어설션)", - "status": "done" - }, - { - "id": "wp7-t9", - "title": "감사 11~14라운드 대응 (트리거 모양, 도달 가능 상태, 버전 스큐, 스키마 truthiness 계약)", - "status": "done" - } - ], - "criteriaIds": [ - "c7" - ] - }, - { - "id": "wp8", - "title": "게이트를 main으로 승격 (fast-forward) — pull_request_target은 기본 브랜치 워크플로를 실행하므로 이것 없이는 발효되지 않음", - "status": "deferred", - "tasks": [ - { - "id": "wp8-t1", - "title": "dev HEAD b4a9fe5c 호스팅 CI success 확인 (Cross-platform CI + Service lifecycle)", - "status": "deferred" - }, - { - "id": "wp8-t2", - "title": "main..dev 98커밋 범위에 릴리스 파이프라인/보안 리뷰 대상 변경이 있는지 확인", - "status": "deferred" - }, - { - "id": "wp8-t3", - "title": "git push origin b4a9fe5c:main (fast-forward, 로컬 체크아웃은 dev 유지)", - "status": "deferred" - }, - { - "id": "wp8-t4", - "title": "승격 후 원격 main의 enforce-pr-target.yml에 ALLOWED_BASES 존재 확인 + c5 met 기록", - "status": "deferred" - } - ], - "criteriaIds": [], - "note": "사용자가 세션 범위를 dev로 좁힘(\"일단 dev에서만 처리해\"). 사전 감사도 DO NOT PROMOTE 판정: 승격 범위가 게이트 한정이 아니라 main..dev 98커밋/9172줄이고, main 푸시는 deploy-docs(실제 Pages 배포)를 트리거한다. 메인테이너의 명시적 수용이 선행 조건. 계획서: devlog/_plan/260727_governance_intake/070_main_promotion.md", - "blocker": "EXTERNAL: main 승격은 (a) 사용자가 이번 세션 범위에서 제외했고(\"일단 dev에서만 처리해\"), (b) 사전 감사가 DO NOT PROMOTE 판정을 냈다 — 승격 범위가 게이트 한정이 아니라 main..dev 98커밋/9172줄이며 main 푸시는 deploy-docs(실제 GitHub Pages 배포)를 트리거하므로 메인테이너의 명시적 수용이 선행 조건이다. 에이전트 권한으로 해소할 수 없다." - }, - { - "id": "wp9", - "title": "ocx account 인증 코드를 셸 인자에서 stdin으로 (WP8 사전 감사 High 지적)", - "status": "done", - "tasks": [ - { - "id": "wp9-t1", - "title": "stdin 읽기 헬퍼 + 주입 가능한 deps (runtime-api.ts)", - "status": "done" - }, - { - "id": "wp9-t2", - "title": "account code 위치 인자 / login --code를 선택으로, 기본 stdin, '-'는 명시적 stdin", - "status": "done" - }, - { - "id": "wp9-t3", - "title": "인자 사용 시 stderr 경고 (값은 절대 에코 금지)", - "status": "done" - }, - { - "id": "wp9-t4", - "title": "tests/cli-account.test.ts 회귀 + 독립 감사", - "status": "done" - } - ], - "criteriaIds": [] - } - ], - "criteria": [ - { - "id": "c1", - "scenario": "WP1 docs-only 사이클이 끝나면 devlog/_plan/260727_governance_intake/ 아래 000/010/020/030 문서가 존재하고 각각 변경 파일 경로와 정확한 문구를 담는다. 프로덕션 코드 변경은 0건이다.", - "expectedEvidence": "ls devlog/_plan/260727_governance_intake/ 출력 + git show --stat 으로 src/ 변경 0건 확인", - "capturedEvidence": "ls devlog/_plan/260727_governance_intake/ → 000_survey.md, 010_branch_policy.md, 011_audit_round1.md, 012_audit_round2.md, 013_audit_round3_and_scope_split.md, 020_pr_split.md, 030_maintainer_wibias.md, 040_pr_target_gate.md (8개). git diff --name-only edf3b2c6..HEAD | rg -v '^devlog/' | wc -l → 0 (프로덕션 코드 변경 0건). 커밋 f43ead8c..e0d600f3 (9개). 독립 감사 6회: 1~5차 FAIL, 6차 NEAR-PASS.", - "status": "met" - }, - { - "id": "c2", - "scenario": "AGENTS.md/CONTRIBUTING.md/MAINTAINERS.md 세 파일 모두에서 dev2-go가 정식 통합선으로 문서화되고, 포팅/리베이스 PR이 환영 대상으로 명시되며, 현재 자동화가 dev2-go PR에 [WRONG BRANCH]를 붙인다는 사실이 숨겨지지 않고 적힌다. .github/ 변경은 0건이다.", - "expectedEvidence": "rg -n 'dev2-go' 세 파일 + rg -n -i 'porting|rebase' + rg -n 'WRONG BRANCH' + git diff --name-only에 .github/ 없음", - "capturedEvidence": "rg -c dev2-go AGENTS.md/CONTRIBUTING.md/MAINTAINERS.md → 2/1/2. rg -c -i \"porting|rebase\" AGENTS.md CONTRIBUTING.md → 1/1. rg -c \"WRONG BRANCH\" → 2/1/1 (세 파일 모두 현 자동화 동작 명시). git diff --name-only | rg \"^\\.github/\" → 0건 (워크플로 미변경). docs-site 5개 로케일(en/ko/ja/ru/zh-cn) 각 dev2-go 1건. 커밋 a37cc55b, e4062e32. 독립 감사 2라운드 VERDICT: PASS.", - "status": "met" - }, - { - "id": "c3", - "scenario": "지정된 PR이 두 개의 독립적인 변경으로 분리되고, 각각 bun run typecheck 통과 및 관련 테스트 통과가 증명된다.", - "expectedEvidence": "git log --oneline 분리 브랜치 + bun run typecheck exit 0 + bun test 관련 파일 결과", - "capturedEvidence": "SRC=d93b46932b58b963ea5dfa27dee5c63f3a7b0f2a 고정. B(codex/catalog-written-signal, PR #526): typecheck exit 0, 22 pass 0 fail, 6파일. A(codex/app-server-restart, PR #527): typecheck exit 0, 22 pass 1 skip 0 fail, 15파일. 합집합 검증 diff pr518-manifest split-union → IDENTICAL (21파일). SRC 불변 재확인. 계획 경계 가정 3회 실측 정정: config-routes.ts, gui i18n syncStaleHint, codex-models-cache-invalidate.test.ts 전부 A 소속.", - "status": "met" - }, - { - "id": "c4", - "scenario": "MAINTAINERS.md의 Current maintainers 표에 @Wibias 행이 있고 .github/CODEOWNERS의 기본 리뷰어에 @Wibias가 포함되며, Maintainer changes 절차 이행이 기록된다.", - "expectedEvidence": "rg -n 'Wibias' MAINTAINERS.md .github/CODEOWNERS 출력", - "capturedEvidence": "rg -c Wibias MAINTAINERS.md → 2 (표 행 + change log). rg -c Wibias .github/CODEOWNERS → 1 (기본 리뷰어 * 규칙만). 보안 경로 미포함 검증 → 0건. MAINTAINERS.md 표 3행. 감사 확인: CODEOWNERS 마지막 매칭 우선 규칙으로 @Wibias는 /src/oauth/, /.github/, /scripts/release.ts, /MAINTAINERS.md, /SECURITY.md, /package.json, /bun.lock의 코드오너가 아님(GitHub API errors:[]). 커밋 01e831d0, 71e43d9c, 491373f3.", - "status": "met" - }, - { - "id": "c5", - "scenario": "enforce-pr-target.yml이 dev2-go PR을 정당하게 통과시키고, 그 외 타깃은 차단·복원 경로가 회귀 테스트로 덮이며, PR을 받는 브랜치는 CI 트리거도 함께 받는다. actor 검증 + head SHA 바인딩은 040 문서의 근거 셋에 따라 채택하지 않았다 (강제력 없는 자동 판정 대신 리뷰가 잡는다). main 승격으로 실제 발효된다.", - "expectedEvidence": "워크플로 diff + 신규 테스트 통과 출력 + main 승격 확인", - "capturedEvidence": "커밋: d761e880 (ALLOWED_BASES allow-list, 워크플로 5지점) / 10b1d2aa (문서 8종, 5개 로케일 포함) / 5229717b (ci.yml+service-lifecycle.yml PR 트리거에 dev2-go) / 76c25710 (하네스 node24 충실도 + 트리거 키집합 고정 + live base 코멘트 어설션) / 2b03e908 (ci.yml paths 양쪽 트리거 정확 집합 고정) / 99679376 (040 문서에 15~17라운드 기록). 검증: bun x tsc --noEmit exit 0; bun run test 4945 pass / 0 fail / 24352 expect(); bun test tests/ci-workflows.test.ts 52 pass / 0 fail / 548 expect(). 변이 회귀: mut16 12/12, mut17 5/5, mut18 5/5 (봉쇄 전 5종 전부 SURVIVED), mut20 8/8 (봉쇄 전 4종 SURVIVED) 전부 CAUGHT. 독립 감사 3라운드: 15=FAIL(5건) -> 76c25710, 16=FAIL(1건) -> 2b03e908, 17=PASS (12/12 CAUGHT, 무해 생존 2건 근거 기록). 미충족 잔여: main 승격 (승인 완료, 다음 work-phase). [세션 종료 시점] dev 쪽 조건은 전부 충족(워크플로 diff + ci-workflows 테스트 통과 + 변이 회귀 전건 CAUGHT). 남은 조건인 'main 승격으로 발효'만 미충족이며, 이는 사용자가 이번 세션 범위에서 제외했다. origin/main은 origin/dev의 조상이라 fast-forward 가능한 상태로 남아 있다.", - "status": "deferred", - "blocker": "EXTERNAL: 'main 승격으로 발효' 조건만 미충족. 승격 권한/수용은 메인테이너 결정이며 사용자가 범위에서 제외했다." - }, - { - "id": "c6", - "scenario": "tests/ci-workflows.test.ts에 enforce-pr-target.yml 테스트가 존재하고, 현재 동작(pull_request_target 트리거 집합, EXPECTED_BASE, 제목 prefix, draft 전환, checkout/PR코드실행 없음, 최소 권한)을 고정한다. WP5에서 게이트를 바꿀 때 이 테스트가 회귀 그물이 된다.", - "expectedEvidence": "bun test tests/ci-workflows.test.ts 통과 출력 + rg enforce-pr-target tests/ci-workflows.test.ts", - "capturedEvidence": "tests/ci-workflows.test.ts에 PR target enforcement 테스트 3개(rg -c → 3). bun test → 14 pass 0 fail 282 expect(). bun run typecheck exit 0. 4라운드 적대적 변이 감사: 1차 FAIL(문자열 매칭 구멍 3), 2차 FAIL(YAML 문법 우회 5), 3차 FAIL(함수 shadow), 4차 PASS. 최종적으로 Bun.YAML 파싱 + quote-aware 주석 제거 + 함수 선언 개수/mutation 결속. 통과하는 변이는 전부 고의적 무력화만 남음.", - "status": "met" - }, - { - "id": "c7", - "scenario": "enforce-pr-target.yml 특성화 테스트가 알려진 모든 우회 변이를 잡는다", - "expectedEvidence": "변이 주입 스크립트 출력에서 알려진 변이 전부 CAUGHT, survived=none. 기준선 bun test 통과, bun run typecheck 오류 0. 독립 감사(gpt-5.6-terra) verdict PASS 또는 NEAR-PASS.", - "capturedEvidence": "변이 회귀 12개 스크립트 131종 전수: 알려진 무해 6종(context-eventname, core-tostring, promise-identity, err-tostringtag, response-headers, comment-after-write)과 들여쓰기 no-op 2종 외 전부 CAUGHT, git status --short .github/ clean. 기준선 bun test tests/ci-workflows.test.ts -> 44 pass / 0 fail / 485 expect(). bun run test -> 4937 pass / 0 fail / 24289 expect() exit 0. bun x tsc --noEmit exit 0. 독립 감사(gpt-5.6-terra, agent 019fa18f) 14라운드 verdict NEAR-PASS, 잔여 지적 1건은 dbd558e1로 봉쇄 후 CAUGHT 확인.", - "status": "met" - }, - { - "id": "c9", - "scenario": "ocx account login/code가 인증 코드를 셸 히스토리와 프로세스 목록에 남기지 않는 경로를 기본으로 제공하고, 인자 경로를 쓰면 값을 노출하지 않는 경고가 나온다.", - "expectedEvidence": "테스트 통과 출력 + 인자 경고 어설션 + 값 미노출 어설션", - "capturedEvidence": "구현: d4dfa24e(stdin 기본 경로), e7f5bef5(공백 문법 redact + 중복 --code 거부), d3dc9d79(미지 플래그를 코드로 먹지 않음), f10c5060(비밀 옵션 뒤 토큰은 모양 무관 은닉, -- 구분자 건너뜀, account code 잔여 위치 인자 은닉, 종료된 stdin 즉시 실패), 1de43286(CR/LF 줄바꿈 고정). 검증: bun run test 4965 pass / 0 fail / 24429 expect(); bun x tsc --noEmit exit 0; bun test tests/cli-account.test.ts 62 pass / 0 fail / 276 expect(). 변이: mut23 10/10 CAUGHT, mut21 9/9 CAUGHT, mut22 4/4 CAUGHT. 독립 감사 2라운드: R2 FAIL(High 2 + Medium 1 + Low 1, 전부 프로브로 실측 재현) → 수정 → R3 VERDICT: PASS. 잔여: `--code --nope`는 --nope를 값으로 간주해 은닉(사용 오류 메시지는 --code를 여전히 지목) — 진단성보다 노출 차단 우선.", - "status": "met" - } - ], - "host": { - "armed": false, - "armedAt": null, - "source": "none" - } -} \ No newline at end of file diff --git a/.codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/ledger.jsonl b/.codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/ledger.jsonl deleted file mode 100644 index 4cabb8387..000000000 --- a/.codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/ledger.jsonl +++ /dev/null @@ -1,21 +0,0 @@ -{"ts":"2026-07-27T00:41:29.353Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"created","detail":"init objective=\"opencodex 저장소 거버넌스 및 기여 정책 4건을 PABCD 다중 work-phase 루프로 처리한다. WP1 문서화 우선 사이클, WP2 dev2-go 기반 PR 및 포팅/리베이스 PR 허용 정책, WP3 대상 PR 두 개로 분할, WP4 Wibias 메인테이너 추가. 검증은 실제 파일과 명령 출력으로만. 서브에이전트는 gpt-5.6-terra medium 일반 티어 활용.\" criteria=0"} -{"ts":"2026-07-27T01:18:20.834Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp1"} -{"ts":"2026-07-27T01:18:20.834Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp2"} -{"ts":"2026-07-27T01:26:02.696Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp2"} -{"ts":"2026-07-27T01:26:02.696Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp4"} -{"ts":"2026-07-27T01:32:20.967Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp4"} -{"ts":"2026-07-27T01:32:20.967Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp3"} -{"ts":"2026-07-27T01:45:55.340Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp3"} -{"ts":"2026-07-27T01:45:55.340Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp5"} -{"ts":"2026-07-27T02:09:28.671Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp6"} -{"ts":"2026-07-27T02:09:28.671Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp5"} -{"ts": "2026-07-27T02:12:28.095252Z", "slug": "opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr", "event": "workphase_started", "detail": "started wp7 (P-phase amendment: wp6 closed before the round-2 audit returned FAIL; hardening is the next unit, LOOP-UNIT-CHAIN-01)"} -{"ts":"2026-07-27T02:14:19.004Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp7"} -{"ts":"2026-07-27T02:14:19.004Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp5"} -{"ts":"2026-07-27T03:11:25Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"re-activated wp7: rounds 7-10 hardening was the unit actually worked; wp5 remains blocked on user approval and was parked back to pending"} -{"ts":"2026-07-27T03:39:57.830Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp7"} -{"ts":"2026-07-27T03:39:57.830Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp5"} -{"ts":"2026-07-27T03:40:13Z","event":"work-phase-complete","workPhaseId":"wp7","criterion":"c7","status":"met","evidence":"rounds 11-14 audited; 131 mutation variants CAUGHT except 6 proven-harmless; suite 4937 pass/0 fail; typecheck 0; commits 5f7afc21, dbd558e1"} -{"ts":"2026-07-27T04:17:39.364Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp5"} -{"ts":"2026-07-27T04:58:54.547Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp9"} -{"ts":"2026-07-27T04:58:54.547Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp8"} diff --git a/.codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/goalplan.json b/.codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/goalplan.json deleted file mode 100644 index bf89c77b1..000000000 --- a/.codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/goalplan.json +++ /dev/null @@ -1,365 +0,0 @@ -{ - "objective": "OpenCodex live unfinished issues and PRs triage against current GitHub state. Scope: repository lidge-jun/opencodex, dev target only, worktree . First work-phase is docs-only manifest/devlog: fetch live open PRs and open issues via gh, record current number/title/state/base/head/mergeable/checks/reviews/labels, classify every item into merge now, takeover-fix, comment/request-changes, needs-author-rebase, needs-human/security, later/enhancement, upstream-tracking, or close, and produce a priority table. Later work-phases process exactly one PR or one issue per full PABCD cycle. Allowed: directly fix bugfix/simple safe items on dev, create PRs, wait for CI, squash merge, and close linked issues when live evidence proves safety. Out of scope: main/preview/release branches, automatic merge of security/auth/permission/data-migration/privilege-boundary work, and unapproved GUI/UX decisions except the approved OpenRouter Free separate-provider direction. Terminal outcomes: DONE when all safe live items are processed and manifest evidence is current; NOOP when an item needs no action after live check; NEEDS_HUMAN or UNSAFE for risk-bound/security/UX-decision items; BLOCKED for external author/rebase/CI or upstream dependency; BUDGET_EXHAUSTED only if explicit runtime bounds are hit. Verification: gh live snapshots, code/diff review for candidate PRs, CI/check URLs where actions occur, comments/merge/close URLs for external state changes, and devlog evidence committed locally before completion.", - "slug": "opencodex-live-unfinished-issues-and-prs-triage", - "createdAt": "2026-07-27T10:35:02.724Z", - "updatedAt": "2026-07-27T12:25:15.899Z", - "activeWorkPhaseId": "WP8", - "workPhases": [ - { - "id": "WP0", - "title": "Live GitHub triage manifest and priority table", - "status": "done", - "tasks": [ - { - "id": "WP0-T1", - "title": "Refresh dev branch/worktree state from origin/dev without touching main/preview/release branches", - "status": "done" - }, - { - "id": "WP0-T2", - "title": "Query all current open PRs and issues via gh with state/base/head/mergeable/checks/reviews/labels", - "status": "done" - }, - { - "id": "WP0-T3", - "title": "Write numbered docs-first devlog manifest and priority table", - "status": "done" - }, - { - "id": "WP0-T4", - "title": "Append follow-up one-item work-phases for safe merge/fix/close candidates discovered by the manifest", - "status": "done" - } - ], - "criteriaIds": [ - "C-WP0-LIVE-MANIFEST" - ] - }, - { - "id": "WP1", - "title": "PR #526 catalog write signal rebase and coverage decision", - "status": "done", - "tasks": [ - { - "id": "WP1-T1", - "title": "Re-check PR #526 live head, checks, diff, and independent review", - "status": "done" - }, - { - "id": "WP1-T2", - "title": "Take over rebase/tests or leave a documented blocker for PR #526 only", - "status": "done" - } - ], - "criteriaIds": [ - "C-WP1-PR526" - ] - }, - { - "id": "WP9", - "title": "Dev baseline checkout blocker from devlog gitlinks", - "status": "done", - "tasks": [ - { - "id": "WP9-T1", - "title": "Confirm current dev gitlink/.gitmodules mismatch and whether devlog chase checkouts are required tracked inputs", - "status": "done" - }, - { - "id": "WP9-T2", - "title": "Remove or repair only the accidental devlog gitlinks on a dev-target branch", - "status": "done" - }, - { - "id": "WP9-T3", - "title": "Verify checkout/submodule commands and open/merge the dev baseline CI fix before rerunning PR #526", - "status": "done" - } - ], - "criteriaIds": [ - "C-WP9-DEVLOG-GITLINK" - ] - }, - { - "id": "WP10", - "title": "Dev baseline Desktop 3P target-platform path fix", - "status": "done", - "tasks": [ - { - "id": "WP10-T1", - "title": "Confirm hosted Windows failure and local Desktop 3P path resolver cause", - "status": "done" - }, - { - "id": "WP10-T2", - "title": "Patch target-platform-specific path joining and regression tests on a dev-target branch", - "status": "done" - }, - { - "id": "WP10-T3", - "title": "Verify locally, push PR, wait for hosted checks, and squash merge if green", - "status": "done" - } - ], - "criteriaIds": [ - "C-WP10-DESKTOP3P-PATH" - ] - }, - { - "id": "WP2", - "title": "PR #528 image bridge credential-origin request changes", - "status": "pending", - "tasks": [ - { - "id": "WP2-T1", - "title": "Re-check PR #528 live head, checks, diff, and independent review", - "status": "pending" - }, - { - "id": "WP2-T2", - "title": "Post request-changes comment for credential-origin binding and stale checks", - "status": "pending" - } - ], - "criteriaIds": [ - "C-WP2-PR528" - ] - }, - { - "id": "WP11", - "title": "PR #526 final rerun and merge after dev baseline fixes", - "status": "done", - "tasks": [ - { - "id": "WP11-T1", - "title": "Rebase PR #526 branch onto current origin/dev after WP9/WP10", - "status": "done" - }, - { - "id": "WP11-T2", - "title": "Run local targeted verification and push the rebased PR head", - "status": "done" - }, - { - "id": "WP11-T3", - "title": "Wait for hosted checks and squash merge PR #526 if the latest head is clean", - "status": "done" - } - ], - "criteriaIds": [ - "C-WP11-PR526-FINAL" - ] - }, - { - "id": "WP3", - "title": "Issue #543 Claude queued_command support reply", - "status": "pending", - "tasks": [ - { - "id": "WP3-T1", - "title": "Verify existing debug capture switch and issue context", - "status": "pending" - }, - { - "id": "WP3-T2", - "title": "Post one maintainer comment requesting marker frames", - "status": "pending" - } - ], - "criteriaIds": [ - "C-WP3-ISSUE543" - ] - }, - { - "id": "WP4", - "title": "Issue #547 Claude Desktop custom-model visibility reply", - "status": "pending", - "tasks": [ - { - "id": "WP4-T1", - "title": "Verify issue #547 context and likely evidence gaps", - "status": "pending" - }, - { - "id": "WP4-T2", - "title": "Post one maintainer comment requesting generated config/log/profile evidence", - "status": "pending" - } - ], - "criteriaIds": [ - "C-WP4-ISSUE547" - ] - }, - { - "id": "WP5", - "title": "Issue #545 64-token classifier investigation", - "status": "pending", - "tasks": [ - { - "id": "WP5-T1", - "title": "Trace max_tokens/max_output_tokens path for Desktop 3P classifier requests", - "status": "pending" - }, - { - "id": "WP5-T2", - "title": "Fix if local bug is proven; otherwise comment with exact evidence needed", - "status": "pending" - } - ], - "criteriaIds": [ - "C-WP5-ISSUE545" - ] - }, - { - "id": "WP6", - "title": "PR #527 wrong-base handling", - "status": "done", - "tasks": [ - { - "id": "WP6-T1", - "title": "Re-check #527 after #526 decision", - "status": "pending" - }, - { - "id": "WP6-T2", - "title": "Retarget/request rebase or leave blocker; do not merge in same phase as #526", - "status": "pending" - } - ], - "criteriaIds": [ - "C-WP6-PR527" - ] - }, - { - "id": "WP7", - "title": "Issue #418 V2 custom delegation investigation", - "status": "done", - "tasks": [ - { - "id": "WP7-T1", - "title": "Read custom-parent/custom-child delegation code path and issue evidence", - "status": "done" - }, - { - "id": "WP7-T2", - "title": "Fix or comment with code pointers and required reproduction evidence", - "status": "done" - } - ], - "criteriaIds": [ - "C-WP7-ISSUE418" - ] - }, - { - "id": "WP8", - "title": "Issue #509 JS heap watchdog investigation", - "status": "in_progress", - "tasks": [ - { - "id": "WP8-T1", - "title": "Trace RSS/heap watchdog logic and issue evidence", - "status": "pending" - }, - { - "id": "WP8-T2", - "title": "Fix or comment with code pointers and blocker", - "status": "pending" - } - ], - "criteriaIds": [ - "C-WP8-ISSUE509" - ] - } - ], - "criteria": [ - { - "id": "C-WP0-LIVE-MANIFEST", - "scenario": "Live GitHub state has been refreshed and every open PR/open issue is classified into the requested triage buckets.", - "expectedEvidence": "devlog/_plan/260727_live_unfinished_triage/ numbered manifest files with gh snapshot timestamp, PR/issue lists, classification rationale, and priority table.", - "capturedEvidence": "WP0 closed by cxc D at 2026-07-27T10:46:14Z. Commit 58b247ac records devlog/_plan/260727_live_unfinished_triage. C check: open PR count 14 with numbers 355,424,429,447,461,491,493,495,498,512,526,527,528,533; open issue count 23 with numbers 42,92,95,177,178,201,241,294,386,401,414,415,417,418,425,462,476,509,521,540,543,545,547; manifest has unsafe merge-now entries 0.", - "status": "met" - }, - { - "id": "C-WP1-PR526", - "scenario": "PR #526 is either taken over with rebase/direct coverage or left open with a fresh blocker comment.", - "expectedEvidence": "live PR head/checks, independent review verdict, test evidence or comment URL.", - "capturedEvidence": "WP1 closed by cxc D at 2026-07-27T11:01:22Z. Same-repo branch codex/catalog-written-signal was rebased onto origin/dev@7fcaa9119253d010393cb457427a2868cd935718 and pushed at 43d0efff4569711ed192e09d4d87b62fc803153c. Local pre-push passed typecheck, lint:gui, bun test 5051 pass/0 fail/24881 assertions, privacy scan, and React Doctor. Hosted Issue quality tests/test failed before tests during checkout with fatal missing .gitmodules mapping for devlog/_chase/_cca; origin/dev has the same gitlink mismatch. Merge held pending WP9 baseline checkout repair.", - "status": "met" - }, - { - "id": "C-WP9-DEVLOG-GITLINK", - "scenario": "The dev baseline checkout blocker caused by tracked devlog gitlinks without .gitmodules mappings is fixed or proven to require human direction.", - "expectedEvidence": "git tree/submodule evidence, commit/PR URL, local checkout/submodule verification, and hosted CI rerun evidence if pushed.", - "capturedEvidence": "WP9 closed by cxc D at 2026-07-27T11:11:58Z. Branch codex/devlog-gitlink-ci-fix removed exactly three accidental 160000 gitlinks without .gitmodules mappings: devlog/_chase/_cca, devlog/_chase/_litellm, and devlog/_fin/opencode-cursor. Local verification: git ls-files -s found no 160000 entries; git submodule status --recursive exit 0; git diff --check origin/dev exit 0. PR #550 https://github.com/lidge-jun/opencodex/pull/550 passed hosted checks and was squash-merged into origin/dev at ff831858388179d3f76f4dd7c119d84470214fa6.", - "status": "met" - }, - { - "id": "C-WP10-DESKTOP3P-PATH", - "scenario": "The dev baseline Desktop 3P path resolver returns target-platform separators across hosted OSes and unblocks PR #526 CI.", - "expectedEvidence": "code pointers, targeted Bun test output, TypeScript output, diff-check output, PR URL, hosted check evidence, and merge SHA if green.", - "capturedEvidence": "WP10 branch codex/desktop3p-path-windows-ci commit f6d2881dd422830eece502e0ba8de493205fe9d1 patched src/claude/desktop-3p-paths.ts to use target-platform posix/win32 joins and updated tests/desktop-3p.test.ts plus tests/claude-desktop-config-path.test.ts. Local verification: bun test tests/desktop-3p.test.ts tests/claude-desktop-config-path.test.ts = 30 pass/0 fail; bun x tsc --noEmit exit 0; git diff --check origin/dev exit 0. Prepush full gate: 5047 pass/0 fail, privacy scan passed, GUI doctor skipped. Independent C review Aquinas PASS. PR #552 https://github.com/lidge-jun/opencodex/pull/552 passed hosted CodeRabbit, enforce-target, label, react-doctor, ubuntu-latest, macos-latest, windows-latest, and all npm-global matrix jobs; squash-merged at 2026-07-27T11:43:58Z as origin/dev 7c74e0a22ec96dd5849d3d7253758f0ab15d9737. Remote topic branch deleted.", - "status": "met" - }, - { - "id": "C-WP2-PR528", - "scenario": "PR #528 receives a request-changes comment for credential-origin binding and stale checks.", - "expectedEvidence": "live PR head/checks, independent review verdict, and comment/review URL.", - "capturedEvidence": "", - "status": "open" - }, - { - "id": "C-WP11-PR526-FINAL", - "scenario": "PR #526 is rebased after dev baseline blockers, verified on the latest head, and merged if clean.", - "expectedEvidence": "rebase head SHA, local test/typecheck/diff-check output, pushed head SHA, hosted check list, merge commit URL/SHA or blocker evidence.", - "capturedEvidence": "WP11 closed by cxc D at 2026-07-27T12:05:44Z merge evidence. Branch codex/catalog-written-signal was rebased on origin/dev@7c74e0a22ec96dd5849d3d7253758f0ab15d9737 and pushed at ce716cc117ab23e4420c8c9fe860959968f66cdc. Local targeted verification passed: bun test tests/codex-refresh.test.ts tests/codex-sync-api.test.ts tests/injection-model-api.test.ts = 24 pass/0 fail/112 assertions; bun x tsc --noEmit exit 0; git diff --check origin/dev exit 0. Pre-push full gate passed: bun run test = 5051 pass/0 fail/24881 assertions, privacy scan passed, GUI doctor skipped. Hosted checks on PR #526 head ce716cc117ab23e4420c8c9fe860959968f66cdc all succeeded: CodeRabbit, label, react-doctor, ubuntu-latest, macos-latest, windows-latest, and npm-global ubuntu/macos/windows. Pre-merge gate passed: remote dev stayed 7c74e0a22ec96dd5849d3d7253758f0ab15d9737, PR head matched ce716cc117ab23e4420c8c9fe860959968f66cdc, mergeable MERGEABLE/CLEAN and REST mergeable_state clean. PR #526 https://github.com/lidge-jun/opencodex/pull/526 was squash-merged into dev at 2026-07-27T12:05:44Z as 9dd3c42dae2e7feda3581c6d477cf5a0d6e646bf. Remote codex/catalog-written-signal branch was preserved at ce716cc117ab23e4420c8c9fe860959968f66cdc.", - "status": "met" - }, - { - "id": "C-WP3-ISSUE543", - "scenario": "Issue #543 receives a concrete support reply naming the existing debug capture switch and requested evidence.", - "expectedEvidence": "comment URL plus code/docs pointers for `ocx debug claude` or `OCX_CLAUDE_DEBUG=1`.", - "capturedEvidence": "", - "status": "open" - }, - { - "id": "C-WP4-ISSUE547", - "scenario": "Issue #547 receives a concrete evidence request for Claude Desktop custom-model visibility.", - "expectedEvidence": "comment URL plus requested config/log/profile evidence.", - "capturedEvidence": "", - "status": "open" - }, - { - "id": "C-WP5-ISSUE545", - "scenario": "Issue #545 is narrowed to a proven local fix or exact remaining evidence need.", - "expectedEvidence": "code pointers, test/command output if fixed, PR/comment URL.", - "capturedEvidence": "", - "status": "open" - }, - { - "id": "C-WP6-PR527", - "scenario": "PR #527 wrong-base state is resolved or documented after PR #526 decision.", - "expectedEvidence": "live base/check state and retarget/request-rebase/comment URL.", - "capturedEvidence": "WP6 closed by cxc D at 2026-07-27T12:16:35Z. Live PR #527 remained OPEN on base codex/catalog-written-signal@ce716cc117ab23e4420c8c9fe860959968f66cdc with head codex/app-server-restart@a64aa585630f664a83c25253497a62810133e832, mergeable CONFLICTING and mergeStateStatus DIRTY. PR #526 had merged to dev as 9dd3c42dae2e7feda3581c6d477cf5a0d6e646bf. Topology showed #527 still carried duplicate old #526 commit 1ba588eff663a5be846a8723b90a452dca8cd04c; merge-tree against current dev conflicted in tests/codex-refresh.test.ts and tests/injection-model-api.test.ts. Independent A review returned GO-WITH-FIXES and the blocker was folded into the comment plan. Posted maintainer comment https://github.com/lidge-jun/opencodex/pull/527#issuecomment-5091163284 requesting a clean rebuild from current dev, dropping 1ba588e, preserving the Grok diagnostic, and retargeting after rebuild. No retarget, push, merge, or branch deletion was performed.", - "status": "met" - }, - { - "id": "C-WP7-ISSUE418", - "scenario": "Issue #418 has a proven fix or a fresh investigation comment with code pointers.", - "expectedEvidence": "test/command output if fixed, otherwise comment URL plus code pointers.", - "capturedEvidence": "WP7 closed as NOOP/comment-request-changes at 2026-07-27. Live issue #418 remains OPEN with bug label. Existing comments already satisfy this phase's intended maintainer action: owner request https://github.com/lidge-jun/opencodex/issues/418#issuecomment-5069836945 asks for raw provider tool-call, Responses event, and child lifecycle trace; reporter acknowledgement https://github.com/lidge-jun/opencodex/issues/418#issuecomment-5070272410 says same-run failing spawn_agent trace is still unavailable until usage limit clears; collaborator cross-link https://github.com/lidge-jun/opencodex/issues/418#issuecomment-5085535548 keeps #418 separate from #92 pending the three-boundary capture. Code review found no proven local argument-drop path: parser copies tool parameters at src/responses/parser.ts:134-139; OpenAI-compatible adapter forwards parameters at src/adapters/openai-chat.ts:432-445; provider function.arguments fragments are accumulated at src/adapters/openai-chat.ts:749-768; bridge forwards deltas at src/bridge.ts:610-617 and materializes {} only when accumulated bytes are empty at src/bridge.ts:366-375. No GitHub comment, close, merge, or code change was performed.", - "status": "met" - }, - { - "id": "C-WP8-ISSUE509", - "scenario": "Issue #509 heap watchdog gap has a proven fix or a fresh investigation comment with code pointers.", - "expectedEvidence": "test/command output if fixed, otherwise comment URL plus code pointers.", - "capturedEvidence": "", - "status": "open" - } - ], - "host": { - "armed": true, - "armedAt": "2026-07-27T10:35:02.725Z", - "source": "freeze" - } -} diff --git a/.codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/ledger.jsonl b/.codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/ledger.jsonl deleted file mode 100644 index 07d4aeae8..000000000 --- a/.codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/ledger.jsonl +++ /dev/null @@ -1,24 +0,0 @@ -{"ts":"2026-07-27T10:35:02.725Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"created","detail":"init objective=\"OpenCodex live unfinished issues and PRs triage against current GitHub state. Scope: repository lidge-jun/opencodex, dev target only, worktree . First work-phase is docs-only manifest/devlog: fetch live open PRs and open issues via gh, record current number/title/state/base/head/mergeable/checks/reviews/labels, classify every item into merge now, takeover-fix, comment/request-changes, needs-author-rebase, needs-human/security, later/enhancement, upstream-tracking, or close, and produce a priority table. Later work-phases process exactly one PR or one issue per full PABCD cycle. Allowed: directly fix bugfix/simple safe items on dev, create PRs, wait for CI, squash merge, and close linked issues when live evidence proves safety. Out of scope: main/preview/release branches, automatic merge of security/auth/permission/data-migration/privilege-boundary work, and unapproved GUI/UX decisions except the approved OpenRouter Free separate-provider direction. Terminal outcomes: DONE when all safe live items are processed and manifest evidence is current; NOOP when an item needs no action after live check; NEEDS_HUMAN or UNSAFE for risk-bound/security/UX-decision items; BLOCKED for external author/rebase/CI or upstream dependency; BUDGET_EXHAUSTED only if explicit runtime bounds are hit. Verification: gh live snapshots, code/diff review for candidate PRs, CI/check URLs where actions occur, comments/merge/close URLs for external state changes, and devlog evidence committed locally before completion.\" criteria=0"} -{"ts":"2026-07-27T10:46:14.884Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP0"} -{"ts":"2026-07-27T10:46:14.884Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP1"} -{"ts":"2026-07-27T11:01:22.413Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP1"} -{"ts":"2026-07-27T11:01:22.413Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP2"} -{"ts":"2026-07-27T11:11:58.894Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP9"} -{"ts":"2026-07-27T11:11:58.894Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP2"} -{"ts":"2026-07-27T11:46:27.214Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP10"} -{"ts":"2026-07-27T11:46:27.214Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP2"} -{"ts":"2026-07-27T11:47:00.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP11"} -{"ts":"2026-07-27T12:05:44.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"task_done","detail":"WP11-T1 rebased codex/catalog-written-signal onto origin/dev@7c74e0a22ec96dd5849d3d7253758f0ab15d9737"} -{"ts":"2026-07-27T12:05:44.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"task_done","detail":"WP11-T2 verified locally and pushed ce716cc117ab23e4420c8c9fe860959968f66cdc"} -{"ts":"2026-07-27T12:05:44.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"task_done","detail":"WP11-T3 hosted checks passed and PR #526 squash-merged as 9dd3c42dae2e7feda3581c6d477cf5a0d6e646bf"} -{"ts":"2026-07-27T12:05:44.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"criterion_met","detail":"C-WP11-PR526-FINAL evidence in devlog/_plan/260727_wp11-pr526-final-rerun-merge/011_phase1_evidence.md"} -{"ts":"2026-07-27T12:05:44.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP11"} -{"ts":"2026-07-27T12:05:44.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP2"} -{"ts":"2026-07-27T12:08:50.093Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP11"} -{"ts":"2026-07-27T12:08:50.093Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP3"} -{"ts":"2026-07-27T12:09:30.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"steering","detail":"corrected next active work-phase from WP3 to WP6 because PR #527 explicitly depends on the PR #526 decision; WP2 and WP3 remain pending"} -{"ts":"2026-07-27T12:09:30.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP6"} -{"ts":"2026-07-27T12:16:35.216Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP6"} -{"ts":"2026-07-27T12:16:35.216Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP7"} -{"ts":"2026-07-27T12:25:15.900Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP7"} -{"ts":"2026-07-27T12:25:15.900Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP8"} diff --git a/.gitignore b/.gitignore index 8ee0b5d9c..e35cc4ee6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,14 @@ devlog/ .opencode/ # Local agent/session artifacts +# These are per-machine agent state (goalplans, ledgers, evidence scratch). +# They are never part of the product and must not be committed, not even with +# `git add -f` — see tests/repo-hygiene.test.ts, which fails if any path here +# becomes tracked again. .codexclaw/ +**/.codexclaw/ .omo/ +**/.omo/ # Test-generated artifacts tests/.tmp-*/ diff --git a/devlog/.DS_Store b/devlog/.DS_Store deleted file mode 100644 index 9ac38fcbe91ac0275aa8957e474efc69401ffed6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK&5qMB5FU36n`(vVftB`XiECH%XW13nOH_7WfFL*kDha6|BG^^Z&_h)zXLuo= zgy-3BY$O6vc6@DvZ$BC z;dj+J=-#<|7#&7O(VOIhT_wwO+0@gtdc{xAtu3>yon|kJ`D)gG@YI@RT9|p|5{i6| zkk>B@liStQHYTrKZsI$lUeufQPuACl_{-xk1QVXKA2d57lW_dI)+YRHB?cJ=jxZ}?VN(b zfG{8o>=y&!gp7{%>$T)kifUQS&bf9r10I&@L*GJKIIi`0n*zgK#fasr_yp<%e$56j U4%m8x2O>WLjs|Ijfq%-t4e(K28~^|S diff --git a/devlog/_plan/.DS_Store b/devlog/_plan/.DS_Store deleted file mode 100644 index 3a1ad0021f9ebeed0b59a514a836fce018b0ef71..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK!D`z;5S?|LMkW+|NTEFh1ic2TxQRo^MYzciL?J!2MU^U(iYVG($=1ObbZ>qr zKhp2%o85IvaB?cNrp&?Gq7R1eXjp!&)@$aCrM8j5C;A$ z22}SnIvwGaTy0%>Ij*%n^edEw<64UkDKJbaMl6@&*H9zy`)mM1hpk0;Ao3$%Xplx2 I_^S;30vo_u@c;k- diff --git a/src/providers/registry.ts b/src/providers/registry.ts index c55b544fb..5902c5b29 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -978,7 +978,6 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ authKind: "key", dashboardUrl: "https://ollama.com/settings/keys", // Live IDs verified 2026-07-10; qwen3-coder:480b retires 2026-07-15. - // Evidence: .codexclaw/evidence/260710_wp9_ollama_cloud_model_ids.md. models: ["glm-5.2", "deepseek-v4-pro", "qwen3-coder:480b", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b"], defaultModel: "glm-5.2", noVisionModels: [ diff --git a/tests/repo-hygiene.test.ts b/tests/repo-hygiene.test.ts new file mode 100644 index 000000000..07fcae065 --- /dev/null +++ b/tests/repo-hygiene.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { fileURLToPath } from "node:url"; + +const repoRoot = fileURLToPath(new URL("../", import.meta.url)); + +/** + * Local agent/session state must never reach a commit. + * + * `.gitignore` alone does not enforce this: `git add -f` overrides it silently, + * and once a path is tracked the ignore rule stops applying to it entirely. The + * `.codexclaw/` goalplans and ledgers were committed exactly that way and rode + * along into `main` and `preview` before anyone noticed. + * + * This test closes that gap by asserting against the real index instead of the + * ignore file, so a forced add fails CI on the commit that introduces it. + */ +const FORBIDDEN_TRACKED_DIRS = [".codexclaw", ".omo", ".claude", "node_modules", ".tmp"]; + +const FORBIDDEN_TRACKED_FILENAMES = [".DS_Store", "Thumbs.db"]; + +function trackedFiles(): string[] { + const result = Bun.spawnSync(["git", "ls-files"], { cwd: repoRoot }); + if (result.exitCode !== 0) { + throw new Error(`git ls-files failed: ${new TextDecoder().decode(result.stderr)}`); + } + return new TextDecoder() + .decode(result.stdout) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +describe("repository hygiene", () => { + test("no local agent or session state is tracked", () => { + const offenders = trackedFiles().filter((path) => + path.split("/").some((segment) => FORBIDDEN_TRACKED_DIRS.includes(segment)), + ); + + expect(offenders).toEqual([]); + }); + + test("no OS metadata files are tracked", () => { + const offenders = trackedFiles().filter((path) => + FORBIDDEN_TRACKED_FILENAMES.includes(path.split("/").pop() ?? ""), + ); + + expect(offenders).toEqual([]); + }); + + test("gitignore still declares the agent-state directories", async () => { + const ignore = await Bun.file(new URL("../.gitignore", import.meta.url)).text(); + + for (const dir of FORBIDDEN_TRACKED_DIRS) { + expect(ignore).toContain(`${dir}/`); + } + }); +}); From d2f8d79d4a310810adfdf0534d2f7383da6cb558 Mon Sep 17 00:00:00 2001 From: Max Kaye Date: Thu, 30 Jul 2026 02:23:46 +1000 Subject: [PATCH 04/43] feat(codex): persist per-account selection order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the storage and pure-selection primitives for account priority tiers, with no routing behaviour change yet. - pool-rotation.ts: DEFAULT/MIN/MAX_ACCOUNT_PRIORITY, strict parseAccountPriority + lenient normalizeAccountPriority (mirroring the sticky-limit pair), and selectPriorityTier — the pure tier filter routing will hang off. Provider-agnostic so the Anthropic pool can adopt it. - account-priority.ts: config-sidecar storage (codexAccountPriorities) plus the manual-selection pin, mirroring account-pause.ts. - config schema: proto-guarded record wrapped in .catch(undefined) so a hand-edited typo disables ordering instead of tripping backup-and-defaults. The sidecar (rather than a codexAccounts row field) is what lets the Desktop login __main__ carry an order at all: it has no row. --- .gitignore | 3 ++ src/codex/account-priority.ts | 71 +++++++++++++++++++++++++++ src/codex/pool-rotation.ts | 68 ++++++++++++++++++++++++++ src/config.ts | 52 ++++++++++++++++++++ src/types.ts | 12 +++++ tests/codex-pool-rotation.test.ts | 79 ++++++++++++++++++++++++++++++ tests/config.test.ts | 80 +++++++++++++++++++++++++++++++ 7 files changed, 365 insertions(+) create mode 100644 src/codex/account-priority.ts diff --git a/.gitignore b/.gitignore index e35cc4ee6..bbc0478f8 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ devlog/ .omo/ **/.omo/ +# Local development worktrees +.worktrees/ + # Test-generated artifacts tests/.tmp-*/ .claude/ diff --git a/src/codex/account-priority.ts b/src/codex/account-priority.ts new file mode 100644 index 000000000..36e690c86 --- /dev/null +++ b/src/codex/account-priority.ts @@ -0,0 +1,71 @@ +import { DEFAULT_ACCOUNT_PRIORITY, normalizeAccountPriority } from "./pool-rotation"; +import type { OcxConfig } from "../types"; + +/** + * Persisted selection order for one account: higher numbers are used earlier. + * Stored as a config sidecar rather than a `codexAccounts` row field because the + * Codex Desktop login (`__main__`) has no row and must be orderable too. + */ +export function getCodexAccountPriority(config: OcxConfig, accountId: string): number { + const priorities = config.codexAccountPriorities; + if (!priorities || !Object.hasOwn(priorities, accountId)) return DEFAULT_ACCOUNT_PRIORITY; + return normalizeAccountPriority(priorities[accountId]); +} + +/** + * Write one account's selection order. The default is stored as absence, so a + * pool that was never ordered keeps no map at all and routing takes its fast path. + * Entries are rebuilt through `Object.fromEntries` so a reserved key such as + * `__proto__` becomes an own data property instead of invoking a prototype setter. + */ +export function setCodexAccountPriority(config: OcxConfig, accountId: string, priority: number): void { + const entries = new Map(Object.entries(config.codexAccountPriorities ?? {})); + if (priority === DEFAULT_ACCOUNT_PRIORITY) entries.delete(accountId); + else entries.set(accountId, priority); + + if (entries.size > 0) config.codexAccountPriorities = Object.fromEntries(entries); + else delete config.codexAccountPriorities; +} + +export function forgetCodexAccountPriority(config: OcxConfig, accountId: string): void { + setCodexAccountPriority(config, accountId, DEFAULT_ACCOUNT_PRIORITY); +} + +/** + * Stable lookup for one selection pass. Routing calls this once per pick and + * hands the closure to `selectPriorityTier`, so a pool without stored order pays + * a single map read rather than one per candidate. + */ +export function codexAccountPriorityLookup(config: OcxConfig): (accountId: string) => number { + const priorities = config.codexAccountPriorities; + if (!priorities) return () => DEFAULT_ACCOUNT_PRIORITY; + return accountId => ( + Object.hasOwn(priorities, accountId) + ? normalizeAccountPriority(priorities[accountId]) + : DEFAULT_ACCOUNT_PRIORITY + ); +} + +/** + * The account an operator most recently selected by hand. While pinned, priority + * never preempts upward: the pin is a tier floor for round-robin/fill-first and a + * veto in the quota path, until the account crosses the auto-switch threshold. + */ +export function pinnedCodexAccountId(config: OcxConfig): string | undefined { + return config.activeCodexAccountPinned; +} + +export function isCodexAccountPinned(config: OcxConfig, accountId: string): boolean { + return config.activeCodexAccountPinned === accountId; +} + +export function setCodexAccountPin(config: OcxConfig, accountId: string): void { + config.activeCodexAccountPinned = accountId; +} + +/** Release the pin. With `accountId` given, only when it is the pinned account. */ +export function clearCodexAccountPin(config: OcxConfig, accountId?: string): void { + if (accountId === undefined || config.activeCodexAccountPinned === accountId) { + delete config.activeCodexAccountPinned; + } +} diff --git a/src/codex/pool-rotation.ts b/src/codex/pool-rotation.ts index c75f6d34a..57422b3ca 100644 --- a/src/codex/pool-rotation.ts +++ b/src/codex/pool-rotation.ts @@ -17,6 +17,11 @@ const MAX_STICKY_LIMIT = 100; const DEFAULT_STRATEGY: OcxAccountPoolRotationStrategy = "quota"; const VALID_STRATEGIES = new Set(["quota", "round-robin", "fill-first"]); +/** Selection order for an account with no stored preference: one flat tier. */ +export const DEFAULT_ACCOUNT_PRIORITY = 0; +export const MIN_ACCOUNT_PRIORITY = -100; +export const MAX_ACCOUNT_PRIORITY = 100; + /** Strict parse for management APIs — returns null instead of defaulting. */ export function parseAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy | null { if (typeof raw === "string" && VALID_STRATEGIES.has(raw as OcxAccountPoolRotationStrategy)) { @@ -41,6 +46,69 @@ export function normalizeAccountPoolStickyLimit(raw: unknown): number { return parseAccountPoolStickyLimit(raw) ?? DEFAULT_STICKY_LIMIT; } +/** Strict parse for management APIs — returns null instead of defaulting. */ +export function parseAccountPriority(raw: unknown): number | null { + if ( + typeof raw === "number" + && Number.isInteger(raw) + && raw >= MIN_ACCOUNT_PRIORITY + && raw <= MAX_ACCOUNT_PRIORITY + ) { + return raw; + } + return null; +} + +export function normalizeAccountPriority(raw: unknown): number { + return parseAccountPriority(raw) ?? DEFAULT_ACCOUNT_PRIORITY; +} + +/** + * Narrow an already-eligible account list to the highest selection-order tier that + * still has usable quota. Priority is an *ordering* boundary layered on top of + * eligibility: it never admits an account the caller already filtered out, and it + * never keeps the pool on a tier whose every member is drained. + * + * Contract (each clause is load-bearing for "no behavior change when unconfigured"): + * - one distinct priority across `ids` (the unconfigured case) returns `ids` unchanged, + * so today's pick sequence is preserved byte for byte; + * - input order is preserved inside the returned tier, which keeps the `__main__` + * head-of-list bias and the first-index tie-break used by SWRR/lowest-usage; + * - every tier drained returns `ids` unchanged, reproducing today's + * stay-put-until-429 behavior rather than inventing a pick; + * - a `pinnedId` that is present *and* has headroom lowers the ceiling to its own + * tier, which is what makes a manual "use this now" survive round-robin and + * fill-first without any mutable selection state. A drained or absent pin is + * ignored, so the pin expires on its own once the account crosses the threshold. + */ +export function selectPriorityTier( + ids: readonly string[], + priorityOf: (id: string) => number, + hasHeadroom: (id: string) => boolean, + pinnedId?: string, +): string[] { + const list = ids as string[]; + if (list.length <= 1) return list; + + const priorities = list.map(priorityOf); + const firstPriority = priorities[0]!; + if (priorities.every(priority => priority === firstPriority)) return list; + + let ceiling = Number.POSITIVE_INFINITY; + if (pinnedId !== undefined) { + const pinnedIndex = list.indexOf(pinnedId); + if (pinnedIndex >= 0 && hasHeadroom(pinnedId)) ceiling = priorities[pinnedIndex]!; + } + + const tiers = [...new Set(priorities)].sort((a, b) => b - a); + for (const tier of tiers) { + if (tier > ceiling) continue; + const members = list.filter((_, index) => priorities[index] === tier); + if (members.some(hasHeadroom)) return members; + } + return list; +} + function getOrCreateState(poolKey: string): SelectionState { let state = selectionState.get(poolKey); if (!state) { diff --git a/src/config.ts b/src/config.ts index bd65ea7ee..d2fcd0b3d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,6 +10,8 @@ import { isValidCodexAccountNamespaceTarget, MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET, } from "./codex/account-namespace-match"; +import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./codex/account-id"; +import { parseAccountPriority } from "./codex/pool-rotation"; import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types"; import { hardenSecretDir, hardenSecretPath, hardenSecretPathAsync } from "./lib/windows-secret-acl"; import { recordOwnedConfigPath } from "./lib/config-ownership"; @@ -664,6 +666,35 @@ const codexAccountNamespacesSchema = z.custom>( } }).pipe(z.record(z.string(), z.string())); +const CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR = + "codexAccountPriorities must be a plain object mapping Codex account ids to selection-order integers"; +const CODEX_ACCOUNT_PRIORITY_KEY_ERROR = + "selection-order keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys"; +const CODEX_ACCOUNT_PRIORITY_VALUE_ERROR = + "selection order must be an integer between -100 and 100"; + +function isCodexAccountPriorityKey(key: unknown): key is string { + return key === MAIN_CODEX_ACCOUNT_ID || isValidCodexAccountId(key); +} + +const codexAccountPrioritiesSchema = z.custom>( + (value): value is Record => !!value + && typeof value === "object" + && !Array.isArray(value) + && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null), + { error: CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR }, +).superRefine((priorities, ctx) => { + // Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys. + for (const [accountId, priority] of Object.entries(priorities)) { + if (!isCodexAccountPriorityKey(accountId)) { + ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_KEY_ERROR }); + } + if (parseAccountPriority(priority) === null) { + ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_VALUE_ERROR }); + } + } +}).pipe(z.record(z.string(), z.number().int())); + const configSchema = z.object({ port: z.number().int().min(0).max(65535).default(10100), // A blank hostname degrades to undefined rather than failing the parse. `getDefaultConfig()` @@ -689,6 +720,12 @@ const configSchema = z.object({ codexShimAutoRestore: z.boolean().optional(), pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(), codexAccountNamespaces: codexAccountNamespacesSchema.optional(), + // Selection order is a preference, not a safety control like pause: a malformed + // map degrades to "no ordering" rather than failing the parse, so a hand-edited + // typo cannot trip the backup-and-defaults repair path and wipe providers or + // pool accounts. Warning emitted in loadConfig. + codexAccountPriorities: codexAccountPrioritiesSchema.optional().catch(undefined), + activeCodexAccountPinned: z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/).optional().catch(undefined), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), // Invalid values degrade to undefined ("auto") instead of failing the whole @@ -1007,6 +1044,19 @@ function warnDegradedHostname(rawParsed: unknown, validated: OcxConfig): void { } } +/** + * Companion to {@link warnDegradedStreamMode} for a malformed selection-order map. + * Priority is a preference, so the schema drops the whole map rather than failing + * the parse — say so once, otherwise the pool silently reverts to flat ordering. + */ +function warnDegradedCodexAccountPriorities(rawParsed: unknown, validated: OcxConfig): void { + if (!rawParsed || typeof rawParsed !== "object") return; + const raw = (rawParsed as Record).codexAccountPriorities; + if (raw !== undefined && validated.codexAccountPriorities === undefined) { + console.warn("⚠️ config.json codexAccountPriorities is invalid (expected account ids mapped to integers between -100 and 100) — account selection order is disabled"); + } +} + type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults"; function rawConfigRecord(rawParsed: unknown): Record | null { @@ -1082,6 +1132,7 @@ export function loadConfig(): OcxConfig { const config = result.data as OcxConfig; warnDegradedStreamMode(parsed, config); warnDegradedHostname(parsed, config); + warnDegradedCodexAccountPriorities(parsed, config); warnDegradedNativeSubagentConfig(parsed, config); return normalizeNativeSubagentSync(config, parsed); } @@ -1099,6 +1150,7 @@ export function loadConfig(): OcxConfig { warnConfigRepaired(configPath, result.error); const config = retryResult.data as OcxConfig; warnDegradedHostname(parsed, config); + warnDegradedCodexAccountPriorities(parsed, config); warnDegradedNativeSubagentConfig(parsed, config); return normalizeNativeSubagentSync(config, parsed); } diff --git a/src/types.ts b/src/types.ts index 12f5f1722..fb42a596e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -682,6 +682,18 @@ export interface OcxConfig { codexAccounts?: CodexAccount[]; /** Account ids administratively excluded from future pool selection until resumed. */ pausedCodexAccountIds?: string[]; + /** + * Selection order per account id, higher used earlier; absent = 0. Keyed by id + * rather than stored on `codexAccounts` rows so the Desktop login (`__main__`), + * which has no row, can be ordered too. Range -100..100. + */ + codexAccountPriorities?: Record; + /** + * Account id the operator last selected by hand. Suppresses upward priority + * preemption until that account crosses the auto-switch threshold. Stores the + * id (not a flag) so a stale pin cannot outlive the selection it described. + */ + activeCodexAccountPinned?: string; /** * Public model-selector namespaces bound to one Codex account. Values are stored account ids; * `"@main"` selects the Codex Desktop/main auth.json account. Account display aliases diff --git a/tests/codex-pool-rotation.test.ts b/tests/codex-pool-rotation.test.ts index f16f84dfb..be8306ba8 100644 --- a/tests/codex-pool-rotation.test.ts +++ b/tests/codex-pool-rotation.test.ts @@ -1,8 +1,12 @@ import { clearPoolRotationState, + DEFAULT_ACCOUNT_PRIORITY, + normalizeAccountPriority, notePoolRotationSuccess, + parseAccountPriority, peekRoundRobinAccount, pickRoundRobinAccount, + selectPriorityTier, } from "../src/codex/pool-rotation"; import { clearCodexUpstreamHealth, @@ -72,6 +76,81 @@ function shareSpreadPercent(counts: Record, total: number): numb return Math.max(...shares) - Math.min(...shares); } +describe("account selection order parsing", () => { + test("parseAccountPriority accepts integers inside the configurable range", () => { + expect(parseAccountPriority(0)).toBe(0); + expect(parseAccountPriority(2)).toBe(2); + expect(parseAccountPriority(-2)).toBe(-2); + expect(parseAccountPriority(100)).toBe(100); + expect(parseAccountPriority(-100)).toBe(-100); + }); + + test("parseAccountPriority rejects non-integers, wrong types, and out-of-range values", () => { + for (const raw of [1.5, "2", true, null, undefined, [], {}, Number.NaN, 101, -101, Number.POSITIVE_INFINITY]) { + expect(parseAccountPriority(raw)).toBeNull(); + } + }); + + test("normalizeAccountPriority degrades anything unparseable to the default tier", () => { + expect(normalizeAccountPriority(3)).toBe(3); + expect(normalizeAccountPriority("3")).toBe(DEFAULT_ACCOUNT_PRIORITY); + expect(normalizeAccountPriority(undefined)).toBe(DEFAULT_ACCOUNT_PRIORITY); + }); +}); + +describe("selectPriorityTier", () => { + const withHeadroom = () => true; + const noHeadroom = () => false; + const flat = () => 0; + + test("returns the input untouched when every account shares one tier", () => { + const ids = ["a", "b", "c"]; + expect(selectPriorityTier(ids, flat, withHeadroom)).toEqual(ids); + }); + + test("keeps only the highest tier that still has headroom", () => { + const priorities: Record = { a: 1, b: 0, c: 1 }; + const selected = selectPriorityTier(["a", "b", "c"], id => priorities[id]!, withHeadroom); + expect(selected).toEqual(["a", "c"]); + }); + + test("preserves input order inside the selected tier", () => { + const priorities: Record = { __main__: 0, a: 0, b: -1 }; + const selected = selectPriorityTier(["__main__", "a", "b"], id => priorities[id]!, withHeadroom); + expect(selected).toEqual(["__main__", "a"]); + }); + + test("falls through to the next tier once the tier above is drained", () => { + const priorities: Record = { a: 1, b: 0 }; + const selected = selectPriorityTier(["a", "b"], id => priorities[id]!, id => id !== "a"); + expect(selected).toEqual(["b"]); + }); + + test("returns the input unchanged when every tier is drained", () => { + const priorities: Record = { a: 1, b: 0 }; + const ids = ["a", "b"]; + expect(selectPriorityTier(ids, id => priorities[id]!, noHeadroom)).toEqual(ids); + }); + + test("a pinned account with headroom lowers the ceiling to its own tier", () => { + const priorities: Record = { a: 1, b: 0, c: 0 }; + const selected = selectPriorityTier(["a", "b", "c"], id => priorities[id]!, withHeadroom, "b"); + expect(selected).toEqual(["b", "c"]); + }); + + test("a drained pin is ignored so ordering resumes on its own", () => { + const priorities: Record = { a: 1, b: 0 }; + const selected = selectPriorityTier(["a", "b"], id => priorities[id]!, id => id !== "b", "b"); + expect(selected).toEqual(["a"]); + }); + + test("a pin that is no longer eligible is ignored", () => { + const priorities: Record = { a: 1, b: 0 }; + const selected = selectPriorityTier(["a", "b"], id => priorities[id]!, withHeadroom, "gone"); + expect(selected).toEqual(["a"]); + }); +}); + describe("pickRoundRobinAccount", () => { beforeEach(() => clearPoolRotationState()); diff --git a/tests/config.test.ts b/tests/config.test.ts index 6f0fe1ed5..66a58fd38 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1463,3 +1463,83 @@ describe("config.ts – Windows ACL hardening integration", () => { spy.mockRestore(); }); }); + +describe("codex account selection order", () => { + function writePriorityConfig( + codexAccountPriorities: unknown, + overrides: Record = {}, + ): void { + writeConfig({ + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + codexAccountPriorities, + ...overrides, + }); + } + + test("round-trips pool ids, the main account, and negative order", () => { + const priorities = { work: 2, side: 1, __main__: -2 }; + writePriorityConfig(priorities); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.source).toBe("file"); + expect(diagnostics.config.codexAccountPriorities).toEqual(priorities); + expect(Object.hasOwn(getDefaultConfig(), "codexAccountPriorities")).toBe(false); + }); + + test.each([ + ["null", null], + ["an array", []], + ["a string", "work"], + ["a fractional value", { work: 1.5 }], + ["a stringified number", { work: "2" }], + ["a boolean", { work: true }], + ["an above-range value", { work: 101 }], + ["a below-range value", { work: -101 }], + ["a reserved constructor key", { constructor: 1 }], + ["a slash in the key", { "work/account": 1 }], + ] as const)("degrades %s to no ordering without discarding the rest of the config", (_label, priorities) => { + writePriorityConfig(priorities); + + const diagnostics = readConfigDiagnostics(); + // Selection order is a preference: a malformed map must never trip the + // backup-and-defaults repair path that would reset providers. + expect(diagnostics.source).toBe("file"); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.codexAccountPriorities).toBeUndefined(); + expect(Object.keys(diagnostics.config.providers)).toContain("openai"); + expect(backupNames()).toHaveLength(0); + }); + + test("degrades a literal __proto__ entry, which JSON.parse materializes as an own key", () => { + writeConfig( + '{"port":10100,"providers":{"openai":{"adapter":"openai-responses",' + + '"baseUrl":"https://chatgpt.com/backend-api/codex","authMode":"forward"}},' + + '"defaultProvider":"openai","codexAccountPriorities":{"__proto__":1,"work":2}}', + ); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("file"); + expect(diagnostics.config.codexAccountPriorities).toBeUndefined(); + expect(Object.keys(diagnostics.config.providers)).toContain("openai"); + }); + + test("keeps a valid pin and degrades a malformed one", () => { + writePriorityConfig({ work: 1 }, { activeCodexAccountPinned: "work" }); + expect(readConfigDiagnostics().config.activeCodexAccountPinned).toBe("work"); + + writePriorityConfig({ work: 1 }, { activeCodexAccountPinned: "work/account" }); + const degraded = readConfigDiagnostics(); + expect(degraded.source).toBe("file"); + expect(degraded.config.activeCodexAccountPinned).toBeUndefined(); + expect(degraded.config.codexAccountPriorities).toEqual({ work: 1 }); + }); +}); From 17ede31dd5b133d61643bd6f1f3922a7c56d15c2 Mon Sep 17 00:00:00 2001 From: Max Kaye Date: Thu, 30 Jul 2026 02:31:50 +1000 Subject: [PATCH 05/43] feat(codex): route the Codex pool by selection order Hangs tiering off a single choke point: getEligiblePoolAccounts now returns the highest-priority tier that still has quota headroom, so all three strategies, failover, auto-switch destinations, and the preview path inherit it without their own priority logic. An unordered pool takes the fast path and picks exactly what it picked before. - hasCodexQuotaHeadroom: isActiveUnderFillFirstThreshold renamed and reused as the tier-drain predicate; its two escape hatches (threshold disabled, unknown usage) are what stop an unprimed tier from being skipped. - pickPriorityPreemption: unbound requests move back up when a higher tier regains headroom, runtime-only so an automatic move never rewrites the operator's persisted selection. Bound threads return before it. - Pin lifecycle: PUT /active records the manual choice, which acts as a tier floor until the account is drained, excluded, or routing moves off it. - pickAlternateCodexAccount now passes excludeId into eligibility instead of post-filtering, or a same-request retry could dead-end on a tier whose only healthy member is the account being excluded. - subagent-model-fallback reads the effective active account; the raw field misses every in-memory cursor move (pre-existing under round-robin). --- src/codex/account-lifecycle.ts | 3 + src/codex/auth-api.ts | 5 + src/codex/routing.ts | 144 ++++++++++++++++++--- src/codex/subagent-model-fallback.ts | 9 +- tests/codex-pool-rotation.test.ts | 143 ++++++++++++++++++++ tests/codex-routing.test.ts | 186 +++++++++++++++++++++++++++ 6 files changed, 472 insertions(+), 18 deletions(-) diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index 35831af55..34bfa7c42 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -7,6 +7,7 @@ import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } f import { invalidateCodexWebSocketsForAccount } from "./websocket-registry"; import { clearMainAccountInfoCache } from "./main-account-cache"; import { forgetCodexAccountPause } from "./account-pause"; +import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority"; import type { OcxConfig } from "../types"; let observedMainChatgptAccountId: string | undefined; @@ -49,6 +50,8 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []) .filter(account => account.isMain || account.id !== accountId); forgetCodexAccountPause(runtimeConfig, accountId); + forgetCodexAccountPriority(runtimeConfig, accountId); + clearCodexAccountPin(runtimeConfig, accountId); if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined; purgeCodexAccountRuntimeState(accountId); invalidateCodexWebSocketsForAccount(accountId); diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index f2cea1bde..f300d15c0 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -12,6 +12,7 @@ import { } from "./account-store"; import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause"; +import { setCodexAccountPin } from "./account-priority"; import { clearCodexAccountCooldown, clearThreadAccountMapForAccount, @@ -809,6 +810,10 @@ export async function handleCodexAuthAPI( if (!exists) return jsonResponse({ error: "Account not found" }, 400); } runtimeConfig.activeCodexAccountId = body.accountId ?? undefined; + // "Use this account now" outranks selection order until the account is spent: + // persisted here rather than in resetCodexRoutingForManualSelection, which is + // runtime state only. + setCodexAccountPin(runtimeConfig, targetAccountId); resetCodexRoutingForManualSelection(targetAccountId); saveRuntimeConfig(config, runtimeConfig); return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true }); diff --git a/src/codex/routing.ts b/src/codex/routing.ts index bbc13f844..ef720b0d7 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -3,6 +3,7 @@ import { saveConfigPreservingClaudeCode } from "../config"; import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store"; import { codexAccountLogLabel } from "./account-label"; import { isCodexAccountPaused } from "./account-pause"; +import { clearCodexAccountPin, codexAccountPriorityLookup, pinnedCodexAccountId } from "./account-priority"; import { isCodexAccountUsable } from "./account-usability"; import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; import { @@ -14,6 +15,7 @@ import { peekRoundRobinAccount, pickRoundRobinAccount, seedPoolRotationAccount, + selectPriorityTier, } from "./pool-rotation"; import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account"; @@ -715,7 +717,15 @@ function getEligiblePoolAccounts( ) { ids.unshift(MAIN_CODEX_ACCOUNT_ID); } - return ids; + // Single choke point for selection order: every strategy, failover, and preview + // reaches the pool through here, so tiering applies once rather than per picker. + // Eligibility above is unchanged — this only narrows an already-eligible list. + return selectPriorityTier( + ids, + codexAccountPriorityLookup(config), + id => hasCodexQuotaHeadroom(config, id), + pinnedCodexAccountId(config), + ); } function listEligibleCodexAccountIds( @@ -730,11 +740,20 @@ function stickyLimitForConfig(config: OcxConfig): number { return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit); } -function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): boolean { +/** + * Whether an account still has quota to give under the auto-switch threshold. + * + * Fill-first and the priority tier filter share this predicate, and share both of + * its escape hatches. A disabled threshold means only health, pause, and reauth + * may drain an account; unknown usage is a guess, so it must neither force + * fill-first off the active account nor drain a tier that was simply never + * primed. A genuinely exhausted account 429s into cooldown and leaves + * eligibility on its own. + */ +function hasCodexQuotaHeadroom(config: OcxConfig, accountId: string): boolean { const threshold = config.autoSwitchThreshold ?? 80; if (threshold <= 0) return true; const usage = computeCodexUsageScore(getAccountQuota(accountId), getPoolAccountPlan(config, accountId)); - // Unknown usage must not force fill-first to abandon the active account. if (isUnknownUsage(usage)) return true; return usage < threshold; } @@ -752,7 +771,7 @@ function pickFillFirstCodexAccount( if (eligible.length === 0) return null; const active = getEffectiveActiveCodexAccountId(config); - if (active && eligible.includes(active) && isActiveUnderFillFirstThreshold(config, active)) { + if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active)) { return active; } @@ -771,7 +790,7 @@ function pickNextFillFirstCodexAccount( if (!afterId) { // Prefer an under-threshold account when starting with no active cursor. for (const id of ordered) { - if (isActiveUnderFillFirstThreshold(config, id)) return id; + if (hasCodexQuotaHeadroom(config, id)) return id; } return ordered[0] ?? null; } @@ -786,7 +805,7 @@ function pickNextFillFirstCodexAccount( const startIdx = stableAll.indexOf(afterId); if (startIdx < 0) { for (const id of ordered) { - if (isActiveUnderFillFirstThreshold(config, id)) return id; + if (hasCodexQuotaHeadroom(config, id)) return id; } return ordered[0] ?? null; } @@ -797,7 +816,7 @@ function pickNextFillFirstCodexAccount( const candidate = stableAll[(startIdx + step) % stableAll.length]!; if (!eligible.includes(candidate)) continue; if (!fallback) fallback = candidate; - if (isActiveUnderFillFirstThreshold(config, candidate)) return candidate; + if (hasCodexQuotaHeadroom(config, candidate)) return candidate; } return fallback ?? ordered[0] ?? null; } @@ -881,15 +900,11 @@ function pickLowerUsageAccount( return best; } -export function pickLowestUsageCodexAccount( - config: OcxConfig, - excludeId?: string, - now = Date.now(), - quotaScope?: CodexQuotaScope, -): string | null { +/** Coolest account in an already-selected candidate list; first index wins ties. */ +function pickLowestUsageAmong(config: OcxConfig, ids: readonly string[]): string | null { let best: string | null = null; let bestUsage = Number.POSITIVE_INFINITY; - for (const id of getEligiblePoolAccounts(config, excludeId, now, quotaScope)) { + for (const id of ids) { const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id)); if (usage < bestUsage) { best = id; @@ -899,6 +914,15 @@ export function pickLowestUsageCodexAccount( return best; } +export function pickLowestUsageCodexAccount( + config: OcxConfig, + excludeId?: string, + now = Date.now(), + quotaScope?: CodexQuotaScope, +): string | null { + return pickLowestUsageAmong(config, getEligiblePoolAccounts(config, excludeId, now, quotaScope)); +} + /** * Strategy-aware alternate after a cooled/excluded account (same-request 429 retry * and active promotion). Quota keeps lowest-usage; fill-first advances stable order; @@ -911,12 +935,16 @@ export function pickAlternateCodexAccount( quotaScope?: CodexQuotaScope, ): string | null { const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); + // The exclusion is passed into eligibility rather than post-filtered off its + // result: when the excluded account is the only healthy member of the top + // tier, the tier walk must be free to descend instead of selecting that tier + // and then handing back an empty list. if (strategy === "round-robin") { - const eligible = listEligibleCodexAccountIds(config, now, quotaScope).filter(id => id !== excludeId); + const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope); return pickRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config)); } if (strategy === "fill-first") { - const eligible = listEligibleCodexAccountIds(config, now, quotaScope).filter(id => id !== excludeId); + const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope); return pickNextFillFirstCodexAccount(config, excludeId, eligible, now); } return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope); @@ -935,10 +963,22 @@ function rememberActiveCodexAccount(_config: OcxConfig, accountId: string): void runtimeActiveCodexAccountId = accountId; } +/** + * End the manual pin when routing moves to a different account. Returns whether + * the pin changed so the caller can fold it into a write it was already making. + */ +function releaseCodexAccountPinFor(config: OcxConfig, accountId: string): boolean { + const pinned = pinnedCodexAccountId(config); + if (pinned === undefined || pinned === accountId) return false; + clearCodexAccountPin(config); + return true; +} + /** Persist operator (or quota-strategy) active selection to config + disk. */ function setActiveCodexAccount(config: OcxConfig, accountId: string): void { runtimeActiveCodexAccountId = undefined; - if (config.activeCodexAccountId === accountId) return; + const releasedPin = releaseCodexAccountPinFor(config, accountId); + if (config.activeCodexAccountId === accountId && !releasedPin) return; config.activeCodexAccountId = accountId; saveConfigPreservingClaudeCode(config); } @@ -949,6 +989,10 @@ function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void { setActiveCodexAccount(config, accountId); return; } + // Runtime-only, like the cursor itself: a caller that persists (pause, delete) + // saves this release with its own write; a transient failover does not, so the + // pin survives a restart that also clears the failure history behind it. + releaseCodexAccountPinFor(config, accountId); rememberActiveCodexAccount(config, accountId); } @@ -967,6 +1011,9 @@ export function reconcileCodexActiveAfterExclusion( if (config.activeCodexAccountId === excludedAccountId) { config.activeCodexAccountId = undefined; } + // Excluding an account revokes any manual pin on it even when it was not the + // effective active — otherwise a paused account keeps acting as a tier floor. + clearCodexAccountPin(config, excludedAccountId); if (!wasEffective) return getEffectiveActiveCodexAccountId(config) ?? null; runtimeActiveCodexAccountId = undefined; @@ -979,6 +1026,53 @@ function isUnknownUsage(usage: number): boolean { return usage >= CODEX_UNKNOWN_USAGE_SCORE; } +/** + * Move an unbound request back up when a higher tier regains headroom — the + * weekly-reset case. Returns null when nothing should change. + * + * Downward moves are deliberately left to {@link applyQuotaAutoSwitch}: this only + * fires when the tier filter has already excluded `active`, and only toward a + * tier that strictly outranks it. Threads bound by affinity never reach here. + */ +function pickPriorityPreemption( + config: OcxConfig, + active: string, + now: number, + quotaScope?: CodexQuotaScope, +): string | null { + const eligible = getEligiblePoolAccounts(config, undefined, now, quotaScope); + if (eligible.length === 0 || eligible.includes(active)) return null; + const pinned = pinnedCodexAccountId(config); + // A live pin already lowered the tier ceiling; never preempt past an explicit + // operator choice. Same liveness test the tier filter applies, so preview and + // resolve agree even before the pin is garbage-collected. + if (pinned !== undefined && eligible.includes(pinned) && hasCodexQuotaHeadroom(config, pinned)) return null; + const priorityOf = codexAccountPriorityLookup(config); + if (priorityOf(eligible[0]!) <= priorityOf(active)) return null; + // Members without headroom are in the tier only because a sibling has some; + // picking one would hand the request straight back to a drained account. + return pickLowestUsageAmong(config, eligible.filter(id => hasCodexQuotaHeadroom(config, id))); +} + +/** + * Release a pin whose account is durably drained. "Use this account now" ends + * when the account crosses the auto-switch threshold or stops being selectable + * at all — never on a transient cooldown or soft-avoid, which it recovers from + * on its own. Clearing the pin also removes the condition, so this writes at + * most once per pin. + */ +function releaseDrainedCodexAccountPin(config: OcxConfig): void { + const pinned = pinnedCodexAccountId(config); + if (pinned === undefined) return; + const drained = !isCodexAccountUsable(config, pinned) + || isAccountNeedsReauth(pinned) + || isCodexAccountPaused(config, pinned) + || !hasCodexQuotaHeadroom(config, pinned); + if (!drained) return; + clearCodexAccountPin(config); + saveConfigPreservingClaudeCode(config); +} + function applyQuotaAutoSwitch( config: OcxConfig, active: string, @@ -1086,6 +1180,7 @@ export function previewCodexAccountForRequest( else if (hasConfiguredPoolAccount(config, active) && !isCodexAccountPaused(config, active)) return active; else return null; } + active = pickPriorityPreemption(config, active, now, quotaScope) ?? active; const threshold = config.autoSwitchThreshold ?? 80; if (threshold > 0) { @@ -1163,6 +1258,11 @@ export function resolveCodexAccountForThreadDetailed( deleteThreadAffinity(threadId, quotaScope); } + // Unbound from here down, so a spent pin can be retired before it constrains + // this pick. Resolve-only: preview stays side-effect-free and reaches the same + // decision by testing pin liveness instead of clearing it. + releaseDrainedCodexAccountPin(config); + const strategyPick = pickUnboundStrategyAccount(config, threadId, now, true, quotaScope); if (strategyPick) return { status: "selected", accountId: strategyPick }; @@ -1184,6 +1284,16 @@ export function resolveCodexAccountForThreadDetailed( return { status: "none" }; } } + // Before applyQuotaAutoSwitch: its sync disk write would otherwise persist a + // move inside the drained tier that preemption immediately overrides. + const preempted = pickPriorityPreemption(config, active, now, quotaScope); + if (preempted) { + // Runtime-only, like every other automatic pick: config.activeCodexAccountId + // stays the operator's selection and getEffectiveActiveCodexAccountId is what + // surfaces this to the API and dashboard. + rememberActiveCodexAccount(config, preempted); + active = preempted; + } active = applyQuotaAutoSwitch(config, active, now, quotaScope); active = applyFailureFailover(config, active, now); if (!isCodexAccountUsable(config, active)) { diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index 6e936ec22..f426a5ef5 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -16,6 +16,7 @@ import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; import { canAcquireCodexQuotaProbeLease, computeCodexUsageScore, + getEffectiveActiveCodexAccountId, getPoolAccountPlan, isCodexAccountInCooldown, } from "./routing"; @@ -108,8 +109,14 @@ function quotaThreshold(config: OcxConfig): number { return threshold > 0 ? threshold : Number.POSITIVE_INFINITY; } +/** + * The account routing would actually use, not just the persisted operator + * selection: round-robin, fill-first, failover, and priority preemption all + * move the cursor in memory only, so reading the raw field would check quota + * against an account this request is not going to touch. + */ function activeCodexAccountId(config: OcxConfig): string | null { - return config.activeCodexAccountId ?? null; + return getEffectiveActiveCodexAccountId(config) ?? null; } /** diff --git a/tests/codex-pool-rotation.test.ts b/tests/codex-pool-rotation.test.ts index be8306ba8..62bb70fd8 100644 --- a/tests/codex-pool-rotation.test.ts +++ b/tests/codex-pool-rotation.test.ts @@ -604,3 +604,146 @@ describe("accountPoolStrategy new-session routing", () => { expect(config.activeCodexAccountId).toBe("a"); }); }); + +describe("selection order across rotation strategies", () => { + beforeEach(() => { + previousOpencodexHome = process.env.OPENCODEX_HOME; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = TEST_DIR; + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + clearPoolRotationState(); + }); + + afterEach(() => { + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + }); + + function primeAllQuota(usage = 10): void { + for (const id of THREE_ACCOUNT_IDS) updateAccountQuota(id, usage); + } + + test("round-robin spreads only across the highest tier", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "round-robin", + codexAccountPriorities: { a: 1, b: 1 }, + } as Partial); + primeAllQuota(); + + const picks = Array.from({ length: 12 }, () => resolveCodexAccountForThread(null, config)); + expect(countPicks(picks, THREE_ACCOUNT_IDS)).toEqual({ a: 6, b: 6, c: 0 }); + }); + + test("round-robin falls through to the next tier when the one above drains", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "round-robin", + codexAccountPriorities: { a: 1, b: 1 }, + } as Partial); + primeAllQuota(); + updateAccountQuota("a", 95); + updateAccountQuota("b", 95); + + const picks = Array.from({ length: 4 }, () => resolveCodexAccountForThread(null, config)); + expect(countPicks(picks, THREE_ACCOUNT_IDS)).toEqual({ a: 0, b: 0, c: 4 }); + }); + + test("round-robin returns to the higher tier after its quota resets", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "round-robin", + codexAccountPriorities: { a: 1 }, + } as Partial); + primeAllQuota(); + updateAccountQuota("a", 95); + expect(resolveCodexAccountForThread(null, config)).not.toBe("a"); + + updateAccountQuota("a", 5); + expect(resolveCodexAccountForThread(null, config)).toBe("a"); + }); + + test("a pinned account holds round-robin until it crosses the threshold", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "round-robin", + codexAccountPriorities: { a: 1, b: 1 }, + activeCodexAccountPinned: "c", + } as Partial); + primeAllQuota(); + + const held = Array.from({ length: 4 }, () => resolveCodexAccountForThread(null, config)); + expect(countPicks(held, THREE_ACCOUNT_IDS)).toEqual({ a: 0, b: 0, c: 4 }); + + updateAccountQuota("c", 95); + expect(resolveCodexAccountForThread(null, config)).not.toBe("c"); + }); + + test("fill-first drains the highest tier before descending", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "fill-first", + activeCodexAccountId: undefined, + codexAccountPriorities: { c: 1 }, + } as Partial); + primeAllQuota(); + + expect(resolveCodexAccountForThread(null, config)).toBe("c"); + updateAccountQuota("c", 95); + expect(resolveCodexAccountForThread(null, config)).toBe("a"); + }); + + test("an alternate crosses tiers when the exclusion empties the tier above", () => { + const config = makeThreeAccountConfig({ codexAccountPriorities: { a: 1 } } as Partial); + primeAllQuota(); + + // Without the exclusion reaching eligibility the tier walk would select + // ["a"] and the caller's post-filter would leave nothing to fail over to. + expect(pickAlternateCodexAccount(config, "a")).toBe("b"); + }); + + test("an independent native scope tiers on its own health snapshot", () => { + const config = makeThreeAccountConfig({ + codexAccountPriorities: { a: 1 }, + } as Partial); + const now = 1_800_000_000_000; + primeAllQuota(); + + expect(resolveCodexAccountForThread(null, config, now, "shared")).toBe("a"); + recordCodexUpstreamOutcome(config, "a", 429, { + now: now + 1, + resetAt: Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000), + modelId: "gpt-5.3-codex-spark", + }); + + expect(resolveCodexAccountForThread(null, config, now + 2, "spark")).toBe("b"); + expect(resolveCodexAccountForThread(null, config, now + 3, "shared")).toBe("a"); + }); + + test.each(["quota", "round-robin", "fill-first"] as const)( + "%s picks the same sequence with no stored order as before the feature", + strategy => { + const config = makeThreeAccountConfig({ accountPoolStrategy: strategy }); + primeAllQuota(); + const picks = Array.from({ length: 6 }, () => resolveCodexAccountForThread(null, config)); + + clearPoolRotationState(); + clearThreadAccountMap(); + const flat = makeThreeAccountConfig({ + accountPoolStrategy: strategy, + codexAccountPriorities: { a: 0, b: 0, c: 0 }, + } as Partial); + const flatPicks = Array.from({ length: 6 }, () => resolveCodexAccountForThread(null, flat)); + + expect(flatPicks).toEqual(picks); + expect(new Set(picks).size).toBe(strategy === "round-robin" ? 3 : 1); + }, + ); +}); diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index 351e98e84..d4c2cfb88 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -24,6 +24,8 @@ import { isCodexAccountSoftAvoided, pickLowestUsageCodexAccount, parseRetryAfterMs, + previewCodexAccountForRequest, + reconcileCodexActiveAfterExclusion, recordCodexUpstreamOutcome, resetCodexRoutingForManualSelection, resolveCodexAccountForThread, @@ -1426,3 +1428,187 @@ describe("codex routing", () => { expect(resolveCodexAccountForThread("t2", config, now + 6)).toBe("b"); }); }); + +describe("codex account selection order", () => { + beforeEach(() => { + previousOpencodexHome = process.env.OPENCODEX_HOME; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = TEST_DIR; + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + clearAccountNeedsReauth("a"); + clearAccountNeedsReauth("b"); + saveTestCredential("a"); + saveTestCredential("b"); + }); + + afterEach(() => { + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearAccountNeedsReauth("a"); + clearAccountNeedsReauth("b"); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + }); + + /** `a` is ordered above `b`; the persisted operator selection is the lower tier. */ + function orderedConfig(overrides: Partial = {}): OcxConfig { + return makeConfig({ + activeCodexAccountId: "b", + codexAccountPriorities: { a: 1 }, + ...overrides, + } as Partial); + } + + test("an unbound request moves back up to the higher tier even when it is hotter", () => { + const config = orderedConfig(); + updateAccountQuota("a", 70); + updateAccountQuota("b", 10); + + expect(resolveCodexAccountForThread(null, config)).toBe("a"); + }); + + test("preemption keeps the operator's persisted selection intact", () => { + const config = orderedConfig(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + + expect(resolveCodexAccountForThread(null, config)).toBe("a"); + expect(config.activeCodexAccountId).toBe("b"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + }); + + test("falls through to the lower tier once the higher one is over threshold", () => { + const config = orderedConfig(); + updateAccountQuota("a", 90); + updateAccountQuota("b", 10); + + expect(resolveCodexAccountForThread(null, config)).toBe("b"); + }); + + test("returns to the higher tier as soon as its quota window resets", () => { + const config = orderedConfig(); + updateAccountQuota("a", 90); + updateAccountQuota("b", 10); + expect(resolveCodexAccountForThread(null, config)).toBe("b"); + + updateAccountQuota("a", 5); + expect(resolveCodexAccountForThread(null, config)).toBe("a"); + }); + + test("unknown usage never drains a tier", () => { + const config = orderedConfig(); + updateAccountQuota("b", 10); + + expect(resolveCodexAccountForThread(null, config)).toBe("a"); + }); + + test("every tier over threshold reproduces the stay-put behaviour", () => { + const config = orderedConfig(); + updateAccountQuota("a", 95); + updateAccountQuota("b", 95); + + expect(resolveCodexAccountForThread(null, config)).toBe("b"); + }); + + test("a disabled auto-switch threshold makes ordering strict", () => { + const config = orderedConfig({ autoSwitchThreshold: 0 }); + updateAccountQuota("a", 99); + updateAccountQuota("b", 1); + + expect(resolveCodexAccountForThread(null, config)).toBe("a"); + }); + + test("preview and resolve agree under tiering", () => { + const config = orderedConfig(); + updateAccountQuota("a", 70); + updateAccountQuota("b", 10); + + expect(previewCodexAccountForRequest(null, config)).toBe("a"); + expect(resolveCodexAccountForThread(null, config)).toBe("a"); + }); + + test("a manually pinned account outranks selection order", () => { + const config = orderedConfig({ activeCodexAccountPinned: "b" } as Partial); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + + expect(resolveCodexAccountForThread(null, config)).toBe("b"); + expect(previewCodexAccountForRequest(null, config)).toBe("b"); + }); + + test("the pin is spent once the pinned account crosses the threshold", () => { + const config = orderedConfig({ activeCodexAccountPinned: "b" } as Partial); + updateAccountQuota("a", 10); + updateAccountQuota("b", 90); + + expect(resolveCodexAccountForThread(null, config)).toBe("a"); + expect(config.activeCodexAccountPinned).toBeUndefined(); + }); + + test("a cooldown on the pinned account hands routing back to selection order", () => { + const config = orderedConfig({ activeCodexAccountPinned: "b" } as Partial); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + recordCodexUpstreamOutcome(config, "b", 429, { retryAfter: "600" }); + + expect(resolveCodexAccountForThread(null, config)).toBe("a"); + expect(config.activeCodexAccountPinned).toBeUndefined(); + }); + + test("a pin the request never moves off survives in config", () => { + const config = orderedConfig({ activeCodexAccountPinned: "b" } as Partial); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + + expect(resolveCodexAccountForThread(null, config)).toBe("b"); + expect(config.activeCodexAccountPinned).toBe("b"); + }); + + test("excluding the pinned account releases the pin", () => { + const config = orderedConfig({ activeCodexAccountPinned: "b" } as Partial); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + + reconcileCodexActiveAfterExclusion(config, "b"); + expect(config.activeCodexAccountPinned).toBeUndefined(); + }); + + test("a bound thread keeps its lower-tier account when ordering changes", () => { + const config = makeConfig({ activeCodexAccountId: "b" }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + expect(resolveCodexAccountForThread("thread-1", config)).toBe("b"); + + config.codexAccountPriorities = { a: 1 }; + expect(resolveCodexAccountForThread("thread-1", config)).toBe("b"); + }); + + test("a bound thread over threshold moves to the highest tier with headroom", () => { + const config = makeConfig({ activeCodexAccountId: "b" }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + expect(resolveCodexAccountForThread("thread-1", config)).toBe("b"); + + config.codexAccountPriorities = { a: 1 }; + updateAccountQuota("b", 90); + expect(resolveCodexAccountForThread("thread-1", config)).toBe("a"); + }); + + test("no stored order leaves the pick sequence untouched", () => { + const ordered = makeConfig({ activeCodexAccountId: "b" }); + updateAccountQuota("a", 5); + updateAccountQuota("b", 50); + + expect(resolveCodexAccountForThread(null, ordered)).toBe("b"); + expect(pickLowestUsageCodexAccount(ordered)).toBe("a"); + }); +}); From 86c305835945b07df364140fb63cbc0b2863951e Mon Sep 17 00:00:00 2001 From: Max Kaye Date: Thu, 30 Jul 2026 02:35:38 +1000 Subject: [PATCH 06/43] feat(codex): expose selection order through the management API - PUT /api/codex-auth/accounts/priority, deliberately its own route rather than a field on the alias PATCH: aliases are display-only and reject __main__, but the Desktop account is exactly the one an operator wants to order last. null resets to 0 and drops an emptied map. - priority is a required field on every account DTO, like paused, so a dashboard never has to infer the default. - GET /active gains pinned, computed by a core predicate that compares the stored pin against the *effective* active account, so an automatic pick that has already moved past the pin does not read as operator intent. --- src/codex/auth-api.ts | 63 +++++++++++++++++- src/codex/routing.ts | 11 +++ tests/codex-auth-api.test.ts | 125 ++++++++++++++++++++++++++++++++++- 3 files changed, 196 insertions(+), 3 deletions(-) diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index f300d15c0..6a290d1e0 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -12,19 +12,24 @@ import { } from "./account-store"; import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause"; -import { setCodexAccountPin } from "./account-priority"; +import { getCodexAccountPriority, setCodexAccountPin, setCodexAccountPriority } from "./account-priority"; import { clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, + isEffectiveCodexAccountPinned, reconcileCodexActiveAfterExclusion, resetCodexRoutingForManualSelection, } from "./routing"; import { + DEFAULT_ACCOUNT_PRIORITY, + MAX_ACCOUNT_PRIORITY, + MIN_ACCOUNT_PRIORITY, normalizeAccountPoolStickyLimit, normalizeAccountPoolStrategy, parseAccountPoolStickyLimit, parseAccountPoolStrategy, + parseAccountPriority, } from "./pool-rotation"; import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision"; export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision"; @@ -137,6 +142,7 @@ function poolAccountDto( quotaResult: PoolQuotaResult, hasCredential: boolean, paused: boolean, + priority: number, ): CodexAuthAccountDto { const quota = quotaForPlan(quotaResult.quota, account.plan); const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id); @@ -149,6 +155,7 @@ function poolAccountDto( ...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}), isMain: false, paused, + priority, quota: quota ? { ...quota } : null, needsReauth, hasCredential, @@ -432,6 +439,8 @@ export interface CodexAuthAccountDto { logLabel?: string; isMain: boolean; paused: boolean; + /** Selection order; higher is used earlier. Always present, 0 when unset. */ + priority: number; quota: (StoredAccountQuota | (Omit & { updatedAt: number })) | null; needsReauth?: boolean; hasCredential: boolean; @@ -548,7 +557,13 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa const quotaResult = cred ? await fetchPoolAccountQuota(a.id, forceRefresh, a.plan) : { quota: null, needsReauth: true }; - return poolAccountDto(a, quotaResult, !!cred, isCodexAccountPaused(runtimeConfig, a.id)); + return poolAccountDto( + a, + quotaResult, + !!cred, + isCodexAccountPaused(runtimeConfig, a.id), + getCodexAccountPriority(runtimeConfig, a.id), + ); }); const hasMainCredential = readCodexTokens() !== null; const mainNeedsReauth = !hasMainCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); @@ -562,6 +577,7 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa plan: mainInfo.plan, isMain: true, paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), + priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), hasCredential: hasMainCredential, needsReauth: mainNeedsReauth, quota: mainInfo.quota ? { ...quotaForPlan({ ...mainInfo.quota, updatedAt: Date.now() }, mainInfo.plan) } : null, @@ -750,6 +766,48 @@ export async function handleCodexAuthAPI( }); } + // Deliberately a route of its own rather than a field on the alias PATCH: aliases + // are display-only and reject __main__, while selection order is routing metadata + // that the Desktop account must be able to carry. Re-ordering never kicks a live + // thread, so there is no affinity clearing and no appliesImmediately here. + if (url.pathname === "/api/codex-auth/accounts/priority" && req.method === "PUT") { + let parsedBody: unknown; + try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } + if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) { + return jsonResponse({ error: "body must be an object" }, 400); + } + const body = parsedBody as { id?: unknown; priority?: unknown }; + const id = typeof body.id === "string" ? body.id.trim() : ""; + if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) { + return jsonResponse({ error: "Invalid account id format" }, 400); + } + + let priority = DEFAULT_ACCOUNT_PRIORITY; + if (body.priority !== null) { + const parsed = parseAccountPriority(body.priority); + if (parsed === null) { + return jsonResponse({ + error: `priority must be null or an integer ${MIN_ACCOUNT_PRIORITY}-${MAX_ACCOUNT_PRIORITY}`, + }, 400); + } + priority = parsed; + } + + const runtimeConfig = getRuntimeConfig(config); + const exists = id === MAIN_CODEX_ACCOUNT_ID + || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id); + if (!exists) return jsonResponse({ error: "Account not found" }, 404); + + setCodexAccountPriority(runtimeConfig, id, priority); + saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ + ok: true, + id, + priority, + activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, + }); + } + if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") { const runtimeConfig = getRuntimeConfig(config); const result = await pauseExhaustedCodexAccounts(runtimeConfig); @@ -823,6 +881,7 @@ export async function handleCodexAuthAPI( const runtimeConfig = getRuntimeConfig(config); return jsonResponse({ activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, + pinned: isEffectiveCodexAccountPinned(runtimeConfig), autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80, upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3, accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy), diff --git a/src/codex/routing.ts b/src/codex/routing.ts index ef720b0d7..cdd21aa80 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -955,6 +955,17 @@ export function getEffectiveActiveCodexAccountId(config: OcxConfig): string | un return runtimeActiveCodexAccountId ?? config.activeCodexAccountId; } +/** + * Whether the account routing is currently on is there because an operator asked + * for it, rather than because a strategy landed on it. Surfaces read this instead + * of comparing the stored pin themselves, which would report a pin that a later + * automatic pick has already moved past. + */ +export function isEffectiveCodexAccountPinned(config: OcxConfig): boolean { + const pinned = pinnedCodexAccountId(config); + return pinned !== undefined && pinned === getEffectiveActiveCodexAccountId(config); +} + /** * Automatic strategy / failover cursor only — never mutates `config.activeCodexAccountId` * so an unrelated `saveConfig` cannot persist transient rotation as operator selection. diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 0b7fa9654..6e75d7c37 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -7,7 +7,7 @@ import { handleCodexAuthAPI, updateAccountQuota, getAccountQuota, checkAccountIdCollision, getMainChatgptAccountId, markAccountNeedsReauth, isAccountNeedsReauth, clearAccountNeedsReauth, clearAccountQuota, - clearMainAccountInfoCache, maskEmail, + clearMainAccountInfoCache, maskEmail, listCodexAuthAccounts, } from "../src/codex/auth-api"; import { getCodexAccountCredential, @@ -549,6 +549,7 @@ describe("codex-auth API", () => { const data = await resp!.json() as { activeCodexAccountId: string | null; autoSwitchThreshold: number }; expect(data).toEqual({ activeCodexAccountId: "pool-live", + pinned: false, autoSwitchThreshold: 55, upstreamFailoverThreshold: 3, accountPoolStrategy: "quota", @@ -1398,6 +1399,128 @@ describe("codex-auth API", () => { expect(resolveCodexAccountForThread("runtime-selection", config)).toBe("pool-runtime"); }); + async function putPriority(config: OcxConfig, body: unknown): Promise { + const req = new Request("http://localhost/api/codex-auth/accounts/priority", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); + return (await handleCodexAuthAPI(req, new URL(req.url), config))!; + } + + test("PUT /api/codex-auth/accounts/priority persists a pool account's selection order", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "work", email: "work@example.test" }); + + const resp = await putPriority(config, { id: "work", priority: 2 }); + + expect(resp.status).toBe(200); + expect(await resp.json()).toMatchObject({ ok: true, id: "work", priority: 2 }); + expect(config.codexAccountPriorities).toEqual({ work: 2 }); + }); + + test("PUT /api/codex-auth/accounts/priority accepts the main Codex account", async () => { + const config = makeConfig(); + + const resp = await putPriority(config, { id: MAIN_CODEX_ACCOUNT_ID, priority: -2 }); + + expect(resp.status).toBe(200); + expect(await resp.json()).toMatchObject({ id: MAIN_CODEX_ACCOUNT_ID, priority: -2 }); + expect(config.codexAccountPriorities).toEqual({ [MAIN_CODEX_ACCOUNT_ID]: -2 }); + }); + + test("a null priority resets the account and drops an emptied map", async () => { + const config = makeConfig({ codexAccountPriorities: { work: 2 } }); + seedPoolAccount(config, { id: "work", email: "work@example.test" }); + + const resp = await putPriority(config, { id: "work", priority: null }); + + expect(resp.status).toBe(200); + expect(await resp.json()).toMatchObject({ id: "work", priority: 0 }); + expect(config.codexAccountPriorities).toBeUndefined(); + }); + + test.each([ + ["a fraction", 1.5], + ["a numeric string", "2"], + ["a boolean", true], + ["an array", []], + ["an object", {}], + ["above the range", 101], + ["below the range", -101], + ] as const)("rejects %s as a selection order", async (_label, priority) => { + const config = makeConfig(); + seedPoolAccount(config, { id: "work", email: "work@example.test" }); + + const resp = await putPriority(config, { id: "work", priority }); + + expect(resp.status).toBe(400); + expect(config.codexAccountPriorities).toBeUndefined(); + }); + + test.each([ + ["a JSON array", "[1]"], + ["a JSON string", '"work"'], + ["malformed JSON", "{"], + ] as const)("rejects %s as a request body", async (_label, body) => { + const resp = await putPriority(makeConfig(), body); + expect(resp.status).toBe(400); + }); + + test("an unknown account id is a 404 and writes nothing", async () => { + const config = makeConfig(); + + const resp = await putPriority(config, { id: "missing", priority: 1 }); + + expect(resp.status).toBe(404); + expect(config.codexAccountPriorities).toBeUndefined(); + }); + + test("selection order is independent of alias, pause, and active selection", async () => { + const config = makeConfig({ activeCodexAccountId: "work" }); + seedPoolAccount(config, { id: "work", email: "work@example.test" }); + + await putPriority(config, { id: "work", priority: 1 }); + + expect(config.codexAccountPriorities).toEqual({ work: 1 }); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(config.activeCodexAccountId).toBe("work"); + expect(config.codexAccounts?.[0]?.alias).toBeUndefined(); + }); + + test("the account list reports selection order, defaulting to zero", async () => { + const config = makeConfig({ codexAccountPriorities: { work: 2 } }); + seedPoolAccount(config, { id: "work", email: "work@example.test" }); + seedPoolAccount(config, { id: "side", email: "side@example.test" }); + + const accounts = await listCodexAuthAccounts(config); + + expect(accounts.find(a => a.id === "work")?.priority).toBe(2); + expect(accounts.find(a => a.id === "side")?.priority).toBe(0); + expect(accounts.find(a => a.isMain)?.priority).toBe(0); + }); + + test("GET /api/codex-auth/active reports an operator pin but not an automatic pick", async () => { + const config = makeConfig({ activeCodexAccountId: "work" }); + seedPoolAccount(config, { id: "work", email: "work@example.test" }); + + const read = async () => { + const req = new Request("http://localhost/api/codex-auth/active"); + return (await (await handleCodexAuthAPI(req, new URL(req.url), config))!.json()) as { pinned: boolean }; + }; + expect((await read()).pinned).toBe(false); + + const selectReq = new Request("http://localhost/api/codex-auth/active", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId: "work" }), + }); + await handleCodexAuthAPI(selectReq, new URL(selectReq.url), config); + + expect(config.activeCodexAccountPinned).toBe("work"); + expect((await read()).pinned).toBe(true); + }); + test("PUT /api/codex-auth/accounts/pause-exhausted pauses only freshly confirmed exhausted accounts", async () => { const config = makeConfig({ codexAccounts: [ From dccf99127a8fb1f587e3b37059a6a2c503f31ed7 Mon Sep 17 00:00:00 2001 From: Max Kaye Date: Thu, 30 Jul 2026 02:46:34 +1000 Subject: [PATCH 07/43] feat(cli): add ocx account priority and a PRIORITY column - `ocx account priority [value|preset|reset]`. Omitting the value is a read, and a read must not rewrite what it reports, so that path never issues a PUT. Values are validated before any HTTP call so a typo never reaches the proxy. - Preset words are first/earlier/normal/later/last rather than high/normal/low: ordering decides *when* the pool reaches an account, not how much traffic it gets, and rank vocabulary implies the latter. - PRIORITY column sits before STATUS, signed, with "-" on the families that have no ordering. --- src/cli/account-api.ts | 4 ++ src/cli/account-extended.ts | 97 +++++++++++++++++++++++++++++++++++++ src/cli/account.ts | 21 ++++++-- src/cli/help.ts | 3 +- tests/cli-account.test.ts | 97 +++++++++++++++++++++++++++++++++++-- 5 files changed, 213 insertions(+), 9 deletions(-) diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index 03708512d..df0e16859 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -21,6 +21,8 @@ export interface AccountRow { masked?: string; active: boolean; needsReauth?: boolean; + /** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */ + priority?: number; quota?: CodexQuotaDto | null; } @@ -148,6 +150,7 @@ interface CodexAccountDto { plan?: string; isMain?: boolean; needsReauth?: boolean; + priority?: number; quota?: CodexQuotaDto | null; } @@ -195,6 +198,7 @@ export async function fetchCodexRows( plan: a.plan, active: a.id === activeId, needsReauth: a.needsReauth, + priority: typeof a.priority === "number" ? a.priority : 0, ...(forceRefresh ? { quota: projectQuota(a.quota) } : {}), })); return { rows, activeId, autoSwitchThreshold, status: 200 }; diff --git a/src/cli/account-extended.ts b/src/cli/account-extended.ts index 500d407be..32246b83d 100644 --- a/src/cli/account-extended.ts +++ b/src/cli/account-extended.ts @@ -18,6 +18,7 @@ const EXTENDED_USAGE = `Usage: ocx account refresh [--json] ocx account auto-switch > [--json] ocx account alias [--json] + ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json] ocx account remove --yes [--json] ocx account clear-cooldown [--json] ocx account add-key [--label