claude-dev-env 8.44.6 → 8.45.1

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.
@@ -14,7 +14,7 @@ python scripts/account_broker.py check --product codex
14
14
  python scripts/account_broker.py run --product claude --report report.json -- claude --output-format json -p "prompt"
15
15
  ```
16
16
 
17
- `accounts` prints the roster without reading meters. `choose` prints `decision`, `accounts`, and `state_path`. Each decision contains `action`, `account`, `home`, `reason`, `tier`, and `resets_at`. A wait decision has no account or home and includes the soonest reset in UTC. A failed meter read excludes the account for that choice. `--spent` saves an exclusion until the supplied Unix reset, the account's soonest known meter reset, or one hour when neither is available. Meter reads younger than 60 seconds are reused.
17
+ `accounts` prints the roster without reading meters. `choose` prints `decision`, `accounts`, and `state_path`. Each decision contains `action`, `account`, `home`, `reason`, `tier`, and `resets_at`. A wait decision has no account or home and includes the soonest reset in UTC. A failed meter read excludes the account for that choice. `--spent` saves an exclusion until the supplied Unix reset, the account's soonest known meter reset, or one hour when neither is available. A `--spent` name absent from the roster is saved until the supplied Unix reset, or for one hour when no reset is given. While any such mark is still in the future, `choose` waits until the soonest mark and exits 3, and with no Codex roster `--spent default` waits until that mark passes. Meter reads younger than 60 seconds are reused.
18
18
 
19
19
  `check` prints nothing and exits 3 while every account is below its floor. `run` sets only `CLAUDE_CONFIG_DIR` or `CODEX_HOME` for each attempt. It replays the same stdin bytes when a usage limit or start failure leads to another account. It writes the command's stdout to stdout and diagnostics to stderr. A resumed Claude session uses the account bound to its session when that account has room.
20
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.44.6",
3
+ "version": "8.45.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -127,7 +127,6 @@ def readings_payload(all_readings: Sequence[Reading]) -> list[dict[str, object]]
127
127
  ]
128
128
 
129
129
 
130
-
131
130
  def _spent_for_readings(all_readings: Sequence[Reading], all_state: dict[str, object], now: datetime) -> tuple[frozenset[Account], dict[Account, datetime]]:
132
131
  marks = all_state["spent"]
133
132
  spent = {}
@@ -147,6 +146,23 @@ def _mark_spent(all_state: dict[str, object], reading: Reading, now: datetime, r
147
146
  return until
148
147
 
149
148
 
149
+ def _outside_roster_resets(product: Product, all_readings: Sequence[Reading], all_state: Mapping[str, object], now: datetime) -> tuple[datetime, ...]:
150
+ marks = all_state.get("spent") if isinstance(all_state.get("spent"), dict) else {}
151
+ all_roster_names = {each_reading.account.name for each_reading in all_readings}
152
+ prefix = f"{product.value}:"
153
+ placeholder_suffix = f":{Path()}"
154
+ return tuple(datetime.fromtimestamp(float(each_raw), timezone.utc) for each_key, each_raw in marks.items() if isinstance(each_key, str) and each_key.startswith(prefix) and each_key.endswith(placeholder_suffix) and each_key[len(prefix):-len(placeholder_suffix)] not in all_roster_names and isinstance(each_raw, (int, float)) and each_raw > now.timestamp())
155
+
156
+
157
+ def _with_outside_spent_marks(decision: Decision, all_outside_resets: Sequence[datetime]) -> Decision:
158
+ if not all_outside_resets:
159
+ return decision
160
+ soonest = min(all_outside_resets) if decision.resets_at is None else min(decision.resets_at, *all_outside_resets)
161
+ if decision.action == "wait" and decision.resets_at == soonest:
162
+ return decision
163
+ return Decision("wait", None, soonest, f"no account has room; next reset at {_time_text(soonest)}", "wait")
164
+
165
+
150
166
  def _write_report(path: Path, report: Report) -> None:
151
167
  payload = {
152
168
  "product": report.product.value,
@@ -268,12 +284,8 @@ def _execute(
268
284
  ) -> tuple[JobOutcome, Report]:
269
285
  context = _prepare_run(product, all_argv, now, timeout_seconds, stdin_text, cwd, encoding, errors)
270
286
  while True:
271
- decision = choose_from_readings(
272
- product, context.all_readings, now=now,
273
- all_spent_accounts=frozenset(context.all_spent_accounts),
274
- preferred_command=context.preferred_command, adapter=context.active,
275
- all_spent_resets=context.all_spent_resets
276
- )
287
+ picked = choose_from_readings(product, context.all_readings, now=now, all_spent_accounts=frozenset(context.all_spent_accounts), preferred_command=context.preferred_command, adapter=context.active, all_spent_resets=context.all_spent_resets)
288
+ decision = _with_outside_spent_marks(picked, _outside_roster_resets(product, context.all_readings, context.all_state, now))
277
289
  context.report.events.append({"type": "pick", "decision": decision_payload(decision)})
278
290
  if decision.account is None:
279
291
  return _wait_outcome(context, decision), context.report
@@ -309,21 +321,20 @@ def _parser() -> argparse.ArgumentParser:
309
321
  return parser
310
322
 
311
323
 
312
- def _parse_spent_mark(all_accounts_by_name: Mapping[str, Reading], mark: str) -> tuple[Reading, datetime | None]:
324
+ def _parse_spent_mark(all_accounts_by_name: Mapping[str, Reading], mark: str, product: Product) -> tuple[Reading, datetime | None]:
313
325
  name, separator, reset_text = mark.partition(":")
314
- if name not in all_accounts_by_name:
315
- raise BrokerConfigurationError(f"unknown account {name}")
316
326
  try:
317
327
  reset = datetime.fromtimestamp(float(reset_text), timezone.utc) if separator else None
318
- except ValueError as error:
328
+ except (ValueError, OSError, OverflowError) as error:
319
329
  raise BrokerConfigurationError(f"invalid reset for account {name}") from error
320
- return all_accounts_by_name[name], reset
330
+ reading = all_accounts_by_name.get(name)
331
+ return reading if reading is not None else Reading(Account(product, name, Path(), False), None), reset
321
332
 
322
333
 
323
- def _save_spent_arguments(all_marks: Sequence[str], all_readings: Sequence[Reading], all_state: dict[str, object], now: datetime) -> None:
334
+ def _save_spent_arguments(all_marks: Sequence[str], all_readings: Sequence[Reading], all_state: dict[str, object], now: datetime, product: Product) -> None:
324
335
  all_accounts_by_name = {each_reading.account.name: each_reading for each_reading in all_readings}
325
336
  for each_mark in all_marks:
326
- reading, reset = _parse_spent_mark(all_accounts_by_name, each_mark)
337
+ reading, reset = _parse_spent_mark(all_accounts_by_name, each_mark, product)
327
338
  _mark_spent(all_state, reading, now, reset)
328
339
 
329
340
 
@@ -352,9 +363,10 @@ def _choose_cli(product: Product, parsed: argparse.Namespace) -> int:
352
363
  all_state = _load_state(broker_state_path())
353
364
  all_readings = read_accounts(product, all_state=all_state, now=now)
354
365
  if parsed.action == "choose" and parsed.spent:
355
- _save_spent_arguments(parsed.spent, all_readings, all_state, now)
366
+ _save_spent_arguments(parsed.spent, all_readings, all_state, now, product)
356
367
  spent, resets = _spent_for_readings(all_readings, all_state, now)
357
368
  decision = choose_from_readings(product, all_readings, now=now, all_spent_accounts=spent, all_spent_resets=resets)
369
+ decision = _with_outside_spent_marks(decision, _outside_roster_resets(product, all_readings, all_state, now))
358
370
  if parsed.action == "choose":
359
371
  print(json.dumps({"decision": decision_payload(decision), "accounts": readings_payload(all_readings), "state_path": str(broker_state_path())}))
360
372
  return WAIT_EXIT_CODE if decision.action == "wait" else 0
@@ -1,4 +1,4 @@
1
- """Run a headless Claude worker through the account broker."""
1
+ """Time one broker job for the headless Claude worker."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -57,6 +57,7 @@ def wait_report(account: str, reason: str, wait_reset_at: datetime | None) -> Wo
57
57
  Args:
58
58
  account: The chosen account slot.
59
59
  reason: Why the broker returned a wait decision.
60
+ wait_reset_at: The soonest account reset, or None when unknown.
60
61
 
61
62
  Returns:
62
63
  A report carrying the wait exit code and no payload.
@@ -111,7 +112,7 @@ def pre_launch_failure_report(
111
112
 
112
113
  Args:
113
114
  account: The chosen account slot.
114
- reason: Why the picker chose that account.
115
+ reason: Why the worker stopped before launch.
115
116
  exit_code: The failure's exit code.
116
117
 
117
118
  Returns:
@@ -11,10 +11,6 @@ DEFAULT_TIMEOUT_MINUTES: int = 60
11
11
 
12
12
  MINIMUM_TIMEOUT_MINUTES: int = 1
13
13
 
14
- TIMEOUT_EXIT_CODE: int = 124
15
-
16
- MISSING_BINARY_EXIT_CODE: int = 127
17
-
18
14
  LAUNCH_FAILURE_EXIT_CODE: int = 1
19
15
 
20
16
  CONFIGURATION_FAILURE_EXIT_CODE: int = 2
@@ -23,18 +19,12 @@ WAIT_DURATION_SECONDS: float = 0.0
23
19
 
24
20
  STDOUT_TAIL_CHARACTER_LIMIT: int = 4000
25
21
 
26
- DRAIN_GRACE_TIMEOUT_SECONDS: int = 10
27
-
28
- DRAIN_ATTEMPT_LIMIT: int = 2
29
-
30
22
  DURATION_DECIMAL_PLACES: int = 3
31
23
 
32
24
  REPORT_INDENT: int = 2
33
25
 
34
26
  UTF8_ENCODING: str = "utf-8"
35
27
 
36
- UTF8_DECODE_ERRORS: str = "replace"
37
-
38
28
  CLI_DESCRIPTION: str = "Run one Claude worker through the account broker."
39
29
 
40
30
  PROMPT_FILE_FLAG: str = "--prompt-file"
@@ -85,8 +75,6 @@ __all__ = [
85
75
  "CWD_FLAG",
86
76
  "DEFAULT_PERMISSION_MODE",
87
77
  "DEFAULT_TIMEOUT_MINUTES",
88
- "DRAIN_ATTEMPT_LIMIT",
89
- "DRAIN_GRACE_TIMEOUT_SECONDS",
90
78
  "DURATION_DECIMAL_PLACES",
91
79
  "INVALID_TIMEOUT_MESSAGE",
92
80
  "JSON_ACCOUNT_KEY",
@@ -94,7 +82,6 @@ __all__ = [
94
82
  "JSON_REASON_KEY",
95
83
  "JSON_RESULT_KEY",
96
84
  "LAUNCH_FAILURE_EXIT_CODE",
97
- "MISSING_BINARY_EXIT_CODE",
98
85
  "MINIMUM_TIMEOUT_MINUTES",
99
86
  "MODEL_FLAG",
100
87
  "OUTPUT_FORMAT_FLAG",
@@ -113,9 +100,7 @@ __all__ = [
113
100
  "SINGLE_PROMPT_FLAG",
114
101
  "STDOUT_TAIL_CHARACTER_LIMIT",
115
102
  "SUMMARY_LINE_TEMPLATE",
116
- "TIMEOUT_EXIT_CODE",
117
103
  "TIMEOUT_MINUTES_FLAG",
118
- "UTF8_DECODE_ERRORS",
119
104
  "UTF8_ENCODING",
120
105
  "WAIT_DURATION_SECONDS",
121
106
  "WAIT_EXIT_CODE",
@@ -1,93 +1,7 @@
1
- """Named constants for the claude fallback-chain runner, and the helper that
2
- picks which text-codec kwargs a chain subprocess wrapper forwards.
3
-
4
- Per the project's configuration conventions, every scalar and structural
5
- constant the runner needs lives here rather than inline in the module.
6
- """
1
+ """Named constants the account broker and the code review CLI share for Claude runs."""
7
2
 
8
3
  from __future__ import annotations
9
4
 
10
- from collections.abc import Mapping
11
-
12
- UTF8_ENCODING: str = "utf-8"
13
- """Encoding used to read the chain configuration file."""
14
-
15
- CODEC_ERROR_STRATEGY: str = "replace"
16
- """Codec error handler that maps any unencodable or undecodable value to a marker."""
17
-
18
- CRLF_NEWLINE: str = "\r\n"
19
- """Windows-style newline sequence normalized away when decoding spool captures."""
20
-
21
- CARRIAGE_RETURN: str = "\r"
22
- """Bare carriage return normalized to LF when decoding spool captures."""
23
-
24
- LINE_FEED: str = "\n"
25
- """Unix-style newline retained after universal-newline normalization."""
26
-
27
- SUBPROCESS_ENCODING_KEYWORD: str = "encoding"
28
- """Keyword name for text encoding when forwarding chain subprocess runner kwargs."""
29
-
30
- SUBPROCESS_ERRORS_KEYWORD: str = "errors"
31
- """Keyword name for text decode error policy when forwarding chain subprocess runner kwargs."""
32
-
33
- ALL_SUBPROCESS_TEXT_CODEC_KEYWORDS: tuple[str, ...] = (
34
- SUBPROCESS_ENCODING_KEYWORD,
35
- SUBPROCESS_ERRORS_KEYWORD,
36
- )
37
- """Keyword names to forward from the chain runner for text-mode subprocess capture."""
38
-
39
-
40
- def collect_forwarded_text_codec(
41
- all_keywords: Mapping[str, object],
42
- ) -> dict[str, object]:
43
- """Return only the text-codec kwargs present in ``all_keywords``.
44
-
45
- ::
46
-
47
- collect_forwarded_text_codec({"encoding": "utf-8", "timeout": 30})
48
- # -> {"encoding": "utf-8"}
49
-
50
- collect_forwarded_text_codec({"timeout": 30})
51
- # -> {}
52
-
53
- Args:
54
- all_keywords: Keyword arguments received by a chain subprocess wrapper.
55
-
56
- Returns:
57
- Mapping of codec keyword names to their values, limited to keys listed
58
- in ``ALL_SUBPROCESS_TEXT_CODEC_KEYWORDS`` that are present in the input.
59
- """
60
- return {
61
- each_key: all_keywords[each_key]
62
- for each_key in ALL_SUBPROCESS_TEXT_CODEC_KEYWORDS
63
- if each_key in all_keywords
64
- }
65
-
66
-
67
- CLAUDE_HOME_SUBDIRECTORY: str = ".claude"
68
- """Per-user directory under the home directory that holds the chain config."""
69
-
70
- CONFIG_FILENAME: str = "claude-chain.json"
71
- """Real chain-configuration filename read from the user's home directory."""
72
-
73
- CHAIN_USAGE_MODULE_NAME: str = "claude_chain_usage"
74
- """Import name of the weekly-usage report module loaded lazily by the runner."""
75
-
76
- EXAMPLE_CONFIG_FILENAME: str = "claude-chain.example.json"
77
- """Committed template filename referenced in the config-error guidance."""
78
-
79
- CONFIG_CHAIN_KEY: str = "chain"
80
- """Top-level key whose value is the ordered list of chain entries."""
81
-
82
- CONFIG_COMMAND_KEY: str = "command"
83
- """Chain-entry key naming the binary to spawn."""
84
-
85
- CONFIG_EXTRA_ARGS_KEY: str = "extra_args"
86
- """Chain-entry key holding per-account arguments appended to each invocation."""
87
-
88
- CONFIG_CREDENTIALS_PATH_KEY: str = "credentials_path"
89
- """Optional chain-entry key naming that account's OAuth credentials file path."""
90
-
91
5
  ALL_USAGE_LIMIT_SIGNATURES: tuple[str, ...] = (
92
6
  "hit your session limit",
93
7
  "usage limit reached",
@@ -96,208 +10,5 @@ ALL_USAGE_LIMIT_SIGNATURES: tuple[str, ...] = (
96
10
  )
97
11
  """Case-insensitive substrings that mark a non-zero exit as a usage-limit refusal."""
98
12
 
99
- ATTEMPT_STATUS_SERVED: str = "served"
100
- """Status recorded when a binary exits zero and serves the call."""
101
-
102
- ATTEMPT_STATUS_USAGE_LIMITED: str = "usage_limited"
103
- """Status recorded when a binary fails with a usage-limit signature."""
104
-
105
- ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND: str = "executable_not_found"
106
- """Status recorded when a binary is not installed."""
107
-
108
- ATTEMPT_STATUS_NONZERO_EXIT: str = "nonzero_exit"
109
- """Status recorded when a binary fails without a usage-limit signature."""
110
-
111
- ATTEMPT_STATUS_TIMEOUT: str = "timeout"
112
- """Status recorded when a binary exceeds the invocation timeout."""
113
-
114
- DEFAULT_TIMEOUT_SECONDS: int = 300
115
- """Timeout applied to each binary invocation when the caller names none."""
116
-
117
- NO_COMPLETED_PROCESS_RETURN_CODE: int = 1
118
- """Return code carried on the result when no binary produced a completed process."""
119
-
120
- CHAIN_EXHAUSTED_EXIT_CODE: int = 2
121
- """CLI exit code when no binary in the chain served the call."""
122
-
123
13
  CHAIN_CONFIG_ERROR_EXIT_CODE: int = 3
124
14
  """CLI exit code when the chain configuration is missing or invalid."""
125
-
126
- CLI_TIMEOUT_FLAG: str = "--timeout-seconds"
127
- """CLI flag that overrides the per-invocation timeout in seconds."""
128
-
129
- CLI_ROUTING_MODE_FLAG: str = "--routing-mode"
130
- """CLI flag that selects usage-ranked or ordered-account chain routing."""
131
-
132
- CLI_ARGUMENTS_SEPARATOR: str = "--"
133
- """CLI token separating runner flags from the passthrough claude arguments."""
134
-
135
- ROUTING_MODE_USAGE_RANKED: str = "usage_ranked"
136
- """Default routing: probe weekly remaining and try highest remaining first."""
137
-
138
- ROUTING_MODE_ORDERED_ACCOUNT: str = "ordered_account"
139
- """Explicit routing: walk chain entries in config order; usage-limit-only fallover."""
140
-
141
- DEFAULT_ROUTING_MODE: str = ROUTING_MODE_USAGE_RANKED
142
- """Routing mode applied when the caller does not name one."""
143
-
144
- ALL_ROUTING_MODES: frozenset[str] = frozenset(
145
- {
146
- ROUTING_MODE_USAGE_RANKED,
147
- ROUTING_MODE_ORDERED_ACCOUNT,
148
- }
149
- )
150
- """Accepted values for the routing-mode parameter and CLI flag."""
151
-
152
- TERMINAL_STATUS_SERVED: str = "served"
153
- """Outcome status when a binary served the call (zero or non-usage nonzero)."""
154
-
155
- TERMINAL_STATUS_ADVISOR_BLOCKED: str = "advisor_blocked"
156
- """Outcome status when ordered-account mode stops on a non-usage failure."""
157
-
158
- TERMINAL_STATUS_CHAIN_EXHAUSTED: str = "chain_exhausted"
159
- """Outcome status when every chain entry was usage-limited or missing."""
160
-
161
- TERMINAL_STATUS_TIMEOUT: str = "timeout"
162
- """Outcome status when a usage-ranked walk stops on TimeoutExpired mid-walk."""
163
-
164
- SESSION_ID_JSON_KEY: str = "session_id"
165
- """JSON key read from Claude ``--output-format json`` events for resume."""
166
-
167
- CHAIN_ADVISOR_BLOCKED_EXIT_CODE: int = 4
168
- """CLI exit code when ordered-account mode stops with advisor_blocked."""
169
-
170
- CONFIG_NOT_OBJECT_REASON: str = "the top-level value is not a JSON object"
171
- """Reason detail when the config root is not an object."""
172
-
173
- CONFIG_CHAIN_NOT_LIST_REASON: str = "the 'chain' key is missing or not a list"
174
- """Reason detail when the chain key is absent or the wrong type."""
175
-
176
- CONFIG_CHAIN_EMPTY_REASON: str = "the 'chain' list is empty"
177
- """Reason detail when the chain contains no entries."""
178
-
179
- CONFIG_ENTRY_NOT_OBJECT_REASON: str = "a chain entry is not a JSON object"
180
- """Reason detail when a chain entry is not an object."""
181
-
182
- CONFIG_ENTRY_COMMAND_MISSING_REASON: str = "a chain entry has no string 'command'"
183
- """Reason detail when a chain entry lacks a usable command."""
184
-
185
- CONFIG_ENTRY_EXTRA_ARGS_INVALID_REASON: str = (
186
- "a chain entry's 'extra_args' is not a list of strings"
187
- )
188
- """Reason detail when a chain entry's extra_args value is the wrong shape."""
189
-
190
- CONFIG_ENTRY_CREDENTIALS_PATH_INVALID_REASON: str = (
191
- "a chain entry's 'credentials_path' is not a non-empty string"
192
- )
193
- """Reason detail when a chain entry's credentials_path value is the wrong shape."""
194
-
195
- CONFIG_MISSING_MESSAGE_TEMPLATE: str = (
196
- "Claude chain config not found at {config_path}. Copy {example_filename} to "
197
- "{config_path} and list your account binaries. Try order comes from weekly "
198
- "remaining; config order is the tiebreak."
199
- )
200
- """Guidance shown when the config file is absent."""
201
-
202
- CONFIG_UNREADABLE_MESSAGE_TEMPLATE: str = (
203
- "Cannot read claude chain config at {config_path}: {error}. "
204
- "See {example_filename} for the expected shape."
205
- )
206
- """Guidance shown when the config file cannot be read."""
207
-
208
- CONFIG_MALFORMED_MESSAGE_TEMPLATE: str = (
209
- "Malformed JSON in claude chain config at {config_path}: {error}. "
210
- "See {example_filename} for the expected shape."
211
- )
212
- """Guidance shown when the config file is not valid JSON."""
213
-
214
- CONFIG_INVALID_SHAPE_MESSAGE_TEMPLATE: str = (
215
- "Invalid claude chain config at {config_path}: {reason}. "
216
- "See {example_filename} for the expected shape."
217
- )
218
- """Guidance shown when the config JSON does not match the expected shape."""
219
-
220
- CHAIN_EXHAUSTED_MESSAGE_TEMPLATE: str = (
221
- "No claude binary in the chain served the call. Attempts: {attempt_summary}"
222
- )
223
- """CLI stderr message when the walk ends without a serving binary."""
224
-
225
- ATTEMPT_SUMMARY_ENTRY_TEMPLATE: str = "{command}={status}"
226
- """Per-attempt fragment used to build the exhausted-chain summary."""
227
-
228
- ATTEMPT_SUMMARY_JOIN_SEPARATOR: str = ", "
229
- """Separator joining per-attempt fragments in the exhausted-chain summary."""
230
-
231
- AFFINITY_STATE_SCHEMA_VERSION: int = 1
232
- """Version field written into the session-to-binary affinity state document."""
233
-
234
- AFFINITY_STATE_FILENAME: str = "claude-chain-affinity.json"
235
- """Default affinity state file name under the Claude home directory."""
236
-
237
- AFFINITY_MAXIMUM_ENTRIES: int = 64
238
- """Hard cap on retained session-to-binary affinity rows (oldest drop first)."""
239
-
240
- AFFINITY_KEY_SCHEMA_VERSION: str = "schema_version"
241
- """JSON key for the affinity document schema version."""
242
-
243
- AFFINITY_KEY_ALL_BINDINGS: str = "all_bindings"
244
- """JSON key for the ordered list of session-to-command bindings."""
245
-
246
- AFFINITY_KEY_SESSION_ID: str = "session_id"
247
- """JSON key for a bound Claude session id."""
248
-
249
- AFFINITY_KEY_COMMAND: str = "command"
250
- """JSON key for the chain binary command bound to a session id."""
251
-
252
- AFFINITY_TEMP_SUFFIX: str = ".tmp"
253
- """Suffix for the temporary file used during atomic affinity replacement."""
254
-
255
- AFFINITY_BINDING_NOT_OBJECT_REASON: str = "a binding entry is not an object"
256
- """Reason when an affinity binding entry is not a JSON object."""
257
-
258
- AFFINITY_BINDING_SESSION_ID_MISSING_REASON: str = (
259
- "binding missing non-empty session_id"
260
- )
261
- """Reason when an affinity binding lacks a usable session_id."""
262
-
263
- AFFINITY_BINDING_COMMAND_MISSING_REASON: str = "binding missing non-empty command"
264
- """Reason when an affinity binding lacks a usable command."""
265
-
266
- AFFINITY_UNSUPPORTED_SCHEMA_VERSION_REASON_TEMPLATE: str = (
267
- "unsupported schema_version {schema_version!r}"
268
- )
269
- """Reason when the affinity document schema version is not supported."""
270
-
271
- AFFINITY_BINDINGS_MISSING_OR_NOT_LIST_REASON: str = (
272
- "all_bindings is missing or not a list"
273
- )
274
- """Reason when all_bindings is absent or the wrong type."""
275
-
276
- AFFINITY_TOP_LEVEL_NOT_OBJECT_REASON: str = "top-level value is not a JSON object"
277
- """Reason when the affinity document root is not an object."""
278
-
279
- AFFINITY_SESSION_ID_AND_COMMAND_REQUIRED_MESSAGE: str = (
280
- "session_id and command must be non-empty"
281
- )
282
- """ValueError message when record_affinity_binding receives empty ids."""
283
-
284
- AFFINITY_MAXIMUM_ENTRIES_MINIMUM_MESSAGE: str = "maximum_entries must be at least 1"
285
- """ValueError message when maximum_entries is below one."""
286
-
287
- AFFINITY_CORRUPT_MESSAGE_TEMPLATE: str = (
288
- "Affinity state at {state_path} is corrupt or unreadable: {error}. "
289
- "Delete or repair the file before retrying."
290
- )
291
- """Actionable diagnostic when affinity state cannot be loaded."""
292
-
293
- AFFINITY_WRITE_FAILED_MESSAGE_TEMPLATE: str = (
294
- "Failed to write affinity state at {state_path}: {error}. "
295
- "Check directory permissions and free space."
296
- )
297
- """Actionable diagnostic when atomic affinity replacement fails."""
298
-
299
- AFFINITY_JSON_INDENT_SPACES: int = 2
300
- """Indent width for the written affinity state JSON document."""
301
-
302
- RESUME_SESSION_FLAG: str = "--resume"
303
- """Claude CLI flag that continues a prior session by id."""
@@ -0,0 +1,49 @@
1
+ """Floors and outcome shape for the account broker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+
7
+ import pytest
8
+
9
+ from dev_env_scripts_constants.account_broker_constants import (
10
+ ALL_CLAUDE_FLOORS,
11
+ ALL_CODEX_FLOORS,
12
+ JobOutcome,
13
+ )
14
+ from dev_env_scripts_constants.claude_account_constants import (
15
+ MAIN_SESSION_USED_CEILING_PERCENT,
16
+ MAIN_SPEND_WINDOW,
17
+ MAIN_WEEKLY_USED_CEILING_PERCENT,
18
+ SECOND_SESSION_USED_CEILING_PERCENT,
19
+ SECOND_WEEKLY_USED_CEILING_PERCENT,
20
+ )
21
+ from dev_env_scripts_constants.codex_account_constants import (
22
+ LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT,
23
+ LUNA_TIER_STOP_PERCENT_LEFT,
24
+ NORMAL_TIER_MINIMUM_PERCENT_LEFT,
25
+ )
26
+
27
+
28
+ def test_should_keep_claude_floors_tied_to_the_account_ceilings() -> None:
29
+ assert ALL_CLAUDE_FLOORS == {
30
+ "main_weekly_used_ceiling": MAIN_WEEKLY_USED_CEILING_PERCENT,
31
+ "main_session_used_ceiling": MAIN_SESSION_USED_CEILING_PERCENT,
32
+ "main_spend_window": MAIN_SPEND_WINDOW,
33
+ "extra_weekly_used_ceiling": SECOND_WEEKLY_USED_CEILING_PERCENT,
34
+ "extra_session_used_ceiling": SECOND_SESSION_USED_CEILING_PERCENT,
35
+ }
36
+
37
+
38
+ def test_should_keep_codex_floors_tied_to_the_tier_limits() -> None:
39
+ assert ALL_CODEX_FLOORS == {
40
+ "normal_minimum_left": NORMAL_TIER_MINIMUM_PERCENT_LEFT,
41
+ "luna_stop_left": LUNA_TIER_STOP_PERCENT_LEFT,
42
+ "luna_short_minimum_left": LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT,
43
+ }
44
+
45
+
46
+ def test_should_freeze_job_outcome() -> None:
47
+ outcome = JobOutcome(0, "", "", None, (), "served", None, None)
48
+ with pytest.raises(dataclasses.FrozenInstanceError):
49
+ outcome.status = "wait"
@@ -24,6 +24,21 @@ from dev_env_scripts_constants.account_broker_constants import WAIT_EXIT_CODE
24
24
  NOW = datetime(2026, 10, 3, tzinfo=timezone.utc)
25
25
 
26
26
 
27
+ class _FrozenClock(datetime):
28
+ instant = NOW
29
+
30
+ @classmethod
31
+ def now(cls, tz: timezone | None = None) -> datetime:
32
+ if tz is None:
33
+ return cls.instant.replace(tzinfo=None)
34
+ return cls.instant.astimezone(tz)
35
+
36
+
37
+ def _freeze_clock(monkeypatch: pytest.MonkeyPatch, instant: datetime) -> None:
38
+ _FrozenClock.instant = instant
39
+ monkeypatch.setattr(account_broker, "datetime", _FrozenClock)
40
+
41
+
27
42
  @pytest.fixture(autouse=True)
28
43
  def isolated_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
29
44
  monkeypatch.setattr(account_broker, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
@@ -525,6 +540,112 @@ def test_should_raise_configuration_error_for_broken_list(
525
540
  account_broker.load_claude_accounts()
526
541
 
527
542
 
543
+ def test_should_wait_when_a_spent_mark_names_an_account_outside_the_roster(
544
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
545
+ ) -> None:
546
+ account = _account("first")
547
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
548
+ _freeze_clock(monkeypatch, NOW)
549
+ sooner = NOW + timedelta(hours=2)
550
+ later = NOW + timedelta(hours=5)
551
+
552
+ code = account_broker.main((
553
+ "choose", "--product", "codex",
554
+ "--spent", f"visitor:{int(sooner.timestamp())}",
555
+ "--spent", f"guest:{int(later.timestamp())}",
556
+ ))
557
+
558
+ decision = json.loads(capsys.readouterr().out)["decision"]
559
+ assert code == WAIT_EXIT_CODE
560
+ assert decision["action"] == "wait"
561
+ assert decision["account"] is None
562
+ assert decision["resets_at"] == sooner.isoformat()
563
+
564
+
565
+ def test_should_wait_for_spent_default_home_until_the_mark_passes(
566
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
567
+ ) -> None:
568
+ home = tmp_path / "default-home"
569
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((), {}))
570
+ monkeypatch.setenv("CODEX_HOME", str(home))
571
+ _freeze_clock(monkeypatch, NOW)
572
+ reset = NOW + timedelta(hours=2)
573
+
574
+ waiting = account_broker.main(("choose", "--product", "codex", "--spent", f"default:{int(reset.timestamp())}"))
575
+ waiting_decision = json.loads(capsys.readouterr().out)["decision"]
576
+ assert waiting == WAIT_EXIT_CODE
577
+ assert waiting_decision["action"] == "wait"
578
+ assert waiting_decision["resets_at"] == reset.isoformat()
579
+
580
+ _FrozenClock.instant = reset + timedelta(seconds=1)
581
+ running = account_broker.main(("choose", "--product", "codex"))
582
+ running_decision = json.loads(capsys.readouterr().out)["decision"]
583
+ assert running == 0
584
+ assert running_decision["action"] == "run"
585
+ assert running_decision["account"] == "default"
586
+ assert running_decision["home"] == str(home.resolve())
587
+
588
+
589
+ def test_should_keep_a_spent_mark_without_a_reset_for_one_hour(
590
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
591
+ ) -> None:
592
+ account = _account("first")
593
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
594
+ _freeze_clock(monkeypatch, NOW)
595
+
596
+ code = account_broker.main(("choose", "--product", "codex", "--spent", "guest"))
597
+
598
+ decision = json.loads(capsys.readouterr().out)["decision"]
599
+ assert code == WAIT_EXIT_CODE
600
+ assert decision["action"] == "wait"
601
+ assert decision["resets_at"] == (NOW + timedelta(hours=1)).isoformat()
602
+
603
+
604
+ def test_should_reject_an_invalid_spent_reset(
605
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
606
+ ) -> None:
607
+ account = _account("first")
608
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
609
+
610
+ code = account_broker.main(("choose", "--product", "codex", "--spent", "guest:tomorrow"))
611
+
612
+ assert code == 2
613
+ assert "invalid reset" in capsys.readouterr().err
614
+
615
+
616
+ @pytest.mark.parametrize("reset_text", ["inf", "1e20", "-1e20"])
617
+ def test_should_reject_a_spent_reset_outside_the_platform_range(
618
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], reset_text: str
619
+ ) -> None:
620
+ account = _account("first")
621
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
622
+
623
+ code = account_broker.main(("choose", "--product", "codex", "--spent", f"guest:{reset_text}"))
624
+
625
+ assert code == 2
626
+ assert "invalid reset" in capsys.readouterr().err
627
+
628
+
629
+ def test_should_run_past_a_spent_mark_left_by_an_account_removed_from_the_roster(
630
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
631
+ ) -> None:
632
+ account = _account("first")
633
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
634
+ _freeze_clock(monkeypatch, NOW)
635
+ retired_key = account_broker._state_key(_account("retired"))
636
+ account_broker._save_state(
637
+ account_broker.broker_state_path(),
638
+ {"meters": {}, "spent": {retired_key: (NOW + timedelta(days=6)).timestamp()}, "affinity": {}},
639
+ )
640
+
641
+ code = account_broker.main(("choose", "--product", "codex"))
642
+
643
+ decision = json.loads(capsys.readouterr().out)["decision"]
644
+ assert code == 0
645
+ assert decision["action"] == "run"
646
+ assert decision["account"] == "first"
647
+
648
+
528
649
  def test_should_run_job_through_override(
529
650
  monkeypatch: pytest.MonkeyPatch
530
651
  ) -> None: