diff --git a/.env.example b/.env.example index 941f674..a74feac 100644 --- a/.env.example +++ b/.env.example @@ -70,6 +70,8 @@ BEAVERGRAM_MCP= # На каждой машине: `t3 auth session issue --token-only --ttl 365d --label beaver` T3_MAC_TOKEN= T3_DELL_TOKEN= +# Токен gateway со scope `api` (admin → Tokens) - им t3code-mcp шлёт события в /hooks/t3code +T3CODE_MCP_GATEWAY_TOKEN= # Ветка/тег t3code-mcp, из которого собирается образ T3CODE_MCP_REF=main diff --git a/config.py b/config.py index 64b9007..0439258 100644 --- a/config.py +++ b/config.py @@ -2,6 +2,7 @@ import logging import os from datetime import UTC, date, datetime, timedelta from pathlib import Path +from typing import Any from zoneinfo import ZoneInfo from beaver_gateway.agents.base import BaseAgent, ExposedMcp @@ -589,6 +590,51 @@ async def memory(run: JobRun) -> None: ) +T3CODE_QUESTION_HINT = ( + "Если ответ следует из задания - ответь сам: t3_answer(thread_id, " + "{id вопроса: label}). Не знаешь - спроси Бобра через say своими словами, " + "потом t3_answer. Тред стоит, пока не ответят." +) + + +def t3code_text(p: dict[str, Any]) -> str: + head = ( + f"t3code: тред «{p.get('title')}» ({p.get('machine')}, {p.get('project')}, " + f"thread_id {p.get('thread_id')})" + ) + event = p.get("event") + if event in ("question", "approval"): + lines = [f"{head} - спрашивает."] + for q in (p.get("question") or {}).get("questions", []): + multi = " (можно несколько)" if q.get("multi_select") else "" + lines.append( + f"- [{q['id']}] {q.get('header') or ''}: {q['question']}{multi}" + ) + lines.extend( + f" · {o['label']}" + + (f" - {o['description']}" if o.get("description") else "") + for o in q.get("options", []) + ) + lines.append(T3CODE_QUESTION_HINT) + return "\n".join(lines) + if event == "completed": + lines = [f"{head} - завершён."] + elif event == "interrupted": + lines = [f"{head} - прерван."] + else: + lines = [f"{head} - упал: {p.get('error') or 'без текста ошибки'}."] + if p.get("text"): + lines.append(f"Ответ треда:\n{p['text']}") + if p.get("files"): + lines.append("Файлы: " + ", ".join(f["path"] for f in p["files"])) + return "\n".join(lines) + + +async def t3code_event(run: JobRun) -> None: + """§5, §8.6: t3code-mcp следит за тредами по WS и присылает сюда события.""" + await run.inject_master(t3code_text(run.payload), urgency="urgent", origin="t3code") + + async def komodo_alert(run: JobRun) -> None: payload = run.payload summary = payload.get("raw") or {k: v for k, v in payload.items() if k != "trigger"} @@ -602,6 +648,8 @@ jobs = [ Job("память", memory, cron="30 4 * * 0", critical=False), Job("deploy", deploy, webhook=True), Job("komodo", komodo_alert, webhook=True), + # события идут подряд (вопрос и завершение) - схлопывать нельзя + Job("t3code", t3code_event, webhook=True, dedupe=False), ] gateway = Gateway( diff --git a/docker-compose.yml b/docker-compose.yml index e2bc69b..889cb0f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -97,10 +97,16 @@ services: profiles: [t3] <<: *restart env_file: .env + environment: + # события тредов (завершён / упал / спрашивает) → инжект в мастер (§4.5) + T3CODE_MCP_HOOK_URL: http://gateway:62990/hooks/t3code + T3CODE_MCP_STATE: /data/t3code-mcp.json volumes: - ./t3code.toml:/config/t3code.toml:ro + - t3code-state:/data volumes: + t3code-state: postgres-data: vault: obsidian-config: