brindle 0.3.12__tar.gz → 0.3.13__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.12 → brindle-0.3.13}/PKG-INFO +2 -2
- {brindle-0.3.12 → brindle-0.3.13}/README.md +1 -1
- {brindle-0.3.12 → brindle-0.3.13}/pyproject.toml +1 -1
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/agents.py +123 -45
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/antigravity.py +54 -8
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/autopilot.py +3 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/company_login.py +19 -2
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/cull.py +58 -6
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/db.py +24 -2
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/doctor.py +29 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/launch_health.py +8 -3
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/native/permissions.py +113 -14
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/permissions.py +70 -12
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pipeline.py +46 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/settings_sync.py +5 -4
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/providers.py +24 -2
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/workspaces.py +4 -3
- {brindle-0.3.12 → brindle-0.3.13}/.gitignore +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/LICENSE +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/SCHEDULE-A +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/__init__.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/__main__.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/account.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/airgap.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/budget.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/__init__.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/backend-auditor.md +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/developer-codex.md +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/developer-fable.md +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/developer-gemini.md +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/developer-heavy.md +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/developer-light.md +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/developer-local.md +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/developer.md +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/qa.md +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/reviewer-local.md +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/reviewer.md +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/subagent.md +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_agents/supervisor.md +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_rules/__init__.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_rules/security/backend.md +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/builtin_rules/tests/only.md +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/ci_adapters.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/ci_client.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/ci_federation.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/ci_hosts.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/cli.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/cloud_credential.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/codemap.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/codemap_docs.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/codex_hook.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/company_identity.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/config.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/conflicts.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/cost.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/cost_estimate.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/demo.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/detect.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/escapes.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/events.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/gates.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/git.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/guardrails.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/history.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/identity_lock.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/inbox.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/keys_cmds.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/keystore.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/learned_rules.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/learning.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/legacy.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/mcp_server.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/model_access.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/model_availability.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/native/__init__.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/native/client.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/native/loop.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/native/runner.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/native/serve.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/native/tools.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pane_auth.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/plugins.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/policy.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pool.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pricing.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/__init__.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/_files.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/account.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/audit_chain.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/audit_export.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/auth.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/brief_learning.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/cost_centers.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/credentials.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/features.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/keys.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/learning.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/license.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/loopback.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/managed_models.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/org_profiles.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/orgkey.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/rollout.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/route_health.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/status.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/team_events.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/pro/team_policy.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/procs.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/profiles.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/quota.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/repo_cmds.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/repos.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/rewind.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/rule_checks.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/savings.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/scratch.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/secrets.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/self_serve.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/services.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/sessions.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/sidebar_follow.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/signin_pause.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/status_cache.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/tasks.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/tmux.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/updates.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/usage.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/view.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/watch.py +0 -0
- {brindle-0.3.12 → brindle-0.3.13}/src/brindle/watch_costs.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.13
|
|
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.13`). 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.13`). 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
|
|
@@ -817,14 +817,12 @@ def _launch(db: DB, agent: Agent, ws: Workspace, *, prompt: str | None,
|
|
|
817
817
|
_launch_headless(db, agent, ws, prompt=prompt, resume=resume, watch_pane=watch_pane, m=m)
|
|
818
818
|
return
|
|
819
819
|
if provider.prompt_after_ready:
|
|
820
|
-
#
|
|
821
|
-
#
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
if prompt:
|
|
827
|
-
db.enqueue(agent.id, prompt, None)
|
|
820
|
+
# Typed in once it's ready, after a warm-up carrying its instructions:
|
|
821
|
+
# agy connects its MCP servers only once a conversation has started,
|
|
822
|
+
# so the task must arrive as a second turn (see brindle.antigravity).
|
|
823
|
+
for text in ((provider.warmup(profile) if not resume else None), prompt):
|
|
824
|
+
if text:
|
|
825
|
+
db.enqueue(agent.id, text, None)
|
|
828
826
|
prompt = None
|
|
829
827
|
# Recorded before launching: the agent's hooks may fire within milliseconds.
|
|
830
828
|
status = "processing" if prompt else "starting"
|
|
@@ -2025,12 +2023,6 @@ def default_review_profile(cfg: RepoConfig, worker: Agent | None, db: DB | None
|
|
|
2025
2023
|
return cfg.review_profile
|
|
2026
2024
|
if cfg.reviewer and cfg.reviewer != "reviewer":
|
|
2027
2025
|
return cfg.reviewer
|
|
2028
|
-
if unusable("antigravity") is None:
|
|
2029
|
-
try:
|
|
2030
|
-
load_profile("reviewer-gemini", repo_root)
|
|
2031
|
-
return "reviewer-gemini"
|
|
2032
|
-
except Exception:
|
|
2033
|
-
pass
|
|
2034
2026
|
if db is None or not repo_root:
|
|
2035
2027
|
return cfg.reviewer
|
|
2036
2028
|
from brindle import learning
|
|
@@ -2079,6 +2071,15 @@ def _review_kill_switch(ws: Workspace) -> None:
|
|
|
2079
2071
|
raise AgentError(f"No reviewer started: {why_not}")
|
|
2080
2072
|
|
|
2081
2073
|
|
|
2074
|
+
def _review_worktree_present(ws: Workspace) -> None:
|
|
2075
|
+
"""Raises ``AgentError`` when ``ws``'s worktree is gone from disk (removed
|
|
2076
|
+
outside brindle, so its record outlived it): every git call on the branch
|
|
2077
|
+
would fail there, and nothing is left to review."""
|
|
2078
|
+
if not os.path.isdir(ws.path):
|
|
2079
|
+
raise AgentError(f"No reviewer started: {ws.id}'s worktree {ws.path} no longer exists. "
|
|
2080
|
+
f"remove_workspace {ws.id} clears its record.")
|
|
2081
|
+
|
|
2082
|
+
|
|
2082
2083
|
def _review_profile(db: DB, ws: Workspace, profile: str | None, cfg: RepoConfig) -> str:
|
|
2083
2084
|
"""The reviewer profile to use, checked to be one that can run: raises
|
|
2084
2085
|
``AgentError`` if it (explicit or picked) doesn't exist, or uses the codex
|
|
@@ -2144,6 +2145,7 @@ def start_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | Non
|
|
|
2144
2145
|
from brindle.config import load_repo_config
|
|
2145
2146
|
from brindle.providers import brindle_invocation
|
|
2146
2147
|
|
|
2148
|
+
_review_worktree_present(ws)
|
|
2147
2149
|
cfg = cfg or load_repo_config(ws.repo_root)
|
|
2148
2150
|
if not cfg.checks:
|
|
2149
2151
|
return request_review(db, caller, ws, profile, focus, cfg)
|
|
@@ -2222,12 +2224,15 @@ def request_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | N
|
|
|
2222
2224
|
from brindle import gates
|
|
2223
2225
|
from brindle.config import load_repo_config
|
|
2224
2226
|
|
|
2227
|
+
_review_worktree_present(ws)
|
|
2225
2228
|
_review_kill_switch(ws)
|
|
2226
2229
|
cfg = cfg or load_repo_config(ws.repo_root)
|
|
2227
2230
|
sha = gates.head(ws)
|
|
2228
2231
|
for old in db.list_agents(ws.id):
|
|
2229
2232
|
if old.mode != "review" or old.result is not None or not is_alive(old):
|
|
2230
2233
|
continue
|
|
2234
|
+
if old.unreported_noted:
|
|
2235
|
+
continue # it stopped without a verdict: not the reviewer of anything
|
|
2231
2236
|
if old.review_sha == sha:
|
|
2232
2237
|
if profile and old.profile != profile:
|
|
2233
2238
|
continue # another profile's review of this commit (a second review): keep both
|
|
@@ -2467,13 +2472,49 @@ def permission_request_decision(db: DB, agent: Agent, payload: dict,
|
|
|
2467
2472
|
if not reqs:
|
|
2468
2473
|
return None
|
|
2469
2474
|
rules = [*permissions.all_rules(ws.repo_root), *profile_rules]
|
|
2475
|
+
from dataclasses import replace
|
|
2476
|
+
|
|
2477
|
+
from brindle.native.permissions import Permissions, split_commands, uncovered_part
|
|
2470
2478
|
from brindle.profiles import load_profile
|
|
2471
2479
|
|
|
2472
2480
|
try:
|
|
2473
|
-
|
|
2481
|
+
profile = load_profile(agent.profile, ws.repo_root)
|
|
2474
2482
|
except KeyError:
|
|
2475
|
-
|
|
2483
|
+
profile = None # deleted profile: no extra allowances
|
|
2484
|
+
if profile is not None:
|
|
2485
|
+
rules += permissions.profile_bash_allows(profile)
|
|
2476
2486
|
decision = permissions.decide_all(reqs, checks=cfg.checks, rules=rules)
|
|
2487
|
+
if decision.decision == "ask" and len(reqs) == 1:
|
|
2488
|
+
# A deny on one part of a compound command (cd x && <denied>)
|
|
2489
|
+
# wins before any part-by-part allow below.
|
|
2490
|
+
decision = permissions.deny_in_parts(reqs[0], checks=cfg.checks, rules=rules) or decision
|
|
2491
|
+
if decision.decision == "ask" and profile is not None:
|
|
2492
|
+
perms = Permissions(profile.permission_mode, profile.allowed_tools)
|
|
2493
|
+
command = reqs[0].command or ""
|
|
2494
|
+
specs = [spec for name, spec in perms.rules
|
|
2495
|
+
if name == "Bash" and spec not in (None, "", "*")]
|
|
2496
|
+
cwd = os.path.realpath(reqs[0].cwd or ws.path)
|
|
2497
|
+
root = os.path.realpath(ws.path)
|
|
2498
|
+
# Like pre_tool_decision: a compound command whose every part
|
|
2499
|
+
# allowed_tools covers (a cd within the worktree counts).
|
|
2500
|
+
if (len(reqs) == 1 and reqs[0].kind == "bash" and specs
|
|
2501
|
+
and (cwd == root or cwd.startswith(root + os.sep))
|
|
2502
|
+
and uncovered_part(specs, command, cd_root=ws.path) is None):
|
|
2503
|
+
decision = permissions.Decision(
|
|
2504
|
+
"allow", "every part of the command matches the profile's allowed_tools")
|
|
2505
|
+
# Deny each part as the allow just read it (quotes and a
|
|
2506
|
+
# harmless redirection gone), which deny_in_parts doesn't.
|
|
2507
|
+
for part in split_commands(command):
|
|
2508
|
+
denied = permissions.decide(replace(reqs[0], command=part), checks=cfg.checks, rules=rules)
|
|
2509
|
+
if denied.decision == "deny":
|
|
2510
|
+
decision = denied
|
|
2511
|
+
break
|
|
2512
|
+
elif perms.mode == "dontAsk":
|
|
2513
|
+
# Nobody watches this pane: a prompt would stall the agent.
|
|
2514
|
+
decision = permissions.Decision(
|
|
2515
|
+
"deny", f"{decision.reason.removesuffix('; asking the user')}, and this profile "
|
|
2516
|
+
"never asks (permission_mode dontAsk). Don't retry it: do without it, or ask "
|
|
2517
|
+
"your supervisor with the send_message tool (brindle MCP server)")
|
|
2477
2518
|
summary = reqs[0].summary() if len(reqs) == 1 else (
|
|
2478
2519
|
f"{reqs[0].tool}: " + ", ".join(r.path or "?" for r in reqs))[:200]
|
|
2479
2520
|
_record_permission(db, agent, ws, summary, f"{decision.decision}: {decision.reason}")
|
|
@@ -2532,24 +2573,26 @@ def _stop_decision(db: DB, agent: Agent, payload: dict, via_inbox: bool = False)
|
|
|
2532
2573
|
"""What the Stop hook decides: a "block" with the reason to carry on, or
|
|
2533
2574
|
None to let the agent stop. ``via_inbox``: the reason will be sent as an
|
|
2534
2575
|
inbox message rather than block the stop, so ``stop_hook_active`` never
|
|
2535
|
-
caps a repeat; the unread-mail reminder is then limited
|
|
2576
|
+
caps a repeat; the unread-mail reminder is then limited to one per
|
|
2577
|
+
NOTICE_TTL (DB.claim_stop_reminder)."""
|
|
2536
2578
|
msg = db.pop_pending(agent.id)
|
|
2537
2579
|
if msg:
|
|
2538
2580
|
db.set_status(agent.id, "processing")
|
|
2539
2581
|
return {"decision": "block", "reason": msg.body}
|
|
2540
|
-
if agent.mode == "interactive" and db.unread_count(agent.id)
|
|
2582
|
+
if (agent.mode == "interactive" and db.unread_count(agent.id)
|
|
2583
|
+
and not payload.get("stop_hook_active")):
|
|
2584
|
+
# Stopping with mail unread, even moments after its notice went out:
|
|
2585
|
+
# that notice may have been lost, and nothing else will wake it.
|
|
2541
2586
|
ap = db.get_autopilot(agent.id)
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2587
|
+
pilot = ap is not None and ap.enabled
|
|
2588
|
+
# An autopilot supervisor that would really be blocked doesn't stop
|
|
2589
|
+
# with mail it hasn't read; stop_hook_active caps that.
|
|
2590
|
+
claimed = db.unread(agent.id) if pilot and not via_inbox else db.claim_stop_reminder(agent.id)
|
|
2591
|
+
if claimed:
|
|
2545
2592
|
db.set_status(agent.id, "processing")
|
|
2546
2593
|
return {"decision": "block", "reason": (
|
|
2547
2594
|
"[brindle autopilot] You have unread messages. Call read_messages "
|
|
2548
|
-
"before you stop.")}
|
|
2549
|
-
claimed = None if payload.get("stop_hook_active") else db.claim_notice(agent.id)
|
|
2550
|
-
if claimed: # the notice never went out (or was lost): hand it over now
|
|
2551
|
-
db.set_status(agent.id, "processing")
|
|
2552
|
-
return {"decision": "block", "reason": unread_notice(db, claimed)}
|
|
2595
|
+
"before you stop." if pilot else unread_notice(db, claimed))}
|
|
2553
2596
|
needs_report = agent.mode in REPORTING_MODES and agent.result is None
|
|
2554
2597
|
if needs_report and not payload.get("stop_hook_active"):
|
|
2555
2598
|
db.set_status(agent.id, "processing")
|
|
@@ -2607,6 +2650,11 @@ def handle_hook(db: DB, agent_id: str, event: str, payload: dict) -> dict | None
|
|
|
2607
2650
|
if transcript and agent.provider == "claude" and transcript != agent.transcript_path:
|
|
2608
2651
|
db.update_agent(agent_id, transcript_path=str(transcript))
|
|
2609
2652
|
|
|
2653
|
+
if event in ("prompt-submit", "pre-tool", "tool-done") and agent.unreported_noted:
|
|
2654
|
+
# It's working again, so a later stop without a result is a new one
|
|
2655
|
+
# its supervisor should hear about (see tell_parent_unreported).
|
|
2656
|
+
db.update_agent(agent_id, unreported_noted=None)
|
|
2657
|
+
|
|
2610
2658
|
if event == "session-start":
|
|
2611
2659
|
from brindle import inbox
|
|
2612
2660
|
|
|
@@ -2711,6 +2759,13 @@ def handle_hook(db: DB, agent_id: str, event: str, payload: dict) -> dict | None
|
|
|
2711
2759
|
decision = pilot.on_stop(db, agent, payload)
|
|
2712
2760
|
if decision:
|
|
2713
2761
|
db.enqueue(agent_id, decision["reason"], None)
|
|
2762
|
+
if (agent.mode in REPORTING_MODES and agent.result is None
|
|
2763
|
+
and not db.pending_count(agent_id)):
|
|
2764
|
+
# No Stop hook can remind it to report, and no hook says when
|
|
2765
|
+
# it resumes: every turn it ends without a result is a stop
|
|
2766
|
+
# its supervisor hears about.
|
|
2767
|
+
db.update_agent(agent_id, unreported_noted=None)
|
|
2768
|
+
tell_parent_unreported(db, db.get_agent(agent_id) or agent)
|
|
2714
2769
|
if db.pending_count(agent_id) and not agent.headless:
|
|
2715
2770
|
flush(db, agent_id)
|
|
2716
2771
|
elif event == "stop-failure":
|
|
@@ -2738,17 +2793,32 @@ def handle_hook(db: DB, agent_id: str, event: str, payload: dict) -> dict | None
|
|
|
2738
2793
|
"oauth_org_not_allowed", "billing_error"
|
|
2739
2794
|
))
|
|
2740
2795
|
)
|
|
2741
|
-
|
|
2796
|
+
# Claude Code's line for a --model its backend doesn't serve: every
|
|
2797
|
+
# turn fails the same way, so the agent can't go on (launch_health
|
|
2798
|
+
# reads the same line on screen).
|
|
2799
|
+
from brindle import launch_health
|
|
2800
|
+
|
|
2801
|
+
is_model = not is_rate_limit and not is_auth and bool(
|
|
2802
|
+
re.search(launch_health.SELECTED_MODEL, err_fields))
|
|
2803
|
+
if is_auth or is_model:
|
|
2742
2804
|
db.set_status(agent_id, "failed")
|
|
2743
2805
|
db.clear_permission_requests(agent_id)
|
|
2744
2806
|
from brindle import providers, autopilot as pilot
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2807
|
+
if is_auth:
|
|
2808
|
+
providers.invalidate_signed_in(agent.provider)
|
|
2809
|
+
err_desc = payload.get("error_details") or payload.get("error") or payload.get("error_type") or "401/403"
|
|
2810
|
+
msg = (
|
|
2811
|
+
f"[brindle] Agent {agent_id} encountered an authentication failure "
|
|
2812
|
+
f"(invalid or expired credentials): {err_desc}. "
|
|
2813
|
+
"Check API keys or login status."
|
|
2814
|
+
)
|
|
2815
|
+
else:
|
|
2816
|
+
err_desc = "its model isn't available on this route"
|
|
2817
|
+
msg = (
|
|
2818
|
+
f"[brindle] Agent {agent_id} failed: Claude Code says there's an issue with "
|
|
2819
|
+
"the selected model (it may not exist, or the account or route may lack access). "
|
|
2820
|
+
"Check the profile's model and the Claude route (`brindle doctor`), then retry."
|
|
2821
|
+
)
|
|
2752
2822
|
if agent.parent_id:
|
|
2753
2823
|
try:
|
|
2754
2824
|
send_message(db, agent.parent_id, msg, sender_id="brindle")
|
|
@@ -2759,7 +2829,8 @@ def handle_hook(db: DB, agent_id: str, event: str, payload: dict) -> dict | None
|
|
|
2759
2829
|
pass
|
|
2760
2830
|
root_id = pilot.root_of(db, agent_id)
|
|
2761
2831
|
if db.get_autopilot(root_id):
|
|
2762
|
-
|
|
2832
|
+
what = "Credentials need" if is_auth else "The model needs"
|
|
2833
|
+
pilot.need_user(db, root_id, f"{what} attention for agent {agent_id}: {err_desc}")
|
|
2763
2834
|
else:
|
|
2764
2835
|
db.set_status(agent_id, "idle")
|
|
2765
2836
|
db.clear_permission_requests(agent_id)
|
|
@@ -2834,30 +2905,36 @@ def command_running(db: DB, agent: Agent) -> bool:
|
|
|
2834
2905
|
return False
|
|
2835
2906
|
|
|
2836
2907
|
|
|
2837
|
-
def tell_parent_unreported(db: DB, agent: Agent) ->
|
|
2908
|
+
def tell_parent_unreported(db: DB, agent: Agent) -> bool:
|
|
2838
2909
|
"""A worker stopped again after being reminded to report, still without a
|
|
2839
2910
|
result. It won't be reminded again on its own, and its supervisor has
|
|
2840
2911
|
usually gone idle waiting for it with nothing left to wake it, so tell the
|
|
2841
|
-
supervisor (queued if it's busy, delivered now if it's idle).
|
|
2912
|
+
supervisor (queued if it's busy, delivered now if it's idle). Returns
|
|
2913
|
+
whether the supervisor was told."""
|
|
2842
2914
|
if agent.mode not in FORWARDING_MODES or not agent.parent_id:
|
|
2843
|
-
return # a synchronous handoff's caller is still waiting on it
|
|
2915
|
+
return False # a synchronous handoff's caller is still waiting on it
|
|
2844
2916
|
if db.get_agent(agent.parent_id) is None:
|
|
2845
|
-
return
|
|
2917
|
+
return False
|
|
2846
2918
|
ws = db.get_workspace(agent.workspace_id)
|
|
2847
|
-
if agent.mode == "review" and ws is not None:
|
|
2919
|
+
if agent.mode == "review" and ws is not None and os.path.isdir(ws.path):
|
|
2848
2920
|
from brindle import gates
|
|
2849
2921
|
|
|
2850
2922
|
if db.latest_review(ws.id, gates.head(ws)):
|
|
2851
|
-
return # its verdict for the current commit is already recorded
|
|
2923
|
+
return False # its verdict for the current commit is already recorded
|
|
2852
2924
|
if agent.unreported_noted:
|
|
2853
|
-
return # the supervisor was told
|
|
2925
|
+
return False # the supervisor was told about this stop; cleared once it resumes
|
|
2854
2926
|
if command_running(db, agent):
|
|
2855
2927
|
# Its turn ended while a long or background command (a test run) is
|
|
2856
2928
|
# still going: it isn't stopped, and the command finishing wakes it.
|
|
2857
2929
|
# Not marked noted, so a real stop later still gets its one alert.
|
|
2858
|
-
return
|
|
2930
|
+
return False
|
|
2859
2931
|
db.update_agent(agent.id, unreported_noted=1)
|
|
2860
|
-
|
|
2932
|
+
if agent.mode == "review" and ws is not None:
|
|
2933
|
+
from brindle import pipeline
|
|
2934
|
+
|
|
2935
|
+
if pipeline.on_reviewer_unreported(db, get(db, agent.id), ws):
|
|
2936
|
+
return True # a fallback reviewer took over, or the supervisor was told
|
|
2937
|
+
where =f" (branch `{ws.branch}`, workspace {ws.id})" if ws else ""
|
|
2861
2938
|
tool = "submit_review" if agent.mode == "review" else "report_result"
|
|
2862
2939
|
who = "Reviewer" if agent.mode == "review" else "Worker"
|
|
2863
2940
|
body = (
|
|
@@ -2870,7 +2947,8 @@ def tell_parent_unreported(db: DB, agent: Agent) -> None:
|
|
|
2870
2947
|
except (AgentError, tmux.TmuxError):
|
|
2871
2948
|
# The parent isn't running (or can't take messages): the notice is
|
|
2872
2949
|
# deliberately dropped here, since there's nothing left to tell.
|
|
2873
|
-
|
|
2950
|
+
return False
|
|
2951
|
+
return True
|
|
2874
2952
|
|
|
2875
2953
|
|
|
2876
2954
|
PERMISSION_EVENTS = ("permission-request", "codex-permission-request")
|
|
@@ -23,9 +23,9 @@ brindle sends it after that is typed in as a new turn once it's idle.
|
|
|
23
23
|
agy's hooks, mapped onto brindle's:
|
|
24
24
|
- ``PreInvocation`` before each model call: working. ``invocationNum`` 0 means
|
|
25
25
|
a new turn, typed by the user unless brindle just delivered a message.
|
|
26
|
-
- ``Stop``: a queued message is typed in as a new turn
|
|
27
|
-
hook (the report reminder, autopilot)
|
|
28
|
-
"
|
|
26
|
+
- ``Stop``: a queued message is typed in as a new turn, and so is what
|
|
27
|
+
brindle's Stop hook would block with (the report reminder, autopilot): a
|
|
28
|
+
``{"decision": "continue"}`` turn would have no MCP tools to act on it.
|
|
29
29
|
- ``PostToolUse``: a tool ran, so a permission prompt was answered.
|
|
30
30
|
- ``PreToolUse`` (only with ``permission_policy: "on"``): brindle's permission
|
|
31
31
|
policy (brindle.permissions) answers deny, or what agy would do anyway (allow
|
|
@@ -275,10 +275,12 @@ def handle_hook(db, agent_id: str, event: str, payload: dict) -> dict | None:
|
|
|
275
275
|
|
|
276
276
|
if event == "agy-pre-invocation":
|
|
277
277
|
db.set_status(agent_id, "processing")
|
|
278
|
-
if payload.get("invocationNum", 0) == 0:
|
|
279
|
-
# A new turn
|
|
278
|
+
if payload.get("invocationNum", 0) == 0 and not db.delivered_since(agent_id, time.time() - DELIVERY_WINDOW):
|
|
279
|
+
# A new turn the user typed. One brindle typed in (the report
|
|
280
|
+
# reminder among them) keeps stop_blocked: the stop path resets
|
|
281
|
+
# it itself before delivering anything else.
|
|
280
282
|
db.update_agent(agent_id, stop_blocked=0)
|
|
281
|
-
if agent.mode == "interactive"
|
|
283
|
+
if agent.mode == "interactive":
|
|
282
284
|
autopilot.user_spoke(db, agent)
|
|
283
285
|
return None
|
|
284
286
|
if event == "agy-post-tool":
|
|
@@ -298,6 +300,12 @@ def handle_hook(db, agent_id: str, event: str, payload: dict) -> dict | None:
|
|
|
298
300
|
error_type = "rate_limit" if limited else ("auth" if autherr else "error")
|
|
299
301
|
agents.handle_hook(db, agent_id, "stop-failure",
|
|
300
302
|
{**base, "error_type": error_type, "error": error})
|
|
303
|
+
failed = db.get_agent(agent_id)
|
|
304
|
+
if failed is not None and failed.status != "failed": # not a sign-in problem
|
|
305
|
+
db.update_agent(agent_id, stop_blocked=0)
|
|
306
|
+
if db.pending_count(agent_id):
|
|
307
|
+
_flush_soon(agent_id)
|
|
308
|
+
_tell_parent_stopped_on_error(db, failed, error_type)
|
|
301
309
|
return None
|
|
302
310
|
if db.pending_count(agent_id):
|
|
303
311
|
# A new turn, not a continuation: only then does agy give the
|
|
@@ -309,13 +317,41 @@ def handle_hook(db, agent_id: str, event: str, payload: dict) -> dict | None:
|
|
|
309
317
|
out = agents.handle_hook(db, agent_id, "stop",
|
|
310
318
|
{**base, "stop_hook_active": bool(agent.stop_blocked)})
|
|
311
319
|
if out and out.get("decision") == "block":
|
|
320
|
+
# Typed in as a new turn too: a turn the Stop hook continues has
|
|
321
|
+
# no MCP tools, so the agent couldn't call report_result in it.
|
|
322
|
+
# stop_blocked stays set through that turn (see agy-pre-invocation),
|
|
323
|
+
# so the reminder is sent once.
|
|
312
324
|
db.update_agent(agent_id, stop_blocked=1)
|
|
313
|
-
|
|
325
|
+
db.enqueue(agent_id, out["reason"], None)
|
|
326
|
+
db.set_status(agent_id, "idle")
|
|
327
|
+
_flush_soon(agent_id)
|
|
328
|
+
return None
|
|
314
329
|
db.update_agent(agent_id, stop_blocked=0)
|
|
315
330
|
return None
|
|
316
331
|
return None
|
|
317
332
|
|
|
318
333
|
|
|
334
|
+
def _tell_parent_stopped_on_error(db, agent, error_type: str) -> None:
|
|
335
|
+
"""A worker's turn ended on a provider error (not sign-in, which
|
|
336
|
+
stop-failure reports itself): no Stop hook follows, so it gets no report
|
|
337
|
+
reminder and its supervisor would wait on it with nothing to wake it."""
|
|
338
|
+
from brindle import agents, tmux
|
|
339
|
+
|
|
340
|
+
if (agent.mode not in agents.FORWARDING_MODES or not agent.parent_id or agent.result is not None
|
|
341
|
+
or db.get_agent(agent.parent_id) is None):
|
|
342
|
+
return
|
|
343
|
+
queued = " Its queued messages are being typed in as a new turn." if db.pending_count(agent.id) else ""
|
|
344
|
+
tool = "submit_review" if agent.mode == "review" else "report_result"
|
|
345
|
+
# The error type only: provider output can carry URLs, keys or prompt text.
|
|
346
|
+
body = (f"{'Reviewer' if agent.mode == 'review' else 'Worker'} {agent.id}'s turn ended on a "
|
|
347
|
+
f"provider error ({error_type}) before it called {tool}.{queued} Check on it: "
|
|
348
|
+
"send_message to have it carry on, or remove_workspace if the work is abandoned.")
|
|
349
|
+
try:
|
|
350
|
+
agents.send_message(db, agent.parent_id, body)
|
|
351
|
+
except (agents.AgentError, tmux.TmuxError):
|
|
352
|
+
db.enqueue(agent.parent_id, body, None)
|
|
353
|
+
|
|
354
|
+
|
|
319
355
|
def _flush_soon(agent_id: str, delay: float = 1.5) -> None:
|
|
320
356
|
"""Type the agent's next queued message once agy has finished stopping."""
|
|
321
357
|
from brindle.providers import brindle_invocation
|
|
@@ -353,6 +389,10 @@ def pre_tool_decision(db, agent_id: str | None, payload: object, strict: bool =
|
|
|
353
389
|
except KeyError:
|
|
354
390
|
pass
|
|
355
391
|
decision = permissions.decide(req, checks=cfg.checks, rules=rules)
|
|
392
|
+
if strict and decision.decision == "ask":
|
|
393
|
+
# A compound or quoted command whose every part is allowed, read as
|
|
394
|
+
# Claude Code's hook reads a profile's allowed_tools.
|
|
395
|
+
decision = permissions.allowlist_decision(req, rules, cfg.checks, cd_root=ws.path) or decision
|
|
356
396
|
spaces = payload.get("workspacePaths")
|
|
357
397
|
spaces = [s for s in spaces if isinstance(s, str) and s] if isinstance(spaces, list) else []
|
|
358
398
|
if strict:
|
|
@@ -368,7 +408,13 @@ def pre_tool_decision(db, agent_id: str | None, payload: object, strict: bool =
|
|
|
368
408
|
out = {"decision": "deny", "reason": f"brindle: {ambiguous}, so it can't be checked; "
|
|
369
409
|
"use one absolute path inside your worktree"}
|
|
370
410
|
else:
|
|
371
|
-
|
|
411
|
+
# agy keeps what it tells the model about brindle's MCP server
|
|
412
|
+
# (its instructions and tools) and its built-in skills here:
|
|
413
|
+
# readable, never writable. Other servers' folders are not.
|
|
414
|
+
agy_home = Path.home() / ".gemini" / "antigravity-cli"
|
|
415
|
+
out = output(decision, req, spaces, protected_roots=[ws.path, ws.repo_root],
|
|
416
|
+
readable=[str(agy_home / "mcp" / "brindle"), str(agy_home / "builtin")],
|
|
417
|
+
has_supervisor=bool(agent.parent_id))
|
|
372
418
|
else:
|
|
373
419
|
out = output(decision, req, spaces)
|
|
374
420
|
if req is not None and out["decision"] == "deny":
|
|
@@ -148,6 +148,9 @@ Acting on what you find:
|
|
|
148
148
|
what you fixed and which findings weren't real. Don't ask first. Ask only
|
|
149
149
|
before outward-facing or hard-to-reverse actions (posting, merging into a
|
|
150
150
|
shared branch, deleting) and for decisions that are the user's to make.
|
|
151
|
+
- Once you've laid out a plan, start it in the same turn. Don't end with
|
|
152
|
+
"shall I start?" or wait for a "yes" or "go": if a step needs a decision
|
|
153
|
+
only the user can make, ask that question and carry on with the rest.
|
|
151
154
|
- Format your own verbose outputs or message transcripts in collapsible
|
|
152
155
|
`<details><summary>Title</summary>...</details>` blocks so they are not
|
|
153
156
|
sprawling in the chat UI."""
|
|
@@ -472,7 +472,9 @@ def claude_model(model: str | None, repo_root: str | None = None) -> str | None:
|
|
|
472
472
|
the profile's CLI flag, just as it does in the company login env.
|
|
473
473
|
Launch and policy checks share this resolution without changing the
|
|
474
474
|
saved profile. Claude Code's own managed settings pick their own route.
|
|
475
|
-
An unavailable setup leaves the profile alone, as company login does
|
|
475
|
+
An unavailable setup leaves the profile alone, as company login does,
|
|
476
|
+
and so does a launch the route's env doesn't reach (``route_in_use``):
|
|
477
|
+
a route model id on the person's own Anthropic sign-in doesn't exist.
|
|
476
478
|
"""
|
|
477
479
|
from brindle import providers
|
|
478
480
|
|
|
@@ -482,11 +484,26 @@ def claude_model(model: str | None, repo_root: str | None = None) -> str | None:
|
|
|
482
484
|
if got is None or got is UNAVAILABLE:
|
|
483
485
|
return model
|
|
484
486
|
c = got[1].claude
|
|
485
|
-
if c is None or not c.model_ids or model in c.model_ids:
|
|
487
|
+
if c is None or not c.model_ids or model in c.model_ids or not route_in_use(got[0], got[1]):
|
|
486
488
|
return model
|
|
487
489
|
return c.default_model or c.model_ids[0]
|
|
488
490
|
|
|
489
491
|
|
|
492
|
+
def route_in_use(org_id: str, setup) -> bool:
|
|
493
|
+
"""Whether a Claude pane gets this org's route env: an enforced setup's
|
|
494
|
+
(``company_identity.enforced_route_env``), or the env company login
|
|
495
|
+
stored for this org (``stored_env``, what ``agents.agent_env`` applies)."""
|
|
496
|
+
if setup.enforce:
|
|
497
|
+
return True
|
|
498
|
+
if not stored_env():
|
|
499
|
+
return False
|
|
500
|
+
try:
|
|
501
|
+
got = config.user_settings().get(ENV_KEY)
|
|
502
|
+
except (OSError, ValueError):
|
|
503
|
+
return False
|
|
504
|
+
return isinstance(got, dict) and got.get("org") == org_id
|
|
505
|
+
|
|
506
|
+
|
|
490
507
|
def org_setup(repo_root: str | None = None, store=None, client=None):
|
|
491
508
|
"""(org id, AgentSetup) for the signed-in org; personal policies supply
|
|
492
509
|
only federated Claude setup. None when there is no applicable setup;
|
|
@@ -186,6 +186,7 @@ def sweep(db: DB, now: float | None = None) -> list[str]:
|
|
|
186
186
|
|
|
187
187
|
# 3. Workers stuck on a prompt nobody is answering, or gone silent.
|
|
188
188
|
done.extend(note_stuck(db, now, panes))
|
|
189
|
+
done.extend(note_idle_unreported(db, now, panes))
|
|
189
190
|
done.extend(note_silent(db, now, panes))
|
|
190
191
|
done.extend(note_model_refused(db, now, panes))
|
|
191
192
|
|
|
@@ -251,6 +252,49 @@ def note_stuck(db: DB, now: float, panes: dict[str, bool]) -> list[str]:
|
|
|
251
252
|
return done
|
|
252
253
|
|
|
253
254
|
|
|
255
|
+
# How long a worker may sit idle without having reported before its supervisor
|
|
256
|
+
# is told (once per idle spell).
|
|
257
|
+
IDLE_UNREPORTED_AFTER = 300.0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def note_idle_unreported(db: DB, now: float, panes: dict[str, bool]) -> list[str]:
|
|
261
|
+
"""Tell each supervisor, once per idle spell, about a worker that has sat
|
|
262
|
+
idle for IDLE_UNREPORTED_AFTER seconds without reporting, goal or not
|
|
263
|
+
(autopilot's Stop hook only checks on workers when a goal is set). It's
|
|
264
|
+
a reminder: the notice its stop sent may have gone unread, or been lost.
|
|
265
|
+
|
|
266
|
+
A worker whose provider has no hook for a turn starting (Codex) stays
|
|
267
|
+
'idle' from its first finished turn on, through any later turns, so it
|
|
268
|
+
also needs a pane that has printed nothing for that long, and the spell
|
|
269
|
+
starts at its last output."""
|
|
270
|
+
from brindle.providers import get_provider
|
|
271
|
+
|
|
272
|
+
done = []
|
|
273
|
+
owners = agents.pane_owners(db, panes)
|
|
274
|
+
for a in db.list_agents():
|
|
275
|
+
# Only these are ever told (a synchronous handoff's caller is waiting).
|
|
276
|
+
if (a.mode not in agents.FORWARDING_MODES or not a.parent_id or a.result is not None
|
|
277
|
+
or a.dismissed_at is not None or a.status != "idle"
|
|
278
|
+
or not agents.runs_process(a) or not agents.same_server(a, panes)
|
|
279
|
+
or not agents.is_alive(a, panes) or not agents.owns_pane(db, a, owners)):
|
|
280
|
+
continue
|
|
281
|
+
since = a.status_since or a.created_at
|
|
282
|
+
provider = get_provider(a.provider)
|
|
283
|
+
if not (provider.uses_hooks and provider.announces_turns):
|
|
284
|
+
last = tmux.window_activity(a.tmux_window, server=agents.server_of(a))
|
|
285
|
+
if last is None:
|
|
286
|
+
continue # no telling whether a later turn is running
|
|
287
|
+
since = max(since, last)
|
|
288
|
+
if now - since < IDLE_UNREPORTED_AFTER or a.stuck_noted == since:
|
|
289
|
+
continue
|
|
290
|
+
db.update_agent(a.id, unreported_noted=None)
|
|
291
|
+
# Not told while a command it started is still running; tried again then.
|
|
292
|
+
if agents.tell_parent_unreported(db, db.get_agent(a.id) or a):
|
|
293
|
+
db.update_agent(a.id, stuck_noted=since)
|
|
294
|
+
done.append(f"told {a.parent_id} that worker {a.id} is idle without reporting")
|
|
295
|
+
return done
|
|
296
|
+
|
|
297
|
+
|
|
254
298
|
# How long a worker no hook reports on (Codex) may show nothing new before its
|
|
255
299
|
# supervisor is told. Such a CLI can sit on a startup error forever: signed in
|
|
256
300
|
# without a plan that includes it, say (issue #42).
|
|
@@ -259,17 +303,23 @@ SILENT_AFTER = 600.0
|
|
|
259
303
|
|
|
260
304
|
def note_silent(db: DB, now: float, panes: dict[str, bool]) -> list[str]:
|
|
261
305
|
"""Tell each supervisor, once per silence, about a worker whose status
|
|
262
|
-
no hook reports ('unknown')
|
|
263
|
-
|
|
306
|
+
no hook reports ('unknown'), that never got past 'starting', or that is
|
|
307
|
+
mid-turn ('processing': a running turn's spinner redraws the pane), and
|
|
308
|
+
whose pane hasn't printed anything for SILENT_AFTER seconds without a
|
|
309
|
+
result."""
|
|
264
310
|
done = []
|
|
265
311
|
owners = agents.pane_owners(db, panes)
|
|
266
312
|
for a in db.list_agents():
|
|
267
313
|
if (a.mode not in agents.REPORTING_MODES or not a.parent_id or a.result is not None
|
|
268
|
-
or a.dismissed_at is not None
|
|
314
|
+
or a.dismissed_at is not None
|
|
315
|
+
or a.status not in ("unknown", "starting", "processing")
|
|
269
316
|
or not agents.runs_process(a) or not agents.same_server(a, panes)
|
|
270
317
|
or not agents.is_alive(a, panes) or not agents.owns_pane(db, a, owners)):
|
|
271
318
|
continue
|
|
272
|
-
|
|
319
|
+
activity = tmux.window_activity(a.tmux_window, server=agents.server_of(a))
|
|
320
|
+
if a.status == "processing" and (activity is None or a.headless):
|
|
321
|
+
continue # no screen to go by, and its turn may well be running
|
|
322
|
+
last = activity or a.created_at
|
|
273
323
|
if now - last < SILENT_AFTER or a.stuck_noted == last:
|
|
274
324
|
continue
|
|
275
325
|
ws = db.get_workspace(a.workspace_id)
|
|
@@ -280,9 +330,11 @@ def note_silent(db: DB, now: float, panes: dict[str, bool]) -> list[str]:
|
|
|
280
330
|
tail = "\n".join([ln for ln in screen.rstrip().splitlines() if ln.strip()][-12:])
|
|
281
331
|
where = f" on branch `{ws.branch}`" if ws else ""
|
|
282
332
|
look = f"Look with `brindle attach {ws.name}`, or" if ws else "Look at its pane, or"
|
|
333
|
+
why = ("Its CLI may be hung mid-turn." if a.status == "processing" else
|
|
334
|
+
"Its CLI may be stuck at startup (for example signed in without a plan "
|
|
335
|
+
"that includes it).")
|
|
283
336
|
body = (f"Worker {a.id} ({a.profile}, {a.provider}){where} has shown nothing new for "
|
|
284
|
-
f"{int((now - last) // 60)} min and hasn't reported.
|
|
285
|
-
f"startup (for example signed in without a plan that includes it). {look} "
|
|
337
|
+
f"{int((now - last) // 60)} min and hasn't reported. {why} {look} "
|
|
286
338
|
"cancel it and assign the task to another profile."
|
|
287
339
|
f"\n\nIts screen:\n{tail}")
|
|
288
340
|
try:
|