[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:
Renzo F
2026-07-14 02:22:37 +02:00
committed by GitHub
co-authored by Backend Developer 1 Backend Documenter Backend Developer 2
parent 09b797fe9c
commit f03859c64c
26 changed files with 1225 additions and 12 deletions
+12
View File
@@ -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)
# ============================================================================= # =============================================================================
+3
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -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.
+1
View File
@@ -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}
+1
View File
@@ -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.
+195
View File
@@ -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).
+1 -1
View File
@@ -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
+1
View File
@@ -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` |
+7 -4
View File
@@ -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
View File
@@ -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 |
+32
View File
@@ -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)
+187
View File
@@ -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)
+17
View File
@@ -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,
+1
View File
@@ -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
View File
@@ -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"
+27
View File
@@ -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)
+17
View File
@@ -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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------