forked from jason/echo
2ddf199850
- vault_lock gains bounded retry/backoff (CHORUS_LOCK_RETRIES=4, CHORUS_LOCK_BACKOFF=0.5s); atomic_index_update and recall-index upkeep retry acquisition — unlocked fallback only after exhausted retries, loudly, so an already-written note is never stranded unindexed. - Duplicate-gate candidates carry the existing note's author; STOP message says whose entity the capture collided with. - recall --author <member> filters hits + linked context to one member's notes; recall briefs and --json surface the author: stamp. - load --all-members appends a group-wide view (every member's scope + last-session heartbeat); roster parsing accepts folders as trailing-slash file entries (the REST API's actual shape). - operating-contract.md concurrency section rewritten for N members. - Two-member e2e suite committed as eval/test_multimember.py (26 checks incl. all four Phase-4 behaviors). - Manifest -> 2.0.0-alpha.4; rebuilt chorus-memory.plugin. Verified: 25/25 unit, scaffold, routing-sync, 5 mock e2e suites (incl. new multimember), run_eval metrics unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
149 lines
7.7 KiB
Python
149 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
|
"""test_multimember.py — two members share one vault (end-to-end vs the mock).
|
|
|
|
Covers the schema-5 group behavior: jason bootstraps; alice self-onboards
|
|
via load; both capture; the daily log interleaves member-tagged lines; scopes are
|
|
independent; triage sees member+group inboxes; lint is clean; schema is 5."""
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
SCRIPTS = REPO / "chorus-memory.plugin.src" / "skills" / "chorus-memory" / "scripts"
|
|
PORT = 8807
|
|
BASE = f"http://127.0.0.1:{PORT}"
|
|
fails = []
|
|
|
|
|
|
def check(name, cond, detail=""):
|
|
print(("ok " if cond else "FAIL") + f" {name}" + (f" — {detail}" if detail and not cond else ""))
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
def run(member, script, *args, stdin=None):
|
|
env = {k: v for k, v in os.environ.items() if not k.startswith("CHORUS_")}
|
|
env.update({"CHORUS_BASE": BASE, "CHORUS_KEY": "k", "CHORUS_GROUP": "MPM",
|
|
"CHORUS_MEMBER": member, "CHORUS_TODAY": "2026-07-21"})
|
|
return subprocess.run([sys.executable, str(SCRIPTS / script), *args],
|
|
capture_output=True, text=True, env=env, input=stdin)
|
|
|
|
|
|
def vault(path):
|
|
try:
|
|
return urllib.request.urlopen(f"{BASE}/vault/{path}", timeout=2).read().decode()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
mock = subprocess.Popen([sys.executable, str(REPO / "eval/mock_olrapi.py"), "--port", str(PORT)],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
for _ in range(50):
|
|
try:
|
|
urllib.request.urlopen(f"{BASE}/__debug__reset", data=b"", timeout=1)
|
|
break
|
|
except Exception:
|
|
time.sleep(0.1)
|
|
|
|
try:
|
|
# --- jason stands up the vault ---
|
|
r = run("jason", "bootstrap.py")
|
|
check("jason bootstraps", r.returncode == 0, r.stderr[:200])
|
|
marker = vault("_agent/chorus-vault.md") or ""
|
|
check("marker is schema 5", "schema_version: 5" in marker, marker[:120])
|
|
check("group profile seeded + jason rostered",
|
|
"- jason (joined 2026-07-21)" in (vault("_agent/memory/semantic/group-profile.md") or ""))
|
|
check("jason anchors exist", vault("_agent/members/jason/preferences.md") is not None
|
|
and "author: jason" in vault("_agent/members/jason/preferences.md"))
|
|
check("group inbox seeded", vault("inbox/captures/group.md") is not None)
|
|
|
|
# --- alice self-onboards via load ---
|
|
r = run("alice", "chorus.py", "load")
|
|
check("alice load self-onboards", "self-onboarding: member 'alice'" in r.stdout, r.stdout[:600])
|
|
check("alice anchors created", vault("_agent/members/alice/preferences.md") is not None)
|
|
profile = vault("_agent/memory/semantic/group-profile.md") or ""
|
|
check("alice rostered in group profile", "- alice (joined 2026-07-21)" in profile, profile[-200:])
|
|
r = run("alice", "chorus.py", "load")
|
|
check("second alice load does NOT re-onboard", "self-onboarding" not in r.stdout)
|
|
check("load reads group profile + both inboxes",
|
|
"group-profile" in r.stdout and "my-inbox" in r.stdout and "group-inbox" in r.stdout)
|
|
|
|
# --- both capture; daily log interleaves member-tagged lines ---
|
|
run("jason", "chorus.py", "capture", "Acme Rockets", "--kind", "company")
|
|
run("alice", "chorus.py", "capture", "Widget Live", "--kind", "project")
|
|
daily = vault("journal/daily/2026-07-21.md") or ""
|
|
check("daily log tags jason", "[jason]: created company" in daily, daily[-300:])
|
|
check("daily log tags alice", "[alice]: created project" in daily, daily[-300:])
|
|
check("captures carry authors",
|
|
"author: jason" in (vault("resources/companies/acme-rockets.md") or "")
|
|
and "author: alice" in (vault("projects/active/widget-live.md")
|
|
or vault("projects/incubating/widget-live.md") or ""))
|
|
|
|
# --- alice updates jason's entity: dated bullet attributes alice ---
|
|
run("alice", "chorus.py", "capture", "Acme Rockets", "--kind", "company", "-",
|
|
stdin="Alice met their CTO.\n")
|
|
acme = vault("resources/companies/acme-rockets.md") or ""
|
|
check("cross-member update attributed", "- 2026-07-21 [alice]: Alice met their CTO." in acme,
|
|
acme[-300:])
|
|
|
|
# --- scopes are independent (raw files: the basic mock's heading-PATCH only
|
|
# appends, so real replace semantics live in test_patch_semantics; here we
|
|
# prove the writes land in EACH member's own file and nowhere else) ---
|
|
r1 = run("jason", "chorus.py", "scope", "set", "fiber buildout")
|
|
r2 = run("alice", "chorus.py", "scope", "set", "widget launch")
|
|
jctx = vault("_agent/members/jason/current-context.md") or ""
|
|
actx = vault("_agent/members/alice/current-context.md") or ""
|
|
check("scope set succeeds per member", r1.returncode == 0 and r2.returncode == 0,
|
|
r1.stderr[:150] + r2.stderr[:150])
|
|
check("scopes independent", "fiber buildout" in jctx and "widget launch" in actx
|
|
and "widget launch" not in jctx and "fiber buildout" not in actx)
|
|
|
|
# --- member inbox + group inbox; triage lists both ---
|
|
run("jason", "chorus.py", "capture", "look into starlink backhaul", "--inbox")
|
|
run("jason", "chorus.py", "append", "inbox/captures/group.md",
|
|
"- 2026-07-21 [jason]: someone should test the new proxy")
|
|
r = run("alice", "chorus.py", "triage", "--list")
|
|
check("alice's triage sees the group item", "test the new proxy" in r.stdout, r.stdout[:400])
|
|
check("alice's triage does NOT see jason's personal item",
|
|
"starlink" not in r.stdout, r.stdout[:400])
|
|
|
|
# --- session logs namespaced; lint clean ---
|
|
run("jason", "chorus.py", "put", "_agent/sessions/jason/2026-07-21-0900-fiber.md", "-",
|
|
stdin="---\ntype: session-log\nstatus: done\ncreated: 2026-07-21\nupdated: 2026-07-21\n"
|
|
"tags: [session]\nagent_written: true\nauthor: jason\nsource_notes: []\n---\n# Fiber session\n")
|
|
# --- Phase 4: gate names the existing entity's author ---
|
|
r = run("alice", "chorus.py", "capture", "Acme Rockets Inc", "--kind", "company")
|
|
check("gate blocks near-duplicate", r.returncode == 76, f"rc={r.returncode}")
|
|
check("gate names the author", "created by jason" in r.stdout, r.stdout[:300])
|
|
|
|
# --- Phase 4: recall --author filters to one member's notes ---
|
|
r = run("jason", "chorus.py", "recall", "Acme", "--author", "jason")
|
|
check("recall --author keeps jason's note", "acme-rockets" in r.stdout, r.stdout[:300])
|
|
r = run("jason", "chorus.py", "recall", "Acme", "--author", "alice")
|
|
check("recall --author filters out others", "acme-rockets" not in r.stdout.split("Linked context")[0].split("Primary")[-1], r.stdout[:400])
|
|
r = run("jason", "chorus.py", "recall", "Acme")
|
|
check("recall surfaces author stamp", "author: jason" in r.stdout, r.stdout[:400])
|
|
|
|
# --- Phase 4: load --all-members group view ---
|
|
run("jason", "chorus.py", "put", "_agent/members/jason/heartbeat.md", "-",
|
|
stdin="_agent/sessions/jason/2026-07-21-0900-fiber.md @ 2026-07-21T14:00:00Z\n")
|
|
r = run("alice", "chorus.py", "load", "--all-members")
|
|
check("load --all-members lists both", "group: all members (2)" in r.stdout
|
|
and "alice (me)" in r.stdout and "jason" in r.stdout, r.stdout[-600:])
|
|
check("all-members shows jason's heartbeat", "2026-07-21-0900-fiber" in r.stdout,
|
|
r.stdout[-600:])
|
|
|
|
run("jason", "sweep.py", "--apply") # index the seeded anchors (documented bootstrap→sweep flow)
|
|
run("jason", "chorus.py", "link", "_agent/memory/semantic/group-profile.md",
|
|
"resources/companies/acme-rockets.md")
|
|
r = run("jason", "vault_lint.py")
|
|
check("lint clean on two-member vault", r.returncode == 0, r.stdout[-500:])
|
|
finally:
|
|
mock.terminate()
|
|
|
|
print()
|
|
sys.exit(1 if fails else print("all multimember tests passed") or 0)
|