claude-dev-env 8.44.5 → 8.45.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/docs/account-broker.md +1 -1
- package/hooks/hooks.json +5 -0
- package/hooks/hooks_constants/subagent_model_pin_hook_constants.py +16 -0
- package/hooks/hooks_constants/thread_spawn_pace_hook_constants.py +3 -20
- package/hooks/routing/subagent_model_pin_hook.py +110 -0
- package/hooks/routing/test_subagent_model_pin_hook.py +67 -0
- package/hooks/routing/test_thread_spawn_pace_hook.py +5 -33
- package/hooks/routing/thread_spawn_pace_hook.py +6 -39
- package/package.json +1 -1
- package/scripts/account_broker.py +27 -15
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +49 -0
- package/scripts/test_account_broker.py +121 -0
package/docs/account-broker.md
CHANGED
|
@@ -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/hooks/hooks.json
CHANGED
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
"type": "command",
|
|
15
15
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_readiness_hook.py",
|
|
16
16
|
"timeout": 10
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/subagent_model_pin_hook.py",
|
|
21
|
+
"timeout": 10
|
|
17
22
|
}
|
|
18
23
|
]
|
|
19
24
|
},
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Constants for the subagent model pin PreToolUse hook.
|
|
2
|
+
|
|
3
|
+
Groups: the model every subagent runs on, the model families the hook moves,
|
|
4
|
+
and the hook input and output keys.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
SUBAGENT_MODEL_ALIAS = "opus"
|
|
10
|
+
ALL_MOVED_MODEL_FAMILIES = ("sonnet", "haiku")
|
|
11
|
+
MODEL_INPUT_KEY = "model"
|
|
12
|
+
TOOL_INPUT_KEY = "tool_input"
|
|
13
|
+
CONTEXT_PREFIX = "Subagent model moved to opus (was "
|
|
14
|
+
CONTEXT_OMITTED_MODEL_TEXT = "inherited from the parent"
|
|
15
|
+
CONTEXT_SUFFIX = ")."
|
|
16
|
+
ADDITIONAL_CONTEXT_KEY = "additionalContext"
|
|
@@ -11,35 +11,18 @@ USAGE_PACE_SCRIPT_FILE_NAME = "usage_pace.py"
|
|
|
11
11
|
USAGE_PACE_TIMEOUT_SECONDS = 15
|
|
12
12
|
|
|
13
13
|
TOOL_INPUT_KEY = "tool_input"
|
|
14
|
-
THREAD_MODEL_WHEN_OVER_PACE = "
|
|
15
|
-
THREAD_EFFORT_WHEN_OVER_PACE = "
|
|
14
|
+
THREAD_MODEL_WHEN_OVER_PACE = "opus"
|
|
15
|
+
THREAD_EFFORT_WHEN_OVER_PACE = "low"
|
|
16
16
|
MODEL_INPUT_KEY = "model"
|
|
17
17
|
EFFORT_INPUT_KEY = "effort"
|
|
18
|
-
INSTRUCTIONS_INPUT_KEY = "instructions"
|
|
19
|
-
INSTRUCTIONS_MAXIMUM_BYTES = 8192
|
|
20
|
-
INSTRUCTIONS_SEPARATOR = "\n\n"
|
|
21
|
-
INSTRUCTIONS_ENCODING = "utf-8"
|
|
22
|
-
FABLE_ADVISOR_LINE = (
|
|
23
|
-
"Mandatory Fable advisor: usage is over pace, so this thread runs on "
|
|
24
|
-
"Sonnet 5.5 at medium effort. Invoke /team-advisor before substantive "
|
|
25
|
-
"work and bind a Fable advisor through it. Consult that advisor again "
|
|
26
|
-
"before you report done, and name its verdict in your report."
|
|
27
|
-
)
|
|
28
18
|
|
|
29
19
|
PERMISSION_DENY = "deny"
|
|
30
20
|
PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
|
|
31
21
|
ADDITIONAL_CONTEXT_KEY = "additionalContext"
|
|
32
22
|
NOT_AN_OBJECT_REASON = "start_thread_session input is not a JSON object"
|
|
33
|
-
NO_INSTRUCTIONS_REASON = "start_thread_session input has no instructions text"
|
|
34
|
-
OVER_BYTE_CAP_REASON_TEMPLATE = (
|
|
35
|
-
"usage is over pace and the brief plus the mandatory Fable advisor line is "
|
|
36
|
-
"{reshaped_byte_count} bytes, over the {maximum_bytes}-byte cap; "
|
|
37
|
-
"shorten the instructions by {excess_byte_count} bytes"
|
|
38
|
-
)
|
|
39
23
|
RESHAPE_CONTEXT_PREFIX = (
|
|
40
24
|
"Usage over pace or unreadable: this thread spawn now runs on "
|
|
41
|
-
"
|
|
42
|
-
"Pace verdict: "
|
|
25
|
+
"opus at low effort. Pace verdict: "
|
|
43
26
|
)
|
|
44
27
|
PACE_VERDICT_CONTEXT_MAXIMUM_CHARACTERS = 600
|
|
45
28
|
LAUNCH_FAILURE_ERROR_TEMPLATE = "usage pace did not finish: {failure_type}"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: run every Agent and Task subagent on Opus.
|
|
3
|
+
|
|
4
|
+
Registered on ``Agent|Task``. Nested spawns pass through the same hook, so the
|
|
5
|
+
pin holds at every level.
|
|
6
|
+
|
|
7
|
+
::
|
|
8
|
+
|
|
9
|
+
model omitted -> allow with updatedInput model "opus"
|
|
10
|
+
model sonnet or haiku -> allow with updatedInput model "opus"
|
|
11
|
+
model opus or fable -> no output; the call runs unchanged
|
|
12
|
+
|
|
13
|
+
A model id counts by its family: ``claude-sonnet-5-5`` is sonnet. A fork
|
|
14
|
+
ignores the model field, so the rewrite leaves it unchanged.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
24
|
+
if hooks_root_directory not in sys.path:
|
|
25
|
+
sys.path.insert(0, hooks_root_directory)
|
|
26
|
+
|
|
27
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
28
|
+
ALLOW_DECISION,
|
|
29
|
+
HOOK_EVENT_NAME,
|
|
30
|
+
)
|
|
31
|
+
from hooks_constants.pre_tool_use_allow_output import (
|
|
32
|
+
HOOK_EVENT_NAME_KEY,
|
|
33
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
34
|
+
PERMISSION_DECISION_KEY,
|
|
35
|
+
UPDATED_INPUT_KEY,
|
|
36
|
+
)
|
|
37
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
38
|
+
from hooks_constants.subagent_model_pin_hook_constants import (
|
|
39
|
+
ADDITIONAL_CONTEXT_KEY,
|
|
40
|
+
ALL_MOVED_MODEL_FAMILIES,
|
|
41
|
+
CONTEXT_OMITTED_MODEL_TEXT,
|
|
42
|
+
CONTEXT_PREFIX,
|
|
43
|
+
CONTEXT_SUFFIX,
|
|
44
|
+
MODEL_INPUT_KEY,
|
|
45
|
+
SUBAGENT_MODEL_ALIAS,
|
|
46
|
+
TOOL_INPUT_KEY,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_moved_model(model: object) -> bool:
|
|
51
|
+
"""Return True when the spawn names no model or a model family the pin moves.
|
|
52
|
+
|
|
53
|
+
::
|
|
54
|
+
|
|
55
|
+
None -> True "sonnet" -> True "claude-haiku-4-5-20251001" -> True
|
|
56
|
+
"opus" -> False "claude-fable-5-1" -> False
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
model: The ``model`` field of the spawn input.
|
|
60
|
+
"""
|
|
61
|
+
if model is None or model == "":
|
|
62
|
+
return True
|
|
63
|
+
if not isinstance(model, str):
|
|
64
|
+
return False
|
|
65
|
+
lowered_model = model.lower()
|
|
66
|
+
return any(each_family in lowered_model for each_family in ALL_MOVED_MODEL_FAMILIES)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def decide_hook_output(tool_input: object) -> dict[str, object] | None:
|
|
70
|
+
"""Choose the hook's output for one subagent spawn.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
tool_input: The ``tool_input`` of the PreToolUse payload.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
None to pass the call through unchanged, else the hook JSON output.
|
|
77
|
+
"""
|
|
78
|
+
if not isinstance(tool_input, dict):
|
|
79
|
+
return None
|
|
80
|
+
requested_model = tool_input.get(MODEL_INPUT_KEY)
|
|
81
|
+
if not is_moved_model(requested_model):
|
|
82
|
+
return None
|
|
83
|
+
previous_model_text = requested_model or CONTEXT_OMITTED_MODEL_TEXT
|
|
84
|
+
return {
|
|
85
|
+
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
86
|
+
HOOK_EVENT_NAME_KEY: HOOK_EVENT_NAME,
|
|
87
|
+
PERMISSION_DECISION_KEY: ALLOW_DECISION,
|
|
88
|
+
UPDATED_INPUT_KEY: {**tool_input, MODEL_INPUT_KEY: SUBAGENT_MODEL_ALIAS},
|
|
89
|
+
ADDITIONAL_CONTEXT_KEY: f"{CONTEXT_PREFIX}{previous_model_text}{CONTEXT_SUFFIX}",
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main() -> int:
|
|
95
|
+
"""Read the PreToolUse payload and print the decision.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
0 in every case.
|
|
99
|
+
"""
|
|
100
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
101
|
+
tool_input = hook_payload.get(TOOL_INPUT_KEY) if hook_payload is not None else None
|
|
102
|
+
hook_output = decide_hook_output(tool_input)
|
|
103
|
+
if hook_output is not None:
|
|
104
|
+
sys.stdout.write(json.dumps(hook_output))
|
|
105
|
+
sys.stdout.flush()
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
sys.exit(main())
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
HOOK_SCRIPT = Path(__file__).resolve().parent / "subagent_model_pin_hook.py"
|
|
9
|
+
HOOKS_JSON = Path(__file__).resolve().parent.parent / "hooks.json"
|
|
10
|
+
CONSTANTS_DIRECTORY = Path(__file__).resolve().parent.parent / "hooks_constants"
|
|
11
|
+
SPAWN_INPUT = {"subagent_type": "pstack:poteto-agent", "prompt": "Do X.", "description": "d"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _run_hook(tool_input: object) -> str:
|
|
15
|
+
completed = subprocess.run(
|
|
16
|
+
[sys.executable, str(HOOK_SCRIPT)],
|
|
17
|
+
input=json.dumps({"tool_name": "Agent", "tool_input": tool_input}),
|
|
18
|
+
capture_output=True,
|
|
19
|
+
text=True,
|
|
20
|
+
check=True,
|
|
21
|
+
)
|
|
22
|
+
return completed.stdout
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.parametrize(
|
|
26
|
+
"requested_model",
|
|
27
|
+
[None, "", "sonnet", "haiku", "claude-sonnet-5-5", "claude-sonnet-5-5[1m]", "claude-haiku-4-5-20251001"],
|
|
28
|
+
)
|
|
29
|
+
def test_should_move_the_spawn_to_opus(requested_model: object) -> None:
|
|
30
|
+
tool_input = dict(SPAWN_INPUT)
|
|
31
|
+
if requested_model is not None:
|
|
32
|
+
tool_input["model"] = requested_model
|
|
33
|
+
decision = json.loads(_run_hook(tool_input))["hookSpecificOutput"]
|
|
34
|
+
assert decision["permissionDecision"] == "allow"
|
|
35
|
+
assert decision["updatedInput"] == {**SPAWN_INPUT, "model": "opus"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.parametrize("requested_model", ["opus", "fable", "claude-opus-5-5", "claude-fable-5-1"])
|
|
39
|
+
def test_should_leave_an_opus_or_fable_spawn_alone(requested_model: str) -> None:
|
|
40
|
+
assert _run_hook({**SPAWN_INPUT, "model": requested_model}) == ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_should_pass_a_tool_input_that_is_not_an_object() -> None:
|
|
44
|
+
assert _run_hook(["not", "an", "object"]) == ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_should_register_the_pin_on_the_agent_and_task_matcher() -> None:
|
|
48
|
+
all_agent_entries = [
|
|
49
|
+
each_entry
|
|
50
|
+
for each_entry in json.loads(HOOKS_JSON.read_text(encoding="utf-8"))["hooks"]["PreToolUse"]
|
|
51
|
+
if each_entry["matcher"] == "Agent|Task"
|
|
52
|
+
]
|
|
53
|
+
all_commands = [
|
|
54
|
+
each_hook["command"] for each_entry in all_agent_entries for each_hook in each_entry["hooks"]
|
|
55
|
+
]
|
|
56
|
+
assert any("subagent_model_pin_hook.py" in each_command for each_command in all_commands)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_should_keep_every_constant_free_of_a_sonnet_model_id() -> None:
|
|
60
|
+
all_offending_files = [
|
|
61
|
+
each_file.name
|
|
62
|
+
for each_file in CONSTANTS_DIRECTORY.glob("*.py")
|
|
63
|
+
if not each_file.name.startswith("test_")
|
|
64
|
+
and each_file.name != "subagent_model_pin_hook_constants.py"
|
|
65
|
+
and "claude-sonnet" in each_file.read_text(encoding="utf-8")
|
|
66
|
+
]
|
|
67
|
+
assert all_offending_files == []
|
|
@@ -5,7 +5,6 @@ import sys
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
import pytest
|
|
8
|
-
from hooks_constants.thread_spawn_pace_hook_constants import FABLE_ADVISOR_LINE
|
|
9
8
|
|
|
10
9
|
HOOK_SCRIPT = Path(__file__).resolve().parent / "thread_spawn_pace_hook.py"
|
|
11
10
|
SPAWN_INPUT = {
|
|
@@ -55,9 +54,8 @@ def _assert_reshaped(stdout: str) -> None:
|
|
|
55
54
|
assert decision["permissionDecision"] == "allow"
|
|
56
55
|
assert decision["updatedInput"] == {
|
|
57
56
|
**SPAWN_INPUT,
|
|
58
|
-
"model": "
|
|
59
|
-
"effort": "
|
|
60
|
-
"instructions": f"Do the example task.\n\n{FABLE_ADVISOR_LINE}",
|
|
57
|
+
"model": "opus",
|
|
58
|
+
"effort": "low",
|
|
61
59
|
}
|
|
62
60
|
|
|
63
61
|
|
|
@@ -91,27 +89,15 @@ def test_should_reshape_the_spawn_when_the_pace_script_is_missing(
|
|
|
91
89
|
_assert_reshaped(_run_hook(tmp_path / "absent.py", SPAWN_INPUT))
|
|
92
90
|
|
|
93
91
|
|
|
94
|
-
def
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
"instructions": f"Do the example task.\n\n{FABLE_ADVISOR_LINE}",
|
|
98
|
-
}
|
|
99
|
-
stdout = _run_hook(
|
|
100
|
-
_write_pace_stub(tmp_path, 0, {"over_pace": True}), already_reshaped
|
|
101
|
-
)
|
|
102
|
-
assert (
|
|
103
|
-
_hook_specific_output(stdout)["updatedInput"]["instructions"].count(
|
|
104
|
-
FABLE_ADVISOR_LINE
|
|
105
|
-
)
|
|
106
|
-
== 1
|
|
107
|
-
)
|
|
92
|
+
def test_should_name_no_sonnet_model_in_the_reshaped_spawn(tmp_path: Path) -> None:
|
|
93
|
+
stdout = _run_hook(_write_pace_stub(tmp_path, 0, {"over_pace": True}), SPAWN_INPUT)
|
|
94
|
+
assert "sonnet" not in stdout.lower()
|
|
108
95
|
|
|
109
96
|
|
|
110
97
|
@pytest.mark.parametrize(
|
|
111
98
|
("tool_input", "reason"),
|
|
112
99
|
[
|
|
113
100
|
(["not", "an", "object"], "start_thread_session input is not a JSON object"),
|
|
114
|
-
({"title": "No brief"}, "start_thread_session input has no instructions text"),
|
|
115
101
|
],
|
|
116
102
|
)
|
|
117
103
|
def test_should_deny_a_spawn_it_cannot_reshape(
|
|
@@ -123,17 +109,3 @@ def test_should_deny_a_spawn_it_cannot_reshape(
|
|
|
123
109
|
assert decision["permissionDecision"] == "deny"
|
|
124
110
|
assert decision["permissionDecisionReason"] == reason
|
|
125
111
|
assert "updatedInput" not in decision
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def test_should_deny_a_brief_the_advisor_line_would_push_past_the_byte_cap(
|
|
129
|
-
tmp_path: Path,
|
|
130
|
-
) -> None:
|
|
131
|
-
full_brief = {**SPAWN_INPUT, "instructions": "x" * 8100}
|
|
132
|
-
decision = _hook_specific_output(
|
|
133
|
-
_run_hook(_write_pace_stub(tmp_path, 0, {"over_pace": True}), full_brief)
|
|
134
|
-
)
|
|
135
|
-
assert decision["permissionDecision"] == "deny"
|
|
136
|
-
over_by = 8100 + 2 + len(FABLE_ADVISOR_LINE.encode("utf-8")) - 8192
|
|
137
|
-
assert decision["permissionDecisionReason"].endswith(
|
|
138
|
-
f"shorten the instructions by {over_by} bytes"
|
|
139
|
-
)
|
|
@@ -8,8 +8,7 @@ beside it, or the script ``COORDINATOR_USAGE_PACE_SCRIPT`` names.
|
|
|
8
8
|
|
|
9
9
|
usage under pace (exit 1) -> no output; the call runs unchanged
|
|
10
10
|
over pace (exit 0) or unreadable -> allow with updatedInput:
|
|
11
|
-
model "
|
|
12
|
-
instructions + the mandatory Fable advisor line
|
|
11
|
+
model "opus", effort "low"
|
|
13
12
|
input that cannot be reshaped -> deny with a one-line reason
|
|
14
13
|
|
|
15
14
|
``updatedInput`` is the whole tool input the call runs with, so it carries
|
|
@@ -42,16 +41,9 @@ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_s
|
|
|
42
41
|
from hooks_constants.thread_spawn_pace_hook_constants import (
|
|
43
42
|
ADDITIONAL_CONTEXT_KEY,
|
|
44
43
|
EFFORT_INPUT_KEY,
|
|
45
|
-
FABLE_ADVISOR_LINE,
|
|
46
|
-
INSTRUCTIONS_ENCODING,
|
|
47
|
-
INSTRUCTIONS_INPUT_KEY,
|
|
48
|
-
INSTRUCTIONS_MAXIMUM_BYTES,
|
|
49
|
-
INSTRUCTIONS_SEPARATOR,
|
|
50
44
|
LAUNCH_FAILURE_ERROR_TEMPLATE,
|
|
51
45
|
MODEL_INPUT_KEY,
|
|
52
|
-
NO_INSTRUCTIONS_REASON,
|
|
53
46
|
NOT_AN_OBJECT_REASON,
|
|
54
|
-
OVER_BYTE_CAP_REASON_TEMPLATE,
|
|
55
47
|
PACE_VERDICT_CONTEXT_MAXIMUM_CHARACTERS,
|
|
56
48
|
PERMISSION_DECISION_REASON_KEY,
|
|
57
49
|
PERMISSION_DENY,
|
|
@@ -72,55 +64,30 @@ from hooks_constants.usage_pace_constants import (
|
|
|
72
64
|
|
|
73
65
|
|
|
74
66
|
class SpawnNotReshapable(Exception):
|
|
75
|
-
"""The spawn input cannot carry the over-pace model
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def _instructions_with_advisor_line(instructions: str) -> str:
|
|
79
|
-
reshaped_instructions = (
|
|
80
|
-
instructions
|
|
81
|
-
if FABLE_ADVISOR_LINE in instructions
|
|
82
|
-
else f"{instructions}{INSTRUCTIONS_SEPARATOR}{FABLE_ADVISOR_LINE}"
|
|
83
|
-
)
|
|
84
|
-
reshaped_byte_count = len(reshaped_instructions.encode(INSTRUCTIONS_ENCODING))
|
|
85
|
-
if reshaped_byte_count > INSTRUCTIONS_MAXIMUM_BYTES:
|
|
86
|
-
raise SpawnNotReshapable(
|
|
87
|
-
OVER_BYTE_CAP_REASON_TEMPLATE.format(
|
|
88
|
-
reshaped_byte_count=reshaped_byte_count,
|
|
89
|
-
maximum_bytes=INSTRUCTIONS_MAXIMUM_BYTES,
|
|
90
|
-
excess_byte_count=reshaped_byte_count - INSTRUCTIONS_MAXIMUM_BYTES,
|
|
91
|
-
)
|
|
92
|
-
)
|
|
93
|
-
return reshaped_instructions
|
|
67
|
+
"""The spawn input cannot carry the over-pace model and effort."""
|
|
94
68
|
|
|
95
69
|
|
|
96
70
|
def reshape_thread_spawn(tool_input: object) -> dict[str, object]:
|
|
97
|
-
"""Move a thread spawn to
|
|
71
|
+
"""Move a thread spawn to the latest Opus at low effort.
|
|
98
72
|
|
|
99
73
|
::
|
|
100
74
|
|
|
101
75
|
{"title": "t", "instructions": "Do X."}
|
|
102
|
-
-> {"title": "t", "instructions": "Do X
|
|
103
|
-
"model": "
|
|
104
|
-
|
|
105
|
-
An instructions text that already carries the advisor line keeps one copy.
|
|
76
|
+
-> {"title": "t", "instructions": "Do X.",
|
|
77
|
+
"model": "opus", "effort": "low"}
|
|
106
78
|
|
|
107
79
|
Args:
|
|
108
80
|
tool_input: The ``tool_input`` object of the start_thread_session call.
|
|
109
81
|
|
|
110
82
|
Raises:
|
|
111
|
-
SpawnNotReshapable: The input is not an object
|
|
112
|
-
text, or would pass the server's instructions byte cap.
|
|
83
|
+
SpawnNotReshapable: The input is not an object.
|
|
113
84
|
"""
|
|
114
85
|
if not isinstance(tool_input, dict):
|
|
115
86
|
raise SpawnNotReshapable(NOT_AN_OBJECT_REASON)
|
|
116
|
-
instructions = tool_input.get(INSTRUCTIONS_INPUT_KEY)
|
|
117
|
-
if not isinstance(instructions, str):
|
|
118
|
-
raise SpawnNotReshapable(NO_INSTRUCTIONS_REASON)
|
|
119
87
|
return {
|
|
120
88
|
**tool_input,
|
|
121
89
|
MODEL_INPUT_KEY: THREAD_MODEL_WHEN_OVER_PACE,
|
|
122
90
|
EFFORT_INPUT_KEY: THREAD_EFFORT_WHEN_OVER_PACE,
|
|
123
|
-
INSTRUCTIONS_INPUT_KEY: _instructions_with_advisor_line(instructions),
|
|
124
91
|
}
|
|
125
92
|
|
|
126
93
|
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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:
|