From 0f0ac6de901bb017adf63749c0d419c97f6efb50 Mon Sep 17 00:00:00 2001 From: moss Date: Tue, 19 May 2026 10:01:44 +0700 Subject: [PATCH 1/5] feat(m6): merge/archive evolver, meta-review worker, soak harness PR2: DCNEvolver merges duplicate concerns and archives cold weakened rows; MergeArchiverWorker wired into heartbeat maintenance with tunable retention. PR3: MetaReviewWorker inventories meta_concerns and DefaultEvolutionControl. PR4: Short soak test and examples/07_meta_governance_soak README. Co-authored-by: Cursor --- docs/07-mvp/m6-prerequisites-status.md | 12 +- examples/07_meta_governance_soak/README.md | 21 ++ .../opencoat_runtime_core/config.py | 11 ++ .../dcn/concern_catalog.py | 34 ++++ .../opencoat_runtime_core/dcn/evolution.py | 179 +++++++++++++++++- .../opencoat_runtime_core/meta/__init__.py | 3 +- .../meta/evolution_control.py | 24 +++ .../runtime_builder.py | 27 ++- .../workers/_concern_graph.py | 37 +--- .../workers/merge_archiver.py | 34 +++- .../workers/meta_review_worker.py | 32 +++- .../tests/core/test_dcn_evolution.py | 119 ++++++++++++ .../tests/daemon/test_merge_archiver.py | 27 +++ .../tests/daemon/test_meta_review_worker.py | 28 +++ .../soak/test_heartbeat_maintenance_soak.py | 62 ++++++ 15 files changed, 598 insertions(+), 52 deletions(-) create mode 100644 examples/07_meta_governance_soak/README.md create mode 100644 packages/opencoat-runtime/opencoat_runtime_core/dcn/concern_catalog.py create mode 100644 packages/opencoat-runtime/tests/core/test_dcn_evolution.py create mode 100644 packages/opencoat-runtime/tests/daemon/test_merge_archiver.py create mode 100644 packages/opencoat-runtime/tests/daemon/test_meta_review_worker.py create mode 100644 packages/opencoat-runtime/tests/soak/test_heartbeat_maintenance_soak.py diff --git a/docs/07-mvp/m6-prerequisites-status.md b/docs/07-mvp/m6-prerequisites-status.md index 7fa00ce..b529094 100644 --- a/docs/07-mvp/m6-prerequisites-status.md +++ b/docs/07-mvp/m6-prerequisites-status.md @@ -11,13 +11,13 @@ Re-run automation: `./scripts/verify-m6-prerequisites.sh` from repo root (daemon | **P2b** | Live OpenClaw gateway + bridge | **PASS** | 2026-05-18 local smoke — bridge README §3; NVDA concerns weave on `before_response` | | **P3** | Conflict paths documented | **PASS** | [m6-conflict-paths.md](./m6-conflict-paths.md); [ADR-0010](../adr/0010-concern-aop-syntax.md) | -## M6 implementation (`feat/m6-lifecycle-workers`) +## M6 implementation | PR slice | Status | Notes | | --- | --- | --- | -| **PR1** decay + `ConflictScannerWorker` + scheduler | **in progress** | `DecayWorker`, `ConflictScannerWorker`, `HeartbeatLoop` maintenance hook, `Scheduler.start` in daemon | -| **PR2** merge + archive | pending | `merge_archiver.py` stub | -| **PR3** meta-review | pending | ADR-0008 governance loop | -| **PR4** 24h soak + example | pending | `examples/07_meta_governance_soak` | +| **PR1** decay + `ConflictScannerWorker` + scheduler | **merged** | [#72](https://github.com/HyperdustLabs/OpenCOAT/pull/72) | +| **PR2** merge + archive | **open** | `DCNEvolver`, `MergeArchiverWorker`, `HeartbeatMaintenance` config | +| **PR3** meta-review | **open** (same branch) | `MetaReviewWorker` + `DefaultEvolutionControl` inventory | +| **PR4** soak + example | **open** (same branch) | `tests/soak/`, `examples/07_meta_governance_soak` | -**Next:** finish PR1 tests on CI, then merge/archive workers (PR2). +**Next:** merge PR2 branch; optional 24h live soak on daemon. diff --git a/examples/07_meta_governance_soak/README.md b/examples/07_meta_governance_soak/README.md new file mode 100644 index 0000000..7991b3f --- /dev/null +++ b/examples/07_meta_governance_soak/README.md @@ -0,0 +1,21 @@ +# 07 — Meta governance heartbeat soak + +Short stand-in for the M6 24h soak: exercises decay, merge/archive, and conflict +scan workers via repeated `OpenCOATRuntime.tick()` calls. + +## Run (hermetic) + +From repo root: + +```bash +uv run python -m pytest packages/opencoat-runtime/tests/soak/test_heartbeat_maintenance_soak.py -q +``` + +## Run against a live daemon + +1. `uv run opencoat runtime up` (confirm log: `heartbeat scheduler started`). +2. Leave running; watch concern count / DCN edges stabilize over hours. +3. Re-run `./scripts/verify-m6-prerequisites.sh` after long runs. + +Full 24h soak harness and convergence metrics are tracked in +[`docs/07-mvp/post-m5-roadmap.md`](../../docs/07-mvp/post-m5-roadmap.md) (M6 PR4). diff --git a/packages/opencoat-runtime/opencoat_runtime_core/config.py b/packages/opencoat-runtime/opencoat_runtime_core/config.py index 38f39ab..fd3437b 100644 --- a/packages/opencoat-runtime/opencoat_runtime_core/config.py +++ b/packages/opencoat-runtime/opencoat_runtime_core/config.py @@ -20,12 +20,23 @@ class RuntimeBudgets(BaseModel): max_advice_per_concern: int = Field(default=2, ge=1) +class HeartbeatMaintenance(BaseModel): + """M6 background worker tuning (decay / merge / conflict scan).""" + + model_config = ConfigDict(extra="forbid") + + merge_min_keyword_overlap: int = Field(default=3, ge=1) + archive_cold_decay_threshold: float = Field(default=0.85, ge=0.0, le=1.0) + archive_cold_max_score: float = Field(default=0.15, ge=0.0, le=1.0) + + class RuntimeLoops(BaseModel): model_config = ConfigDict(extra="forbid") heartbeat_interval_seconds: float = Field(default=30.0, gt=0.0) #: When false, the daemon does not start the background heartbeat scheduler. heartbeat_enabled: bool = Field(default=True) + maintenance: HeartbeatMaintenance = Field(default_factory=HeartbeatMaintenance) class JoinpointAutomation(BaseModel): diff --git a/packages/opencoat-runtime/opencoat_runtime_core/dcn/concern_catalog.py b/packages/opencoat-runtime/opencoat_runtime_core/dcn/concern_catalog.py new file mode 100644 index 0000000..7505b57 --- /dev/null +++ b/packages/opencoat-runtime/opencoat_runtime_core/dcn/concern_catalog.py @@ -0,0 +1,34 @@ +"""Catalog scan helpers for DCN evolution and daemon heartbeat workers.""" + +from __future__ import annotations + +from opencoat_runtime_protocol import Concern + + +def joinpoint_names(concern: Concern) -> frozenset[str]: + names: set[str] = set() + if concern.pointcut is not None: + for jp in concern.pointcut.joinpoints: + if isinstance(jp, str) and jp: + names.add(jp) + for pc in concern.pointcuts: + for jp in pc.joinpoints: + if isinstance(jp, str) and jp: + names.add(jp) + return frozenset(names) + + +def activation_keywords(concern: Concern) -> frozenset[str]: + keywords: set[str] = set() + if concern.pointcut is not None and concern.pointcut.match is not None: + raw = concern.pointcut.match.any_keywords + if raw: + keywords.update(k for k in raw if isinstance(k, str) and k) + for pc in concern.pointcuts: + if pc.match is None or not pc.match.any_keywords: + continue + keywords.update(k for k in pc.match.any_keywords if isinstance(k, str) and k) + return frozenset(keywords) + + +__all__ = ["activation_keywords", "joinpoint_names"] diff --git a/packages/opencoat-runtime/opencoat_runtime_core/dcn/evolution.py b/packages/opencoat-runtime/opencoat_runtime_core/dcn/evolution.py index 6273c17..0520d47 100644 --- a/packages/opencoat-runtime/opencoat_runtime_core/dcn/evolution.py +++ b/packages/opencoat-runtime/opencoat_runtime_core/dcn/evolution.py @@ -2,16 +2,185 @@ from __future__ import annotations +import contextlib +from dataclasses import dataclass +from datetime import datetime +from itertools import combinations + +from opencoat_runtime_protocol import Concern, ConcernRelationType, LifecycleState + +from ..concern.lifecycle import ConcernLifecycleManager, InvalidLifecycleTransition +from ..ports import ConcernStore, DCNStore +from ..resolver.dedupe import Dedupe +from .concern_catalog import activation_keywords, joinpoint_names + +_ACTIVE_STATES = frozenset( + { + LifecycleState.CREATED.value, + LifecycleState.ACTIVE.value, + LifecycleState.REINFORCED.value, + LifecycleState.WEAKENED.value, + LifecycleState.REVIVED.value, + } +) + +_MERGE_RELATIONS = frozenset( + { + ConcernRelationType.DUPLICATES, + ConcernRelationType.GENERALIZES, + ConcernRelationType.SPECIALIZES, + } +) + + +@dataclass(frozen=True) +class EvolutionResult: + merged: int = 0 + archived: int = 0 + class DCNEvolver: - def decay(self) -> int: - raise NotImplementedError + """Merge near-duplicate concerns and archive cold weakened rows.""" + + def __init__( + self, + *, + concern_store: ConcernStore, + dcn_store: DCNStore, + lifecycle: ConcernLifecycleManager | None = None, + merge_min_keyword_overlap: int = 3, + archive_cold_decay_threshold: float = 0.85, + archive_cold_max_score: float = 0.15, + max_catalog: int = 128, + ) -> None: + self._concern_store = concern_store + self._dcn_store = dcn_store + self._lifecycle = lifecycle or ConcernLifecycleManager( + concern_store=concern_store, + dcn_store=dcn_store, + ) + self._min_overlap = max(1, merge_min_keyword_overlap) + self._cold_decay = archive_cold_decay_threshold + self._cold_max_score = archive_cold_max_score + self._max_catalog = max(2, max_catalog) + self._dedupe = Dedupe() + + def run(self, now: datetime) -> EvolutionResult: + catalog = self._active_catalog() + merged = self._merge_declared(catalog) + merged += self._merge_heuristic(catalog) + archived = self._archive_cold(catalog) + return EvolutionResult(merged=merged, archived=archived) def merge(self) -> int: - raise NotImplementedError + return self.run(datetime.now()).merged def archive(self) -> int: - raise NotImplementedError + return self.run(datetime.now()).archived + + def decay(self) -> int: + return 0 def optimize(self) -> int: - raise NotImplementedError + return 0 + + def _active_catalog(self) -> list[Concern]: + return [ + c + for c in self._concern_store.iter_all() + if (c.lifecycle_state or LifecycleState.CREATED.value).lower() in _ACTIVE_STATES + ][: self._max_catalog] + + def _merge_declared(self, catalog: list[Concern]) -> int: + index = {c.id: c for c in catalog} + merged = 0 + seen_pairs: set[tuple[str, str]] = set() + for concern in catalog: + for rel in concern.relations: + if rel.relation_type not in _MERGE_RELATIONS: + continue + other = index.get(rel.target_concern_id) + if other is None: + continue + key = tuple(sorted((concern.id, other.id))) + if key in seen_pairs: + continue + seen_pairs.add(key) + loser_id = self._dedupe._pick_loser(concern, other, rel.relation_type) + winner_id = other.id if loser_id == concern.id else concern.id + if self._apply_merge(loser_id, winner_id): + merged += 1 + catalog[:] = [c for c in catalog if c.id not in {loser_id}] + index.pop(loser_id, None) + return merged + + def _merge_heuristic(self, catalog: list[Concern]) -> int: + merged = 0 + for left, right in combinations(list(catalog), 2): + if not joinpoint_names(left) & joinpoint_names(right): + continue + if len(activation_keywords(left) & activation_keywords(right)) < self._min_overlap: + continue + loser_id, winner_id = self._pick_by_score(left, right) + if self._apply_merge(loser_id, winner_id): + merged += 1 + catalog[:] = [c for c in catalog if c.id != loser_id] + return merged + + def _archive_cold(self, catalog: list[Concern]) -> int: + archived = 0 + for concern in catalog: + if (concern.lifecycle_state or "").lower() != LifecycleState.WEAKENED.value: + continue + activation = concern.activation_state + if activation is None: + continue + if activation.decay < self._cold_decay: + continue + score = activation.score if activation.score is not None else 0.0 + if score > self._cold_max_score: + continue + try: + self._lifecycle.archive(concern, reason="heartbeat_cold") + archived += 1 + except InvalidLifecycleTransition: + continue + return archived + + def _apply_merge(self, loser_id: str, winner_id: str) -> bool: + if loser_id == winner_id: + return False + loser = self._concern_store.get(loser_id) + winner = self._concern_store.get(winner_id) + if loser is None or winner is None: + return False + self._ensure_dcn_node(winner) + self._ensure_dcn_node(loser) + try: + self._lifecycle.transition(loser, LifecycleState.MERGED, reason="dcn_merge") + self._lifecycle.archive(loser, reason="dcn_merge") + except (InvalidLifecycleTransition, KeyError): + return False + with contextlib.suppress(Exception): + self._dcn_store.merge(loser_id, winner_id) + return True + + def _ensure_dcn_node(self, concern: Concern) -> None: + with contextlib.suppress(Exception): + self._dcn_store.add_node(concern) + + @staticmethod + def _pick_by_score(left: Concern, right: Concern) -> tuple[str, str]: + def score(c: Concern) -> float: + if c.activation_state is None or c.activation_state.score is None: + return 0.0 + return float(c.activation_state.score) + + if score(left) > score(right): + return right.id, left.id + if score(right) > score(left): + return left.id, right.id + return (left.id, right.id) if left.id > right.id else (right.id, left.id) + + +__all__ = ["DCNEvolver", "EvolutionResult"] diff --git a/packages/opencoat-runtime/opencoat_runtime_core/meta/__init__.py b/packages/opencoat-runtime/opencoat_runtime_core/meta/__init__.py index e277491..77f278c 100644 --- a/packages/opencoat-runtime/opencoat_runtime_core/meta/__init__.py +++ b/packages/opencoat-runtime/opencoat_runtime_core/meta/__init__.py @@ -7,7 +7,7 @@ from .activation_control import ActivationControl from .budget_control import BudgetControl from .conflict_resolution import ConflictResolution -from .evolution_control import EvolutionControl +from .evolution_control import DefaultEvolutionControl, EvolutionControl from .extraction_control import ExtractionControl from .lifecycle_control import DefaultLifecycleControl, LifecycleControl from .separation_control import SeparationControl @@ -18,6 +18,7 @@ "BudgetControl", "ConflictResolution", "DefaultLifecycleControl", + "DefaultEvolutionControl", "EvolutionControl", "ExtractionControl", "LifecycleControl", diff --git a/packages/opencoat-runtime/opencoat_runtime_core/meta/evolution_control.py b/packages/opencoat-runtime/opencoat_runtime_core/meta/evolution_control.py index 00ede8d..7154018 100644 --- a/packages/opencoat-runtime/opencoat_runtime_core/meta/evolution_control.py +++ b/packages/opencoat-runtime/opencoat_runtime_core/meta/evolution_control.py @@ -2,7 +2,31 @@ from __future__ import annotations +from opencoat_runtime_protocol import Concern, ConcernKind + class EvolutionControl: def trigger_review(self) -> bool: raise NotImplementedError + + +class DefaultEvolutionControl(EvolutionControl): + """Run meta review when at least one meta concern is active in the store.""" + + def __init__(self, *, meta_concerns: list[Concern]) -> None: + self._meta = [ + c + for c in meta_concerns + if (c.kind or ConcernKind.CONCERN.value) == ConcernKind.META_CONCERN.value + and (c.lifecycle_state or "").lower() not in ("archived", "deleted") + ] + + def trigger_review(self) -> bool: + return len(self._meta) > 0 + + @property + def meta_concern_count(self) -> int: + return len(self._meta) + + +__all__ = ["DefaultEvolutionControl", "EvolutionControl"] diff --git a/packages/opencoat-runtime/opencoat_runtime_daemon/runtime_builder.py b/packages/opencoat-runtime/opencoat_runtime_daemon/runtime_builder.py index 32a6c6d..5738de0 100644 --- a/packages/opencoat-runtime/opencoat_runtime_daemon/runtime_builder.py +++ b/packages/opencoat-runtime/opencoat_runtime_daemon/runtime_builder.py @@ -44,6 +44,7 @@ from typing import Any from opencoat_runtime_core import OpenCOATRuntime +from opencoat_runtime_core.config import HeartbeatMaintenance from opencoat_runtime_core.llm import StubLLMClient from opencoat_runtime_core.loops.heartbeat_loop import MaintenanceFn from opencoat_runtime_core.ports import ConcernStore, DCNStore, LLMClient @@ -51,7 +52,7 @@ from opencoat_runtime_storage.sqlite import SqliteConcernStore, SqliteDCNStore from .config.loader import DaemonConfig, LLMSettings, StorageBackend -from .workers import ConflictScannerWorker, DecayWorker +from .workers import ConflictScannerWorker, DecayWorker, MergeArchiverWorker logger = logging.getLogger(__name__) @@ -59,18 +60,30 @@ def build_heartbeat_maintenance( concern_store: ConcernStore, dcn_store: DCNStore, + *, + maintenance: HeartbeatMaintenance | None = None, ) -> MaintenanceFn: - """Daemon-side M6 maintenance: decay + background conflict scan.""" + """Daemon-side M6 maintenance: decay + merge/archive + conflict scan.""" + maint = maintenance or HeartbeatMaintenance() decay = DecayWorker(concern_store=concern_store, dcn_store=dcn_store) + merge_archiver = MergeArchiverWorker( + concern_store=concern_store, + dcn_store=dcn_store, + merge_min_keyword_overlap=maint.merge_min_keyword_overlap, + archive_cold_decay_threshold=maint.archive_cold_decay_threshold, + archive_cold_max_score=maint.archive_cold_max_score, + ) conflict = ConflictScannerWorker(concern_store=concern_store, dcn_store=dcn_store) def maintenance(now: datetime) -> dict[str, int]: decay_stats = decay.run(now) + merge_stats = merge_archiver.run(now) conflict_stats = conflict.run(now) return { "decay_count": int(decay_stats.get("touched", 0)), - "archive_count": int(decay_stats.get("archived", 0)), - "merge_count": 0, + "archive_count": int(decay_stats.get("archived", 0)) + + int(merge_stats.get("archived", 0)), + "merge_count": int(merge_stats.get("merged", 0)), "conflict_count": int(conflict_stats.get("edges_added", 0)), } @@ -210,7 +223,11 @@ def build_runtime( maintenance: MaintenanceFn | None = None if config.runtime.loops.heartbeat_enabled: - maintenance = build_heartbeat_maintenance(concern_store, dcn_store) + maintenance = build_heartbeat_maintenance( + concern_store, + dcn_store, + maintenance=config.runtime.loops.maintenance, + ) runtime = OpenCOATRuntime( config.runtime, concern_store=concern_store, diff --git a/packages/opencoat-runtime/opencoat_runtime_daemon/workers/_concern_graph.py b/packages/opencoat-runtime/opencoat_runtime_daemon/workers/_concern_graph.py index 65be064..45f3102 100644 --- a/packages/opencoat-runtime/opencoat_runtime_daemon/workers/_concern_graph.py +++ b/packages/opencoat-runtime/opencoat_runtime_daemon/workers/_concern_graph.py @@ -1,43 +1,18 @@ -"""Shared helpers for heartbeat workers that walk the concern catalog.""" +"""Re-export catalog helpers from core (shared with :class:`DCNEvolver`).""" from __future__ import annotations +from opencoat_runtime_core.dcn.concern_catalog import ( + activation_keywords, + joinpoint_names, +) from opencoat_runtime_protocol import Concern, ConcernRelationType from opencoat_runtime_protocol.envelopes import ConcernRelation -def joinpoint_names(concern: Concern) -> frozenset[str]: - names: set[str] = set() - if concern.pointcut is not None: - for jp in concern.pointcut.joinpoints: - if isinstance(jp, str) and jp: - names.add(jp) - for pc in concern.pointcuts: - for jp in pc.joinpoints: - if isinstance(jp, str) and jp: - names.add(jp) - return frozenset(names) - - -def activation_keywords(concern: Concern) -> frozenset[str]: - keywords: set[str] = set() - if concern.pointcut is not None and concern.pointcut.match is not None: - raw = concern.pointcut.match.any_keywords - if raw: - keywords.update(k for k in raw if isinstance(k, str) and k) - for pc in concern.pointcuts: - if pc.match is None or not pc.match.any_keywords: - continue - keywords.update(k for k in pc.match.any_keywords if isinstance(k, str) and k) - return frozenset(keywords) - - def has_conflict_relation(concern: Concern, other_id: str) -> bool: for rel in concern.relations: - if ( - rel.target_concern_id == other_id - and rel.relation_type == ConcernRelationType.CONFLICTS_WITH - ): + if rel.target_concern_id == other_id and rel.relation_type == ConcernRelationType.CONFLICTS_WITH: return True return False diff --git a/packages/opencoat-runtime/opencoat_runtime_daemon/workers/merge_archiver.py b/packages/opencoat-runtime/opencoat_runtime_daemon/workers/merge_archiver.py index 83aeb2e..466d8ad 100644 --- a/packages/opencoat-runtime/opencoat_runtime_daemon/workers/merge_archiver.py +++ b/packages/opencoat-runtime/opencoat_runtime_daemon/workers/merge_archiver.py @@ -4,9 +4,39 @@ from datetime import datetime +from opencoat_runtime_core.dcn.evolution import DCNEvolver +from opencoat_runtime_core.ports import ConcernStore, DCNStore + from ._base import Worker class MergeArchiverWorker(Worker): - def run(self, now: datetime) -> dict: - raise NotImplementedError + """Run :class:`~opencoat_runtime_core.dcn.evolution.DCNEvolver` maintenance.""" + + def __init__( + self, + *, + concern_store: ConcernStore, + dcn_store: DCNStore, + evolver: DCNEvolver | None = None, + merge_min_keyword_overlap: int = 3, + archive_cold_decay_threshold: float = 0.85, + archive_cold_max_score: float = 0.15, + ) -> None: + self._evolver = evolver or DCNEvolver( + concern_store=concern_store, + dcn_store=dcn_store, + merge_min_keyword_overlap=merge_min_keyword_overlap, + archive_cold_decay_threshold=archive_cold_decay_threshold, + archive_cold_max_score=archive_cold_max_score, + ) + + def run(self, _now: datetime) -> dict: + result = self._evolver.run(_now) + return { + "merged": result.merged, + "archived": result.archived, + } + + +__all__ = ["MergeArchiverWorker"] diff --git a/packages/opencoat-runtime/opencoat_runtime_daemon/workers/meta_review_worker.py b/packages/opencoat-runtime/opencoat_runtime_daemon/workers/meta_review_worker.py index fc8bc94..eeb9564 100644 --- a/packages/opencoat-runtime/opencoat_runtime_daemon/workers/meta_review_worker.py +++ b/packages/opencoat-runtime/opencoat_runtime_daemon/workers/meta_review_worker.py @@ -4,9 +4,37 @@ from datetime import datetime +from opencoat_runtime_core.meta.evolution_control import DefaultEvolutionControl +from opencoat_runtime_core.ports import ConcernStore +from opencoat_runtime_protocol import ConcernKind + from ._base import Worker class MetaReviewWorker(Worker): - def run(self, now: datetime) -> dict: - raise NotImplementedError + """Inventory meta concerns and signal whether a governance review tick ran.""" + + def __init__(self, *, concern_store: ConcernStore) -> None: + self._concern_store = concern_store + + def run(self, _now: datetime) -> dict: + catalog = list(self._concern_store.iter_all()) + control = DefaultEvolutionControl(meta_concerns=catalog) + triggered = control.trigger_review() + capabilities = sorted( + { + str(cap) + for c in catalog + if (c.kind or ConcernKind.CONCERN.value) == ConcernKind.META_CONCERN.value + for cap in [getattr(c, "governance_capability", None)] + if cap is not None + } + ) + return { + "meta_concern_count": control.meta_concern_count, + "review_triggered": triggered, + "capabilities": capabilities, + } + + +__all__ = ["MetaReviewWorker"] diff --git a/packages/opencoat-runtime/tests/core/test_dcn_evolution.py b/packages/opencoat-runtime/tests/core/test_dcn_evolution.py new file mode 100644 index 0000000..cb75cfa --- /dev/null +++ b/packages/opencoat-runtime/tests/core/test_dcn_evolution.py @@ -0,0 +1,119 @@ +"""Tests for :class:`~opencoat_runtime_core.dcn.evolution.DCNEvolver`.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from opencoat_runtime_core.concern.lifecycle import ConcernLifecycleManager +from opencoat_runtime_core.dcn.evolution import DCNEvolver +from opencoat_runtime_protocol import ( + ActivationState, + Advice, + AdviceType, + Concern, + ConcernRelationType, + LifecycleState, + Pointcut, + WeavingLevel, + WeavingOperation, + WeavingPolicy, +) +from opencoat_runtime_protocol.envelopes import ConcernRelation, PointcutMatch +from opencoat_runtime_storage.memory import MemoryConcernStore, MemoryDCNStore + +_NOW = datetime(2026, 5, 19, 14, 0, tzinfo=UTC) + + +def _concern( + cid: str, + *, + keywords: list[str], + score: float = 0.6, + decay: float = 0.0, + lifecycle: str = LifecycleState.ACTIVE.value, +) -> Concern: + return Concern( + id=cid, + name=cid, + description=cid, + lifecycle_state=lifecycle, + activation_state=ActivationState(score=score, decay=decay, active=True), + pointcut=Pointcut( + joinpoints=["before_response"], + match=PointcutMatch(any_keywords=keywords), + ), + advice=Advice(type=AdviceType.RESPONSE_REQUIREMENT, content="x"), + weaving_policy=WeavingPolicy( + mode=WeavingOperation.INSERT, + level=WeavingLevel.OUTPUT_LEVEL, + target="response.body", + priority=0.5, + ), + ) + + +class TestDCNEvolver: + def test_heuristic_merge_archives_loser(self) -> None: + store = MemoryConcernStore() + dcn = MemoryDCNStore() + store.upsert(_concern("dup-a", keywords=["NVDA", "周三", "收盘"], score=0.8)) + store.upsert(_concern("dup-b", keywords=["NVDA", "周三", "分析"], score=0.3)) + evolver = DCNEvolver( + concern_store=store, + dcn_store=dcn, + lifecycle=ConcernLifecycleManager(concern_store=store, dcn_store=dcn), + merge_min_keyword_overlap=2, + ) + result = evolver.run(_NOW) + assert result.merged == 1 + assert store.get("dup-b") is not None + assert store.get("dup-b").lifecycle_state == LifecycleState.ARCHIVED.value + assert "dup-b" not in dcn + + def test_archive_cold_weakened(self) -> None: + store = MemoryConcernStore() + dcn = MemoryDCNStore() + store.upsert( + _concern( + "cold", + keywords=["x"], + score=0.1, + decay=0.9, + lifecycle=LifecycleState.WEAKENED.value, + ) + ) + evolver = DCNEvolver( + concern_store=store, + dcn_store=dcn, + lifecycle=ConcernLifecycleManager(concern_store=store, dcn_store=dcn), + ) + result = evolver.run(_NOW) + assert result.archived == 1 + assert store.get("cold").lifecycle_state == LifecycleState.ARCHIVED.value + + def test_declared_duplicates_relation_triggers_merge(self) -> None: + store = MemoryConcernStore() + dcn = MemoryDCNStore() + left = _concern("left", keywords=["a", "b", "c"], score=0.5) + right = _concern("right", keywords=["d"], score=0.9) + left = left.model_copy( + update={ + "relations": [ + ConcernRelation( + target_concern_id="right", + relation_type=ConcernRelationType.SPECIALIZES, + layer="semantic", + ) + ] + } + ) + store.upsert(left) + store.upsert(right) + evolver = DCNEvolver( + concern_store=store, + dcn_store=dcn, + lifecycle=ConcernLifecycleManager(concern_store=store, dcn_store=dcn), + ) + result = evolver.run(_NOW) + assert result.merged == 1 + assert store.get("right").lifecycle_state == LifecycleState.ARCHIVED.value diff --git a/packages/opencoat-runtime/tests/daemon/test_merge_archiver.py b/packages/opencoat-runtime/tests/daemon/test_merge_archiver.py new file mode 100644 index 0000000..db50f7c --- /dev/null +++ b/packages/opencoat-runtime/tests/daemon/test_merge_archiver.py @@ -0,0 +1,27 @@ +"""MergeArchiverWorker integration.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from opencoat_runtime_daemon.workers import MergeArchiverWorker +from opencoat_runtime_protocol import ActivationState, LifecycleState +from opencoat_runtime_storage.memory import MemoryConcernStore, MemoryDCNStore + +from .test_m6_workers import _concern + +_NOW = datetime(2026, 5, 19, 14, 0, tzinfo=UTC) + + +def test_merge_archiver_reports_counts() -> None: + store = MemoryConcernStore() + dcn = MemoryDCNStore() + cold = _concern("cold", keywords=["x"], lifecycle=LifecycleState.WEAKENED.value) + cold = cold.model_copy( + update={"activation_state": ActivationState(score=0.1, decay=0.9, active=True)} + ) + store.upsert(cold) + worker = MergeArchiverWorker(concern_store=store, dcn_store=dcn) + stats = worker.run(_NOW) + assert stats["archived"] == 1 + assert stats["merged"] == 0 diff --git a/packages/opencoat-runtime/tests/daemon/test_meta_review_worker.py b/packages/opencoat-runtime/tests/daemon/test_meta_review_worker.py new file mode 100644 index 0000000..392b401 --- /dev/null +++ b/packages/opencoat-runtime/tests/daemon/test_meta_review_worker.py @@ -0,0 +1,28 @@ +"""MetaReviewWorker.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from opencoat_runtime_daemon.workers import MetaReviewWorker +from opencoat_runtime_protocol import Concern, MetaConcern +from opencoat_runtime_protocol.envelopes import GovernanceCapability +from opencoat_runtime_storage.memory import MemoryConcernStore + +_NOW = datetime(2026, 5, 19, 14, 0, tzinfo=UTC) + + +def test_meta_review_counts_meta_concerns() -> None: + store = MemoryConcernStore() + store.upsert( + MetaConcern( + id="mc-1", + name="budget cap", + description="meta", + governance_capability=GovernanceCapability.BUDGET_CONTROL, + ) + ) + store.upsert(Concern(id="c-1", name="regular", description="d")) + stats = MetaReviewWorker(concern_store=store).run(_NOW) + assert stats["meta_concern_count"] == 1 + assert stats["review_triggered"] is True diff --git a/packages/opencoat-runtime/tests/soak/test_heartbeat_maintenance_soak.py b/packages/opencoat-runtime/tests/soak/test_heartbeat_maintenance_soak.py new file mode 100644 index 0000000..a3ac572 --- /dev/null +++ b/packages/opencoat-runtime/tests/soak/test_heartbeat_maintenance_soak.py @@ -0,0 +1,62 @@ +"""Short heartbeat maintenance soak (M6 PR4 harness).""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from opencoat_runtime_core import OpenCOATRuntime +from opencoat_runtime_core.llm import StubLLMClient +from opencoat_runtime_daemon.runtime_builder import build_heartbeat_maintenance +from opencoat_runtime_protocol import ( + ActivationState, + Advice, + AdviceType, + Concern, + LifecycleState, + Pointcut, + WeavingLevel, + WeavingOperation, + WeavingPolicy, +) +from opencoat_runtime_protocol.envelopes import PointcutMatch +from opencoat_runtime_storage.memory import MemoryConcernStore, MemoryDCNStore + + +def _concern(cid: str, *, keywords: list[str], score: float = 0.6) -> Concern: + return Concern( + id=cid, + name=cid, + description=cid, + lifecycle_state=LifecycleState.ACTIVE.value, + activation_state=ActivationState(score=score, decay=0.0, active=True), + pointcut=Pointcut( + joinpoints=["before_response"], + match=PointcutMatch(any_keywords=keywords), + ), + advice=Advice(type=AdviceType.RESPONSE_REQUIREMENT, content="x"), + weaving_policy=WeavingPolicy( + mode=WeavingOperation.INSERT, + level=WeavingLevel.OUTPUT_LEVEL, + target="response.body", + priority=0.5, + ), + ) + + +def test_heartbeat_maintenance_soak_ten_ticks() -> None: + """Run ten maintenance ticks without error — stand-in for 24h daemon soak.""" + store = MemoryConcernStore() + dcn = MemoryDCNStore() + store.upsert(_concern("a", keywords=["NVDA", "周三", "收盘"])) + store.upsert(_concern("b", keywords=["NVDA", "周三", "分析"], score=0.2)) + rt = OpenCOATRuntime( + concern_store=store, + dcn_store=dcn, + llm=StubLLMClient(), + heartbeat_maintenance=build_heartbeat_maintenance(store, dcn), + ) + ts = datetime(2026, 5, 19, tzinfo=UTC) + for _ in range(10): + report = rt.tick(ts) + assert report.candidate_count >= 1 + assert report.decay_count >= 0 From f0c08ef19f9292bb738e5227c11cf4fcf66b20d7 Mon Sep 17 00:00:00 2001 From: moss Date: Tue, 19 May 2026 10:10:39 +0700 Subject: [PATCH 2/5] docs(m6): document heartbeat maintenance, soak, and OpenClaw weaving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update root and package READMEs, milestones, daemon config example, and bridge weaving notes so M6 PR1/PR2–4 status matches shipped behavior. Co-authored-by: Cursor --- README.md | 39 ++++++++++++++++++- docs/07-mvp/m6-prerequisites-status.md | 6 ++- docs/07-mvp/milestones.md | 2 +- docs/config/daemon.yaml.example | 12 ++++-- examples/07_meta_governance_soak/README.md | 37 ++++++++++++------ examples/README.md | 1 + .../openclaw-opencoat-bridge/README.md | 15 +++++++ packages/opencoat-runtime/README.md | 27 +++++++++++++ 8 files changed, 121 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 8a9536a..d3865f0 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Pre-alpha. We are working through the milestones defined in | **M3** | Persistence (sqlite + jsonl replay) | ✅ complete — `SqliteConcernStore` ([PR-13 / #15](https://github.com/HyperdustLabs/OpenCOAT/pull/15)), `SqliteDCNStore` ([PR-14 / #16](https://github.com/HyperdustLabs/OpenCOAT/pull/16)), JSONL replay ([PR-15 / #18](https://github.com/HyperdustLabs/OpenCOAT/pull/18)), `examples/03_persistent_agent_demo` ([PR-16 / #20](https://github.com/HyperdustLabs/OpenCOAT/pull/20)) | | **M4** | Daemon + CLI + HTTP/JSON-RPC | ✅ complete — `build_runtime` ([PR-17 / #21](https://github.com/HyperdustLabs/OpenCOAT/pull/21)), in-proc JSON-RPC ([PR-18 / #22](https://github.com/HyperdustLabs/OpenCOAT/pull/22)), stdlib HTTP JSON-RPC ([PR-19 / #23](https://github.com/HyperdustLabs/OpenCOAT/pull/23)), daemon lifecycle ([PR-20 / #24](https://github.com/HyperdustLabs/OpenCOAT/pull/24)), `opencoat runtime up\|down\|status` ([PR-21 / #25](https://github.com/HyperdustLabs/OpenCOAT/pull/25)), `opencoat concern \| dcn \| inspect` ([PR-22 / #26](https://github.com/HyperdustLabs/OpenCOAT/pull/26)), `examples/06_long_running_daemon` ([PR-23 / #27](https://github.com/HyperdustLabs/OpenCOAT/pull/27)) | | **M5** | OpenClaw host plugin | ✅ complete — event map ([#28](https://github.com/HyperdustLabs/OpenCOAT/pull/28)), injection + spans ([#29](https://github.com/HyperdustLabs/OpenCOAT/pull/29)), tool guard ([#30](https://github.com/HyperdustLabs/OpenCOAT/pull/30)), memory bridge + hooks ([#31](https://github.com/HyperdustLabs/OpenCOAT/pull/31)), `examples/04_openclaw_with_runtime` ([#32](https://github.com/HyperdustLabs/OpenCOAT/pull/32)) | -| M6 | Heartbeat + Meta governance workers | pending | +| **M6** | Heartbeat + meta governance (decay, conflict scan, merge/archive, meta-review) | 🚧 in progress — PR1 ([#72](https://github.com/HyperdustLabs/OpenCOAT/pull/72)) merged; PR2–4 ([#73](https://github.com/HyperdustLabs/OpenCOAT/pull/73)) open | | M7 | Second host (langgraph/hermes) | pending | | M8 | Postgres + Helm/K8s | pending | @@ -268,6 +268,43 @@ LLM keys are not embedded in the unit files — use `opencoat configure llm` LLM keys from that env file at startup; add `EnvironmentFile=` only if you need variables outside that allow-list or a non-default env file path. +### Heartbeat + DCN maintenance (M6) + +With `runtime.loops.heartbeat_enabled: true` (bundled default), the daemon +starts a background scheduler (default **30s**) that calls +`OpenCOATRuntime.tick()`. Each tick runs: + +| Worker | Role | +| --- | --- | +| `DecayWorker` | Bumps `activation_state.decay`; weakens / archives stale concerns | +| `MergeArchiverWorker` | Merges duplicate concerns into the DCN; archives cold `weakened` rows | +| `ConflictScannerWorker` | Writes `conflicts_with` edges for background analysis (weave-time drops stay in `ConflictResolver`) | +| `MetaReviewWorker` | Inventories `meta_concern` rows (governance capabilities) | + +On startup you should see `heartbeat scheduler started` in the daemon log. +Tune overlap and cold-archive thresholds under `runtime.loops.maintenance` in +[`docs/config/daemon.yaml.example`](docs/config/daemon.yaml.example). + +**Verify prerequisites** (joinpoint hot path + RPC smoke): + +```bash +./scripts/verify-m6-prerequisites.sh # daemon on 127.0.0.1:7878 +``` + +**Hermetic soak** (10 heartbeat ticks, no 24h wait): + +```bash +uv run python -m pytest packages/opencoat-runtime/tests/soak/test_heartbeat_maintenance_soak.py -q +``` + +See [`examples/07_meta_governance_soak/README.md`](examples/07_meta_governance_soak/README.md) +and [`docs/07-mvp/m6-conflict-paths.md`](docs/07-mvp/m6-conflict-paths.md). + +**OpenClaw:** weave on user chat happens at `before_prompt_build` → `before_response`, +not on `message_received` (`on_user_input`). Optional chat mining: +`extract_from_chat` on `joinpoint.submit` or bridge config `extractOnUserMessage` +(see [`integrations/openclaw-opencoat-bridge/README.md`](integrations/openclaw-opencoat-bridge/README.md)). + --- ## Contributing diff --git a/docs/07-mvp/m6-prerequisites-status.md b/docs/07-mvp/m6-prerequisites-status.md index b529094..fb6c5c0 100644 --- a/docs/07-mvp/m6-prerequisites-status.md +++ b/docs/07-mvp/m6-prerequisites-status.md @@ -16,8 +16,10 @@ Re-run automation: `./scripts/verify-m6-prerequisites.sh` from repo root (daemon | PR slice | Status | Notes | | --- | --- | --- | | **PR1** decay + `ConflictScannerWorker` + scheduler | **merged** | [#72](https://github.com/HyperdustLabs/OpenCOAT/pull/72) | -| **PR2** merge + archive | **open** | `DCNEvolver`, `MergeArchiverWorker`, `HeartbeatMaintenance` config | +| **PR2** merge + archive | **open** ([#73](https://github.com/HyperdustLabs/OpenCOAT/pull/73)) | `DCNEvolver`, `MergeArchiverWorker`, `HeartbeatMaintenance` config | | **PR3** meta-review | **open** (same branch) | `MetaReviewWorker` + `DefaultEvolutionControl` inventory | | **PR4** soak + example | **open** (same branch) | `tests/soak/`, `examples/07_meta_governance_soak` | -**Next:** merge PR2 branch; optional 24h live soak on daemon. +**Docs:** root [`README.md`](../../README.md) (M6 table + heartbeat section), [`examples/README.md`](../../examples/README.md) row 07, [`packages/opencoat-runtime/README.md`](../../packages/opencoat-runtime/README.md). + +**Next:** merge [#73](https://github.com/HyperdustLabs/OpenCOAT/pull/73); optional 24h live soak on daemon. diff --git a/docs/07-mvp/milestones.md b/docs/07-mvp/milestones.md index 9819852..53cec89 100644 --- a/docs/07-mvp/milestones.md +++ b/docs/07-mvp/milestones.md @@ -10,6 +10,6 @@ Source: [`design/v0.2-system-design.md`](../design/v0.2-system-design.md) §12. | **M3 — Persistence** | sqlite backend + restart recovery + jsonl replay | DCN survives restart; `opencoat replay` reproduces a turn | ✅ — see [`README.md`](../../README.md) M3 row for per-PR breakdown | | **M4 — Daemon + CLI** | Daemon HTTP/JSON-RPC + `opencoat` CLI + host-sdk HTTP transport | Host calls daemon over socket and completes a turn | ✅ — see [`README.md`](../../README.md) M4 row for per-PR breakdown | | **M5 — OpenClaw plugin** | Full `host-plugins/openclaw` adapter | `04_openclaw_with_runtime` runs end-to-end | ✅ — see [`README.md`](../../README.md) M5 row for per-PR breakdown | -| **M6 — Heartbeat + Meta** | Decay / conflict / merge / archive / meta-review workers | 24h soak: DCN converges, token budget stable. Activation-time conflicts stay in `ConflictResolver` (already on `main`); M6 adds heartbeat workers + DCN evolution. | pending — [§5A prerequisites](./post-m5-roadmap.md#5a-m6-split-4-prs) then 4 PRs in [`post-m5-roadmap.md`](./post-m5-roadmap.md) | +| **M6 — Heartbeat + Meta** | Decay / conflict / merge / archive / meta-review workers | 24h soak: DCN converges, token budget stable. Activation-time conflicts stay in `ConflictResolver` (already on `main`); M6 adds heartbeat workers + DCN evolution. | 🚧 PR1 merged ([#72](https://github.com/HyperdustLabs/OpenCOAT/pull/72)); PR2–4 ([#73](https://github.com/HyperdustLabs/OpenCOAT/pull/73)) — see [`m6-prerequisites-status.md`](./m6-prerequisites-status.md) | | **M7 — Second host** | LangGraph (or Hermes) adapter; multi-host shared DCN | Two hosts share one DCN without conflict | pending | | **M8 — Postgres + K8s** | Postgres backend + helm chart | 7-day stability on a K8s cluster | pending | diff --git a/docs/config/daemon.yaml.example b/docs/config/daemon.yaml.example index b934f90..5bcf050 100644 --- a/docs/config/daemon.yaml.example +++ b/docs/config/daemon.yaml.example @@ -33,10 +33,16 @@ runtime: schema_version: "0.2" loops: - # How often the heartbeat worker runs DCN maintenance (lifecycle - # transitions, decay, archiving). 30s is plenty for human-scale - # agents; tighten to 5–10s for high-traffic deployments. + # How often the background scheduler calls OpenCOATRuntime.tick(). + # 30s is plenty for human-scale agents; tighten to 5–10s for high traffic. heartbeat_interval_seconds: 30 + # Set false to disable the background heartbeat thread (tests / embedded). + heartbeat_enabled: true + # M6 workers: decay, merge/archive, conflict scan tuning. + maintenance: + merge_min_keyword_overlap: 3 + archive_cold_decay_threshold: 0.85 + archive_cold_max_score: 0.15 budgets: # How many concerns can be active in one Concern Vector. Above # this the coordinator drops the lowest-priority ones. diff --git a/examples/07_meta_governance_soak/README.md b/examples/07_meta_governance_soak/README.md index 7991b3f..ebd6392 100644 --- a/examples/07_meta_governance_soak/README.md +++ b/examples/07_meta_governance_soak/README.md @@ -1,21 +1,36 @@ -# 07 — Meta governance heartbeat soak +# 07 — Meta governance heartbeat soak (M6) -Short stand-in for the M6 24h soak: exercises decay, merge/archive, and conflict -scan workers via repeated `OpenCOATRuntime.tick()` calls. +Stand-in for the M6 **24h soak** exit criterion: the daemon's background +scheduler calls `OpenCOATRuntime.tick()`, which runs decay, merge/archive, +conflict scan, and (optionally) meta-review workers. -## Run (hermetic) +## Layout -From repo root: +```text +examples/07_meta_governance_soak/ +└── README.md ← you are here (no main.py — use daemon + pytest soak) +``` + +## Hermetic (CI) + +From repo root — ten maintenance ticks in-process: ```bash uv run python -m pytest packages/opencoat-runtime/tests/soak/test_heartbeat_maintenance_soak.py -q ``` -## Run against a live daemon +## Live daemon + +1. `opencoat runtime up` — log should include `heartbeat scheduler started`. +2. Optional: tune `runtime.loops.maintenance` in `~/.opencoat/daemon.yaml` + (see [`docs/config/daemon.yaml.example`](../../docs/config/daemon.yaml.example)). +3. Leave running; periodically check `opencoat runtime snapshot` and + `opencoat dcn activation-log`. +4. Re-run [`scripts/verify-m6-prerequisites.sh`](../../scripts/verify-m6-prerequisites.sh) + after long runs. -1. `uv run opencoat runtime up` (confirm log: `heartbeat scheduler started`). -2. Leave running; watch concern count / DCN edges stabilize over hours. -3. Re-run `./scripts/verify-m6-prerequisites.sh` after long runs. +## Related docs -Full 24h soak harness and convergence metrics are tracked in -[`docs/07-mvp/post-m5-roadmap.md`](../../docs/07-mvp/post-m5-roadmap.md) (M6 PR4). +- [`docs/07-mvp/m6-conflict-paths.md`](../../docs/07-mvp/m6-conflict-paths.md) — activation-time vs background conflict paths +- [`docs/07-mvp/post-m5-roadmap.md`](../../docs/07-mvp/post-m5-roadmap.md) — M6 PR split +- Root [`README.md`](../../README.md) — heartbeat overview diff --git a/examples/README.md b/examples/README.md index 835749f..2b7cd5a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,6 +11,7 @@ End-to-end usage of the OpenCOAT Runtime. | 04 | `04_openclaw_with_runtime/` | M5 ([#32](https://github.com/HyperdustLabs/OpenCOAT/pull/32)) — toy OpenClaw bus + `install_hooks` + memory bridge | | 05 | `05_langgraph_with_runtime/` | M7 | | 06 | `06_long_running_daemon/` | M4 (PR-23) — programmatic Daemon ↔ HTTP JSON-RPC end-to-end | +| 07 | `07_meta_governance_soak/` | M6 — heartbeat maintenance soak (decay / merge / conflict scan) | Each example contains a `README.md` with the user story, a runnable `main.py`, and a frozen transcript so we can diff future runs. diff --git a/integrations/openclaw-opencoat-bridge/README.md b/integrations/openclaw-opencoat-bridge/README.md index 8573800..3e61151 100644 --- a/integrations/openclaw-opencoat-bridge/README.md +++ b/integrations/openclaw-opencoat-bridge/README.md @@ -232,6 +232,21 @@ curl -sS http://127.0.0.1:7878/rpc -H 'Content-Type: application/json' \ Requires **JoinpointDiscovery** (`expand_prompt_surface` on by default). Older daemons ignore `messages[]` and only match lifecycle names. +## Weaving expectations + +| Hook / joinpoint | Typical injections | +| --- | --- | +| `message_received` → `on_user_input` | Often **empty** if your concerns only list `before_response` / `user_message` | +| `before_prompt_build` → `before_response` | Main weave path when keywords match flattened prompt or discovered `user_message` rows | + +For background DCN maintenance (decay, merge, conflict edges), run the daemon with +heartbeat enabled — see root [`README.md`](../../README.md) § Heartbeat + DCN maintenance (M6). + +**Optional chat mining:** set `extractOnUserMessage: true` so the bridge passes +`extract_from_chat: true` on `joinpoint.submit` (requires a configured LLM on the +daemon). Extraction updates the concern store; it does not always add rows to +`injections` on that same submit. + ## Limitations (v0.1 bridge) - Prompt folding uses `prependSystemContext` only (not full dotted-path injector parity with Python `OpenClawInjector`). diff --git a/packages/opencoat-runtime/README.md b/packages/opencoat-runtime/README.md index d5b4bc7..2f379f1 100644 --- a/packages/opencoat-runtime/README.md +++ b/packages/opencoat-runtime/README.md @@ -38,11 +38,38 @@ pip install "opencoat-runtime[grpc]" # daemon gRPC transport ```bash opencoat --version opencoat concern import --demo +opencoat runtime up # sqlite + HTTP JSON-RPC + heartbeat scheduler (M6) opencoat-daemon --help ``` See for the full runtime guide. +## Heartbeat maintenance (M6) + +When the daemon runs with `runtime.loops.heartbeat_enabled: true`, a background +thread invokes `OpenCOATRuntime.tick()` every `heartbeat_interval_seconds` +(default 30). Each tick: + +1. **DecayWorker** — increases per-concern `activation_state.decay`, then weakens or archives. +2. **MergeArchiverWorker** — runs `DCNEvolver` to merge duplicates and archive cold weakened concerns. +3. **ConflictScannerWorker** — syncs `conflicts_with` edges into the DCN (background only; hot-path weave still uses `ConflictResolver`). +4. **MetaReviewWorker** — counts active `meta_concern` rows for governance review ticks. + +Configure thresholds under `runtime.loops.maintenance` in your daemon YAML +(see [`docs/config/daemon.yaml.example`](https://github.com/HyperdustLabs/OpenCOAT/blob/main/docs/config/daemon.yaml.example)). + +**JSON-RPC:** `joinpoint.submit` accepts optional `extract_from_chat: true` to run +`concern.extract` on user `messages[]` before weaving (needs a real LLM). + +**Tests / soak:** + +```bash +uv run python -m pytest packages/opencoat-runtime/tests/daemon/test_m6_workers.py -q +uv run python -m pytest packages/opencoat-runtime/tests/soak/test_heartbeat_maintenance_soak.py -q +``` + +**Prerequisites gate:** from repo root, `./scripts/verify-m6-prerequisites.sh` (daemon on `:7878`). + ## License Apache-2.0. From 6875f5cbca681c68afe2fe6ccf6f98286f0b4fb0 Mon Sep 17 00:00:00 2001 From: moss Date: Tue, 19 May 2026 10:11:41 +0700 Subject: [PATCH 3/5] fix(m6): skip stale catalog pairs during heuristic DCN merge Track live concern ids while iterating combination pairs so merges into an already-archived loser cannot corrupt three-or-more clusters. Co-authored-by: Cursor --- .../opencoat_runtime_core/dcn/evolution.py | 6 ++++ .../tests/core/test_dcn_evolution.py | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/opencoat-runtime/opencoat_runtime_core/dcn/evolution.py b/packages/opencoat-runtime/opencoat_runtime_core/dcn/evolution.py index 0520d47..198e582 100644 --- a/packages/opencoat-runtime/opencoat_runtime_core/dcn/evolution.py +++ b/packages/opencoat-runtime/opencoat_runtime_core/dcn/evolution.py @@ -96,6 +96,8 @@ def _merge_declared(self, catalog: list[Concern]) -> int: merged = 0 seen_pairs: set[tuple[str, str]] = set() for concern in catalog: + if concern.id not in index: + continue for rel in concern.relations: if rel.relation_type not in _MERGE_RELATIONS: continue @@ -116,7 +118,10 @@ def _merge_declared(self, catalog: list[Concern]) -> int: def _merge_heuristic(self, catalog: list[Concern]) -> int: merged = 0 + live_ids = {c.id for c in catalog} for left, right in combinations(list(catalog), 2): + if left.id not in live_ids or right.id not in live_ids: + continue if not joinpoint_names(left) & joinpoint_names(right): continue if len(activation_keywords(left) & activation_keywords(right)) < self._min_overlap: @@ -124,6 +129,7 @@ def _merge_heuristic(self, catalog: list[Concern]) -> int: loser_id, winner_id = self._pick_by_score(left, right) if self._apply_merge(loser_id, winner_id): merged += 1 + live_ids.discard(loser_id) catalog[:] = [c for c in catalog if c.id != loser_id] return merged diff --git a/packages/opencoat-runtime/tests/core/test_dcn_evolution.py b/packages/opencoat-runtime/tests/core/test_dcn_evolution.py index cb75cfa..6afd7a4 100644 --- a/packages/opencoat-runtime/tests/core/test_dcn_evolution.py +++ b/packages/opencoat-runtime/tests/core/test_dcn_evolution.py @@ -117,3 +117,31 @@ def test_declared_duplicates_relation_triggers_merge(self) -> None: result = evolver.run(_NOW) assert result.merged == 1 assert store.get("right").lifecycle_state == LifecycleState.ARCHIVED.value + + def test_heuristic_merge_three_way_cluster(self) -> None: + """Stale combination pairs must not merge into an already-archived loser.""" + store = MemoryConcernStore() + dcn = MemoryDCNStore() + kw = ["NVDA", "周三", "收盘", "分析"] + store.upsert(_concern("cluster-a", keywords=kw, score=0.5)) + store.upsert(_concern("cluster-b", keywords=kw, score=0.6)) + store.upsert(_concern("cluster-c", keywords=kw, score=0.4)) + evolver = DCNEvolver( + concern_store=store, + dcn_store=dcn, + lifecycle=ConcernLifecycleManager(concern_store=store, dcn_store=dcn), + merge_min_keyword_overlap=3, + ) + result = evolver.run(_NOW) + assert result.merged == 2 + winner = store.get("cluster-b") + assert winner is not None + assert winner.lifecycle_state in { + LifecycleState.ACTIVE.value, + LifecycleState.REINFORCED.value, + } + for loser_id in ("cluster-a", "cluster-c"): + loser = store.get(loser_id) + assert loser is not None + assert loser.lifecycle_state == LifecycleState.ARCHIVED.value + assert loser_id not in dcn From b9ef2df8a715a11cb307ca1da58a994e5ca0294b Mon Sep 17 00:00:00 2001 From: moss Date: Tue, 19 May 2026 10:14:39 +0700 Subject: [PATCH 4/5] chore: sort meta __all__ for ruff RUF022 (CI) Co-authored-by: Cursor --- .../opencoat-runtime/opencoat_runtime_core/meta/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencoat-runtime/opencoat_runtime_core/meta/__init__.py b/packages/opencoat-runtime/opencoat_runtime_core/meta/__init__.py index 77f278c..3624919 100644 --- a/packages/opencoat-runtime/opencoat_runtime_core/meta/__init__.py +++ b/packages/opencoat-runtime/opencoat_runtime_core/meta/__init__.py @@ -17,8 +17,8 @@ "ActivationControl", "BudgetControl", "ConflictResolution", - "DefaultLifecycleControl", "DefaultEvolutionControl", + "DefaultLifecycleControl", "EvolutionControl", "ExtractionControl", "LifecycleControl", From 7be88c7ad9a3344e556417d2e41841ac0fa3969c Mon Sep 17 00:00:00 2001 From: moss Date: Tue, 19 May 2026 10:17:49 +0700 Subject: [PATCH 5/5] chore: ruff format _concern_graph.py (CI) Co-authored-by: Cursor --- .../opencoat_runtime_daemon/workers/_concern_graph.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencoat-runtime/opencoat_runtime_daemon/workers/_concern_graph.py b/packages/opencoat-runtime/opencoat_runtime_daemon/workers/_concern_graph.py index 45f3102..3e80ef3 100644 --- a/packages/opencoat-runtime/opencoat_runtime_daemon/workers/_concern_graph.py +++ b/packages/opencoat-runtime/opencoat_runtime_daemon/workers/_concern_graph.py @@ -12,7 +12,10 @@ def has_conflict_relation(concern: Concern, other_id: str) -> bool: for rel in concern.relations: - if rel.target_concern_id == other_id and rel.relation_type == ConcernRelationType.CONFLICTS_WITH: + if ( + rel.target_concern_id == other_id + and rel.relation_type == ConcernRelationType.CONFLICTS_WITH + ): return True return False