ctrlrelay 0.11.0__tar.gz → 0.11.1__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.1}/CHANGELOG.md +27 -1
  2. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/PKG-INFO +1 -1
  3. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/pyproject.toml +1 -1
  4. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/pipelines/secops.py +43 -0
  5. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_secops_pipeline.py +197 -0
  6. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  7. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  8. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  9. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.github/dependabot.yml +0 -0
  10. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.github/workflows/build.yml +0 -0
  11. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.github/workflows/cla.yml +0 -0
  12. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.github/workflows/pages.yml +0 -0
  13. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.github/workflows/publish.yml +0 -0
  14. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.github/workflows/test.yml +0 -0
  15. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.gitignore +0 -0
  16. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/CODE_OF_CONDUCT.md +0 -0
  17. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/CONTRIBUTING.md +0 -0
  18. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/LICENSE +0 -0
  19. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/README.md +0 -0
  20. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/SECURITY.md +0 -0
  21. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/config/orchestrator.yaml.example +0 -0
  22. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/Gemfile +0 -0
  23. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/_config.yml +0 -0
  24. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/architecture.md +0 -0
  25. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/bridge.md +0 -0
  26. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/cli.md +0 -0
  27. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/configuration.md +0 -0
  28. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/development.md +0 -0
  29. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/feedback-loop.md +0 -0
  30. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/getting-started.md +0 -0
  31. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/index.md +0 -0
  32. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/operations.md +0 -0
  33. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/personalization.md +0 -0
  34. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/__init__.py +0 -0
  35. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/bridge/__init__.py +0 -0
  36. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/bridge/__main__.py +0 -0
  37. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/bridge/protocol.py +0 -0
  38. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/bridge/server.py +0 -0
  39. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  40. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/cli.py +0 -0
  41. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/__init__.py +0 -0
  42. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/archived.py +0 -0
  43. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/audit.py +0 -0
  44. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/checkpoint.py +0 -0
  45. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/code_review.py +0 -0
  46. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/config.py +0 -0
  47. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/dispatcher.py +0 -0
  48. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/github.py +0 -0
  49. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/network.py +0 -0
  50. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/obs.py +0 -0
  51. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/poller.py +0 -0
  52. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
  53. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
  54. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/question_expiry.py +0 -0
  55. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/scheduler.py +0 -0
  56. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/state.py +0 -0
  57. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/worktree.py +0 -0
  58. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
  59. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/dashboard/client.py +0 -0
  60. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/gh_protocol.py +0 -0
  61. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/install.py +0 -0
  62. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/personalization/__init__.py +0 -0
  63. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/personalization/manager.py +0 -0
  64. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/personalization/paths.py +0 -0
  65. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
  66. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/pipelines/base.py +0 -0
  67. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/pipelines/dev.py +0 -0
  68. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  69. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/pipelines/task.py +0 -0
  70. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/setup.py +0 -0
  71. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/templates/__init__.py +0 -0
  72. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  73. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  74. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  75. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  76. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  77. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  78. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  79. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/transports/__init__.py +0 -0
  80. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/transports/base.py +0 -0
  81. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/transports/file_mock.py +0 -0
  82. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/transports/socket_client.py +0 -0
  83. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/__init__.py +0 -0
  84. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/conftest.py +0 -0
  85. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_archived.py +0 -0
  86. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_audit.py +0 -0
  87. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_bridge_protocol.py +0 -0
  88. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_bridge_server.py +0 -0
  89. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_checkpoint.py +0 -0
  90. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_cli_ci_wait.py +0 -0
  91. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_cli_dev.py +0 -0
  92. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_cli_repos.py +0 -0
  93. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_cli_secops.py +0 -0
  94. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_cli_start.py +0 -0
  95. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_cli_version.py +0 -0
  96. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_code_review.py +0 -0
  97. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_config.py +0 -0
  98. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_dashboard_client.py +0 -0
  99. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_dev_integration.py +0 -0
  100. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_dev_pipeline.py +0 -0
  101. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_dispatcher.py +0 -0
  102. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_docs_site.py +0 -0
  103. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_gh_protocol.py +0 -0
  104. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_github.py +0 -0
  105. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_install.py +0 -0
  106. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_network.py +0 -0
  107. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_obs.py +0 -0
  108. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_personalization.py +0 -0
  109. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_pipeline_base.py +0 -0
  110. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_poller.py +0 -0
  111. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_post_merge.py +0 -0
  112. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_pr_verifier.py +0 -0
  113. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_pr_watcher.py +0 -0
  114. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_question_expiry.py +0 -0
  115. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_scheduler.py +0 -0
  116. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_secops_integration.py +0 -0
  117. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_setup.py +0 -0
  118. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_state.py +0 -0
  119. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_task_pipeline.py +0 -0
  120. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_telegram_handler.py +0 -0
  121. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_transport.py +0 -0
  122. {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_worktree.py +0 -0
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.11.1] - 2026-09-09
11
+
12
+ ### Fixed
13
+
14
+ - **Answering a secops question inline now gets a confirmation.** Two
15
+ paths reach a resumed session and only one of them reported. A reply
16
+ that arrived after the session had torn down landed in
17
+ `pending_resumes` and the sweeper sent `✅ Resume succeeded on <repo>`;
18
+ a reply that arrived while the session was still parked in
19
+ `transport.ask` resumed in-process and sent nothing at all.
20
+
21
+ The only completion message on that second path was the sweep-wide
22
+ `✅ Scheduled secops sweep done: N/M ok`, which fires once every repo
23
+ has been swept. On the 2026-09-09 sweep that was hours after the reply
24
+ and named no repo: four answers merged 20 Dependabot PRs across
25
+ `ai-miner`, `kenos-orchestrator`, `prompt-injection-benchmark` and
26
+ `aiproxyguard-promptest`, and not one outbound message went back to
27
+ the operator. Answering looked indistinguishable from being ignored.
28
+
29
+ The inline path now reports the same three outcomes the sweeper
30
+ already did — summary, re-blocked question, or error. Repos that never
31
+ asked anything stay silent; acking all ~90 daily is how an operator
32
+ learns to ignore the channel. The send is best-effort, so a dead bridge
33
+ socket cannot discard completed work or abort the rest of the sweep.
34
+
10
35
  ## [0.11.0] - 2026-09-08
11
36
 
12
37
  ### Added
@@ -1591,7 +1616,8 @@ pipeline).
1591
1616
  per-phase implementation plans (Phase 0 through Phase 4).
1592
1617
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
1593
1618
 
1594
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.0...HEAD
1619
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.1...HEAD
1620
+ [0.11.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.0...v0.11.1
1595
1621
  [0.11.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.10.0...v0.11.0
1596
1622
  [0.10.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.9.0...v0.10.0
1597
1623
  [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.1
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.1"
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"
@@ -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).
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