claude-dev-env 8.38.3 → 8.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_shared/advisor/reference/astra-rung.md +7 -7
- package/_shared/advisor/scripts/codex_astra_advisor.py +13 -13
- package/_shared/advisor/scripts/codex_astra_preflight.py +62 -33
- package/_shared/advisor/scripts/config/advisor_scripts_constants/astra_advisor_constants.py +5 -2
- package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +64 -33
- package/_shared/advisor/scripts/tests/test_codex_astra_preflight.py +125 -3
- package/_shared/pr-loop/scripts/check_convergence.py +23 -17
- package/_shared/pr-loop/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +4 -3
- package/_shared/pr-loop/scripts/test_check_convergence_codex.py +64 -25
- package/docs/codex-accounts.md +27 -31
- package/package.json +1 -1
- package/scripts/codex_account_choice.py +8 -303
- package/scripts/test_codex_account_choice.py +11 -273
|
@@ -29,10 +29,10 @@ The frozen process env is only a fallback when that disk read fails (logged once
|
|
|
29
29
|
A probe error does not waive the gate; the live GitHub checks still run.
|
|
30
30
|
|
|
31
31
|
The Codex gate is conditional-required: it demands
|
|
32
|
-
``codex_clean_at == current_head`` only when the
|
|
33
|
-
(``scripts/
|
|
32
|
+
``codex_clean_at == current_head`` only when the account broker
|
|
33
|
+
(``scripts/account_broker.py choose --product codex``) answers the ``normal`` tier, so
|
|
34
34
|
one signed-in account has room for a review. A ``luna`` or ``wait`` answer, an
|
|
35
|
-
unreadable
|
|
35
|
+
unreadable broker, the codex token, or ``codex_down`` never blocks ready.
|
|
36
36
|
"""
|
|
37
37
|
|
|
38
38
|
from __future__ import annotations
|
|
@@ -64,11 +64,12 @@ from check_convergence_thread_gates import (
|
|
|
64
64
|
_count_unresolved_bot_threads,
|
|
65
65
|
)
|
|
66
66
|
from pr_converge_scripts_constants.convergence_gate_constants import (
|
|
67
|
-
|
|
67
|
+
ALL_CODEX_ACCOUNT_BROKER_RELATIVE_PARTS,
|
|
68
|
+
ALL_CODEX_BROKER_ACCEPTED_EXIT_CODES,
|
|
68
69
|
BUGBOT_DOWN_BYPASS_NOTE,
|
|
69
70
|
CLAUDE_JOB_DIR_ENV_VAR_NAME,
|
|
70
71
|
CODEX_ACCOUNT_PICK_TIMEOUT_SECONDS,
|
|
71
|
-
|
|
72
|
+
CODEX_ACCOUNT_BROKER_CHOOSE_COMMAND,
|
|
72
73
|
CODEX_BYPASS_DETAIL,
|
|
73
74
|
CODEX_CLEAN_AT_STATE_KEY,
|
|
74
75
|
CODEX_CLEAN_DETAIL_TEMPLATE,
|
|
@@ -344,16 +345,16 @@ def _evaluate_codex_clean(
|
|
|
344
345
|
|
|
345
346
|
::
|
|
346
347
|
|
|
347
|
-
clean stamp on head -> pass (
|
|
348
|
+
clean stamp on head -> pass (broker never runs)
|
|
348
349
|
no stamp, tier luna, wait, or None -> skip (never blocks)
|
|
349
350
|
no stamp, tier normal -> fail
|
|
350
351
|
|
|
351
|
-
A stamp on the current HEAD settles the gate on its own, so the
|
|
352
|
-
stays behind it: the
|
|
352
|
+
A stamp on the current HEAD settles the gate on its own, so the broker
|
|
353
|
+
stays behind it: the broker starts one Codex app server per account, and
|
|
353
354
|
the common already-clean tick has no reason to pay for them.
|
|
354
355
|
|
|
355
356
|
Args:
|
|
356
|
-
read_codex_tier: Returns the
|
|
357
|
+
read_codex_tier: Returns the broker's tier, or None when the broker cannot say.
|
|
357
358
|
codex_clean_at: HEAD SHA where Codex last reported clean, or None.
|
|
358
359
|
head_sha: Current PR HEAD commit SHA.
|
|
359
360
|
|
|
@@ -367,20 +368,22 @@ def _evaluate_codex_clean(
|
|
|
367
368
|
return False, CODEX_MISSING_CLEAN_DETAIL_TEMPLATE % _short_sha(head_sha)
|
|
368
369
|
|
|
369
370
|
|
|
370
|
-
def
|
|
371
|
-
"""Return the installed
|
|
371
|
+
def _codex_account_broker_path() -> Path:
|
|
372
|
+
"""Return the installed account broker beside this shared tree."""
|
|
372
373
|
shared_root = Path(__file__).resolve().parents[SHARED_PACKAGE_ROOT_PARENT_INDEX]
|
|
373
|
-
return shared_root.joinpath(*
|
|
374
|
+
return shared_root.joinpath(*ALL_CODEX_ACCOUNT_BROKER_RELATIVE_PARTS)
|
|
374
375
|
|
|
375
376
|
|
|
376
377
|
def _read_codex_tier() -> str | None:
|
|
377
|
-
"""Ask the
|
|
378
|
+
"""Ask the account broker which tier a review runs on, or None when it cannot say."""
|
|
378
379
|
try:
|
|
379
380
|
completed = subprocess.run(
|
|
380
381
|
[
|
|
381
382
|
sys.executable,
|
|
382
|
-
str(
|
|
383
|
-
|
|
383
|
+
str(_codex_account_broker_path()),
|
|
384
|
+
CODEX_ACCOUNT_BROKER_CHOOSE_COMMAND,
|
|
385
|
+
"--product",
|
|
386
|
+
"codex",
|
|
384
387
|
],
|
|
385
388
|
capture_output=True,
|
|
386
389
|
text=True,
|
|
@@ -390,9 +393,12 @@ def _read_codex_tier() -> str | None:
|
|
|
390
393
|
answer = json.loads(completed.stdout)
|
|
391
394
|
except (OSError, subprocess.SubprocessError, json.JSONDecodeError):
|
|
392
395
|
return None
|
|
393
|
-
if completed.returncode
|
|
396
|
+
if completed.returncode not in ALL_CODEX_BROKER_ACCEPTED_EXIT_CODES or not isinstance(answer, dict):
|
|
394
397
|
return None
|
|
395
|
-
|
|
398
|
+
decision = answer.get("decision")
|
|
399
|
+
if not isinstance(decision, dict):
|
|
400
|
+
return None
|
|
401
|
+
tier = decision.get(CODEX_TIER_KEY)
|
|
396
402
|
return tier if isinstance(tier, str) else None
|
|
397
403
|
|
|
398
404
|
|
|
@@ -69,11 +69,12 @@ CODEX_CLEAN_AT_STATE_KEY: str = "codex_clean_at"
|
|
|
69
69
|
CODEX_DOWN_STATE_KEY: str = "codex_down"
|
|
70
70
|
MINIMUM_ABBREVIATED_SHA_LENGTH: int = 7
|
|
71
71
|
SHARED_PACKAGE_ROOT_PARENT_INDEX: int = 3
|
|
72
|
-
|
|
72
|
+
ALL_CODEX_ACCOUNT_BROKER_RELATIVE_PARTS: tuple[str, ...] = (
|
|
73
73
|
"scripts",
|
|
74
|
-
"
|
|
74
|
+
"account_broker.py",
|
|
75
75
|
)
|
|
76
|
-
|
|
76
|
+
CODEX_ACCOUNT_BROKER_CHOOSE_COMMAND: str = "choose"
|
|
77
77
|
CODEX_ACCOUNT_PICK_TIMEOUT_SECONDS: float = 150.0
|
|
78
78
|
CODEX_TIER_KEY: str = "tier"
|
|
79
|
+
ALL_CODEX_BROKER_ACCEPTED_EXIT_CODES: tuple[int, ...] = (0, 3)
|
|
79
80
|
CODEX_TIER_NORMAL: str = "normal"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Fixture-driven tests for the conditional Codex convergence gate.
|
|
2
2
|
|
|
3
3
|
Covers: required-and-clean, required-and-dirty, skipped-by-tier,
|
|
4
|
-
skipped-by-token, skipped-by-down, and the read of the
|
|
4
|
+
skipped-by-token, skipped-by-down, and the read of the account broker's
|
|
5
5
|
``choose`` answer. The required tier comes from ``CODEX_TIER_NORMAL``.
|
|
6
6
|
"""
|
|
7
7
|
|
|
@@ -117,15 +117,15 @@ def should_pass_when_codex_required_and_clean(
|
|
|
117
117
|
assert "All pre-conditions met" in captured
|
|
118
118
|
|
|
119
119
|
|
|
120
|
-
def
|
|
120
|
+
def test_should_pass_without_running_the_broker_when_the_clean_stamp_is_on_head(
|
|
121
121
|
monkeypatch: pytest.MonkeyPatch,
|
|
122
122
|
) -> None:
|
|
123
|
-
def
|
|
123
|
+
def _broker_that_must_not_run() -> str | None:
|
|
124
124
|
raise AssertionError(
|
|
125
|
-
"the
|
|
125
|
+
"the account broker must not run when codex_clean_at is on HEAD"
|
|
126
126
|
)
|
|
127
127
|
|
|
128
|
-
monkeypatch.setattr(check_convergence, "_read_codex_tier",
|
|
128
|
+
monkeypatch.setattr(check_convergence, "_read_codex_tier", _broker_that_must_not_run)
|
|
129
129
|
|
|
130
130
|
is_passed, detail = check_convergence._evaluate_codex_clean(
|
|
131
131
|
read_codex_tier=check_convergence._read_codex_tier,
|
|
@@ -228,7 +228,7 @@ def should_fail_when_codex_required_and_dirty(
|
|
|
228
228
|
|
|
229
229
|
|
|
230
230
|
@pytest.mark.parametrize("tier_without_room", ALL_TIERS_WITHOUT_ROOM_FOR_REVIEW)
|
|
231
|
-
def
|
|
231
|
+
def test_should_skip_when_the_broker_names_no_account_with_room_for_review(
|
|
232
232
|
tier_without_room: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
233
233
|
) -> None:
|
|
234
234
|
fixture_path = _write_fixture(
|
|
@@ -513,47 +513,86 @@ def should_accept_codex_clean_at_flag_in_parsed_arguments() -> None:
|
|
|
513
513
|
assert arguments.codex_clean_at == HEAD_SHA
|
|
514
514
|
|
|
515
515
|
|
|
516
|
-
def
|
|
517
|
-
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
516
|
+
def _install_fake_broker(
|
|
517
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, broker_source: str
|
|
518
518
|
) -> None:
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
monkeypatch.setattr(check_convergence, "
|
|
519
|
+
broker_path = tmp_path / "account_broker.py"
|
|
520
|
+
broker_path.write_text(broker_source, encoding="utf-8")
|
|
521
|
+
monkeypatch.setattr(check_convergence, "_codex_account_broker_path", lambda: broker_path)
|
|
522
522
|
|
|
523
523
|
|
|
524
|
-
def
|
|
524
|
+
def test_should_read_normal_tier_from_broker_decision(
|
|
525
525
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
526
526
|
) -> None:
|
|
527
|
-
|
|
527
|
+
broker_path = tmp_path / "account_broker.py"
|
|
528
|
+
broker_path.write_text(
|
|
529
|
+
"import json, sys\n"
|
|
530
|
+
"assert sys.argv[1:] == ['choose', '--product', 'codex']\n"
|
|
531
|
+
"print(json.dumps({'decision': {'tier': 'normal', 'reason': 'room'}, 'accounts': []}))\n",
|
|
532
|
+
encoding="utf-8",
|
|
533
|
+
)
|
|
534
|
+
monkeypatch.setattr(check_convergence, "_codex_account_broker_path", lambda: broker_path)
|
|
535
|
+
|
|
536
|
+
assert check_convergence._read_codex_tier() == CODEX_TIER_NORMAL
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def test_should_read_wait_tier_from_broker_exit_three(
|
|
540
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
541
|
+
) -> None:
|
|
542
|
+
broker_path = tmp_path / "account_broker.py"
|
|
543
|
+
broker_path.write_text(
|
|
544
|
+
"import json, sys\n"
|
|
545
|
+
"assert sys.argv[1:] == ['choose', '--product', 'codex']\n"
|
|
546
|
+
"print(json.dumps({'decision': {'tier': 'wait', 'reason': 'no room'}, 'accounts': []}))\n"
|
|
547
|
+
"sys.exit(3)\n",
|
|
548
|
+
encoding="utf-8",
|
|
549
|
+
)
|
|
550
|
+
monkeypatch.setattr(check_convergence, "_codex_account_broker_path", lambda: broker_path)
|
|
551
|
+
|
|
552
|
+
assert check_convergence._read_codex_tier() == "wait"
|
|
553
|
+
|
|
554
|
+
passed, detail = check_convergence._evaluate_codex_clean(
|
|
555
|
+
read_codex_tier=check_convergence._read_codex_tier,
|
|
556
|
+
codex_clean_at=None,
|
|
557
|
+
head_sha=HEAD_SHA,
|
|
558
|
+
)
|
|
559
|
+
assert passed
|
|
560
|
+
assert "skipped" in detail
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def test_should_read_the_tier_the_broker_chooses(
|
|
564
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
565
|
+
) -> None:
|
|
566
|
+
_install_fake_broker(
|
|
528
567
|
tmp_path,
|
|
529
568
|
monkeypatch,
|
|
530
569
|
"import json, sys\n"
|
|
531
|
-
"assert sys.argv[1:] == ['choose']\n"
|
|
532
|
-
"print(json.dumps({'tier': 'normal', 'account': 'codex-2'}))\n",
|
|
570
|
+
"assert sys.argv[1:] == ['choose', '--product', 'codex']\n"
|
|
571
|
+
"print(json.dumps({'decision': {'tier': 'normal', 'account': 'codex-2'}, 'accounts': []}))\n",
|
|
533
572
|
)
|
|
534
573
|
|
|
535
574
|
assert check_convergence._read_codex_tier() == CODEX_TIER_NORMAL
|
|
536
575
|
|
|
537
576
|
|
|
538
577
|
@pytest.mark.parametrize(
|
|
539
|
-
"
|
|
578
|
+
"broker_source",
|
|
540
579
|
[
|
|
541
|
-
"import sys\nprint('{\"tier\": \"normal\"}')\nsys.exit(1)\n",
|
|
580
|
+
"import sys\nprint('{\"decision\": {\"tier\": \"normal\"}}')\nsys.exit(1)\n",
|
|
542
581
|
"print('not json')\n",
|
|
543
582
|
"print('[\"normal\"]')\n",
|
|
544
|
-
"print('{\"tier\": 1}')\n",
|
|
583
|
+
"print('{\"decision\": {\"tier\": 1}}')\n",
|
|
545
584
|
],
|
|
546
585
|
)
|
|
547
|
-
def
|
|
548
|
-
|
|
586
|
+
def test_should_read_no_tier_when_the_broker_fails_or_answers_out_of_shape(
|
|
587
|
+
broker_source: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
549
588
|
) -> None:
|
|
550
|
-
|
|
589
|
+
_install_fake_broker(tmp_path, monkeypatch, broker_source)
|
|
551
590
|
|
|
552
591
|
assert check_convergence._read_codex_tier() is None
|
|
553
592
|
|
|
554
593
|
|
|
555
|
-
def
|
|
556
|
-
|
|
594
|
+
def test_should_locate_the_packaged_codex_account_broker() -> None:
|
|
595
|
+
broker_path = check_convergence._codex_account_broker_path()
|
|
557
596
|
|
|
558
|
-
assert
|
|
559
|
-
assert
|
|
597
|
+
assert broker_path.name == "account_broker.py"
|
|
598
|
+
assert broker_path.is_file()
|
package/docs/codex-accounts.md
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
# Codex accounts
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
3
|
+
The account broker assigns agent work across the Codex accounts.
|
|
4
|
+
Each account signs in under its own Codex home, and every home
|
|
5
5
|
shares the same Codex setup: config, rules, skills, plugins, prompts and agents.
|
|
6
|
-
A job asks the
|
|
6
|
+
A job asks the broker which account to use.
|
|
7
7
|
|
|
8
8
|
## Pieces
|
|
9
9
|
|
|
10
10
|
| File | What it does |
|
|
11
11
|
|---|---|
|
|
12
|
-
| `scripts/
|
|
12
|
+
| `scripts/account_broker.py` | `choose --product codex` names the account and tier a job runs on; `check --product codex` reports whether any account has room |
|
|
13
|
+
| `scripts/codex_account_choice.py` | `sync` links the shared setup into every account's home; `setup` and `install` write one launcher per account |
|
|
13
14
|
| `scripts/codex_account_meters.py` | Reads one account's rate-limit windows through `codex app-server` with `CODEX_HOME` set to that account's home |
|
|
14
15
|
| `scripts/dev_env_scripts_constants/codex_account_constants.py` | The fallback account names, the roster variable and file, the launcher template, the shared entry names, the 10% bar, the 1% Luna stop, and the 20% 5-hour floor for Luna |
|
|
15
16
|
|
|
@@ -21,8 +22,8 @@ The account roster names the accounts and their order. It comes from the
|
|
|
21
22
|
in `account-launchers.json` under the profiles root. When neither exists, the
|
|
22
23
|
accounts are `codex-1`, `codex-2`, `codex-3` and `codex-4`.
|
|
23
24
|
|
|
24
|
-
`choose
|
|
25
|
-
|
|
25
|
+
The broker's `choose` and `check` commands and the setup commands use that list.
|
|
26
|
+
Each account's home is `~/.codex-profiles/<name>`, or
|
|
26
27
|
`<name>` under `CODEX_PROFILES_ROOT` when set. `~/.codex` holds the shared setup
|
|
27
28
|
and is never an account. A name uses letters, digits, hyphens or underscores.
|
|
28
29
|
`main`, `wait` and the Windows device names are refused.
|
|
@@ -83,50 +84,45 @@ folder under the profiles root stays, with its `auth.json`, so typing the name
|
|
|
83
84
|
again restores it. A blank first line empties the roster and moves every launcher
|
|
84
85
|
aside.
|
|
85
86
|
|
|
86
|
-
`check <name>` reads a saved name the same way:
|
|
87
|
-
|
|
88
|
-
```
|
|
89
|
-
python packages/claude-dev-env/scripts/codex_account_choice.py check alpha --floor 1
|
|
90
|
-
```
|
|
91
|
-
|
|
92
87
|
## Which account a job uses
|
|
93
88
|
|
|
94
89
|
Room is the smaller of an account's two windows: the 5-hour window and the week.
|
|
95
90
|
|
|
96
91
|
| Condition | Answer |
|
|
97
92
|
|---|---|
|
|
98
|
-
|
|
|
99
|
-
| No account over 10%, one or more over 1% | `luna` on the account with the most room
|
|
93
|
+
| One or more accounts with more than 10% left | `normal` on the account with the most room |
|
|
94
|
+
| No account over 10%, one or more over 1% | `luna` on the account with the most room |
|
|
100
95
|
| An account that reports a 5-hour window, with under 20% of that window left | never takes `luna` |
|
|
101
|
-
| No account over 1% | `wait`, naming the
|
|
102
|
-
|
|
|
96
|
+
| No account over 1% | `wait`, naming the next reset when known |
|
|
97
|
+
| A meter is unread | The account has `meters: null` in `accounts` |
|
|
103
98
|
|
|
104
99
|
```
|
|
105
|
-
python packages/claude-dev-env/scripts/
|
|
106
|
-
{"
|
|
107
|
-
"
|
|
100
|
+
python packages/claude-dev-env/scripts/account_broker.py choose --product codex
|
|
101
|
+
{"decision": {"action": "run", "account": "codex-1", "home": "...\\codex-1",
|
|
102
|
+
"resets_at": null, "reason": "codex-1 has 62% left", "tier": "normal"},
|
|
103
|
+
"accounts": [{"name": "codex-1", "home": "...\\codex-1", "is_main": false,
|
|
104
|
+
"meters": {"session_percent_left": 62.0, "session_resets_at": null,
|
|
105
|
+
"weekly_percent_left": 70.0, "weekly_resets_at": null}}]}
|
|
108
106
|
```
|
|
109
107
|
|
|
110
|
-
A job runs Codex with `CODEX_HOME` set to `
|
|
108
|
+
A job runs Codex with `CODEX_HOME` set to `decision.home`. On `luna`, the job runs
|
|
111
109
|
Luna and polls `check` between steps:
|
|
112
110
|
|
|
113
111
|
```
|
|
114
|
-
python packages/claude-dev-env/scripts/
|
|
112
|
+
python packages/claude-dev-env/scripts/account_broker.py check --product codex
|
|
115
113
|
```
|
|
116
114
|
|
|
117
|
-
`check` exits
|
|
118
|
-
|
|
115
|
+
`check` exits 3 while every account is below its floor, and 0 when an account
|
|
116
|
+
has room. The job stops on 3. `choose` also exits 3 for `wait` and still prints
|
|
117
|
+
the JSON answer.
|
|
119
118
|
|
|
120
119
|
## Shared callers
|
|
121
120
|
|
|
122
|
-
Two scripts under `_shared` run `choose` by path. Each finds the
|
|
123
|
-
`scripts/
|
|
124
|
-
installed, that is `~/.claude/scripts/
|
|
121
|
+
Two scripts under `_shared` run `choose --product codex` by path. Each finds the
|
|
122
|
+
broker at `scripts/account_broker.py` in the directory that holds `_shared`.
|
|
123
|
+
Once installed, that is `~/.claude/scripts/account_broker.py`.
|
|
125
124
|
|
|
126
125
|
| Caller | What it does with the answer |
|
|
127
126
|
|---|---|
|
|
128
|
-
| `_shared/pr-loop/scripts/check_convergence.py` | Requires a Codex clean stamp on HEAD only on `normal`. `luna`, `wait`, or a failed
|
|
129
|
-
| `_shared/advisor/scripts/codex_astra_advisor.py` | Binds Astra only on `normal`, and runs Codex with `CODEX_HOME` set to `
|
|
130
|
-
|
|
131
|
-
Pass `--codex-path` when `codex` is off PATH. Without it the picker also tries
|
|
132
|
-
the desktop install path under the user home.
|
|
127
|
+
| `_shared/pr-loop/scripts/check_convergence.py` | Requires a Codex clean stamp on HEAD only on `normal`. `luna`, `wait`, or a failed broker skips the Codex gate |
|
|
128
|
+
| `_shared/advisor/scripts/codex_astra_advisor.py` | Binds Astra only on `normal`, and runs Codex with `CODEX_HOME` set to `decision.home` |
|