ctrlrelay 0.7.0__tar.gz → 0.8.0__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 (114) hide show
  1. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/CHANGELOG.md +17 -1
  2. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/config/orchestrator.yaml.example +6 -0
  4. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/docs/configuration.md +26 -0
  5. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/pyproject.toml +1 -1
  6. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/cli.py +2 -0
  7. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/config.py +7 -0
  8. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/pipelines/dev.py +4 -1
  9. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_config.py +64 -0
  10. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_dev_pipeline.py +59 -0
  11. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  12. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  13. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  14. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/.github/dependabot.yml +0 -0
  15. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/.github/workflows/build.yml +0 -0
  16. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/.github/workflows/cla.yml +0 -0
  17. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/.github/workflows/pages.yml +0 -0
  18. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/.github/workflows/publish.yml +0 -0
  19. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/.github/workflows/test.yml +0 -0
  20. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/.gitignore +0 -0
  21. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/CODE_OF_CONDUCT.md +0 -0
  22. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/CONTRIBUTING.md +0 -0
  23. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/LICENSE +0 -0
  24. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/README.md +0 -0
  25. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/SECURITY.md +0 -0
  26. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/docs/Gemfile +0 -0
  27. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/docs/_config.yml +0 -0
  28. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/docs/architecture.md +0 -0
  29. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/docs/bridge.md +0 -0
  30. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/docs/cli.md +0 -0
  31. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/docs/development.md +0 -0
  32. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/docs/feedback-loop.md +0 -0
  33. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/docs/getting-started.md +0 -0
  34. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/docs/index.md +0 -0
  35. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/docs/operations.md +0 -0
  36. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/docs/personalization.md +0 -0
  37. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/__init__.py +0 -0
  38. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  39. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  40. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  41. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/bridge/server.py +0 -0
  42. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  43. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/__init__.py +0 -0
  44. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/audit.py +0 -0
  45. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  46. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/dispatcher.py +0 -0
  47. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/github.py +0 -0
  48. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/obs.py +0 -0
  49. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/poller.py +0 -0
  50. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  51. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  52. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/scheduler.py +0 -0
  53. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/state.py +0 -0
  54. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/worktree.py +0 -0
  55. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  56. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/dashboard/client.py +0 -0
  57. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/gh_protocol.py +0 -0
  58. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/install.py +0 -0
  59. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  60. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/personalization/manager.py +0 -0
  61. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/personalization/paths.py +0 -0
  62. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  63. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/pipelines/base.py +0 -0
  64. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  65. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/pipelines/secops.py +0 -0
  66. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/pipelines/task.py +0 -0
  67. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/setup.py +0 -0
  68. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/__init__.py +0 -0
  69. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  70. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  71. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  72. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  73. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  74. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  75. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  76. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/transports/__init__.py +0 -0
  77. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/transports/base.py +0 -0
  78. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  79. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/src/ctrlrelay/transports/socket_client.py +0 -0
  80. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/__init__.py +0 -0
  81. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/conftest.py +0 -0
  82. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_audit.py +0 -0
  83. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_bridge_protocol.py +0 -0
  84. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_bridge_server.py +0 -0
  85. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_checkpoint.py +0 -0
  86. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_cli_ci_wait.py +0 -0
  87. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_cli_dev.py +0 -0
  88. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_cli_repos.py +0 -0
  89. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_cli_secops.py +0 -0
  90. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_cli_start.py +0 -0
  91. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_cli_version.py +0 -0
  92. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_dashboard_client.py +0 -0
  93. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_dev_integration.py +0 -0
  94. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_dispatcher.py +0 -0
  95. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_docs_site.py +0 -0
  96. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_gh_protocol.py +0 -0
  97. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_github.py +0 -0
  98. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_install.py +0 -0
  99. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_obs.py +0 -0
  100. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_personalization.py +0 -0
  101. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_pipeline_base.py +0 -0
  102. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_poller.py +0 -0
  103. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_post_merge.py +0 -0
  104. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_pr_verifier.py +0 -0
  105. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_pr_watcher.py +0 -0
  106. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_scheduler.py +0 -0
  107. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_secops_integration.py +0 -0
  108. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_secops_pipeline.py +0 -0
  109. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_setup.py +0 -0
  110. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_state.py +0 -0
  111. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_task_pipeline.py +0 -0
  112. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_telegram_handler.py +0 -0
  113. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_transport.py +0 -0
  114. {ctrlrelay-0.7.0 → ctrlrelay-0.8.0}/tests/test_worktree.py +0 -0
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-09-04
11
+
12
+ ### Added
13
+
14
+ - **`ci_wait_timeout_seconds`: per-repo cap on the dev pipeline's
15
+ `ctrlrelay ci wait` call.** The dev pipeline prompt tells Claude to run
16
+ `ctrlrelay ci wait --pr <PR> --repo <repo> --timeout 600` before
17
+ signaling DONE — a single blocking call, hardcoded to 10 minutes, that
18
+ generates zero tokens while it polls GitHub. Measured directly on a real
19
+ session: two `ci wait` calls accounted for 12 of a 26-minute run, versus
20
+ under 35K output tokens generated the whole time — the wall-clock cost
21
+ is CI runtime, not model work. `ci_wait_timeout_seconds` (default `600`,
22
+ unchanged behavior) lets a repo with a faster CI suite cap that single
23
+ call lower instead of always budgeting the full 10 minutes.
24
+
10
25
  ## [0.7.0] - 2026-09-04
