claude-dev-env 2.9.0 → 2.10.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/CLAUDE.md +1 -1
- package/_shared/pr-loop/CLAUDE.md +1 -1
- package/_shared/pr-loop/audit-contract.md +17 -6
- package/_shared/pr-loop/audit-reply-template.md +4 -4
- package/_shared/pr-loop/code-rules-gate.md +3 -5
- package/_shared/pr-loop/fix-protocol.md +2 -3
- package/_shared/pr-loop/gh-payloads.md +1 -1
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/README.md +1 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +16 -1
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +11 -5
- package/_shared/pr-loop/scripts/preflight.py +9 -4
- package/_shared/pr-loop/scripts/reviews_disabled.py +50 -22
- package/_shared/pr-loop/scripts/tests/conftest.py +20 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +6 -6
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +50 -6
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
- package/_shared/pr-loop/state-schema.md +5 -14
- package/agents/CLAUDE.md +2 -2
- package/agents/clean-coder.md +58 -548
- package/agents/code-quality-agent.md +10 -2
- package/agents/code-verifier.md +1 -1
- package/agents/test_agent_frontmatter.py +32 -40
- package/audit-rubrics/CLAUDE.md +2 -1
- package/audit-rubrics/audit-categories.json +704 -0
- package/audit-rubrics/prompts/category-i-concurrency.md +1 -1
- package/bin/CLAUDE.md +16 -5
- package/bin/ever-shipped-skills.mjs +2 -0
- package/bin/install-plan.mjs +402 -0
- package/bin/install-transaction.mjs +455 -0
- package/bin/install.mjs +593 -147
- package/bin/install.plan.test.mjs +194 -0
- package/bin/install.profile-root.test.mjs +154 -0
- package/bin/install.profiles.test.mjs +253 -0
- package/bin/install.settings-defaults.test.mjs +200 -0
- package/bin/install.transaction.test.mjs +400 -0
- package/bin/install.uninstall-transaction.test.mjs +418 -0
- package/bin/merge_managed_permissions.mjs +130 -0
- package/bin/resolve-install-root.mjs +181 -0
- package/bin/select-install-targets.mjs +401 -0
- package/commands/CLAUDE.md +0 -2
- package/docs/references/CLAUDE.md +1 -0
- package/docs/references/advisor-tool.md +1 -1
- package/docs/references/prose-style-enforcement.md +25 -0
- package/hooks/blocking/CLAUDE.md +6 -6
- package/hooks/blocking/_path_setup.py +9 -5
- package/hooks/blocking/code_rules_docstrings.py +124 -30
- package/hooks/blocking/code_rules_enforcer.py +161 -16
- package/hooks/blocking/code_rules_shared.py +40 -23
- package/hooks/blocking/config/CLAUDE.md +3 -5
- package/hooks/blocking/config/prose_style_enforcement_constants.py +38 -0
- package/hooks/blocking/config/test_prose_style_enforcement_constants.py +45 -0
- package/hooks/blocking/eli11_reply_enforcer.py +70 -113
- package/hooks/blocking/hedging_language_blocker.py +103 -20
- package/hooks/blocking/hook_prose_detector_consistency.py +6 -0
- package/hooks/blocking/intent_only_ending_blocker.py +6 -0
- package/hooks/blocking/plain_language_blocker.py +139 -20
- package/hooks/blocking/pre_tool_use_dispatcher.py +102 -20
- package/hooks/blocking/state_description_blocker.py +7 -1
- package/hooks/blocking/tdd_enforcer.py +8 -0
- package/hooks/blocking/test__path_setup.py +28 -0
- package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +99 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +232 -10
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +41 -0
- package/hooks/blocking/test_code_rules_enforcer_string_magic.py +98 -0
- package/hooks/blocking/test_eli11_reply_enforcer.py +98 -165
- package/hooks/blocking/test_hedging_language_blocker.py +120 -1
- package/hooks/blocking/test_hook_prose_detector_consistency.py +28 -8
- package/hooks/blocking/test_intent_only_ending_blocker.py +27 -2
- package/hooks/blocking/test_package_inventory_stale_blocker.py +11 -4
- package/hooks/blocking/test_plain_language_blocker.py +129 -19
- package/hooks/blocking/test_plain_language_blocker_allowlist.py +70 -26
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +99 -26
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +87 -50
- package/hooks/blocking/test_state_description_blocker.py +45 -2
- package/hooks/blocking/test_stop_dispatcher.py +11 -7
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +12 -12
- package/hooks/blocking/volatile_path_in_post_blocker.py +2 -2
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/CLAUDE.md +14 -3
- package/hooks/hooks_constants/ask_user_question_shape.py +281 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -1
- package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +5 -12
- package/hooks/hooks_constants/hedging_uncertainty_constants.py +42 -0
- package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +23 -0
- package/hooks/hooks_constants/orchestrator_auto_starter_constants.py +23 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +4 -1
- package/hooks/hooks_constants/plain_language_blocker_constants.py +4 -1
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +6 -0
- package/hooks/hooks_constants/project_paths_reader.py +31 -4
- package/hooks/hooks_constants/prose_matcher_precision_constants.py +40 -0
- package/hooks/hooks_constants/pytest_invocation.py +354 -0
- package/hooks/hooks_constants/session_start_injector.py +163 -0
- package/hooks/hooks_constants/session_start_injector_constants.py +46 -0
- package/hooks/hooks_constants/shell_command_pipeline.py +397 -0
- package/hooks/hooks_constants/shell_command_segments.py +5 -0
- package/hooks/hooks_constants/test_ask_user_question_shape.py +167 -0
- package/hooks/hooks_constants/test_project_paths_reader.py +29 -0
- package/hooks/hooks_constants/test_prose_metrics_parity.py +8 -0
- package/hooks/hooks_constants/test_pytest_invocation.py +130 -0
- package/hooks/hooks_constants/test_session_start_injector.py +168 -0
- package/hooks/hooks_constants/test_shell_command_pipeline.py +135 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +1 -1
- package/hooks/hooks_constants/working_style_prompt_constants.py +30 -0
- package/hooks/observability/CLAUDE.md +2 -0
- package/hooks/observability/prose_matcher_advisory.py +237 -0
- package/hooks/observability/test_prose_matcher_advisory.py +143 -0
- package/hooks/session/CLAUDE.md +9 -1
- package/hooks/session/_path_setup.py +13 -0
- package/hooks/session/issue_tracker_session_starter.py +135 -0
- package/hooks/session/orchestrator_auto_starter.py +100 -0
- package/hooks/session/test__path_setup.py +28 -0
- package/hooks/session/test_issue_tracker_session_starter.py +104 -0
- package/hooks/session/test_orchestrator_auto_starter.py +99 -0
- package/hooks/session/test_working_style_prompt.py +47 -0
- package/hooks/session/untracked_repo_detector.py +1 -24
- package/hooks/session/working_style_prompt.py +36 -0
- package/hooks/validators/_path_setup.py +19 -0
- package/hooks/validators/run_all_validators.py +8 -13
- package/installable-surfaces.manifest.json +21 -0
- package/output-styles/CLAUDE.md +1 -3
- package/package.json +4 -2
- package/rules/CLAUDE.md +1 -0
- package/rules/durable-post-artifacts.md +2 -2
- package/rules/eli11-replies.md +6 -1
- package/rules/hedging-claims.md +4 -2
- package/rules/long-horizon-autonomy.md +3 -1
- package/rules/opus5-communication-contract.md +45 -0
- package/rules/plain-language.md +2 -2
- package/rules/research-mode.md +1 -1
- package/scripts/CLAUDE.md +11 -0
- package/scripts/Sync-RepoMain.ps1 +215 -0
- package/scripts/active_capability_references.py +218 -0
- package/scripts/ci/windows-installer-lifecycle.ps1 +78 -0
- package/scripts/claude_chain_runner.py +394 -6
- package/scripts/claude_chain_usage.py +1 -1
- package/scripts/codex_compat_materializer.py +105 -85
- package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -0
- package/scripts/dev_env_scripts_constants/active_capability_constants.py +46 -0
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +74 -0
- package/scripts/dev_env_scripts_constants/verify_installable_package_constants.py +116 -0
- package/scripts/profile-isolation-launchers/config/mcp-bundles.json +25 -0
- package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +60 -0
- package/scripts/profile-isolation-launchers/config/profiles.manifest.json +54 -0
- package/scripts/profile-isolation-launchers/config/shared-allowlist.json +64 -0
- package/scripts/profile-isolation-launchers/launcher-runtime.mjs +180 -0
- package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +288 -0
- package/scripts/profile-isolation-launchers/mcp-bundles.mjs +275 -0
- package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +221 -0
- package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +108 -0
- package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +147 -0
- package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +102 -0
- package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +210 -0
- package/scripts/profile-isolation-launchers/version-compatibility.mjs +299 -0
- package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +127 -0
- package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +51 -0
- package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +77 -0
- package/scripts/spawn_grok_batch.py +3 -0
- package/scripts/test_active_capability_references.py +108 -0
- package/scripts/test_claude_chain_runner.py +414 -82
- package/scripts/test_claude_chain_usage.py +12 -12
- package/scripts/test_resolve_worker_spawn.py +2 -2
- package/scripts/test_verify_installable_package.py +208 -0
- package/scripts/tests/test_codex_compat_materializer.py +33 -0
- package/scripts/verify_installable_package.py +612 -0
- package/settings.json +10 -0
- package/skills/CLAUDE.md +2 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/audit_category_schema.py +355 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/audit_category_schema_constants.py +32 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +7 -19
- package/skills/_shared/pr-loop/scripts/test_audit_category_schema.py +94 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +21 -0
- package/skills/autoconverge/reference/convergence.md +2 -1
- package/skills/autoconverge/reference/stop-conditions.md +5 -3
- package/skills/beat-sheet/SKILL.md +54 -0
- package/skills/beat-sheet/reference/visual-beats.md +29 -0
- package/skills/bugteam/CONSTRAINTS.md +4 -4
- package/skills/bugteam/EXAMPLES.md +1 -1
- package/skills/bugteam/reference/README.md +1 -1
- package/skills/e-code-review/SKILL.md +26 -5
- package/skills/e-code-review/reference/effort-evaluation.md +35 -0
- package/skills/e-code-review/reference/medium.md +15 -4
- package/skills/e-code-review/scripts/config/e_code_review_effort_constants/__init__.py +41 -0
- package/skills/e-code-review/scripts/config/e_code_review_effort_constants/effort_constants.py +40 -0
- package/skills/e-code-review/scripts/e_code_review_scripts_constants/finding_pipeline_constants.py +49 -0
- package/skills/e-code-review/scripts/effort_defaults_evidence.json +186 -0
- package/skills/e-code-review/scripts/effort_evaluation.py +362 -0
- package/skills/e-code-review/scripts/finding_pipeline.py +140 -0
- package/skills/e-code-review/scripts/fixtures/demanding.json +26 -0
- package/skills/e-code-review/scripts/fixtures/easy.json +14 -0
- package/skills/e-code-review/scripts/fixtures/medium.json +20 -0
- package/skills/e-code-review/scripts/grok_code_review.py +16 -7
- package/skills/e-code-review/scripts/test_effort_evaluation.py +180 -0
- package/skills/e-code-review/scripts/test_finding_pipeline.py +197 -0
- package/skills/e-code-review/scripts/test_grok_code_review.py +77 -0
- package/skills/plan-to-pr/scripts/create_packet.py +4 -4
- package/skills/plan-to-pr/scripts/load_skill_constants.py +41 -0
- package/skills/plan-to-pr/scripts/validate_packet.py +4 -4
- package/skills/plan-to-pr/scripts/validate_protocol.py +4 -1
- package/skills/plan-to-pr/scripts/validate_run.py +4 -1
- package/skills/pr-converge/scripts/check_convergence.py +21 -19
- package/skills/pr-converge/scripts/check_convergence_availability.py +50 -7
- package/skills/pr-converge/scripts/conftest.py +35 -0
- package/skills/pr-converge/scripts/test_check_convergence_availability.py +65 -0
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +11 -1
- package/skills/pr-converge/scripts/test_check_convergence_contract.py +9 -2
- package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
- package/skills/rebase/SKILL.md +15 -3
- package/skills/reviewer-gates/SKILL.md +2 -2
- package/skills/show/SKILL.md +51 -0
- package/skills/show/references/accessibility.md +7 -0
- package/skills/show/references/art.md +3 -0
- package/skills/show/references/charts.md +3 -0
- package/skills/show/references/core-design.md +14 -0
- package/skills/show/references/erds.md +3 -0
- package/skills/show/references/flowcharts.md +3 -0
- package/skills/show/references/host-and-html.md +3 -0
- package/skills/show/references/illustrative-diagrams.md +10 -0
- package/skills/show/references/interaction.md +3 -0
- package/skills/show/references/mockups.md +3 -0
- package/skills/show/references/quality-gates.md +7 -0
- package/skills/show/references/structural-diagrams.md +3 -0
- package/skills/show/references/subject-inventory.md +21 -0
- package/skills/show/references/svg-contract.md +22 -0
- package/skills/show/routing.yaml +30 -0
- package/skills/show/samples/pr1262-v2.svg +222 -0
- package/skills/show/scripts/README.md +6 -0
- package/skills/show/scripts/validate-artifact.py +91 -0
- package/skills/show/scripts/validate-package.py +18 -0
- package/skills/show/templates/html-widget.html +4 -0
- package/skills/show/templates/svg-base.svg +19 -0
- package/skills/show/tests/fixtures/css-var.svg +6 -0
- package/skills/show/tests/fixtures/dead-ref.svg +7 -0
- package/skills/show/tests/fixtures/filled-glyph.svg +8 -0
- package/skills/show/tests/fixtures/inherited-fill.svg +18 -0
- package/skills/show/tests/fixtures/invalid.svg +1 -0
- package/skills/show/tests/fixtures/large-canvas.svg +21 -0
- package/skills/show/tests/fixtures/unfilled-connector.svg +15 -0
- package/skills/show/tests/fixtures/valid.html +1 -0
- package/skills/show/tests/test_validate-artifact.py +74 -0
- package/skills/show/tests/test_validators.py +59 -0
- package/skills/show/workflows/create-visual.md +13 -0
- package/skills/show/workflows/review-visual.md +20 -0
- package/skills/split-pr/SKILL.md +85 -0
- package/skills/split-pr/reference/path-layers.md +16 -0
- package/skills/split-pr/reference/proposal-format.md +15 -0
- package/skills/split-pr/reference/split-further-loop.md +10 -0
- package/skills/split-pr/reference/splitting-principles.md +26 -0
- package/skills/split-pr/scripts/analyze_pr.py +279 -0
- package/skills/split-pr/scripts/categorize_files.py +106 -0
- package/skills/split-pr/scripts/config/__init__.py +1 -0
- package/skills/split-pr/scripts/config/dependency_constants.py +14 -0
- package/skills/split-pr/scripts/config/git_operations_constants.py +36 -0
- package/skills/split-pr/scripts/config/packing_constants.py +61 -0
- package/skills/split-pr/scripts/config/plan_constants.py +49 -0
- package/skills/split-pr/scripts/config/split_pr_constants.py +110 -0
- package/skills/split-pr/scripts/execute_split_slices.py +82 -0
- package/skills/split-pr/scripts/pack_files_into_slices.py +212 -0
- package/skills/split-pr/scripts/split_pr_dependency_graph.py +70 -0
- package/skills/split-pr/scripts/split_pr_git_operations.py +184 -0
- package/skills/split-pr/scripts/split_pr_layer_order.py +58 -0
- package/skills/split-pr/scripts/split_pr_paginate.py +119 -0
- package/skills/split-pr/scripts/split_pr_process_runner.py +52 -0
- package/skills/split-pr/scripts/split_pr_script_types.py +126 -0
- package/skills/split-pr/scripts/split_pr_title.py +41 -0
- package/skills/split-pr/scripts/test_analyze_pr.py +228 -0
- package/skills/split-pr/scripts/test_categorize_files.py +55 -0
- package/skills/split-pr/scripts/test_categorize_files_packing.py +59 -0
- package/skills/split-pr/scripts/test_execute_split_slices.py +99 -0
- package/skills/split-pr/scripts/test_split_pr_dependency_graph.py +47 -0
- package/skills/split-pr/scripts/test_split_pr_git_operations.py +125 -0
- package/skills/split-pr/scripts/test_split_pr_layer_order.py +36 -0
- package/skills/split-pr/scripts/test_split_pr_paginate.py +65 -0
- package/skills/split-pr/scripts/test_split_pr_script_types.py +73 -0
- package/skills/split-pr/scripts/test_split_pr_title.py +28 -0
- package/skills/split-pr/scripts/test_verify_dependency_graph.py +46 -0
- package/skills/split-pr/scripts/test_verify_plan.py +56 -0
- package/skills/split-pr/scripts/test_verify_plan_contract.py +50 -0
- package/skills/split-pr/scripts/test_verify_plan_path_normalization.py +45 -0
- package/skills/split-pr/scripts/verify_dependency_graph.py +111 -0
- package/skills/split-pr/scripts/verify_plan.py +139 -0
- package/system-prompts/software-engineer.xml +11 -2
- package/commands/initialize.md +0 -90
- package/commands/stubcheck.md +0 -88
- package/output-styles/caveman-agent.md +0 -37
|
@@ -15,7 +15,25 @@ if str(_SCRIPTS_DIR) not in sys.path:
|
|
|
15
15
|
|
|
16
16
|
import claude_chain_runner as runner # noqa: E402
|
|
17
17
|
import claude_chain_usage as chain_usage # noqa: E402
|
|
18
|
+
from claude_chain_runner import ( # noqa: E402
|
|
19
|
+
ChainEntry,
|
|
20
|
+
default_affinity_state_path,
|
|
21
|
+
extract_resume_session_id,
|
|
22
|
+
load_affinity_store,
|
|
23
|
+
lookup_affinity_command,
|
|
24
|
+
order_entries_for_resume,
|
|
25
|
+
record_affinity_binding,
|
|
26
|
+
save_affinity_store_atomic,
|
|
27
|
+
)
|
|
18
28
|
from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
|
|
29
|
+
AFFINITY_KEY_ALL_BINDINGS,
|
|
30
|
+
AFFINITY_KEY_COMMAND,
|
|
31
|
+
AFFINITY_KEY_SCHEMA_VERSION,
|
|
32
|
+
AFFINITY_KEY_SESSION_ID,
|
|
33
|
+
AFFINITY_MAXIMUM_ENTRIES,
|
|
34
|
+
AFFINITY_STATE_FILENAME,
|
|
35
|
+
AFFINITY_STATE_SCHEMA_VERSION,
|
|
36
|
+
RESUME_SESSION_FLAG,
|
|
19
37
|
ALL_USAGE_LIMIT_SIGNATURES,
|
|
20
38
|
ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
|
|
21
39
|
ATTEMPT_STATUS_NONZERO_EXIT,
|
|
@@ -183,6 +201,12 @@ def _install(
|
|
|
183
201
|
else _config_order_usage_reporter
|
|
184
202
|
)
|
|
185
203
|
monkeypatch.setattr(runner, "chain_weekly_usage_reporter", active_reporter)
|
|
204
|
+
affinity_path = Path(config_file).parent / AFFINITY_STATE_FILENAME
|
|
205
|
+
monkeypatch.setattr(
|
|
206
|
+
runner,
|
|
207
|
+
"default_affinity_state_path",
|
|
208
|
+
lambda _home, _affinity_path=affinity_path: _affinity_path,
|
|
209
|
+
)
|
|
186
210
|
_install_tty_stdin(monkeypatch)
|
|
187
211
|
return recorder
|
|
188
212
|
|
|
@@ -190,11 +214,11 @@ def _install(
|
|
|
190
214
|
def test_highest_weekly_remaining_is_tried_first(
|
|
191
215
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
192
216
|
) -> None:
|
|
193
|
-
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-
|
|
217
|
+
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
|
|
194
218
|
usage_reporter = _usage_reporter_from_remaining(
|
|
195
219
|
{
|
|
196
220
|
"claude": _LOW_WEEKLY_REMAINING_PERCENT,
|
|
197
|
-
"claude-
|
|
221
|
+
"claude-profile-c": _HIGH_WEEKLY_REMAINING_PERCENT,
|
|
198
222
|
}
|
|
199
223
|
)
|
|
200
224
|
recorder = _install(
|
|
@@ -202,17 +226,17 @@ def test_highest_weekly_remaining_is_tried_first(
|
|
|
202
226
|
config_file,
|
|
203
227
|
{
|
|
204
228
|
"claude": _completed("claude", 0, stdout="from-claude"),
|
|
205
|
-
"claude-
|
|
229
|
+
"claude-profile-c": _completed("claude-profile-c", 0, stdout="from-profile-c"),
|
|
206
230
|
},
|
|
207
231
|
weekly_usage_reporter=usage_reporter,
|
|
208
232
|
)
|
|
209
233
|
chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
|
|
210
|
-
assert chain_result.served_command == "claude-
|
|
234
|
+
assert chain_result.served_command == "claude-profile-c"
|
|
211
235
|
assert chain_result.returncode == 0
|
|
212
|
-
assert chain_result.stdout == "from-
|
|
213
|
-
assert recorder.invocations[0][0] == "claude-
|
|
236
|
+
assert chain_result.stdout == "from-profile-c"
|
|
237
|
+
assert recorder.invocations[0][0] == "claude-profile-c"
|
|
214
238
|
assert [each_attempt.command for each_attempt in chain_result.attempts] == [
|
|
215
|
-
"claude-
|
|
239
|
+
"claude-profile-c"
|
|
216
240
|
]
|
|
217
241
|
assert chain_result.attempts[0].status == ATTEMPT_STATUS_SERVED
|
|
218
242
|
|
|
@@ -221,22 +245,22 @@ def test_usage_limit_failsover_remaining_ranked_accounts(
|
|
|
221
245
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
222
246
|
) -> None:
|
|
223
247
|
config_file = _write_chain_config(
|
|
224
|
-
tmp_path, [_entry("claude"), _entry("claude-
|
|
248
|
+
tmp_path, [_entry("claude"), _entry("claude-profile-c"), _entry("claude-profile-a")]
|
|
225
249
|
)
|
|
226
250
|
usage_reporter = _usage_reporter_from_remaining(
|
|
227
251
|
{
|
|
228
252
|
"claude": _MID_WEEKLY_REMAINING_PERCENT,
|
|
229
|
-
"claude-
|
|
230
|
-
"claude-
|
|
253
|
+
"claude-profile-c": _HIGH_WEEKLY_REMAINING_PERCENT,
|
|
254
|
+
"claude-profile-a": _LOW_WEEKLY_REMAINING_PERCENT,
|
|
231
255
|
}
|
|
232
256
|
)
|
|
233
257
|
_install(
|
|
234
258
|
monkeypatch,
|
|
235
259
|
config_file,
|
|
236
260
|
{
|
|
237
|
-
"claude-
|
|
261
|
+
"claude-profile-c": _completed("claude-profile-c", 1, stderr=_A_SIGNATURE),
|
|
238
262
|
"claude": _completed("claude", 0, stdout="ok"),
|
|
239
|
-
"claude-
|
|
263
|
+
"claude-profile-a": _completed("claude-profile-a", 0, stdout="should not run"),
|
|
240
264
|
},
|
|
241
265
|
weekly_usage_reporter=usage_reporter,
|
|
242
266
|
)
|
|
@@ -244,7 +268,7 @@ def test_usage_limit_failsover_remaining_ranked_accounts(
|
|
|
244
268
|
assert chain_result.served_command == "claude"
|
|
245
269
|
assert chain_result.returncode == 0
|
|
246
270
|
assert [each_attempt.command for each_attempt in chain_result.attempts] == [
|
|
247
|
-
"claude-
|
|
271
|
+
"claude-profile-c",
|
|
248
272
|
"claude",
|
|
249
273
|
]
|
|
250
274
|
assert chain_result.attempts[0].status == ATTEMPT_STATUS_USAGE_LIMITED
|
|
@@ -254,24 +278,24 @@ def test_usage_limit_failsover_remaining_ranked_accounts(
|
|
|
254
278
|
def test_non_usage_error_on_highest_ranked_stops_without_rest(
|
|
255
279
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
256
280
|
) -> None:
|
|
257
|
-
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-
|
|
281
|
+
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
|
|
258
282
|
usage_reporter = _usage_reporter_from_remaining(
|
|
259
283
|
{
|
|
260
284
|
"claude": _LOW_WEEKLY_REMAINING_PERCENT,
|
|
261
|
-
"claude-
|
|
285
|
+
"claude-profile-c": _HIGH_WEEKLY_REMAINING_PERCENT,
|
|
262
286
|
}
|
|
263
287
|
)
|
|
264
288
|
_install(
|
|
265
289
|
monkeypatch,
|
|
266
290
|
config_file,
|
|
267
291
|
{
|
|
268
|
-
"claude-
|
|
292
|
+
"claude-profile-c": _completed("claude-profile-c", 2, stderr="unknown flag"),
|
|
269
293
|
"claude": _completed("claude", 0, stdout="should not run"),
|
|
270
294
|
},
|
|
271
295
|
weekly_usage_reporter=usage_reporter,
|
|
272
296
|
)
|
|
273
297
|
chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
|
|
274
|
-
assert chain_result.served_command == "claude-
|
|
298
|
+
assert chain_result.served_command == "claude-profile-c"
|
|
275
299
|
assert chain_result.returncode == 2
|
|
276
300
|
assert len(chain_result.attempts) == 1
|
|
277
301
|
assert chain_result.attempts[0].status == ATTEMPT_STATUS_NONZERO_EXIT
|
|
@@ -280,11 +304,11 @@ def test_non_usage_error_on_highest_ranked_stops_without_rest(
|
|
|
280
304
|
def test_weekly_usage_probe_runs_once_per_run_claude(
|
|
281
305
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
282
306
|
) -> None:
|
|
283
|
-
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-
|
|
307
|
+
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
|
|
284
308
|
usage_reporter = _usage_reporter_from_remaining(
|
|
285
309
|
{
|
|
286
310
|
"claude": _HIGH_WEEKLY_REMAINING_PERCENT,
|
|
287
|
-
"claude-
|
|
311
|
+
"claude-profile-c": _LOW_WEEKLY_REMAINING_PERCENT,
|
|
288
312
|
}
|
|
289
313
|
)
|
|
290
314
|
_install(
|
|
@@ -292,7 +316,7 @@ def test_weekly_usage_probe_runs_once_per_run_claude(
|
|
|
292
316
|
config_file,
|
|
293
317
|
{
|
|
294
318
|
"claude": _completed("claude", 1, stderr=_A_SIGNATURE),
|
|
295
|
-
"claude-
|
|
319
|
+
"claude-profile-c": _completed("claude-profile-c", 0, stdout="ok"),
|
|
296
320
|
},
|
|
297
321
|
weekly_usage_reporter=usage_reporter,
|
|
298
322
|
)
|
|
@@ -303,7 +327,7 @@ def test_weekly_usage_probe_runs_once_per_run_claude(
|
|
|
303
327
|
def test_usage_reporter_import_failure_falls_back_to_config_order(
|
|
304
328
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
305
329
|
) -> None:
|
|
306
|
-
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-
|
|
330
|
+
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
|
|
307
331
|
|
|
308
332
|
def broken_usage_reporter(
|
|
309
333
|
*, config_path: Path
|
|
@@ -315,7 +339,7 @@ def test_usage_reporter_import_failure_falls_back_to_config_order(
|
|
|
315
339
|
config_file,
|
|
316
340
|
{
|
|
317
341
|
"claude": _completed("claude", 0, stdout="from-claude"),
|
|
318
|
-
"claude-
|
|
342
|
+
"claude-profile-c": _completed("claude-profile-c", 0, stdout="from-profile-c"),
|
|
319
343
|
},
|
|
320
344
|
weekly_usage_reporter=broken_usage_reporter,
|
|
321
345
|
)
|
|
@@ -332,18 +356,18 @@ def test_usage_reporter_import_failure_falls_back_to_config_order(
|
|
|
332
356
|
def test_missing_high_remaining_binary_falls_through_to_lower_remaining(
|
|
333
357
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
334
358
|
) -> None:
|
|
335
|
-
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-
|
|
359
|
+
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
|
|
336
360
|
usage_reporter = _usage_reporter_from_remaining(
|
|
337
361
|
{
|
|
338
362
|
"claude": _LOW_WEEKLY_REMAINING_PERCENT,
|
|
339
|
-
"claude-
|
|
363
|
+
"claude-profile-c": _HIGH_WEEKLY_REMAINING_PERCENT,
|
|
340
364
|
}
|
|
341
365
|
)
|
|
342
366
|
_install(
|
|
343
367
|
monkeypatch,
|
|
344
368
|
config_file,
|
|
345
369
|
{
|
|
346
|
-
"claude-
|
|
370
|
+
"claude-profile-c": FileNotFoundError(),
|
|
347
371
|
"claude": _completed("claude", 0, stdout="from-claude"),
|
|
348
372
|
},
|
|
349
373
|
weekly_usage_reporter=usage_reporter,
|
|
@@ -353,7 +377,7 @@ def test_missing_high_remaining_binary_falls_through_to_lower_remaining(
|
|
|
353
377
|
assert chain_result.returncode == 0
|
|
354
378
|
assert chain_result.stdout == "from-claude"
|
|
355
379
|
assert [each_attempt.command for each_attempt in chain_result.attempts] == [
|
|
356
|
-
"claude-
|
|
380
|
+
"claude-profile-c",
|
|
357
381
|
"claude",
|
|
358
382
|
]
|
|
359
383
|
assert [each_attempt.status for each_attempt in chain_result.attempts] == [
|
|
@@ -365,18 +389,18 @@ def test_missing_high_remaining_binary_falls_through_to_lower_remaining(
|
|
|
365
389
|
def test_all_missing_binaries_exhausts_chain(
|
|
366
390
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
367
391
|
) -> None:
|
|
368
|
-
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-
|
|
392
|
+
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
|
|
369
393
|
usage_reporter = _usage_reporter_from_remaining(
|
|
370
394
|
{
|
|
371
395
|
"claude": _LOW_WEEKLY_REMAINING_PERCENT,
|
|
372
|
-
"claude-
|
|
396
|
+
"claude-profile-c": _HIGH_WEEKLY_REMAINING_PERCENT,
|
|
373
397
|
}
|
|
374
398
|
)
|
|
375
399
|
_install(
|
|
376
400
|
monkeypatch,
|
|
377
401
|
config_file,
|
|
378
402
|
{
|
|
379
|
-
"claude-
|
|
403
|
+
"claude-profile-c": FileNotFoundError(),
|
|
380
404
|
"claude": FileNotFoundError(),
|
|
381
405
|
},
|
|
382
406
|
weekly_usage_reporter=usage_reporter,
|
|
@@ -385,7 +409,7 @@ def test_all_missing_binaries_exhausts_chain(
|
|
|
385
409
|
assert chain_result.served_command is None
|
|
386
410
|
assert chain_result.returncode == NO_COMPLETED_PROCESS_RETURN_CODE
|
|
387
411
|
assert [each_attempt.command for each_attempt in chain_result.attempts] == [
|
|
388
|
-
"claude-
|
|
412
|
+
"claude-profile-c",
|
|
389
413
|
"claude",
|
|
390
414
|
]
|
|
391
415
|
assert [each_attempt.status for each_attempt in chain_result.attempts] == [
|
|
@@ -398,31 +422,31 @@ def test_missing_later_ranked_binary_is_skipped(
|
|
|
398
422
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
399
423
|
) -> None:
|
|
400
424
|
config_file = _write_chain_config(
|
|
401
|
-
tmp_path, [_entry("claude"), _entry("claude-
|
|
425
|
+
tmp_path, [_entry("claude"), _entry("claude-profile-c"), _entry("claude-profile-a")]
|
|
402
426
|
)
|
|
403
427
|
usage_reporter = _usage_reporter_from_remaining(
|
|
404
428
|
{
|
|
405
429
|
"claude": _MID_WEEKLY_REMAINING_PERCENT,
|
|
406
|
-
"claude-
|
|
407
|
-
"claude-
|
|
430
|
+
"claude-profile-c": _HIGH_WEEKLY_REMAINING_PERCENT,
|
|
431
|
+
"claude-profile-a": _LOW_WEEKLY_REMAINING_PERCENT,
|
|
408
432
|
}
|
|
409
433
|
)
|
|
410
434
|
_install(
|
|
411
435
|
monkeypatch,
|
|
412
436
|
config_file,
|
|
413
437
|
{
|
|
414
|
-
"claude-
|
|
438
|
+
"claude-profile-c": _completed("claude-profile-c", 1, stderr=_A_SIGNATURE),
|
|
415
439
|
"claude": FileNotFoundError(),
|
|
416
|
-
"claude-
|
|
440
|
+
"claude-profile-a": _completed("claude-profile-a", 0, stdout="done"),
|
|
417
441
|
},
|
|
418
442
|
weekly_usage_reporter=usage_reporter,
|
|
419
443
|
)
|
|
420
444
|
chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
|
|
421
|
-
assert chain_result.served_command == "claude-
|
|
445
|
+
assert chain_result.served_command == "claude-profile-a"
|
|
422
446
|
assert [each_attempt.command for each_attempt in chain_result.attempts] == [
|
|
423
|
-
"claude-
|
|
447
|
+
"claude-profile-c",
|
|
424
448
|
"claude",
|
|
425
|
-
"claude-
|
|
449
|
+
"claude-profile-a",
|
|
426
450
|
]
|
|
427
451
|
assert [each_attempt.status for each_attempt in chain_result.attempts] == [
|
|
428
452
|
ATTEMPT_STATUS_USAGE_LIMITED,
|
|
@@ -438,28 +462,28 @@ def test_ranked_walk_preserves_extra_args_for_mapped_entry(
|
|
|
438
462
|
tmp_path,
|
|
439
463
|
[
|
|
440
464
|
_entry("claude", extra_args=["--account", "primary"]),
|
|
441
|
-
_entry("claude-
|
|
465
|
+
_entry("claude-profile-c", extra_args=["--account", "profile-c"]),
|
|
442
466
|
],
|
|
443
467
|
)
|
|
444
468
|
usage_reporter = _usage_reporter_from_remaining(
|
|
445
469
|
{
|
|
446
470
|
"claude": _LOW_WEEKLY_REMAINING_PERCENT,
|
|
447
|
-
"claude-
|
|
471
|
+
"claude-profile-c": _HIGH_WEEKLY_REMAINING_PERCENT,
|
|
448
472
|
}
|
|
449
473
|
)
|
|
450
474
|
recorder = _install(
|
|
451
475
|
monkeypatch,
|
|
452
476
|
config_file,
|
|
453
|
-
{"claude-
|
|
477
|
+
{"claude-profile-c": _completed("claude-profile-c", 0)},
|
|
454
478
|
weekly_usage_reporter=usage_reporter,
|
|
455
479
|
)
|
|
456
480
|
runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
|
|
457
481
|
assert recorder.invocations[0] == [
|
|
458
|
-
"claude-
|
|
482
|
+
"claude-profile-c",
|
|
459
483
|
"-p",
|
|
460
484
|
"hello",
|
|
461
485
|
"--account",
|
|
462
|
-
"
|
|
486
|
+
"profile-c",
|
|
463
487
|
]
|
|
464
488
|
|
|
465
489
|
|
|
@@ -496,14 +520,14 @@ def test_duplicate_command_entries_are_both_walked(
|
|
|
496
520
|
def test_entry_absent_from_usage_report_is_still_walked(
|
|
497
521
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
498
522
|
) -> None:
|
|
499
|
-
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-
|
|
523
|
+
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
|
|
500
524
|
|
|
501
525
|
def partial_usage_reporter(
|
|
502
526
|
*, config_path: Path
|
|
503
527
|
) -> list[chain_usage.AccountUsageReport]:
|
|
504
528
|
return [
|
|
505
529
|
chain_usage.AccountUsageReport(
|
|
506
|
-
command="claude-
|
|
530
|
+
command="claude-profile-c",
|
|
507
531
|
weekly_remaining_percent=_HIGH_WEEKLY_REMAINING_PERCENT,
|
|
508
532
|
)
|
|
509
533
|
]
|
|
@@ -512,7 +536,7 @@ def test_entry_absent_from_usage_report_is_still_walked(
|
|
|
512
536
|
monkeypatch,
|
|
513
537
|
config_file,
|
|
514
538
|
{
|
|
515
|
-
"claude-
|
|
539
|
+
"claude-profile-c": _completed("claude-profile-c", 1, stderr=_A_SIGNATURE),
|
|
516
540
|
"claude": _completed("claude", 0, stdout="ok"),
|
|
517
541
|
},
|
|
518
542
|
weekly_usage_reporter=partial_usage_reporter,
|
|
@@ -520,7 +544,7 @@ def test_entry_absent_from_usage_report_is_still_walked(
|
|
|
520
544
|
chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
|
|
521
545
|
assert chain_result.served_command == "claude"
|
|
522
546
|
assert [each_attempt.command for each_attempt in chain_result.attempts] == [
|
|
523
|
-
"claude-
|
|
547
|
+
"claude-profile-c",
|
|
524
548
|
"claude",
|
|
525
549
|
]
|
|
526
550
|
|
|
@@ -528,22 +552,22 @@ def test_entry_absent_from_usage_report_is_still_walked(
|
|
|
528
552
|
def test_usage_limited_primary_falls_over_to_second(
|
|
529
553
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
530
554
|
) -> None:
|
|
531
|
-
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-
|
|
555
|
+
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
|
|
532
556
|
_install(
|
|
533
557
|
monkeypatch,
|
|
534
558
|
config_file,
|
|
535
559
|
{
|
|
536
560
|
"claude": _completed("claude", 1, stderr=_A_SIGNATURE),
|
|
537
|
-
"claude-
|
|
561
|
+
"claude-profile-c": _completed("claude-profile-c", 0, stdout="ok"),
|
|
538
562
|
},
|
|
539
563
|
)
|
|
540
564
|
chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
|
|
541
|
-
assert chain_result.served_command == "claude-
|
|
565
|
+
assert chain_result.served_command == "claude-profile-c"
|
|
542
566
|
assert chain_result.returncode == 0
|
|
543
567
|
assert chain_result.stdout == "ok"
|
|
544
568
|
assert [each_attempt.command for each_attempt in chain_result.attempts] == [
|
|
545
569
|
"claude",
|
|
546
|
-
"claude-
|
|
570
|
+
"claude-profile-c",
|
|
547
571
|
]
|
|
548
572
|
assert chain_result.attempts[0].status == ATTEMPT_STATUS_USAGE_LIMITED
|
|
549
573
|
assert chain_result.attempts[1].status == ATTEMPT_STATUS_SERVED
|
|
@@ -552,13 +576,13 @@ def test_usage_limited_primary_falls_over_to_second(
|
|
|
552
576
|
def test_nonzero_exit_without_signature_does_not_fall_over(
|
|
553
577
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
554
578
|
) -> None:
|
|
555
|
-
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-
|
|
579
|
+
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
|
|
556
580
|
_install(
|
|
557
581
|
monkeypatch,
|
|
558
582
|
config_file,
|
|
559
583
|
{
|
|
560
584
|
"claude": _completed("claude", 2, stderr="unknown flag"),
|
|
561
|
-
"claude-
|
|
585
|
+
"claude-profile-c": _completed("claude-profile-c", 0, stdout="should not run"),
|
|
562
586
|
},
|
|
563
587
|
)
|
|
564
588
|
chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
|
|
@@ -571,13 +595,13 @@ def test_nonzero_exit_without_signature_does_not_fall_over(
|
|
|
571
595
|
def test_timeout_on_primary_does_not_fall_over(
|
|
572
596
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
573
597
|
) -> None:
|
|
574
|
-
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-
|
|
598
|
+
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
|
|
575
599
|
_install(
|
|
576
600
|
monkeypatch,
|
|
577
601
|
config_file,
|
|
578
602
|
{
|
|
579
603
|
"claude": subprocess.TimeoutExpired(cmd=["claude"], timeout=5),
|
|
580
|
-
"claude-
|
|
604
|
+
"claude-profile-c": _completed("claude-profile-c", 0),
|
|
581
605
|
},
|
|
582
606
|
)
|
|
583
607
|
chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
|
|
@@ -591,22 +615,22 @@ def test_timeout_on_primary_does_not_fall_over(
|
|
|
591
615
|
def test_missing_primary_binary_falls_through_to_next(
|
|
592
616
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
593
617
|
) -> None:
|
|
594
|
-
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-
|
|
618
|
+
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
|
|
595
619
|
_install(
|
|
596
620
|
monkeypatch,
|
|
597
621
|
config_file,
|
|
598
622
|
{
|
|
599
623
|
"claude": FileNotFoundError(),
|
|
600
|
-
"claude-
|
|
624
|
+
"claude-profile-c": _completed("claude-profile-c", 0, stdout="from-profile-c"),
|
|
601
625
|
},
|
|
602
626
|
)
|
|
603
627
|
chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
|
|
604
|
-
assert chain_result.served_command == "claude-
|
|
628
|
+
assert chain_result.served_command == "claude-profile-c"
|
|
605
629
|
assert chain_result.returncode == 0
|
|
606
|
-
assert chain_result.stdout == "from-
|
|
630
|
+
assert chain_result.stdout == "from-profile-c"
|
|
607
631
|
assert [each_attempt.command for each_attempt in chain_result.attempts] == [
|
|
608
632
|
"claude",
|
|
609
|
-
"claude-
|
|
633
|
+
"claude-profile-c",
|
|
610
634
|
]
|
|
611
635
|
assert [each_attempt.status for each_attempt in chain_result.attempts] == [
|
|
612
636
|
ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
|
|
@@ -618,19 +642,19 @@ def test_missing_fallback_binary_is_skipped_and_walk_continues(
|
|
|
618
642
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
619
643
|
) -> None:
|
|
620
644
|
config_file = _write_chain_config(
|
|
621
|
-
tmp_path, [_entry("claude"), _entry("claude-
|
|
645
|
+
tmp_path, [_entry("claude"), _entry("claude-profile-c"), _entry("claude-profile-a")]
|
|
622
646
|
)
|
|
623
647
|
_install(
|
|
624
648
|
monkeypatch,
|
|
625
649
|
config_file,
|
|
626
650
|
{
|
|
627
651
|
"claude": _completed("claude", 1, stderr=_A_SIGNATURE),
|
|
628
|
-
"claude-
|
|
629
|
-
"claude-
|
|
652
|
+
"claude-profile-c": FileNotFoundError(),
|
|
653
|
+
"claude-profile-a": _completed("claude-profile-a", 0, stdout="done"),
|
|
630
654
|
},
|
|
631
655
|
)
|
|
632
656
|
chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
|
|
633
|
-
assert chain_result.served_command == "claude-
|
|
657
|
+
assert chain_result.served_command == "claude-profile-a"
|
|
634
658
|
assert [each_attempt.status for each_attempt in chain_result.attempts] == [
|
|
635
659
|
ATTEMPT_STATUS_USAGE_LIMITED,
|
|
636
660
|
ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
|
|
@@ -641,13 +665,13 @@ def test_missing_fallback_binary_is_skipped_and_walk_continues(
|
|
|
641
665
|
def test_zero_exit_mentioning_usage_limit_is_success(
|
|
642
666
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
643
667
|
) -> None:
|
|
644
|
-
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-
|
|
668
|
+
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
|
|
645
669
|
_install(
|
|
646
670
|
monkeypatch,
|
|
647
671
|
config_file,
|
|
648
672
|
{
|
|
649
673
|
"claude": _completed("claude", 0, stdout=f"note: {_A_SIGNATURE} earlier"),
|
|
650
|
-
"claude-
|
|
674
|
+
"claude-profile-c": _completed("claude-profile-c", 0),
|
|
651
675
|
},
|
|
652
676
|
)
|
|
653
677
|
chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
|
|
@@ -660,13 +684,13 @@ def test_zero_exit_mentioning_usage_limit_is_success(
|
|
|
660
684
|
def test_exhausted_chain_records_every_attempt(
|
|
661
685
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
662
686
|
) -> None:
|
|
663
|
-
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-
|
|
687
|
+
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
|
|
664
688
|
_install(
|
|
665
689
|
monkeypatch,
|
|
666
690
|
config_file,
|
|
667
691
|
{
|
|
668
692
|
"claude": _completed("claude", 1, stderr=_A_SIGNATURE),
|
|
669
|
-
"claude-
|
|
693
|
+
"claude-profile-c": _completed("claude-profile-c", 1, stderr=_A_SIGNATURE),
|
|
670
694
|
},
|
|
671
695
|
)
|
|
672
696
|
chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
|
|
@@ -683,12 +707,12 @@ def test_reordering_config_changes_walk_order(
|
|
|
683
707
|
) -> None:
|
|
684
708
|
behavior_by_command = {
|
|
685
709
|
"claude": _completed("claude", 0, stdout="from-claude"),
|
|
686
|
-
"claude-
|
|
710
|
+
"claude-profile-c": _completed("claude-profile-c", 0, stdout="from-profile-c"),
|
|
687
711
|
}
|
|
688
712
|
first_config = tmp_path / "first"
|
|
689
713
|
first_config.mkdir()
|
|
690
714
|
config_a = _write_chain_config(
|
|
691
|
-
first_config, [_entry("claude"), _entry("claude-
|
|
715
|
+
first_config, [_entry("claude"), _entry("claude-profile-c")]
|
|
692
716
|
)
|
|
693
717
|
_install(monkeypatch, config_a, behavior_by_command)
|
|
694
718
|
result_a = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
|
|
@@ -697,22 +721,22 @@ def test_reordering_config_changes_walk_order(
|
|
|
697
721
|
second_config = tmp_path / "second"
|
|
698
722
|
second_config.mkdir()
|
|
699
723
|
config_b = _write_chain_config(
|
|
700
|
-
second_config, [_entry("claude-
|
|
724
|
+
second_config, [_entry("claude-profile-c"), _entry("claude")]
|
|
701
725
|
)
|
|
702
726
|
_install(monkeypatch, config_b, behavior_by_command)
|
|
703
727
|
result_b = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
|
|
704
|
-
assert result_b.served_command == "claude-
|
|
728
|
+
assert result_b.served_command == "claude-profile-c"
|
|
705
729
|
|
|
706
730
|
|
|
707
731
|
def test_extra_args_are_appended_to_invocation(
|
|
708
732
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
709
733
|
) -> None:
|
|
710
734
|
config_file = _write_chain_config(
|
|
711
|
-
tmp_path, [_entry("claude", extra_args=["--account", "
|
|
735
|
+
tmp_path, [_entry("claude", extra_args=["--account", "profile-c"])]
|
|
712
736
|
)
|
|
713
737
|
recorder = _install(monkeypatch, config_file, {"claude": _completed("claude", 0)})
|
|
714
738
|
runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
|
|
715
|
-
assert recorder.invocations[0] == ["claude", "-p", "hello", "--account", "
|
|
739
|
+
assert recorder.invocations[0] == ["claude", "-p", "hello", "--account", "profile-c"]
|
|
716
740
|
|
|
717
741
|
|
|
718
742
|
def test_run_claude_forwards_stdin_text_to_subprocess(
|
|
@@ -757,17 +781,17 @@ def test_cli_forwards_piped_stdin_to_invocation(
|
|
|
757
781
|
def test_signature_matching_is_case_insensitive(
|
|
758
782
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
759
783
|
) -> None:
|
|
760
|
-
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-
|
|
784
|
+
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
|
|
761
785
|
_install(
|
|
762
786
|
monkeypatch,
|
|
763
787
|
config_file,
|
|
764
788
|
{
|
|
765
789
|
"claude": _completed("claude", 1, stderr=_A_SIGNATURE.upper()),
|
|
766
|
-
"claude-
|
|
790
|
+
"claude-profile-c": _completed("claude-profile-c", 0),
|
|
767
791
|
},
|
|
768
792
|
)
|
|
769
793
|
chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
|
|
770
|
-
assert chain_result.served_command == "claude-
|
|
794
|
+
assert chain_result.served_command == "claude-profile-c"
|
|
771
795
|
|
|
772
796
|
|
|
773
797
|
def test_cli_passthrough_builds_argument_list_and_timeout(
|
|
@@ -811,13 +835,13 @@ def test_cli_served_nonzero_exit_is_passed_through(
|
|
|
811
835
|
def test_cli_exhausted_chain_exits_nonzero_with_attempt_summary(
|
|
812
836
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
813
837
|
) -> None:
|
|
814
|
-
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-
|
|
838
|
+
config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-profile-c")])
|
|
815
839
|
_install(
|
|
816
840
|
monkeypatch,
|
|
817
841
|
config_file,
|
|
818
842
|
{
|
|
819
843
|
"claude": _completed("claude", 1, stderr=_A_SIGNATURE),
|
|
820
|
-
"claude-
|
|
844
|
+
"claude-profile-c": _completed("claude-profile-c", 1, stderr=_A_SIGNATURE),
|
|
821
845
|
},
|
|
822
846
|
)
|
|
823
847
|
exit_code = runner.main([CLI_ARGUMENTS_SEPARATOR, "-p", "hi"])
|
|
@@ -827,7 +851,7 @@ def test_cli_exhausted_chain_exits_nonzero_with_attempt_summary(
|
|
|
827
851
|
ATTEMPT_SUMMARY_ENTRY_TEMPLATE.format(
|
|
828
852
|
command=each_command, status=ATTEMPT_STATUS_USAGE_LIMITED
|
|
829
853
|
)
|
|
830
|
-
for each_command in ("claude", "claude-
|
|
854
|
+
for each_command in ("claude", "claude-profile-c")
|
|
831
855
|
)
|
|
832
856
|
assert (
|
|
833
857
|
CHAIN_EXHAUSTED_MESSAGE_TEMPLATE.format(attempt_summary=expected_summary)
|
|
@@ -937,11 +961,11 @@ def test_extra_args_default_to_empty_when_omitted(tmp_path: Path) -> None:
|
|
|
937
961
|
|
|
938
962
|
def test_load_chain_parses_command_and_extra_args(tmp_path: Path) -> None:
|
|
939
963
|
config_file = _write_chain_config(
|
|
940
|
-
tmp_path, [_entry("claude", extra_args=["--account", "
|
|
964
|
+
tmp_path, [_entry("claude", extra_args=["--account", "profile-c"])]
|
|
941
965
|
)
|
|
942
966
|
all_entries = runner.load_chain(config_file)
|
|
943
967
|
assert all_entries == [
|
|
944
|
-
runner.ChainEntry(command="claude", extra_args=("--account", "
|
|
968
|
+
runner.ChainEntry(command="claude", extra_args=("--account", "profile-c"))
|
|
945
969
|
]
|
|
946
970
|
|
|
947
971
|
|
|
@@ -1477,3 +1501,311 @@ def test_cli_ordered_account_advisor_blocked_exit_code(
|
|
|
1477
1501
|
assert exit_code == CHAIN_ADVISOR_BLOCKED_EXIT_CODE
|
|
1478
1502
|
|
|
1479
1503
|
|
|
1504
|
+
def test_default_affinity_state_path_joins_filename() -> None:
|
|
1505
|
+
claude_home = Path("/tmp/claude-home")
|
|
1506
|
+
resolved = default_affinity_state_path(claude_home)
|
|
1507
|
+
assert resolved == claude_home / AFFINITY_STATE_FILENAME
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
def test_affinity_store_round_trip_is_versioned_and_atomic(tmp_path: Path) -> None:
|
|
1511
|
+
state_path = tmp_path / AFFINITY_STATE_FILENAME
|
|
1512
|
+
empty_store = load_affinity_store(state_path)
|
|
1513
|
+
assert empty_store.schema_version == AFFINITY_STATE_SCHEMA_VERSION
|
|
1514
|
+
assert empty_store.all_bindings == []
|
|
1515
|
+
|
|
1516
|
+
updated = record_affinity_binding(
|
|
1517
|
+
empty_store,
|
|
1518
|
+
session_id=_BOUND_SESSION_ID,
|
|
1519
|
+
command=_PRIMARY_LAUNCHER,
|
|
1520
|
+
)
|
|
1521
|
+
save_affinity_store_atomic(state_path, updated)
|
|
1522
|
+
|
|
1523
|
+
reloaded = load_affinity_store(state_path)
|
|
1524
|
+
assert reloaded.schema_version == AFFINITY_STATE_SCHEMA_VERSION
|
|
1525
|
+
assert len(reloaded.all_bindings) == 1
|
|
1526
|
+
assert reloaded.all_bindings[0].session_id == _BOUND_SESSION_ID
|
|
1527
|
+
assert reloaded.all_bindings[0].command == _PRIMARY_LAUNCHER
|
|
1528
|
+
on_disk = json.loads(state_path.read_text(encoding=UTF8_ENCODING))
|
|
1529
|
+
assert on_disk[AFFINITY_KEY_SCHEMA_VERSION] == AFFINITY_STATE_SCHEMA_VERSION
|
|
1530
|
+
assert on_disk[AFFINITY_KEY_ALL_BINDINGS][0][AFFINITY_KEY_SESSION_ID] == (
|
|
1531
|
+
_BOUND_SESSION_ID
|
|
1532
|
+
)
|
|
1533
|
+
assert on_disk[AFFINITY_KEY_ALL_BINDINGS][0][AFFINITY_KEY_COMMAND] == (
|
|
1534
|
+
_PRIMARY_LAUNCHER
|
|
1535
|
+
)
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
def test_affinity_store_is_bounded_and_drops_oldest() -> None:
|
|
1539
|
+
store = runner.AffinityStore()
|
|
1540
|
+
for each_index in range(AFFINITY_MAXIMUM_ENTRIES + 3):
|
|
1541
|
+
store = record_affinity_binding(
|
|
1542
|
+
store,
|
|
1543
|
+
session_id=f"session-{each_index}",
|
|
1544
|
+
command=_PRIMARY_LAUNCHER,
|
|
1545
|
+
maximum_entries=AFFINITY_MAXIMUM_ENTRIES,
|
|
1546
|
+
)
|
|
1547
|
+
assert len(store.all_bindings) == AFFINITY_MAXIMUM_ENTRIES
|
|
1548
|
+
all_session_ids = [each.session_id for each in store.all_bindings]
|
|
1549
|
+
assert "session-0" not in all_session_ids
|
|
1550
|
+
assert "session-1" not in all_session_ids
|
|
1551
|
+
assert "session-2" not in all_session_ids
|
|
1552
|
+
assert f"session-{AFFINITY_MAXIMUM_ENTRIES + 2}" in all_session_ids
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
def test_affinity_rebind_moves_session_to_newest_end() -> None:
|
|
1556
|
+
store = runner.AffinityStore()
|
|
1557
|
+
store = record_affinity_binding(
|
|
1558
|
+
store, session_id="a", command=_PRIMARY_LAUNCHER
|
|
1559
|
+
)
|
|
1560
|
+
store = record_affinity_binding(
|
|
1561
|
+
store, session_id="b", command=_SECONDARY_LAUNCHER
|
|
1562
|
+
)
|
|
1563
|
+
store = record_affinity_binding(
|
|
1564
|
+
store, session_id="a", command=_SECONDARY_LAUNCHER
|
|
1565
|
+
)
|
|
1566
|
+
assert [each.session_id for each in store.all_bindings] == ["b", "a"]
|
|
1567
|
+
assert store.all_bindings[-1].command == _SECONDARY_LAUNCHER
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def test_affinity_corrupt_state_raises_actionable_diagnostic(tmp_path: Path) -> None:
|
|
1571
|
+
state_path = tmp_path / AFFINITY_STATE_FILENAME
|
|
1572
|
+
state_path.write_text("{not-json", encoding=UTF8_ENCODING)
|
|
1573
|
+
with pytest.raises(ValueError, match="corrupt or unreadable") as raised:
|
|
1574
|
+
load_affinity_store(state_path)
|
|
1575
|
+
assert str(state_path) in str(raised.value)
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
def test_affinity_unsupported_schema_version_raises_actionable_diagnostic(
|
|
1579
|
+
tmp_path: Path,
|
|
1580
|
+
) -> None:
|
|
1581
|
+
state_path = tmp_path / AFFINITY_STATE_FILENAME
|
|
1582
|
+
state_path.write_text(
|
|
1583
|
+
json.dumps(
|
|
1584
|
+
{
|
|
1585
|
+
AFFINITY_KEY_SCHEMA_VERSION: AFFINITY_STATE_SCHEMA_VERSION + 1,
|
|
1586
|
+
AFFINITY_KEY_ALL_BINDINGS: [],
|
|
1587
|
+
}
|
|
1588
|
+
),
|
|
1589
|
+
encoding=UTF8_ENCODING,
|
|
1590
|
+
)
|
|
1591
|
+
with pytest.raises(ValueError, match="unsupported schema_version") as raised:
|
|
1592
|
+
load_affinity_store(state_path)
|
|
1593
|
+
assert str(state_path) in str(raised.value)
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
def test_affinity_binding_not_object_raises_actionable_diagnostic(
|
|
1597
|
+
tmp_path: Path,
|
|
1598
|
+
) -> None:
|
|
1599
|
+
state_path = tmp_path / AFFINITY_STATE_FILENAME
|
|
1600
|
+
state_path.write_text(
|
|
1601
|
+
json.dumps(
|
|
1602
|
+
{
|
|
1603
|
+
AFFINITY_KEY_SCHEMA_VERSION: AFFINITY_STATE_SCHEMA_VERSION,
|
|
1604
|
+
AFFINITY_KEY_ALL_BINDINGS: ["not-an-object"],
|
|
1605
|
+
}
|
|
1606
|
+
),
|
|
1607
|
+
encoding=UTF8_ENCODING,
|
|
1608
|
+
)
|
|
1609
|
+
with pytest.raises(ValueError, match="not an object") as raised:
|
|
1610
|
+
load_affinity_store(state_path)
|
|
1611
|
+
assert str(state_path) in str(raised.value)
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
def test_record_affinity_binding_rejects_empty_session_or_command() -> None:
|
|
1615
|
+
store = runner.AffinityStore()
|
|
1616
|
+
with pytest.raises(ValueError, match="session_id and command"):
|
|
1617
|
+
record_affinity_binding(store, session_id="", command=_PRIMARY_LAUNCHER)
|
|
1618
|
+
with pytest.raises(ValueError, match="session_id and command"):
|
|
1619
|
+
record_affinity_binding(store, session_id=_BOUND_SESSION_ID, command="")
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
def test_record_affinity_binding_rejects_non_positive_maximum_entries() -> None:
|
|
1623
|
+
store = runner.AffinityStore()
|
|
1624
|
+
with pytest.raises(ValueError, match="maximum_entries must be at least 1"):
|
|
1625
|
+
record_affinity_binding(
|
|
1626
|
+
store,
|
|
1627
|
+
session_id=_BOUND_SESSION_ID,
|
|
1628
|
+
command=_PRIMARY_LAUNCHER,
|
|
1629
|
+
maximum_entries=0,
|
|
1630
|
+
)
|
|
1631
|
+
|
|
1632
|
+
|
|
1633
|
+
def test_affinity_write_failure_raises_actionable_diagnostic(
|
|
1634
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1635
|
+
) -> None:
|
|
1636
|
+
state_path = tmp_path / AFFINITY_STATE_FILENAME
|
|
1637
|
+
store = record_affinity_binding(
|
|
1638
|
+
runner.AffinityStore(),
|
|
1639
|
+
session_id=_BOUND_SESSION_ID,
|
|
1640
|
+
command=_PRIMARY_LAUNCHER,
|
|
1641
|
+
)
|
|
1642
|
+
|
|
1643
|
+
def _raise_replace(_source: object, _destination: object) -> None:
|
|
1644
|
+
raise OSError("disk full")
|
|
1645
|
+
|
|
1646
|
+
monkeypatch.setattr(runner.os, "replace", _raise_replace)
|
|
1647
|
+
with pytest.raises(OSError, match="Failed to write affinity state") as raised:
|
|
1648
|
+
save_affinity_store_atomic(state_path, store)
|
|
1649
|
+
assert str(state_path) in str(raised.value)
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
def test_extract_resume_session_id_reads_flag() -> None:
|
|
1653
|
+
assert extract_resume_session_id(
|
|
1654
|
+
[RESUME_SESSION_FLAG, _BOUND_SESSION_ID, "-p", "hi"]
|
|
1655
|
+
) == _BOUND_SESSION_ID
|
|
1656
|
+
assert extract_resume_session_id(["-p", "hi"]) is None
|
|
1657
|
+
assert extract_resume_session_id([RESUME_SESSION_FLAG]) is None
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
def test_order_entries_for_resume_puts_originating_binary_first() -> None:
|
|
1661
|
+
all_entries = [
|
|
1662
|
+
ChainEntry(command=_PRIMARY_LAUNCHER, extra_args=()),
|
|
1663
|
+
ChainEntry(command=_SECONDARY_LAUNCHER, extra_args=()),
|
|
1664
|
+
]
|
|
1665
|
+
ordered = order_entries_for_resume(
|
|
1666
|
+
all_entries, preferred_command=_SECONDARY_LAUNCHER
|
|
1667
|
+
)
|
|
1668
|
+
assert [each.command for each in ordered] == [
|
|
1669
|
+
_SECONDARY_LAUNCHER,
|
|
1670
|
+
_PRIMARY_LAUNCHER,
|
|
1671
|
+
]
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
def test_order_entries_for_resume_falls_back_when_command_missing() -> None:
|
|
1675
|
+
all_entries = [
|
|
1676
|
+
ChainEntry(command=_PRIMARY_LAUNCHER, extra_args=()),
|
|
1677
|
+
ChainEntry(command=_SECONDARY_LAUNCHER, extra_args=()),
|
|
1678
|
+
]
|
|
1679
|
+
ordered = order_entries_for_resume(
|
|
1680
|
+
all_entries, preferred_command="missing-binary"
|
|
1681
|
+
)
|
|
1682
|
+
assert [each.command for each in ordered] == [
|
|
1683
|
+
_PRIMARY_LAUNCHER,
|
|
1684
|
+
_SECONDARY_LAUNCHER,
|
|
1685
|
+
]
|
|
1686
|
+
ordered_none = order_entries_for_resume(all_entries, preferred_command=None)
|
|
1687
|
+
assert [each.command for each in ordered_none] == [
|
|
1688
|
+
_PRIMARY_LAUNCHER,
|
|
1689
|
+
_SECONDARY_LAUNCHER,
|
|
1690
|
+
]
|
|
1691
|
+
|
|
1692
|
+
|
|
1693
|
+
def test_lookup_affinity_command_returns_bound_binary() -> None:
|
|
1694
|
+
store = record_affinity_binding(
|
|
1695
|
+
runner.AffinityStore(),
|
|
1696
|
+
session_id=_BOUND_SESSION_ID,
|
|
1697
|
+
command=_SECONDARY_LAUNCHER,
|
|
1698
|
+
)
|
|
1699
|
+
assert lookup_affinity_command(store, _BOUND_SESSION_ID) == _SECONDARY_LAUNCHER
|
|
1700
|
+
assert lookup_affinity_command(store, "unknown") is None
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
def test_run_claude_resume_routes_through_originating_binary(
|
|
1704
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
1705
|
+
) -> None:
|
|
1706
|
+
config_file = _write_chain_config(
|
|
1707
|
+
tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
|
|
1708
|
+
)
|
|
1709
|
+
affinity_path = tmp_path / "claude-chain-affinity.json"
|
|
1710
|
+
store = record_affinity_binding(
|
|
1711
|
+
runner.AffinityStore(),
|
|
1712
|
+
session_id=_BOUND_SESSION_ID,
|
|
1713
|
+
command=_SECONDARY_LAUNCHER,
|
|
1714
|
+
)
|
|
1715
|
+
save_affinity_store_atomic(affinity_path, store)
|
|
1716
|
+
|
|
1717
|
+
monkeypatch.setattr(runner, "chain_config_path", lambda: config_file)
|
|
1718
|
+
monkeypatch.setattr(
|
|
1719
|
+
runner,
|
|
1720
|
+
"default_affinity_state_path",
|
|
1721
|
+
lambda _home: affinity_path,
|
|
1722
|
+
)
|
|
1723
|
+
monkeypatch.setattr(
|
|
1724
|
+
runner,
|
|
1725
|
+
"chain_weekly_usage_reporter",
|
|
1726
|
+
_usage_reporter_from_remaining(
|
|
1727
|
+
{
|
|
1728
|
+
_PRIMARY_LAUNCHER: _HIGH_WEEKLY_REMAINING_PERCENT,
|
|
1729
|
+
_SECONDARY_LAUNCHER: _LOW_WEEKLY_REMAINING_PERCENT,
|
|
1730
|
+
}
|
|
1731
|
+
),
|
|
1732
|
+
)
|
|
1733
|
+
all_called: list[str] = []
|
|
1734
|
+
|
|
1735
|
+
def _runner(command, **_kwargs):
|
|
1736
|
+
all_called.append(command[0])
|
|
1737
|
+
return _completed(command[0], 0, stdout=_JSON_BIND_STDOUT_WITH_SESSION)
|
|
1738
|
+
|
|
1739
|
+
monkeypatch.setattr(runner, "chain_subprocess_runner", _runner)
|
|
1740
|
+
outcome = runner.run_claude(
|
|
1741
|
+
[RESUME_SESSION_FLAG, _BOUND_SESSION_ID, "-p", "hi"],
|
|
1742
|
+
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
|
|
1743
|
+
)
|
|
1744
|
+
assert outcome.served_command == _SECONDARY_LAUNCHER
|
|
1745
|
+
assert all_called[0] == _SECONDARY_LAUNCHER
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
def test_run_claude_success_persists_session_affinity(
|
|
1749
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
1750
|
+
) -> None:
|
|
1751
|
+
config_file = _write_chain_config(
|
|
1752
|
+
tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
|
|
1753
|
+
)
|
|
1754
|
+
_install(
|
|
1755
|
+
monkeypatch,
|
|
1756
|
+
config_file,
|
|
1757
|
+
{
|
|
1758
|
+
_PRIMARY_LAUNCHER: _completed(
|
|
1759
|
+
_PRIMARY_LAUNCHER, 0, stdout=_JSON_BIND_STDOUT_WITH_SESSION
|
|
1760
|
+
),
|
|
1761
|
+
_SECONDARY_LAUNCHER: _completed(_SECONDARY_LAUNCHER, 0),
|
|
1762
|
+
},
|
|
1763
|
+
)
|
|
1764
|
+
chain_result = runner.run_claude(
|
|
1765
|
+
_PROMPT_ARGUMENTS,
|
|
1766
|
+
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
|
|
1767
|
+
routing_mode=ROUTING_MODE_ORDERED_ACCOUNT,
|
|
1768
|
+
)
|
|
1769
|
+
assert chain_result.served_command == _PRIMARY_LAUNCHER
|
|
1770
|
+
assert chain_result.session_id == _BOUND_SESSION_ID
|
|
1771
|
+
affinity_path = Path(config_file).parent / AFFINITY_STATE_FILENAME
|
|
1772
|
+
reloaded = load_affinity_store(affinity_path)
|
|
1773
|
+
assert len(reloaded.all_bindings) == 1
|
|
1774
|
+
assert reloaded.all_bindings[0].session_id == _BOUND_SESSION_ID
|
|
1775
|
+
assert reloaded.all_bindings[0].command == _PRIMARY_LAUNCHER
|
|
1776
|
+
|
|
1777
|
+
|
|
1778
|
+
def test_run_claude_resume_falls_back_when_affinity_corrupt(
|
|
1779
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
1780
|
+
) -> None:
|
|
1781
|
+
config_file = _write_chain_config(
|
|
1782
|
+
tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
|
|
1783
|
+
)
|
|
1784
|
+
affinity_path = Path(config_file).parent / AFFINITY_STATE_FILENAME
|
|
1785
|
+
affinity_path.write_text("{not-json", encoding=UTF8_ENCODING)
|
|
1786
|
+
usage_reporter = _usage_reporter_from_remaining(
|
|
1787
|
+
{
|
|
1788
|
+
_PRIMARY_LAUNCHER: _HIGH_WEEKLY_REMAINING_PERCENT,
|
|
1789
|
+
_SECONDARY_LAUNCHER: _LOW_WEEKLY_REMAINING_PERCENT,
|
|
1790
|
+
}
|
|
1791
|
+
)
|
|
1792
|
+
all_called: list[str] = []
|
|
1793
|
+
|
|
1794
|
+
def _runner(command, **_kwargs):
|
|
1795
|
+
all_called.append(command[0])
|
|
1796
|
+
return _completed(command[0], 0, stdout=_JSON_BIND_STDOUT_WITH_SESSION)
|
|
1797
|
+
|
|
1798
|
+
monkeypatch.setattr(runner, "chain_config_path", lambda: config_file)
|
|
1799
|
+
monkeypatch.setattr(
|
|
1800
|
+
runner,
|
|
1801
|
+
"default_affinity_state_path",
|
|
1802
|
+
lambda _home, _affinity_path=affinity_path: _affinity_path,
|
|
1803
|
+
)
|
|
1804
|
+
monkeypatch.setattr(runner, "chain_weekly_usage_reporter", usage_reporter)
|
|
1805
|
+
monkeypatch.setattr(runner, "chain_subprocess_runner", _runner)
|
|
1806
|
+
outcome = runner.run_claude(
|
|
1807
|
+
[RESUME_SESSION_FLAG, _BOUND_SESSION_ID, "-p", "hi"],
|
|
1808
|
+
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
|
|
1809
|
+
)
|
|
1810
|
+
assert outcome.served_command == _PRIMARY_LAUNCHER
|
|
1811
|
+
assert all_called[0] == _PRIMARY_LAUNCHER
|