feat(watch,server): t3_watch to turn thread events off and back on
This commit is contained in:
@@ -23,7 +23,7 @@ from t3code_mcp import t3, views
|
||||
from t3code_mcp.config import Machine, ModelSpec
|
||||
from t3code_mcp.t3 import T3Client, T3Error
|
||||
from t3code_mcp.views import BUSY
|
||||
from t3code_mcp.watch import Tracker
|
||||
from t3code_mcp.watch import Tracked, Tracker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
@@ -121,6 +121,16 @@ def _project_view(
|
||||
}
|
||||
|
||||
|
||||
def _watch_view(thread_id: str, tracked: Tracked) -> dict[str, Any]:
|
||||
return {
|
||||
"thread_id": thread_id,
|
||||
"machine": tracked.machine,
|
||||
"project": tracked.project,
|
||||
"title": tracked.title,
|
||||
"state": tracked.state,
|
||||
}
|
||||
|
||||
|
||||
def build_server(
|
||||
registry: Registry,
|
||||
*,
|
||||
@@ -145,7 +155,8 @@ def build_server(
|
||||
"Coding threads on Бобёр's machines through T3 Code. Flow: t3_machines → "
|
||||
"t3_projects(machine) → t3_dispatch, then either t3_wait (block) or just "
|
||||
"carry on: the gateway injects an event when a dispatched thread finishes, "
|
||||
"fails or asks a question (answer with t3_answer). Prompts run "
|
||||
"fails or asks a question (answer with t3_answer; t3_watch turns those "
|
||||
"events off for a thread). Prompts run "
|
||||
"autonomously (full-access, no approvals): say what to change, what to "
|
||||
"check, what not to touch, and that the answer should end with a report."
|
||||
),
|
||||
@@ -222,6 +233,15 @@ def build_server(
|
||||
str | None,
|
||||
Field(description="Continue this existing thread instead of creating one"),
|
||||
] = None,
|
||||
watch: Annotated[ # noqa: FBT002 - MCP arguments are always named
|
||||
bool,
|
||||
Field(
|
||||
description=(
|
||||
"Report this thread back as an event; false for a thread you "
|
||||
"read yourself. Every dispatch resets the flag - see t3_watch"
|
||||
)
|
||||
),
|
||||
] = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Start a coding thread on a machine (or follow up in one); returns at once.
|
||||
|
||||
@@ -269,7 +289,7 @@ def build_server(
|
||||
)
|
||||
await _call(client.dispatch(t3.turn_start(tid, text, title_seed=heading)))
|
||||
registry.threads[tid] = machine
|
||||
registry.tracker.track(tid, machine, target["title"], heading)
|
||||
registry.tracker.track(tid, machine, target["title"], heading, watch=watch)
|
||||
return {
|
||||
"thread_id": tid,
|
||||
"machine": machine,
|
||||
@@ -278,6 +298,40 @@ def build_server(
|
||||
"title": heading,
|
||||
"model": str(selection),
|
||||
"state": "running",
|
||||
"watch": watch,
|
||||
}
|
||||
|
||||
@mcp.tool
|
||||
async def t3_watch(
|
||||
thread_id: Annotated[
|
||||
str | None,
|
||||
Field(description="Thread to subscribe or unsubscribe; omit to only list"),
|
||||
] = None,
|
||||
enabled: Annotated[
|
||||
bool | None,
|
||||
Field(description="True - report the thread back again, false - stop"),
|
||||
] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Which dispatched threads report back, and turn that on or off per thread.
|
||||
|
||||
t3_dispatch subscribes its thread, so its completion, failure and
|
||||
questions arrive here as events. Unsubscribe a thread someone reads
|
||||
directly: it keeps running, it just stops reporting. Called with no
|
||||
arguments this only lists.
|
||||
"""
|
||||
if thread_id is not None:
|
||||
if enabled is None:
|
||||
msg = "pass enabled=true or enabled=false along with thread_id"
|
||||
raise ToolError(msg)
|
||||
try:
|
||||
registry.tracker.set_watch(thread_id, enabled=enabled)
|
||||
except KeyError:
|
||||
msg = f"thread {thread_id} was not dispatched from here; nothing to "
|
||||
raise ToolError(msg + ("watch" if enabled else "unwatch")) from None
|
||||
threads = registry.tracker.threads
|
||||
return {
|
||||
"watched": [_watch_view(k, v) for k, v in threads.items() if v.watch],
|
||||
"unwatched": [_watch_view(k, v) for k, v in threads.items() if not v.watch],
|
||||
}
|
||||
|
||||
@mcp.tool
|
||||
|
||||
+32
-6
@@ -7,8 +7,10 @@ read-model carries everything a transition needs - `latestTurn.state`,
|
||||
Reconnects resume with `afterSequence`; a fresh snapshot re-derives every
|
||||
tracked thread, so transitions missed while offline are still reported.
|
||||
|
||||
Only threads started through `t3_dispatch` are tracked. Events go to the
|
||||
gateway webhook through a persisted outbox that retries until accepted.
|
||||
Only threads started through `t3_dispatch` are tracked, and only while
|
||||
their `watch` flag is on - `t3_watch` turns it off for a thread someone
|
||||
reads directly. Events go to the gateway webhook through a persisted
|
||||
outbox that retries until accepted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,7 +20,7 @@ import contextlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from dataclasses import asdict, dataclass, field, fields
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import httpx
|
||||
@@ -54,6 +56,7 @@ class Tracked:
|
||||
state: str = "starting"
|
||||
pending: str | None = None
|
||||
error: str | None = None
|
||||
watch: bool = True
|
||||
updated: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
@@ -72,7 +75,11 @@ class Tracker:
|
||||
self.sequences: dict[str, int] = {}
|
||||
if path is not None and path.exists():
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
self.threads = {k: Tracked(**v) for k, v in raw.get("threads", {}).items()}
|
||||
known = {f.name for f in fields(Tracked)}
|
||||
self.threads = {
|
||||
k: Tracked(**{n: x for n, x in v.items() if n in known})
|
||||
for k, v in raw.get("threads", {}).items()
|
||||
}
|
||||
self.outbox = raw.get("outbox", [])
|
||||
self.sequences = raw.get("sequences", {})
|
||||
|
||||
@@ -91,10 +98,27 @@ class Tracker:
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(self.path)
|
||||
|
||||
def track(self, thread_id: str, machine: str, project: str, title: str) -> None:
|
||||
self.threads[thread_id] = Tracked(machine=machine, project=project, title=title)
|
||||
def track(
|
||||
self,
|
||||
thread_id: str,
|
||||
machine: str,
|
||||
project: str,
|
||||
title: str,
|
||||
*,
|
||||
watch: bool = True,
|
||||
) -> None:
|
||||
self.threads[thread_id] = Tracked(
|
||||
machine=machine, project=project, title=title, watch=watch
|
||||
)
|
||||
self.save()
|
||||
|
||||
def set_watch(self, thread_id: str, *, enabled: bool) -> Tracked:
|
||||
tracked = self.threads[thread_id]
|
||||
tracked.watch = enabled
|
||||
tracked.updated = time.time()
|
||||
self.save()
|
||||
return tracked
|
||||
|
||||
def apply(self, machine: str, shell_thread: dict[str, Any]) -> list[Event]:
|
||||
tracked = self.threads.get(shell_thread["id"])
|
||||
if tracked is None or tracked.machine != machine:
|
||||
@@ -111,6 +135,8 @@ class Tracker:
|
||||
events.append(Event(TERMINAL[state], shell_thread["id"], machine))
|
||||
elif tracked.state in views.BUSY and error and not tracked.error:
|
||||
events.append(Event("failed", shell_thread["id"], machine))
|
||||
if not tracked.watch:
|
||||
events.clear()
|
||||
changed = (state, pending, error) != (
|
||||
tracked.state,
|
||||
tracked.pending,
|
||||
|
||||
Reference in New Issue
Block a user