Skip to content

Commit 0f2bc2a

Browse files
feat(doctor): detect competing memory-care queue writers
Warn when more than one enabled producer can write the same memory-care scan-latest.json / refresh-queue.json, with a read-only migration plan. This is diagnostic only: it never disables a cron job, edits a card, or mutates a queue. - Classify producers by what they write: the Brigade memory-care scan writer (`brigade memory care scan`, resolved to memory-care _output_dir) and the legacy OpenClaw "Card Decay Scanner (Daily)" cron. Reader scanners (import-issues / import memory-refresh) and the "Card Decay Auto-Refresh (Safe)" consumer are never counted as writers. - Compare resolved output directories (expanduser + resolve; reuse _scanner_output_path) and emit exactly one WARN per shared directory, naming both producers and the migration order. - Gate the host-global legacy cron producer on the target actually being a memory-care workspace, so cron state does not bleed onto unrelated repos. - Document `.brigade/memory-care/decay/` as the current default write location and the legacy `memory/cards/decay/` fallback. Closes #403 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f588957 commit 0f2bc2a

5 files changed

Lines changed: 432 additions & 2 deletions

File tree

docs/memory-care.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,12 @@ Safe fix plans are copied into memory-care imports as metadata so the work inbox
6363
`brigade memory care scan` writes:
6464

6565
```text
66-
memory/cards/decay/scan-latest.json
67-
memory/cards/decay/refresh-queue.json
66+
.brigade/memory-care/decay/scan-latest.json
67+
.brigade/memory-care/decay/refresh-queue.json
6868
```
6969

70+
Before 0.9.1, scans wrote to `memory/cards/decay/`. Readers still fall back to that location when the default path has no `scan-latest.json` and the legacy path has existing output, so existing workspaces keep their queue continuity.
71+
7072
Queue entries include card identity, issue type, severity, priority, safe summary, evidence references, suggested refresh action, acceptance criteria, source item key, source fingerprint, and safe fix-plan metadata when available. `brigade memory care import-issues` imports those entries as source `memory-care` task imports with dedupe and dismissed-until-changed behavior.
7173

7274
## Boundary

src/brigade/doctor.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66
import os
77
import re
8+
import shlex
89
import shutil
910
from datetime import date, datetime, timezone
1011
from pathlib import Path
@@ -80,6 +81,7 @@ def memory_station_checks(ctx: DoctorContext) -> List[CheckResult]:
8081
checks.extend(_check_memory_cards(ctx.target))
8182
checks.extend(_check_memory_index(ctx.target))
8283
checks.extend(_check_memory_care(ctx.target))
84+
checks.extend(_check_memory_care_producer_collision(ctx.target))
8385
return checks
8486

8587

@@ -627,6 +629,104 @@ def _check_memory_care(target: Path) -> List[CheckResult]:
627629
return results
628630

629631

