Implements the Lazarus Pit v2.0 foundation: - cell.py: Cell model, SQLite registry, filesystem packager with per-cell HERMES_HOME - operator_ctl.py: summon, invite, team, status, close, destroy commands - backends/base.py + process_backend.py: backend abstraction with process implementation - cli.py: operator CLI entrypoint - tests/test_cell.py: 13 tests for isolation, registry, TTL, lifecycle - README.md: quick start and architecture invariants All acceptance criteria for #274 and #269 are scaffolded and tested. 13 tests pass.
This commit is contained in:
55
lazarus/README.md
Normal file
55
lazarus/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Lazarus Pit v2.0 — Cells, Invites, and Teaming
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Summon a single agent
|
||||
python3 -m lazarus.cli summon allegro Timmy_Foundation/timmy-config#262
|
||||
|
||||
# Form a team
|
||||
python3 -m lazarus.cli team allegro+ezra Timmy_Foundation/timmy-config#262
|
||||
|
||||
# Invite a guest bot
|
||||
python3 -m lazarus.cli invite <cell_id> bot:kimi observer
|
||||
|
||||
# Check status
|
||||
python3 -m lazarus.cli status
|
||||
|
||||
# Close / destroy
|
||||
python3 -m lazarus.cli close <cell_id>
|
||||
python3 -m lazarus.cli destroy <cell_id>
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
lazarus/
|
||||
├── cell.py # Cell model, registry, filesystem packager
|
||||
├── operator_ctl.py # summon, invite, team, status, close, destroy
|
||||
├── cli.py # CLI entrypoint
|
||||
├── backends/
|
||||
│ ├── base.py # Backend abstraction
|
||||
│ └── process_backend.py
|
||||
└── tests/
|
||||
└── test_cell.py # Isolation + lifecycle tests
|
||||
```
|
||||
|
||||
## Design Invariants
|
||||
|
||||
1. **One cell, one project target**
|
||||
2. **Cells cannot read each other's `HERMES_HOME` by default**
|
||||
3. **Guests join with least privilege**
|
||||
4. **Destroyed cells are scrubbed from disk**
|
||||
5. **All cell state is persisted in SQLite registry**
|
||||
|
||||
## Cell Lifecycle
|
||||
|
||||
```
|
||||
proposed -> active -> idle -> closing -> archived -> destroyed
|
||||
```
|
||||
|
||||
## Roles
|
||||
|
||||
- `executor` — read/write code, run tools, push commits
|
||||
- `observer` — read-only access
|
||||
- `director` — can close cells and publish back to Gitea
|
||||
5
lazarus/__init__.py
Normal file
5
lazarus/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Lazarus Pit v2.0 — Multi-Agent Resurrection & Teaming Arena."""
|
||||
from .cell import Cell, CellState, CellRole
|
||||
from .operator_ctl import OperatorCtl
|
||||
|
||||
__all__ = ["Cell", "CellState", "CellRole", "OperatorCtl"]
|
||||
5
lazarus/backends/__init__.py
Normal file
5
lazarus/backends/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Cell execution backends for Lazarus Pit."""
|
||||
from .base import Backend, BackendResult
|
||||
from .process_backend import ProcessBackend
|
||||
|
||||
__all__ = ["Backend", "BackendResult", "ProcessBackend"]
|
||||
50
lazarus/backends/base.py
Normal file
50
lazarus/backends/base.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Backend abstraction for Lazarus Pit cells.
|
||||
|
||||
Every backend must implement the same contract so that cells,
|
||||
teams, and operators do not need to know how a cell is actually running.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackendResult:
|
||||
success: bool
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
pid: Optional[int] = None
|
||||
message: str = ""
|
||||
|
||||
|
||||
class Backend(ABC):
|
||||
"""Abstract cell backend."""
|
||||
|
||||
name: str = "abstract"
|
||||
|
||||
@abstractmethod
|
||||
def spawn(self, cell_id: str, hermes_home: str, command: list, env: Optional[dict] = None) -> BackendResult:
|
||||
"""Spawn the cell process/environment."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def probe(self, cell_id: str) -> BackendResult:
|
||||
"""Check if the cell is alive and responsive."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def logs(self, cell_id: str, tail: int = 50) -> BackendResult:
|
||||
"""Return recent logs from the cell."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def close(self, cell_id: str) -> BackendResult:
|
||||
"""Gracefully close the cell."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def destroy(self, cell_id: str) -> BackendResult:
|
||||
"""Forcefully destroy the cell and all runtime state."""
|
||||
...
|
||||
97
lazarus/backends/process_backend.py
Normal file
97
lazarus/backends/process_backend.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Process backend for Lazarus Pit.
|
||||
|
||||
Fastest spawn/teardown. Isolation comes from unique HERMES_HOME
|
||||
and independent Python process. No containers required.
|
||||
"""
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .base import Backend, BackendResult
|
||||
|
||||
|
||||
class ProcessBackend(Backend):
|
||||
name = "process"
|
||||
|
||||
def __init__(self, log_dir: Optional[str] = None):
|
||||
self.log_dir = Path(log_dir or "/tmp/lazarus-logs")
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._pids: dict = {}
|
||||
|
||||
def _log_path(self, cell_id: str) -> Path:
|
||||
return self.log_dir / f"{cell_id}.log"
|
||||
|
||||
def spawn(self, cell_id: str, hermes_home: str, command: list, env: Optional[dict] = None) -> BackendResult:
|
||||
log_path = self._log_path(cell_id)
|
||||
run_env = dict(os.environ)
|
||||
run_env["HERMES_HOME"] = hermes_home
|
||||
if env:
|
||||
run_env.update(env)
|
||||
|
||||
try:
|
||||
with open(log_path, "a") as log_file:
|
||||
proc = subprocess.Popen(
|
||||
command,
|
||||
env=run_env,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=hermes_home,
|
||||
)
|
||||
self._pids[cell_id] = proc.pid
|
||||
return BackendResult(
|
||||
success=True,
|
||||
pid=proc.pid,
|
||||
message=f"Spawned {cell_id} with pid {proc.pid}",
|
||||
)
|
||||
except Exception as e:
|
||||
return BackendResult(success=False, stderr=str(e), message=f"Spawn failed: {e}")
|
||||
|
||||
def probe(self, cell_id: str) -> BackendResult:
|
||||
pid = self._pids.get(cell_id)
|
||||
if not pid:
|
||||
return BackendResult(success=False, message="No known pid for cell")
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return BackendResult(success=True, pid=pid, message="Process is alive")
|
||||
except OSError:
|
||||
return BackendResult(success=False, pid=pid, message="Process is dead")
|
||||
|
||||
def logs(self, cell_id: str, tail: int = 50) -> BackendResult:
|
||||
log_path = self._log_path(cell_id)
|
||||
if not log_path.exists():
|
||||
return BackendResult(success=True, stdout="", message="No logs yet")
|
||||
try:
|
||||
lines = log_path.read_text().splitlines()
|
||||
return BackendResult(
|
||||
success=True,
|
||||
stdout="\n".join(lines[-tail:]),
|
||||
message=f"Returned last {tail} lines",
|
||||
)
|
||||
except Exception as e:
|
||||
return BackendResult(success=False, stderr=str(e), message=f"Log read failed: {e}")
|
||||
|
||||
def close(self, cell_id: str) -> BackendResult:
|
||||
pid = self._pids.get(cell_id)
|
||||
if not pid:
|
||||
return BackendResult(success=False, message="No known pid for cell")
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
return BackendResult(success=True, pid=pid, message="Sent SIGTERM")
|
||||
except OSError as e:
|
||||
return BackendResult(success=False, stderr=str(e), message=f"Close failed: {e}")
|
||||
|
||||
def destroy(self, cell_id: str) -> BackendResult:
|
||||
res = self.close(cell_id)
|
||||
log_path = self._log_path(cell_id)
|
||||
if log_path.exists():
|
||||
log_path.unlink()
|
||||
self._pids.pop(cell_id, None)
|
||||
return BackendResult(
|
||||
success=res.success,
|
||||
pid=res.pid,
|
||||
message=f"Destroyed {cell_id}",
|
||||
)
|
||||
240
lazarus/cell.py
Normal file
240
lazarus/cell.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Cell model for Lazarus Pit v2.0.
|
||||
|
||||
A cell is a bounded execution/living space for one or more participants
|
||||
on a single project target. Cells are isolated by filesystem, credentials,
|
||||
and optionally process/container boundaries.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
|
||||
class CellState(Enum):
|
||||
PROPOSED = "proposed"
|
||||
ACTIVE = "active"
|
||||
IDLE = "idle"
|
||||
CLOSING = "closing"
|
||||
ARCHIVED = "archived"
|
||||
DESTROYED = "destroyed"
|
||||
|
||||
|
||||
class CellRole(Enum):
|
||||
EXECUTOR = "executor" # Can read/write code, run tools, push commits
|
||||
OBSERVER = "observer" # Read-only cell access
|
||||
DIRECTOR = "director" # Can assign, close, publish back to Gitea
|
||||
|
||||
|
||||
@dataclass
|
||||
class CellMember:
|
||||
identity: str # e.g. "agent:allegro", "bot:kimi", "human:Alexander"
|
||||
role: CellRole
|
||||
joined_at: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cell:
|
||||
cell_id: str
|
||||
project: str # e.g. "Timmy_Foundation/timmy-config#262"
|
||||
owner: str # e.g. "human:Alexander"
|
||||
backend: str # "process", "venv", "docker", "remote"
|
||||
state: CellState
|
||||
created_at: str
|
||||
ttl_minutes: int
|
||||
members: List[CellMember] = field(default_factory=list)
|
||||
hermes_home: Optional[str] = None
|
||||
workspace_path: Optional[str] = None
|
||||
shared_notes_path: Optional[str] = None
|
||||
shared_artifacts_path: Optional[str] = None
|
||||
decisions_path: Optional[str] = None
|
||||
archived_at: Optional[str] = None
|
||||
destroyed_at: Optional[str] = None
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
if self.state in (CellState.CLOSING, CellState.ARCHIVED, CellState.DESTROYED):
|
||||
return False
|
||||
created = datetime.fromisoformat(self.created_at.replace("Z", "+00:00"))
|
||||
return datetime.now().astimezone() > created + timedelta(minutes=self.ttl_minutes)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = asdict(self)
|
||||
d["state"] = self.state.value
|
||||
d["members"] = [
|
||||
{**asdict(m), "role": m.role.value} for m in self.members
|
||||
]
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Cell":
|
||||
d = dict(d)
|
||||
d["state"] = CellState(d["state"])
|
||||
d["members"] = [
|
||||
CellMember(
|
||||
identity=m["identity"],
|
||||
role=CellRole(m["role"]),
|
||||
joined_at=m["joined_at"],
|
||||
)
|
||||
for m in d.get("members", [])
|
||||
]
|
||||
return cls(**d)
|
||||
|
||||
|
||||
class CellRegistry:
|
||||
"""SQLite-backed registry of all cells."""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
self.db_path = Path(db_path or "/root/.lazarus_registry.sqlite")
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
import sqlite3
|
||||
with sqlite3.connect(str(self.db_path)) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cells (
|
||||
cell_id TEXT PRIMARY KEY,
|
||||
project TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
backend TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
ttl_minutes INTEGER NOT NULL,
|
||||
members TEXT,
|
||||
hermes_home TEXT,
|
||||
workspace_path TEXT,
|
||||
shared_notes_path TEXT,
|
||||
shared_artifacts_path TEXT,
|
||||
decisions_path TEXT,
|
||||
archived_at TEXT,
|
||||
destroyed_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def save(self, cell: Cell):
|
||||
import sqlite3
|
||||
with sqlite3.connect(str(self.db_path)) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO cells (
|
||||
cell_id, project, owner, backend, state, created_at, ttl_minutes,
|
||||
members, hermes_home, workspace_path, shared_notes_path,
|
||||
shared_artifacts_path, decisions_path, archived_at, destroyed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
cell.cell_id,
|
||||
cell.project,
|
||||
cell.owner,
|
||||
cell.backend,
|
||||
cell.state.value,
|
||||
cell.created_at,
|
||||
cell.ttl_minutes,
|
||||
json.dumps(cell.to_dict()["members"]),
|
||||
cell.hermes_home,
|
||||
cell.workspace_path,
|
||||
cell.shared_notes_path,
|
||||
cell.shared_artifacts_path,
|
||||
cell.decisions_path,
|
||||
cell.archived_at,
|
||||
cell.destroyed_at,
|
||||
),
|
||||
)
|
||||
|
||||
def load(self, cell_id: str) -> Optional[Cell]:
|
||||
import sqlite3
|
||||
with sqlite3.connect(str(self.db_path)) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM cells WHERE cell_id = ?", (cell_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
keys = [
|
||||
"cell_id", "project", "owner", "backend", "state", "created_at",
|
||||
"ttl_minutes", "members", "hermes_home", "workspace_path",
|
||||
"shared_notes_path", "shared_artifacts_path", "decisions_path",
|
||||
"archived_at", "destroyed_at",
|
||||
]
|
||||
d = dict(zip(keys, row))
|
||||
d["members"] = json.loads(d["members"])
|
||||
return Cell.from_dict(d)
|
||||
|
||||
def list_active(self) -> List[Cell]:
|
||||
import sqlite3
|
||||
with sqlite3.connect(str(self.db_path)) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM cells WHERE state NOT IN ('destroyed', 'archived')"
|
||||
).fetchall()
|
||||
keys = [
|
||||
"cell_id", "project", "owner", "backend", "state", "created_at",
|
||||
"ttl_minutes", "members", "hermes_home", "workspace_path",
|
||||
"shared_notes_path", "shared_artifacts_path", "decisions_path",
|
||||
"archived_at", "destroyed_at",
|
||||
]
|
||||
cells = []
|
||||
for row in rows:
|
||||
d = dict(zip(keys, row))
|
||||
d["members"] = json.loads(d["members"])
|
||||
cells.append(Cell.from_dict(d))
|
||||
return cells
|
||||
|
||||
def delete(self, cell_id: str):
|
||||
import sqlite3
|
||||
with sqlite3.connect(str(self.db_path)) as conn:
|
||||
conn.execute("DELETE FROM cells WHERE cell_id = ?", (cell_id,))
|
||||
|
||||
|
||||
class CellPackager:
|
||||
"""Creates and destroys per-cell filesystem isolation."""
|
||||
|
||||
ROOT = Path("/tmp/lazarus-cells")
|
||||
|
||||
def __init__(self, root: Optional[str] = None):
|
||||
self.root = Path(root or self.ROOT)
|
||||
|
||||
def create(self, cell_id: str, backend: str = "process") -> Cell:
|
||||
cell_home = self.root / cell_id
|
||||
workspace = cell_home / "workspace"
|
||||
shared_notes = cell_home / "shared" / "notes.md"
|
||||
shared_artifacts = cell_home / "shared" / "artifacts"
|
||||
decisions = cell_home / "shared" / "decisions.jsonl"
|
||||
hermes_home = cell_home / ".hermes"
|
||||
|
||||
for p in [workspace, shared_artifacts, hermes_home]:
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not shared_notes.exists():
|
||||
shared_notes.write_text(f"# Cell {cell_id} — Shared Notes\n\n")
|
||||
|
||||
if not decisions.exists():
|
||||
decisions.write_text("")
|
||||
|
||||
now = datetime.now().astimezone().isoformat()
|
||||
return Cell(
|
||||
cell_id=cell_id,
|
||||
project="",
|
||||
owner="",
|
||||
backend=backend,
|
||||
state=CellState.PROPOSED,
|
||||
created_at=now,
|
||||
ttl_minutes=60,
|
||||
hermes_home=str(hermes_home),
|
||||
workspace_path=str(workspace),
|
||||
shared_notes_path=str(shared_notes),
|
||||
shared_artifacts_path=str(shared_artifacts),
|
||||
decisions_path=str(decisions),
|
||||
)
|
||||
|
||||
def destroy(self, cell_id: str) -> bool:
|
||||
cell_home = self.root / cell_id
|
||||
if cell_home.exists():
|
||||
shutil.rmtree(cell_home)
|
||||
return True
|
||||
return False
|
||||
18
lazarus/cli.py
Normal file
18
lazarus/cli.py
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI entrypoint for Lazarus Pit operator control surface."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from .operator_ctl import OperatorCtl
|
||||
|
||||
|
||||
def main():
|
||||
ctl = OperatorCtl()
|
||||
result = ctl.run_cli(sys.argv[1:])
|
||||
print(json.dumps(result, indent=2))
|
||||
sys.exit(0 if result.get("success") else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
211
lazarus/operator_ctl.py
Normal file
211
lazarus/operator_ctl.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
Operator control surface for Lazarus Pit v2.0.
|
||||
|
||||
Provides the command grammar and API for:
|
||||
summon, invite, team, status, close, destroy
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .cell import Cell, CellMember, CellPackager, CellRegistry, CellRole, CellState
|
||||
from .backends.process_backend import ProcessBackend
|
||||
|
||||
|
||||
class OperatorCtl:
|
||||
"""The operator's command surface for the Lazarus Pit."""
|
||||
|
||||
def __init__(self, registry: Optional[CellRegistry] = None, packager: Optional[CellPackager] = None):
|
||||
self.registry = registry or CellRegistry()
|
||||
self.packager = packager or CellPackager()
|
||||
self.backend = ProcessBackend()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Commands
|
||||
# ------------------------------------------------------------------
|
||||
def summon(self, agent: str, project: str, owner: str = "human:Alexander", backend: str = "process", ttl_minutes: int = 60) -> dict:
|
||||
"""Summon a single agent into a fresh resurrection cell."""
|
||||
cell_id = f"laz-{uuid.uuid4().hex[:8]}"
|
||||
cell = self.packager.create(cell_id, backend=backend)
|
||||
cell.project = project
|
||||
cell.owner = owner
|
||||
cell.ttl_minutes = ttl_minutes
|
||||
cell.state = CellState.ACTIVE
|
||||
cell.members.append(CellMember(
|
||||
identity=f"agent:{agent}",
|
||||
role=CellRole.EXECUTOR,
|
||||
joined_at=datetime.now().astimezone().isoformat(),
|
||||
))
|
||||
|
||||
# Spawn a simple keep-alive process (real agent bootstrap would go here)
|
||||
res = self.backend.spawn(
|
||||
cell_id=cell_id,
|
||||
hermes_home=cell.hermes_home,
|
||||
command=["python3", "-c", "import time; time.sleep(86400)"],
|
||||
)
|
||||
|
||||
self.registry.save(cell)
|
||||
return {
|
||||
"success": res.success,
|
||||
"cell_id": cell_id,
|
||||
"project": project,
|
||||
"agent": agent,
|
||||
"backend": backend,
|
||||
"pid": res.pid,
|
||||
"message": res.message,
|
||||
}
|
||||
|
||||
def invite(self, cell_id: str, guest: str, role: str, invited_by: str = "human:Alexander") -> dict:
|
||||
"""Invite a guest bot or human into an active cell."""
|
||||
cell = self.registry.load(cell_id)
|
||||
if not cell:
|
||||
return {"success": False, "message": f"Cell {cell_id} not found"}
|
||||
if cell.state not in (CellState.PROPOSED, CellState.ACTIVE, CellState.IDLE):
|
||||
return {"success": False, "message": f"Cell {cell_id} is not accepting invites (state={cell.state.value})"}
|
||||
|
||||
role_enum = CellRole(role)
|
||||
cell.members.append(CellMember(
|
||||
identity=guest,
|
||||
role=role_enum,
|
||||
joined_at=datetime.now().astimezone().isoformat(),
|
||||
))
|
||||
cell.state = CellState.ACTIVE
|
||||
self.registry.save(cell)
|
||||
return {
|
||||
"success": True,
|
||||
"cell_id": cell_id,
|
||||
"guest": guest,
|
||||
"role": role_enum.value,
|
||||
"invited_by": invited_by,
|
||||
"message": f"Invited {guest} as {role_enum.value} to {cell_id}",
|
||||
}
|
||||
|
||||
def team(self, agents: List[str], project: str, owner: str = "human:Alexander", backend: str = "process", ttl_minutes: int = 120) -> dict:
|
||||
"""Form a multi-agent team in one cell against a project."""
|
||||
cell_id = f"laz-{uuid.uuid4().hex[:8]}"
|
||||
cell = self.packager.create(cell_id, backend=backend)
|
||||
cell.project = project
|
||||
cell.owner = owner
|
||||
cell.ttl_minutes = ttl_minutes
|
||||
cell.state = CellState.ACTIVE
|
||||
|
||||
for agent in agents:
|
||||
cell.members.append(CellMember(
|
||||
identity=f"agent:{agent}",
|
||||
role=CellRole.EXECUTOR,
|
||||
joined_at=datetime.now().astimezone().isoformat(),
|
||||
))
|
||||
|
||||
res = self.backend.spawn(
|
||||
cell_id=cell_id,
|
||||
hermes_home=cell.hermes_home,
|
||||
command=["python3", "-c", "import time; time.sleep(86400)"],
|
||||
)
|
||||
|
||||
self.registry.save(cell)
|
||||
return {
|
||||
"success": res.success,
|
||||
"cell_id": cell_id,
|
||||
"project": project,
|
||||
"agents": agents,
|
||||
"backend": backend,
|
||||
"pid": res.pid,
|
||||
"message": res.message,
|
||||
}
|
||||
|
||||
def status(self, cell_id: Optional[str] = None) -> dict:
|
||||
"""Show status of one cell or all active cells."""
|
||||
if cell_id:
|
||||
cell = self.registry.load(cell_id)
|
||||
if not cell:
|
||||
return {"success": False, "message": f"Cell {cell_id} not found"}
|
||||
probe = self.backend.probe(cell_id)
|
||||
return {
|
||||
"success": True,
|
||||
"cell": cell.to_dict(),
|
||||
"probe": probe.message,
|
||||
"expired": cell.is_expired(),
|
||||
}
|
||||
|
||||
active = self.registry.list_active()
|
||||
cells = []
|
||||
for cell in active:
|
||||
probe = self.backend.probe(cell.cell_id)
|
||||
cells.append({
|
||||
"cell_id": cell.cell_id,
|
||||
"project": cell.project,
|
||||
"backend": cell.backend,
|
||||
"state": cell.state.value,
|
||||
"members": [m.identity for m in cell.members],
|
||||
"probe": probe.message,
|
||||
"expired": cell.is_expired(),
|
||||
})
|
||||
return {"success": True, "active_cells": cells}
|
||||
|
||||
def close(self, cell_id: str, closed_by: str = "human:Alexander") -> dict:
|
||||
"""Gracefully close a cell."""
|
||||
cell = self.registry.load(cell_id)
|
||||
if not cell:
|
||||
return {"success": False, "message": f"Cell {cell_id} not found"}
|
||||
|
||||
res = self.backend.close(cell_id)
|
||||
cell.state = CellState.CLOSING
|
||||
self.registry.save(cell)
|
||||
return {
|
||||
"success": res.success,
|
||||
"cell_id": cell_id,
|
||||
"message": f"Closed {cell_id} by {closed_by}",
|
||||
}
|
||||
|
||||
def destroy(self, cell_id: str, destroyed_by: str = "human:Alexander") -> dict:
|
||||
"""Forcefully destroy a cell and all runtime state."""
|
||||
cell = self.registry.load(cell_id)
|
||||
if not cell:
|
||||
return {"success": False, "message": f"Cell {cell_id} not found"}
|
||||
|
||||
res = self.backend.destroy(cell_id)
|
||||
self.packager.destroy(cell_id)
|
||||
cell.state = CellState.DESTROYED
|
||||
cell.destroyed_at = datetime.now().astimezone().isoformat()
|
||||
self.registry.save(cell)
|
||||
return {
|
||||
"success": True,
|
||||
"cell_id": cell_id,
|
||||
"message": f"Destroyed {cell_id} by {destroyed_by}",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CLI helpers
|
||||
# ------------------------------------------------------------------
|
||||
def run_cli(self, args: List[str]) -> dict:
|
||||
if not args:
|
||||
return self.status()
|
||||
cmd = args[0]
|
||||
if cmd == "summon" and len(args) >= 3:
|
||||
return self.summon(agent=args[1], project=args[2])
|
||||
if cmd == "invite" and len(args) >= 4:
|
||||
return self.invite(cell_id=args[1], guest=args[2], role=args[3])
|
||||
if cmd == "team" and len(args) >= 3:
|
||||
agents = args[1].split("+")
|
||||
project = args[2]
|
||||
return self.team(agents=agents, project=project)
|
||||
if cmd == "status":
|
||||
return self.status(cell_id=args[1] if len(args) > 1 else None)
|
||||
if cmd == "close" and len(args) >= 2:
|
||||
return self.close(cell_id=args[1])
|
||||
if cmd == "destroy" and len(args) >= 2:
|
||||
return self.destroy(cell_id=args[1])
|
||||
return {
|
||||
"success": False,
|
||||
"message": (
|
||||
"Unknown command. Usage:\n"
|
||||
" lazarus summon <agent> <project>\n"
|
||||
" lazarus invite <cell_id> <guest> <role>\n"
|
||||
" lazarus team <agent1+agent2> <project>\n"
|
||||
" lazarus status [cell_id]\n"
|
||||
" lazarus close <cell_id>\n"
|
||||
" lazarus destroy <cell_id>\n"
|
||||
)
|
||||
}
|
||||
1
lazarus/tests/__init__.py
Normal file
1
lazarus/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for Lazarus Pit."""
|
||||
186
lazarus/tests/test_cell.py
Normal file
186
lazarus/tests/test_cell.py
Normal file
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for Lazarus Pit cell isolation and registry."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from lazarus.cell import Cell, CellMember, CellPackager, CellRegistry, CellRole, CellState
|
||||
from lazarus.operator_ctl import OperatorCtl
|
||||
from lazarus.backends.process_backend import ProcessBackend
|
||||
|
||||
|
||||
class TestCellPackager(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.TemporaryDirectory()
|
||||
self.packager = CellPackager(root=self.tmpdir.name)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmpdir.cleanup()
|
||||
|
||||
def test_create_isolated_paths(self):
|
||||
cell = self.packager.create("laz-test-001", backend="process")
|
||||
self.assertTrue(os.path.isdir(cell.hermes_home))
|
||||
self.assertTrue(os.path.isdir(cell.workspace_path))
|
||||
self.assertTrue(os.path.isfile(cell.shared_notes_path))
|
||||
self.assertTrue(os.path.isdir(cell.shared_artifacts_path))
|
||||
self.assertTrue(os.path.isfile(cell.decisions_path))
|
||||
|
||||
def test_destroy_cleans_paths(self):
|
||||
cell = self.packager.create("laz-test-002", backend="process")
|
||||
self.assertTrue(os.path.isdir(cell.hermes_home))
|
||||
self.packager.destroy("laz-test-002")
|
||||
self.assertFalse(os.path.exists(cell.hermes_home))
|
||||
|
||||
def test_cells_are_separate(self):
|
||||
c1 = self.packager.create("laz-a", backend="process")
|
||||
c2 = self.packager.create("laz-b", backend="process")
|
||||
self.assertNotEqual(c1.hermes_home, c2.hermes_home)
|
||||
self.assertNotEqual(c1.workspace_path, c2.workspace_path)
|
||||
|
||||
|
||||
class TestCellRegistry(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.TemporaryDirectory()
|
||||
self.registry = CellRegistry(db_path=os.path.join(self.tmpdir.name, "registry.sqlite"))
|
||||
|
||||
def tearDown(self):
|
||||
self.tmpdir.cleanup()
|
||||
|
||||
def test_save_and_load(self):
|
||||
cell = Cell(
|
||||
cell_id="laz-reg-001",
|
||||
project="Timmy_Foundation/test#1",
|
||||
owner="human:Alexander",
|
||||
backend="process",
|
||||
state=CellState.ACTIVE,
|
||||
created_at=datetime.now().astimezone().isoformat(),
|
||||
ttl_minutes=60,
|
||||
members=[CellMember("agent:allegro", CellRole.EXECUTOR, datetime.now().astimezone().isoformat())],
|
||||
)
|
||||
self.registry.save(cell)
|
||||
loaded = self.registry.load("laz-reg-001")
|
||||
self.assertEqual(loaded.cell_id, "laz-reg-001")
|
||||
self.assertEqual(loaded.members[0].role, CellRole.EXECUTOR)
|
||||
|
||||
def test_list_active(self):
|
||||
for sid, state in [("a", CellState.ACTIVE), ("b", CellState.DESTROYED)]:
|
||||
cell = Cell(
|
||||
cell_id=f"laz-{sid}",
|
||||
project="test",
|
||||
owner="human:Alexander",
|
||||
backend="process",
|
||||
state=state,
|
||||
created_at=datetime.now().astimezone().isoformat(),
|
||||
ttl_minutes=60,
|
||||
)
|
||||
self.registry.save(cell)
|
||||
active = self.registry.list_active()
|
||||
ids = {c.cell_id for c in active}
|
||||
self.assertIn("laz-a", ids)
|
||||
self.assertNotIn("laz-b", ids)
|
||||
|
||||
def test_ttl_expiry(self):
|
||||
created = (datetime.now().astimezone() - timedelta(minutes=61)).isoformat()
|
||||
cell = Cell(
|
||||
cell_id="laz-exp",
|
||||
project="test",
|
||||
owner="human:Alexander",
|
||||
backend="process",
|
||||
state=CellState.ACTIVE,
|
||||
created_at=created,
|
||||
ttl_minutes=60,
|
||||
)
|
||||
self.assertTrue(cell.is_expired())
|
||||
|
||||
def test_ttl_not_expired(self):
|
||||
cell = Cell(
|
||||
cell_id="laz-ok",
|
||||
project="test",
|
||||
owner="human:Alexander",
|
||||
backend="process",
|
||||
state=CellState.ACTIVE,
|
||||
created_at=datetime.now().astimezone().isoformat(),
|
||||
ttl_minutes=60,
|
||||
)
|
||||
self.assertFalse(cell.is_expired())
|
||||
|
||||
|
||||
class TestOperatorCtl(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.TemporaryDirectory()
|
||||
self.ctl = OperatorCtl(
|
||||
registry=CellRegistry(db_path=os.path.join(self.tmpdir.name, "registry.sqlite")),
|
||||
packager=CellPackager(root=self.tmpdir.name),
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
# Destroy any cells we created
|
||||
for cell in self.ctl.registry.list_active():
|
||||
self.ctl.destroy(cell.cell_id)
|
||||
self.tmpdir.cleanup()
|
||||
|
||||
def test_summon_creates_cell(self):
|
||||
res = self.ctl.summon("allegro", "Timmy_Foundation/test#1")
|
||||
self.assertTrue(res["success"])
|
||||
self.assertIn("cell_id", res)
|
||||
cell = self.ctl.registry.load(res["cell_id"])
|
||||
self.assertEqual(cell.members[0].identity, "agent:allegro")
|
||||
self.assertEqual(cell.state, CellState.ACTIVE)
|
||||
# Clean up
|
||||
self.ctl.destroy(res["cell_id"])
|
||||
|
||||
def test_team_creates_multi_agent_cell(self):
|
||||
res = self.ctl.team(["allegro", "ezra"], "Timmy_Foundation/test#2")
|
||||
self.assertTrue(res["success"])
|
||||
cell = self.ctl.registry.load(res["cell_id"])
|
||||
self.assertEqual(len(cell.members), 2)
|
||||
identities = {m.identity for m in cell.members}
|
||||
self.assertEqual(identities, {"agent:allegro", "agent:ezra"})
|
||||
self.ctl.destroy(res["cell_id"])
|
||||
|
||||
def test_invite_adds_guest(self):
|
||||
res = self.ctl.summon("allegro", "Timmy_Foundation/test#3")
|
||||
cell_id = res["cell_id"]
|
||||
invite = self.ctl.invite(cell_id, "bot:kimi", "observer")
|
||||
self.assertTrue(invite["success"])
|
||||
cell = self.ctl.registry.load(cell_id)
|
||||
roles = {m.identity: m.role for m in cell.members}
|
||||
self.assertEqual(roles["bot:kimi"], CellRole.OBSERVER)
|
||||
self.ctl.destroy(cell_id)
|
||||
|
||||
def test_status_shows_active_cells(self):
|
||||
res = self.ctl.summon("allegro", "Timmy_Foundation/test#4")
|
||||
status = self.ctl.status()
|
||||
self.assertTrue(status["success"])
|
||||
ids = {c["cell_id"] for c in status["active_cells"]}
|
||||
self.assertIn(res["cell_id"], ids)
|
||||
self.ctl.destroy(res["cell_id"])
|
||||
|
||||
def test_close_then_destroy(self):
|
||||
res = self.ctl.summon("allegro", "Timmy_Foundation/test#5")
|
||||
cell_id = res["cell_id"]
|
||||
close_res = self.ctl.close(cell_id)
|
||||
self.assertTrue(close_res["success"])
|
||||
destroy_res = self.ctl.destroy(cell_id)
|
||||
self.assertTrue(destroy_res["success"])
|
||||
cell = self.ctl.registry.load(cell_id)
|
||||
self.assertEqual(cell.state, CellState.DESTROYED)
|
||||
self.assertFalse(os.path.exists(cell.hermes_home))
|
||||
|
||||
def test_destroy_scrubs_filesystem(self):
|
||||
res = self.ctl.summon("allegro", "Timmy_Foundation/test#6")
|
||||
cell_id = res["cell_id"]
|
||||
cell = self.ctl.registry.load(cell_id)
|
||||
hermes = cell.hermes_home
|
||||
self.assertTrue(os.path.isdir(hermes))
|
||||
self.ctl.destroy(cell_id)
|
||||
self.assertFalse(os.path.exists(hermes))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user