mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[4cfd99c2] Backend: docs-divergence engine, feature flag, release seam, and compose wiring (#507) (#513)
* [fe5c049b] Register docs-sync feature flag and compose wiring (#505) * [fe5c049b] Register docs-sync feature flag and compose wiring * [fe5c049b] feat(config): wire ROBOCO_DOCS_SYNC_ENABLED flag and compose defaults * [fe5c049b] docs(config): document ROBOCO_DOCS_SYNC_ENABLED flag and compose defaults --------- * [687574d2] Implement docs-sync engine and release-proposal seam (#506) * [687574d2] Add docs-sync engine and release-proposal publish seam * [687574d2] Restore task.py safeguards deleted by docs-sync engine commit and filter docs_sync version in SQL * [687574d2] docs(map): add engine-docs-sync architecture map and cross-references * [687574d2] docs(config): update docs-sync flag, cap settings, and changelog entry --------- * [3e7cd5a8] Fix task.py regressions from docs-sync PR (#509) * [3e7cd5a8] fix(task): restore deleted auditor alerts and revert descendant cast form in task.py * [3e7cd5a8] docs(task-service): restore auditor alerts and cast notes in map and changelog --------- * [e6e23c1f] Enforce docs_sync_max_per_cycle cap in docs_sync_engine.py (#510) * [e6e23c1f] Enforce docs_sync_max_per_cycle cap in DocsSyncEngine * [e6e23c1f] docs(docs-sync): document docs_sync_max_per_cycle enforcement in engine map, README, and docstring --------- * [e4b7dd0f] Revert task.py cast regressions from docs-sync PR (#511) * [e4b7dd0f] fix(task): revert cast regressions in supersede and descendants * [e4b7dd0f] docs(map): correct PR #511 cast regression entry in task-service slice map * [e4b7dd0f] docs(backend): add SQLAlchemy UUID cast pattern note and inline comments in task.py --------- * [1fdfe711] Fix Python quality gate on docs-sync PR (#512) * [1fdfe711] Fix ruff formatting in task.py and add coverage tests for docs-sync surface * [1fdfe711] fix(task): use generic JSON .as_string() accessor in list_open_docs_sync_tasks and correct test patch targets * [1fdfe711] docs(task-service): record docs-sync JSON accessor fix and list_open_docs_sync_tasks map entry --------- --------- Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech> Co-authored-by: Backend Documenter <be-doc@roboco.tech> Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
This commit is contained in:
co-authored by
Backend Developer 1
Backend Documenter
Backend Developer 2
parent
09b797fe9c
commit
f03859c64c
@@ -199,6 +199,18 @@ ROBOCO_PANEL_AGENT_TOKEN=
|
|||||||
# the interval has elapsed AND recent delivery activity exists. 0 disables.
|
# the interval has elapsed AND recent delivery activity exists. 0 disables.
|
||||||
# ROBOCO_AUDIT_INTERVAL_SECONDS=21600
|
# ROBOCO_AUDIT_INTERVAL_SECONDS=21600
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Docs-divergence sync (release -> docs-update task)
|
||||||
|
# =============================================================================
|
||||||
|
# Master switch for the docs-sync engine. When on, a successful release publish
|
||||||
|
# originates one bounded, deduped docs-update task against the roboco-website
|
||||||
|
# project. Requires roboco-website to be registered as a project.
|
||||||
|
# NAS compose defaults this to true; local-dev and registry composes default to
|
||||||
|
# false. Toggle from Settings → Feature Flags.
|
||||||
|
# ROBOCO_DOCS_SYNC_ENABLED=false
|
||||||
|
# ROBOCO_DOCS_SYNC_MAX_OPEN_TASKS=3
|
||||||
|
# ROBOCO_DOCS_SYNC_MAX_PER_CYCLE=1
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CORS (comma-separated origins)
|
# CORS (comma-separated origins)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
- **Scheduled auditor sweeps.** `ROBOCO_AUDIT_INTERVAL_SECONDS` (default 21600s / 6h, `audit_interval_seconds` in `roboco/config.py`) drives a periodic auditor spawn. `_dispatch_audit_work` now spawns the auditor on a scheduled sweep when the interval has elapsed, the auditor is not already active, and recent delivery activity exists (active delivery states or a task completed within the window). Reactive alert spawns also stamp `_last_audit_spawn_at` so the interval gate is shared. A one-tick notification sentinel and the existing active-agent breaker prevent auditor spawn storms; `0` disables scheduled sweeps. The auditor identity prompt and `_build_audit_prompt(scheduled=True)` support sweep-based reviews.
|
- **Scheduled auditor sweeps.** `ROBOCO_AUDIT_INTERVAL_SECONDS` (default 21600s / 6h, `audit_interval_seconds` in `roboco/config.py`) drives a periodic auditor spawn. `_dispatch_audit_work` now spawns the auditor on a scheduled sweep when the interval has elapsed, the auditor is not already active, and recent delivery activity exists (active delivery states or a task completed within the window). Reactive alert spawns also stamp `_last_audit_spawn_at` so the interval gate is shared. A one-tick notification sentinel and the existing active-agent breaker prevent auditor spawn storms; `0` disables scheduled sweeps. The auditor identity prompt and `_build_audit_prompt(scheduled=True)` support sweep-based reviews.
|
||||||
- **E2E smoke test for auditor triggers.** `tests/e2e_smoke/test_auditor_triggers.py` exercises both auditor spawn paths end-to-end against the real orchestrator dispatcher: a scheduled sweep that sees recent delivery activity and a reactive `ALERT` created by `POST /api/tasks/{id}/fail-qa`. `spawn_agent` is stubbed so the test asserts the dispatch decision without running an auditor container. The e2e harness now mounts `/api/notifications` so `_dispatch_audit_work` can poll alert rows.
|
- **E2E smoke test for auditor triggers.** `tests/e2e_smoke/test_auditor_triggers.py` exercises both auditor spawn paths end-to-end against the real orchestrator dispatcher: a scheduled sweep that sees recent delivery activity and a reactive `ALERT` created by `POST /api/tasks/{id}/fail-qa`. `spawn_agent` is stubbed so the test asserts the dispatch decision without running an auditor container. The e2e harness now mounts `/api/notifications` so `_dispatch_audit_work` can poll alert rows.
|
||||||
|
- **Docs-divergence sync engine (default-off).** With `ROBOCO_DOCS_SYNC_ENABLED`, a successful release publish now hands the release to `DocsSyncEngine`, which originates exactly one PENDING Main-PM docs-update task against the `roboco-website` project per release tag. The task carries the release's drafted CHANGELOG section and a pointer to the divergence checklist (declared-vs-actual agent count, stale verb-surface tables) so the public docs at docs.roboco.tech reflect what actually shipped. Bounded by `ROBOCO_DOCS_SYNC_MAX_OPEN_TASKS` (default 3) and `ROBOCO_DOCS_SYNC_MAX_PER_CYCLE` (default 1), deduped per release version via the `docs_sync_release_version` marker, and never auto-merges — the docs update still ships through the normal dev → QA → PR-review → CEO-merge gates. If `roboco-website` is not registered as a project the engine logs a warning and no-ops.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
- **`_fresh_orchestrator` test helper initializes orchestrator state.** `tests/e2e_smoke/test_auditor_triggers.py` constructs a bare `AgentOrchestrator` via `__new__` so it can patch `spawn_agent`, but that bypasses `__init__`. The helper now explicitly sets `_instances = {}` and `_last_audit_spawn_at = None` so `_is_agent_active` and `_dispatch_audit_work` no longer raise `AttributeError` during the auditor trigger e2e tests.
|
- **`_fresh_orchestrator` test helper initializes orchestrator state.** `tests/e2e_smoke/test_auditor_triggers.py` constructs a bare `AgentOrchestrator` via `__new__` so it can patch `spawn_agent`, but that bypasses `__init__`. The helper now explicitly sets `_instances = {}` and `_last_audit_spawn_at = None` so `_is_agent_active` and `_dispatch_audit_work` no longer raise `AttributeError` during the auditor trigger e2e tests.
|
||||||
- **Hardened `AgentOrchestrator.__new__` for `__init__`-bypass test instances.** `AgentOrchestrator.__new__` now pre-initializes `_last_audit_spawn_at` and `_notification_spawn_at` alongside `_instances`, so bare-`__new__` orchestrator instances used by e2e/unit-test helpers no longer raise `AttributeError` when the auditor-dispatch and notification-cooldown paths run. The existing `_fresh_orchestrator` helper still sets these explicitly for clarity, but the safety net is now in the class itself. Normal construction via `__init__` is unchanged.
|
- **Hardened `AgentOrchestrator.__new__` for `__init__`-bypass test instances.** `AgentOrchestrator.__new__` now pre-initializes `_last_audit_spawn_at` and `_notification_spawn_at` alongside `_instances`, so bare-`__new__` orchestrator instances used by e2e/unit-test helpers no longer raise `AttributeError` when the auditor-dispatch and notification-cooldown paths run. The existing `_fresh_orchestrator` helper still sets these explicitly for clarity, but the safety net is now in the class itself. Normal construction via `__init__` is unchanged.
|
||||||
- **Python quality gate type hygiene on the auditor-revival branch.** `roboco/services/task.py:get_all_descendants` now uses `cast("UUID", child.id)` instead of `# type: ignore[arg-type]` for the SQLAlchemy `Mapped[UUID]` value, and `roboco/services/notification_delivery.py` narrows the return types of `get_ack_status` and `get_delivery_summary` from bare `dict` to `dict[str, Any]`. These are typing-only changes; runtime behavior is unchanged and the local ruff / mypy quality gate stays green.
|
- **Python quality gate type hygiene on the auditor-revival branch.** `roboco/services/task.py:get_all_descendants` now uses `cast("UUID", child.id)` instead of `# type: ignore[arg-type]` for the SQLAlchemy `Mapped[UUID]` value, and `roboco/services/notification_delivery.py` narrows the return types of `get_ack_status` and `get_delivery_summary` from bare `dict` to `dict[str, Any]`. These are typing-only changes; runtime behavior is unchanged and the local ruff / mypy quality gate stays green.
|
||||||
|
- **Restored task.py auditor alerts and descendant-traversal cast after the docs-sync PR regression.** `roboco/services/task.py` again calls `_alert_auditor_of_rework` immediately after `await self.session.flush()` in `fail_qa`, `pr_fail`, and `request_changes`, matching the pre-regression reactive auditor ALERT path; the `_supersede_replacement_landed` descendant loop reverts to the original unquoted `cast(UUID, child.id)` form with a scoped `# noqa: TC006`. `DOCS_SYNC_SOURCE` and `list_open_docs_sync_tasks` were not touched.
|
||||||
|
- **Docs-sync version-scoped query uses the generic JSON accessor.** `list_open_docs_sync_tasks(version=...)` in `roboco/services/task.py` now compares `TaskTable.orchestration_markers[markers.DOCS_SYNC_RELEASE_VERSION].as_string()` instead of the JSONB-specific `.astext`, because `orchestration_markers` is declared as generic `JSON`. This fixes the `AttributeError` raised by Postgres-backed integration tests and keeps the docs-sync dedupe/cap predicate in SQL.
|
||||||
|
|
||||||
## [0.23.0] - 2026-07-11
|
## [0.23.0] - 2026-07-11
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,9 @@ ROBOCO_LOCAL_LLM_MODEL=glm-5.2:cloud
|
|||||||
ROBOCO_CONVENTIONS_ENABLED=false # per-project architectural conventions standard
|
ROBOCO_CONVENTIONS_ENABLED=false # per-project architectural conventions standard
|
||||||
ROBOCO_TOOLCHAIN_MATCH_ENABLED=false # build each target project under its own Python
|
ROBOCO_TOOLCHAIN_MATCH_ENABLED=false # build each target project under its own Python
|
||||||
ROBOCO_OVERLOAD_BREAK_ENABLED=true # park a provider on a persistent model-API overload
|
ROBOCO_OVERLOAD_BREAK_ENABLED=true # park a provider on a persistent model-API overload
|
||||||
|
ROBOCO_DOCS_SYNC_ENABLED=false # docs-divergence sync (release → docs-update task). Default-off; when on, a successful release publish originates one bounded, deduped docs-update task against the roboco-website project.
|
||||||
|
ROBOCO_DOCS_SYNC_MAX_OPEN_TASKS=3 # rolling cap on concurrently-open docs-sync tasks
|
||||||
|
ROBOCO_DOCS_SYNC_MAX_PER_CYCLE=1 # max docs-sync tasks originated per publish invocation
|
||||||
|
|
||||||
# Auditor scheduled sweeps (default 6 hours; 0 disables)
|
# Auditor scheduled sweeps (default 6 hours; 0 disables)
|
||||||
ROBOCO_AUDIT_INTERVAL_SECONDS=21600
|
ROBOCO_AUDIT_INTERVAL_SECONDS=21600
|
||||||
|
|||||||
@@ -337,6 +337,9 @@ services:
|
|||||||
ROBOCO_SELF_HEAL_ORIGINATE_ENABLED: ${ROBOCO_SELF_HEAL_ORIGINATE_ENABLED:-false}
|
ROBOCO_SELF_HEAL_ORIGINATE_ENABLED: ${ROBOCO_SELF_HEAL_ORIGINATE_ENABLED:-false}
|
||||||
ROBOCO_SELF_HEAL_PROJECT_SLUG: ${ROBOCO_SELF_HEAL_PROJECT_SLUG:-roboco-api}
|
ROBOCO_SELF_HEAL_PROJECT_SLUG: ${ROBOCO_SELF_HEAL_PROJECT_SLUG:-roboco-api}
|
||||||
ROBOCO_SELF_HEAL_CI_WORKFLOW: ${ROBOCO_SELF_HEAL_CI_WORKFLOW:-ci.yml}
|
ROBOCO_SELF_HEAL_CI_WORKFLOW: ${ROBOCO_SELF_HEAL_CI_WORKFLOW:-ci.yml}
|
||||||
|
# Docs-divergence sync (release -> docs-update task): default OFF in the
|
||||||
|
# registry compose; arm via .env once the engine is verified.
|
||||||
|
ROBOCO_DOCS_SYNC_ENABLED: ${ROBOCO_DOCS_SYNC_ENABLED:-false}
|
||||||
# Video engine (HyperFrames): NAS-default-on, OFF in public registry —
|
# Video engine (HyperFrames): NAS-default-on, OFF in public registry —
|
||||||
# heavier optional feature. Arm via .env + uncomment the video-renders
|
# heavier optional feature. Arm via .env + uncomment the video-renders
|
||||||
# bind mount above so renders persist.
|
# bind mount above so renders persist.
|
||||||
|
|||||||
@@ -486,6 +486,7 @@ services:
|
|||||||
# X credentials (Settings -> the X card) regardless of this flag.
|
# X credentials (Settings -> the X card) regardless of this flag.
|
||||||
ROBOCO_CI_WATCH_ENABLED: ${ROBOCO_CI_WATCH_ENABLED:-true}
|
ROBOCO_CI_WATCH_ENABLED: ${ROBOCO_CI_WATCH_ENABLED:-true}
|
||||||
ROBOCO_DEP_UPDATE_ENABLED: ${ROBOCO_DEP_UPDATE_ENABLED:-true}
|
ROBOCO_DEP_UPDATE_ENABLED: ${ROBOCO_DEP_UPDATE_ENABLED:-true}
|
||||||
|
ROBOCO_DOCS_SYNC_ENABLED: ${ROBOCO_DOCS_SYNC_ENABLED:-true}
|
||||||
ROBOCO_RELEASE_MANAGER_ENABLED: ${ROBOCO_RELEASE_MANAGER_ENABLED:-true}
|
ROBOCO_RELEASE_MANAGER_ENABLED: ${ROBOCO_RELEASE_MANAGER_ENABLED:-true}
|
||||||
ROBOCO_ORG_MEMORY_ENABLED: ${ROBOCO_ORG_MEMORY_ENABLED:-true}
|
ROBOCO_ORG_MEMORY_ENABLED: ${ROBOCO_ORG_MEMORY_ENABLED:-true}
|
||||||
ROBOCO_X_ENGINE_ENABLED: ${ROBOCO_X_ENGINE_ENABLED:-true}
|
ROBOCO_X_ENGINE_ENABLED: ${ROBOCO_X_ENGINE_ENABLED:-true}
|
||||||
|
|||||||
@@ -525,6 +525,7 @@ services:
|
|||||||
# X credentials (Settings -> the X card) regardless of this flag.
|
# X credentials (Settings -> the X card) regardless of this flag.
|
||||||
ROBOCO_CI_WATCH_ENABLED: ${ROBOCO_CI_WATCH_ENABLED:-true}
|
ROBOCO_CI_WATCH_ENABLED: ${ROBOCO_CI_WATCH_ENABLED:-true}
|
||||||
ROBOCO_DEP_UPDATE_ENABLED: ${ROBOCO_DEP_UPDATE_ENABLED:-true}
|
ROBOCO_DEP_UPDATE_ENABLED: ${ROBOCO_DEP_UPDATE_ENABLED:-true}
|
||||||
|
ROBOCO_DOCS_SYNC_ENABLED: ${ROBOCO_DOCS_SYNC_ENABLED:-true}
|
||||||
ROBOCO_RELEASE_MANAGER_ENABLED: ${ROBOCO_RELEASE_MANAGER_ENABLED:-true}
|
ROBOCO_RELEASE_MANAGER_ENABLED: ${ROBOCO_RELEASE_MANAGER_ENABLED:-true}
|
||||||
ROBOCO_ORG_MEMORY_ENABLED: ${ROBOCO_ORG_MEMORY_ENABLED:-true}
|
ROBOCO_ORG_MEMORY_ENABLED: ${ROBOCO_ORG_MEMORY_ENABLED:-true}
|
||||||
ROBOCO_X_ENGINE_ENABLED: ${ROBOCO_X_ENGINE_ENABLED:-true}
|
ROBOCO_X_ENGINE_ENABLED: ${ROBOCO_X_ENGINE_ENABLED:-true}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# SQLAlchemy `Mapped[UUID]` cast convention
|
||||||
|
|
||||||
|
When casting SQLAlchemy `Mapped[UUID]` primary-key columns to the runtime `uuid.UUID` type for typing purposes, use the string-literal form:
|
||||||
|
|
||||||
|
```python
|
||||||
|
cast('UUID', child.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
not the runtime symbol form:
|
||||||
|
|
||||||
|
```python
|
||||||
|
cast(UUID, child.id) # noqa: TC006
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
- `Mapped[UUID]` resolves to `uuid.UUID` at runtime, but static checkers need the cast target.
|
||||||
|
- The string-literal form avoids importing `UUID` solely to pass it to `typing.cast`, which ruff's `TC006` rule flags as a typing-only import used at runtime.
|
||||||
|
- It also avoids `# noqa` or `# type: ignore` suppressions.
|
||||||
|
|
||||||
|
## Where we use it
|
||||||
|
|
||||||
|
- `roboco/services/task.py:_supersede_replacement_landed` — descendant traversal.
|
||||||
|
- `roboco/services/task.py:get_all_descendants` — descendant traversal.
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# RoboCo Slice Map — `engine-docs-sync`
|
||||||
|
|
||||||
|
Slice key: `engine-docs-sync`. Repo root: `/Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco`. Scope: `roboco/services/docs_sync_engine.py`, the docs-sync touch points in `roboco/services/release_proposal.py` and `roboco/services/task.py`, and the release-version marker in `roboco/foundation/policy/content/markers.py`.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
A default-off, release-triggered task-origination engine that keeps the public docs at docs.roboco.tech in sync with what actually ships. On a successful release publish, if `docs_sync_enabled` is on and the `roboco-website` project is registered, the engine opens exactly one PENDING Main-PM planning task per release tag against `roboco-website`. The task brief carries the release's drafted CHANGELOG section plus a pointer to the divergence checklist surfaced by `ReleaseReadinessReport`. The engine never writes docs itself, never starts/approves/merges, and has no background loop — it is invoked synchronously from `ReleaseProposalService.approve()` after the release is already published. Like the other autonomy engines, it is conservative: gate on a default-off flag, dedupe per release version, bound concurrently-open and per-cycle originations, and flush-only (the caller owns the commit).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| Path | Role | LOC |
|
||||||
|
|---|---|---|
|
||||||
|
| `roboco/services/docs_sync_engine.py` | `DocsSyncEngine` and `get_docs_sync_engine` — release-triggered origination of one docs-update task per release tag | 188 |
|
||||||
|
| `roboco/services/release_proposal.py` | Publish-success seam: `ReleaseProposalService._draft_docs_update` hands the release report to `DocsSyncEngine` best-effort | 18 |
|
||||||
|
| `roboco/services/task.py` | `DOCS_SYNC_SOURCE` constant and `TaskService.list_open_docs_sync_tasks(version=None)` dedupe query | 31 |
|
||||||
|
| `roboco/foundation/policy/content/markers.py` | `DOCS_SYNC_RELEASE_VERSION` marker + `get/set_docs_sync_release_version` accessors | 17 |
|
||||||
|
| `roboco/config.py` | `docs_sync_enabled`, `docs_sync_max_open_tasks`, `docs_sync_max_per_cycle` settings | 25 |
|
||||||
|
| `tests/integration/services/test_docs_sync_engine.py` | Mocked integration tests for enabled / disabled / missing-project / dedupe / cap paths | 224 |
|
||||||
|
| `tests/unit/services/test_release_proposal_docs_sync_hook.py` | Unit test that `ReleaseProposalService._draft_docs_update` is called on publish success and swallows engine failures | — |
|
||||||
|
|
||||||
|
## Key Symbols
|
||||||
|
|
||||||
|
| Name | Kind | File:Line | Responsibility |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `_DOCS_PROJECT_SLUG` | constant | `roboco/services/docs_sync_engine.py:50` | The registered project slug that hosts the public docs (`roboco-website`). Engine no-ops with a warning when this project is missing. |
|
||||||
|
| `_DIVERGENCE_CHECKLIST_POINTER` | constant | `roboco/services/docs_sync_engine.py:53` | Text pointer included in every originated task, telling the assignee to review the release-readiness `docs_drift` gaps. |
|
||||||
|
| `DocsSyncEngine` | class | `roboco/services/docs_sync_engine.py:61` | Release-triggered docs-update task origination service. |
|
||||||
|
| `DocsSyncEngine.__init__` | method | `roboco/services/docs_sync_engine.py:66` | Initializes the per-instance `_per_cycle_originated` counter. Reset per engine instance because `release_proposal.py` constructs a fresh engine per publish invocation. |
|
||||||
|
| `DocsSyncEngine.originate_docs_update` | method | `roboco/services/docs_sync_engine.py:72` | Public entry point: returns the created `TaskTable` or `None` when disabled / missing project / either cap reached / already open for this version. Flushes; caller commits. |
|
||||||
|
| `DocsSyncEngine._per_cycle_originated` | attribute | `roboco/services/docs_sync_engine.py:70` | Instance counter tracking how many docs-sync tasks this engine has originated. Guarded against `settings.docs_sync_max_per_cycle`. |
|
||||||
|
| `DocsSyncEngine._already_open_for_version` | method | `roboco/services/docs_sync_engine.py:132` | Dedupe check using `TaskService.list_open_docs_sync_tasks(version=...)` filtered by the `docs_sync_release_version` marker. |
|
||||||
|
| `DocsSyncEngine._open_task` | method | `roboco/services/docs_sync_engine.py:139` | Builds the `TaskCreateRequest` (Main-PM planning root, `confirmed_by_human=True`, source `docs_sync`) and stamps the release-version marker. |
|
||||||
|
| `get_docs_sync_engine` | function | `roboco/services/docs_sync_engine.py:185` | Factory constructing a `DocsSyncEngine` bound to a session. |
|
||||||
|
| `DOCS_SYNC_SOURCE` | constant | `roboco/services/task.py:600` | Source tag value `"docs_sync"` used for dedupe queries and task creation. |
|
||||||
|
| `list_open_docs_sync_tasks` | method | `roboco/services/task.py:1577` | Returns non-terminal `docs_sync` tasks, optionally scoped to one release version via JSONB marker filtering in SQL. |
|
||||||
|
| `get_docs_sync_release_version` / `set_docs_sync_release_version` | functions | `roboco/foundation/policy/content/markers.py:412,417` | Read/write the `docs_sync_release_version` marker used for per-release dedupe. |
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
`ReleaseProposalService.approve()` runs `ReleaseExecutor.execute(report)` in a background task. When the executor returns `published` or `already_published`, `approve()` marks the proposal `COMPLETED`, flushes, and calls the best-effort post-publish hooks in sequence: `_draft_x_post(report)`, `_draft_video(report)`, `_draft_docs_update(report)`. Each catches `Exception` and logs a warning so a failure in any hook cannot roll back or otherwise affect the already-succeeded release.
|
||||||
|
|
||||||
|
`_draft_docs_update` imports `get_docs_sync_engine` locally and calls `originate_docs_update(version=report.proposed_version, changelog=report.drafted_changelog)`. Inside the engine:
|
||||||
|
|
||||||
|
1. **Flag gate**: returns `None` immediately if `settings.docs_sync_enabled` is `False`.
|
||||||
|
2. **Project resolution**: looks up `roboco-website` via `ProjectService.get_by_slug`. If missing, logs a warning and returns `None`.
|
||||||
|
3. **Rolling cap**: counts all non-terminal `docs_sync` tasks via `list_open_docs_sync_tasks()`; if the count is already at `docs_sync_max_open_tasks` (default 3), logs and returns `None`.
|
||||||
|
4. **Per-cycle cap**: checks an instance-level counter (`_per_cycle_originated`) against `docs_sync_max_per_cycle` (default 1). If the counter is already at the cap, logs and returns `None`. The counter resets per engine instance; `release_proposal.py` constructs a fresh instance per publish invocation.
|
||||||
|
5. **Per-release dedupe**: calls `list_open_docs_sync_tasks(version=version)` filtering on the `docs_sync_release_version` JSONB marker in SQL; if any row exists, logs and returns `None`.
|
||||||
|
6. **Originate**: creates a `TaskCreateRequest` with `source=DOCS_SYNC_SOURCE`, `team=Team.MAIN_PM`, `assigned_to=AGENTS["main-pm"].uuid`, `created_by=AGENTS["system"].uuid`, `task_type=PLANNING`, `nature=TECHNICAL`, `complexity=MEDIUM`, `status=PENDING`, `confirmed_by_human=True`, `project_id=roboco-website.id`. The description includes the release version, the drafted CHANGELOG section, the divergence-checklist pointer, and a note that the task is a Main-PM coordination root ready to decompose and delegate.
|
||||||
|
7. **Marker stamp**: calls `markers.set_docs_sync_release_version(task, version)` and flushes.
|
||||||
|
|
||||||
|
The created task then rides the normal delivery lifecycle: Main PM decomposes it into per-cell subtasks, a dev/documenter implements the docs update, QA verifies, the PR reviewer passes it, and the CEO merges.
|
||||||
|
|
||||||
|
## Mermaid
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant RP as ReleaseProposalService.approve
|
||||||
|
participant RE as ReleaseExecutor
|
||||||
|
participant DSE as DocsSyncEngine
|
||||||
|
participant PS as ProjectService
|
||||||
|
participant TS as TaskService
|
||||||
|
participant DB as TaskTable
|
||||||
|
|
||||||
|
RP->>RE: execute(report)
|
||||||
|
RE-->>RP: status: published
|
||||||
|
RP->>RP: task.status = COMPLETED; flush
|
||||||
|
RP->>DSE: _draft_docs_update(report)
|
||||||
|
DSE->>DSE: settings.docs_sync_enabled?
|
||||||
|
alt disabled
|
||||||
|
DSE-->>RP: None
|
||||||
|
else enabled
|
||||||
|
DSE->>PS: get_by_slug("roboco-website")
|
||||||
|
alt project missing
|
||||||
|
DSE->>DSE: logger.warning
|
||||||
|
DSE-->>RP: None
|
||||||
|
else project exists
|
||||||
|
DSE->>TS: list_open_docs_sync_tasks()
|
||||||
|
alt open_count >= max_open_tasks
|
||||||
|
DSE-->>RP: None
|
||||||
|
else under rolling cap
|
||||||
|
DSE->>DSE: _per_cycle_originated >= max_per_cycle?
|
||||||
|
alt per-cycle cap reached
|
||||||
|
DSE-->>RP: None
|
||||||
|
else under per-cycle cap
|
||||||
|
DSE->>TS: list_open_docs_sync_tasks(version=report.proposed_version)
|
||||||
|
alt already open for version
|
||||||
|
DSE-->>RP: None
|
||||||
|
else new version
|
||||||
|
DSE->>TS: create(TaskCreateRequest, source=docs_sync)
|
||||||
|
TS->>DB: INSERT PENDING Main-PM planning task
|
||||||
|
DSE->>DSE: _per_cycle_originated += 1
|
||||||
|
DSE->>DB: set docs_sync_release_version marker
|
||||||
|
DSE-->>RP: created task
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
RP->>RP: catch Exception: log warning
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logical Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
engine-docs-sync
|
||||||
|
DocsSyncEngine (roboco/services/docs_sync_engine.py)
|
||||||
|
_DOCS_PROJECT_SLUG = "roboco-website"
|
||||||
|
_DIVERGENCE_CHECKLIST_POINTER
|
||||||
|
originate_docs_update(version, changelog) -> TaskTable | None
|
||||||
|
gate on docs_sync_enabled
|
||||||
|
resolve roboco-website project
|
||||||
|
check docs_sync_max_open_tasks rolling cap
|
||||||
|
check docs_sync_max_per_cycle instance counter
|
||||||
|
dedupe per version via list_open_docs_sync_tasks(version)
|
||||||
|
_open_task(project_id, version, changelog)
|
||||||
|
increment _per_cycle_originated
|
||||||
|
_already_open_for_version(task_svc, version) -> bool
|
||||||
|
_open_task(task_svc, project_id, version, changelog) -> TaskTable
|
||||||
|
create TaskCreateRequest(source=docs_sync, team=MAIN_PM, ...)
|
||||||
|
markers.set_docs_sync_release_version(task, version)
|
||||||
|
session.flush()
|
||||||
|
get_docs_sync_engine(session) -> DocsSyncEngine
|
||||||
|
ReleaseProposalService seam (roboco/services/release_proposal.py)
|
||||||
|
approve() -> on published/already_published: _draft_docs_update(report)
|
||||||
|
_draft_docs_update(report) -> best-effort; catch Exception, log warning
|
||||||
|
TaskService support (roboco/services/task.py)
|
||||||
|
DOCS_SYNC_SOURCE = "docs_sync"
|
||||||
|
list_open_docs_sync_tasks(version=None) -> [TaskTable]
|
||||||
|
base filter: source == docs_sync AND status not in (COMPLETED, CANCELLED)
|
||||||
|
optional version filter: orchestration_markers["docs_sync_release_version"].astext == version
|
||||||
|
Markers (roboco/foundation/policy/content/markers.py)
|
||||||
|
DOCS_SYNC_RELEASE_VERSION = "docs_sync_release_version"
|
||||||
|
get_docs_sync_release_version(task) -> str | None
|
||||||
|
set_docs_sync_release_version(task, version)
|
||||||
|
Config (roboco/config.py)
|
||||||
|
docs_sync_enabled (default False)
|
||||||
|
docs_sync_max_open_tasks (default 3, ge=1)
|
||||||
|
docs_sync_max_per_cycle (default 1, ge=1)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Internal: `roboco.config.settings`, `roboco.foundation.identity` (`AGENTS`), `roboco.foundation.policy.content.markers` (`DOCS_SYNC_RELEASE_VERSION`, `set_docs_sync_release_version`), `roboco.models.base` (`Complexity`, `TaskNature`, `TaskStatus`, `TaskType`, `Team`), `roboco.services.base.BaseService`, `roboco.services.project.get_project_service`, `roboco.services.task` (`DOCS_SYNC_SOURCE`, `TaskCreateRequest`, `TaskService`, `get_task_service`).
|
||||||
|
- External: `sqlalchemy.ext.asyncio.AsyncSession`.
|
||||||
|
|
||||||
|
## Entry Points
|
||||||
|
|
||||||
|
| Name | File | Trigger |
|
||||||
|
|---|---|---|
|
||||||
|
| `ReleaseProposalService.approve` | `roboco/services/release_proposal.py:82` | CEO panel `POST /api/release/proposal/approve`; runs executor, then calls `_draft_docs_update(report)` on publish success |
|
||||||
|
| `DocsSyncEngine.originate_docs_update` | `roboco/services/docs_sync_engine.py:72` | Called from `_draft_docs_update`; no background loop or public API route |
|
||||||
|
|
||||||
|
## Config Flags
|
||||||
|
|
||||||
|
- `ROBOCO_DOCS_SYNC_ENABLED` (`docs_sync_enabled`) — master switch; default `false`. When off the engine is never invoked.
|
||||||
|
- `ROBOCO_DOCS_SYNC_MAX_OPEN_TASKS` (`docs_sync_max_open_tasks`) — rolling cap on concurrently-open docs-sync tasks; default `3`.
|
||||||
|
- `ROBOCO_DOCS_SYNC_MAX_PER_CYCLE` (`docs_sync_max_per_cycle`) — max docs-sync tasks originated in one invocation; default `1`. Because a release publish is a single invocation, this bounds it to one task per publish event.
|
||||||
|
|
||||||
|
The flag is registered in `roboco/services/settings.py`'s `FEATURE_FLAGS` tuple, so it can be toggled from the panel's Settings → Feature Flags card without editing env.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **No background loop.** Unlike `SelfHealEngine`, `CiWatchEngine`, and `DepUpdateEngine`, `DocsSyncEngine` has no orchestrator loop. It only runs as a synchronous post-publish hook inside `ReleaseProposalService.approve()`.
|
||||||
|
- **Best-effort seam.** `_draft_docs_update` catches `Exception` broadly and logs a warning. An engine failure (e.g., DB rollback, unexpected `TaskService` error) must never affect the already-succeeded release publish or the proposal completion.
|
||||||
|
- **Requires `roboco-website` registration.** The engine logs a warning and returns `None` when the docs project is not registered. This is a deliberate operator step; the engine does not create the project itself.
|
||||||
|
- **Per-release dedupe is in SQL.** `list_open_docs_sync_tasks(version=...)` filters by the `docs_sync_release_version` JSONB marker in SQL so the database can use JSONB indexes and avoid hauling every open docs-sync row into Python.
|
||||||
|
- **Rolling cap counts all open docs-sync tasks, not per version.** If three docs-sync tasks are already open for older releases, a new release publish will not originate a fourth task until one of the older ones closes.
|
||||||
|
- **The originated task is `confirmed_by_human=True`.** It is ready to start immediately and appears in the Main PM's work queue; it is not held for CEO approval like a release proposal.
|
||||||
|
- **The docs update still goes through normal gates.** The engine only opens the coordination root; implementation, QA, PR review, and CEO merge happen through the standard lifecycle.
|
||||||
|
|
||||||
|
## Drift from CLAUDE.md
|
||||||
|
|
||||||
|
- CLAUDE.md does not mention a docs-sync engine; this is a new subsystem.
|
||||||
|
|
||||||
|
## Changes Since Baseline
|
||||||
|
|
||||||
|
| SHA | Subject | Impact |
|
||||||
|
|---|---|---|
|
||||||
|
| af2fb904 | `[687574d2] Add docs-sync engine and release-proposal publish seam` | Introduced `roboco/services/docs_sync_engine.py`, the `release_proposal.py` seam, `DOCS_SYNC_SOURCE`, `list_open_docs_sync_tasks`, the `docs_sync_release_version` marker, and `docs_sync_max_open_tasks` / `docs_sync_max_per_cycle` config caps. |
|
||||||
|
| db919882 | `[687574d2] Restore task.py safeguards deleted by docs-sync engine commit and filter docs_sync version in SQL` | Restored unrelated `task.py` safeguards accidentally deleted by the first commit and moved the version predicate in `list_open_docs_sync_tasks` into SQL against the JSONB marker. |
|
||||||
|
| d333dde7 | `[e6e23c1f] Enforce docs_sync_max_per_cycle cap in DocsSyncEngine` | Added a per-instance `_per_cycle_originated` counter and guard so `originate_docs_update` respects `docs_sync_max_per_cycle` in addition to the existing rolling cap. Added `test_per_cycle_cap_is_enforced`. |
|
||||||
|
|
||||||
|
## Regression Risks
|
||||||
|
|
||||||
|
| Title | File:Line | Claim | Severity |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Missing `roboco-website` project silently skips docs updates | `roboco/services/docs_sync_engine.py:80` | By design the engine logs a warning and no-ops; operators must register the docs project before enabling the flag. | low |
|
||||||
|
| Broad exception catch in `_draft_docs_update` could mask persistent engine failures | `roboco/services/release_proposal.py:242` | Best-effort by design so publish success is never endangered, but a persistent failure would only surface as a log warning. | low |
|
||||||
|
| Cap/dedupe ordering: open_count is checked before per-version dedupe | `roboco/services/docs_sync_engine.py:97` | Correct: a duplicate for the same version is rejected after the cap check, so the cap is not consumed by duplicates. | low |
|
||||||
|
| Instance-level `_per_cycle_originated` counter persists across calls on a reused engine | `roboco/services/docs_sync_engine.py:66` | Safe today because `release_proposal.py` constructs a fresh `DocsSyncEngine` per publish invocation. A future refactor that reuses an instance must either create a fresh engine per publish or add an explicit reset. | low |
|
||||||
|
|
||||||
|
## Health
|
||||||
|
|
||||||
|
The engine is intentionally small and conservative: default-off, no background loop, bounded, deduped, and flush-only. It follows the same safety model as the other autonomy engines (never start/approve/merge/deploy) while staying out of the orchestrator's periodic loops entirely. The main dependency on operator action is registering `roboco-website` as a project before enabling the flag. Health is good.
|
||||||
@@ -187,3 +187,6 @@ engines-heal-ciwatch-depupdate
|
|||||||
|
|
||||||
## Health
|
## Health
|
||||||
All three engines are small, single-purpose, and follow a deliberately conservative pattern: gate on a default-off flag, read-only detect, bounded+deduped originate of one PENDING task, flush-only (caller commits), never start/approve/merge/deploy. The safety invariants (self_heal HELD behind CEO approve; ci_watch/dep_update READY but ride normal gates; per-git_url dedupe for monorepos; per-cycle + rolling caps) are intact and consistent with CLAUDE.md. Two medium risks present at baseline are now resolved: CEO notify spam (536bbb64 added Redis per-fingerprint dedupe) and multi-workflow monorepo under-count (536bbb64 changed _should_open to dedupe per (git_url, workflow); d34bc1a7 hardened the SQL to normalize git_url accidentals and treat empty-string workflow as NULL). Remaining standing risks are low-severity: fingerprint collision on shared signal_name, dep_update cap-check ordering (non-eligibles consume a loop slot not a cap slot), and ci_watch cell-PM notify swallowing all exceptions. Health is good.
|
All three engines are small, single-purpose, and follow a deliberately conservative pattern: gate on a default-off flag, read-only detect, bounded+deduped originate of one PENDING task, flush-only (caller commits), never start/approve/merge/deploy. The safety invariants (self_heal HELD behind CEO approve; ci_watch/dep_update READY but ride normal gates; per-git_url dedupe for monorepos; per-cycle + rolling caps) are intact and consistent with CLAUDE.md. Two medium risks present at baseline are now resolved: CEO notify spam (536bbb64 added Redis per-fingerprint dedupe) and multi-workflow monorepo under-count (536bbb64 changed _should_open to dedupe per (git_url, workflow); d34bc1a7 hardened the SQL to normalize git_url accidentals and treat empty-string workflow as NULL). Remaining standing risks are low-severity: fingerprint collision on shared signal_name, dep_update cap-check ordering (non-eligibles consume a loop slot not a cap slot), and ci_watch cell-PM notify swallowing all exceptions. Health is good.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
- `docs/map/engine-docs-sync.md` — a sibling originate-only engine that opens a docs-update task on release publish (release-triggered, no background loop).
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ The gated release manager: a default-off background loop that deterministically
|
|||||||
## Data Flow
|
## Data Flow
|
||||||
DETECT loop: the orchestrator spawns `_release_manager_loop` (an asyncio task started in `start()`) which, when `release_manager_enabled`, sleeps `release_manager_interval_seconds` then calls `_run_release_manager_cycle` → opens a DB session → `get_release_manager_engine(db).run_cycle()`. `run_cycle` short-circuits if disabled, if `TaskService.list_open_release_proposals()` already returns one (dedup by `source='release_manager'` + non-terminal status), or if `_ready_report()` returns None. `_ready_report` calls the injected assessor (default `_production_assess`): resolve the RoboCo project by `self_heal_project_slug`, `WorkspaceService.ensure_read_clone`, `GitService.get_latest_ci_conclusion`, then `gather_snapshot(read_clone_root, master_ci_conclusion)` + `assess(snapshot, today)`. `assess` runs `classify_changes` → `derive_bump` → `next_version` → assembles gaps (changelog, version_ref, docs_drift, migration, classification, gate). If green + past threshold, `_originate` creates a PENDING HELD `RELEASE_MANAGER_SOURCE` task owned by `secretary-1` via `TaskService.create(TaskCreateRequest(..., confirmed_by_human=False))`, stores the report dict via `markers.set_release_report`, flushes, and best-effort notifies the CEO. The orchestrator cycle commits the session.
|
DETECT loop: the orchestrator spawns `_release_manager_loop` (an asyncio task started in `start()`) which, when `release_manager_enabled`, sleeps `release_manager_interval_seconds` then calls `_run_release_manager_cycle` → opens a DB session → `get_release_manager_engine(db).run_cycle()`. `run_cycle` short-circuits if disabled, if `TaskService.list_open_release_proposals()` already returns one (dedup by `source='release_manager'` + non-terminal status), or if `_ready_report()` returns None. `_ready_report` calls the injected assessor (default `_production_assess`): resolve the RoboCo project by `self_heal_project_slug`, `WorkspaceService.ensure_read_clone`, `GitService.get_latest_ci_conclusion`, then `gather_snapshot(read_clone_root, master_ci_conclusion)` + `assess(snapshot, today)`. `assess` runs `classify_changes` → `derive_bump` → `next_version` → assembles gaps (changelog, version_ref, docs_drift, migration, classification, gate). If green + past threshold, `_originate` creates a PENDING HELD `RELEASE_MANAGER_SOURCE` task owned by `secretary-1` via `TaskService.create(TaskCreateRequest(..., confirmed_by_human=False))`, stores the report dict via `markers.set_release_report`, flushes, and best-effort notifies the CEO. The orchestrator cycle commits the session.
|
||||||
|
|
||||||
CEO ACT path: `GET /api/release/proposal` (CEO-only) → `ReleaseProposalService.open_proposal()` → `list_open_release_proposals()[0]`. `POST /proposal/approve` (returns 202 immediately): route calls `dispatch_approve(task_id, session_factory)` which spawns `_run_approve_background` as a background asyncio.Task (the request session closes at the 202 return; the background task opens a fresh session). In `approve(task_id)`: loads the task, verifies `source == RELEASE_MANAGER_SOURCE`, reads `markers.get_release_report`; acquires Redis fencing-token mutex (`SET NX EX 3000`) — raises `ReleaseLockUnavailable` on Redis outage → returns `redis_unavailable`; returns `already_in_progress` if lock is held. Then: `get_release_executor(session)` → `executor.execute(report)` run as `asyncio.Task` while a `_heartbeat_loop` task refreshes the TTL every 60s (cancels execute and returns `lock_lost` if the lock is no longer ours). `get_release_executor` resolves the project + token, `_inject_token_into_url`, `_prepare_release_clone` (rm -rf + fresh clone); `ci_workflow` is set from `_resolve_release_ci_workflow()` (not self_heal_ci_workflow). `execute`: `is_already_published` (ls-remote tag); `release_commit_sha` (half-landed check — if prior release commit on branch, skip re-bump and rejoin CI→publish tail); else: `apply_version_bumps` (replace old version across plan, uv.lock scoped) + `write_changelog_entry` → `run_gate` (make quality, 1800s) → `commit_and_push` (add -A, commit -S, push HEAD:default; RuntimeError → `commit_failed`) → `wait_for_ci` (poll GitService 80×30s, scoped to release_ci_workflow) → `publish_release` (gh release create; RuntimeError → `publish_failed`). On `published` OR `already_published`, the proposal task is set COMPLETED + flushed; on gate/CI/commit/publish failure a ReleaseResult is returned and the proposal stays open. The background task commits the session on success, rolls back on failure. The panel polls `GET /proposal` for the final status. `POST /proposal/reject` → `svc.reject(task_id, required_changes)` writes `markers.set_release_required_changes` and keeps the task held.
|
CEO ACT path: `GET /api/release/proposal` (CEO-only) → `ReleaseProposalService.open_proposal()` → `list_open_release_proposals()[0]`. `POST /proposal/approve` (returns 202 immediately): route calls `dispatch_approve(task_id, session_factory)` which spawns `_run_approve_background` as a background asyncio.Task (the request session closes at the 202 return; the background task opens a fresh session). In `approve(task_id)`: loads the task, verifies `source == RELEASE_MANAGER_SOURCE`, reads `markers.get_release_report`; acquires Redis fencing-token mutex (`SET NX EX 3000`) — raises `ReleaseLockUnavailable` on Redis outage → returns `redis_unavailable`; returns `already_in_progress` if lock is held. Then: `get_release_executor(session)` → `executor.execute(report)` run as `asyncio.Task` while a `_heartbeat_loop` task refreshes the TTL every 60s (cancels execute and returns `lock_lost` if the lock is no longer ours). `get_release_executor` resolves the project + token, `_inject_token_into_url`, `_prepare_release_clone` (rm -rf + fresh clone); `ci_workflow` is set from `_resolve_release_ci_workflow()` (not self_heal_ci_workflow). `execute`: `is_already_published` (ls-remote tag); `release_commit_sha` (half-landed check — if prior release commit on branch, skip re-bump and rejoin CI→publish tail); else: `apply_version_bumps` (replace old version across plan, uv.lock scoped) + `write_changelog_entry` → `run_gate` (make quality, 1800s) → `commit_and_push` (add -A, commit -S, push HEAD:default; RuntimeError → `commit_failed`) → `wait_for_ci` (poll GitService 80×30s, scoped to release_ci_workflow) → `publish_release` (gh release create; RuntimeError → `publish_failed`). On `published` OR `already_published`, the proposal task is set COMPLETED + flushed, and the publish-success path then hands the release to the best-effort post-publish hooks: `_draft_x_post(report)`, `_draft_video(report)`, and `_draft_docs_update(report)`. Each hook catches `Exception` broadly and logs a warning so a drafting/origination failure never affects the already-succeeded release. `_draft_docs_update` invokes `DocsSyncEngine.originate_docs_update(version=report.proposed_version, changelog=report.drafted_changelog)`; if `ROBOCO_DOCS_SYNC_ENABLED` is on and `roboco-website` is registered, exactly one PENDING Main-PM docs-update task is created for that release tag. On gate/CI/commit/publish failure a ReleaseResult is returned and the proposal stays open. The background task commits the session on success, rolls back on failure. The panel polls `GET /proposal` for the final status. `POST /proposal/reject` → `svc.reject(task_id, required_changes)` writes `markers.set_release_required_changes` and keeps the task held.
|
||||||
|
|
||||||
## Mermaid
|
## Mermaid
|
||||||
```mermaid
|
```mermaid
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ Panel-tunable flags defined in `services/settings.py:46` `FEATURE_FLAGS` (stored
|
|||||||
| `ci_watch_enabled` | Multi-repo CI-watch | `ROBOCO_CI_WATCH_ENABLED` |
|
| `ci_watch_enabled` | Multi-repo CI-watch | `ROBOCO_CI_WATCH_ENABLED` |
|
||||||
| `dep_update_enabled` | Dependency-update bot | `ROBOCO_DEP_UPDATE_ENABLED` |
|
| `dep_update_enabled` | Dependency-update bot | `ROBOCO_DEP_UPDATE_ENABLED` |
|
||||||
| `release_manager_enabled` | Gated release manager | `ROBOCO_RELEASE_MANAGER_ENABLED` |
|
| `release_manager_enabled` | Gated release manager | `ROBOCO_RELEASE_MANAGER_ENABLED` |
|
||||||
|
| `docs_sync_enabled` | Docs-divergence sync (release → docs-update task) | `ROBOCO_DOCS_SYNC_ENABLED` |
|
||||||
| `org_memory_enabled` | Organizational memory loop | `ROBOCO_ORG_MEMORY_ENABLED` |
|
| `org_memory_enabled` | Organizational memory loop | `ROBOCO_ORG_MEMORY_ENABLED` |
|
||||||
| `sandbox_db_enabled` | Sandboxed per-agent test DB/Redis/Mongo (engine registry) | `ROBOCO_SANDBOX_DB_ENABLED` |
|
| `sandbox_db_enabled` | Sandboxed per-agent test DB/Redis/Mongo (engine registry) | `ROBOCO_SANDBOX_DB_ENABLED` |
|
||||||
| `x_engine_enabled` | X (Twitter) engine | `ROBOCO_X_ENGINE_ENABLED` |
|
| `x_engine_enabled` | X (Twitter) engine | `ROBOCO_X_ENGINE_ENABLED` |
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|------|------|-----------|----------------|
|
|------|------|-----------|----------------|
|
||||||
| `_validate_and_set_status` | method | task.py:548 | Single chokepoint: validate transition + git requirements, set status, poke dispatcher, emit audit. |
|
| `_validate_and_set_status` | method | task.py:548 | Single chokepoint: validate transition + git requirements, set status, poke dispatcher, emit audit. |
|
||||||
| `_emit_status_transition_audit` | method | task.py:652 | Write `task.<status>` audit row in caller session; bump `revision_count` on entry into `needs_revision`. |
|
| `_emit_status_transition_audit` | method | task.py:652 | Write `task.<status>` audit row in caller session; bump `revision_count` on entry into `needs_revision`. |
|
||||||
| `_alert_auditor_of_rework` | method | task.py:1019 | Best-effort helper that asks `NotificationDeliveryService` to send a HIGH `ALERT` to the auditor when a task enters `needs_revision`. |
|
| `_alert_auditor_of_rework` | method | task.py:1019 | Best-effort helper that asks `NotificationDeliveryService` to send a HIGH `ALERT` to the auditor when a task enters `needs_revision`. Called from `fail_qa`, `pr_fail`, and `request_changes` immediately after `await self.session.flush()` so the transition row is visible before the alert is dispatched. |
|
||||||
| `create` | method | task.py:864 | New task; depth/batch/AC validation; branchless/umbrella flags; baseline constraints attachment; (V2) vault materialize-on-create. |
|
| `create` | method | task.py:864 | New task; depth/batch/AC validation; branchless/umbrella flags; baseline constraints attachment; (V2) vault materialize-on-create. |
|
||||||
| `_attach_baseline_constraints` | method | task.py:971 | Append conventions baseline constraints to task prompt (gated `conventions_enabled`). |
|
| `_attach_baseline_constraints` | method | task.py:971 | Append conventions baseline constraints to task prompt (gated `conventions_enabled`). |
|
||||||
| `_materialize_vault_note` | method | task.py:910 | V2: best-effort vault seam called from `create` — assembles + writes a deterministic task note (narrative placeholder) so a task is visible in the vault from the moment it exists, not just at Auditor curation/rebuild. Gated `obsidian_vault_enabled`; swallows + logs any failure. |
|
| `_materialize_vault_note` | method | task.py:910 | V2: best-effort vault seam called from `create` — assembles + writes a deterministic task note (narrative placeholder) so a task is visible in the vault from the moment it exists, not just at Auditor curation/rebuild. Gated `obsidian_vault_enabled`; swallows + logs any failure. |
|
||||||
@@ -44,9 +44,10 @@
|
|||||||
| `unclaim_for_agent` / `_force_unclaim_to_pending` | method | task.py:3579 / 3507 | Release claim to pool; abandon stale work session. |
|
| `unclaim_for_agent` / `_force_unclaim_to_pending` | method | task.py:3579 / 3507 | Release claim to pool; abandon stale work session. |
|
||||||
| `block` / `soft_block` / `unblock` | method | task.py:3760 / 3823 / 3897 | Snapshot pre-block owner; restore on unblock. |
|
| `block` / `soft_block` / `unblock` | method | task.py:3760 / 3823 / 3897 | Snapshot pre-block owner; restore on unblock. |
|
||||||
| `submit_for_qa` | method | task.py:4065 | `verifying→awaiting_qa`; clears claimed_by (passes explicit audit_agent_id). |
|
| `submit_for_qa` | method | task.py:4065 | `verifying→awaiting_qa`; clears claimed_by (passes explicit audit_agent_id). |
|
||||||
| `pass_qa` / `fail_qa` | method | task.py:4112 / 4187 | QA verdict; `fail_qa` routes back to original dev (marker → work-session fallback) and emits a best-effort auditor rework ALERT. |
|
| `pass_qa` / `fail_qa` | method | task.py:4112 / 4187 | QA verdict; `fail_qa` routes back to original dev (marker → work-session fallback), then calls `_alert_auditor_of_rework` after flush to emit a best-effort auditor rework ALERT. |
|
||||||
| `_resolve_revision_dev` | method | task.py:4301 | Work-session fallback when `original_developer` marker missing. |
|
| `_resolve_revision_dev` | method | task.py:4301 | Work-session fallback when `original_developer` marker missing. |
|
||||||
| `docs_complete` | method | task.py:4336 | `awaiting_documentation→awaiting_pm_review` (parallel completion). |
|
| `docs_complete` | method | task.py:4336 | `awaiting_documentation→awaiting_pm_review` (parallel completion). |
|
||||||
|
| `request_changes` | method | task.py:9975 | PM merge-review request-changes path; transitions to `needs_revision`, then calls `_alert_auditor_of_rework` after flush to emit a best-effort auditor rework ALERT. |
|
||||||
| `submit_for_pm_review` / `complete` | method | task.py:4690 / 4882 | PM review submit + completion / CEO escalation chain. |
|
| `submit_for_pm_review` / `complete` | method | task.py:4690 / 4882 | PM review submit + completion / CEO escalation chain. |
|
||||||
| `_apply_complete_approval_chain` | method | task.py:4811 | Leaf→completed vs root→awaiting_ceo_approval. |
|
| `_apply_complete_approval_chain` | method | task.py:4811 | Leaf→completed vs root→awaiting_ceo_approval. |
|
||||||
| `_assert_pr_merged_for_complete` | method | task.py:4845 | PR-merged gate before `complete`. |
|
| `_assert_pr_merged_for_complete` | method | task.py:4845 | PR-merged gate before `complete`. |
|
||||||
@@ -57,7 +58,8 @@
|
|||||||
| `_remove_task_worktree_on_terminal` | method | task.py:5601 | Best-effort worktree cleanup on complete/ceo_approve; no-op for branchless. |
|
| `_remove_task_worktree_on_terminal` | method | task.py:5601 | Best-effort worktree cleanup on complete/ceo_approve; no-op for branchless. |
|
||||||
| `cancel` | method | task.py:5644 | Cascade-cancel descendants through the validator. |
|
| `cancel` | method | task.py:5644 | Cascade-cancel descendants through the validator. |
|
||||||
| `reassign` / `reassign_active_claim` | method | task.py:7657 / 7807 | Reassignment with Board/Main-PM diversion guards. |
|
| `reassign` / `reassign_active_claim` | method | task.py:7657 / 7807 | Reassignment with Board/Main-PM diversion guards. |
|
||||||
| `pr_pass` / `pr_fail` | method | task.py:8100 / 8137 | In-path PR-review gate verdicts; `pr_fail` transitions to `needs_revision` and emits a best-effort auditor rework ALERT. |
|
| `pr_pass` / `pr_fail` | method | task.py:8100 / 8137 | In-path PR-review gate verdicts; `pr_fail` transitions to `needs_revision`, then calls `_alert_auditor_of_rework` after flush to emit a best-effort auditor rework ALERT. |
|
||||||
|
| `list_open_docs_sync_tasks` | method | task.py:1580 | Returns open `source=docs_sync` tasks, optionally scoped to one release version via the `docs_sync_release_version` marker. The version predicate is applied in SQL so dedupe/cap checks do not haul every open row into Python. |
|
||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
Request → `TaskService` loads `TaskTable` (`get`/`_load_task_or_raise`) → validates role/transition (`validate_task_transition`) + git reqs (`validate_git_requirements`, branchless/umbrella/external-review exempt) → mutates columns → `_emit_status_transition_audit` writes `AuditLogTable` row + bumps `revision_count` in the same session → pokes orchestrator `trigger_dispatch()` → fires fire-and-forget background tasks (RAG indexing, learning distillation, worktree cleanup, work-session close). Terminal states trigger `_unblock_dependents` to revive waiting tasks.
|
Request → `TaskService` loads `TaskTable` (`get`/`_load_task_or_raise`) → validates role/transition (`validate_task_transition`) + git reqs (`validate_git_requirements`, branchless/umbrella/external-review exempt) → mutates columns → `_emit_status_transition_audit` writes `AuditLogTable` row + bumps `revision_count` in the same session → pokes orchestrator `trigger_dispatch()` → fires fire-and-forget background tasks (RAG indexing, learning distillation, worktree cleanup, work-session close). Terminal states trigger `_unblock_dependents` to revive waiting tasks.
|
||||||
@@ -135,6 +137,7 @@ stateDiagram-v2
|
|||||||
- Background indexing/learning/cleanup tasks are tracked on `self._background_tasks` and are best-effort — a failure never blocks the transition.
|
- Background indexing/learning/cleanup tasks are tracked on `self._background_tasks` and are best-effort — a failure never blocks the transition.
|
||||||
- The sequence gate (`_claim_blocked_by_sequence`) is enforced ONLY in `_validate_claim_preconditions`, i.e. inside `claim` itself — both the gateway claim verbs AND the orchestrator's raw dispatch claim cross it because they both funnel through `TaskService.claim`, unlike the pre-#382 dependency gate which briefly lived only on the gateway side. Any future claim path that bypasses `TaskService.claim` (a raw `admin_set_status`, for instance) does NOT get sequence enforcement.
|
- The sequence gate (`_claim_blocked_by_sequence`) is enforced ONLY in `_validate_claim_preconditions`, i.e. inside `claim` itself — both the gateway claim verbs AND the orchestrator's raw dispatch claim cross it because they both funnel through `TaskService.claim`, unlike the pre-#382 dependency gate which briefly lived only on the gateway side. Any future claim path that bypasses `TaskService.claim` (a raw `admin_set_status`, for instance) does NOT get sequence enforcement.
|
||||||
- `_apply_dependency_lineage` is scoped to SAME-REPO dependencies only (`dep_task.project_id != ctx.project.id` short-circuits) — a cross-repo dependency edge (e.g. a MegaTask root-subtask in another project) has no shared git history to merge and is silently skipped; the dependency TIMING gate still holds the claim regardless of repo.
|
- `_apply_dependency_lineage` is scoped to SAME-REPO dependencies only (`dep_task.project_id != ctx.project.id` short-circuits) — a cross-repo dependency edge (e.g. a MegaTask root-subtask in another project) has no shared git history to merge and is silently skipped; the dependency TIMING gate still holds the claim regardless of repo.
|
||||||
|
- `TaskTable.orchestration_markers` is generic `JSON`, not `JSONB`. Any SQL predicate on a marker key must use `.as_string()` (or the JSON dialect's generic comparator), not `.astext`, which is JSONB-only and raises `AttributeError` at compile time. `list_open_docs_sync_tasks(version=...)` at task.py:1596 is the current example; the inline comment records the rationale.
|
||||||
|
|
||||||
## Drift from CLAUDE.md
|
## Drift from CLAUDE.md
|
||||||
- CLAUDE.md states ceo_reject "~4779 skips _validate_and_set_status in branchless path". Actual: branchless branch of `ceo_reject` is at task.py:5488 and routes through `admin_set_status` (which DOES emit audit at task.py:2100). The non-branchless branch DOES call `_validate_and_set_status` (task.py:5461). No audit gap — the line reference is stale.
|
- CLAUDE.md states ceo_reject "~4779 skips _validate_and_set_status in branchless path". Actual: branchless branch of `ceo_reject` is at task.py:5488 and routes through `admin_set_status` (which DOES emit audit at task.py:2100). The non-branchless branch DOES call `_validate_and_set_status` (task.py:5461). No audit gap — the line reference is stale.
|
||||||
@@ -146,7 +149,7 @@ stateDiagram-v2
|
|||||||
- `15effce0` Chore: 141 Gaps fill-in (#283) — bulk gap closure; transition audit chokepoint + `revision_count` centralization (task.py:685-706), branchless/umbrella git-context exemptions, fail_qa work-session fallback, ceo_reject branchless routing.
|
- `15effce0` Chore: 141 Gaps fill-in (#283) — bulk gap closure; transition audit chokepoint + `revision_count` centralization (task.py:685-706), branchless/umbrella git-context exemptions, fail_qa work-session fallback, ceo_reject branchless routing.
|
||||||
- `3aff6e04` Chore: Close gaps (#285) — follow-on gap close (worktree-on-terminal cleanup F123 Phase C, escalation audit emit, rework routing hardening).
|
- `3aff6e04` Chore: Close gaps (#285) — follow-on gap close (worktree-on-terminal cleanup F123 Phase C, escalation audit emit, rework routing hardening).
|
||||||
|
|
||||||
> Post-snapshot updates (since 2026-06-29): `20f1f9ba` admin_set_status: thread actor_id/actor_role into `_apply_pre_block_restore`; blocked→pending/in_progress restore now attributes the audit row to the admin actor (not the restored owner) and emits a `task.admin_override` row (forced=False, restore=True) independent of the force flag. `b3558d4e` complexity: extract `_restore_block_ownership` (line 8526) + `_emit_admin_override_audit` (line 8555) from `_apply_pre_block_restore` — no behavior change, splits a C-rank block for the xenon gate. `0e7674af` escalate_to_ceo gains `actor_agent_id: UUID | None = None` param stamped as audit_agent_id; push_branch / create_pr / create_root_pr / escalate_to_ceo side-effect handlers in the verb runner now forward actor_agent_id (was dropped, causing wrong workspace or role-only audit attribution). `8f3f4236` (#452) "sequence is the bar" — adds `_claim_blocked_by_sequence` + `_validate_claim_preconditions` wiring, `stamp_wave_sequence` (replacing a raw per-sibling delegation ordinal), and migration 069 (`tasks.parent_task_id` index, the sibling probe's hot path). `f2834cf5` (#466) adds `_apply_dependency_lineage`/`_merge_one_dependency`, called from `_create_branch_in_project` right after a fresh branch cut. `61e00832` (PR #492) added `_alert_auditor_of_rework()` and invoked it from `fail_qa`, `pr_fail`, and `request_changes` after each transition to `needs_revision`, wiring the reactive auditor ALERT path.
|
> Post-snapshot updates (since 2026-06-29): `20f1f9ba` admin_set_status: thread actor_id/actor_role into `_apply_pre_block_restore`; blocked→pending/in_progress restore now attributes the audit row to the admin actor (not the restored owner) and emits a `task.admin_override` row (forced=False, restore=True) independent of the force flag. `b3558d4e` complexity: extract `_restore_block_ownership` (line 8526) + `_emit_admin_override_audit` (line 8555) from `_apply_pre_block_restore` — no behavior change, splits a C-rank block for the xenon gate. `0e7674af` escalate_to_ceo gains `actor_agent_id: UUID | None = None` param stamped as audit_agent_id; push_branch / create_pr / create_root_pr / escalate_to_ceo side-effect handlers in the verb runner now forward actor_agent_id (was dropped, causing wrong workspace or role-only audit attribution). `8f3f4236` (#452) "sequence is the bar" — adds `_claim_blocked_by_sequence` + `_validate_claim_preconditions` wiring, `stamp_wave_sequence` (replacing a raw per-sibling delegation ordinal), and migration 069 (`tasks.parent_task_id` index, the sibling probe's hot path). `f2834cf5` (#466) adds `_apply_dependency_lineage`/`_merge_one_dependency`, called from `_create_branch_in_project` right after a fresh branch cut. `61e00832` (PR #492) added `_alert_auditor_of_rework()` and invoked it from `fail_qa`, `pr_fail`, and `request_changes` after each transition to `needs_revision`, wiring the reactive auditor ALERT path. `f6c75237` (PR #509) restored those `_alert_auditor_of_rework()` calls after they were accidentally deleted by the docs-sync PR: all three call sites now dispatch the alert immediately after `await self.session.flush()` so the `needs_revision` transition row is committed before the auditor notification is created. The same commit also changed the descendant-traversal casts in `_supersede_replacement_landed` and `get_all_descendants`, but it used `cast(UUID, child.id)` with a scoped `# noqa: TC006` and `child.id` with a `# type: ignore[arg-type]`, respectively. `e4b7dd0f` / PR #511 reverted those two cast regressions to the preferred string-literal form `cast('UUID', child.id)` with no lint or type suppression, leaving `DOCS_SYNC_SOURCE` and `list_open_docs_sync_tasks` untouched.
|
||||||
>
|
>
|
||||||
> (uncommitted, branch `feature/findings-ledger`, 2026-07-11) Revision-findings ledger: `_audit_events_for` (task.py:997) gains `task.request_changes` (agent_role `cell_pm`/`main_pm`) and `task.ceo_reject` (agent_role `ceo`) branches alongside the existing `task.qa_fail`/`task.pr_fail`; `ceo_reject` gains reason validation + a ledger `Finding` insert (see above); `qa_fail` and `request_changes` drop their raw `dev_notes` appends (the mirror-column data-loss bug) in favor of the ledger + a structured note. Full detail: `docs/map/review-findings.md`.
|
> (uncommitted, branch `feature/findings-ledger`, 2026-07-11) Revision-findings ledger: `_audit_events_for` (task.py:997) gains `task.request_changes` (agent_role `cell_pm`/`main_pm`) and `task.ceo_reject` (agent_role `ceo`) branches alongside the existing `task.qa_fail`/`task.pr_fail`; `ceo_reject` gains reason validation + a ledger `Finding` insert (see above); `qa_fail` and `request_changes` drop their raw `dev_notes` appends (the mirror-column data-loss bug) in favor of the ledger + a structured note. Full detail: `docs/map/review-findings.md`.
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -17,7 +17,7 @@ The pytest test suite for RoboCo: 571 test_*.py files across tests/foundation, t
|
|||||||
| Makefile | quality/quality-fast/panel-gate/test/test-3.10..3.14 targets wiring pytest+ruff+mypy+xenon+vulture into the merge gate | 547 |
|
| Makefile | quality/quality-fast/panel-gate/test/test-3.10..3.14 targets wiring pytest+ruff+mypy+xenon+vulture into the merge gate | 547 |
|
||||||
| tests/foundation/ | 21 structural/parity tests: agents_config parity, lifecycle consumer/generator parity, role-set parity, seed-orchestrator parity, tracing-verb parity, route-guard consolidation, smoke replay, lifecycle spec, pr_review_gate, identity, communications, journaling, cell_teams, agent_loop, validate, task_completeness | |
|
| tests/foundation/ | 21 structural/parity tests: agents_config parity, lifecycle consumer/generator parity, role-set parity, seed-orchestrator parity, tracing-verb parity, route-guard consolidation, smoke replay, lifecycle spec, pr_review_gate, identity, communications, journaling, cell_teams, agent_loop, validate, task_completeness | |
|
||||||
| tests/integration/ | 100 real-DB + FastAPI integration tests: full lifecycle real DB (_StubGit), task_service_*, *_routes, migrations 013-049, sequencing, conventions e2e, metrics, release, ci_watch, dep_update, prompter_live, secretary | |
|
| tests/integration/ | 100 real-DB + FastAPI integration tests: full lifecycle real DB (_StubGit), task_service_*, *_routes, migrations 013-049, sequencing, conventions e2e, metrics, release, ci_watch, dep_update, prompter_live, secretary | |
|
||||||
| tests/integration/services/ | 9 autonomy-engine integration tests: ci_watch_engine/notify/source, dep_update_engine/probe/source, external_pr_repo_dedup, project_autonomy_update, active_task_owns_branch_scoping | |
|
| tests/integration/services/ | 10 autonomy-engine integration tests: ci_watch_engine/notify/source, dep_update_engine/probe/source, docs_sync_engine, external_pr_repo_dedup, project_autonomy_update, active_task_owns_branch_scoping | |
|
||||||
| tests/integration/v1/ | 1 FastAPI TestClient e2e (test_full_pending_to_completed) exercising all six v1 routers via stateful mocked Choreographer/ContentActions through the full hand-off chain | |
|
| tests/integration/v1/ | 1 FastAPI TestClient e2e (test_full_pending_to_completed) exercising all six v1 routers via stateful mocked Choreographer/ContentActions through the full hand-off chain | |
|
||||||
| tests/property/ | 2 deterministic property tests (no hypothesis dep): state-machine invariants (orphan/terminal/reachability/random-walk) + tracing-completeness over smoke_test_batch | |
|
| tests/property/ | 2 deterministic property tests (no hypothesis dep): state-machine invariants (orphan/terminal/reachability/random-walk) + tracing-completeness over smoke_test_batch | |
|
||||||
| tests/unit/ | ~430 unit tests mirroring roboco/: agents, api (+routes/+schemas), billing, config, conventions, db, enforcement, events, foundation/policy, gateway (105), llm, mcp_servers, migrations, models, runtime (64), scripts, services (120), templates, utils | |
|
| tests/unit/ | ~430 unit tests mirroring roboco/: agents, api (+routes/+schemas), billing, config, conventions, db, enforcement, events, foundation/policy, gateway (105), llm, mcp_servers, migrations, models, runtime (64), scripts, services (120), templates, utils | |
|
||||||
@@ -116,7 +116,7 @@ tests/
|
|||||||
│ ├── test_migration_013/014/016/028 + batch_intake/ci_watch/dep_update/observability
|
│ ├── test_migration_013/014/016/028 + batch_intake/ci_watch/dep_update/observability
|
||||||
│ ├── test_*_routes.py (agents, dashboard, docs, git, journal, kanban, notifications, pitch, product, project, release, research, secretary, stream, tasks, work_session, orchestrator, prompter_live)
|
│ ├── test_*_routes.py (agents, dashboard, docs, git, journal, kanban, notifications, pitch, product, project, release, research, secretary, stream, tasks, work_session, orchestrator, prompter_live)
|
||||||
│ ├── test_task_service_* [basics, transitions, lifecycle_misc, misc, background, no_silent_fallback, route_orchestration]
|
│ ├── test_task_service_* [basics, transitions, lifecycle_misc, misc, background, no_silent_fallback, route_orchestration]
|
||||||
│ ├── services/ [ci_watch_engine/notify/source, dep_update_engine/probe/source, external_pr_repo_dedup, project_autonomy_update, active_task_owns_branch_scoping]
|
│ ├── services/ [ci_watch_engine/notify/source, dep_update_engine/probe/source, docs_sync_engine, external_pr_repo_dedup, project_autonomy_update, active_task_owns_branch_scoping]
|
||||||
│ ├── v1/test_full_pending_to_completed.py [TestClient e2e, all 6 v1 routers, stateful mocks]
|
│ ├── v1/test_full_pending_to_completed.py [TestClient e2e, all 6 v1 routers, stateful mocks]
|
||||||
│ ├── test_bash_guard_message.sh [standalone smoke]
|
│ ├── test_bash_guard_message.sh [standalone smoke]
|
||||||
│ └── test_stop_hook_verb_names.sh [standalone smoke]
|
│ └── test_stop_hook_verb_names.sh [standalone smoke]
|
||||||
@@ -141,7 +141,7 @@ tests/
|
|||||||
├── agents/ (4) — autogen_prompt_layer, briefing_cluster, conventions_ambient_injection, tool_load_directive
|
├── agents/ (4) — autogen_prompt_layer, briefing_cluster, conventions_ambient_injection, tool_load_directive
|
||||||
├── api/ (30) + routes/ (3) + routes/v1/ (11) + schemas/ (2) + schemas/v1/ (1) — middleware, deps, errors, websocket_*, schemas, v1 flow router tests
|
├── api/ (30) + routes/ (3) + routes/v1/ (11) + schemas/ (2) + schemas/v1/ (1) — middleware, deps, errors, websocket_*, schemas, v1 flow router tests
|
||||||
├── billing/ (1) — pricing
|
├── billing/ (1) — pricing
|
||||||
├── config/ (5) — ci_watch/conventions/dep_update/org_memory/release_manager flag tests
|
├── config/ (6) — ci_watch/conventions/dep_update/docs_sync/org_memory/release_manager flag tests
|
||||||
├── conventions/ (10) — classify_python/ts, cli, cli_smoke, custom, hygiene, modularity, placement, runner, scan
|
├── conventions/ (10) — classify_python/ts, cli, cli_smoke, custom, hygiene, modularity, placement, runner, scan
|
||||||
├── db/ (1) — respawn_tracker_table
|
├── db/ (1) — respawn_tracker_table
|
||||||
├── enforcement/ (4) — a2a_access, journal_perms, task_lifecycle, task_ownership
|
├── enforcement/ (4) — a2a_access, journal_perms, task_lifecycle, task_ownership
|
||||||
@@ -154,7 +154,7 @@ tests/
|
|||||||
├── models/ (7) — events, journal, llm, misc, product, task_create_completeness, transcription
|
├── models/ (7) — events, journal, llm, misc, product, task_create_completeness, transcription
|
||||||
├── runtime/ (64) — orchestrator spawn/reaper/loops; per_dev_lane_queue, respawn_persistence, readopt_running_agents, gateway_health
|
├── runtime/ (64) — orchestrator spawn/reaper/loops; per_dev_lane_queue, respawn_persistence, readopt_running_agents, gateway_health
|
||||||
├── scripts/ (2) — bash_guard, verify_postgres_enums
|
├── scripts/ (2) — bash_guard, verify_postgres_enums
|
||||||
├── services/ (120) + optimal_brain/ (10 + conftest) — task, git (+worktree family), workspace, work_session, release_*, sequencing, conventions, playbook, notification, rate_limit, optimal_brain
|
├── services/ (~120) + optimal_brain/ (10 + conftest) — task, git (+worktree family), workspace, work_session, release_* executor/readiness/manager/proposal + docs_sync hook, sequencing, conventions, playbook, notification, rate_limit, optimal_brain
|
||||||
├── templates/ (2) — pr_internal, pr_root
|
├── templates/ (2) — pr_internal, pr_root
|
||||||
└── utils/ (2) — converters, crypto
|
└── utils/ (2) — converters, crypto
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ The fan-out generalizations of self-heal — they watch any opted-in project, no
|
|||||||
| `ROBOCO_RELEASE_MANAGER_ENABLED` | `false` | Master switch for the gated release manager; off = the loop never runs. Even on it only PROPOSES — the CEO approves before any publish |
|
| `ROBOCO_RELEASE_MANAGER_ENABLED` | `false` | Master switch for the gated release manager; off = the loop never runs. Even on it only PROPOSES — the CEO approves before any publish |
|
||||||
| `ROBOCO_RELEASE_MIN_COMMITS` | `8` | Minimum unreleased commits since the last tag before a release is proposed (a feat/security change also qualifies) |
|
| `ROBOCO_RELEASE_MIN_COMMITS` | `8` | Minimum unreleased commits since the last tag before a release is proposed (a feat/security change also qualifies) |
|
||||||
| `ROBOCO_RELEASE_MANAGER_INTERVAL_SECONDS` | `3600` | Seconds between release-readiness assessment passes |
|
| `ROBOCO_RELEASE_MANAGER_INTERVAL_SECONDS` | `3600` | Seconds between release-readiness assessment passes |
|
||||||
|
| `ROBOCO_DOCS_SYNC_ENABLED` | `false` | Master switch for the docs-divergence sync engine. When on, a successful release publish originates one bounded, deduped docs-update task per release tag against the `roboco-website` project. Requires `roboco-website` to be registered as a project; otherwise the engine logs a warning and no-ops. Panel-toggleable via `FEATURE_FLAGS`. The NAS compose defaults it to `true`; the local and registry composes default it to `false`. |
|
||||||
|
| `ROBOCO_DOCS_SYNC_MAX_OPEN_TASKS` | `3` | Rolling cap on concurrently-open docs-sync tasks; the engine originates nothing more while this many are still open. |
|
||||||
|
| `ROBOCO_DOCS_SYNC_MAX_PER_CYCLE` | `1` | Max docs-sync tasks the engine may originate in one invocation. A release publish is a single invocation, so this bounds it to one task per publish event. |
|
||||||
| `ROBOCO_ORG_MEMORY_ENABLED` | `false` | Master switch for the org-memory loop (distill at completion, index journals, auto-inject lessons/playbooks); off = legacy capture, no inject |
|
| `ROBOCO_ORG_MEMORY_ENABLED` | `false` | Master switch for the org-memory loop (distill at completion, index journals, auto-inject lessons/playbooks); off = legacy capture, no inject |
|
||||||
| `ROBOCO_ORG_MEMORY_TOP_K` | `3` | Max institutional-memory items injected into a briefing on claim |
|
| `ROBOCO_ORG_MEMORY_TOP_K` | `3` | Max institutional-memory items injected into a briefing on claim |
|
||||||
| `ROBOCO_ORG_MEMORY_MIN_SCORE` | `0.6` | Cosine-similarity floor for injected memory; below it nothing is injected |
|
| `ROBOCO_ORG_MEMORY_MIN_SCORE` | `0.6` | Cosine-similarity floor for injected memory; below it nothing is injected |
|
||||||
|
|||||||
@@ -847,6 +847,38 @@ class Settings(BaseSettings):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Docs-divergence sync — when enabled, the release-proposal publish-success
|
||||||
|
# path invokes the docs-sync engine to originate one bounded, deduped
|
||||||
|
# docs-update task against the roboco-website project per release. Default-off;
|
||||||
|
# the engine no-ops when disabled and logs a warning when roboco-website is
|
||||||
|
# not registered as a project.
|
||||||
|
docs_sync_enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"Master switch for the docs-divergence sync engine. OFF by default; "
|
||||||
|
"when off the engine is never invoked on release publish. When on, "
|
||||||
|
"a successful release proposal may originate one docs-update task "
|
||||||
|
"per release tag against the roboco-website project."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
docs_sync_max_open_tasks: int = Field(
|
||||||
|
default=3,
|
||||||
|
ge=1,
|
||||||
|
description=(
|
||||||
|
"Rolling cap on concurrently-open docs-sync tasks; the engine "
|
||||||
|
"originates nothing more while this many are still open."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
docs_sync_max_per_cycle: int = Field(
|
||||||
|
default=1,
|
||||||
|
ge=1,
|
||||||
|
description=(
|
||||||
|
"Max docs-sync tasks the engine may originate in one invocation. "
|
||||||
|
"A release publish is a single invocation, so this bounds it to "
|
||||||
|
"one task per publish event."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Organizational-memory loop — distill a high-signal lesson at task
|
# Organizational-memory loop — distill a high-signal lesson at task
|
||||||
# completion, index journal reflections, and auto-inject similar past
|
# completion, index journal reflections, and auto-inject similar past
|
||||||
# lessons/playbooks into the agent briefing on claim. Default-off; when off
|
# lessons/playbooks into the agent briefing on claim. Default-off; when off
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ VIDEO_DRAFT = "video_draft"
|
|||||||
VIDEO_REJECT_REASON = "video_reject_reason"
|
VIDEO_REJECT_REASON = "video_reject_reason"
|
||||||
VAULT_CURATION_DISPATCHED = "vault_curation_dispatched"
|
VAULT_CURATION_DISPATCHED = "vault_curation_dispatched"
|
||||||
VAULT_NOTE_REF = "vault_note_ref"
|
VAULT_NOTE_REF = "vault_note_ref"
|
||||||
|
DOCS_SYNC_RELEASE_VERSION = "docs_sync_release_version"
|
||||||
|
|
||||||
|
|
||||||
def get_marker(task: HasMarkers, key: str, default: Any = None) -> Any:
|
def get_marker(task: HasMarkers, key: str, default: Any = None) -> Any:
|
||||||
@@ -401,3 +402,17 @@ def set_transition_note(task: HasMarkers, event: str, note: str) -> None:
|
|||||||
notes = dict(existing) if isinstance(existing, dict) else {}
|
notes = dict(existing) if isinstance(existing, dict) else {}
|
||||||
notes[event] = note
|
notes[event] = note
|
||||||
set_marker(task, TRANSITION_NOTES, notes)
|
set_marker(task, TRANSITION_NOTES, notes)
|
||||||
|
|
||||||
|
|
||||||
|
# --- docs-sync release version --------------------------------------------- #
|
||||||
|
# The docs-sync engine stamps the release version (e.g. "0.23.0") on each
|
||||||
|
# docs_update task it originates so it can dedupe per release.
|
||||||
|
|
||||||
|
|
||||||
|
def get_docs_sync_release_version(task: HasMarkers) -> str | None:
|
||||||
|
val = get_marker(task, DOCS_SYNC_RELEASE_VERSION)
|
||||||
|
return str(val) if val else None
|
||||||
|
|
||||||
|
|
||||||
|
def set_docs_sync_release_version(task: HasMarkers, version: str) -> None:
|
||||||
|
set_marker(task, DOCS_SYNC_RELEASE_VERSION, version)
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""Docs-divergence sync engine — dormant by default.
|
||||||
|
|
||||||
|
On a successful release publish, if ``docs_sync_enabled`` is on, the engine
|
||||||
|
originates exactly one docs-update task against the ``roboco-website`` project,
|
||||||
|
carrying the release's CHANGELOG section as the brief plus a pointer to the
|
||||||
|
divergence checklist. Conservative:
|
||||||
|
|
||||||
|
* **Default OFF** (``docs_sync_enabled``) — the engine is never invoked.
|
||||||
|
* **Never self-deploys** — it only OPENS a task; the docs update still ships
|
||||||
|
through the normal gates (dev -> QA -> PR review -> the CEO's merge).
|
||||||
|
* **Bounded + deduped per release** — at most one open docs_sync task per
|
||||||
|
release version, a rolling open-task cap, and a per-invocation cap so a
|
||||||
|
single publish event cannot originate more than ``docs_sync_max_per_cycle``
|
||||||
|
tasks.
|
||||||
|
|
||||||
|
The release-proposal service calls ``originate_docs_update`` from its publish-
|
||||||
|
success path; the engine itself has no background loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
|
from roboco.config import settings
|
||||||
|
from roboco.foundation import identity as _foundation
|
||||||
|
from roboco.foundation.policy.content import markers
|
||||||
|
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team
|
||||||
|
from roboco.services.base import BaseService
|
||||||
|
from roboco.services.project import get_project_service
|
||||||
|
from roboco.services.task import (
|
||||||
|
DOCS_SYNC_SOURCE,
|
||||||
|
TaskCreateRequest,
|
||||||
|
TaskService,
|
||||||
|
get_task_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from roboco.db.tables import TaskTable
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Project slug that hosts the public docs site. The engine logs a warning and
|
||||||
|
# no-ops when this project is not registered.
|
||||||
|
_DOCS_PROJECT_SLUG = "roboco-website"
|
||||||
|
|
||||||
|
# Pointer to the divergence checklist included in every originated task.
|
||||||
|
_DIVERGENCE_CHECKLIST_POINTER = (
|
||||||
|
"Review the divergence checklist in the release readiness report "
|
||||||
|
"(docs_drift gaps such as declared-vs-actual agent count and stale "
|
||||||
|
"verb-surface tables) and update the public docs at docs.roboco.tech "
|
||||||
|
"so they reflect what actually shipped."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocsSyncEngine(BaseService):
|
||||||
|
"""Open one docs-update task per published release against roboco-website."""
|
||||||
|
|
||||||
|
service_name = "docs_sync_engine"
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
super().__init__(session)
|
||||||
|
# Tracks how many tasks this engine instance has originated. Reset per
|
||||||
|
# instance because release_proposal creates a fresh engine per publish.
|
||||||
|
self._per_cycle_originated = 0
|
||||||
|
|
||||||
|
async def originate_docs_update(
|
||||||
|
self, version: str, changelog: str
|
||||||
|
) -> TaskTable | None:
|
||||||
|
"""If enabled, open one docs-update task for this release.
|
||||||
|
|
||||||
|
Returns the created task, or None when disabled, when roboco-website is
|
||||||
|
not registered, when either cap is reached, or when a task for this
|
||||||
|
version is already open. Flushes; the caller (release_proposal) owns
|
||||||
|
the commit. Never starts / approves / merges.
|
||||||
|
"""
|
||||||
|
if not settings.docs_sync_enabled:
|
||||||
|
return None
|
||||||
|
|
||||||
|
project = await get_project_service(self.session).get_by_slug(
|
||||||
|
_DOCS_PROJECT_SLUG
|
||||||
|
)
|
||||||
|
if project is None or getattr(project, "id", None) is None:
|
||||||
|
logger.warning(
|
||||||
|
"docs-sync enabled but project %r is not registered; "
|
||||||
|
"skipping docs-update task origination",
|
||||||
|
_DOCS_PROJECT_SLUG,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
task_svc = get_task_service(self.session)
|
||||||
|
open_tasks = await task_svc.list_open_docs_sync_tasks()
|
||||||
|
open_count = len(open_tasks)
|
||||||
|
if open_count >= settings.docs_sync_max_open_tasks:
|
||||||
|
self.log.info(
|
||||||
|
"docs-sync open-task cap reached; not originating",
|
||||||
|
cap=settings.docs_sync_max_open_tasks,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self._per_cycle_originated >= settings.docs_sync_max_per_cycle:
|
||||||
|
self.log.info(
|
||||||
|
"docs-sync per-cycle cap reached; not originating",
|
||||||
|
cap=settings.docs_sync_max_per_cycle,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if await self._already_open_for_version(task_svc, version):
|
||||||
|
self.log.info(
|
||||||
|
"docs-sync task already open for version",
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
task = await self._open_task(
|
||||||
|
task_svc, cast("UUID", project.id), version, changelog
|
||||||
|
)
|
||||||
|
self._per_cycle_originated += 1
|
||||||
|
self.log.info(
|
||||||
|
"docs-sync task opened",
|
||||||
|
task_id=str(task.id),
|
||||||
|
version=version,
|
||||||
|
project=_DOCS_PROJECT_SLUG,
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
async def _already_open_for_version(
|
||||||
|
self, task_svc: TaskService, version: str
|
||||||
|
) -> bool:
|
||||||
|
"""True when a non-terminal docs_sync task already exists for ``version``."""
|
||||||
|
existing = await task_svc.list_open_docs_sync_tasks(version=version)
|
||||||
|
return bool(existing)
|
||||||
|
|
||||||
|
async def _open_task(
|
||||||
|
self,
|
||||||
|
task_svc: TaskService,
|
||||||
|
project_id: UUID,
|
||||||
|
version: str,
|
||||||
|
changelog: str,
|
||||||
|
) -> TaskTable:
|
||||||
|
task = await task_svc.create(
|
||||||
|
TaskCreateRequest(
|
||||||
|
title=f"Docs update for release v{version}",
|
||||||
|
description=(
|
||||||
|
f"A release ({version}) has published. Update the public "
|
||||||
|
f"docs so they reflect what shipped.\n\n"
|
||||||
|
f"## Release CHANGELOG\n\n{changelog}\n\n"
|
||||||
|
f"## Divergence checklist\n\n{_DIVERGENCE_CHECKLIST_POINTER}\n\n"
|
||||||
|
"This is a Main-PM coordination root: decompose the docs "
|
||||||
|
"update and delegate the code work to a cell dev or "
|
||||||
|
"documenter — the Main PM does not write the update "
|
||||||
|
"itself. This task was opened automatically by the "
|
||||||
|
"docs-sync engine and is READY TO START NOW — no approval "
|
||||||
|
"needed. It still ships through the normal gates (QA, PR "
|
||||||
|
"review, and the CEO's merge)."
|
||||||
|
),
|
||||||
|
acceptance_criteria=[
|
||||||
|
"The docs update is decomposed into one or more delivery "
|
||||||
|
"subtasks delegated to a cell",
|
||||||
|
f"docs.roboco.tech accurately reflects the v{version} "
|
||||||
|
"release and the update is merged through the normal gates",
|
||||||
|
],
|
||||||
|
team=Team.MAIN_PM,
|
||||||
|
assigned_to=_foundation.AGENTS["main-pm"].uuid,
|
||||||
|
created_by=_foundation.AGENTS["system"].uuid,
|
||||||
|
task_type=TaskType.PLANNING,
|
||||||
|
nature=TaskNature.TECHNICAL,
|
||||||
|
estimated_complexity=Complexity.MEDIUM,
|
||||||
|
project_id=project_id,
|
||||||
|
status=TaskStatus.PENDING,
|
||||||
|
source=DOCS_SYNC_SOURCE,
|
||||||
|
confirmed_by_human=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
markers.set_docs_sync_release_version(task, version)
|
||||||
|
await self.session.flush()
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def get_docs_sync_engine(session: AsyncSession) -> DocsSyncEngine:
|
||||||
|
"""Construct a DocsSyncEngine bound to ``session``."""
|
||||||
|
return DocsSyncEngine(session)
|
||||||
@@ -201,6 +201,7 @@ class ReleaseProposalService(BaseService):
|
|||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
await self._draft_x_post(report)
|
await self._draft_x_post(report)
|
||||||
await self._draft_video(report)
|
await self._draft_video(report)
|
||||||
|
await self._draft_docs_update(report)
|
||||||
return result
|
return result
|
||||||
finally:
|
finally:
|
||||||
await self._finalize_release_lock(
|
await self._finalize_release_lock(
|
||||||
@@ -238,6 +239,22 @@ class ReleaseProposalService(BaseService):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("video draft failed (best-effort): %s", exc)
|
logger.warning("video draft failed (best-effort): %s", exc)
|
||||||
|
|
||||||
|
async def _draft_docs_update(self, report: ReleaseReadinessReport) -> None:
|
||||||
|
"""Hand the just-published release to the docs-sync engine for a
|
||||||
|
docs-update task (best-effort — never raises into approve(); an
|
||||||
|
origination failure must not affect the release's already-succeeded
|
||||||
|
publish). Off or a missing roboco-website project is itself a no-op
|
||||||
|
inside the engine."""
|
||||||
|
try:
|
||||||
|
from roboco.services.docs_sync_engine import get_docs_sync_engine
|
||||||
|
|
||||||
|
await get_docs_sync_engine(self.session).originate_docs_update(
|
||||||
|
version=report.proposed_version,
|
||||||
|
changelog=report.drafted_changelog,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("docs-sync task origination failed (best-effort): %s", exc)
|
||||||
|
|
||||||
async def _finalize_release_lock(
|
async def _finalize_release_lock(
|
||||||
self,
|
self,
|
||||||
heartbeat_task: asyncio.Task[None] | None,
|
heartbeat_task: asyncio.Task[None] | None,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ FEATURE_FLAGS: tuple[tuple[str, str], ...] = (
|
|||||||
("gateway_health_enabled", "Gateway-health recovery"),
|
("gateway_health_enabled", "Gateway-health recovery"),
|
||||||
("ci_watch_enabled", "Multi-repo CI-watch"),
|
("ci_watch_enabled", "Multi-repo CI-watch"),
|
||||||
("dep_update_enabled", "Dependency-update bot"),
|
("dep_update_enabled", "Dependency-update bot"),
|
||||||
|
("docs_sync_enabled", "Docs-divergence sync (release -> docs-update task)"),
|
||||||
("release_manager_enabled", "Gated release manager"),
|
("release_manager_enabled", "Gated release manager"),
|
||||||
("org_memory_enabled", "Organizational memory loop"),
|
("org_memory_enabled", "Organizational memory loop"),
|
||||||
("sandbox_db_enabled", "Sandboxed per-agent test DB/Redis"),
|
("sandbox_db_enabled", "Sandboxed per-agent test DB/Redis"),
|
||||||
|
|||||||
+35
-3
@@ -597,6 +597,11 @@ CI_WATCH_SOURCE = "ci_watch"
|
|||||||
# lifecycle (+ PR-review gate) and is never auto-merged.
|
# lifecycle (+ PR-review gate) and is never auto-merged.
|
||||||
DEP_UPDATE_SOURCE = "dep_update"
|
DEP_UPDATE_SOURCE = "dep_update"
|
||||||
|
|
||||||
|
# Source tag for a docs-divergence sync task: opened by the docs-sync engine on a
|
||||||
|
# successful release publish. Rides the normal delivery lifecycle and never
|
||||||
|
# auto-merges; requires the roboco-website project to be registered.
|
||||||
|
DOCS_SYNC_SOURCE = "docs_sync"
|
||||||
|
|
||||||
# Source tag for a gated release proposal: opened by the release-manager engine
|
# Source tag for a gated release proposal: opened by the release-manager engine
|
||||||
# when accumulated unreleased changes pass the threshold + the gate is green.
|
# when accumulated unreleased changes pass the threshold + the gate is green.
|
||||||
# Unlike the sources above it is NEVER dispatched — it is HELD for the CEO
|
# Unlike the sources above it is NEVER dispatched — it is HELD for the CEO
|
||||||
@@ -1569,6 +1574,32 @@ class TaskService(BaseService):
|
|||||||
result = await self.session.execute(stmt)
|
result = await self.session.execute(stmt)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def list_open_docs_sync_tasks(
|
||||||
|
self, version: str | None = None
|
||||||
|
) -> list[TaskTable]:
|
||||||
|
"""Non-terminal docs_sync tasks — the dedupe + open-cap basis.
|
||||||
|
|
||||||
|
Optionally scoped to one release ``version`` via the
|
||||||
|
``docs_sync_release_version`` marker so a release never gets a second
|
||||||
|
docs-update task while the first is still open.
|
||||||
|
"""
|
||||||
|
stmt = select(TaskTable).where(
|
||||||
|
TaskTable.source == DOCS_SYNC_SOURCE,
|
||||||
|
TaskTable.status.notin_([TaskStatus.COMPLETED, TaskStatus.CANCELLED]),
|
||||||
|
)
|
||||||
|
if version is not None:
|
||||||
|
# Filter by marker in SQL so the database applies the predicate
|
||||||
|
# and avoids hauling every open docs_sync row into Python.
|
||||||
|
# .as_string() is the generic JSON comparator; .astext is JSONB-only.
|
||||||
|
stmt = stmt.where(
|
||||||
|
TaskTable.orchestration_markers[
|
||||||
|
markers.DOCS_SYNC_RELEASE_VERSION
|
||||||
|
].as_string()
|
||||||
|
== version
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
async def list_open_release_proposals(self) -> list[TaskTable]:
|
async def list_open_release_proposals(self) -> list[TaskTable]:
|
||||||
"""Non-terminal release-manager proposals — the one-open-at-a-time basis.
|
"""Non-terminal release-manager proposals — the one-open-at-a-time basis.
|
||||||
|
|
||||||
@@ -2039,6 +2070,8 @@ class TaskService(BaseService):
|
|||||||
)
|
)
|
||||||
frontier = []
|
frontier = []
|
||||||
for child in result.scalars().all():
|
for child in result.scalars().all():
|
||||||
|
# Use string-literal cast('UUID', ...) to avoid a typing-only UUID
|
||||||
|
# import and to keep ruff/mypy happy without noqa/type: ignore.
|
||||||
child_id = cast("UUID", child.id)
|
child_id = cast("UUID", child.id)
|
||||||
if child_id in seen:
|
if child_id in seen:
|
||||||
continue
|
continue
|
||||||
@@ -5169,7 +5202,6 @@ class TaskService(BaseService):
|
|||||||
)
|
)
|
||||||
|
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
|
|
||||||
await self._alert_auditor_of_rework(
|
await self._alert_auditor_of_rework(
|
||||||
task,
|
task,
|
||||||
reason=notes or "QA review failed",
|
reason=notes or "QA review failed",
|
||||||
@@ -7992,8 +8024,8 @@ class TaskService(BaseService):
|
|||||||
children = await self.get_subtasks(current_id)
|
children = await self.get_subtasks(current_id)
|
||||||
for child in children:
|
for child in children:
|
||||||
descendants.append(child)
|
descendants.append(child)
|
||||||
# child.id is SQLAlchemy Mapped[UUID]
|
# String-literal cast tells mypy the SQLAlchemy Mapped[UUID]
|
||||||
# but resolves to uuid.UUID at runtime
|
# resolves to uuid.UUID at runtime, without a type: ignore.
|
||||||
to_process.append(cast("UUID", child.id))
|
to_process.append(cast("UUID", child.id))
|
||||||
|
|
||||||
return descendants
|
return descendants
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""DocsSyncEngine — originate one docs-update task per release, bounded + deduped.
|
||||||
|
|
||||||
|
Mirrors the dep-update engine unit-test style: mocked TaskService/ProjectService
|
||||||
|
so the engine's logic can be exercised without a real Postgres + pgvector setup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from roboco.config import settings
|
||||||
|
from roboco.services.docs_sync_engine import DocsSyncEngine
|
||||||
|
|
||||||
|
|
||||||
|
def _project(project_id: Any, slug: str, git_url: str) -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(id=project_id, slug=slug, git_url=git_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _task(task_id: Any, project_id: Any, version: str | None = None) -> SimpleNamespace:
|
||||||
|
markers: dict[str, Any] = {}
|
||||||
|
if version is not None:
|
||||||
|
markers["docs_sync_release_version"] = version
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=task_id,
|
||||||
|
project_id=project_id,
|
||||||
|
orchestration_markers=markers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engine(project_svc: Any, task_svc: Any) -> tuple[DocsSyncEngine, list[Any]]:
|
||||||
|
session = MagicMock()
|
||||||
|
session.flush = AsyncMock(return_value=None)
|
||||||
|
engine = DocsSyncEngine(session)
|
||||||
|
patchers = [
|
||||||
|
patch(
|
||||||
|
"roboco.services.docs_sync_engine.get_project_service",
|
||||||
|
return_value=project_svc,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"roboco.services.docs_sync_engine.get_task_service",
|
||||||
|
return_value=task_svc,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for p in patchers:
|
||||||
|
p.start()
|
||||||
|
return engine, patchers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(settings, "docs_sync_enabled", True)
|
||||||
|
monkeypatch.setattr(settings, "docs_sync_max_open_tasks", 3)
|
||||||
|
monkeypatch.setattr(settings, "docs_sync_max_per_cycle", 1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enabled_opens_one_docs_update_task(_enabled: None) -> None:
|
||||||
|
project_id = uuid4()
|
||||||
|
project = _project(
|
||||||
|
project_id, "roboco-website", "https://github.com/x/roboco-website.git"
|
||||||
|
)
|
||||||
|
created = _task(uuid4(), project_id, "0.23.0")
|
||||||
|
|
||||||
|
project_svc = MagicMock()
|
||||||
|
project_svc.get_by_slug = AsyncMock(return_value=project)
|
||||||
|
task_svc = MagicMock()
|
||||||
|
task_svc.list_open_docs_sync_tasks = AsyncMock(return_value=[])
|
||||||
|
task_svc.create = AsyncMock(return_value=created)
|
||||||
|
|
||||||
|
engine, patchers = _make_engine(project_svc, task_svc)
|
||||||
|
try:
|
||||||
|
result = await engine.originate_docs_update(
|
||||||
|
version="0.23.0",
|
||||||
|
changelog="## [0.23.0]\n\n### Added\n- docs-sync engine\n",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
for p in patchers:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.id == created.id
|
||||||
|
assert result.orchestration_markers is not None
|
||||||
|
assert result.orchestration_markers.get("docs_sync_release_version") == "0.23.0"
|
||||||
|
task_svc.create.assert_awaited_once()
|
||||||
|
req = task_svc.create.await_args.args[0]
|
||||||
|
assert req.project_id == project_id
|
||||||
|
assert req.source == "docs_sync"
|
||||||
|
assert "docs-sync engine" in req.description
|
||||||
|
assert "Divergence checklist" in req.description
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_same_version_is_deduped(_enabled: None) -> None:
|
||||||
|
project_id = uuid4()
|
||||||
|
project = _project(
|
||||||
|
project_id, "roboco-website", "https://github.com/x/roboco-website.git"
|
||||||
|
)
|
||||||
|
open_task = _task(uuid4(), project_id, "0.23.0")
|
||||||
|
|
||||||
|
project_svc = MagicMock()
|
||||||
|
project_svc.get_by_slug = AsyncMock(return_value=project)
|
||||||
|
task_svc = MagicMock()
|
||||||
|
task_svc.list_open_docs_sync_tasks = AsyncMock(return_value=[open_task])
|
||||||
|
task_svc.create = AsyncMock()
|
||||||
|
|
||||||
|
engine, patchers = _make_engine(project_svc, task_svc)
|
||||||
|
try:
|
||||||
|
result = await engine.originate_docs_update(version="0.23.0", changelog="x")
|
||||||
|
finally:
|
||||||
|
for p in patchers:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
task_svc.create.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_different_versions_open_distinct_tasks(_enabled: None) -> None:
|
||||||
|
project_id = uuid4()
|
||||||
|
project = _project(
|
||||||
|
project_id, "roboco-website", "https://github.com/x/roboco-website.git"
|
||||||
|
)
|
||||||
|
open_task = _task(uuid4(), project_id, "0.23.0")
|
||||||
|
new_task = _task(uuid4(), project_id, "0.24.0")
|
||||||
|
|
||||||
|
project_svc = MagicMock()
|
||||||
|
project_svc.get_by_slug = AsyncMock(return_value=project)
|
||||||
|
task_svc = MagicMock()
|
||||||
|
task_svc.list_open_docs_sync_tasks = AsyncMock(
|
||||||
|
side_effect=lambda version=None: (
|
||||||
|
[open_task]
|
||||||
|
if version == "0.23.0"
|
||||||
|
else ([] if version == "0.24.0" else [open_task])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
task_svc.create = AsyncMock(return_value=new_task)
|
||||||
|
|
||||||
|
engine, patchers = _make_engine(project_svc, task_svc)
|
||||||
|
try:
|
||||||
|
result = await engine.originate_docs_update(version="0.24.0", changelog="y")
|
||||||
|
finally:
|
||||||
|
for p in patchers:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.id == new_task.id
|
||||||
|
task_svc.create.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disabled_is_noop(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(settings, "docs_sync_enabled", False)
|
||||||
|
project_svc = MagicMock()
|
||||||
|
project_svc.get_by_slug = AsyncMock()
|
||||||
|
task_svc = MagicMock()
|
||||||
|
task_svc.create = AsyncMock()
|
||||||
|
|
||||||
|
engine, patchers = _make_engine(project_svc, task_svc)
|
||||||
|
try:
|
||||||
|
result = await engine.originate_docs_update(version="0.23.0", changelog="x")
|
||||||
|
finally:
|
||||||
|
for p in patchers:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert project_svc.get_by_slug.await_count == 0
|
||||||
|
assert task_svc.create.await_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_project_warns_and_returns_none(
|
||||||
|
_enabled: None, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
project_svc = MagicMock()
|
||||||
|
project_svc.get_by_slug = AsyncMock(return_value=None)
|
||||||
|
task_svc = MagicMock()
|
||||||
|
task_svc.create = AsyncMock()
|
||||||
|
|
||||||
|
engine, patchers = _make_engine(project_svc, task_svc)
|
||||||
|
try:
|
||||||
|
with caplog.at_level("WARNING", logger="roboco.services.docs_sync_engine"):
|
||||||
|
result = await engine.originate_docs_update(version="0.23.0", changelog="x")
|
||||||
|
finally:
|
||||||
|
for p in patchers:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert "roboco-website" in caplog.text
|
||||||
|
assert "not registered" in caplog.text
|
||||||
|
assert task_svc.create.await_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_open_task_cap_is_enforced(
|
||||||
|
_enabled: None, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(settings, "docs_sync_max_open_tasks", 1)
|
||||||
|
project_id = uuid4()
|
||||||
|
project = _project(
|
||||||
|
project_id, "roboco-website", "https://github.com/x/roboco-website.git"
|
||||||
|
)
|
||||||
|
open_task = _task(uuid4(), project_id, "0.23.0")
|
||||||
|
|
||||||
|
project_svc = MagicMock()
|
||||||
|
project_svc.get_by_slug = AsyncMock(return_value=project)
|
||||||
|
task_svc = MagicMock()
|
||||||
|
task_svc.list_open_docs_sync_tasks = AsyncMock(return_value=[open_task])
|
||||||
|
task_svc.create = AsyncMock()
|
||||||
|
|
||||||
|
engine, patchers = _make_engine(project_svc, task_svc)
|
||||||
|
try:
|
||||||
|
result = await engine.originate_docs_update(version="0.24.0", changelog="y")
|
||||||
|
finally:
|
||||||
|
for p in patchers:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
task_svc.create.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_per_cycle_cap_is_enforced(
|
||||||
|
_enabled: None, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Once the per-cycle cap is reached, further calls on the same engine no-op."""
|
||||||
|
monkeypatch.setattr(settings, "docs_sync_max_per_cycle", 1)
|
||||||
|
project_id = uuid4()
|
||||||
|
project = _project(
|
||||||
|
project_id, "roboco-website", "https://github.com/x/roboco-website.git"
|
||||||
|
)
|
||||||
|
first_task = _task(uuid4(), project_id, "0.23.0")
|
||||||
|
second_task = _task(uuid4(), project_id, "0.24.0")
|
||||||
|
|
||||||
|
project_svc = MagicMock()
|
||||||
|
project_svc.get_by_slug = AsyncMock(return_value=project)
|
||||||
|
task_svc = MagicMock()
|
||||||
|
task_svc.list_open_docs_sync_tasks = AsyncMock(
|
||||||
|
side_effect=lambda version=None: [first_task] if version is None else []
|
||||||
|
)
|
||||||
|
task_svc.create = AsyncMock(side_effect=[first_task, second_task])
|
||||||
|
|
||||||
|
engine, patchers = _make_engine(project_svc, task_svc)
|
||||||
|
try:
|
||||||
|
first = await engine.originate_docs_update(version="0.23.0", changelog="x")
|
||||||
|
second = await engine.originate_docs_update(version="0.24.0", changelog="y")
|
||||||
|
finally:
|
||||||
|
for p in patchers:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
assert first is not None
|
||||||
|
assert first.id == first_task.id
|
||||||
|
assert second is None
|
||||||
|
task_svc.create.assert_awaited_once()
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""DOCS_SYNC_SOURCE + list_open_docs_sync_tasks — the dedupe + open-cap basis.
|
||||||
|
|
||||||
|
Open docs_sync tasks count toward the cap and block a duplicate per release
|
||||||
|
version; terminal ones and tasks from other sources do not.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from roboco.db.tables import AgentTable, ProjectTable
|
||||||
|
from roboco.foundation import identity as _foundation
|
||||||
|
from roboco.foundation.policy.content import markers
|
||||||
|
from roboco.models.base import (
|
||||||
|
AgentRole,
|
||||||
|
AgentStatus,
|
||||||
|
Complexity,
|
||||||
|
TaskNature,
|
||||||
|
TaskStatus,
|
||||||
|
TaskType,
|
||||||
|
Team,
|
||||||
|
)
|
||||||
|
from roboco.models.task import TaskCreateRequest
|
||||||
|
from roboco.services.task import DOCS_SYNC_SOURCE, get_task_service
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
||||||
|
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
|
||||||
|
_VERSION = "0.23.0"
|
||||||
|
_TWO = 2
|
||||||
|
_ONE = 1
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_create_agent(
|
||||||
|
db: AsyncSession, agent_id: object, role: AgentRole, slug: str
|
||||||
|
) -> None:
|
||||||
|
if await db.get(AgentTable, agent_id) is None:
|
||||||
|
db.add(
|
||||||
|
AgentTable(
|
||||||
|
id=agent_id,
|
||||||
|
name=slug,
|
||||||
|
slug=f"{slug}-{uuid4().hex[:8]}",
|
||||||
|
role=role,
|
||||||
|
team=None,
|
||||||
|
status=AgentStatus.ACTIVE,
|
||||||
|
model_config={},
|
||||||
|
system_prompt="x",
|
||||||
|
capabilities=[],
|
||||||
|
permissions={},
|
||||||
|
metrics={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_project(db: AsyncSession) -> ProjectTable:
|
||||||
|
project = ProjectTable(
|
||||||
|
id=uuid4(),
|
||||||
|
name="RoboCo Website",
|
||||||
|
slug=f"website-{uuid4().hex[:8]}",
|
||||||
|
git_url="https://github.com/rennf93/roboco-website.git",
|
||||||
|
assigned_cell=Team.BACKEND,
|
||||||
|
created_by=SYSTEM_UUID,
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
await db.flush()
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_task(
|
||||||
|
db: AsyncSession,
|
||||||
|
project: ProjectTable,
|
||||||
|
*,
|
||||||
|
source: str = DOCS_SYNC_SOURCE,
|
||||||
|
terminal: bool = False,
|
||||||
|
version: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
markers_dict: dict[str, object] = {}
|
||||||
|
if version is not None:
|
||||||
|
markers_dict[markers.DOCS_SYNC_RELEASE_VERSION] = version
|
||||||
|
task = await get_task_service(db).create(
|
||||||
|
TaskCreateRequest(
|
||||||
|
title=f"Update docs for v{version or 'unknown'}",
|
||||||
|
description="Refresh published docs to match the shipped release.",
|
||||||
|
acceptance_criteria=["docs refreshed", "gate green"],
|
||||||
|
team=Team.MAIN_PM,
|
||||||
|
assigned_to=MAIN_PM_UUID,
|
||||||
|
created_by=SYSTEM_UUID,
|
||||||
|
task_type=TaskType.PLANNING,
|
||||||
|
nature=TaskNature.TECHNICAL,
|
||||||
|
estimated_complexity=Complexity.MEDIUM,
|
||||||
|
project_id=cast("UUID", project.id),
|
||||||
|
status=TaskStatus.PENDING,
|
||||||
|
source=source,
|
||||||
|
confirmed_by_human=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if markers_dict:
|
||||||
|
task.orchestration_markers = markers_dict
|
||||||
|
if terminal:
|
||||||
|
task.status = TaskStatus.COMPLETED
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def _agents(db_session: AsyncSession) -> None:
|
||||||
|
await _get_or_create_agent(db_session, SYSTEM_UUID, AgentRole.SYSTEM, "system")
|
||||||
|
await _get_or_create_agent(db_session, MAIN_PM_UUID, AgentRole.MAIN_PM, "main-pm")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lists_only_open_docs_sync_tasks(db_session: AsyncSession) -> None:
|
||||||
|
proj = await _seed_project(db_session)
|
||||||
|
await _make_task(db_session, proj, version=_VERSION)
|
||||||
|
await _make_task(db_session, proj, terminal=True, version=_VERSION)
|
||||||
|
await _make_task(db_session, proj, source="manual", version=_VERSION)
|
||||||
|
|
||||||
|
open_tasks = await get_task_service(db_session).list_open_docs_sync_tasks()
|
||||||
|
|
||||||
|
assert len(open_tasks) == _ONE
|
||||||
|
assert open_tasks[0].source == DOCS_SYNC_SOURCE
|
||||||
|
assert open_tasks[0].status != TaskStatus.COMPLETED
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_version_scoping(db_session: AsyncSession) -> None:
|
||||||
|
proj = await _seed_project(db_session)
|
||||||
|
await _make_task(db_session, proj, version="0.23.0")
|
||||||
|
await _make_task(db_session, proj, version="0.24.0")
|
||||||
|
|
||||||
|
svc = get_task_service(db_session)
|
||||||
|
assert len(await svc.list_open_docs_sync_tasks()) == _TWO
|
||||||
|
scoped = await svc.list_open_docs_sync_tasks(version="0.23.0")
|
||||||
|
assert len(scoped) == _ONE
|
||||||
|
assert markers.get_docs_sync_release_version(scoped[0]) == "0.23.0"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Docs-divergence sync is gated by a default-off config flag."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from roboco.config import Settings
|
||||||
|
from roboco.services.settings import FEATURE_FLAGS, validate_setting
|
||||||
|
|
||||||
|
|
||||||
|
def test_docs_sync_disabled_by_default() -> None:
|
||||||
|
s = Settings()
|
||||||
|
assert s.docs_sync_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_docs_sync_reads_env_var() -> None:
|
||||||
|
with mock.patch.dict(os.environ, {"ROBOCO_DOCS_SYNC_ENABLED": "true"}):
|
||||||
|
assert Settings().docs_sync_enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_docs_sync_flag_registered_in_feature_flags() -> None:
|
||||||
|
assert "docs_sync_enabled" in [key for key, _ in FEATURE_FLAGS]
|
||||||
|
|
||||||
|
|
||||||
|
def test_docs_sync_flag_validates_as_bool() -> None:
|
||||||
|
validate_setting("docs_sync_enabled", "true")
|
||||||
@@ -123,3 +123,10 @@ def test_documenter_self_heal_head_supersede() -> None:
|
|||||||
assert m.get_self_heal_fingerprint(t) == "deadbeef"
|
assert m.get_self_heal_fingerprint(t) == "deadbeef"
|
||||||
assert m.get_external_pr_head(t) == "sha123"
|
assert m.get_external_pr_head(t) == "sha123"
|
||||||
assert m.get_external_pr_supersede(t) == "pr=1 review=2 closed=1"
|
assert m.get_external_pr_supersede(t) == "pr=1 review=2 closed=1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_docs_sync_release_version_roundtrip() -> None:
|
||||||
|
t = _task()
|
||||||
|
assert m.get_docs_sync_release_version(t) is None
|
||||||
|
m.set_docs_sync_release_version(t, "0.23.0")
|
||||||
|
assert m.get_docs_sync_release_version(t) == "0.23.0"
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""The release-proposal publish hook originates a docs-update task (best-effort,
|
||||||
|
never raises into approve()). Layering: release_proposal calls only the small
|
||||||
|
typed seam ``DocsSyncEngine.originate_docs_update`` — this test patches at that
|
||||||
|
seam, not the engine's internals.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
|
||||||
|
from roboco.foundation import identity as _foundation
|
||||||
|
from roboco.models.base import AgentRole, AgentStatus, TaskNature, TaskStatus, TaskType
|
||||||
|
from roboco.models.base import Team as T
|
||||||
|
from roboco.services.release_executor import ReleaseResult
|
||||||
|
from roboco.services.release_proposal import ReleaseProposalService
|
||||||
|
from roboco.services.release_readiness import ReleaseReadinessReport, report_to_dict
|
||||||
|
from roboco.services.task import RELEASE_MANAGER_SOURCE
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
_VERSION = "0.23.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _report() -> ReleaseReadinessReport:
|
||||||
|
return ReleaseReadinessReport(
|
||||||
|
proposed_version=_VERSION,
|
||||||
|
bump_kind="minor",
|
||||||
|
change_summary=["feat: docs-sync engine", "fix: typos"],
|
||||||
|
drafted_changelog=f"## [{_VERSION}]\n\n### Added\n- docs-sync engine\n",
|
||||||
|
version_bump_plan=["pyproject.toml"],
|
||||||
|
gaps=[],
|
||||||
|
migration_notes=[],
|
||||||
|
gate_state="green",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_proposal(session: AsyncSession) -> TaskTable:
|
||||||
|
system_uuid = _foundation.AGENTS["system"].uuid
|
||||||
|
secretary_uuid = _foundation.AGENTS["secretary-1"].uuid
|
||||||
|
for uuid_, slug, role in (
|
||||||
|
(system_uuid, "system", AgentRole.SYSTEM),
|
||||||
|
(secretary_uuid, "secretary-1", AgentRole.SECRETARY),
|
||||||
|
):
|
||||||
|
if await session.get(AgentTable, uuid_) is None:
|
||||||
|
session.add(
|
||||||
|
AgentTable(
|
||||||
|
id=uuid_,
|
||||||
|
name=slug,
|
||||||
|
slug=slug,
|
||||||
|
role=role,
|
||||||
|
team=None,
|
||||||
|
status=AgentStatus.ACTIVE,
|
||||||
|
model_config={},
|
||||||
|
system_prompt="x",
|
||||||
|
capabilities=[],
|
||||||
|
permissions={},
|
||||||
|
metrics={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.flush()
|
||||||
|
project = ProjectTable(
|
||||||
|
id=uuid4(),
|
||||||
|
name="RoboCo",
|
||||||
|
slug=f"roboco-{uuid4().hex[:6]}",
|
||||||
|
git_url="https://example.com/roboco.git",
|
||||||
|
assigned_cell=T.BACKEND,
|
||||||
|
created_by=system_uuid,
|
||||||
|
)
|
||||||
|
session.add(project)
|
||||||
|
await session.flush()
|
||||||
|
task = TaskTable(
|
||||||
|
id=uuid4(),
|
||||||
|
title=f"Release proposal: v{_VERSION}",
|
||||||
|
description="proposal body",
|
||||||
|
acceptance_criteria=["CEO approves"],
|
||||||
|
status=TaskStatus.PENDING,
|
||||||
|
priority=2,
|
||||||
|
task_type=TaskType.ADMINISTRATIVE,
|
||||||
|
nature=TaskNature.NON_TECHNICAL,
|
||||||
|
project_id=project.id,
|
||||||
|
created_by=system_uuid,
|
||||||
|
assigned_to=secretary_uuid,
|
||||||
|
team=T.MAIN_PM,
|
||||||
|
source=RELEASE_MANAGER_SOURCE,
|
||||||
|
confirmed_by_human=False,
|
||||||
|
orchestration_markers={"release_report": report_to_dict(_report())},
|
||||||
|
)
|
||||||
|
session.add(task)
|
||||||
|
await session.flush()
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_success_calls_docs_sync_seam(db_session: AsyncSession) -> None:
|
||||||
|
task = await _seed_proposal(db_session)
|
||||||
|
published = ReleaseResult(
|
||||||
|
status="published",
|
||||||
|
version=_VERSION,
|
||||||
|
files_changed=["pyproject.toml"],
|
||||||
|
commit_sha="abc123",
|
||||||
|
release_url=f"https://github.com/x/roboco/releases/tag/v{_VERSION}",
|
||||||
|
detail="ok",
|
||||||
|
)
|
||||||
|
fake_executor = AsyncMock()
|
||||||
|
fake_executor.execute = AsyncMock(return_value=published)
|
||||||
|
fake_engine = AsyncMock()
|
||||||
|
fake_engine.originate_docs_update = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"roboco.services.release_proposal.get_release_executor",
|
||||||
|
AsyncMock(return_value=fake_executor),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"roboco.services.docs_sync_engine.get_docs_sync_engine",
|
||||||
|
return_value=fake_engine,
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
ReleaseProposalService, "_acquire_release_lock", AsyncMock(return_value="t")
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
ReleaseProposalService,
|
||||||
|
"_release_release_lock",
|
||||||
|
AsyncMock(return_value=None),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
ReleaseProposalService,
|
||||||
|
"_heartbeat_release_lock",
|
||||||
|
AsyncMock(return_value=True),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await ReleaseProposalService(db_session).approve(cast("UUID", task.id))
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.status == "published"
|
||||||
|
fake_engine.originate_docs_update.assert_awaited_once_with(
|
||||||
|
version=_VERSION,
|
||||||
|
changelog=f"## [{_VERSION}]\n\n### Added\n- docs-sync engine\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_docs_sync_failure_never_fails_the_approve(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""A docs-sync origination exception is swallowed — the release already
|
||||||
|
published."""
|
||||||
|
task = await _seed_proposal(db_session)
|
||||||
|
published = ReleaseResult(
|
||||||
|
status="published",
|
||||||
|
version=_VERSION,
|
||||||
|
files_changed=["pyproject.toml"],
|
||||||
|
commit_sha="abc123",
|
||||||
|
release_url=None,
|
||||||
|
detail="ok",
|
||||||
|
)
|
||||||
|
fake_executor = AsyncMock()
|
||||||
|
fake_executor.execute = AsyncMock(return_value=published)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"roboco.services.release_proposal.get_release_executor",
|
||||||
|
AsyncMock(return_value=fake_executor),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"roboco.services.docs_sync_engine.get_docs_sync_engine",
|
||||||
|
side_effect=RuntimeError("docs-sync boom"),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
ReleaseProposalService, "_acquire_release_lock", AsyncMock(return_value="t")
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
ReleaseProposalService,
|
||||||
|
"_release_release_lock",
|
||||||
|
AsyncMock(return_value=None),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
ReleaseProposalService,
|
||||||
|
"_heartbeat_release_lock",
|
||||||
|
AsyncMock(return_value=True),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await ReleaseProposalService(db_session).approve(cast("UUID", task.id))
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.status == "published"
|
||||||
|
await db_session.refresh(task)
|
||||||
|
assert task.status == TaskStatus.COMPLETED
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_draft_docs_update_calls_engine_seam() -> None:
|
||||||
|
"""``_draft_docs_update`` is the best-effort seam; cover it directly so the
|
||||||
|
publish-success path is exercised even when the full ``approve()`` DB fixture
|
||||||
|
is unavailable."""
|
||||||
|
report = _report()
|
||||||
|
fake_engine = AsyncMock()
|
||||||
|
fake_engine.originate_docs_update = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"roboco.services.docs_sync_engine.get_docs_sync_engine",
|
||||||
|
return_value=fake_engine,
|
||||||
|
):
|
||||||
|
await ReleaseProposalService(MagicMock())._draft_docs_update(report)
|
||||||
|
|
||||||
|
fake_engine.originate_docs_update.assert_awaited_once_with(
|
||||||
|
version=_VERSION,
|
||||||
|
changelog=report.drafted_changelog,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_draft_docs_update_swallows_engine_exception() -> None:
|
||||||
|
"""An engine exception must never propagate out of the best-effort seam."""
|
||||||
|
report = _report()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"roboco.services.docs_sync_engine.get_docs_sync_engine",
|
||||||
|
side_effect=RuntimeError("docs-sync boom"),
|
||||||
|
):
|
||||||
|
await ReleaseProposalService(MagicMock())._draft_docs_update(report)
|
||||||
@@ -341,6 +341,23 @@ async def test_list_video_pipeline_tasks_empty_when_nothing_in_flight() -> None:
|
|||||||
assert await svc.list_video_pipeline_tasks() == []
|
assert await svc.list_video_pipeline_tasks() == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# list_open_docs_sync_tasks — docs-sync dedupe + open-cap basis
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_open_docs_sync_tasks_returns_non_terminal_source_tasks() -> None:
|
||||||
|
task = _build_task(status=TaskStatus.PENDING)
|
||||||
|
scalars = MagicMock()
|
||||||
|
scalars.all.return_value = [task]
|
||||||
|
result = MagicMock()
|
||||||
|
result.scalars.return_value = scalars
|
||||||
|
svc = _service_with(result)
|
||||||
|
out = await svc.list_open_docs_sync_tasks()
|
||||||
|
assert out == [task]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# all_subtasks_terminal
|
# all_subtasks_terminal
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user