feat(t3,views,server,watch): t3 code orchestration protocol 2

This commit is contained in:
hh committed 2026-10-16 01:48:32 +02:00
1 parent 133bc6e915
commit f4c54b6e16
7 files changed
+448 -294

No files matched your search

+41 -49
View File
@@ -14,6 +14,7 @@ from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Annotated, Any
import httpx
import websockets
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from pydantic import Field
@@ -30,16 +31,17 @@ if TYPE_CHECKING:
from starlette.requests import Request
from t3code_mcp.t3 import RpcCall
TITLE_MAX = 80
WAIT_MAX = 3600.0
PENDING_EVERY = 6
STALLED_POLLS = 3
@dataclass
class Registry:
machines: dict[str, Machine]
transport: httpx.AsyncBaseTransport | None = None
rpc: Callable[[str], RpcCall] | None = None
clients: dict[str, T3Client] = field(default_factory=dict)
threads: dict[str, str] = field(default_factory=dict)
tracker: Tracker = field(default_factory=Tracker)
@@ -55,7 +57,10 @@ class Registry:
machine = self.machine(name)
if name not in self.clients:
self.clients[name] = T3Client(
machine.url, machine.token, transport=self.transport
machine.url,
machine.token,
transport=self.transport,
rpc=self.rpc(name) if self.rpc else None,
)
return self.clients[name]
@@ -67,7 +72,7 @@ class Registry:
)
names = [known] if known in self.machines else list(self.machines)
for name in names:
detail = await self.client(name).thread(thread_id, turn_limit=1)
detail = await self.client(name).thread(thread_id)
if detail is not None:
self.threads[thread_id] = name
return name, detail
@@ -90,7 +95,7 @@ def _title(prompt: str, title: str | None) -> str:
def _find_project(
machine: Machine, projects: list[dict[str, Any]], key: str
) -> dict[str, Any]:
allowed = [p for p in projects if not p.get("deletedAt") and machine.allows(p)]
allowed = [p for p in projects if machine.allows(p)]
for project in allowed:
if key in (project["id"], project["title"], project["workspaceRoot"]):
return project
@@ -107,9 +112,7 @@ def _project_view(
for t in threads
if t["projectId"] == project["id"] and not t.get("archivedAt")
]
running = sum(
1 for t in own if (t.get("latestTurn") or {}).get("state") == "running"
)
running = sum(1 for t in own if views.shell_state(t) in BUSY)
default = project.get("defaultModelSelection")
return {
"id": project["id"],
@@ -201,7 +204,7 @@ def build_server(
return [
_project_view(p, shell["threads"])
for p in shell["projects"]
if not p.get("deletedAt") and spec.allows(p)
if spec.allows(p)
]
@mcp.tool
@@ -273,12 +276,12 @@ def build_server(
if existing is None or existing["projectId"] != target["id"]:
msg = f"thread {thread_id} is not in {target['title']} on {machine}"
raise ToolError(msg)
if (existing.get("latestTurn") or {}).get("state") == "running":
if views.shell_state(existing) in BUSY:
msg = f"thread {thread_id} is running; t3_wait or t3_interrupt first"
raise ToolError(msg)
tid = thread_id
await _call(
client.dispatch(t3.turn_start(tid, text, selection.selection()))
client.dispatch(t3.message_dispatch(tid, text, selection.selection()))
)
else:
tid = t3.new_id()
@@ -287,7 +290,9 @@ def build_server(
t3.thread_create(tid, target["id"], heading, selection.selection())
)
)
await _call(client.dispatch(t3.turn_start(tid, text, title_seed=heading)))
await _call(
client.dispatch(t3.message_dispatch(tid, text, title_seed=heading))
)
registry.threads[tid] = machine
registry.tracker.track(tid, machine, target["title"], heading, watch=watch)
return {
@@ -338,24 +343,22 @@ def build_server(
async def t3_thread(
thread_id: Annotated[str, Field(description="thread_id from t3_dispatch")],
turns: Annotated[
int, Field(description="How many recent user turns to include", ge=1, le=20)
int,
Field(
description="How many recent runs (user turns) to include", ge=1, le=20
),
] = 2,
) -> dict[str, Any]:
"""State of a thread without waiting.
Turn state, last messages, tools run, files changed, pending questions.
"""
machine, _ = await registry.locate(thread_id)
client = registry.client(machine)
detail = await _call(client.thread(thread_id, turn_limit=turns))
shell = await _call(client.shell())
if detail is None:
msg = f"thread {thread_id} vanished from {machine}"
raise ToolError(msg)
machine, detail = await registry.locate(thread_id)
shell = await _call(registry.client(machine).shell())
summary = views.summary(
machine, detail, project=views.project_title(shell, detail)
machine, detail, project=views.project_title(shell, detail), turns=turns
)
summary["pending"] = views.pending_in(shell, thread_id)
summary["pending"] = views.pending_detail(detail["projection"])
return summary
@mcp.tool
@@ -378,23 +381,13 @@ def build_server(
machine, detail = await registry.locate(thread_id)
client = registry.client(machine)
started = time.monotonic()
polls = 0
pending: dict[str, bool] | None = None
project: str | None = None
stalled = 0
project = views.project_title(await _call(client.shell()), detail)
while True:
thread = detail["thread"]
state = views.state(thread)
session = thread.get("session") or {}
status = session.get("status")
if polls % PENDING_EVERY == 0:
shell = await _call(client.shell())
pending = views.pending_in(shell, thread_id)
project = views.project_title(shell, detail)
failed = status == "error" or bool(session.get("lastError"))
stalled = stalled + 1 if state == "starting" and status == "stopped" else 0
busy = (state in BUSY or status == "starting") and stalled < STALLED_POLLS
done = not busy or failed or pending is not None
projection = detail["projection"]
state = views.state(projection)
pending = views.pending_detail(projection)
stuck = views.latest_run(projection) is None and views.error(projection)
done = state not in BUSY or pending is not None or stuck
timed_out = time.monotonic() - started >= timeout
if done or timed_out:
summary = views.summary(machine, detail, project=project)
@@ -403,8 +396,7 @@ def build_server(
summary["waited"] = round(time.monotonic() - started, 1)
return summary
await asyncio.sleep(poll)
polls += 1
refreshed = await _call(client.thread(thread_id, turn_limit=1))
refreshed = await _call(client.thread(thread_id))
if refreshed is None:
msg = f"thread {thread_id} vanished from {machine}"
raise ToolError(msg)
@@ -425,7 +417,7 @@ def build_server(
) -> dict[str, Any]:
"""Answer the pending question of a thread; its turn resumes."""
machine, detail = await registry.locate(thread_id)
pending = views.question(detail)
pending = views.question(detail["projection"])
if pending is None or not pending.get("request_id"):
msg = f"thread {thread_id} has no pending question"
raise ToolError(msg)
@@ -436,7 +428,9 @@ def build_server(
raise ToolError(msg)
await _call(
registry.client(machine).dispatch(
t3.user_input_respond(thread_id, pending["request_id"], dict(answers))
t3.runtime_request_respond(
thread_id, pending["request_id"], dict(answers)
)
)
)
return {
@@ -452,17 +446,15 @@ def build_server(
) -> dict[str, Any]:
"""Interrupt the running turn; continue later via t3_dispatch(thread_id=...)."""
machine, detail = await registry.locate(thread_id)
latest = detail["thread"].get("latestTurn") or {}
if latest.get("state") != "running":
latest = views.latest_run(detail["projection"])
if latest is None or latest["status"] not in BUSY:
return {
"thread_id": thread_id,
"machine": machine,
"state": latest.get("state", "idle"),
"state": latest["status"] if latest else "idle",
}
await _call(
registry.client(machine).dispatch(
t3.turn_interrupt(thread_id, latest.get("turnId"))
)
registry.client(machine).dispatch(t3.run_interrupt(thread_id, latest["id"]))
)
return {"thread_id": thread_id, "machine": machine, "state": "interrupting"}
@@ -474,6 +466,6 @@ async def _call(awaitable: Any) -> Any:
return await awaitable
except T3Error as exc:
raise ToolError(str(exc)) from exc
except httpx.HTTPError as exc:
except (httpx.HTTPError, websockets.WebSocketException, OSError) as exc:
msg = f"T3 unreachable: {exc}"
raise ToolError(msg) from exc