brindle 0.3.20__tar.gz → 0.3.22__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.
Files changed (143) hide show
  1. {brindle-0.3.20 → brindle-0.3.22}/PKG-INFO +2 -2
  2. {brindle-0.3.20 → brindle-0.3.22}/README.md +1 -1
  3. {brindle-0.3.20 → brindle-0.3.22}/pyproject.toml +1 -1
  4. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/agents.py +112 -21
  5. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/cli.py +15 -1
  6. brindle-0.3.22/src/brindle/cloud_login.py +497 -0
  7. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/db.py +17 -1
  8. brindle-0.3.22/src/brindle/login_pause.py +330 -0
  9. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/model_access.py +2 -0
  10. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/providers.py +5 -1
  11. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/tasks.py +56 -23
  12. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/tmux.py +10 -0
  13. brindle-0.3.20/src/brindle/cloud_login.py +0 -225
  14. brindle-0.3.20/src/brindle/login_pause.py +0 -223
  15. {brindle-0.3.20 → brindle-0.3.22}/.gitignore +0 -0
  16. {brindle-0.3.20 → brindle-0.3.22}/LICENSE +0 -0
  17. {brindle-0.3.20 → brindle-0.3.22}/SCHEDULE-A +0 -0
  18. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/__init__.py +0 -0
  19. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/__main__.py +0 -0
  20. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/account.py +0 -0
  21. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/airgap.py +0 -0
  22. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/antigravity.py +0 -0
  23. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/autopilot.py +0 -0
  24. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/budget.py +0 -0
  25. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_agents/__init__.py +0 -0
  26. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_agents/backend-auditor.md +0 -0
  27. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_agents/developer-codex.md +0 -0
  28. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_agents/developer-fable.md +0 -0
  29. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_agents/developer-gemini.md +0 -0
  30. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_agents/developer-heavy.md +0 -0
  31. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_agents/developer-light.md +0 -0
  32. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_agents/developer-local.md +0 -0
  33. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_agents/developer.md +0 -0
  34. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_agents/qa.md +0 -0
  35. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
  36. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
  37. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_agents/reviewer-local.md +0 -0
  38. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_agents/reviewer.md +0 -0
  39. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_agents/subagent.md +0 -0
  40. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_agents/supervisor.md +0 -0
  41. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_rules/__init__.py +0 -0
  42. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_rules/security/backend.md +0 -0
  43. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
  44. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/builtin_rules/tests/only.md +0 -0
  45. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/ci_adapters.py +0 -0
  46. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/ci_client.py +0 -0
  47. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/ci_federation.py +0 -0
  48. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/ci_hosts.py +0 -0
  49. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/cloud_credential.py +0 -0
  50. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/codemap.py +0 -0
  51. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/codemap_docs.py +0 -0
  52. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/codex_hook.py +0 -0
  53. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/company_identity.py +0 -0
  54. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/company_login.py +0 -0
  55. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/config.py +0 -0
  56. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/conflicts.py +0 -0
  57. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/cost.py +0 -0
  58. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/cost_estimate.py +0 -0
  59. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/cull.py +0 -0
  60. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/demo.py +0 -0
  61. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/detect.py +0 -0
  62. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/doctor.py +0 -0
  63. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/escapes.py +0 -0
  64. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/events.py +0 -0
  65. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/gates.py +0 -0
  66. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/git.py +0 -0
  67. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/guardrails.py +0 -0
  68. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/history.py +0 -0
  69. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/identity_lock.py +0 -0
  70. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/inbox.py +0 -0
  71. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/key_check.py +0 -0
  72. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/keys_cmds.py +0 -0
  73. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/keystore.py +0 -0
  74. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/launch_health.py +0 -0
  75. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/learned_rules.py +0 -0
  76. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/learning.py +0 -0
  77. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/legacy.py +0 -0
  78. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/local_setup.py +0 -0
  79. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/mcp_server.py +0 -0
  80. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/model_availability.py +0 -0
  81. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/native/__init__.py +0 -0
  82. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/native/client.py +0 -0
  83. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/native/loop.py +0 -0
  84. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/native/permissions.py +0 -0
  85. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/native/runner.py +0 -0
  86. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/native/serve.py +0 -0
  87. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/native/tools.py +0 -0
  88. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pane_auth.py +0 -0
  89. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/permissions.py +0 -0
  90. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/picker.py +0 -0
  91. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pipeline.py +0 -0
  92. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/plugins.py +0 -0
  93. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/policy.py +0 -0
  94. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pool.py +0 -0
  95. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pricing.py +0 -0
  96. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/__init__.py +0 -0
  97. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/_files.py +0 -0
  98. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/account.py +0 -0
  99. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/audit_chain.py +0 -0
  100. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/audit_export.py +0 -0
  101. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/auth.py +0 -0
  102. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/brief_learning.py +0 -0
  103. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/cost_centers.py +0 -0
  104. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/credentials.py +0 -0
  105. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/features.py +0 -0
  106. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/keys.py +0 -0
  107. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/learning.py +0 -0
  108. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/license.py +0 -0
  109. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/loopback.py +0 -0
  110. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/managed_models.py +0 -0
  111. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/org_profiles.py +0 -0
  112. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/orgkey.py +0 -0
  113. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/rollout.py +0 -0
  114. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/route_health.py +0 -0
  115. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/settings_sync.py +0 -0
  116. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/status.py +0 -0
  117. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/team_events.py +0 -0
  118. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/pro/team_policy.py +0 -0
  119. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/procs.py +0 -0
  120. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/profiles.py +0 -0
  121. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/prompt_answer.py +0 -0
  122. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/quota.py +0 -0
  123. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/repo_cmds.py +0 -0
  124. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/repos.py +0 -0
  125. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/rewind.py +0 -0
  126. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/rule_checks.py +0 -0
  127. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/savings.py +0 -0
  128. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/scratch.py +0 -0
  129. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/secrets.py +0 -0
  130. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/self_serve.py +0 -0
  131. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/services.py +0 -0
  132. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/sessions.py +0 -0
  133. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/sidebar_follow.py +0 -0
  134. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/signin_pause.py +0 -0
  135. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/status_cache.py +0 -0
  136. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/updates.py +0 -0
  137. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/upgrade.py +0 -0
  138. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/usage.py +0 -0
  139. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/view.py +0 -0
  140. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/watch.py +0 -0
  141. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/watch_costs.py +0 -0
  142. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/worker_models.py +0 -0
  143. {brindle-0.3.20 → brindle-0.3.22}/src/brindle/workspaces.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brindle
