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.
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/CHANGELOG.md +27 -1
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/PKG-INFO +1 -1
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/pyproject.toml +1 -1
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/pipelines/secops.py +43 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_secops_pipeline.py +197 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/.gitignore +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/LICENSE +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/README.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/SECURITY.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/Gemfile +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/_config.yml +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/architecture.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/bridge.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/cli.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/configuration.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/development.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/getting-started.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/index.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/operations.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/docs/personalization.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/cli.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/config.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/pipelines/dev.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/conftest.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_archived.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_audit.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_config.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_github.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_install.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_network.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_obs.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_poller.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_setup.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_state.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/tests/test_transport.py +0 -0
- {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.
|
|
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.
|
|
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/
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
{ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/templates/systemd/bridge.service.template
RENAMED
|
File without changes
|
{ctrlrelay-0.11.0 → ctrlrelay-0.11.1}/src/ctrlrelay/templates/systemd/poller.service.template
RENAMED
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|