claude-dev-env 8.79.0 → 8.80.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/.agents/skills/pr-lifecycle/reference/review-loop-before-merge.md +2 -2
- package/bin/install.settings-defaults.test.mjs +27 -2
- package/bin/merge_settings_defaults.mjs +39 -21
- package/bin/merge_settings_defaults.test.mjs +42 -0
- package/docs/account-broker.md +1 -1
- package/docs/codex-accounts.md +6 -5
- package/hooks/blocking/cloud_graphql_gate.py +213 -0
- package/hooks/blocking/headless_claude_broker_gate.py +1 -8
- package/hooks/blocking/merge_review_gate.py +65 -3
- package/hooks/blocking/pr_lifecycle_skill_gate.py +6 -7
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_cloud_graphql_gate.py +210 -0
- package/hooks/blocking/test_headless_claude_broker_gate.py +12 -7
- package/hooks/blocking/test_merge_review_gate.py +63 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +3 -0
- package/hooks/conftest.py +1 -1
- package/hooks/features/bash-dispatchers.md +3 -0
- package/hooks/features/conduct-gates.md +2 -2
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/cloud_graphql_gate_constants.py +116 -0
- package/hooks/hooks_constants/cloud_session.py +17 -0
- package/hooks/hooks_constants/cloud_session_constants.py +11 -0
- package/hooks/hooks_constants/gh_command_words.py +52 -0
- package/hooks/hooks_constants/gh_command_words_constants.py +64 -0
- package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +1 -7
- package/hooks/hooks_constants/hook_block_logger.py +21 -3
- package/hooks/hooks_constants/merge_review_gate_constants.py +14 -3
- package/hooks/hooks_constants/mod_handoff.py +2 -3
- package/hooks/hooks_constants/mod_handoff_constants.py +0 -4
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +27 -2
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +0 -1
- package/hooks/hooks_constants/shell_command_evaluation_constants.py +67 -0
- package/hooks/hooks_constants/shell_command_substitution_constants.py +25 -0
- package/hooks/hooks_constants/shell_command_wrappers.py +327 -12
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
- package/hooks/hooks_constants/test_cloud_session.py +27 -0
- package/hooks/hooks_constants/test_gh_command_words.py +45 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +61 -0
- package/hooks/hooks_constants/test_mod_handoff.py +3 -2
- package/hooks/hooks_constants/test_piped_pytest_blocker_constants.py +11 -0
- package/hooks/hooks_constants/test_shell_command_wrappers.py +125 -0
- package/hooks/test_conftest.py +1 -1
- package/package.json +1 -1
- package/scripts/account_broker_support.py +10 -11
- package/scripts/codex_account_meters.py +30 -43
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +34 -25
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +0 -9
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +0 -18
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +42 -0
- package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +119 -0
- package/scripts/merge_review_receipt.py +56 -12
- package/scripts/test_account_broker.py +12 -0
- package/scripts/test_account_broker_support.py +32 -6
- package/scripts/test_codex_account_meters.py +26 -14
- package/scripts/test_merge_review_receipt.py +86 -0
- package/settings.json +3 -0
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import base64
|
|
6
|
+
|
|
5
7
|
import pytest
|
|
6
8
|
|
|
7
9
|
from hooks_constants import shell_command_wrappers
|
|
@@ -31,6 +33,29 @@ def test_should_return_only_the_command_when_no_wrapper_runs() -> None:
|
|
|
31
33
|
assert shell_command_wrappers.all_wrapped_command_texts("git push") == ["git push"]
|
|
32
34
|
|
|
33
35
|
|
|
36
|
+
@pytest.mark.parametrize(
|
|
37
|
+
"command",
|
|
38
|
+
[
|
|
39
|
+
"git push",
|
|
40
|
+
'bash -c "git add -A && git push"; echo done',
|
|
41
|
+
"bash -c \"pwsh -Command 'git push; git status'\"",
|
|
42
|
+
"sudo bash -c 'gh pr list' | head",
|
|
43
|
+
'echo "$(git push)"',
|
|
44
|
+
"x=`git push`",
|
|
45
|
+
],
|
|
46
|
+
)
|
|
47
|
+
def test_should_return_the_segments_of_every_wrapped_command_text(command: str) -> None:
|
|
48
|
+
all_segments_from_texts = [
|
|
49
|
+
each_segment
|
|
50
|
+
for each_text in shell_command_wrappers.all_wrapped_command_texts(command)
|
|
51
|
+
for each_segment, _following_operator in (
|
|
52
|
+
shell_command_wrappers.pipeline_segments_for_command(each_text)
|
|
53
|
+
)
|
|
54
|
+
]
|
|
55
|
+
all_segments = shell_command_wrappers.all_wrapped_command_segments(command)
|
|
56
|
+
assert sorted(all_segments) == sorted(all_segments_from_texts)
|
|
57
|
+
|
|
58
|
+
|
|
34
59
|
@pytest.mark.parametrize(
|
|
35
60
|
("all_segment_tokens", "expected_program_and_arguments"),
|
|
36
61
|
[
|
|
@@ -50,6 +75,13 @@ def test_should_return_only_the_command_when_no_wrapper_runs() -> None:
|
|
|
50
75
|
(["time", "sudo", "git", "push"], ("git", ["push"])),
|
|
51
76
|
(["'/usr/bin/git'", "push"], ("git", ["push"])),
|
|
52
77
|
(["VAR=x"], ("", [])),
|
|
78
|
+
(["if", "git", "push"], ("git", ["push"])),
|
|
79
|
+
(["then", "git", "push"], ("git", ["push"])),
|
|
80
|
+
(["if", "!", "git", "push"], ("git", ["push"])),
|
|
81
|
+
(["while", "git", "push"], ("git", ["push"])),
|
|
82
|
+
(["command", "git", "push"], ("git", ["push"])),
|
|
83
|
+
(["command", "-p", "git", "push"], ("git", ["push"])),
|
|
84
|
+
(["exec", "-a", "name", "git", "push"], ("git", ["push"])),
|
|
53
85
|
],
|
|
54
86
|
)
|
|
55
87
|
def test_should_return_the_program_past_its_wrappers(
|
|
@@ -59,3 +91,96 @@ def test_should_return_the_program_past_its_wrappers(
|
|
|
59
91
|
shell_command_wrappers.segment_program_and_arguments(all_segment_tokens)
|
|
60
92
|
== expected_program_and_arguments
|
|
61
93
|
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.mark.parametrize(
|
|
97
|
+
("command", "all_expected_texts"),
|
|
98
|
+
[
|
|
99
|
+
('echo "$(gh pr list)"', ["gh pr list"]),
|
|
100
|
+
("x=`git push`", ["git push"]),
|
|
101
|
+
("echo $(echo $(git push))", ["echo $(git push)"]),
|
|
102
|
+
("echo $(cat <(ls))", ["cat <(ls)"]),
|
|
103
|
+
("echo \"$(printf ')'; git push)\"", ["printf ')'; git push"]),
|
|
104
|
+
('echo $(echo "$(git push)")', ['echo "$(git push)"']),
|
|
105
|
+
("x=`echo \\`git push\\``", ["echo `git push`"]),
|
|
106
|
+
("echo '$(gh pr list)'", []),
|
|
107
|
+
("echo \\$(gh pr list)", []),
|
|
108
|
+
("git push", []),
|
|
109
|
+
],
|
|
110
|
+
)
|
|
111
|
+
def test_should_return_each_command_substitution_body_outside_single_quotes(
|
|
112
|
+
command: str, all_expected_texts: list[str]
|
|
113
|
+
) -> None:
|
|
114
|
+
assert shell_command_wrappers.all_command_substitution_texts(command) == all_expected_texts
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_should_unwrap_a_command_substitution_in_turn() -> None:
|
|
118
|
+
all_command_texts = shell_command_wrappers.all_wrapped_command_texts(
|
|
119
|
+
"echo \"$(bash -c 'git push')\""
|
|
120
|
+
)
|
|
121
|
+
assert "bash -c 'git push'" in all_command_texts
|
|
122
|
+
assert "git push" in all_command_texts
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_ENCODED_GIT_PUSH = base64.b64encode("git push".encode("utf-16-le")).decode()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@pytest.mark.parametrize(
|
|
129
|
+
("all_segment_tokens", "expected_inner_command"),
|
|
130
|
+
[
|
|
131
|
+
(["eval", "git push"], "git push"),
|
|
132
|
+
(["eval", "git", "push"], "git push"),
|
|
133
|
+
(["Invoke-Expression", "'git push'"], "git push"),
|
|
134
|
+
(["iex", '"git push"'], "git push"),
|
|
135
|
+
(["find", ".", "-exec", "git", "push", ";"], "git push"),
|
|
136
|
+
(["find", ".", "-execdir", "git", "push", "{}", "+"], "git push {}"),
|
|
137
|
+
(["pwsh", "-EncodedCommand", _ENCODED_GIT_PUSH], "git push"),
|
|
138
|
+
(["powershell.exe", "-enc", _ENCODED_GIT_PUSH], "git push"),
|
|
139
|
+
(["pwsh", "-e", _ENCODED_GIT_PUSH], "git push"),
|
|
140
|
+
(["pwsh", "-ec", _ENCODED_GIT_PUSH], "git push"),
|
|
141
|
+
(["pwsh", "-e", "not-base64!"], None),
|
|
142
|
+
(["find", ".", "-exec", "true", ";", "-exec", "git", "push", ";"], "true ; git push"),
|
|
143
|
+
(["-exec", "git", "push", "\\"], "git push \\"),
|
|
144
|
+
(["pwsh", "-e", f"'{_ENCODED_GIT_PUSH[:8]} {_ENCODED_GIT_PUSH[8:]}'"], "git push"),
|
|
145
|
+
(["Start-Process", "git", "-ArgumentList", "'push'"], "git push"),
|
|
146
|
+
(
|
|
147
|
+
["Start-Process", "-FilePath", "git", "-ArgumentList", "push,origin", "-Wait"],
|
|
148
|
+
"git push origin",
|
|
149
|
+
),
|
|
150
|
+
(["saps", "git", "-WorkingDirectory", "C:/repo", "-ArgumentList", "push"], "git push"),
|
|
151
|
+
(["Start-Process", "-FilePath:git", "-ArgumentList:'push'"], "git push"),
|
|
152
|
+
(["Start-Process", "git", "-WorkingDirectory:C:/repo", "-ArgumentList:push"], "git push"),
|
|
153
|
+
(["find", ".", "-name", "x"], None),
|
|
154
|
+
(["git", "push"], None),
|
|
155
|
+
],
|
|
156
|
+
)
|
|
157
|
+
def test_should_return_the_command_an_evaluating_program_runs(
|
|
158
|
+
all_segment_tokens: list[str], expected_inner_command: str | None
|
|
159
|
+
) -> None:
|
|
160
|
+
assert (
|
|
161
|
+
shell_command_wrappers.evaluated_inner_command(all_segment_tokens) == expected_inner_command
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_should_unwrap_an_evaluated_command_in_turn() -> None:
|
|
166
|
+
assert "git push" in shell_command_wrappers.all_wrapped_command_texts('eval "git push"')
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_should_read_a_quoted_value_that_spans_lines() -> None:
|
|
170
|
+
command = f'pwsh -EncodedCommand "{_ENCODED_GIT_PUSH[:8]}\n{_ENCODED_GIT_PUSH[8:]}"'
|
|
171
|
+
assert "git push" in shell_command_wrappers.all_wrapped_command_texts(command)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_should_stop_unwrapping_past_the_depth_limit_without_error() -> None:
|
|
175
|
+
nesting_depth = 1000
|
|
176
|
+
command = "echo " + "$(" * nesting_depth + "git push" + ")" * nesting_depth
|
|
177
|
+
all_command_texts = shell_command_wrappers.all_wrapped_command_texts(command)
|
|
178
|
+
assert all_command_texts[0] == command
|
|
179
|
+
assert len(all_command_texts) <= nesting_depth
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_should_parse_a_repeated_inner_command_once() -> None:
|
|
183
|
+
all_command_texts = shell_command_wrappers.all_wrapped_command_texts(
|
|
184
|
+
'bash -c "git push"; bash -c "git push"'
|
|
185
|
+
)
|
|
186
|
+
assert all_command_texts.count("git push") == 1
|
package/hooks/test_conftest.py
CHANGED
|
@@ -9,7 +9,7 @@ import sys
|
|
|
9
9
|
from hooks_constants.harness_scratchpad_constants import (
|
|
10
10
|
CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME,
|
|
11
11
|
)
|
|
12
|
-
from hooks_constants.
|
|
12
|
+
from hooks_constants.cloud_session_constants import CLOUD_SESSION_ENV_VAR
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def test_live_session_id_is_absent_inside_each_test() -> None:
|
package/package.json
CHANGED
|
@@ -43,9 +43,6 @@ from dev_env_scripts_constants.account_broker_constants import (
|
|
|
43
43
|
JobOutcome,
|
|
44
44
|
LOGIN_REFRESH_TIMEOUT_SECONDS,
|
|
45
45
|
METER_READ_ATTEMPTS,
|
|
46
|
-
METER_READ_DELAY_FACTOR,
|
|
47
|
-
METER_READ_FIRST_DELAY_SECONDS,
|
|
48
|
-
METER_READ_MAX_DELAY_SECONDS,
|
|
49
46
|
Meters,
|
|
50
47
|
Product,
|
|
51
48
|
ProductAdapter,
|
|
@@ -54,6 +51,7 @@ from dev_env_scripts_constants.account_broker_constants import (
|
|
|
54
51
|
REPORT_INDENT_SPACES,
|
|
55
52
|
SubprocessRunner,
|
|
56
53
|
codex_usage_limit_signatures,
|
|
54
|
+
meter_read_delay_seconds,
|
|
57
55
|
parse_utc_time,
|
|
58
56
|
utc_time_text,
|
|
59
57
|
)
|
|
@@ -70,7 +68,6 @@ from dev_env_scripts_constants.codex_account_constants import (
|
|
|
70
68
|
CODEX_ACCOUNT_LAUNCHERS_FILE_NAME,
|
|
71
69
|
CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE,
|
|
72
70
|
CODEX_HOME_ENVIRONMENT_VARIABLE,
|
|
73
|
-
WEEKLY_WINDOW_MINUTES,
|
|
74
71
|
)
|
|
75
72
|
from dev_env_scripts_constants.shared_tree_constants import CLAUDE_CONFIG_DIR_ENV_VAR
|
|
76
73
|
from shared_tree_paths import resolve_shared_process_tree_scripts_directory
|
|
@@ -303,14 +300,13 @@ def read_codex_account_meters(account: Account) -> Meters:
|
|
|
303
300
|
|
|
304
301
|
Raises:
|
|
305
302
|
CodexMeterUnreadError: The meter read failed.
|
|
306
|
-
|
|
303
|
+
CodexExecutableNotFoundError: No Codex executable was found, so
|
|
304
|
+
read_meters_with_backoff stops without a retry.
|
|
307
305
|
"""
|
|
308
306
|
codex_path = codex_account_meters.resolve_codex_path(None)
|
|
309
307
|
usage = codex_account_meters.read_codex_meters(codex_path, account.home)
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
weekly_window = max(weekly, key=lambda window: window.used_percent) if weekly else None
|
|
313
|
-
short_window = max(short, key=lambda window: window.used_percent) if short else None
|
|
308
|
+
short_window = usage.short_window
|
|
309
|
+
weekly_window = usage.weekly_window
|
|
314
310
|
return Meters(
|
|
315
311
|
usage.short_window_percent_left,
|
|
316
312
|
short_window.resets_at if short_window else None,
|
|
@@ -328,14 +324,17 @@ def read_meters_with_backoff(read_meters: Callable[[Account], Meters | None], ac
|
|
|
328
324
|
|
|
329
325
|
Returns:
|
|
330
326
|
The meters from the first read that succeeds, or None with the last
|
|
331
|
-
failure text once every attempt failed.
|
|
327
|
+
failure text once every attempt failed. A missing Codex executable
|
|
328
|
+
fails at once, without a retry.
|
|
332
329
|
"""
|
|
333
330
|
unread_reason = None
|
|
334
331
|
for each_attempt in range(METER_READ_ATTEMPTS):
|
|
335
332
|
if each_attempt:
|
|
336
|
-
time.sleep(
|
|
333
|
+
time.sleep(meter_read_delay_seconds(each_attempt))
|
|
337
334
|
try:
|
|
338
335
|
return read_meters(account), None
|
|
336
|
+
except codex_account_meters.CodexExecutableNotFoundError as error:
|
|
337
|
+
return None, str(error)
|
|
339
338
|
except (
|
|
340
339
|
codex_account_meters.CodexMeterUnreadError,
|
|
341
340
|
WeeklyUtilizationProbeError,
|
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
Codex reports usage through ``codex app-server`` and the JSON-RPC method
|
|
5
5
|
``account/rateLimits/read``. The reply names up to two windows, a short one
|
|
6
|
-
(5 hours) and a weekly one. The account
|
|
6
|
+
(5 hours) and a weekly one. The account broker counts the account's room as
|
|
7
|
+
the smaller of the two::
|
|
7
8
|
|
|
8
9
|
5-hour window 30% used, week 85% used -> 15% left
|
|
9
10
|
week window only, 40% used -> 60% left
|
|
10
11
|
no window, error reply, no reply -> CodexMeterUnreadError
|
|
11
12
|
|
|
12
|
-
Every failure raises ``CodexMeterUnreadError``.
|
|
13
|
-
|
|
13
|
+
Every read failure raises ``CodexMeterUnreadError``. A missing Codex
|
|
14
|
+
executable raises ``CodexExecutableNotFoundError`` from ``resolve_codex_path``.
|
|
15
|
+
The caller decides what an unread account means; this module never guesses a
|
|
16
|
+
number.
|
|
14
17
|
"""
|
|
15
18
|
|
|
16
19
|
from __future__ import annotations
|
|
@@ -69,6 +72,10 @@ class CodexMeterUnreadError(Exception):
|
|
|
69
72
|
"""Raised when one account's rate-limit windows cannot be read."""
|
|
70
73
|
|
|
71
74
|
|
|
75
|
+
class CodexExecutableNotFoundError(FileNotFoundError):
|
|
76
|
+
"""Raised when no Codex executable is on PATH or at its install path."""
|
|
77
|
+
|
|
78
|
+
|
|
72
79
|
@dataclass(frozen=True)
|
|
73
80
|
class UsageWindow:
|
|
74
81
|
"""One rate-limit window: its length, the percent used, and when it resets."""
|
|
@@ -77,56 +84,36 @@ class UsageWindow:
|
|
|
77
84
|
used_percent: float
|
|
78
85
|
resets_at: datetime | None
|
|
79
86
|
|
|
87
|
+
@property
|
|
88
|
+
def is_short(self) -> bool:
|
|
89
|
+
"""Whether the window reports a length shorter than a week. A window with no length counts as weekly."""
|
|
90
|
+
return self.duration_minutes is not None and self.duration_minutes < WEEKLY_WINDOW_MINUTES
|
|
91
|
+
|
|
80
92
|
|
|
81
93
|
@dataclass(frozen=True)
|
|
82
94
|
class CodexAccountMeters:
|
|
83
|
-
"""One account's windows, with
|
|
95
|
+
"""One account's windows, with its tightest short window and its tightest weekly window."""
|
|
84
96
|
|
|
85
97
|
all_windows: tuple[UsageWindow, ...]
|
|
86
98
|
|
|
87
99
|
@property
|
|
88
|
-
def
|
|
89
|
-
"""
|
|
90
|
-
|
|
91
|
-
return max(
|
|
100
|
+
def short_window(self) -> UsageWindow | None:
|
|
101
|
+
"""The most used window shorter than a week, or None when the account has none."""
|
|
102
|
+
all_short_windows = [each_window for each_window in self.all_windows if each_window.is_short]
|
|
103
|
+
return max(all_short_windows, key=lambda each_window: each_window.used_percent, default=None)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def weekly_window(self) -> UsageWindow | None:
|
|
107
|
+
"""The most used window of a week or of no reported length, or None when the account has none."""
|
|
108
|
+
all_weekly_windows = [each_window for each_window in self.all_windows if not each_window.is_short]
|
|
109
|
+
return max(all_weekly_windows, key=lambda each_window: each_window.used_percent, default=None)
|
|
92
110
|
|
|
93
111
|
@property
|
|
94
112
|
def short_window_percent_left(self) -> float | None:
|
|
95
113
|
"""Room left in the windows shorter than a week, or None when the account has none."""
|
|
96
|
-
|
|
97
|
-
each_window.used_percent
|
|
98
|
-
for each_window in self.all_windows
|
|
99
|
-
if each_window.duration_minutes is not None
|
|
100
|
-
and each_window.duration_minutes < WEEKLY_WINDOW_MINUTES
|
|
101
|
-
]
|
|
102
|
-
if not all_short_used:
|
|
103
|
-
return None
|
|
104
|
-
return max(0.0, FULL_PERCENT - max(all_short_used))
|
|
105
|
-
|
|
106
|
-
def resets_before_room(self, minimum_percent_left: float) -> datetime | None:
|
|
107
|
-
"""When every window that holds the account at or under a bar has reset.
|
|
108
|
-
|
|
109
|
-
Args:
|
|
110
|
-
minimum_percent_left: The percent left the account must pass.
|
|
111
|
-
|
|
112
|
-
Returns:
|
|
113
|
-
The latest reset among the blocking windows, or None when one
|
|
114
|
-
carries no reset time or none blocks.
|
|
115
|
-
"""
|
|
116
|
-
all_blocking = [
|
|
117
|
-
each_window
|
|
118
|
-
for each_window in self.all_windows
|
|
119
|
-
if FULL_PERCENT - each_window.used_percent <= minimum_percent_left
|
|
120
|
-
]
|
|
121
|
-
if not all_blocking or any(
|
|
122
|
-
each_window.resets_at is None for each_window in all_blocking
|
|
123
|
-
):
|
|
114
|
+
if self.short_window is None:
|
|
124
115
|
return None
|
|
125
|
-
return
|
|
126
|
-
each_window.resets_at
|
|
127
|
-
for each_window in all_blocking
|
|
128
|
-
if each_window.resets_at is not None
|
|
129
|
-
)
|
|
116
|
+
return FULL_PERCENT - self.short_window.used_percent
|
|
130
117
|
|
|
131
118
|
|
|
132
119
|
def _finite_number(raw_number: object) -> float | None:
|
|
@@ -396,7 +383,7 @@ def resolve_codex_path(explicit_path: Path | None) -> Path:
|
|
|
396
383
|
The named path, else ``codex`` on PATH, else a known install path.
|
|
397
384
|
|
|
398
385
|
Raises:
|
|
399
|
-
|
|
386
|
+
CodexExecutableNotFoundError: No Codex executable was found.
|
|
400
387
|
"""
|
|
401
388
|
if explicit_path is not None:
|
|
402
389
|
return explicit_path
|
|
@@ -407,4 +394,4 @@ def resolve_codex_path(explicit_path: Path | None) -> Path:
|
|
|
407
394
|
candidate = Path.home().joinpath(*each_parts)
|
|
408
395
|
if candidate.is_file():
|
|
409
396
|
return candidate
|
|
410
|
-
raise
|
|
397
|
+
raise CodexExecutableNotFoundError("no codex executable on PATH or at its install path")
|
|
@@ -112,6 +112,11 @@ def codex_usage_limit_signatures() -> tuple[str, ...]:
|
|
|
112
112
|
return module.ALL_USAGE_LIMIT_MARKERS
|
|
113
113
|
|
|
114
114
|
|
|
115
|
+
def meter_read_delay_seconds(attempt_index: int) -> float:
|
|
116
|
+
"""Seconds to wait before a meter read retry, where the first retry has index 1."""
|
|
117
|
+
return min(METER_READ_FIRST_DELAY_SECONDS * METER_READ_DELAY_FACTOR ** (attempt_index - 1), METER_READ_MAX_DELAY_SECONDS)
|
|
118
|
+
|
|
119
|
+
|
|
115
120
|
def utc_time_text(moment: datetime | None) -> str | None:
|
|
116
121
|
return moment.astimezone(timezone.utc).isoformat() if moment is not None else None
|
|
117
122
|
|
|
@@ -217,16 +222,26 @@ class ProductAdapter:
|
|
|
217
222
|
usage_limit_signatures: tuple[str, ...]
|
|
218
223
|
|
|
219
224
|
|
|
225
|
+
def _is_at_or_under(room: float | None, floor: float) -> bool:
|
|
226
|
+
return room is not None and room <= floor
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _claude_blocking_flags(meters: Meters, is_main: bool) -> tuple[bool, bool]:
|
|
230
|
+
session_key, weekly_key = (
|
|
231
|
+
("main_session_used_ceiling", "main_weekly_used_ceiling")
|
|
232
|
+
if is_main
|
|
233
|
+
else ("extra_session_used_ceiling", "extra_weekly_used_ceiling")
|
|
234
|
+
)
|
|
235
|
+
return (
|
|
236
|
+
_is_at_or_under(meters.session_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS[session_key]),
|
|
237
|
+
_is_at_or_under(meters.weekly_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS[weekly_key]),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
220
241
|
def _claude_extra_tier(meters: Meters | None) -> str | None:
|
|
221
242
|
if meters is None or meters.tightest_percent_left is None:
|
|
222
243
|
return None
|
|
223
|
-
if (
|
|
224
|
-
meters.weekly_percent_left is not None
|
|
225
|
-
and meters.weekly_percent_left <= FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_weekly_used_ceiling"]
|
|
226
|
-
) or (
|
|
227
|
-
meters.session_percent_left is not None
|
|
228
|
-
and meters.session_percent_left <= FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_session_used_ceiling"]
|
|
229
|
-
):
|
|
244
|
+
if any(_claude_blocking_flags(meters, is_main=False)):
|
|
230
245
|
return None
|
|
231
246
|
return "normal"
|
|
232
247
|
|
|
@@ -253,24 +268,19 @@ def _account_reset(reading: Reading, is_spent: bool = False) -> datetime | None:
|
|
|
253
268
|
return None
|
|
254
269
|
if is_spent:
|
|
255
270
|
return min((reset for reset in (meters.session_resets_at, meters.weekly_resets_at) if reset is not None), default=None)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
blocking = (
|
|
259
|
-
(meters.session_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["main_session_used_ceiling"], meters.session_resets_at),
|
|
260
|
-
(meters.weekly_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["main_weekly_used_ceiling"], meters.weekly_resets_at),
|
|
261
|
-
)
|
|
262
|
-
elif reading.account.product is Product.CLAUDE:
|
|
263
|
-
blocking = (
|
|
264
|
-
(meters.session_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_session_used_ceiling"], meters.session_resets_at),
|
|
265
|
-
(meters.weekly_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_weekly_used_ceiling"], meters.weekly_resets_at),
|
|
266
|
-
)
|
|
271
|
+
if reading.account.product is Product.CLAUDE:
|
|
272
|
+
all_blocking_flags = _claude_blocking_flags(meters, reading.account.is_main)
|
|
267
273
|
else:
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
(meters.weekly_percent_left, ALL_CODEX_FLOORS["luna_stop_left"]
|
|
274
|
+
all_blocking_flags = (
|
|
275
|
+
meters.session_percent_left is not None and meters.session_percent_left < ALL_CODEX_FLOORS["luna_short_minimum_left"],
|
|
276
|
+
_is_at_or_under(meters.weekly_percent_left, ALL_CODEX_FLOORS["luna_stop_left"]),
|
|
271
277
|
)
|
|
272
|
-
|
|
273
|
-
|
|
278
|
+
all_resets = (meters.session_resets_at, meters.weekly_resets_at)
|
|
279
|
+
all_blocking_resets = [reset for is_blocking, reset in zip(all_blocking_flags, all_resets) if is_blocking]
|
|
280
|
+
all_known_resets = [reset for reset in all_blocking_resets if reset is not None]
|
|
281
|
+
if len(all_known_resets) < len(all_blocking_resets):
|
|
282
|
+
return None
|
|
283
|
+
return max(all_known_resets, default=None)
|
|
274
284
|
|
|
275
285
|
|
|
276
286
|
def _rank_key(reading: Reading) -> tuple[bool, int, float]:
|
|
@@ -284,8 +294,7 @@ def _main_has_room(meters: Meters | None) -> bool:
|
|
|
284
294
|
meters is not None
|
|
285
295
|
and meters.session_percent_left is not None
|
|
286
296
|
and meters.weekly_percent_left is not None
|
|
287
|
-
and meters
|
|
288
|
-
and meters.weekly_percent_left > FULL_PERCENT - ALL_CLAUDE_FLOORS["main_weekly_used_ceiling"]
|
|
297
|
+
and not any(_claude_blocking_flags(meters, is_main=True))
|
|
289
298
|
)
|
|
290
299
|
|
|
291
300
|
|
|
@@ -181,15 +181,6 @@ REASON_ALL_EXTRAS_WAIT_TEMPLATE: str = (
|
|
|
181
181
|
)
|
|
182
182
|
"""Reason when every configured extra profile is blocked."""
|
|
183
183
|
|
|
184
|
-
REASON_WAIT_TEMPLATE: str = (
|
|
185
|
-
"second account is at {weekly_used_percent:.0f}% of its week"
|
|
186
|
-
" and {session_used_percent:.0f}% of its 5-hour window; next reset {next_reset}"
|
|
187
|
-
)
|
|
188
|
-
"""Reason when neither account has room."""
|
|
189
|
-
|
|
190
|
-
UNKNOWN_RESET_TEXT: str = "unknown"
|
|
191
|
-
"""Reset text when the blocking meter carries no reset time."""
|
|
192
|
-
|
|
193
184
|
JSON_ACCOUNT_KEY: str = "account"
|
|
194
185
|
"""Picker JSON key naming the chosen account."""
|
|
195
186
|
|
|
@@ -174,26 +174,8 @@ EXIT_CODE_ROOM: int = 0
|
|
|
174
174
|
EXIT_CODE_NO_ROOM: int = 3
|
|
175
175
|
"""Check answer: the account is at or below the floor, or its meter is unread."""
|
|
176
176
|
|
|
177
|
-
REASON_NORMAL_TEMPLATE: str = "{account} has {percent_left:.0f}% left"
|
|
178
|
-
"""Reason when an account takes the job at its normal model."""
|
|
179
|
-
|
|
180
|
-
REASON_LUNA_TEMPLATE: str = (
|
|
181
|
-
"every account is at or under {bar:.0f}% left; {account} has"
|
|
182
|
-
" {percent_left:.0f}% and runs Luna until {stop:.0f}%"
|
|
183
|
-
)
|
|
184
|
-
"""Reason when the job falls back to Luna."""
|
|
185
|
-
|
|
186
|
-
REASON_WAIT_TEMPLATE: str = "no account has room; {account} resets first, at {reset}"
|
|
187
|
-
"""Reason when every account is out of room."""
|
|
188
|
-
|
|
189
|
-
REASON_WAIT_UNREAD: str = "no account meter could be read"
|
|
190
|
-
"""Reason when no account's meter reads at all."""
|
|
191
|
-
|
|
192
177
|
UNREAD_NOT_SIGNED_IN: str = "not signed in"
|
|
193
178
|
"""Unread reason when the account's Codex home holds no sign-in."""
|
|
194
179
|
|
|
195
|
-
UNKNOWN_RESET_TEXT: str = "an unknown time"
|
|
196
|
-
"""Reset text when no blocking window carries a reset time."""
|
|
197
|
-
|
|
198
180
|
TEXT_ENCODING: str = "utf-8"
|
|
199
181
|
"""Encoding for the server pipes and the JSON reports."""
|
|
@@ -189,3 +189,45 @@ def test_should_treat_accounts_that_differ_only_in_profile_as_one_account() -> N
|
|
|
189
189
|
home = Path("/profiles/first")
|
|
190
190
|
|
|
191
191
|
assert Account(Product.CLAUDE, "first", home, profile="first") == Account(Product.CLAUDE, "first", home)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _codex_reading(meters: broker_constants.Meters) -> broker_constants.Reading:
|
|
195
|
+
return broker_constants.Reading(Account(Product.CODEX, "codex-1", Path("/profiles/codex-1")), meters)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_should_let_a_codex_account_at_the_short_window_floor_take_luna() -> None:
|
|
199
|
+
weekly_reset = datetime(2026, 10, 4, tzinfo=timezone.utc)
|
|
200
|
+
session_reset = weekly_reset + timedelta(hours=4)
|
|
201
|
+
meters = broker_constants.Meters(
|
|
202
|
+
LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT, session_reset, LUNA_TIER_STOP_PERCENT_LEFT / 2, weekly_reset
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
assert broker_constants._codex_tier(
|
|
206
|
+
dataclasses.replace(meters, weekly_percent_left=NORMAL_TIER_MINIMUM_PERCENT_LEFT)
|
|
207
|
+
) == "luna"
|
|
208
|
+
assert broker_constants._account_reset(_codex_reading(meters)) == weekly_reset
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_should_name_no_codex_reset_when_a_blocking_window_has_none() -> None:
|
|
212
|
+
session_reset = datetime(2026, 10, 3, 2, tzinfo=timezone.utc)
|
|
213
|
+
meters = broker_constants.Meters(
|
|
214
|
+
LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT / 2, session_reset, LUNA_TIER_STOP_PERCENT_LEFT / 2, None
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
assert broker_constants._account_reset(_codex_reading(meters)) is None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_should_name_the_latest_codex_reset_when_every_blocking_window_has_one() -> None:
|
|
221
|
+
session_reset = datetime(2026, 10, 3, 2, tzinfo=timezone.utc)
|
|
222
|
+
weekly_reset = datetime(2026, 10, 4, tzinfo=timezone.utc)
|
|
223
|
+
meters = broker_constants.Meters(
|
|
224
|
+
LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT / 2, session_reset, LUNA_TIER_STOP_PERCENT_LEFT / 2, weekly_reset
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
assert broker_constants._account_reset(_codex_reading(meters)) == weekly_reset
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_should_double_the_meter_read_delay_up_to_its_cap() -> None:
|
|
231
|
+
all_delays = [broker_constants.meter_read_delay_seconds(each_attempt) for each_attempt in range(1, 6)]
|
|
232
|
+
|
|
233
|
+
assert all_delays == [1.0, 2.0, 4.0, 8.0, 8.0]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""The Codex account constants match what docs/codex-accounts.md tells the reader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from dev_env_scripts_constants import codex_account_constants as constants
|
|
10
|
+
from dev_env_scripts_constants.account_broker_constants import METER_READ_ATTEMPTS, meter_read_delay_seconds
|
|
11
|
+
from dev_env_scripts_constants.claude_account_constants import ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS
|
|
12
|
+
|
|
13
|
+
CODEX_ACCOUNTS_DOC_PATH = Path(__file__).resolve().parents[2] / "docs" / "codex-accounts.md"
|
|
14
|
+
SECTION_HEADING_PREFIX = "## "
|
|
15
|
+
CHOOSE_COMMAND_SUFFIX = "choose --product codex"
|
|
16
|
+
CODE_FENCE = "```"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def documented_section_lines(heading: str) -> list[str]:
|
|
20
|
+
all_doc_lines = CODEX_ACCOUNTS_DOC_PATH.read_text(encoding="utf-8").splitlines()
|
|
21
|
+
heading_index = all_doc_lines.index(SECTION_HEADING_PREFIX + heading)
|
|
22
|
+
all_section_lines: list[str] = []
|
|
23
|
+
for each_line in all_doc_lines[heading_index + 1 :]:
|
|
24
|
+
if each_line.startswith(SECTION_HEADING_PREFIX):
|
|
25
|
+
break
|
|
26
|
+
all_section_lines.append(each_line)
|
|
27
|
+
return all_section_lines
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def documented_section(heading: str) -> str:
|
|
31
|
+
return " ".join(" ".join(documented_section_lines(heading)).split())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def documented_choose_sample() -> dict[str, Any]:
|
|
35
|
+
all_section_lines = documented_section_lines("Which account a job uses")
|
|
36
|
+
command_index = next(
|
|
37
|
+
index for index, each_line in enumerate(all_section_lines) if each_line.endswith(CHOOSE_COMMAND_SUFFIX)
|
|
38
|
+
)
|
|
39
|
+
fence_index = all_section_lines.index(CODE_FENCE, command_index)
|
|
40
|
+
return json.loads("\n".join(all_section_lines[command_index + 1 : fence_index]))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def documented_list(all_names: list[str]) -> str:
|
|
44
|
+
*all_leading_names, last_name = [f"`{each_name}`" for each_name in all_names]
|
|
45
|
+
if not all_leading_names:
|
|
46
|
+
return last_name
|
|
47
|
+
return ", ".join(all_leading_names) + f" and {last_name}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def documented_percent(percent: float) -> str:
|
|
51
|
+
return f"{percent:g}%"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_fallback_roster_matches_the_documented_accounts() -> None:
|
|
55
|
+
expected_sentence = (
|
|
56
|
+
"When neither exists, the accounts are "
|
|
57
|
+
+ documented_list(list(constants.ALL_CODEX_ACCOUNT_NAMES))
|
|
58
|
+
+ "."
|
|
59
|
+
)
|
|
60
|
+
assert expected_sentence in documented_section("Names and order")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_shared_home_entries_match_the_documented_list() -> None:
|
|
64
|
+
expected_sentence = "They are " + documented_list(sorted(constants.ALL_SHARED_CODEX_HOME_NAMES)) + "."
|
|
65
|
+
assert expected_sentence in documented_section("Sign in once")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_sign_in_file_stays_per_account() -> None:
|
|
69
|
+
expected_sentence = f"The sign-in lives in that folder's `{constants.CODEX_AUTH_FILE_NAME}`."
|
|
70
|
+
assert expected_sentence in documented_section("Sign in once")
|
|
71
|
+
assert constants.CODEX_AUTH_FILE_NAME not in constants.ALL_SHARED_CODEX_HOME_NAMES
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_launcher_matches_the_documented_name_directory_and_variable() -> None:
|
|
75
|
+
launcher_file_name = constants.CODEX_LAUNCHER_PROGRAM.file_name_template.format(profile_name="<name>")
|
|
76
|
+
launcher_directory = "/".join(["~", *ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS])
|
|
77
|
+
expected_sentence = (
|
|
78
|
+
f"Each roster account gets a launcher, `{launcher_file_name}`, in `{launcher_directory}`. "
|
|
79
|
+
f"It sets `{constants.CODEX_LAUNCHER_PROGRAM.environment_variable}` to that account's home "
|
|
80
|
+
f"and runs Codex with every argument"
|
|
81
|
+
)
|
|
82
|
+
assert expected_sentence in documented_section("Named launchers")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_tier_bars_match_the_documented_table() -> None:
|
|
86
|
+
normal_bar = documented_percent(constants.NORMAL_TIER_MINIMUM_PERCENT_LEFT)
|
|
87
|
+
luna_stop = documented_percent(constants.LUNA_TIER_STOP_PERCENT_LEFT)
|
|
88
|
+
luna_short_window_floor = documented_percent(constants.LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT)
|
|
89
|
+
all_expected_rows = [
|
|
90
|
+
f"| One or more accounts with more than {normal_bar} left | `{constants.TIER_NORMAL}` on the account with the most room |",
|
|
91
|
+
f"| No account over {normal_bar}, one or more over {luna_stop} | `{constants.TIER_LUNA}` on the account with the most room |",
|
|
92
|
+
f"| An account that reports a window shorter than a week, such as the 5-hour window, with under {luna_short_window_floor} of that window left | never takes `{constants.TIER_LUNA}` |",
|
|
93
|
+
f"| No account over {luna_stop} | `{constants.TIER_WAIT}`, naming the next reset when known |",
|
|
94
|
+
]
|
|
95
|
+
section_text = documented_section("Which account a job uses")
|
|
96
|
+
for each_row in all_expected_rows:
|
|
97
|
+
assert each_row in section_text
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_meter_retries_match_the_documented_table() -> None:
|
|
101
|
+
all_delays = [meter_read_delay_seconds(each_attempt) for each_attempt in range(1, METER_READ_ATTEMPTS)]
|
|
102
|
+
all_delay_texts = [f"{each_delay:g} second" + ("" if each_delay == 1 else "s") for each_delay in all_delays]
|
|
103
|
+
expected_row = (
|
|
104
|
+
f"| A meter read fails | The broker reads it again, up to {METER_READ_ATTEMPTS} reads, "
|
|
105
|
+
f"waiting {' and then '.join(all_delay_texts)} |"
|
|
106
|
+
)
|
|
107
|
+
assert expected_row in documented_section("Which account a job uses")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_documented_choose_sample_runs_normal_on_the_tightest_window() -> None:
|
|
111
|
+
sample = documented_choose_sample()
|
|
112
|
+
decision = sample["decision"]
|
|
113
|
+
chosen_meters = next(
|
|
114
|
+
each_account["meters"] for each_account in sample["accounts"] if each_account["name"] == decision["account"]
|
|
115
|
+
)
|
|
116
|
+
room = min(chosen_meters["session_percent_left"], chosen_meters["weekly_percent_left"])
|
|
117
|
+
assert decision["tier"] == constants.TIER_NORMAL
|
|
118
|
+
assert room > constants.NORMAL_TIER_MINIMUM_PERCENT_LEFT
|
|
119
|
+
assert decision["reason"] == f"{decision['account']} has {room:g}% left"
|