brindle 0.3.23__tar.gz → 0.3.24__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.23 → brindle-0.3.24}/PKG-INFO +2 -2
- {brindle-0.3.23 → brindle-0.3.24}/README.md +1 -1
- {brindle-0.3.23 → brindle-0.3.24}/pyproject.toml +1 -1
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/agents.py +54 -27
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/antigravity.py +3 -1
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/autopilot.py +4 -3
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/cli.py +43 -17
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/cloud_login.py +45 -7
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/company_identity.py +4 -1
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/company_login.py +26 -3
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/cull.py +11 -3
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/db.py +7 -2
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/doctor.py +17 -6
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/gates.py +1 -1
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/mcp_server.py +5 -2
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/picker.py +14 -1
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pipeline.py +138 -17
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/prompt_answer.py +17 -2
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/providers.py +40 -4
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/sidebar_follow.py +11 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/sidebar_logo.py +10 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/signin_pause.py +8 -2
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/tasks.py +59 -12
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/tmux.py +33 -16
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/upgrade.py +20 -4
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/view.py +2 -1
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/watch.py +25 -5
- {brindle-0.3.23 → brindle-0.3.24}/.gitignore +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/LICENSE +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/SCHEDULE-A +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/__init__.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/__main__.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/account.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/airgap.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/assets/braid_dark.png +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/assets/braid_light.png +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/budget.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/__init__.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/backend-auditor.md +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/developer-codex.md +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/developer-fable.md +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/developer-gemini.md +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/developer-heavy.md +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/developer-light.md +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/developer-local.md +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/developer.md +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/qa.md +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/reviewer-local.md +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/reviewer.md +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/subagent.md +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/supervisor.md +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_rules/__init__.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_rules/security/backend.md +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_rules/tests/only.md +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/ci_adapters.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/ci_client.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/ci_federation.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/ci_hosts.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/cloud_credential.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/codemap.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/codemap_docs.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/codex_hook.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/config.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/conflicts.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/cost.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/cost_estimate.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/demo.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/detect.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/escapes.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/events.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/git.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/guardrails.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/history.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/identity_lock.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/inbox.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/key_check.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/keys_cmds.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/keystore.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/launch_health.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/learned_rules.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/learning.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/legacy.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/local_setup.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/login_pause.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/model_access.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/model_availability.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/native/__init__.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/native/client.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/native/loop.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/native/permissions.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/native/runner.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/native/serve.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/native/tools.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pane_auth.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/permissions.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/plugins.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/policy.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pool.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pricing.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/__init__.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/_files.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/account.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/audit_chain.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/audit_export.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/auth.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/brief_learning.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/cost_centers.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/credentials.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/features.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/keys.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/learning.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/license.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/loopback.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/managed_models.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/org_profiles.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/orgkey.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/rollout.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/route_health.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/settings_sync.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/status.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/team_events.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/team_policy.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/procs.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/profiles.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/quota.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/repo_cmds.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/repos.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/rewind.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/rule_checks.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/savings.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/scratch.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/secrets.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/self_serve.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/services.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/sessions.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/status_cache.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/updates.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/usage.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/watch_costs.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/worker_models.py +0 -0
- {brindle-0.3.23 → brindle-0.3.24}/src/brindle/workspaces.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.24
|
|
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.24`). 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
|
|
@@ -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.24`). 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
|
|
@@ -1358,6 +1358,24 @@ def is_alive(agent: Agent, panes: dict[str, bool] | None = None) -> bool:
|
|
|
1358
1358
|
return tmux.window_alive(agent.tmux_window, server=agent.tmux_server or None)
|
|
1359
1359
|
|
|
1360
1360
|
|
|
1361
|
+
LAUNCH_GRACE = 60.0 # seconds a just-spawned agent may go without a window before it counts as dead
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def alive_or_starting(agent: Agent) -> bool:
|
|
1365
|
+
"""``is_alive``, or still being launched: spawn records the agent before
|
|
1366
|
+
its window opens (the launch's checks can take seconds), so one with no
|
|
1367
|
+
window yet is starting, not dead, for LAUNCH_GRACE seconds. A launch known
|
|
1368
|
+
to have failed ends that: spawn deletes the row of one that raised, and
|
|
1369
|
+
launch_health marks one whose CLI died at startup "failed". For the
|
|
1370
|
+
checks that would replace the agent (start another reviewer, forget an
|
|
1371
|
+
auditor), not for ones that talk to its pane."""
|
|
1372
|
+
if is_alive(agent):
|
|
1373
|
+
return True
|
|
1374
|
+
return (runs_process(agent) and not agent.tmux_window and agent.result is None
|
|
1375
|
+
and agent.status not in ("paused", "done", "failed")
|
|
1376
|
+
and time.time() - agent.created_at < LAUNCH_GRACE)
|
|
1377
|
+
|
|
1378
|
+
|
|
1361
1379
|
def server_of(agent: Agent) -> str | None:
|
|
1362
1380
|
"""The tmux server ``agent``'s window is on (a private socket name, or
|
|
1363
1381
|
None for the default server), for the ``server`` argument of tmux calls
|
|
@@ -1908,12 +1926,17 @@ def _detach(args: list[str]) -> None:
|
|
|
1908
1926
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
1909
1927
|
|
|
1910
1928
|
|
|
1929
|
+
# A shell that starts the command after it in the background and exits, so the
|
|
1930
|
+
# command is nobody's child (see _detach_orphan).
|
|
1931
|
+
ORPHANED = ["/bin/sh", "-c", '"$@" </dev/null >/dev/null 2>&1 &', "sh"]
|
|
1932
|
+
|
|
1933
|
+
|
|
1911
1934
|
def _detach_orphan(args: list[str]) -> None:
|
|
1912
1935
|
"""Start a brindle helper that outlives this process and its agent: a
|
|
1913
1936
|
shell starts it in the background and exits, so it is nobody's child.
|
|
1914
1937
|
Stopping an agent stops every process descended from its MCP server
|
|
1915
1938
|
(brindle.procs), a helper ``_detach`` started there included."""
|
|
1916
|
-
subprocess.run([
|
|
1939
|
+
subprocess.run([*ORPHANED, *args],
|
|
1917
1940
|
start_new_session=True, stdin=subprocess.DEVNULL,
|
|
1918
1941
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
|
|
1919
1942
|
|
|
@@ -2350,7 +2373,7 @@ def _live_reviewer(db: DB, ws: Workspace, sha: str, profile: str | None = None)
|
|
|
2350
2373
|
``profile``, only one running that profile (a second review by another
|
|
2351
2374
|
profile is not a duplicate)."""
|
|
2352
2375
|
for a in db.list_agents(ws.id):
|
|
2353
|
-
if a.mode == "review" and a.result is None and
|
|
2376
|
+
if a.mode == "review" and a.result is None and alive_or_starting(a) and a.review_sha == sha \
|
|
2354
2377
|
and (profile is None or a.profile == profile):
|
|
2355
2378
|
return a
|
|
2356
2379
|
return None
|
|
@@ -2386,7 +2409,9 @@ def start_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | Non
|
|
|
2386
2409
|
argv += ["--profile", profile]
|
|
2387
2410
|
if focus:
|
|
2388
2411
|
argv += ["--focus", focus]
|
|
2389
|
-
|
|
2412
|
+
# Nobody's child: the worker reporting here may be closed while the checks
|
|
2413
|
+
# still run, and stopping it would stop this run too, so no reviewer (#190).
|
|
2414
|
+
_detach([*ORPHANED, *argv])
|
|
2390
2415
|
return None
|
|
2391
2416
|
|
|
2392
2417
|
|
|
@@ -2417,34 +2442,36 @@ def review_after_checks(db: DB, caller_id: str | None, ws: Workspace, profile: s
|
|
|
2417
2442
|
a new report (its worker committed again, or merged its moved base in)
|
|
2418
2443
|
is checked again on its new head, up to ``MOVED_RETRIES`` times, and then
|
|
2419
2444
|
goes to the supervisor. Raises ``AgentError`` when the reviewer can't
|
|
2420
|
-
start.
|
|
2445
|
+
start. Holds ``pipeline.review_run`` throughout, so the sweep can tell
|
|
2446
|
+
this run at work (queued for a slot, say) from one that died."""
|
|
2421
2447
|
from brindle import gates, pipeline
|
|
2422
2448
|
from brindle.config import load_repo_config
|
|
2423
2449
|
|
|
2424
2450
|
cfg = cfg or load_repo_config(ws.repo_root)
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2451
|
+
with pipeline.review_run(ws.id):
|
|
2452
|
+
since = pipeline.review_claim(db, ws)
|
|
2453
|
+
for _attempt in range(MOVED_RETRIES + 1):
|
|
2454
|
+
sha = gates.head(ws)
|
|
2455
|
+
if _live_reviewer(db, ws, sha, profile):
|
|
2456
|
+
return None
|
|
2457
|
+
try:
|
|
2458
|
+
summary = gates.check_summary(db, ws, cfg, cancel=lambda sha=sha: gates.head(ws) != sha)
|
|
2459
|
+
except gates.Abandoned:
|
|
2460
|
+
summary = None
|
|
2461
|
+
except Exception as e: # noqa: BLE001 - the review goes ahead, saying so
|
|
2462
|
+
summary = f"(running the checks crashed: {e})"
|
|
2463
|
+
if db.get_workspace(ws.id) is None:
|
|
2464
|
+
return None
|
|
2465
|
+
if gates.head(ws) == sha:
|
|
2466
|
+
break
|
|
2467
|
+
if not pipeline.still_claimed(db, ws, since):
|
|
2468
|
+
return None
|
|
2469
|
+
else:
|
|
2470
|
+
pipeline.on_moved_during_checks(db, ws)
|
|
2441
2471
|
return None
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
return
|
|
2445
|
-
caller = db.get_agent(caller_id) if caller_id else None
|
|
2446
|
-
text = f"Checks for {ws.branch} at {sha[:8]}:\n\n{summary}" if summary else None
|
|
2447
|
-
return request_review(db, caller, ws, profile, focus, cfg, check_summary=text)
|
|
2472
|
+
caller = db.get_agent(caller_id) if caller_id else None
|
|
2473
|
+
text = f"Checks for {ws.branch} at {sha[:8]}:\n\n{summary}" if summary else None
|
|
2474
|
+
return request_review(db, caller, ws, profile, focus, cfg, check_summary=text)
|
|
2448
2475
|
|
|
2449
2476
|
|
|
2450
2477
|
def request_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | None = None,
|
|
@@ -2469,7 +2496,7 @@ def request_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | N
|
|
|
2469
2496
|
cfg = cfg or load_repo_config(ws.repo_root)
|
|
2470
2497
|
sha = gates.head(ws)
|
|
2471
2498
|
for old in db.list_agents(ws.id):
|
|
2472
|
-
if old.mode != "review" or old.result is not None or not
|
|
2499
|
+
if old.mode != "review" or old.result is not None or not alive_or_starting(old):
|
|
2473
2500
|
continue
|
|
2474
2501
|
if old.unreported_noted:
|
|
2475
2502
|
continue # it stopped without a verdict: not the reviewer of anything
|
|
@@ -110,7 +110,9 @@ def binary() -> str:
|
|
|
110
110
|
explicit = os.environ.get("BRINDLE_AGY_BIN")
|
|
111
111
|
if explicit:
|
|
112
112
|
return explicit
|
|
113
|
-
|
|
113
|
+
from brindle.providers import which_cli
|
|
114
|
+
|
|
115
|
+
return which_cli("agy") or (BUNDLED if os.path.exists(BUNDLED) else "agy")
|
|
114
116
|
|
|
115
117
|
|
|
116
118
|
def tool_names() -> list[str]:
|
|
@@ -1823,9 +1823,10 @@ def on_stop(db: DB, agent: Agent, payload: dict) -> dict | None:
|
|
|
1823
1823
|
if ap.state == "auditing":
|
|
1824
1824
|
if ap.auditor_id:
|
|
1825
1825
|
auditor = db.get_agent(ap.auditor_id)
|
|
1826
|
-
if auditor is None or auditor.status in ("paused", "done") or not agents.
|
|
1827
|
-
|
|
1828
|
-
#
|
|
1826
|
+
if auditor is None or auditor.status in ("paused", "done") or not agents.alive_or_starting(auditor) \
|
|
1827
|
+
or auditor.result is not None:
|
|
1828
|
+
# Auditor was spawned but is no longer running (dead or crashed without verdict;
|
|
1829
|
+
# one still starting isn't). Reset state to running so autopilot can recover.
|
|
1829
1830
|
db.update_autopilot(agent.id, state="running", auditor_id=None)
|
|
1830
1831
|
ap = db.get_autopilot(agent.id)
|
|
1831
1832
|
else:
|
|
@@ -35,6 +35,7 @@ class _App(typer.Typer):
|
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
SUPERVISOR_FLAGS = ("-s", "--supervisor")
|
|
38
|
+
_group_supervisor: Optional[str] = None # -s given before the subcommand (set by default())
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
def _bare_supervisor_flag(args: list[str], commands) -> list[str]:
|
|
@@ -44,6 +45,11 @@ def _bare_supervisor_flag(args: list[str], commands) -> list[str]:
|
|
|
44
45
|
value."""
|
|
45
46
|
from brindle.picker import PICK
|
|
46
47
|
|
|
48
|
+
split = []
|
|
49
|
+
for a in args:
|
|
50
|
+
# Click would read "-s=codex" as the value "=codex".
|
|
51
|
+
split += ["-s", a[3:]] if a.startswith("-s=") else [a]
|
|
52
|
+
args = split
|
|
47
53
|
out = []
|
|
48
54
|
for i, a in enumerate(args):
|
|
49
55
|
out.append(a)
|
|
@@ -306,7 +312,18 @@ def start(
|
|
|
306
312
|
from brindle.config import load_repo_config
|
|
307
313
|
from brindle import scratch
|
|
308
314
|
|
|
309
|
-
|
|
315
|
+
from brindle import picker
|
|
316
|
+
from brindle.providers import NOT_SUPERVISOR, SUPERVISOR_PROVIDERS, get_provider
|
|
317
|
+
|
|
318
|
+
provider = provider or provider_alias or _group_supervisor
|
|
319
|
+
if provider and provider != picker.PICK:
|
|
320
|
+
if provider in NOT_SUPERVISOR:
|
|
321
|
+
_fail(f"the {provider} provider can't run the supervisor; use one of: "
|
|
322
|
+
f"{', '.join(SUPERVISOR_PROVIDERS)}")
|
|
323
|
+
try:
|
|
324
|
+
get_provider(provider)
|
|
325
|
+
except KeyError as e:
|
|
326
|
+
_fail(str(e).strip("'\""))
|
|
310
327
|
db = DB()
|
|
311
328
|
if branch or worktree:
|
|
312
329
|
ws = _run(workspaces.checkout_for, db, os.getcwd(), branch=branch, worktree=worktree)
|
|
@@ -314,7 +331,7 @@ def start(
|
|
|
314
331
|
ws = _here_or_scratch(db, reuse_scratch=False)
|
|
315
332
|
running = _running_session(db, ws)
|
|
316
333
|
if running and attach and sys.stdin.isatty():
|
|
317
|
-
choice = _ask_about_running(running)
|
|
334
|
+
choice = _ask_about_running(running, provider)
|
|
318
335
|
if choice == "o":
|
|
319
336
|
_attach(ws, running.tmux_window)
|
|
320
337
|
return
|
|
@@ -324,30 +341,27 @@ def start(
|
|
|
324
341
|
|
|
325
342
|
if not branch and not worktree and not scratch.is_scratch(ws.path):
|
|
326
343
|
curr_b = git.current_branch(ws.path)
|
|
344
|
+
default_b = git.default_branch(ws.repo_root) if curr_b else None
|
|
327
345
|
if curr_b and git.merged_into_default(ws.repo_root, curr_b):
|
|
328
|
-
default_b = git.default_branch(ws.repo_root)
|
|
329
346
|
typer.secho(f"! Branch '{curr_b}' is already merged into '{default_b}'.", fg="yellow")
|
|
330
347
|
try:
|
|
331
348
|
if not git.dirty_files(ws.path):
|
|
332
349
|
git.run(["checkout", default_b], ws.path)
|
|
333
350
|
ws = workspaces.refresh_branch(db, ws)
|
|
351
|
+
curr_b = default_b
|
|
334
352
|
typer.secho(f"✓ Switched to '{default_b}'.", fg="green")
|
|
335
353
|
except Exception:
|
|
336
354
|
pass
|
|
337
|
-
elif curr_b and git.upstream_gone(ws.path, curr_b):
|
|
355
|
+
elif curr_b != default_b and git.upstream_gone(ws.path, curr_b):
|
|
356
|
+
# (the default branch is left out: a network probe for the common case)
|
|
338
357
|
typer.secho(f"! Branch '{curr_b}' was deleted on origin but has unmerged changes.", fg="yellow")
|
|
339
358
|
try:
|
|
340
|
-
if curr_b and
|
|
341
|
-
if warning := git.fast_forward(ws.path,
|
|
359
|
+
if curr_b and curr_b == default_b:
|
|
360
|
+
if warning := git.fast_forward(ws.path, curr_b):
|
|
342
361
|
typer.secho(f"! {warning}", fg="yellow")
|
|
343
362
|
except git.GitError:
|
|
344
363
|
pass
|
|
345
364
|
|
|
346
|
-
from brindle.providers import NOT_SUPERVISOR, SUPERVISOR_PROVIDERS
|
|
347
|
-
|
|
348
|
-
if provider and provider in NOT_SUPERVISOR:
|
|
349
|
-
_fail(f"the {provider} provider can't run the supervisor; use one of: "
|
|
350
|
-
f"{', '.join(SUPERVISOR_PROVIDERS)}")
|
|
351
365
|
provider = _resolve_supervisor(ws.repo_root, provider, yes=yes, demo=demo)
|
|
352
366
|
_preflight(agents.SUPERVISOR, provider, ws.repo_root)
|
|
353
367
|
_trust_codex_hook(ws.repo_root)
|
|
@@ -474,14 +488,12 @@ def _trust_codex_hook(repo_root: str) -> None:
|
|
|
474
488
|
hook if it isn't yet (or brindle moved), and say so in one line, so Codex
|
|
475
489
|
workers follow the policy from their first launch. A trust the person
|
|
476
490
|
removed stays removed; `brindle doctor` points at it."""
|
|
477
|
-
import shutil
|
|
478
|
-
|
|
479
491
|
from brindle import codex_hook
|
|
480
|
-
from brindle.providers import codex_binary
|
|
492
|
+
from brindle.providers import codex_binary, which_cli
|
|
481
493
|
|
|
482
494
|
try:
|
|
483
495
|
binary = codex_binary()
|
|
484
|
-
if not codex_hook.policy_on(repo_root) or not (
|
|
496
|
+
if not codex_hook.policy_on(repo_root) or not (which_cli(binary) or os.path.isfile(binary)):
|
|
485
497
|
return
|
|
486
498
|
command = codex_hook.hook_command()
|
|
487
499
|
before = codex_hook.status(command)
|
|
@@ -562,12 +574,21 @@ def _running_session(db: DB, ws: Workspace):
|
|
|
562
574
|
return live[0] if live else None
|
|
563
575
|
|
|
564
576
|
|
|
565
|
-
def _ask_about_running(running) -> str:
|
|
577
|
+
def _ask_about_running(running, wanted: Optional[str] = None) -> str:
|
|
578
|
+
"""Open, branch off or pause the session running here. ``wanted`` is the
|
|
579
|
+
CLI asked for with -s: when the running session is on another one,
|
|
580
|
+
opening it wouldn't give that, so the default is to start fresh."""
|
|
566
581
|
import click
|
|
567
582
|
|
|
583
|
+
from brindle import picker
|
|
584
|
+
|
|
585
|
+
differs = bool(wanted) and wanted != picker.PICK and wanted != running.provider
|
|
568
586
|
typer.echo(f"A brindle session is already running here ({running.id}).")
|
|
587
|
+
if differs:
|
|
588
|
+
typer.echo(f"It runs on {running.provider}, not {wanted}; opening it keeps {running.provider}.")
|
|
569
589
|
typer.echo(" [o] open it [n] new session in its own worktree [p] pause it and start fresh")
|
|
570
|
-
return typer.prompt("Which", type=click.Choice(["o", "n", "p"]), default="o",
|
|
590
|
+
return typer.prompt("Which", type=click.Choice(["o", "n", "p"]), default="p" if differs else "o",
|
|
591
|
+
show_choices=False)
|
|
571
592
|
|
|
572
593
|
|
|
573
594
|
def _session_worktree(db: DB, ws: Workspace) -> Workspace:
|
|
@@ -874,6 +895,8 @@ def default(
|
|
|
874
895
|
) -> None:
|
|
875
896
|
"""Bare `brindle`: a fresh supervisor chat here (a scratch session outside git)."""
|
|
876
897
|
provider = provider or provider_alias
|
|
898
|
+
global _group_supervisor
|
|
899
|
+
_group_supervisor = provider if ctx.invoked_subcommand else None # `-s codex start`
|
|
877
900
|
if show_version:
|
|
878
901
|
from brindle import __version__
|
|
879
902
|
|
|
@@ -888,6 +911,9 @@ def default(
|
|
|
888
911
|
legacy_root = None
|
|
889
912
|
legacy.warn_once(legacy_root)
|
|
890
913
|
if cont:
|
|
914
|
+
if provider:
|
|
915
|
+
_fail("--continue resumes the session on the CLI it had; drop -s, "
|
|
916
|
+
"or run `brindle -s <cli>` to start fresh")
|
|
891
917
|
continue_cmd(session_id=None, attach=True)
|
|
892
918
|
else:
|
|
893
919
|
start(prompt=None, provider=provider, attach=True, watch=True,
|
|
@@ -131,6 +131,30 @@ def _aws(env: Mapping[str, str], home: Path, now: float) -> LoginStatus:
|
|
|
131
131
|
return _from_expiry(expires, refresh, now, "no AWS SSO login found for this profile")
|
|
132
132
|
|
|
133
133
|
|
|
134
|
+
def run_bounded(argv: list[str], *, capture_output: bool = True, text: bool = True,
|
|
135
|
+
timeout: float = CLI_TIMEOUT, env=None) -> subprocess.CompletedProcess:
|
|
136
|
+
"""``subprocess.run`` for a cloud CLI that leaves nothing behind: the CLI
|
|
137
|
+
gets no terminal and its own process group, and a timeout ends the whole
|
|
138
|
+
group (gcloud is a wrapper around python, which may hold a local auth
|
|
139
|
+
server and its port), not just the wrapper."""
|
|
140
|
+
with subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
|
141
|
+
stderr=subprocess.PIPE, text=text, env=env,
|
|
142
|
+
start_new_session=True) as proc:
|
|
143
|
+
try:
|
|
144
|
+
out, err = proc.communicate(timeout=timeout)
|
|
145
|
+
except BaseException:
|
|
146
|
+
try:
|
|
147
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
148
|
+
except OSError:
|
|
149
|
+
proc.kill()
|
|
150
|
+
raise # Popen's exit reaps the wrapper
|
|
151
|
+
try: # a server the CLI left running (trade-off: the group id could in theory
|
|
152
|
+
os.killpg(proc.pid, signal.SIGKILL) # be reused once the group is empty)
|
|
153
|
+
except OSError:
|
|
154
|
+
pass
|
|
155
|
+
return subprocess.CompletedProcess(argv, proc.returncode, out, err)
|
|
156
|
+
|
|
157
|
+
|
|
134
158
|
def _full_env(env: Mapping[str, str] | None) -> dict[str, str] | None:
|
|
135
159
|
"""The environment for a CLI call: the supplied one over the ambient one,
|
|
136
160
|
or None (inherit) when the caller supplied none."""
|
|
@@ -203,7 +227,7 @@ def _google(run, env, now: float, expiry) -> LoginStatus:
|
|
|
203
227
|
|
|
204
228
|
def status(provider: str | None = None, *, env: Mapping[str, str] | None = None,
|
|
205
229
|
home: str | Path | None = None, now: float | None = None,
|
|
206
|
-
run=
|
|
230
|
+
run=run_bounded, expiry=tokeninfo_expiry) -> LoginStatus:
|
|
207
231
|
"""The login state for ``provider`` (bedrock, vertex or foundry; by default
|
|
208
232
|
the one the environment's ``CLAUDE_CODE_USE_*`` flag selects)."""
|
|
209
233
|
supplied = env
|
|
@@ -222,7 +246,7 @@ def status(provider: str | None = None, *, env: Mapping[str, str] | None = None,
|
|
|
222
246
|
|
|
223
247
|
def refresh(provider: str | None = None, *, env: Mapping[str, str] | None = None,
|
|
224
248
|
home: str | Path | None = None, now: float | None = None,
|
|
225
|
-
run=
|
|
249
|
+
run=run_bounded, expiry=tokeninfo_expiry) -> LoginStatus:
|
|
226
250
|
"""Renew the login without the person where the CLI can, and return the
|
|
227
251
|
status after. Asking gcloud or az for an access token (as ``status`` does)
|
|
228
252
|
renews it from the stored refresh token; the AWS CLI renews an SSO token
|
|
@@ -417,8 +441,8 @@ def _dead_at(last: dict, running: bool, now: float) -> float | None:
|
|
|
417
441
|
|
|
418
442
|
def sign_in(provider: str | None = None, *, env: Mapping[str, str] | None = None,
|
|
419
443
|
home: str | Path | None = None, now: float | None = None,
|
|
420
|
-
last_input: float | None = None, run=
|
|
421
|
-
popen=None, kill=None, platform: str | None = None) -> str:
|
|
444
|
+
last_input: float | None = None, run=run_bounded, expiry=tokeninfo_expiry,
|
|
445
|
+
popen=None, kill=None, platform: str | None = None, which=None) -> str:
|
|
422
446
|
"""Open the browser sign-in a lapsed login needs: one tab machine-wide,
|
|
423
447
|
when the person is there to use it.
|
|
424
448
|
|
|
@@ -454,10 +478,17 @@ def sign_in(provider: str | None = None, *, env: Mapping[str, str] | None = None
|
|
|
454
478
|
last = lg.read()
|
|
455
479
|
running = not _locked(fd)
|
|
456
480
|
this_lapse = last.get("lapse") == lg.fingerprint() and "opened_at" in last
|
|
481
|
+
ended = False
|
|
457
482
|
if this_lapse:
|
|
458
483
|
dead_at = _dead_at(last, running, now)
|
|
459
484
|
if dead_at is None:
|
|
460
485
|
return "waiting"
|
|
486
|
+
if running and isinstance(last.get("pid"), int):
|
|
487
|
+
try: # past TAB_LIFE its code is useless: don't leave its auth server up
|
|
488
|
+
(kill or _end_signin)(last["pid"])
|
|
489
|
+
except OSError:
|
|
490
|
+
pass
|
|
491
|
+
ended = True
|
|
461
492
|
if not running and "dead_at" not in last:
|
|
462
493
|
lg.write({**last, "dead_at": dead_at}) # we hold the lock
|
|
463
494
|
floor = float(last["opened_at"]) + REOPEN_FLOOR
|
|
@@ -470,7 +501,7 @@ def sign_in(provider: str | None = None, *, env: Mapping[str, str] | None = None
|
|
|
470
501
|
if not s.expired or not s.refresh_cmd:
|
|
471
502
|
return "no lapse"
|
|
472
503
|
if running: # a dead tab, or one left from an earlier lapse: end it, take its lock
|
|
473
|
-
if isinstance(last.get("pid"), int):
|
|
504
|
+
if isinstance(last.get("pid"), int) and not ended:
|
|
474
505
|
try:
|
|
475
506
|
(kill or _end_signin)(last["pid"])
|
|
476
507
|
except OSError:
|
|
@@ -480,13 +511,20 @@ def sign_in(provider: str | None = None, *, env: Mapping[str, str] | None = None
|
|
|
480
511
|
if time.monotonic() > deadline:
|
|
481
512
|
return "waiting"
|
|
482
513
|
time.sleep(0.1)
|
|
514
|
+
child_env = _full_env(env)
|
|
483
515
|
if not _browser_here(lg.env, sys.platform if platform is None else platform):
|
|
484
|
-
|
|
516
|
+
# WSL2 has no Linux browser, but the Windows one can open the URL.
|
|
517
|
+
from brindle import providers
|
|
518
|
+
|
|
519
|
+
opener = None if lg.env.get("SSH_CONNECTION") or lg.env.get("SSH_TTY") else providers.windows_browser(which)
|
|
520
|
+
if not opener:
|
|
521
|
+
return "manual"
|
|
522
|
+
child_env = {**(os.environ if child_env is None else child_env), "BROWSER": opener}
|
|
485
523
|
try:
|
|
486
524
|
# The child inherits the locked descriptor: the lock is held until
|
|
487
525
|
# the sign-in exits, however long the person takes.
|
|
488
526
|
proc = (popen or subprocess.Popen)(
|
|
489
|
-
shlex.split(s.refresh_cmd), env=
|
|
527
|
+
shlex.split(s.refresh_cmd), env=child_env, stdin=subprocess.DEVNULL,
|
|
490
528
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
491
529
|
start_new_session=True, pass_fds=(fd,))
|
|
492
530
|
except OSError:
|
|
@@ -43,6 +43,7 @@ CACHE_SECONDS = 30.0
|
|
|
43
43
|
LAUNCH_CACHE_SECONDS = 300.0
|
|
44
44
|
LAUNCH_CACHE_FILE = "identity-launch-cache.json"
|
|
45
45
|
PROBE_TIMEOUT = 20.0
|
|
46
|
+
RETRY_FACTOR = 3
|
|
46
47
|
PERSONAL_PLANS = ("free", "pro", "max")
|
|
47
48
|
|
|
48
49
|
# Under an enforced company identity (Enterprise), what can pick the account a
|
|
@@ -251,6 +252,8 @@ def _probe(argv: list[str], cache: bool = True) -> tuple[int, str] | None:
|
|
|
251
252
|
if hit and now - hit[0] < CACHE_SECONDS:
|
|
252
253
|
return hit[1]
|
|
253
254
|
code, out = company_login.run_cmd(argv, PROBE_TIMEOUT)
|
|
255
|
+
if code == 124: # a healthy CLI can miss the timeout on a loaded machine: once more, longer
|
|
256
|
+
code, out = company_login.run_cmd(argv, PROBE_TIMEOUT * RETRY_FACTOR)
|
|
254
257
|
if code in (124, 127):
|
|
255
258
|
return None
|
|
256
259
|
_cache[key] = (now, (code, out))
|
|
@@ -279,7 +282,7 @@ class Identity:
|
|
|
279
282
|
|
|
280
283
|
|
|
281
284
|
def _unknown(provider: str, tool: str, why: str = "", exe: str = "") -> Identity:
|
|
282
|
-
install = company_login.
|
|
285
|
+
install = company_login.install_hint(tool) if tool in company_login.INSTALL else None
|
|
283
286
|
if why:
|
|
284
287
|
return Identity(provider, f"{tool}: {why}", determined=False)
|
|
285
288
|
if company_login.which(exe or tool):
|
|
@@ -18,6 +18,7 @@ import os
|
|
|
18
18
|
import re
|
|
19
19
|
import shutil
|
|
20
20
|
import subprocess
|
|
21
|
+
import sys
|
|
21
22
|
import tempfile
|
|
22
23
|
from pathlib import Path
|
|
23
24
|
from typing import Callable
|
|
@@ -38,6 +39,28 @@ INSTALL = {
|
|
|
38
39
|
"codex": "npm install -g @openai/codex",
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
# What to do instead on Linux (and WSL), where there is no brew and a global
|
|
43
|
+
# npm install into a root-owned prefix fails with EACCES. INSTALL stays the
|
|
44
|
+
# command brindle itself runs.
|
|
45
|
+
NPM_PREFIX_NOTE = "on Linux, if that fails with EACCES: `npm config set prefix ~/.local`, then retry (no sudo)"
|
|
46
|
+
INSTALL_LINUX = {
|
|
47
|
+
"aws": "Linux: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html "
|
|
48
|
+
"(or `sudo snap install aws-cli --classic`)",
|
|
49
|
+
"gcloud": "Linux: `sudo snap install google-cloud-cli --classic` (or https://cloud.google.com/sdk/docs/install)",
|
|
50
|
+
"az": "Linux: `curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash` "
|
|
51
|
+
"(https://learn.microsoft.com/cli/azure/install-azure-cli-linux)",
|
|
52
|
+
"claude": NPM_PREFIX_NOTE,
|
|
53
|
+
"codex": NPM_PREFIX_NOTE,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def install_hint(tool: str) -> str:
|
|
58
|
+
"""How to install ``tool``, with the Linux alternative appended on Linux."""
|
|
59
|
+
hint = INSTALL[tool]
|
|
60
|
+
if sys.platform.startswith("linux"):
|
|
61
|
+
hint += f" [{INSTALL_LINUX[tool]}]"
|
|
62
|
+
return hint
|
|
63
|
+
|
|
41
64
|
|
|
42
65
|
def which(name: str) -> str | None:
|
|
43
66
|
return shutil.which(name)
|
|
@@ -50,7 +73,7 @@ def run_cmd(argv: list[str], timeout: float = CHECK_TIMEOUT, interactive: bool =
|
|
|
50
73
|
can't run or times out is returncode 127 or 124."""
|
|
51
74
|
try:
|
|
52
75
|
if interactive:
|
|
53
|
-
proc = subprocess.run(argv, timeout=timeout, check=False)
|
|
76
|
+
proc = subprocess.run(argv, timeout=timeout, check=False, env=env)
|
|
54
77
|
return proc.returncode, ""
|
|
55
78
|
# Never hand a probe the terminal: `claude` turns newline translation
|
|
56
79
|
# back on when it exits, which shifts the curses sidebar's drawing.
|
|
@@ -274,7 +297,7 @@ class Login:
|
|
|
274
297
|
def _need(self, tool: str) -> bool:
|
|
275
298
|
if which(tool):
|
|
276
299
|
return True
|
|
277
|
-
self.say(f"{tool} isn't installed; install it with: {
|
|
300
|
+
self.say(f"{tool} isn't installed; install it with: {install_hint(tool)}")
|
|
278
301
|
return False
|
|
279
302
|
|
|
280
303
|
def _ensure(self, tool: str, check: list[str], login: list[str], what: str) -> bool:
|
|
@@ -402,7 +425,7 @@ class Login:
|
|
|
402
425
|
binary = which("claude")
|
|
403
426
|
env = {**os.environ, **claude_env(self.org_id, c)}
|
|
404
427
|
if models and not binary:
|
|
405
|
-
self.say(f"claude isn't installed; couldn't check model access (install: {
|
|
428
|
+
self.say(f"claude isn't installed; couldn't check model access (install: {install_hint('claude')})")
|
|
406
429
|
return False
|
|
407
430
|
# Probe from an empty directory: a repo's own .claude settings and hooks
|
|
408
431
|
# must not run while we sign in.
|
|
@@ -49,7 +49,7 @@ from brindle.db import DB, Agent, Workspace
|
|
|
49
49
|
from brindle.pro import route_health
|
|
50
50
|
|
|
51
51
|
# An agent this new may not have its window recorded yet.
|
|
52
|
-
LAUNCH_GRACE =
|
|
52
|
+
LAUNCH_GRACE = agents.LAUNCH_GRACE
|
|
53
53
|
# How long a message waits for its normal delivery (a hook, the start-up
|
|
54
54
|
# flush) before a sweep retries it.
|
|
55
55
|
FLUSH_RETRY_AFTER = 30.0
|
|
@@ -431,7 +431,9 @@ def note_reported_stuck(db: DB, now: float, panes: dict[str, bool]) -> list[str]
|
|
|
431
431
|
"""Tell each supervisor, once per spell, about a worker that reported but
|
|
432
432
|
whose work went nowhere: its branch waits in the pipeline for a review no
|
|
433
433
|
reviewer is doing (none running, no verdict for its commit) for
|
|
434
|
-
UNPICKED_AFTER seconds, or twice the repo's checks if they take longer
|
|
434
|
+
UNPICKED_AFTER seconds, or twice the repo's checks if they take longer
|
|
435
|
+
(once nothing checks it either, the review is first started again: see
|
|
436
|
+
pipeline.restart_review);
|
|
435
437
|
or it's sitting idle with brindle messages it hasn't taken for
|
|
436
438
|
UNDELIVERED_AFTER seconds. Nobody else would notice either: the
|
|
437
439
|
supervisor waits for a message that never comes."""
|
|
@@ -467,10 +469,16 @@ def note_reported_stuck(db: DB, now: float, panes: dict[str, bool]) -> list[str]
|
|
|
467
469
|
# later reviewer's start (a fallback review, say).
|
|
468
470
|
reviewers = [r for r in db.list_agents(ws.id) if r.mode == "review"]
|
|
469
471
|
since = max([a.reviewing_since] + [r.created_at for r in reviewers])
|
|
470
|
-
if (now - since <
|
|
472
|
+
if (now - since < pipeline.RESTART_REVIEW_AFTER or db.latest_review(ws.id, head)
|
|
471
473
|
or any(r.dismissed_at is None and r.status not in ("paused", "done")
|
|
472
474
|
and agents.is_alive(r, panes) for r in reviewers)):
|
|
473
475
|
continue
|
|
476
|
+
# Its check run died (#186, #190): start it again before asking anyone.
|
|
477
|
+
if pipeline.restart_review(db, a, ws, since):
|
|
478
|
+
done.append(f"restarted the review of `{ws.branch}`, which nothing was working on")
|
|
479
|
+
continue
|
|
480
|
+
if now - since < max(UNPICKED_AFTER, 2 * wait):
|
|
481
|
+
continue
|
|
474
482
|
body = (f"Worker {a.id} reported on {where}, and for {int((now - since) // 60)} min no "
|
|
475
483
|
f"reviewer has been running and its commit {head[:8]} has had no verdict. Call "
|
|
476
484
|
f"request_review(\"{ws.id}\") to get it reviewed.")
|
|
@@ -69,7 +69,9 @@ CREATE TABLE IF NOT EXISTS agents (
|
|
|
69
69
|
tmux_server TEXT, -- the private tmux server (BRINDLE_TMUX_SOCKET) its window is on; NULL: the default server
|
|
70
70
|
reopened_at REAL, -- when a dead reviewer's session was reopened once (pipeline.on_reviewer_unreported)
|
|
71
71
|
hooks_off TEXT, -- why its CLI runs none of brindle's hooks, shown on its sidebar row (agents.screen_status)
|
|
72
|
-
reviewing_since REAL
|
|
72
|
+
reviewing_since REAL, -- when its last report put its branch in review (pipeline.on_report)
|
|
73
|
+
review_restarted REAL, -- the wait in review whose dead check run was started again (pipeline.restart_review)
|
|
74
|
+
merge_failed TEXT -- why the pipeline couldn't merge its approved branch (pipeline.review_state)
|
|
73
75
|
);
|
|
74
76
|
CREATE TABLE IF NOT EXISTS inbox (
|
|
75
77
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -464,6 +466,8 @@ class Agent:
|
|
|
464
466
|
reopened_at: float | None = None
|
|
465
467
|
hooks_off: str | None = None # why its CLI runs none of brindle's hooks (agents.screen_status)
|
|
466
468
|
reviewing_since: float | None = None # when its last report put its branch in review (pipeline.on_report)
|
|
469
|
+
review_restarted: float | None = None # the wait in review whose review was started again (pipeline.restart_review)
|
|
470
|
+
merge_failed: str | None = None # why the pipeline couldn't merge its approved branch (pipeline.review_state)
|
|
467
471
|
|
|
468
472
|
|
|
469
473
|
@dataclass
|
|
@@ -712,7 +716,8 @@ class DB:
|
|
|
712
716
|
for col, kind in (("plan_first", "INTEGER"), ("plan_state", "TEXT"),
|
|
713
717
|
("tmux_server", "TEXT"), ("stop_reminded_at", "REAL"),
|
|
714
718
|
("reopened_at", "REAL"), ("hooks_off", "TEXT"),
|
|
715
|
-
("reviewing_since", "REAL"))
|
|
719
|
+
("reviewing_since", "REAL"), ("review_restarted", "REAL"),
|
|
720
|
+
("merge_failed", "TEXT")):
|
|
716
721
|
if col not in cols:
|
|
717
722
|
self.conn.execute(f"ALTER TABLE agents ADD COLUMN {col} {kind}")
|
|
718
723
|
task_cols = {r["name"] for r in self.conn.execute("PRAGMA table_info(tasks)")}
|