3
- Version: 0.3.20
3
+ Version: 0.3.22
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.20`). A session started before an upgrade keeps
97
+ session shows it too (`brindle 0.3.22`). 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.20`). A session started before an upgrade keeps
66
+ session shows it too (`brindle 0.3.22`). 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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brindle"
3
- version = "0.3.20"
3
+ version = "0.3.22"
4
4
  description = "Run coding agents in parallel, each on its own git branch, with a supervisor that delegates, reviews, and merges"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -19,6 +19,7 @@ import subprocess
19
19
  import sys
20
20
  import time
21
21
  import uuid
22
+ from types import SimpleNamespace
22
23
 
23
24
  from brindle import cloud_credential, company_identity, company_login, git, keystore, pane_auth, providers, secrets, tmux, workspaces
24
25
  from brindle.config import RepoConfig
@@ -981,11 +982,12 @@ def run_headless(db: DB, agent_id: str, resume: str | None = None, *,
981
982
  provider = get_provider(agent.provider)
982
983
  session = resume
983
984
  turn = 0
985
+ retry = None # a turn the cloud login failed: run again before anything else
984
986
  while True:
985
987
  agent = db.get_agent(agent_id)
986
988
  if agent is None:
987
989
  return 0
988
- msg = db.pop_pending(agent_id)
990
+ msg, retry = retry or db.pop_pending(agent_id), None
989
991
  if msg is None:
990
992
  if exit_when_idle:
991
993
  return 0
@@ -1014,12 +1016,46 @@ def run_headless(db: DB, agent_id: str, resume: str | None = None, *,
1014
1016
  return 0
1015
1017
  session = agent.session_ref or session
1016
1018
  if code != 0:
1019
+ if wait_for_cloud_login(db, agent, ws):
1020
+ # The same turn again, from where it stopped.
1021
+ retry = SimpleNamespace(body=HEADLESS_CONTINUE) if session else msg
1022
+ continue
1017
1023
  print(f"\n── brindle: claude exited with code {code}; this worker has stopped ──", flush=True)
1018
1024
  return code
1019
1025
  db.set_status(agent_id, "idle", only_if="processing")
1020
1026
  print("── brindle: turn finished; waiting for messages ──", flush=True)
1021
1027
 
1022
1028
 
1029
+ def wait_for_cloud_login(db: DB, agent: Agent, ws: Workspace, poll: float = 60.0) -> bool:
1030
+ """A headless turn failed. If it was on a lapsed cloud login, wait here
1031
+ (offering the one browser sign-in, see cloud_login.sign_in) until the
1032
+ login works again and return True; False for any other failure."""
1033
+ from brindle import cloud_login
1034
+
1035
+ try:
1036
+ env = agent_env(ws, agent.id, agent)
1037
+ except Exception: # noqa: BLE001 - an unreadable env: not ours to wait on
1038
+ return False
1039
+ if cloud_login.provider_from_env({**os.environ, **env}) is None:
1040
+ return False
1041
+ s = cloud_login.refresh(env=env)
1042
+ if not s.expired:
1043
+ return False
1044
+ print("\n── brindle: the cloud login lapsed; this worker waits until it's renewed "
1045
+ f"(sign in with the browser tab brindle opens, or run `{s.refresh_cmd}`) ──", flush=True)
1046
+ while db.get_agent(agent.id) is not None:
1047
+ try:
1048
+ last_input = tmux.last_input()
1049
+ except tmux.TmuxError:
1050
+ last_input = None
1051
+ cloud_login.sign_in(env=env, last_input=last_input)
1052
+ if cloud_login.status(env=env).ok:
1053
+ print("── brindle: cloud login renewed; continuing ──", flush=True)
1054
+ return True
1055
+ time.sleep(poll)
1056
+ return False
1057
+
1058
+
1023
1059
  def ready(db: DB, agent_id: str) -> None:
1024
1060
  """after_launch saw the CLI's input box. For CLIs with no hook that says
1025
1061
  so, this is when it's ready (and when queued messages can go in)."""
@@ -1867,6 +1903,16 @@ def _detach(args: list[str]) -> None:
1867
1903
  stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
1868
1904
 
1869
1905
 
1906
+ def _detach_orphan(args: list[str]) -> None:
1907
+ """Start a brindle helper that outlives this process and its agent: a
1908
+ shell starts it in the background and exits, so it is nobody's child.
1909
+ Stopping an agent stops every process descended from its MCP server
1910
+ (brindle.procs), a helper ``_detach`` started there included."""
1911
+ subprocess.run(["/bin/sh", "-c", '"$@" </dev/null >/dev/null 2>&1 &', "sh", *args],
1912
+ start_new_session=True, stdin=subprocess.DEVNULL,
1913
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
1914
+
1915
+
1870
1916
  def wait_for_result(db: DB, agent_id: str, timeout: float, poll: float = 2.0) -> str:
1871
1917
  deadline = time.time() + timeout
1872
1918
  while time.time() < deadline:
@@ -1935,37 +1981,73 @@ def submit_review(db: DB, caller_id: str, approved: bool, summary: str) -> str:
1935
1981
  db.enqueue(root_id, text, None) # handed over at the supervisor's next Stop
1936
1982
  close_later(caller.id)
1937
1983
  return "Audit recorded and sent to the supervisor. You're done."
1984
+ # Already recorded: closing this reviewer is its verdict's job, once
1985
+ # whatever the verdict set off is done. Checked before the lock too, which
1986
+ # that work (a merge running the checks) can hold for minutes.
1987
+ recorded = "Your review was already recorded. You're done."
1988
+ if caller.result is not None:
1989
+ return recorded
1938
1990
  # One verdict at a time per workspace: a second one (the same reviewer
1939
1991
  # calling twice, or another reviewer of the same branch) waits here, then
1940
1992
  # finds what the first one did instead of merging alongside it.
1941
1993
  with pipeline.merge_lock(ws.id):
1942
1994
  caller = db.get_agent(caller_id)
1943
- if caller is None or caller.result is not None or db.get_workspace(ws.id) is None \
1944
- or not os.path.isdir(ws.path):
1945
- # Already recorded, or the branch merged (or was removed) meanwhile.
1946
- close_later(caller_id)
1947
- return "Your review was already recorded, or the branch is already merged. You're done."
1995
+ if caller is not None and caller.result is not None:
1996
+ return recorded
1997
+ if caller is None or db.get_workspace(ws.id) is None or not os.path.isdir(ws.path):
1998
+ close_later(caller_id) # the branch merged (or was removed) meanwhile
1999
+ return "The branch is already merged or removed. You're done."
1948
2000
  sha = gates.head(ws)
1949
2001
  db.add_review(ws.id, sha, caller.id, approved, summary)
1950
2002
  if approved:
1951
2003
  db.bump_progress(autopilot.root_of(db, caller.id))
1952
2004
  pipeline.note_review(db, ws, approved, reviewer=caller)
1953
2005
  verdict = "APPROVED" if approved else "CHANGES REQUESTED"
1954
- text = f"Review of {ws.branch} (workspace {ws.id}) at {sha[:8]}: {verdict}\n\n{summary}"
1955
- # Outside the lock: an approval merges every ready branch of the base,
1956
- # taking each one's lock in turn, and holding this branch's lock while
1957
- # waiting for that would deadlock with a drain that wants it.
1958
- handled = pipeline.on_review(db, caller, ws, approved, summary)
1959
- # A merge the pipeline just made removes this reviewer's own record.
1960
- report_result(db, caller.id, text, forward=not handled, removed=(caller, ws))
1961
- close_later(caller.id)
1962
- if not approved:
1963
- from brindle import learned_rules
1964
-
1965
- learned_rules.refresh_later(ws.repo_root) # the finding just landed in the history
1966
- if handled:
1967
- return f"Review recorded ({verdict}); brindle takes it from here. You're done."
1968
- return f"Review recorded ({verdict}) and sent to your supervisor. You're done."
2006
+ db.set_result(caller.id, _review_text(ws, sha, approved, summary))
2007
+ finish_review_later(caller.id, ws.id)
2008
+ return f"Review recorded ({verdict}); brindle takes it from here. You're done."
2009
+
2010
+
2011
+ def _review_text(ws: Workspace, sha: str, approved: bool, summary: str) -> str:
2012
+ verdict = "APPROVED" if approved else "CHANGES REQUESTED"
2013
+ return f"Review of {ws.branch} (workspace {ws.id}) at {sha[:8]}: {verdict}\n\n{summary}"
2014
+
2015
+
2016
+ def finish_review_later(reviewer_id: str, ws_id: str) -> None:
2017
+ """Act on a recorded verdict (``finish_review``) from a process of its
2018
+ own. Not in the reviewer's MCP server: a merge runs the repo's checks,
2019
+ which can take minutes, and a reviewer kept waiting that long calls
2020
+ submit_review again, or is closed, which would stop the merge, the
2021
+ queued tasks it starts and the supervisor's notice half way."""
2022
+ from brindle.providers import brindle_invocation
2023
+
2024
+ _detach_orphan([*brindle_invocation(), "_finish-review", reviewer_id, ws_id])
2025
+
2026
+
2027
+ def finish_review(db: DB, reviewer_id: str, ws_id: str) -> None:
2028
+ """Hand a verdict submit_review recorded to the pipeline if the branch
2029
+ is piped, else to the supervisor, then close the reviewer."""
2030
+ from brindle import pipeline
2031
+
2032
+ try:
2033
+ caller, ws = db.get_agent(reviewer_id), db.get_workspace(ws_id)
2034
+ review = next((r for r in reversed(db.workspace_reviews(ws_id))
2035
+ if r.reviewer_id == reviewer_id), None) if caller and ws else None
2036
+ if review is None:
2037
+ return
2038
+ # Outside the lock: an approval merges every ready branch of the base,
2039
+ # taking each one's lock in turn, and holding this branch's lock while
2040
+ # waiting for that would deadlock with a drain that wants it.
2041
+ handled = pipeline.on_review(db, caller, ws, review.approved, review.summary or "")
2042
+ # A merge the pipeline just made removes this reviewer's own record.
2043
+ text = _review_text(ws, review.sha, review.approved, review.summary or "")
2044
+ report_result(db, caller.id, text, forward=not handled, removed=(caller, ws))
2045
+ if not review.approved:
2046
+ from brindle import learned_rules
2047
+
2048
+ learned_rules.refresh_later(ws.repo_root) # the finding just landed in the history
2049
+ finally:
2050
+ close_later(reviewer_id)
1969
2051
 
1970
2052
 
1971
2053
  def close_later(agent_id: str, delay: float = 5.0) -> None:
@@ -2713,6 +2795,15 @@ def _stop_decision(db: DB, agent: Agent, payload: dict, via_inbox: bool = False)
2713
2795
  inbox message rather than block the stop, so ``stop_hook_active`` never
2714
2796
  caps a repeat; the unread-mail reminder is then limited to one per
2715
2797
  NOTICE_TTL (DB.claim_stop_reminder)."""
2798
+ if agent.mode == "interactive":
2799
+ # Every supervisor turn ends here: start any queued task a cut-off or
2800
+ # manual merge left waiting (its "Started" note is delivered below).
2801
+ try:
2802
+ from brindle import tasks
2803
+
2804
+ tasks.sweep(db)
2805
+ except Exception: # noqa: BLE001 -- must never break the Stop hook
2806
+ log.exception("brindle: queued-task sweep failed")
2716
2807
  msg = db.pop_pending(agent.id)
2717
2808
  if msg:
2718
2809
  db.set_status(agent.id, "processing")
@@ -589,7 +589,12 @@ def _pause_running(db: DB, ws: Workspace, stop_procs: bool = True) -> None:
589
589
  ``stop_procs=False`` when a detached cull follows (see agents.pause)."""
590
590
  from brindle import scratch
591
591
 
592
- for a in _live_sessions(db, ws):
592
+ live = _live_sessions(db, ws)
593
+ caller = os.environ.get("BRINDLE_AGENT_ID")
594
+ if caller and any(a.id == caller for a in live):
595
+ # Pausing kills the session's process tree, this command with it.
596
+ _fail("this command would stop the session running it; run it from a terminal instead")
597
+ for a in live:
593
598
  # A session starts here next: keep its local models loaded.
594
599
  agents.pause(db, a.id, stop_procs=stop_procs, stop_local_models=False)
595
600
  typer.echo(f"Paused the session that was still running here ({a.id}); "
@@ -2814,6 +2819,15 @@ def close_cmd(agent_id: str, delay: float = typer.Option(0.0)) -> None:
2814
2819
  pass
2815
2820
 
2816
2821
 
2822
+ @app.command("_finish-review", hidden=True)
2823
+ def finish_review_cmd(reviewer_id: str, workspace_id: str) -> None:
2824
+ import signal
2825
+
2826
+ # Started by the reviewer's submit_review, and must outlive its close.
2827
+ signal.signal(signal.SIGHUP, signal.SIG_IGN)
2828
+ agents.finish_review(_helper_db(), reviewer_id, workspace_id)
2829
+
2830
+
2817
2831
  @app.command("_statusline", hidden=True)
2818
2832
  def statusline_cmd() -> None:
2819
2833
  from brindle.providers import status_line