11
26
 
12
27
  ### Added
@@ -1232,7 +1247,8 @@ pipeline).
1232
1247
  per-phase implementation plans (Phase 0 through Phase 4).
1233
1248
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
1234
1249
 
1235
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.7.0...HEAD
1250
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.8.0...HEAD
1251
+ [0.8.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.7.0...v0.8.0
1236
1252
  [0.7.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.6.0...v0.7.0
1237
1253
  [0.6.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.5.0...v0.6.0
1238
1254
  [0.1.3]: https://github.com/AInvirion/ctrlrelay/releases/tag/v0.1.3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
5
5
  Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
6
6
  Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
@@ -146,3 +146,9 @@ repos: []
146
146
  # # Matching is case-insensitive. Defaults to [] (assignment alone
147
147
  # # is sufficient, same as today).
148
148
  # require_labels: []
149
+ # # Hard cap, in seconds, on the single `ctrlrelay ci wait` call the
150
+ # # dev pipeline prompt runs before signaling DONE. Lower this to
151
+ # # match how long this repo's CI actually takes — no tokens are
152
+ # # generated while blocked in this call, so a slow default here
153
+ # # just adds idle wall-clock time. Defaults to 600 (10 minutes).
154
+ # ci_wait_timeout_seconds: 600
@@ -201,6 +201,7 @@ and ask the operator), or `never` (skip).
201
201
  | `exclude_labels` | `["manual", "operator", "instruction"]` | Issue labels that tell the poller "this isn't for the agent". See [exclude_labels](#reposautomationexclude_labels) below. |
202
202
  | `include_labels` | `[]` | Issue labels that opt an issue **into** the dev pipeline regardless of who is (or isn't) assigned. See [include_labels](#reposautomationinclude_labels) below. |
203
203
  | `require_labels` | `[]` | Issue labels that gate the plain-assignment trigger: when set, assignment alone is no longer enough — the issue must also carry one of these labels. See [require_labels](#reposautomationrequire_labels) below. |
204
+ | `ci_wait_timeout_seconds` | `600` | Hard cap, in seconds, on the single `ctrlrelay ci wait` call the dev pipeline prompt tells Claude to run before signaling DONE. See [ci_wait_timeout_seconds](#reposautomationci_wait_timeout_seconds) below. |
204
205
 
205
206
  The current secops and dev pipelines read these settings to bias their prompts
206
207
  to Claude — they're not enforced by hard-coded checks.
@@ -345,6 +346,31 @@ repos:
345
346
  picked up yet.
346
347
  - `exclude_labels` is still checked first, same precedence as always.
347
348
 
349
+ ### repos[].automation.ci_wait_timeout_seconds
350
+
351
+ The dev pipeline prompt tells Claude to run `ctrlrelay ci wait --pr <PR>
352
+ --repo <repo> --timeout <N>` before signaling DONE — a single blocking call
353
+ that polls GitHub every 15s until CI finishes, fails, or the timeout hits.
354
+ No tokens get generated while it's blocked; on a repo with a slow CI suite,
355
+ that one call can eat most of a session's wall-clock time.
356
+
357
+ ```yaml
358
+ repos:
359
+ - name: "your-org/your-repo"
360
+ local_path: "~/Projects/your-repo"
361
+ automation:
362
+ ci_wait_timeout_seconds: 300
363
+ ```
364
+
365
+ - Default: `600` (10 minutes).
366
+ - Lower it to match how long the repo's CI actually takes — a repo with a
367
+ 2-minute CI suite gains nothing from a 10-minute cap.
368
+ - A timeout here isn't a failure: `ctrlrelay ci wait` exits 2 (not 1) when
369
+ the deadline hits with checks still pending, and the prompt tells Claude
370
+ to treat that as acceptable and hand off rather than loop.
371
+ - This does not change the overall session timeout (`agent.default_timeout_seconds`,
372
+ default 1800s) — it only bounds the one `ci wait` call.
373
+
348
374
  ## schedules
349
375
 
350
376
  In-process cron jobs run by the poller daemon. All expressions are
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Local-first orchestrator for headless coding agents across multiple GitHub repos"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -748,6 +748,7 @@ def run_dev(
748
748
  state_db=db,
749
749
  transport=None,
750
750
  contexts_dir=config.paths.contexts,
751
+ ci_wait_timeout_seconds=repo_config.automation.ci_wait_timeout_seconds,
751
752
  )
752
753
 
753
754
  try:
@@ -1273,6 +1274,7 @@ def poller_start(
1273
1274
  state_db=state_db,
1274
1275
  transport=connected_transport,
1275
1276
  contexts_dir=config.paths.contexts,
1277
+ ci_wait_timeout_seconds=repo_config.automation.ci_wait_timeout_seconds,
1276
1278
  )
1277
1279
 
1278
1280
  # Lock-conflict retry hook. The poller marks issues seen
@@ -201,6 +201,13 @@ class AutomationConfig(BaseModel):
201
201
  # precedence so "not for the agent at all" overrides "agent does
202
202
  # this but differently".
203
203
  task_labels: list[str] = Field(default_factory=lambda: ["task"])
204
+ # Hard cap, in seconds, on a single `ctrlrelay ci wait` call the dev
205
+ # pipeline prompt tells Claude to run before signaling DONE. A repo
206
+ # with slow CI can eat most of a session's wall-clock time sitting
207
+ # in this one blocking call generating zero tokens; a fast-CI repo
208
+ # doesn't need anywhere near the 600s default. Lower this per-repo
209
+ # to match how long that repo's CI actually takes.
210
+ ci_wait_timeout_seconds: int = 600
204
211
 
205
212
 
206
213
  _REPO_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
@@ -330,6 +330,7 @@ class DevPipeline:
330
330
  issue_title = extra.get("issue_title", "")
331
331
  issue_body = extra.get("issue_body", "")
332
332
  branch_name = extra.get("branch_name", "")
333
+ ci_wait_timeout_seconds = extra.get("ci_wait_timeout_seconds", 600)
333
334
  state_file_path = str(state_file) if state_file else "/tmp/state.json"
334
335
 
335
336
  return f"""You are working on issue #{issue_number} in repository {repo}.
@@ -349,7 +350,7 @@ Execute the following workflow:
349
350
  4. Push the branch and open a PR that references the issue
350
351
  5. Before signaling DONE, verify the PR is mergeable:
351
352
  - Wait for CI by running EXACTLY this command (do NOT improvise bash):
352
- `ctrlrelay ci wait --pr <PR> --repo {repo} --timeout 600`
353
+ `ctrlrelay ci wait --pr <PR> --repo {repo} --timeout {ci_wait_timeout_seconds}`
353
354
  Exit codes: 0 = all checks passed, 1 = a check failed (investigate,
354
355
  fix, push, then re-run the wait), 2 = hard timeout while CI is still
355
356
  pending (treat as acceptable — hand off and let the orchestrator
@@ -608,6 +609,7 @@ async def run_dev_issue(
608
609
  max_fix_attempts: int = DEFAULT_MAX_FIX_ATTEMPTS,
609
610
  max_blocked_rounds: int = DEFAULT_MAX_BLOCKED_ROUNDS,
610
611
  pr_verifier: PRVerifier | None = None,
612
+ ci_wait_timeout_seconds: int = 600,
611
613
  ) -> PipelineResult:
612
614
  """Run dev pipeline for a single issue."""
613
615
  session_id = f"dev-{repo.replace('/', '-')}-{issue_number}-{uuid.uuid4().hex[:8]}"
@@ -715,6 +717,7 @@ async def run_dev_issue(
715
717
  "issue_title": issue.get("title", ""),
716
718
  "issue_body": issue.get("body", ""),
717
719
  "branch_name": branch_name,
720
+ "ci_wait_timeout_seconds": ci_wait_timeout_seconds,
718
721
  },
719
722
  )
720
723
 
@@ -640,3 +640,67 @@ class TestAutomationRequireLabels:
640
640
 
641
641
  assert config.repos[0].automation.require_labels == ["ctrlrelay:auto"]
642
642
  assert config.repos[1].automation.require_labels == []
643
+
644
+
645
+ class TestAutomationCiWaitTimeout:
646
+ """ci_wait_timeout_seconds caps the single `ctrlrelay ci wait` call the
647
+ dev pipeline prompt tells Claude to run before signaling DONE. Default
648
+ 600 preserves today's behavior."""
649
+
650
+ def test_default_ci_wait_timeout_is_600(self) -> None:
651
+ auto = AutomationConfig()
652
+ assert auto.ci_wait_timeout_seconds == 600
653
+
654
+ def test_ci_wait_timeout_override_accepts_int(self) -> None:
655
+ auto = AutomationConfig(ci_wait_timeout_seconds=300)
656
+ assert auto.ci_wait_timeout_seconds == 300
657
+
658
+ def test_config_without_ci_wait_timeout_key_defaults_to_600(
659
+ self, sample_config_dict: dict, tmp_path: Path
660
+ ) -> None:
661
+ sample_config_dict["repos"] = [
662
+ {
663
+ "name": "owner/repo",
664
+ "local_path": "~/Projects/repo",
665
+ "automation": {"dependabot_patch": "auto"},
666
+ }
667
+ ]
668
+ cfg_path = tmp_path / "cfg.yaml"
669
+ cfg_path.write_text(yaml.dump(sample_config_dict))
670
+
671
+ config = load_config(cfg_path)
672
+
673
+ assert config.repos[0].automation.ci_wait_timeout_seconds == 600
674
+
675
+ def test_ci_wait_timeout_from_yaml(
676
+ self, sample_config_dict: dict, tmp_path: Path
677
+ ) -> None:
678
+ sample_config_dict["repos"] = [
679
+ {
680
+ "name": "owner/repo",
681
+ "local_path": "~/Projects/repo",
682
+ "automation": {"ci_wait_timeout_seconds": 300},
683
+ }
684
+ ]
685
+ cfg_path = tmp_path / "cfg.yaml"
686
+ cfg_path.write_text(yaml.dump(sample_config_dict))
687
+
688
+ config = load_config(cfg_path)
689
+
690
+ assert config.repos[0].automation.ci_wait_timeout_seconds == 300
691
+
692
+ def test_ci_wait_timeout_rejects_non_int(
693
+ self, sample_config_dict: dict, tmp_path: Path
694
+ ) -> None:
695
+ sample_config_dict["repos"] = [
696
+ {
697
+ "name": "owner/repo",
698
+ "local_path": "~/Projects/repo",
699
+ "automation": {"ci_wait_timeout_seconds": "fast"},
700
+ }
701
+ ]
702
+ cfg_path = tmp_path / "cfg.yaml"
703
+ cfg_path.write_text(yaml.dump(sample_config_dict))
704
+
705
+ with pytest.raises(ConfigError, match="ci_wait_timeout_seconds"):
706
+ load_config(cfg_path)
@@ -61,6 +61,65 @@ class TestDevPipeline:
61
61
  "prompt should explicitly ban `until`/`while` bash CI-wait loops"
62
62
  )
63
63
 
64
+ def test_prompt_uses_default_ci_wait_timeout_when_not_configured(self) -> None:
65
+ """No ci_wait_timeout_seconds in extra: falls back to 600, matching
66
+ AutomationConfig's default so an unconfigured repo behaves as before."""
67
+ from ctrlrelay.pipelines.dev import DevPipeline
68
+
69
+ pipeline = DevPipeline(
70
+ dispatcher=MagicMock(),
71
+ github=MagicMock(),
72
+ worktree=MagicMock(),
73
+ dashboard=None,
74
+ state_db=MagicMock(),
75
+ transport=None,
76
+ )
77
+
78
+ prompt = pipeline._build_prompt(
79
+ repo="owner/repo",
80
+ issue_number=42,
81
+ extra={
82
+ "issue_title": "t",
83
+ "issue_body": "b",
84
+ "branch_name": "fix/issue-42",
85
+ },
86
+ session_id="dev-42",
87
+ state_file=Path("/tmp/state.json"),
88
+ )
89
+
90
+ assert "ctrlrelay ci wait --pr <PR> --repo owner/repo --timeout 600" in prompt
91
+
92
+ def test_prompt_uses_configured_ci_wait_timeout(self) -> None:
93
+ """A repo with a slow (or fast) CI can override the 600s default so
94
+ the dev pipeline doesn't spend most of a session blocked in one
95
+ `ctrlrelay ci wait` call."""
96
+ from ctrlrelay.pipelines.dev import DevPipeline
97
+
98
+ pipeline = DevPipeline(
99
+ dispatcher=MagicMock(),
100
+ github=MagicMock(),
101
+ worktree=MagicMock(),
102
+ dashboard=None,
103
+ state_db=MagicMock(),
104
+ transport=None,
105
+ )
106
+
107
+ prompt = pipeline._build_prompt(
108
+ repo="owner/repo",
109
+ issue_number=42,
110
+ extra={
111
+ "issue_title": "t",
112
+ "issue_body": "b",
113
+ "branch_name": "fix/issue-42",
114
+ "ci_wait_timeout_seconds": 300,
115
+ },
116
+ session_id="dev-42",
117
+ state_file=Path("/tmp/state.json"),
118
+ )
119
+
120
+ assert "ctrlrelay ci wait --pr <PR> --repo owner/repo --timeout 300" in prompt
121
+ assert "--timeout 600" not in prompt
122
+
64
123
  @pytest.mark.asyncio
65
124
  async def test_run_dispatches_claude_session(self, tmp_path: Path) -> None:
66
125
  """Should dispatch Claude session with issue context."""
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes