brindle 0.3.22__tar.gz → 0.3.23__tar.gz
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.
- {brindle-0.3.22 → brindle-0.3.23}/PKG-INFO +10 -3
- {brindle-0.3.22 → brindle-0.3.23}/README.md +9 -2
- {brindle-0.3.22 → brindle-0.3.23}/pyproject.toml +1 -1
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/agents.py +64 -42
- brindle-0.3.23/src/brindle/assets/braid_dark.png +0 -0
- brindle-0.3.23/src/brindle/assets/braid_light.png +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/autopilot.py +111 -4
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/developer-local.md +1 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/reviewer-local.md +1 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/config.py +56 -8
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/cull.py +48 -20
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/db.py +7 -2
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/gates.py +3 -1
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/git.py +23 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/mcp_server.py +43 -1
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/native/client.py +22 -2
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/native/runner.py +44 -2
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/permissions.py +16 -1
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pipeline.py +66 -4
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pool.py +26 -14
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/status.py +18 -3
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/procs.py +18 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/profiles.py +42 -2
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/providers.py +21 -3
- brindle-0.3.23/src/brindle/sidebar_logo.py +240 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/tmux.py +19 -2
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/view.py +7 -4
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/watch.py +95 -64
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/workspaces.py +14 -3
- {brindle-0.3.22 → brindle-0.3.23}/.gitignore +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/LICENSE +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/SCHEDULE-A +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/__init__.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/__main__.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/account.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/airgap.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/antigravity.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/budget.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/__init__.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/backend-auditor.md +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/developer-codex.md +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/developer-fable.md +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/developer-gemini.md +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/developer-heavy.md +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/developer-light.md +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/developer.md +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/qa.md +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/reviewer.md +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/subagent.md +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/supervisor.md +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_rules/__init__.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_rules/security/backend.md +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_rules/tests/only.md +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/ci_adapters.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/ci_client.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/ci_federation.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/ci_hosts.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/cli.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/cloud_credential.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/cloud_login.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/codemap.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/codemap_docs.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/codex_hook.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/company_identity.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/company_login.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/conflicts.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/cost.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/cost_estimate.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/demo.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/detect.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/doctor.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/escapes.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/events.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/guardrails.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/history.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/identity_lock.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/inbox.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/key_check.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/keys_cmds.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/keystore.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/launch_health.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/learned_rules.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/learning.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/legacy.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/local_setup.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/login_pause.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/model_access.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/model_availability.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/native/__init__.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/native/loop.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/native/permissions.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/native/serve.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/native/tools.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pane_auth.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/picker.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/plugins.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/policy.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pricing.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/__init__.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/_files.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/account.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/audit_chain.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/audit_export.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/auth.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/brief_learning.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/cost_centers.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/credentials.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/features.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/keys.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/learning.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/license.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/loopback.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/managed_models.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/org_profiles.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/orgkey.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/rollout.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/route_health.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/settings_sync.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/team_events.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/team_policy.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/prompt_answer.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/quota.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/repo_cmds.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/repos.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/rewind.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/rule_checks.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/savings.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/scratch.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/secrets.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/self_serve.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/services.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/sessions.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/sidebar_follow.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/signin_pause.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/status_cache.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/tasks.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/updates.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/upgrade.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/usage.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/watch_costs.py +0 -0
- {brindle-0.3.22 → brindle-0.3.23}/src/brindle/worker_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: brindle
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.23
|
|
4
4
|
Summary: Run coding agents in parallel, each on its own git branch, with a supervisor that delegates, reviews, and merges
|
|
5
5
|
Project-URL: Homepage, https://pawdelta.com/brindle/
|
|
6
6
|
Project-URL: Documentation, https://pawdelta.com/brindle/docs/recommended-use
|
|
@@ -94,7 +94,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
|
|
|
94
94
|
brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
|
|
95
95
|
|
|
96
96
|
`brindle --version` prints the installed version; the tmux status bar of every brindle
|
|
97
|
-
session shows it too (`brindle 0.3.
|
|
97
|
+
session shows it too (`brindle 0.3.23`). A session started before an upgrade keeps
|
|
98
98
|
running the old code, and shows the old number, until you restart it.
|
|
99
99
|
|
|
100
100
|
## Quick start
|
|
@@ -562,7 +562,7 @@ Autopilot, merge gates and cleanup:
|
|
|
562
562
|
| `pre_commit` | `true` | run [pre-commit](https://pre-commit.com) over the branch, if the repo uses it |
|
|
563
563
|
| `max_agents` | `4` | workers running at once per session (`0`: no cap) |
|
|
564
564
|
| `check_timeout` | `900` | seconds each check may take |
|
|
565
|
-
| `check_concurrency` |
|
|
565
|
+
| `check_concurrency` | half the logical cores, at least 2, at most 6 and 2 GB of RAM per run | check runs at once on this machine, across branches; the rest wait their turn (`0`: no cap). A run far past its last duration is reported to the supervisor |
|
|
566
566
|
| `fable_escalation` | on, unless the worker runs on a cloud account (Bedrock, Vertex or Foundry) | retry a failed heavy task once on `developer-heavy` before handing it to you; `true` turns it on for a cloud account, `false` off |
|
|
567
567
|
| `usage_limit` | `90` | autopilot stops pushing on at this % of your Claude usage limit |
|
|
568
568
|
| `limit_cooldown_minutes` | `300` for Antigravity | how long a provider that hit its limit counts as unavailable |
|
|
@@ -1844,6 +1844,13 @@ in the same checkout keeps it running for the new one.
|
|
|
1844
1844
|
|
|
1845
1845
|
Then a supervisor can `assign` a task to `developer-local`, or the repo config
|
|
1846
1846
|
can make the free model the reviewer: `"review_profile": "reviewer-local"`.
|
|
1847
|
+
When the local model isn't served (Ollama isn't running, or the model isn't
|
|
1848
|
+
pulled), the worker starts as the profile's `fallback:` instead, and is recorded
|
|
1849
|
+
as that profile: `developer-light` (Haiku) for `developer-local`, `reviewer` for
|
|
1850
|
+
`reviewer-local`. A native worker on a hosted model that refuses a request
|
|
1851
|
+
retries it on `developer-local`'s model, when that is served on this machine or
|
|
1852
|
+
the private network, and keeps the rest of the conversation there; the transcript
|
|
1853
|
+
records which model gave each reply.
|
|
1847
1854
|
Other endpoints, same fields:
|
|
1848
1855
|
|
|
1849
1856
|
| Backend | `api` | `base_url` | Notes |
|
|
@@ -63,7 +63,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
|
|
|
63
63
|
brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
|
|
64
64
|
|
|
65
65
|
`brindle --version` prints the installed version; the tmux status bar of every brindle
|
|
66
|
-
session shows it too (`brindle 0.3.
|
|
66
|
+
session shows it too (`brindle 0.3.23`). A session started before an upgrade keeps
|
|
67
67
|
running the old code, and shows the old number, until you restart it.
|
|
68
68
|
|
|
69
69
|
## Quick start
|
|
@@ -531,7 +531,7 @@ Autopilot, merge gates and cleanup:
|
|
|
531
531
|
| `pre_commit` | `true` | run [pre-commit](https://pre-commit.com) over the branch, if the repo uses it |
|
|
532
532
|
| `max_agents` | `4` | workers running at once per session (`0`: no cap) |
|
|
533
533
|
| `check_timeout` | `900` | seconds each check may take |
|
|
534
|
-
| `check_concurrency` |
|
|
534
|
+
| `check_concurrency` | half the logical cores, at least 2, at most 6 and 2 GB of RAM per run | check runs at once on this machine, across branches; the rest wait their turn (`0`: no cap). A run far past its last duration is reported to the supervisor |
|
|
535
535
|
| `fable_escalation` | on, unless the worker runs on a cloud account (Bedrock, Vertex or Foundry) | retry a failed heavy task once on `developer-heavy` before handing it to you; `true` turns it on for a cloud account, `false` off |
|
|
536
536
|
| `usage_limit` | `90` | autopilot stops pushing on at this % of your Claude usage limit |
|
|
537
537
|
| `limit_cooldown_minutes` | `300` for Antigravity | how long a provider that hit its limit counts as unavailable |
|
|
@@ -1813,6 +1813,13 @@ in the same checkout keeps it running for the new one.
|
|
|
1813
1813
|
|
|
1814
1814
|
Then a supervisor can `assign` a task to `developer-local`, or the repo config
|
|
1815
1815
|
can make the free model the reviewer: `"review_profile": "reviewer-local"`.
|
|
1816
|
+
When the local model isn't served (Ollama isn't running, or the model isn't
|
|
1817
|
+
pulled), the worker starts as the profile's `fallback:` instead, and is recorded
|
|
1818
|
+
as that profile: `developer-light` (Haiku) for `developer-local`, `reviewer` for
|
|
1819
|
+
`reviewer-local`. A native worker on a hosted model that refuses a request
|
|
1820
|
+
retries it on `developer-local`'s model, when that is served on this machine or
|
|
1821
|
+
the private network, and keeps the rest of the conversation there; the transcript
|
|
1822
|
+
records which model gave each reply.
|
|
1816
1823
|
Other endpoints, same fields:
|
|
1817
1824
|
|
|
1818
1825
|
| Backend | `api` | `base_url` | Notes |
|
|
@@ -26,7 +26,7 @@ from brindle.config import RepoConfig
|
|
|
26
26
|
from brindle.db import DB, Agent, Workspace
|
|
27
27
|
from brindle.model_availability import ModelAvailabilityChecker, PROVISIONING_TIMEOUT, provisioning_context
|
|
28
28
|
from brindle.pro import route_health
|
|
29
|
-
from brindle.profiles import load_profile, missing_add_dirs
|
|
29
|
+
from brindle.profiles import load_profile, local_fallback, missing_add_dirs
|
|
30
30
|
from brindle.providers import LaunchContext, get_provider, signed_out, unusable
|
|
31
31
|
from brindle.sidebar_follow import (
|
|
32
32
|
SIDEBAR_COLUMNS,
|
|
@@ -336,6 +336,9 @@ def spawn(
|
|
|
336
336
|
|
|
337
337
|
identity_lock.ensure_workspace(db, ws) # no new agent in another identity's worktree
|
|
338
338
|
profile = load_profile(profile_name, ws.repo_root)
|
|
339
|
+
hosted = local_fallback(profile, ws.repo_root) # its local model isn't up
|
|
340
|
+
if hosted:
|
|
341
|
+
profile_name, profile = hosted, load_profile(hosted, ws.repo_root)
|
|
339
342
|
provider = get_provider(provider_name or profile.provider)
|
|
340
343
|
agent_id = new_id()
|
|
341
344
|
# Headless is a Claude Code mode; other CLIs ignore the profile field.
|
|
@@ -1462,7 +1465,9 @@ def one_line(db: DB, message_id: int, body: str, sender_id: str | None) -> str:
|
|
|
1462
1465
|
who = f"{sender.profile} {sender_id}" if sender else sender_id
|
|
1463
1466
|
else:
|
|
1464
1467
|
who = "brindle"
|
|
1465
|
-
|
|
1468
|
+
# A stuck-worker notice (cull.note_stuck) carries the prompt it waits on in
|
|
1469
|
+
# its first line, which has to arrive whole.
|
|
1470
|
+
head = first_line(body, 1500 if "has been waiting on a prompt for" in body[:200] else 300)
|
|
1466
1471
|
if not sender_id:
|
|
1467
1472
|
head = head.removeprefix("[brindle] ") # the sender tag already says it
|
|
1468
1473
|
return (f"[{who}] {head} "
|
|
@@ -1982,28 +1987,30 @@ def submit_review(db: DB, caller_id: str, approved: bool, summary: str) -> str:
|
|
|
1982
1987
|
close_later(caller.id)
|
|
1983
1988
|
return "Audit recorded and sent to the supervisor. You're done."
|
|
1984
1989
|
# Already recorded: closing this reviewer is its verdict's job, once
|
|
1985
|
-
# whatever the verdict set off is done.
|
|
1986
|
-
# that work (a merge running the checks) can hold for minutes.
|
|
1987
|
-
recorded = "Your review was already recorded. You're done."
|
|
1990
|
+
# whatever the verdict set off is done.
|
|
1988
1991
|
if caller.result is not None:
|
|
1989
|
-
return recorded
|
|
1990
|
-
#
|
|
1991
|
-
#
|
|
1992
|
-
#
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
1992
|
+
return "Your review was already recorded. You're done."
|
|
1993
|
+
# Recorded without the branch's merge lock: a merge holds it while the
|
|
1994
|
+
# repo's checks run, for minutes, and a verdict waiting on it left the
|
|
1995
|
+
# reviewer's call hanging until its CLI gave up on it, never recorded.
|
|
1996
|
+
# Acting on the verdict (finish_review) takes the lock, apart from the
|
|
1997
|
+
# reviewer, and finds whatever a merge in progress did: a second
|
|
1998
|
+
# reviewer's verdict for a branch that merged meanwhile is dropped there.
|
|
1999
|
+
gone = "The branch is already merged or removed. You're done."
|
|
2000
|
+
if db.get_workspace(ws.id) is None or not os.path.isdir(ws.path):
|
|
2001
|
+
close_later(caller_id)
|
|
2002
|
+
return gone
|
|
2003
|
+
try:
|
|
2000
2004
|
sha = gates.head(ws)
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
db.
|
|
2005
|
+
except git.GitError: # removed just now
|
|
2006
|
+
close_later(caller_id)
|
|
2007
|
+
return gone
|
|
2008
|
+
db.add_review(ws.id, sha, caller.id, approved, summary)
|
|
2009
|
+
if approved:
|
|
2010
|
+
db.bump_progress(autopilot.root_of(db, caller.id))
|
|
2011
|
+
pipeline.note_review(db, ws, approved, reviewer=caller)
|
|
2012
|
+
verdict = "APPROVED" if approved else "CHANGES REQUESTED"
|
|
2013
|
+
db.set_result(caller.id, _review_text(ws, sha, approved, summary))
|
|
2007
2014
|
finish_review_later(caller.id, ws.id)
|
|
2008
2015
|
return f"Review recorded ({verdict}); brindle takes it from here. You're done."
|
|
2009
2016
|
|
|
@@ -2273,14 +2280,12 @@ def default_review_profile(cfg: RepoConfig, worker: Agent | None, db: DB | None
|
|
|
2273
2280
|
def _local_reviewer_available() -> bool:
|
|
2274
2281
|
"""Whether the built-in ``reviewer-local`` profile's model is served
|
|
2275
2282
|
right now. Any failure counts as not available."""
|
|
2276
|
-
|
|
2277
|
-
from brindle.native import runner
|
|
2283
|
+
from brindle.native import runner
|
|
2278
2284
|
|
|
2279
|
-
|
|
2280
|
-
|
|
2285
|
+
try:
|
|
2286
|
+
return runner.served(load_profile("reviewer-local"))
|
|
2281
2287
|
except Exception:
|
|
2282
2288
|
return False
|
|
2283
|
-
return bool(ok) and "is available" in detail
|
|
2284
2289
|
|
|
2285
2290
|
|
|
2286
2291
|
def _review_kill_switch(ws: Workspace) -> None:
|
|
@@ -2397,30 +2402,45 @@ def expected_check_wait(db: DB, ws: Workspace, cfg: RepoConfig) -> float | None:
|
|
|
2397
2402
|
return total if cfg.checks else None
|
|
2398
2403
|
|
|
2399
2404
|
|
|
2405
|
+
MOVED_RETRIES = 2 # check runs a branch that keeps moving under them gets again (review_after_checks)
|
|
2406
|
+
|
|
2407
|
+
|
|
2400
2408
|
def review_after_checks(db: DB, caller_id: str | None, ws: Workspace, profile: str | None = None,
|
|
2401
2409
|
focus: str | None = None, cfg: RepoConfig | None = None) -> Agent | None:
|
|
2402
2410
|
"""Run ``cfg.checks`` for ``ws`` (through ``gates.check_summary``: its
|
|
2403
2411
|
lock and the by-sha cache, so a result a worker's report already warmed
|
|
2404
2412
|
is instant), then start the reviewer with the results in its prompt.
|
|
2405
2413
|
Meant to run from a detached process, which outlives the call that
|
|
2406
|
-
started it. Gives up quietly when the
|
|
2407
|
-
|
|
2408
|
-
|
|
2414
|
+
started it. Gives up quietly when the workspace is gone, a reviewer is
|
|
2415
|
+
already at work on this commit, or the branch moves on to a commit a new
|
|
2416
|
+
report hands to a run of its own. A piped branch that moves on without
|
|
2417
|
+
a new report (its worker committed again, or merged its moved base in)
|
|
2418
|
+
is checked again on its new head, up to ``MOVED_RETRIES`` times, and then
|
|
2419
|
+
goes to the supervisor. Raises ``AgentError`` when the reviewer can't
|
|
2409
2420
|
start."""
|
|
2410
|
-
from brindle import gates
|
|
2421
|
+
from brindle import gates, pipeline
|
|
2411
2422
|
from brindle.config import load_repo_config
|
|
2412
2423
|
|
|
2413
2424
|
cfg = cfg or load_repo_config(ws.repo_root)
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2425
|
+
since = pipeline.review_claim(db, ws)
|
|
2426
|
+
for _attempt in range(MOVED_RETRIES + 1):
|
|
2427
|
+
sha = gates.head(ws)
|
|
2428
|
+
if _live_reviewer(db, ws, sha, profile):
|
|
2429
|
+
return None
|
|
2430
|
+
try:
|
|
2431
|
+
summary = gates.check_summary(db, ws, cfg, cancel=lambda sha=sha: gates.head(ws) != sha)
|
|
2432
|
+
except gates.Abandoned:
|
|
2433
|
+
summary = None
|
|
2434
|
+
except Exception as e: # noqa: BLE001 - the review goes ahead, saying so
|
|
2435
|
+
summary = f"(running the checks crashed: {e})"
|
|
2436
|
+
if db.get_workspace(ws.id) is None:
|
|
2437
|
+
return None
|
|
2438
|
+
if gates.head(ws) == sha:
|
|
2439
|
+
break
|
|
2440
|
+
if not pipeline.still_claimed(db, ws, since):
|
|
2441
|
+
return None
|
|
2442
|
+
else:
|
|
2443
|
+
pipeline.on_moved_during_checks(db, ws)
|
|
2424
2444
|
return None
|
|
2425
2445
|
caller = db.get_agent(caller_id) if caller_id else None
|
|
2426
2446
|
text = f"Checks for {ws.branch} at {sha[:8]}:\n\n{summary}" if summary else None
|
|
@@ -3322,7 +3342,9 @@ def tell_parent_unreported(db: DB, agent: Agent) -> bool:
|
|
|
3322
3342
|
if agent.mode == "review" and ws is not None and os.path.isdir(ws.path):
|
|
3323
3343
|
from brindle import gates
|
|
3324
3344
|
|
|
3325
|
-
|
|
3345
|
+
# Its own verdict only: another reviewer's (a second review's) says
|
|
3346
|
+
# nothing about whether this one's call got through.
|
|
3347
|
+
if any(r.reviewer_id == agent.id for r in db.reviews_at(ws.id, gates.head(ws))):
|
|
3326
3348
|
return False # its verdict for the current commit is already recorded
|
|
3327
3349
|
if agent.unreported_noted:
|
|
3328
3350
|
return False # the supervisor was told about this stop; cleared once it resumes
|
|
Binary file
|
|
Binary file
|
|
@@ -54,6 +54,7 @@ log = logging.getLogger(__name__)
|
|
|
54
54
|
GOALS_FILE = "goals.md"
|
|
55
55
|
GOALS_DIR = "goals" # .brindle/goals/<session id>.md: each session's own goal
|
|
56
56
|
MAX_NUDGES = 3
|
|
57
|
+
NUDGE_BACKOFF = 900.0 # seconds before the same reminder is repeated when nothing has changed
|
|
57
58
|
MAX_GOAL_CHARS = 4000 # Claude Code's limit for a /goal condition
|
|
58
59
|
OUTPUT_TAIL_LINES = 30
|
|
59
60
|
|
|
@@ -1817,6 +1818,8 @@ def on_stop(db: DB, agent: Agent, payload: dict) -> dict | None:
|
|
|
1817
1818
|
|
|
1818
1819
|
if login_pause.paused(db, agent.id):
|
|
1819
1820
|
return None # only the person can renew the login; a nudge would just fail
|
|
1821
|
+
if proposing(ap):
|
|
1822
|
+
return None # brindle's own dialog is asking the user to approve checks
|
|
1820
1823
|
if ap.state == "auditing":
|
|
1821
1824
|
if ap.auditor_id:
|
|
1822
1825
|
auditor = db.get_agent(ap.auditor_id)
|
|
@@ -1856,12 +1859,16 @@ def on_stop(db: DB, agent: Agent, payload: dict) -> dict | None:
|
|
|
1856
1859
|
# One reminder per step: never counts toward stalling.
|
|
1857
1860
|
db.update_autopilot(agent.id, nudges=0, nudged_at=ap.progress)
|
|
1858
1861
|
return {"decision": "block", "reason": nudge(db, ap, cfg, working, stalled)}
|
|
1862
|
+
now = time.time()
|
|
1863
|
+
if (ap.nudged_at == ap.progress and ap.nudged_time is not None
|
|
1864
|
+
and now - ap.nudged_time < NUDGE_BACKOFF):
|
|
1865
|
+
return None # (#177) already reminded since the last step: it's legitimately waiting
|
|
1859
1866
|
nudges = ap.nudges if ap.nudged_at == ap.progress else 0
|
|
1860
1867
|
if nudges >= MAX_NUDGES:
|
|
1861
1868
|
db.update_autopilot(agent.id, state="stalled", nudges=0,
|
|
1862
1869
|
note=f"no progress after {MAX_NUDGES} reminders to keep going")
|
|
1863
1870
|
return None
|
|
1864
|
-
db.update_autopilot(agent.id, nudges=nudges + 1, nudged_at=ap.progress)
|
|
1871
|
+
db.update_autopilot(agent.id, nudges=nudges + 1, nudged_at=ap.progress, nudged_time=now)
|
|
1865
1872
|
return {"decision": "block", "reason": nudge(db, ap, cfg, working, stalled)}
|
|
1866
1873
|
|
|
1867
1874
|
|
|
@@ -1911,12 +1918,112 @@ def nudge(db: DB, ap: Autopilot, cfg: RepoConfig,
|
|
|
1911
1918
|
|
|
1912
1919
|
|
|
1913
1920
|
NO_CHECKS = """- This repo has no `checks` in .brindle/config.json, so nothing verifies a
|
|
1914
|
-
branch before it merges. Before the first `assign`,
|
|
1915
|
-
|
|
1916
|
-
|
|
1921
|
+
branch before it merges. Before the first `assign`, call `add_checks` with
|
|
1922
|
+
the check command you propose{suggestion}. brindle itself asks the user to
|
|
1923
|
+
approve it (and to allow its commands in .claude/settings.local.json) and
|
|
1924
|
+
writes what they accept; don't ask them yourself. Then commit
|
|
1925
|
+
.brindle/config.json.
|
|
1917
1926
|
"""
|
|
1918
1927
|
|
|
1919
1928
|
|
|
1929
|
+
PROPOSAL_TTL = 30 * 60 # seconds a check proposal may hold autopilot (see proposing)
|
|
1930
|
+
|
|
1931
|
+
|
|
1932
|
+
SAFE_ALLOW = re.compile(r"[A-Za-z0-9_./ =,@+-]+") # no wildcard, quote, $, &, redirect or newline
|
|
1933
|
+
# Programs whose only job is checking code. Exact, case-sensitive matches: anything
|
|
1934
|
+
# else (wrappers, shells, package runners, interpreters) is refused.
|
|
1935
|
+
CHECK_PROGRAMS = {"pytest", "ruff", "mypy", "shellcheck", "eslint", "tsc",
|
|
1936
|
+
"flake8", "pyright", "golangci-lint"}
|
|
1937
|
+
# Toolchains run code through many subcommands (go run, cargo install, ...): only these.
|
|
1938
|
+
CHECK_SUBCOMMANDS = {"go": {"test", "vet"}, "cargo": {"test", "check", "clippy", "fmt"}}
|
|
1939
|
+
# Flags allowed on those subcommands; any other flag can name a program to run (-exec, -toolexec, --config ...).
|
|
1940
|
+
HARMLESS_FLAGS = {"-v", "-q", "-race", "--check", "--all-targets", "--workspace"}
|
|
1941
|
+
CHECK_MODULES = {"pytest", "mypy", "ruff", "unittest"} # the only `python -m` targets allowed
|
|
1942
|
+
|
|
1943
|
+
|
|
1944
|
+
def _runs_arbitrary(c: str) -> bool:
|
|
1945
|
+
"""True unless the command runs a known check program. ``NAME=VALUE`` prefixes
|
|
1946
|
+
and paths are refused; ``uv run`` is looked through to the program it runs."""
|
|
1947
|
+
argv = c.split()
|
|
1948
|
+
if argv[:1] == ["uv"] and argv[1:2] == ["run"]:
|
|
1949
|
+
argv = argv[2:]
|
|
1950
|
+
if not argv or "=" in argv[0] or argv[0] not in {*CHECK_PROGRAMS, *CHECK_SUBCOMMANDS, "python", "python3"}:
|
|
1951
|
+
return True
|
|
1952
|
+
if argv[0] in CHECK_SUBCOMMANDS:
|
|
1953
|
+
return (argv[1:2] == [] or argv[1] not in CHECK_SUBCOMMANDS[argv[0]]
|
|
1954
|
+
or any(a.startswith("-") and a not in HARMLESS_FLAGS for a in argv[2:]))
|
|
1955
|
+
if argv[0] in CHECK_PROGRAMS:
|
|
1956
|
+
return False
|
|
1957
|
+
return argv[1:2] != ["-m"] or argv[2:3] == [] or argv[2] not in CHECK_MODULES
|
|
1958
|
+
|
|
1959
|
+
|
|
1960
|
+
def allow_rules(checks: list[str]) -> tuple[list[str], str | None]:
|
|
1961
|
+
"""One narrow Bash allow rule per command in ``checks``, or none and why."""
|
|
1962
|
+
commands = [c.strip() for check in checks for c in re.split(r"&&|\|\||;|\|", check)]
|
|
1963
|
+
unsafe = [c for c in commands if c and (
|
|
1964
|
+
not SAFE_ALLOW.fullmatch(c) or _runs_arbitrary(c))]
|
|
1965
|
+
if unsafe:
|
|
1966
|
+
return [], (f"{unsafe[0]!r} has a wildcard, substitution, redirect or a binary "
|
|
1967
|
+
"that runs arbitrary code")
|
|
1968
|
+
return list(dict.fromkeys(f"Bash({c})" for c in commands if c)), None
|
|
1969
|
+
|
|
1970
|
+
|
|
1971
|
+
def propose_checks(db: DB, root_id: str, checks: list[str]) -> tuple[str, list[str]]:
|
|
1972
|
+
"""Record ``checks`` as awaiting the user's answer in brindle's dialog (no
|
|
1973
|
+
nudge meanwhile, see on_stop). Returns the dialog's text and the allow
|
|
1974
|
+
rules it offers."""
|
|
1975
|
+
rules, refusal = allow_rules(checks)
|
|
1976
|
+
db.update_autopilot(root_id, proposal=json.dumps(
|
|
1977
|
+
{"checks": checks, "rules": rules, "at": time.time()}))
|
|
1978
|
+
text = ("brindle: run these checks on every branch before it merges "
|
|
1979
|
+
"(saved to .brindle/config.json)?\n" + "\n".join(f" {c}" for c in checks))
|
|
1980
|
+
if rules:
|
|
1981
|
+
text += ("\n\nAlso allow their commands without a permission prompt "
|
|
1982
|
+
"(.claude/settings.local.json):\n" + "\n".join(f" {r}" for r in rules))
|
|
1983
|
+
else:
|
|
1984
|
+
text += f"\n\nbrindle won't offer to allow their commands: {refusal}."
|
|
1985
|
+
return text, rules
|
|
1986
|
+
|
|
1987
|
+
|
|
1988
|
+
def proposing(ap: Autopilot) -> bool:
|
|
1989
|
+
"""A check proposal is waiting on the user. One older than PROPOSAL_TTL
|
|
1990
|
+
is a dialog whose server died: it no longer holds autopilot."""
|
|
1991
|
+
try:
|
|
1992
|
+
at = float(json.loads(ap.proposal or "{}").get("at") or 0)
|
|
1993
|
+
except (ValueError, TypeError, AttributeError):
|
|
1994
|
+
return False
|
|
1995
|
+
return time.time() - at < PROPOSAL_TTL
|
|
1996
|
+
|
|
1997
|
+
|
|
1998
|
+
def add_checks(repo_root: str, checks: list[str], allow: bool = False) -> str:
|
|
1999
|
+
"""Add approved ``checks`` to .brindle/config.json and, with ``allow``, one
|
|
2000
|
+
narrow Bash allow rule per command in them to .claude/settings.local.json."""
|
|
2001
|
+
from brindle.config import CONFIG_FILE, _read_json
|
|
2002
|
+
|
|
2003
|
+
root = Path(config_root(repo_root))
|
|
2004
|
+
path = root / CONFIG_DIR / CONFIG_FILE
|
|
2005
|
+
data = _read_json(path)
|
|
2006
|
+
data["checks"] = [*(c for c in data.get("checks") or [] if c not in checks), *checks]
|
|
2007
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
2008
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
2009
|
+
text = f"Added {len(checks)} check(s) to {path}."
|
|
2010
|
+
if not allow:
|
|
2011
|
+
return text
|
|
2012
|
+
rules, refusal = allow_rules(checks)
|
|
2013
|
+
if refusal:
|
|
2014
|
+
return f"{text} Did not allow any commands: {refusal}. The user can add rules themselves."
|
|
2015
|
+
settings_path = root / ".claude" / "settings.local.json"
|
|
2016
|
+
settings = _read_json(settings_path)
|
|
2017
|
+
perms = settings.setdefault("permissions", {})
|
|
2018
|
+
allowed = perms.setdefault("allow", [])
|
|
2019
|
+
for rule in rules:
|
|
2020
|
+
if rule not in allowed:
|
|
2021
|
+
allowed.append(rule)
|
|
2022
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2023
|
+
settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
|
|
2024
|
+
return f"{text} Allowed their commands in {settings_path}."
|
|
2025
|
+
|
|
2026
|
+
|
|
1920
2027
|
def guide(cfg: RepoConfig, repo_root: str | None = None) -> str:
|
|
1921
2028
|
text = GUIDE.format(max_agents=cfg.max_agents or "any number of")
|
|
1922
2029
|
if repo_root and not cfg.checks:
|
|
@@ -6,6 +6,7 @@ api: openai # Ollama's OpenAI-compatible endpoint
|
|
|
6
6
|
base_url: http://localhost:11434/v1
|
|
7
7
|
model: qwen3-coder:30b # ollama pull qwen3-coder:30b (19 GB; needs ~24 GB of memory)
|
|
8
8
|
context_tokens: 32k # Ollama's default window is smaller: set OLLAMA_CONTEXT_LENGTH=40960 for `ollama serve`
|
|
9
|
+
fallback: developer-light # hosted Haiku, when Ollama isn't running or the model isn't pulled
|
|
9
10
|
permission_mode: acceptEdits # edits are free; commands need a rule below
|
|
10
11
|
allowed_tools: Bash(git add:*), Bash(git commit:*), Bash(git status:*), Bash(git ls-files:*), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(git rev-parse:*), Bash(git branch:*), Bash(pytest:*), Bash(python -m pytest:*), Bash(python3 -m pytest:*), Bash(python -m unittest:*), Bash(python3 -m unittest:*), Bash(uv run:*), Bash(uv sync:*), Bash(npm test:*), Bash(npm run:*), Bash(npm ci:*), Bash(pnpm test:*), Bash(pnpm run:*), Bash(pnpm install:*), Bash(yarn test:*), Bash(yarn run:*), Bash(cargo build:*), Bash(cargo test:*), Bash(cargo check:*), Bash(cargo clippy:*), Bash(go build:*), Bash(go test:*), Bash(go vet:*), Bash(make:*), Bash(swift build:*), Bash(swift test:*), Bash(ls:*), Bash(pwd), Bash(cat:*), Bash(tail:*), Bash(head:*), Bash(grep:*), Bash(wc:*)
|
|
11
12
|
---
|
|
@@ -6,6 +6,7 @@ api: openai
|
|
|
6
6
|
base_url: http://localhost:11434/v1
|
|
7
7
|
model: qwen3-coder:30b
|
|
8
8
|
context_tokens: 32k
|
|
9
|
+
fallback: reviewer # hosted, when Ollama isn't running or the model isn't pulled
|
|
9
10
|
permission_mode: dontAsk # nobody watches a reviewer: refuse what allowed_tools doesn't cover
|
|
10
11
|
allowed_tools: Bash(git status:*), Bash(git ls-files:*), Bash(git stash list), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(git merge-base:*), Bash(pytest:*), Bash(python -m pytest:*), Bash(python3 -m pytest:*), Bash(python -m unittest:*), Bash(python3 -m unittest:*), Bash(uv run:*), Bash(npm test:*), Bash(npm run:*), Bash(pnpm test:*), Bash(pnpm run:*), Bash(yarn test:*), Bash(yarn run:*), Bash(cargo test:*), Bash(cargo check:*), Bash(cargo clippy:*), Bash(go test:*), Bash(go vet:*), Bash(make:*), Bash(swift test:*), Bash(ls:*), Bash(pwd), Bash(cat:*), Bash(tail:*), Bash(head:*), Bash(grep:*), Bash(wc:*)
|
|
11
12
|
---
|
|
@@ -30,8 +30,22 @@ def db_path() -> Path:
|
|
|
30
30
|
return brindle_home() / "brindle.db"
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
WORKTREES_DIR = "worktrees"
|
|
34
|
+
|
|
35
|
+
|
|
33
36
|
def worktrees_dir() -> Path:
|
|
34
|
-
|
|
37
|
+
"""``~/.brindle/worktrees``, where worktrees went before they moved under
|
|
38
|
+
each repo (see ``worktree_root``). Existing ones there keep working."""
|
|
39
|
+
return brindle_home() / WORKTREES_DIR
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def worktree_root(repo_root: str | Path, cfg: RepoConfig | None = None) -> Path:
|
|
43
|
+
"""Where ``repo_root``'s new worktrees go: ``<repo>/.brindle/worktrees``,
|
|
44
|
+
or the repo's ``worktree_root`` (``~`` expanded; relative to the repo)."""
|
|
45
|
+
configured = (cfg or load_repo_config(repo_root)).worktree_root
|
|
46
|
+
if not configured:
|
|
47
|
+
return Path(repo_root) / CONFIG_DIR / WORKTREES_DIR
|
|
48
|
+
return Path(repo_root) / os.path.expanduser(configured)
|
|
35
49
|
|
|
36
50
|
|
|
37
51
|
def user_profiles_dir() -> Path:
|
|
@@ -80,6 +94,7 @@ class RepoConfig:
|
|
|
80
94
|
copy: list[str] = field(default_factory=list)
|
|
81
95
|
base_branch: str | None = None
|
|
82
96
|
branch_prefix: str = ""
|
|
97
|
+
worktree_root: str | None = None # where new worktrees go (None: <repo>/.brindle/worktrees; see worktree_root())
|
|
83
98
|
default_agent: str = "developer"
|
|
84
99
|
fetch: bool = True
|
|
85
100
|
auto_update: bool = False # upgrade the PyPI tool at session start
|
|
@@ -130,6 +145,9 @@ class RepoConfig:
|
|
|
130
145
|
# already fast). 0 disables the pool.
|
|
131
146
|
pool_size: int | None = None
|
|
132
147
|
add_dirs: list[str] = field(default_factory=list)
|
|
148
|
+
# Directories outside the worktree that workers may read (not write), e.g. a
|
|
149
|
+
# gitignored results/ folder in the main checkout.
|
|
150
|
+
worker_extra_read_dirs: list[str] = field(default_factory=list)
|
|
133
151
|
# Per-worktree Docker services (brindle Pro): [{"name", "preset"?, "image"?, "port"?, "env"?}]
|
|
134
152
|
# -- see brindle.services.
|
|
135
153
|
services: list[dict] = field(default_factory=list)
|
|
@@ -199,13 +217,22 @@ def config_root(path: str | Path) -> Path:
|
|
|
199
217
|
path = Path(path)
|
|
200
218
|
if (path / CONFIG_DIR).is_dir():
|
|
201
219
|
return path
|
|
220
|
+
main = linked_main(path)
|
|
221
|
+
return main if main is not None and (main / CONFIG_DIR).is_dir() else path
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def linked_main(path: str | Path) -> Path | None:
|
|
225
|
+
"""The main checkout of the linked worktree at ``path``, or None when
|
|
226
|
+
``path`` isn't one. A linked worktree's ``.git`` is a file saying
|
|
227
|
+
``gitdir: <main>/.git/worktrees/<name>``, and that folder's ``commondir``
|
|
228
|
+
names ``<main>/.git`` (relative to it), so no git process runs."""
|
|
229
|
+
path = Path(path)
|
|
202
230
|
try:
|
|
203
|
-
gitdir =
|
|
231
|
+
gitdir = path / (path / ".git").read_text(encoding="utf-8").split("gitdir:", 1)[1].strip()
|
|
204
232
|
common = (gitdir / (gitdir / "commondir").read_text(encoding="utf-8").strip()).resolve()
|
|
205
|
-
except (OSError, IndexError): # not a
|
|
206
|
-
return
|
|
207
|
-
|
|
208
|
-
return main if common.name == ".git" and (main / CONFIG_DIR).is_dir() else path
|
|
233
|
+
except (OSError, IndexError): # no .git file, or not a worktree's gitdir
|
|
234
|
+
return None
|
|
235
|
+
return common.parent if common.name == ".git" else None
|
|
209
236
|
|
|
210
237
|
|
|
211
238
|
def _default_permission_policy() -> str:
|
|
@@ -219,6 +246,25 @@ def _default_permission_policy() -> str:
|
|
|
219
246
|
return "off"
|
|
220
247
|
|
|
221
248
|
|
|
249
|
+
CHECK_RAM_GB = 2 # RAM budgeted per concurrent check run
|
|
250
|
+
CHECK_CONCURRENCY_CAP = 6
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _memory_gb() -> float:
|
|
254
|
+
try:
|
|
255
|
+
return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / 2**30
|
|
256
|
+
except (ValueError, OSError):
|
|
257
|
+
return 0.0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def host_check_concurrency() -> int:
|
|
261
|
+
"""Check runs at once when the repo doesn't say: half the logical cores and
|
|
262
|
+
RAM for ``CHECK_RAM_GB`` per run, never below 2 or above the cap (#169)."""
|
|
263
|
+
cores = os.cpu_count() or 1
|
|
264
|
+
by_ram = int(_memory_gb() // CHECK_RAM_GB)
|
|
265
|
+
return max(2, min(cores // 2, by_ram, CHECK_CONCURRENCY_CAP))
|
|
266
|
+
|
|
267
|
+
|
|
222
268
|
def load_repo_config(repo_root: str | Path) -> RepoConfig:
|
|
223
269
|
base = config_root(repo_root) / CONFIG_DIR
|
|
224
270
|
shared = _read_json(base / CONFIG_FILE)
|
|
@@ -226,10 +272,10 @@ def load_repo_config(repo_root: str | Path) -> RepoConfig:
|
|
|
226
272
|
user = user_settings()
|
|
227
273
|
|
|
228
274
|
cfg = RepoConfig()
|
|
229
|
-
for key in ("setup", "teardown", "copy", "checks", "add_dirs", "protected_paths"):
|
|
275
|
+
for key in ("setup", "teardown", "copy", "checks", "add_dirs", "worker_extra_read_dirs", "protected_paths"):
|
|
230
276
|
merged = _merge_commands(list(shared.get(key, [])), local.get(key))
|
|
231
277
|
setattr(cfg, key, merged)
|
|
232
|
-
for key in ("base_branch", "branch_prefix", "default_agent", "fetch", "auto_update", "autopilot", "review",
|
|
278
|
+
for key in ("base_branch", "branch_prefix", "worktree_root", "default_agent", "fetch", "auto_update", "autopilot", "review",
|
|
233
279
|
"reviewer", "review_profile", "pre_commit", "max_agents", "check_timeout",
|
|
234
280
|
"check_concurrency",
|
|
235
281
|
"usage_limit", "pool_size", "graphify", "stale_after", "pipeline",
|
|
@@ -248,6 +294,8 @@ def load_repo_config(repo_root: str | Path) -> RepoConfig:
|
|
|
248
294
|
if isinstance(rule, str) and rule.strip() and rule.strip() not in rules:
|
|
249
295
|
rules.append(rule.strip())
|
|
250
296
|
cfg.rules = rules
|
|
297
|
+
if not any("check_concurrency" in source for source in (local, shared, user)):
|
|
298
|
+
cfg.check_concurrency = host_check_concurrency()
|
|
251
299
|
if not any("permission_policy" in source for source in (local, shared, user)):
|
|
252
300
|
cfg.permission_policy = _default_permission_policy()
|
|
253
301
|
services = local["services"] if "services" in local else shared.get("services")
|