Files
Algis DumbrisandClaude Opus 4.7 73a1802155 feat(020): token accounting + watchdog CronJob
Token-usage wiring (closes the 0-tokens gap in memory_dream_usage):
- internal/harness/k8sjob/k8sjob.go: after extractResultJSON, parse
  tokens_in/tokens_out/tokens_cached/cost_usd from the final JSON
  envelope and stash into ExecResult.Usage so the dream worker's
  UsageGate circuit breaker actually counts consumption.
- dream-agent/dream_runner.py: Max20 OAuth sessions don't surface
  per-call tokens through the SDK's ResultMessage.usage. Falls back
  to a turn-based estimate so the gate has SOME signal:
    tokens_in_est = turns * 5000 + tool_calls * 2000
    tokens_out_est = turns * 300
  Calibrated against observed reflection runs.

Watchdog (deploy/kubic/watchdog/):
- watchdog.yaml: in-cluster CronJob runs every hour at :05 past UTC,
  with a dedicated ServiceAccount + Role granting (get/list/exec on
  pods, patch+update on deployments/scale) inside the synapbus
  namespace only.
- Health checks: pod readiness + restart count; last-1h job
  succ/fail/in_flight counts; today's jobs_started + tokens_in +
  circuit_broken.
- Red flags that auto-stop synapbus (scale to 0):
    * pod restart count > 3
    * failed dream jobs in last 1h > 20
    * jobs_started today > 200 OR tokens_in > 30M
    * circuit broke AND still firing (started >> completed)
- Dockerfile: slim alpine + kubectl v1.30.5 binary (synapbus-watchdog:v1).
  Built locally and imported into kubic's containerd because the
  public docker.io/bitnami/kubectl manifest was returning text/html
  from kubic's network egress.

Replaces the schedule-skill remote-agent approach because Anthropic
cloud agents can't reach kubic.home.arpa (LAN-only) and can't call
kubectl scale. The k8s CronJob is the right primitive for an
in-cluster safety watchdog.

Live evidence: first manual run on kubic reported
  pod=synapbus-... ready=true restarts=0
  last_1h jobs total=18 succ=18 fail=0 in_flight=0
  today: jobs_started=189 tokens_in=0 succeeded=169 failed=15
  HEALTHY — no action

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:22:13 +03:00

402 lines
14 KiB
Python

