mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 05:16:25 +00:00
dc80e7bc28
The distilled knowledge layer over the raw log/transcript history, inspired by TencentDB-Agent-Memory's memory-hub model, adapted to handler's invariants (state lives only in the database; workers stay stateless and disposable): - memory_notes + memory_links tables (migration 0014) with a portable DAL: scoped listing/search (project + global), idempotent linking, and a one-read graph. Deleting an agent nulls attribution but keeps its notes; deleting a project removes its notes and edges, leaving global knowledge intact. - /memory API routes: reads on the normal token, note/link authoring on the admin token, plus GET /memory/graph for the dashboard. - Bundled handler-memory MCP server (python -m handler.mcpserver), a dependency-free stdio JSON-RPC implementation injected into every launch's --mcp-config ahead of the DB connectors and allowlisted in generated settings, exposing memory_search / memory_get / memory_save / memory_link. Identity and DATABASE_URL arrive via the spawn env, same as hooks. - SessionStart recall hook: injects the most recent notes in scope as additional context at session start, best-effort, never blocking. - Memory page in the web UI: a hand-rolled force-directed SVG graph of the note web (colored by kind, hover highlights, click-through details), plus note/link authoring — the app's first visualization, no chart dependency. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WYqkoYPX8NAo1V2KyXr1pk
184 lines
6.9 KiB
Python
184 lines
6.9 KiB
Python
"""Control-layer spawn: the hard test-task gate, settings generation, identity env.
|
|
|
|
Spawns go through the ``fake_launch`` seam (conftest) — the headless analogue of the old
|
|
fake tmux: it records the launch and mirrors its DB side effects, no subprocess."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from handler.control import spawn
|
|
from handler.db import repository as repo
|
|
from handler.db.engine import get_engine
|
|
|
|
|
|
def _register_project(root):
|
|
with get_engine().begin() as conn:
|
|
repo.create_project(conn, "proj", str(root))
|
|
|
|
|
|
def _write_mise(root, with_test=True):
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
body = "[tasks.lint]\nrun = 'ruff check .'\n"
|
|
if with_test:
|
|
body = "[tasks.test]\nrun = 'pytest'\n" + body
|
|
(root / ".mise.toml").write_text(body)
|
|
|
|
|
|
def test_spawn_refuses_without_test_task(env, fake_launch):
|
|
root = env["tmp"] / "proj"
|
|
_write_mise(root, with_test=False)
|
|
_register_project(root)
|
|
with pytest.raises(spawn.SpawnError, match="no \\[tasks.test\\]"):
|
|
spawn.spawn("proj", "api", task="do it")
|
|
assert fake_launch == []
|
|
|
|
|
|
def test_spawn_refuses_without_mise_file(env, fake_launch):
|
|
root = env["tmp"] / "proj"
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
_register_project(root)
|
|
with pytest.raises(spawn.SpawnError, match="no mise config"):
|
|
spawn.spawn("proj", "api", task="do it")
|
|
|
|
|
|
def test_spawn_refuses_without_task(env, fake_launch):
|
|
root = env["tmp"] / "proj"
|
|
_write_mise(root)
|
|
_register_project(root)
|
|
with pytest.raises(spawn.SpawnError, match="requires a task"):
|
|
spawn.spawn("proj", "api")
|
|
# Fail-fast: no orphaned agent row behind the refused spawn.
|
|
with get_engine().begin() as conn:
|
|
assert repo.get_agent_by_name(conn, "proj", "api") is None
|
|
assert fake_launch == []
|
|
|
|
|
|
def test_spawn_accepts_dotless_mise_toml(env, fake_launch):
|
|
# mise also reads `mise.toml` (no leading dot); the gate must honor it too.
|
|
root = env["tmp"] / "proj"
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
(root / "mise.toml").write_text("[tasks.test]\nrun = 'pytest'\n")
|
|
_register_project(root)
|
|
|
|
agent = spawn.spawn("proj", "api", task="do it")
|
|
with get_engine().begin() as conn:
|
|
assert repo.get_agent_by_name(conn, "proj", "api")["id"] == agent["id"]
|
|
|
|
|
|
def test_spawn_creates_agent_settings_and_run(env, fake_launch):
|
|
root = env["tmp"] / "proj"
|
|
_write_mise(root, with_test=True)
|
|
_register_project(root)
|
|
|
|
agent = spawn.spawn("proj", "api", task="build the thing")
|
|
|
|
# Agent row created.
|
|
with get_engine().begin() as conn:
|
|
assert repo.get_agent_by_name(conn, "proj", "api")["id"] == agent["id"]
|
|
|
|
# settings.json wires all five hook events AND the headless permission allowlist
|
|
# (claude -p auto-denies anything that would prompt; the allowlist is what lets
|
|
# normal work proceed — the hooks stay the hard gate).
|
|
settings = json.loads((root / ".claude" / "settings.json").read_text())
|
|
assert set(settings["hooks"]) == {
|
|
"Stop",
|
|
"SessionEnd",
|
|
"SessionStart",
|
|
"PreToolUse",
|
|
"Notification",
|
|
}
|
|
pre = settings["hooks"]["PreToolUse"][0]
|
|
assert pre["matcher"] == "AskUserQuestion|Bash"
|
|
assert "handler.hooks pre_tool_use" in pre["hooks"][0]["command"]
|
|
assert settings["permissions"]["defaultMode"] == "acceptEdits"
|
|
assert "Bash(git *)" in settings["permissions"]["allow"]
|
|
|
|
# A headless run launched with identity + DATABASE_URL in env and the task as prompt.
|
|
call = fake_launch[0]
|
|
assert call["kind"] == "spawn"
|
|
assert call["prompt"] == "build the thing"
|
|
assert call["env"]["HANDLER_PROJECT_ID"] == "proj"
|
|
assert call["env"]["HANDLER_AGENT_NAME"] == "api"
|
|
assert call["env"]["HANDLER_AGENT_ID"] == str(agent["id"])
|
|
assert call["env"]["DATABASE_URL"] == env["url"]
|
|
# The run row + session id landed on the agent.
|
|
with get_engine().begin() as conn:
|
|
row = repo.get_agent_by_name(conn, "proj", "api")
|
|
assert row["session_id"] == call["run"]["session_id"]
|
|
assert repo.get_latest_run(conn, row["id"])["kind"] == "spawn"
|
|
|
|
|
|
def test_spawn_mise_init_skips_test_gate_and_marks_env(env, fake_launch):
|
|
# A repo with no .mise.toml at all: the normal gate would refuse, but the mise-init
|
|
# bootstrap agent must launch anyway (creating that file is its whole job).
|
|
root = env["tmp"] / "proj"
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
_register_project(root)
|
|
|
|
agent = spawn.spawn(
|
|
"proj", "mise-init", task="write the mise config", require_tests=False, mise_init=True
|
|
)
|
|
|
|
with get_engine().begin() as conn:
|
|
assert repo.get_agent_by_name(conn, "proj", "mise-init")["id"] == agent["id"]
|
|
# The launched run carries HANDLER_MISE_INIT so its hooks enforce commit + push.
|
|
assert fake_launch[0]["env"]["HANDLER_MISE_INIT"] == "1"
|
|
|
|
|
|
def test_spawn_still_gates_without_mise_init_flag(env, fake_launch):
|
|
root = env["tmp"] / "proj"
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
_register_project(root)
|
|
# require_tests defaults on, so a normal spawn against a mise-less repo still refuses.
|
|
with pytest.raises(spawn.SpawnError, match="no mise config"):
|
|
spawn.spawn("proj", "api", task="do it")
|
|
assert fake_launch == []
|
|
|
|
|
|
def test_kill_cancels_run_and_sets_done(env, fake_launch):
|
|
root = env["tmp"] / "proj"
|
|
_write_mise(root, with_test=True)
|
|
_register_project(root)
|
|
spawn.spawn("proj", "api", task="do it")
|
|
|
|
spawn.kill("proj", "api")
|
|
with get_engine().begin() as conn:
|
|
agent = repo.get_agent_by_name(conn, "proj", "api")
|
|
assert agent["status"] == "done"
|
|
# The running run was flagged; the owning supervisor terminates its own child.
|
|
assert repo.get_latest_run(conn, agent["id"])["cancel_requested"] is True
|
|
|
|
|
|
def test_resume_reinjects_when_no_transcript(env, fake_launch):
|
|
"""A resume with no archive and no local transcript degrades to a fresh run whose
|
|
prompt carries the operator's answer (context re-injection)."""
|
|
root = env["tmp"] / "proj"
|
|
_write_mise(root, with_test=True)
|
|
_register_project(root)
|
|
spawn.spawn("proj", "api", task="do it")
|
|
with get_engine().begin() as conn:
|
|
agent = repo.get_agent_by_name(conn, "proj", "api")
|
|
repo.finish_run(conn, repo.get_latest_run(conn, agent["id"])["id"], "completed")
|
|
|
|
ok, detail = spawn.resume(agent, "use Postgres")
|
|
assert ok is True
|
|
assert "re-injected" in detail
|
|
assert fake_launch[-1]["kind"] == "spawn"
|
|
assert "use Postgres" in fake_launch[-1]["prompt"]
|
|
|
|
|
|
def test_resume_refused_while_run_live(env, fake_launch):
|
|
root = env["tmp"] / "proj"
|
|
_write_mise(root, with_test=True)
|
|
_register_project(root)
|
|
spawn.spawn("proj", "api", task="do it") # fake run stays 'running'
|
|
with get_engine().begin() as conn:
|
|
agent = repo.get_agent_by_name(conn, "proj", "api")
|
|
|
|
ok, detail = spawn.resume(agent, "answer")
|
|
assert ok is False
|
|
assert "live run" in detail
|