ctrlrelay 0.11.0__tar.gz → 0.11.2__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 (122) hide show
  1. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/CHANGELOG.md +56 -1
  2. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/PKG-INFO +1 -1
  3. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/pyproject.toml +1 -1
  4. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/worktree.py +92 -8
  5. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/pipelines/secops.py +43 -0
  6. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_secops_pipeline.py +197 -0
  7. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_worktree.py +179 -0
  8. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  9. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  10. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  11. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.github/dependabot.yml +0 -0
  12. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.github/workflows/build.yml +0 -0
  13. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.github/workflows/cla.yml +0 -0
  14. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.github/workflows/pages.yml +0 -0
  15. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.github/workflows/publish.yml +0 -0
  16. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.github/workflows/test.yml +0 -0
  17. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.gitignore +0 -0
  18. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/CODE_OF_CONDUCT.md +0 -0
  19. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/CONTRIBUTING.md +0 -0
  20. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/LICENSE +0 -0
  21. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/README.md +0 -0
  22. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/SECURITY.md +0 -0
  23. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/config/orchestrator.yaml.example +0 -0
  24. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/Gemfile +0 -0
  25. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/_config.yml +0 -0
  26. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/architecture.md +0 -0
  27. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/bridge.md +0 -0
  28. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/cli.md +0 -0
  29. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/configuration.md +0 -0
  30. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/development.md +0 -0
  31. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/feedback-loop.md +0 -0
  32. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/getting-started.md +0 -0
  33. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/index.md +0 -0
  34. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/operations.md +0 -0
  35. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/personalization.md +0 -0
  36. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/__init__.py +0 -0
  37. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/bridge/__init__.py +0 -0
  38. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/bridge/__main__.py +0 -0
  39. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/bridge/protocol.py +0 -0
  40. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/bridge/server.py +0 -0
  41. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  42. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/cli.py +0 -0
  43. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/__init__.py +0 -0
  44. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/archived.py +0 -0
  45. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/audit.py +0 -0
  46. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/checkpoint.py +0 -0
  47. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/code_review.py +0 -0
  48. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/config.py +0 -0
  49. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/dispatcher.py +0 -0
  50. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/github.py +0 -0
  51. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/network.py +0 -0
  52. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/obs.py +0 -0
  53. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/poller.py +0 -0
  54. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/pr_verifier.py +0 -0
  55. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/pr_watcher.py +0 -0
  56. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/question_expiry.py +0 -0
  57. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/scheduler.py +0 -0
  58. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/state.py +0 -0
  59. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/dashboard/__init__.py +0 -0
  60. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/dashboard/client.py +0 -0
  61. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/gh_protocol.py +0 -0
  62. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/install.py +0 -0
  63. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/personalization/__init__.py +0 -0
  64. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/personalization/manager.py +0 -0
  65. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/personalization/paths.py +0 -0
  66. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/pipelines/__init__.py +0 -0
  67. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/pipelines/base.py +0 -0
  68. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/pipelines/dev.py +0 -0
  69. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  70. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/pipelines/task.py +0 -0
  71. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/setup.py +0 -0
  72. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/__init__.py +0 -0
  73. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  74. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  75. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  76. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  77. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  78. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  79. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  80. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/transports/__init__.py +0 -0
  81. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/transports/base.py +0 -0
  82. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/transports/file_mock.py +0 -0
  83. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/transports/socket_client.py +0 -0
  84. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/__init__.py +0 -0
  85. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/conftest.py +0 -0
  86. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_archived.py +0 -0
  87. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_audit.py +0 -0
  88. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_bridge_protocol.py +0 -0
  89. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_bridge_server.py +0 -0
  90. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_checkpoint.py +0 -0
  91. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_cli_ci_wait.py +0 -0
  92. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_cli_dev.py +0 -0
  93. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_cli_repos.py +0 -0
  94. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_cli_secops.py +0 -0
  95. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_cli_start.py +0 -0
  96. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_cli_version.py +0 -0
  97. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_code_review.py +0 -0
  98. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_config.py +0 -0
  99. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_dashboard_client.py +0 -0
  100. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_dev_integration.py +0 -0
  101. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_dev_pipeline.py +0 -0
  102. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_dispatcher.py +0 -0
  103. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_docs_site.py +0 -0
  104. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_gh_protocol.py +0 -0
  105. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_github.py +0 -0
  106. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_install.py +0 -0
  107. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_network.py +0 -0
  108. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_obs.py +0 -0
  109. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_personalization.py +0 -0
  110. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_pipeline_base.py +0 -0
  111. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_poller.py +0 -0
  112. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_post_merge.py +0 -0
  113. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_pr_verifier.py +0 -0
  114. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_pr_watcher.py +0 -0
  115. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_question_expiry.py +0 -0
  116. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_scheduler.py +0 -0
  117. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_secops_integration.py +0 -0
  118. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_setup.py +0 -0
  119. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_state.py +0 -0
  120. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_task_pipeline.py +0 -0
  121. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_telegram_handler.py +0 -0
  122. {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_transport.py +0 -0
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.11.2] - 2026-09-10
11
+
12
+ ### Fixed
13
+
14
+ - **A rebased Dependabot branch no longer fails the whole sweep.** The
15
+ bare-repo fetch uses a non-force refspec so it cannot discard a prior
16
+ session's unpushed commits. Dependabot rebases its branches onto a
17
+ moved base, which rewrites their tips — so every rebase became a
18
+ `non-fast-forward` rejection, and `git fetch` exits 1 on a declined
19
+ rewind even when every other ref applied. `ensure_bare_repo` read that
20
+ exit code as failure and aborted the session.
21
+
22
+ Observed on `AInvirion/zzsites`, whose sweep died reporting
23
+ `WorktreeError: git failed: ... ! [rejected] ... (non-fast-forward)`
24
+ in the same fetch that had just advanced `main`. Twelve bare repos
25
+ were holding a `dependabot/*` ref at the time, each one primed to fail
26
+ the moment Dependabot rebased it.
27
+
28
+ `dependabot/*` is now fetched with a force refspec first — those
29
+ branches are bot-authored and never carry local work, so rewinding one
30
+ destroys nothing. Any rejection that survives is logged as
31
+ `worktree.fetch.non_fast_forward` and tolerated rather than raised,
32
+ because a declined rewind is the refspec doing its job. Real failures
33
+ still raise: a `fatal:`/`error:` line, a rejection for any other
34
+ reason, or an unexplained non-zero exit. Failing open there would turn
35
+ an unreachable remote into a silent no-op and run pipelines against a
36
+ stale tree.
37
+
38
+ ## [0.11.1] - 2026-09-09
39
+
40
+ ### Fixed
41
+
42
+ - **Answering a secops question inline now gets a confirmation.** Two
43
+ paths reach a resumed session and only one of them reported. A reply
44
+ that arrived after the session had torn down landed in
45
+ `pending_resumes` and the sweeper sent `✅ Resume succeeded on <repo>`;
46
+ a reply that arrived while the session was still parked in
47
+ `transport.ask` resumed in-process and sent nothing at all.
48
+
49
+ The only completion message on that second path was the sweep-wide
50
+ `✅ Scheduled secops sweep done: N/M ok`, which fires once every repo
51
+ has been swept. On the 2026-09-09 sweep that was hours after the reply
52
+ and named no repo: four answers merged 20 Dependabot PRs across
53
+ `ai-miner`, `kenos-orchestrator`, `prompt-injection-benchmark` and
54
+ `aiproxyguard-promptest`, and not one outbound message went back to
55
+ the operator. Answering looked indistinguishable from being ignored.
56
+
57
+ The inline path now reports the same three outcomes the sweeper
58
+ already did — summary, re-blocked question, or error. Repos that never
59
+ asked anything stay silent; acking all ~90 daily is how an operator
60
+ learns to ignore the channel. The send is best-effort, so a dead bridge
61
+ socket cannot discard completed work or abort the rest of the sweep.
62
+
10
63
  ## [0.11.0] - 2026-09-08
