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.
Files changed (144) hide show
  1. {brindle-0.3.23 → brindle-0.3.24}/PKG-INFO +2 -2
  2. {brindle-0.3.23 → brindle-0.3.24}/README.md +1 -1
  3. {brindle-0.3.23 → brindle-0.3.24}/pyproject.toml +1 -1
  4. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/agents.py +54 -27
  5. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/antigravity.py +3 -1
  6. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/autopilot.py +4 -3
  7. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/cli.py +43 -17
  8. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/cloud_login.py +45 -7
  9. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/company_identity.py +4 -1
  10. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/company_login.py +26 -3
  11. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/cull.py +11 -3
  12. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/db.py +7 -2
  13. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/doctor.py +17 -6
  14. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/gates.py +1 -1
  15. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/mcp_server.py +5 -2
  16. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/picker.py +14 -1
  17. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pipeline.py +138 -17
  18. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/prompt_answer.py +17 -2
  19. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/providers.py +40 -4
  20. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/sidebar_follow.py +11 -0
  21. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/sidebar_logo.py +10 -0
  22. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/signin_pause.py +8 -2
  23. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/tasks.py +59 -12
  24. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/tmux.py +33 -16
  25. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/upgrade.py +20 -4
  26. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/view.py +2 -1
  27. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/watch.py +25 -5
  28. {brindle-0.3.23 → brindle-0.3.24}/.gitignore +0 -0
  29. {brindle-0.3.23 → brindle-0.3.24}/LICENSE +0 -0
  30. {brindle-0.3.23 → brindle-0.3.24}/SCHEDULE-A +0 -0
  31. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/__init__.py +0 -0
  32. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/__main__.py +0 -0
  33. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/account.py +0 -0
  34. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/airgap.py +0 -0
  35. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/assets/braid_dark.png +0 -0
  36. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/assets/braid_light.png +0 -0
  37. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/budget.py +0 -0
  38. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/__init__.py +0 -0
  39. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/backend-auditor.md +0 -0
  40. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/developer-codex.md +0 -0
  41. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/developer-fable.md +0 -0
  42. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/developer-gemini.md +0 -0
  43. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/developer-heavy.md +0 -0
  44. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/developer-light.md +0 -0
  45. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/developer-local.md +0 -0
  46. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/developer.md +0 -0
  47. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/qa.md +0 -0
  48. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
  49. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
  50. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/reviewer-local.md +0 -0
  51. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/reviewer.md +0 -0
  52. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/subagent.md +0 -0
  53. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_agents/supervisor.md +0 -0
  54. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_rules/__init__.py +0 -0
  55. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_rules/security/backend.md +0 -0
  56. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
  57. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/builtin_rules/tests/only.md +0 -0
  58. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/ci_adapters.py +0 -0
  59. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/ci_client.py +0 -0
  60. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/ci_federation.py +0 -0
  61. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/ci_hosts.py +0 -0
  62. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/cloud_credential.py +0 -0
  63. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/codemap.py +0 -0
  64. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/codemap_docs.py +0 -0
  65. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/codex_hook.py +0 -0
  66. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/config.py +0 -0
  67. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/conflicts.py +0 -0
  68. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/cost.py +0 -0
  69. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/cost_estimate.py +0 -0
  70. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/demo.py +0 -0
  71. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/detect.py +0 -0
  72. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/escapes.py +0 -0
  73. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/events.py +0 -0
  74. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/git.py +0 -0
  75. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/guardrails.py +0 -0
  76. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/history.py +0 -0
  77. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/identity_lock.py +0 -0
  78. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/inbox.py +0 -0
  79. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/key_check.py +0 -0
  80. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/keys_cmds.py +0 -0
  81. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/keystore.py +0 -0
  82. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/launch_health.py +0 -0
  83. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/learned_rules.py +0 -0
  84. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/learning.py +0 -0
  85. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/legacy.py +0 -0
  86. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/local_setup.py +0 -0
  87. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/login_pause.py +0 -0
  88. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/model_access.py +0 -0
  89. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/model_availability.py +0 -0
  90. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/native/__init__.py +0 -0
  91. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/native/client.py +0 -0
  92. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/native/loop.py +0 -0
  93. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/native/permissions.py +0 -0
  94. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/native/runner.py +0 -0
  95. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/native/serve.py +0 -0
  96. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/native/tools.py +0 -0
  97. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pane_auth.py +0 -0
  98. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/permissions.py +0 -0
  99. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/plugins.py +0 -0
  100. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/policy.py +0 -0
  101. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pool.py +0 -0
  102. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pricing.py +0 -0
  103. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/__init__.py +0 -0
  104. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/_files.py +0 -0
  105. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/account.py +0 -0
  106. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/audit_chain.py +0 -0
  107. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/audit_export.py +0 -0
  108. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/auth.py +0 -0
  109. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/brief_learning.py +0 -0
  110. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/cost_centers.py +0 -0
  111. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/credentials.py +0 -0
  112. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/features.py +0 -0
  113. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/keys.py +0 -0
  114. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/learning.py +0 -0
  115. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/license.py +0 -0
  116. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/loopback.py +0 -0
  117. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/managed_models.py +0 -0
  118. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/org_profiles.py +0 -0
  119. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/orgkey.py +0 -0
  120. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/rollout.py +0 -0
  121. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/route_health.py +0 -0
  122. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/settings_sync.py +0 -0
  123. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/status.py +0 -0
  124. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/team_events.py +0 -0
  125. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/pro/team_policy.py +0 -0
  126. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/procs.py +0 -0
  127. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/profiles.py +0 -0
  128. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/quota.py +0 -0
  129. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/repo_cmds.py +0 -0
  130. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/repos.py +0 -0
  131. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/rewind.py +0 -0
  132. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/rule_checks.py +0 -0
  133. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/savings.py +0 -0
  134. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/scratch.py +0 -0
  135. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/secrets.py +0 -0
  136. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/self_serve.py +0 -0
  137. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/services.py +0 -0
  138. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/sessions.py +0 -0
  139. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/status_cache.py +0 -0
  140. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/updates.py +0 -0
  141. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/usage.py +0 -0
  142. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/watch_costs.py +0 -0
  143. {brindle-0.3.23 → brindle-0.3.24}/src/brindle/worker_models.py +0 -0
  144. {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.23
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.23`). A session started before an upgrade keeps
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.23`). A session started before an upgrade keeps
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brindle"
3
- version = "0.3.23"
3
+ version = "0.3.24"
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"
@@ -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(["/bin/sh", "-c", '"$@" </dev/null >/dev/null 2>&1 &', "sh", *args],
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 is_alive(a) and a.review_sha == sha \
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
- _detach(argv)
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
- since = pipeline.review_claim(db, ws)
2426
- for _attempt in range(MOVED_RETRIES + 1):
2427
- sha = gates.head(ws)
2428
- if _live_reviewer(db, ws, sha, profile):
2429
- return None
2430
- try:
2431
- summary = gates.check_summary(db, ws, cfg, cancel=lambda sha=sha: gates.head(ws) != sha)
2432
- except gates.Abandoned:
2433
- summary = None
2434
- except Exception as e: # noqa: BLE001 - the review goes ahead, saying so
2435
- summary = f"(running the checks crashed: {e})"
2436
- if db.get_workspace(ws.id) is None:
2437
- return None
2438
- if gates.head(ws) == sha:
2439
- break
2440
- if not pipeline.still_claimed(db, ws, since):
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
- else:
2443
- pipeline.on_moved_during_checks(db, ws)
2444
- return None
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 is_alive(old):
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
- return shutil.which("agy") or (BUNDLED if os.path.exists(BUNDLED) else "agy")
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.is_alive(auditor) or auditor.result is not None:
1827
- # Auditor was spawned but is no longer running (dead or crashed without verdict).
1828
- # Reset state to running so autopilot can recover.
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
- provider = provider or provider_alias
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 git.current_branch(ws.path) == git.default_branch(ws.repo_root):
341
- if warning := git.fast_forward(ws.path, git.current_branch(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 (shutil.which(binary) or os.path.isfile(binary)):
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", show_choices=False)
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=subprocess.run, expiry=tokeninfo_expiry) -> LoginStatus:
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=subprocess.run, expiry=tokeninfo_expiry) -> LoginStatus:
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=subprocess.run, expiry=tokeninfo_expiry,
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
- return "manual"
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=_full_env(env), stdin=subprocess.DEVNULL,
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.INSTALL.get(tool)
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: {INSTALL[tool]}")
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: {INSTALL['claude']})")
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 = 60.0
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 < max(UNPICKED_AFTER, 2 * wait) or db.latest_review(ws.id, head)
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 -- when its last report put its branch in review (pipeline.on_report)
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)")}