#!/usr/bin/env python3
"""SynapBus dream-agent runner — memory consolidation worker.
Dispatched by SynapBus's ConsolidatorWorker via the k8sjob harness.
Runs Claude Code (via claude-agent-sdk) against SynapBus's MCP server,
using a one-time dispatch token to authorize the six memory_* tools.
Environment contract (set by ConsolidatorWorker.runJob + k8sjob harness):
SYNAPBUS_URL base URL, e.g. http://synapbus.synapbus.svc.cluster.local:8080
SYNAPBUS_API_KEY dream-claude agent API key (Bearer auth)
SYNAPBUS_DISPATCH_TOKEN one-shot token authorizing memory_* tools
SYNAPBUS_CONSOLIDATION_JOB_ID parent job id (audit anchor)
SYNAPBUS_JOB_TYPE reflection | core_rewrite | dedup_contradiction | link_gen
SYNAPBUS_OWNER_ID target owner id
SYNAPBUS_DREAM_PROMPT job-type prompt (PromptFor)
SYNAPBUS_RUN_ID harness-injected run id
Optional:
ANTHROPIC_API_KEY or CLAUDE_CONFIG_DIR Claude Code credentials
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT OTLP/HTTP traces endpoint
DREAM_MAX_TURNS override max_turns (default 20)
DREAM_MODEL override model (default claude-sonnet-4-6)
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import shutil
import sys
import tempfile
import time
from typing import Any
# --- Structured JSON logging (one obj per line for Loki) -------------------
class _JsonFormatter(logging.Formatter):
def __init__(self) -> None:
super().__init__()
self.job_id = os.environ.get("SYNAPBUS_CONSOLIDATION_JOB_ID", "")
self.job_type = os.environ.get("SYNAPBUS_JOB_TYPE", "")
self.owner_id = os.environ.get("SYNAPBUS_OWNER_ID", "")
self.run_id = os.environ.get("SYNAPBUS_RUN_ID", "")
self.trace_id: str = ""
def format(self, record: logging.LogRecord) -> str:
entry: dict[str, Any] = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%SZ"),
"level": record.levelname,
"logger": record.name,
"job_id": self.job_id,
"job_type": self.job_type,
"owner_id": self.owner_id,
"run_id": self.run_id,
}
if self.trace_id:
entry["traceID"] = self.trace_id
if isinstance(record.msg, dict):
entry.update(record.msg)
else:
entry["msg"] = record.getMessage()
return json.dumps(entry, default=str)
def _setup_logging() -> logging.Logger:
lg = logging.getLogger("dream-agent")
lg.setLevel(logging.INFO)
lg.handlers.clear()
lg.propagate = False
h = logging.StreamHandler(sys.stdout)
h.setFormatter(_JsonFormatter())
lg.addHandler(h)
root = logging.getLogger()
root.handlers.clear()
root.addHandler(h)
return lg
logger = logging.getLogger("dream-agent")
# --- OTEL tracing (best-effort) --------------------------------------------
_tracer = None
def _init_tracing() -> None:
global _tracer
ep = os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "")
if not ep:
return
try:
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.resources import Resource
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
resource = Resource.create({
"service.name": "synapbus-dream-agent",
"service.version": "0.1.0",
"synapbus.job_id": os.environ.get("SYNAPBUS_CONSOLIDATION_JOB_ID", ""),
"synapbus.job_type": os.environ.get("SYNAPBUS_JOB_TYPE", ""),
"synapbus.owner_id": os.environ.get("SYNAPBUS_OWNER_ID", ""),
})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=ep)))
trace.set_tracer_provider(provider)
_tracer = trace.get_tracer("synapbus-dream-agent", "0.1.0")
logger.info({"msg": "OTEL tracing enabled", "endpoint": ep})
except ImportError:
logger.info({"msg": "OTEL packages not installed; tracing disabled"})
except Exception as e: # noqa: BLE001
logger.warning({"msg": "OTEL init failed", "error": str(e)})
def _shutdown_tracing() -> None:
try:
from opentelemetry import trace
p = trace.get_tracer_provider()
if hasattr(p, "shutdown"):
p.shutdown()
except Exception: # noqa: BLE001
pass
# --- Claude Code creds: ensure config dir is writable ----------------------
def _ensure_writable_config() -> str:
src = os.environ.get("CLAUDE_CONFIG_DIR", os.path.expanduser("~/.claude"))
test = os.path.join(src, ".write_test")
try:
os.makedirs(src, exist_ok=True)
with open(test, "w") as f:
f.write("ok")
os.remove(test)
return src
except OSError:
pass
tmp = tempfile.mkdtemp(prefix="claude_config_")
for fn in (".credentials.json", "credentials.json", "settings.json"):
s = os.path.join(src, fn)
if os.path.exists(s):
shutil.copy2(s, os.path.join(tmp, fn))
logger.info({"msg": "Created writable Claude config dir", "path": tmp})
return tmp
# --- Required-env helper ---------------------------------------------------
_REQUIRED = (
"SYNAPBUS_URL",
"SYNAPBUS_API_KEY",
"SYNAPBUS_DISPATCH_TOKEN",
"SYNAPBUS_CONSOLIDATION_JOB_ID",
"SYNAPBUS_JOB_TYPE",
"SYNAPBUS_OWNER_ID",
"SYNAPBUS_DREAM_PROMPT",
)
def _read_env() -> dict[str, str]:
out: dict[str, str] = {}
missing: list[str] = []
for k in _REQUIRED:
v = os.environ.get(k, "")
if not v:
missing.append(k)
out[k] = v
if missing:
raise RuntimeError(f"missing required env vars: {','.join(missing)}")
out["SYNAPBUS_RUN_ID"] = os.environ.get("SYNAPBUS_RUN_ID", "")
return out
# --- Prompt builder --------------------------------------------------------
_ALLOWED_TOOLS = [
"mcp__synapbus__memory_list_unprocessed",
"mcp__synapbus__memory_write_reflection",
"mcp__synapbus__memory_rewrite_core",
"mcp__synapbus__memory_mark_duplicate",
"mcp__synapbus__memory_supersede",
"mcp__synapbus__memory_add_link",
]
def _build_prompt(env: dict[str, str]) -> str:
return (
f"{env['SYNAPBUS_DREAM_PROMPT']}\n\n"
"Context:\n"
f"- job_id: {env['SYNAPBUS_CONSOLIDATION_JOB_ID']}\n"
f"- job_type: {env['SYNAPBUS_JOB_TYPE']}\n"
f"- owner_id: {env['SYNAPBUS_OWNER_ID']}\n"
f"- run_id: {env['SYNAPBUS_RUN_ID']}\n"
"- The dispatch token is forwarded automatically on every MCP "
"request via the `X-Synapbus-Dispatch-Token` header. You do not "
"need to pass it as a tool argument.\n"
"- Pass `owner_id` from the context above on every memory_* call.\n"
"- Use ONLY the memory_* tools listed in `allowed_tools`. Do not "
"call send_message, execute, search, or any other tool.\n"
"- When you are done, output a one-line JSON summary and exit.\n"
)
# --- Session runner --------------------------------------------------------
async def run_session(env: dict[str, str], model: str, max_turns: int, config_dir: str) -> dict[str, Any]:
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ResultMessage,
TextBlock,
query,
)
try:
from claude_agent_sdk import ToolUseBlock, ToolResultBlock, ThinkingBlock, UserMessage
except ImportError:
ToolUseBlock = ToolResultBlock = ThinkingBlock = UserMessage = None
base = env["SYNAPBUS_URL"].rstrip("/")
mcp_servers: dict[str, Any] = {
"synapbus": {
"type": "http",
"url": f"{base}/mcp",
"headers": {
"Authorization": f"Bearer {env['SYNAPBUS_API_KEY']}",
"X-Synapbus-Dispatch-Token": env["SYNAPBUS_DISPATCH_TOKEN"],
},
},
}
prompt = _build_prompt(env)
def _on_stderr(line: str) -> None:
logger.warning({"msg": "sdk_stderr", "line": line.rstrip()})
tokens_in = 0
tokens_out = 0
tool_calls = 0
turn = 0
started = time.time()
status = "ok"
error_msg = ""
try:
async for message in query(
prompt=prompt,
options=ClaudeAgentOptions(
model=model,
max_turns=max_turns,
mcp_servers=mcp_servers,
permission_mode="bypassPermissions",
allowed_tools=_ALLOWED_TOOLS,
env={"CLAUDE_CONFIG_DIR": config_dir},
stderr=_on_stderr,
),
):
if isinstance(message, ResultMessage):
usage = getattr(message, "usage", None)
tokens_in = getattr(usage, "input_tokens", 0) if usage else 0
tokens_out = getattr(usage, "output_tokens", 0) if usage else 0
# Max20 OAuth sessions don't surface tokens through the
# SDK's ResultMessage.usage. Fall back to a turn-based
# estimate so the server-side UsageGate has *some* signal.
# Numbers calibrated from observed reflection runs:
# ~5K input + ~300 output per turn, plus ~2K per tool call
# (memory_list_unprocessed payloads dominate).
if tokens_in == 0:
num_turns = int(getattr(message, "num_turns", 0) or 0)
tokens_in = max(0, num_turns * 5000 + tool_calls * 2000)
if tokens_out == 0:
num_turns = int(getattr(message, "num_turns", 0) or 0)
tokens_out = max(0, num_turns * 300)
is_error = getattr(message, "is_error", False)
duration_s = round(time.time() - started, 1)
logger.info({
"type": "result",
"turns": getattr(message, "num_turns", 0),
"cost_usd": getattr(message, "cost_usd", 0) or 0,
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"tool_calls": tool_calls,
"duration_s": duration_s,
"is_error": is_error,
})
if is_error:
status = "error"
error_msg = "result_message.is_error=true"
elif isinstance(message, AssistantMessage):
turn += 1
for block in message.content:
if isinstance(block, TextBlock):
logger.info({
"type": "text",
"turn": turn,
"text": block.text[:300].replace("\n", " "),
})
elif ToolUseBlock and isinstance(block, ToolUseBlock):
tool_calls += 1
logger.info({
"type": "tool_use",
"turn": turn,
"tool": getattr(block, "name", "unknown"),
"input": str(getattr(block, "input", ""))[:200],
})
elif ThinkingBlock and isinstance(block, ThinkingBlock):
logger.info({
"type": "thinking",
"turn": turn,
"text": getattr(block, "text", "")[:200].replace("\n", " "),
})
elif UserMessage and isinstance(message, UserMessage):
for block in message.content:
if ToolResultBlock and isinstance(block, ToolResultBlock):
is_err = getattr(block, "is_error", False)
logger.info({
"type": "tool_result",
"turn": turn,
"is_error": is_err,
"content": str(getattr(block, "content", ""))[:200],
})
except Exception as e: # noqa: BLE001
status = "error"
error_msg = f"{type(e).__name__}: {e}"
logger.error({"msg": "session failed", "error": error_msg})
return {
"final": True,
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"tool_calls": tool_calls,
"turns": turn,
"status": status,
"error": error_msg,
}
# --- main ------------------------------------------------------------------
def main() -> int:
global logger
parser = argparse.ArgumentParser(description="SynapBus dream-agent runner")
parser.add_argument("--mock", action="store_true",
help="Log env contract and exit without invoking the SDK")
parser.add_argument("--max-turns", type=int,
default=int(os.environ.get("DREAM_MAX_TURNS", "20")))
parser.add_argument("--model", default=os.environ.get("DREAM_MODEL", "claude-sonnet-4-6"))
args = parser.parse_args()
logger = _setup_logging()
_init_tracing()
try:
env = _read_env()
except RuntimeError as e:
logger.error({"msg": "env validation failed", "error": str(e)})
print(json.dumps({
"final": True, "tokens_in": 0, "tokens_out": 0,
"tool_calls": 0, "status": "error", "error": str(e),
}))
return 1
logger.info({
"msg": "dream-agent starting",
"synapbus_url": env["SYNAPBUS_URL"],
"model": args.model,
"max_turns": args.max_turns,
})
if args.mock:
logger.info({"msg": "--mock; skipping SDK invocation"})
print(json.dumps({
"final": True, "tokens_in": 0, "tokens_out": 0,
"tool_calls": 0, "status": "ok", "error": "",
}))
return 0
config_dir = _ensure_writable_config()
try:
result = asyncio.run(run_session(env, args.model, args.max_turns, config_dir))
except Exception as e: # noqa: BLE001
logger.error({"msg": "fatal", "error": f"{type(e).__name__}: {e}"})
print(json.dumps({
"final": True, "tokens_in": 0, "tokens_out": 0,
"tool_calls": 0, "status": "error", "error": str(e),
}))
_shutdown_tracing()
return 1
# Final single-line envelope for harness Usage parsing.
print(json.dumps(result))
_shutdown_tracing()
return 0 if result.get("status") == "ok" else 1
if __name__ == "__main__":
sys.exit(main())