11
64
 
12
65
  ### Added
@@ -1591,7 +1644,9 @@ pipeline).
1591
1644
  per-phase implementation plans (Phase 0 through Phase 4).
1592
1645
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
1593
1646
 
1594
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.0...HEAD
1647
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.2...HEAD
1648
+ [0.11.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.1...v0.11.2
1649
+ [0.11.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.0...v0.11.1
1595
1650
  [0.11.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.10.0...v0.11.0
1596
1651
  [0.10.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.9.0...v0.10.0
1597
1652
  [0.9.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.8.1...v0.9.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.11.0
3
+ Version: 0.11.2
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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.11.0"
3
+ version = "0.11.2"
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"
@@ -8,9 +8,13 @@ from dataclasses import dataclass
8
8
  from pathlib import Path
9
9
  from typing import TYPE_CHECKING
10
10
 
11
+ from ctrlrelay.core.obs import get_logger, log_event
12
+
11
13
  if TYPE_CHECKING:
12
14
  from ctrlrelay.core.github import GitHubCLI
13
15
 
16
+ _logger = get_logger("core.worktree")
17
+
14
18
 
15
19
  # Open-PR probe (issue #52) retries a handful of times before failing
16
20
  # closed. Transient gh/network errors are common enough that one-shot
@@ -36,6 +40,49 @@ _PR_PROBE_TIMEOUT_SECONDS = 10
36
40
  # genuinely stuck, not merely large.
37
41
  _GIT_TRANSFER_TIMEOUT_SECONDS = 1800
38
42
 
43
+ # Branches ctrlrelay never authors, so rewinding one can never destroy
44
+ # local work. Dependabot rebases its branches onto a moved base, which
45
+ # rewrites their tips — under a non-force refspec every such rebase is
46
+ # a non-fast-forward rejection.
47
+ _BOT_BRANCH_REFSPEC = "+refs/heads/dependabot/*:refs/heads/dependabot/*"
48
+
49
+
50
+ def _rejections_are_only_non_fast_forward(stderr: str) -> list[str]:
51
+ """Return the refs a fetch declined to rewind, or ``[]`` if the
52
+ failure was anything else.
53
+
54
+ A non-fast-forward rejection is the non-force refspec doing its job:
55
+ it refused to discard local commits. Git still exits 1, which the
56
+ caller must not read as "the fetch failed" — the rest of the fetch
57
+ (the default branch, prunes, new branches) applied normally.
58
+
59
+ Anything git considers a real error — ``fatal:`` on a dead remote,
60
+ ``error:`` on a bad object, or a rejection for some other reason —
61
+ returns ``[]`` so the caller raises as before. Failing open here
62
+ would turn an unreachable remote into a silent no-op and let
63
+ pipelines run against a stale tree, which is the bug this module's
64
+ explicit refspec was added to prevent in the first place.
65
+ """
66
+ refs: list[str] = []
67
+ for raw in stderr.splitlines():
68
+ line = raw.strip()
69
+ if not line:
70
+ continue
71
+ low = line.lower()
72
+ if low.startswith(("fatal:", "error:")):
73
+ return []
74
+ if not line.startswith("!"):
75
+ continue # progress line: "From ...", " * [new branch]", " abc..def"
76
+ if "non-fast-forward" not in low and "non-fast forward" not in low:
77
+ return [] # rejected for some other reason — not ours to swallow
78
+ # " ! [rejected] <src> -> <dst> (non-fast-forward)"
79
+ body = line.lstrip("! ")
80
+ if "]" in body:
81
+ body = body.split("]", 1)[1]
82
+ target = body.split("->")[-1].split("(")[0].strip()
83
+ refs.append(target or body.strip())
84
+ return refs
85
+
39
86
 
40
87
  class WorktreeError(Exception):
41
88
  """Raised when worktree operations fail."""
@@ -74,10 +121,17 @@ class WorktreeManager:
74
121
  *args: str,
75
122
  cwd: Path | None = None,
76
123
  timeout: int | None = None,
124
+ tolerate_non_fast_forward: bool = False,
77
125
  ) -> str:
78
126
  """Run git command and return stdout. `timeout` overrides self.timeout
79
127
  for one call — useful for cheap probes that shouldn't inherit the full
80
- 120s default when network is flaky."""
128
+ 120s default when network is flaky.
129
+
130
+ ``tolerate_non_fast_forward`` is for fetches under a non-force
131
+ refspec: git exits 1 when it declines to rewind a diverged local
132
+ branch, even though every other ref updated. See
133
+ :func:`_rejections_are_only_non_fast_forward`.
134
+ """
81
135
  cmd = ["git", *args]
82
136
  effective_timeout = timeout if timeout is not None else self.timeout
83
137
  proc = await asyncio.create_subprocess_exec(
@@ -124,7 +178,21 @@ class WorktreeManager:
124
178
  raise
125
179
 
126
180
  if proc.returncode != 0:
127
- raise WorktreeError(f"git failed: {stderr.decode().strip()}")
181
+ err = stderr.decode().strip()
182
+ declined = (
183
+ _rejections_are_only_non_fast_forward(err)
184
+ if tolerate_non_fast_forward
185
+ else []
186
+ )
187
+ if not declined:
188
+ raise WorktreeError(f"git failed: {err}")
189
+ log_event(
190
+ _logger,
191
+ "worktree.fetch.non_fast_forward",
192
+ cwd=str(cwd) if cwd is not None else None,
193
+ refs=declined,
194
+ ref_count=len(declined),
195
+ )
128
196
 
129
197
  return stdout.decode()
130
198
 
@@ -766,12 +834,11 @@ class WorktreeManager:
766
834
  test counts from a commit that was two weeks old.
767
835
 
768
836
  Refspec: ``refs/heads/*:refs/heads/*`` (no leading ``+``).
769
- Fast-forward-only on purpose — codex caught this on the
770
- first pass: a force update would clobber the dev pipeline's
771
- ``create_worktree_with_new_branch`` reuse path, which keeps
772
- local-ahead-of-origin commits when a prior session pushed
773
- partial work. Non-force semantics give us the right
774
- behavior in all three cases:
837
+ Fast-forward-only on purpose: a force update would clobber
838
+ the dev pipeline's ``create_worktree_with_new_branch`` reuse
839
+ path, which keeps local-ahead-of-origin commits when a prior
840
+ session pushed partial work. Non-force semantics give us the
841
+ right behavior in all three cases:
775
842
 
776
843
  - Local branch BEHIND origin (default-branch case, the bug
777
844
  we're fixing): fast-forward applies, local catches up.
@@ -782,15 +849,32 @@ class WorktreeManager:
782
849
 
783
850
  ``--prune`` drops refs that no longer exist on origin so
784
851
  deleted branches don't linger forever.
852
+
853
+ Two things keep that non-force refspec from failing the
854
+ caller, both learned from a secops sweep that died on
855
+ ``AInvirion/zzsites`` with ``main`` already updated:
856
+
857
+ - ``dependabot/*`` is fetched with a force refspec first.
858
+ Dependabot rebases its branches, which rewrites their tips,
859
+ so under a plain refspec every rebase is a rejection. These
860
+ branches are bot-authored and never carry local work, so
861
+ rewinding one destroys nothing. Twelve bare repos were
862
+ holding such a ref when this was found.
863
+ - Any rejection that survives is tolerated rather than raised.
864
+ Git exits 1 on a declined rewind even when every other ref
865
+ applied, and that exit code was aborting the whole session
866
+ for a branch it had correctly protected.
785
867
  """
786
868
  bare_path = self._get_bare_repo_path(repo)
787
869
 
788
870
  if bare_path.exists():
789
871
  await self._run_git(
790
872
  "fetch", "--prune", "origin",
873
+ _BOT_BRANCH_REFSPEC,
791
874
  "refs/heads/*:refs/heads/*",
792
875
  cwd=bare_path,
793
876
  timeout=_GIT_TRANSFER_TIMEOUT_SECONDS,
877
+ tolerate_non_fast_forward=True,
794
878
  )
795
879
  else:
796
880
  await self._run_git(
@@ -657,6 +657,49 @@ async def run_secops_all(
657
657
  rounds += 1
658
658
  result = await pipeline.resume(ctx, answer)
659
659
 
660
+ # Close the loop on an answer the operator gave inline.
661
+ # The sweep-wide summary only lands when every repo has
662
+ # been swept — on a 90-repo sweep that's hours after the
663
+ # reply, so answering felt like shouting into a void. The
664
+ # late-answer path (pending_resumes) already reports per
665
+ # session; this is the same courtesy for the case where
666
+ # the session was still alive to receive the answer.
667
+ # Guarded on rounds > 0 so the ~90 repos that never asked
668
+ # anything stay silent.
669
+ if rounds > 0 and transport is not None:
670
+ try:
671
+ if result.success:
672
+ await transport.send(
673
+ f"✅ Answer applied on {repo}\n"
674
+ f"Session: `{session_id}`\n"
675
+ f"\n{result.summary}"
676
+ )
677
+ elif result.blocked:
678
+ q = result.question or "(no question text)"
679
+ await transport.send(
680
+ f"⏸️ Re-blocked after your answer on {repo}\n"
681
+ f"Session: `{session_id}`\n"
682
+ f"\n{q}"
683
+ )
684
+ else:
685
+ err = result.error or result.summary
686
+ await transport.send(
687
+ f"❌ Failed after your answer on {repo}\n"
688
+ f"Session: `{session_id}`\n"
689
+ f"\n{err}"
690
+ )
691
+ except Exception as e:
692
+ # Best-effort: a dead socket must not lose the
693
+ # completed work or abort the rest of the sweep.
694
+ log_event(
695
+ _logger,
696
+ "secops.answer_ack.send_failed",
697
+ session_id=session_id,
698
+ repo=repo,
699
+ error_type=type(e).__name__,
700
+ error=str(e)[:200],
701
+ )
702
+
660
703
  results.append(result)
661
704
 
662
705
  status = "done" if result.success else ("blocked" if result.blocked else "failed")
@@ -432,6 +432,203 @@ class TestSecopsBlockedDispatch:
432
432
  assert "Need decision" in mock_db.add_pending_resume.call_args.kwargs["question"]
433
433
 
434
434
 
435
+ class TestSecopsInlineAnswerAck:
436
+ """An answer the operator gives while the session is still alive must
437
+ get its own result message. The sweep-wide summary only lands once
438
+ every repo is done — hours later on a large sweep — so without this
439
+ the operator answers and hears nothing back. The late-answer path
440
+ (pending_resumes sweeper) already reports per session; this covers
441
+ the inline case.
442
+ """
443
+
444
+ @staticmethod
445
+ def _states(final_status, *, summary=None, question=None, error=None):
446
+ from ctrlrelay.core.checkpoint import CheckpointStatus
447
+
448
+ blocked = MagicMock()
449
+ blocked.status = CheckpointStatus.BLOCKED_NEEDS_INPUT
450
+ blocked.question = "Merge PR #42 (minor bump)?"
451
+ blocked.summary = None
452
+ blocked.outputs = {}
453
+ blocked.error = None
454
+
455
+ final = MagicMock()
456
+ final.status = final_status
457
+ final.summary = summary
458
+ final.question = question
459
+ final.outputs = {}
460
+ final.error = error
461
+ return blocked, final
462
+
463
+ @staticmethod
464
+ async def _run(tmp_path, blocked, final, transport):
465
+ from ctrlrelay.core.dispatcher import SessionResult
466
+ from ctrlrelay.pipelines.secops import run_secops_all
467
+
468
+ mock_dispatcher = AsyncMock()
469
+ mock_dispatcher.spawn_session.side_effect = [
470
+ SessionResult(session_id="sess", exit_code=0, state=blocked),
471
+ SessionResult(session_id="sess", exit_code=0, state=final),
472
+ ]
473
+
474
+ mock_worktree = AsyncMock()
475
+ mock_worktree.create_worktree.return_value = tmp_path / "worktree"
476
+ mock_worktree.ensure_bare_repo.return_value = tmp_path / "bare"
477
+
478
+ mock_db = MagicMock()
479
+ mock_db.acquire_lock.return_value = True
480
+ mock_db.get_agent_session_id.return_value = "sess"
481
+
482
+ repo = MagicMock()
483
+ repo.name = "owner/repo"
484
+
485
+ return await run_secops_all(
486
+ repos=[repo],
487
+ dispatcher=mock_dispatcher,
488
+ github=MagicMock(),
489
+ worktree=mock_worktree,
490
+ dashboard=None,
491
+ state_db=mock_db,
492
+ transport=transport,
493
+ contexts_dir=tmp_path / "contexts",
494
+ )
495
+
496
+ @pytest.mark.asyncio
497
+ async def test_successful_resume_sends_summary_to_operator(
498
+ self, tmp_path: Path
499
+ ) -> None:
500
+ from ctrlrelay.core.checkpoint import CheckpointStatus
501
+
502
+ blocked, final = self._states(
503
+ CheckpointStatus.DONE, summary="Merged 7 minor Dependabot PRs"
504
+ )
505
+ transport = AsyncMock()
506
+ transport.ask.return_value = "yes, merge"
507
+
508
+ results = await self._run(tmp_path, blocked, final, transport)
509
+
510
+ assert results[0].success
511
+ transport.send.assert_called_once()
512
+ text = transport.send.call_args.args[0]
513
+ assert "owner/repo" in text
514
+ assert "Merged 7 minor Dependabot PRs" in text
515
+ assert text.startswith("✅")
516
+
517
+ @pytest.mark.asyncio
518
+ async def test_reblocked_resume_reports_the_new_question(
519
+ self, tmp_path: Path
520
+ ) -> None:
521
+ """Hitting max_blocked_rounds (or a second BLOCKED the operator
522
+ never sees) must not go silent either — report the open question
523
+ so the operator knows the session is still stuck."""
524
+ from ctrlrelay.core.checkpoint import CheckpointStatus
525
+ from ctrlrelay.core.dispatcher import SessionResult
526
+ from ctrlrelay.pipelines.secops import run_secops_all
527
+
528
+ blocked, _ = self._states(CheckpointStatus.DONE)
529
+
530
+ mock_dispatcher = AsyncMock()
531
+ mock_dispatcher.spawn_session.return_value = SessionResult(
532
+ session_id="sess", exit_code=0, state=blocked,
533
+ )
534
+ mock_worktree = AsyncMock()
535
+ mock_worktree.create_worktree.return_value = tmp_path / "worktree"
536
+ mock_worktree.ensure_bare_repo.return_value = tmp_path / "bare"
537
+ mock_db = MagicMock()
538
+ mock_db.acquire_lock.return_value = True
539
+ mock_db.get_agent_session_id.return_value = "sess"
540
+
541
+ transport = AsyncMock()
542
+ transport.ask.return_value = "yes"
543
+ repo = MagicMock()
544
+ repo.name = "owner/repo"
545
+
546
+ results = await run_secops_all(
547
+ repos=[repo],
548
+ dispatcher=mock_dispatcher,
549
+ github=MagicMock(),
550
+ worktree=mock_worktree,
551
+ dashboard=None,
552
+ state_db=mock_db,
553
+ transport=transport,
554
+ contexts_dir=tmp_path / "contexts",
555
+ max_blocked_rounds=2,
556
+ )
557
+
558
+ assert results[0].blocked
559
+ transport.send.assert_called_once()
560
+ text = transport.send.call_args.args[0]
561
+ assert text.startswith("⏸️")
562
+ assert "Merge PR #42" in text
563
+
564
+ @pytest.mark.asyncio
565
+ async def test_repo_that_never_asked_stays_silent(
566
+ self, tmp_path: Path
567
+ ) -> None:
568
+ """The overwhelming majority of a 90-repo sweep finds nothing to
569
+ decide. Those must send nothing — otherwise the ack turns into
570
+ 90 notifications a day and the operator stops reading them."""
571
+ from ctrlrelay.core.checkpoint import CheckpointStatus
572
+ from ctrlrelay.core.dispatcher import SessionResult
573
+ from ctrlrelay.pipelines.secops import run_secops_all
574
+
575
+ done = MagicMock()
576
+ done.status = CheckpointStatus.DONE
577
+ done.summary = "No open Dependabot alerts, nothing to do"
578
+ done.outputs = {}
579
+ done.error = None
580
+
581
+ mock_dispatcher = AsyncMock()
582
+ mock_dispatcher.spawn_session.return_value = SessionResult(
583
+ session_id="sess", exit_code=0, state=done,
584
+ )
585
+ mock_worktree = AsyncMock()
586
+ mock_worktree.create_worktree.return_value = tmp_path / "worktree"
587
+ mock_worktree.ensure_bare_repo.return_value = tmp_path / "bare"
588
+ mock_db = MagicMock()
589
+ mock_db.acquire_lock.return_value = True
590
+
591
+ transport = AsyncMock()
592
+ repo = MagicMock()
593
+ repo.name = "owner/repo"
594
+
595
+ results = await run_secops_all(
596
+ repos=[repo],
597
+ dispatcher=mock_dispatcher,
598
+ github=MagicMock(),
599
+ worktree=mock_worktree,
600
+ dashboard=None,
601
+ state_db=mock_db,
602
+ transport=transport,
603
+ contexts_dir=tmp_path / "contexts",
604
+ )
605
+
606
+ assert results[0].success
607
+ transport.ask.assert_not_called()
608
+ transport.send.assert_not_called()
609
+
610
+ @pytest.mark.asyncio
611
+ async def test_send_failure_does_not_lose_the_completed_result(
612
+ self, tmp_path: Path
613
+ ) -> None:
614
+ """A dead bridge socket at ack time must not discard work the
615
+ agent already did, nor abort the rest of the sweep."""
616
+ from ctrlrelay.core.checkpoint import CheckpointStatus
617
+
618
+ blocked, final = self._states(
619
+ CheckpointStatus.DONE, summary="Merged PR #42"
620
+ )
621
+ transport = AsyncMock()
622
+ transport.ask.return_value = "yes"
623
+ transport.send.side_effect = RuntimeError("socket closed")
624
+
625
+ results = await self._run(tmp_path, blocked, final, transport)
626
+
627
+ assert len(results) == 1
628
+ assert results[0].success
629
+ assert results[0].summary == "Merged PR #42"
630
+
631
+
435
632
  class TestSecopsPromptRespectsAutomationConfig:
436
633
  """The per-repo `automation:` block in orchestrator.yaml defines the
437
634
  Dependabot policy per severity tier (patch/minor/major × auto/ask/never).
@@ -1599,3 +1599,182 @@ class TestGitTimeoutIsDiagnosable:
1599
1599
  await wt.ensure_bare_repo("owner/repo")
1600
1600
  fetch_kwargs = wt._run_git.await_args_list[0].kwargs
1601
1601
  assert fetch_kwargs["timeout"] == _GIT_TRANSFER_TIMEOUT_SECONDS
1602
+
1603
+
1604
+ class TestFetchSurvivesRewrittenBranches:
1605
+ """A secops sweep died on `AInvirion/zzsites` with the message
1606
+ `WorktreeError: git failed: ... ! [rejected] ... (non-fast-forward)`
1607
+ — while the same fetch had already advanced `main`. Dependabot
1608
+ rebases its branches, which rewrites their tips, so under the
1609
+ non-force refspec every rebase is a rejection and `git fetch`
1610
+ exits 1. Twelve bare repos held such a ref when this was found.
1611
+ """
1612
+
1613
+ @staticmethod
1614
+ def _git(*args: str, cwd: Path) -> str:
1615
+ import subprocess
1616
+
1617
+ env = {
1618
+ "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
1619
+ "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
1620
+ "PATH": __import__("os").environ.get("PATH", ""),
1621
+ "HOME": str(cwd),
1622
+ }
1623
+ return subprocess.run(
1624
+ ["git", *args], cwd=cwd, env=env,
1625
+ capture_output=True, text=True, check=True,
1626
+ ).stdout
1627
+
1628
+ @classmethod
1629
+ def _build_repos(cls, tmp_path: Path) -> tuple[Path, Path]:
1630
+ """origin with a rewritten dependabot branch and a rewritten
1631
+ `fix/` branch; a bare clone holding its own commit on each."""
1632
+ import subprocess
1633
+
1634
+ origin = tmp_path / "origin"
1635
+ origin.mkdir()
1636
+ cls._git("init", "-q", "-b", "main", ".", cwd=origin)
1637
+ (origin / "f").write_text("base\n")
1638
+ cls._git("add", ".", cwd=origin)
1639
+ cls._git("commit", "-qm", "base", cwd=origin)
1640
+ cls._git("branch", "dependabot/uv/pkg-1.0", cwd=origin)
1641
+ cls._git("branch", "fix/issue-1", cwd=origin)
1642
+
1643
+ bare = tmp_path / "bare.git"
1644
+ subprocess.run(
1645
+ ["git", "clone", "-q", "--bare", str(origin), str(bare)],
1646
+ check=True, capture_output=True,
1647
+ )
1648
+
1649
+ # Rewrite both branches on origin so neither is a descendant of
1650
+ # what the bare clone now holds.
1651
+ for br, text in (
1652
+ ("dependabot/uv/pkg-1.0", "rebased"), ("fix/issue-1", "forced"),
1653
+ ):
1654
+ cls._git("checkout", "-q", br, cwd=origin)
1655
+ (origin / "f").write_text(text + "\n")
1656
+ cls._git("commit", "-qam", "v1", cwd=origin)
1657
+ cls._git("commit", "-q", "--amend", "-m", br, cwd=origin)
1658
+
1659
+ # Give the bare repo a local-only commit on each branch.
1660
+ main_tree = cls._git(
1661
+ "rev-parse", "refs/heads/main^{tree}", cwd=bare
1662
+ ).strip()
1663
+ for br in ("dependabot/uv/pkg-1.0", "fix/issue-1"):
1664
+ sha = cls._git(
1665
+ "commit-tree", main_tree, "-p", "refs/heads/main",
1666
+ "-m", f"local work on {br}", cwd=bare,
1667
+ ).strip()
1668
+ cls._git("update-ref", f"refs/heads/{br}", sha, cwd=bare)
1669
+ return origin, bare
1670
+
1671
+ @staticmethod
1672
+ def _sha(bare: Path, ref: str) -> str:
1673
+ import subprocess
1674
+
1675
+ return subprocess.run(
1676
+ ["git", "rev-parse", ref], cwd=bare,
1677
+ capture_output=True, text=True, check=True,
1678
+ ).stdout.strip()
1679
+
1680
+ @pytest.mark.asyncio
1681
+ async def test_rebased_dependabot_branch_no_longer_fails_the_sweep(
1682
+ self, tmp_path: Path
1683
+ ) -> None:
1684
+ from ctrlrelay.core.worktree import WorktreeManager
1685
+
1686
+ origin, bare = self._build_repos(tmp_path)
1687
+ before_fix = self._sha(bare, "refs/heads/fix/issue-1")
1688
+
1689
+ wt = WorktreeManager(
1690
+ worktrees_dir=tmp_path / "wt", bare_repos_dir=tmp_path / "repos",
1691
+ )
1692
+ target = wt._get_bare_repo_path("owner/repo")
1693
+ target.parent.mkdir(parents=True, exist_ok=True)
1694
+ bare.rename(target)
1695
+ self._git("remote", "set-url", "origin", str(origin), cwd=target)
1696
+
1697
+ # Must not raise — this is the reported failure.
1698
+ await wt.ensure_bare_repo("owner/repo")
1699
+
1700
+ # Bot branch caught up to the rebase...
1701
+ assert self._sha(target, "refs/heads/dependabot/uv/pkg-1.0") == \
1702
+ self._sha(origin, "refs/heads/dependabot/uv/pkg-1.0")
1703
+ # ...and the local commit on fix/ was NOT discarded. This is the
1704
+ # protection the non-force refspec exists for; a blanket "+"
1705
+ # would silently destroy a prior session's unpushed work.
1706
+ assert self._sha(target, "refs/heads/fix/issue-1") == before_fix
1707
+
1708
+ @pytest.mark.asyncio
1709
+ async def test_real_errors_still_raise(self, tmp_path: Path) -> None:
1710
+ """Failing open would turn an unreachable remote into a silent
1711
+ no-op and run pipelines against a stale tree."""
1712
+ from ctrlrelay.core.worktree import WorktreeError, WorktreeManager
1713
+
1714
+ wt = WorktreeManager(
1715
+ worktrees_dir=tmp_path / "wt", bare_repos_dir=tmp_path / "repos",
1716
+ )
1717
+ bare = wt._get_bare_repo_path("owner/repo")
1718
+ bare.parent.mkdir(parents=True, exist_ok=True)
1719
+ self._git("init", "-q", "--bare", str(bare), cwd=tmp_path)
1720
+ self._git(
1721
+ "remote", "add", "origin",
1722
+ str(tmp_path / "does-not-exist"), cwd=bare,
1723
+ )
1724
+
1725
+ with pytest.raises(WorktreeError):
1726
+ await wt.ensure_bare_repo("owner/repo")
1727
+
1728
+
1729
+ class TestNonFastForwardClassifier:
1730
+ """`_rejections_are_only_non_fast_forward` decides whether a
1731
+ non-zero `git fetch` is the refspec doing its job or a real
1732
+ failure. Getting it wrong in the permissive direction hides
1733
+ outages; in the strict direction it re-breaks the sweep."""
1734
+
1735
+ def test_extracts_the_declined_refs(self) -> None:
1736
+ from ctrlrelay.core.worktree import (
1737
+ _rejections_are_only_non_fast_forward,
1738
+ )
1739
+
1740
+ stderr = (
1741
+ "From github.com:AInvirion/zzsites\n"
1742
+ " - [deleted] (none) -> dependabot/uv/authlib-1.6.12\n"
1743
+ " ! [rejected] dependabot/uv/playwright-1.62.0 -> "
1744
+ "dependabot/uv/playwright-1.62.0 (non-fast-forward)\n"
1745
+ " * [new branch] feat/import-navigation -> feat/import-navigation\n"
1746
+ " c6cfa84..f0a7911 main -> main\n"
1747
+ )
1748
+ assert _rejections_are_only_non_fast_forward(stderr) == [
1749
+ "dependabot/uv/playwright-1.62.0"
1750
+ ]
1751
+
1752
+ def test_fatal_is_not_tolerated(self) -> None:
1753
+ from ctrlrelay.core.worktree import (
1754
+ _rejections_are_only_non_fast_forward,
1755
+ )
1756
+
1757
+ stderr = (
1758
+ " ! [rejected] a -> a (non-fast-forward)\n"
1759
+ "fatal: could not read from remote repository\n"
1760
+ )
1761
+ assert _rejections_are_only_non_fast_forward(stderr) == []
1762
+
1763
+ def test_other_rejection_reasons_are_not_tolerated(self) -> None:
1764
+ from ctrlrelay.core.worktree import (
1765
+ _rejections_are_only_non_fast_forward,
1766
+ )
1767
+
1768
+ stderr = " ! [rejected] v1 -> v1 (would clobber existing tag)\n"
1769
+ assert _rejections_are_only_non_fast_forward(stderr) == []
1770
+
1771
+ def test_no_rejection_at_all_is_not_tolerated(self) -> None:
1772
+ """Exit 1 with no rejection line is an unexplained failure."""
1773
+ from ctrlrelay.core.worktree import (
1774
+ _rejections_are_only_non_fast_forward,
1775
+ )
1776
+
1777
+ assert _rejections_are_only_non_fast_forward("") == []
1778
+ assert _rejections_are_only_non_fast_forward(
1779
+ "From github.com:o/r\n abc..def main -> main\n"
1780
+ ) == []
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