632+
def _is_memory_care_scan_command(command: str) -> bool:
633+
"""Return True when a scanner command is the Brigade memory-care writer."""
634+
try:
635+
parts = shlex.split(command.strip())
636+
except ValueError:
637+
return False
638+
return len(parts) >= 4 and parts[:4] == ["brigade", "memory", "care", "scan"]
639+
640+
641+
def _check_memory_care_producer_collision(target: Path) -> List[CheckResult]:
642+
"""Warn when two enabled producers can write the same memory-care artifact.
643+
644+
This is a read-only migration-planning check. It inspects configured
645+
producers, resolves their output destinations, and reports collisions. It
646+
never disables a cron job, edits a card, or mutates a queue file.
647+
"""
648+
from . import memory_cmd
649+
from .work_cmd.config import _scanner_output_path
650+
651+
target = target.expanduser().resolve()
652+
producer_dirs: dict[Path, set[str]] = {}
653+
654+
def _add(dir_path: Path, label: str) -> None:
655+
producer_dirs.setdefault(dir_path, set()).add(label)
656+
657+
p1_exists = False
658+
config_dir: Path | None = None
659+
660+
# Brigade memory-care scanner configured for this workspace.
661+
if memory_cmd.config_path(target).is_file():
662+
p1_exists = True
663+
try:
664+
config = memory_cmd.load_config(target) or memory_cmd.MemoryCareConfig()
665+
except ValueError:
666+
config = memory_cmd.MemoryCareConfig()
667+
config_dir = memory_cmd._output_dir(target, config).expanduser().resolve()
668+
_add(config_dir, "brigade memory-care")
669+
670+
# Enabled Brigade scanners whose command is the memory-care writer.
671+
scanners_path = target / ".brigade" / "scanners.toml"
672+
if scanners_path.is_file():
673+
try:
674+
data = memory_cmd.tomllib.loads(scanners_path.read_text())
675+
except (memory_cmd.tomllib.TOMLDecodeError, OSError):
676+
data = {}
677+
if isinstance(data, dict):
678+
for scanner in data.get("scanner", []):
679+
if not isinstance(scanner, dict):
680+
continue
681+
if not scanner.get("enabled", False):
682+
continue
683+
command = scanner.get("command")
684+
if not isinstance(command, str) or not _is_memory_care_scan_command(command):
685+
continue
686+
p1_exists = True
687+
output_path = _scanner_output_path(target, scanner)
688+
if output_path is None:
689+
continue
690+
scanner_dir = output_path.expanduser().resolve().parent
691+
if scanner_dir == config_dir:
692+
# Same producer as the configured writer; do not double-count.
693+
continue
694+
_add(scanner_dir, f"brigade memory-care scanner {scanner.get('id', 'unknown')}")
695+
696+
# Legacy OpenClaw cron producer (only when this target is a memory-care workspace).
697+
if p1_exists:
698+
jobs_path = Path.home() / ".openclaw" / "cron" / "jobs.json"
699+
if jobs_path.is_file():
700+
try:
701+
data = json.loads(jobs_path.read_text())
702+
except (OSError, json.JSONDecodeError):
703+
data = {}
704+
jobs = data.get("jobs", []) if isinstance(data, dict) else []
705+
legacy_job = _find_job(jobs, "Card Decay Scanner (Daily)")
706+
if legacy_job is not None and legacy_job.get("enabled", False):
707+
legacy_dir = (target / "memory/cards/decay").expanduser().resolve()
708+
_add(legacy_dir, "legacy Card Decay Scanner (Daily)")
709+
710+
results: List[CheckResult] = []
711+
for dir_path, labels in sorted(producer_dirs.items()):
712+
if len(labels) < 2:
713+
continue
714+
try:
715+
rel = dir_path.relative_to(target)
716+
except ValueError:
717+
rel = dir_path.name
718+
labels_str = ", ".join(sorted(labels))
719+
detail = (
720+
f"writer collision on `{rel}`: enabled producers are {labels_str}. "
721+
"Migration order: (1) verify the Brigade output with `brigade memory care status`; "
722+
"(2) point consumers at the Brigade output location; "
723+
"(3) disable the legacy 'Card Decay Scanner (Daily)' cron job. "
724+
"This check is read-only; no queue files, cards, or cron jobs were changed."
725+
)
726+
results.append((WARN, "memory-care: producer collision", detail))
727+
return results
728+
729+
630730
def _check_memory_care_scan_freshness(scan: Path, scan_date: object) -> CheckResult:
631731
if not scan_date:
632732
return (WARN, "memory-care: scan freshness", f"scan_date missing in {scan}")

src/brigade/memory_cmd.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,19 @@ def _read_output_dir(target: Path, config: MemoryCareConfig) -> Path:
7979
return output
8080

8181

82+
def memory_care_producer_artifacts(target: Path, config: MemoryCareConfig) -> list[tuple[Path, str]]:
83+
"""Return the absolute artifact paths produced by the Brigade memory-care scanner.
84+
85+
Uses the write location (`_output_dir`), not the reader fallback, so
86+
producer-collision checks compare actual write destinations.
87+
"""
88+
output = _output_dir(target, config)
89+
return [
90+
(output / "scan-latest.json", "brigade memory-care"),
91+
(output / "refresh-queue.json", "brigade memory-care"),
92+
]
93+
94+
8295
def _scan_path(target: Path, config: MemoryCareConfig) -> Path:
8396
return _read_output_dir(target, config) / "scan-latest.json"
8497

0 commit comments

Comments
 (0)