Modules/Taskboard/server/sessions.py
Шурупов Илья Викторович 1edafd5087 Taskboard: remove the ready and failed statuses
Statuses are now backlog, queued, wip, review, done and blocked. 'queued' takes over from 'ready' as what
the dispatcher starts once dependencies are done; create_subtask creates queued children. 'failed' merges
into 'blocked': fail_task, the dispatcher's reaper and launch failures all set blocked, and fail_task's
blocked argument is kept only for older callers.

Like running -> wip, existing rows are renamed on connect, history shows the new names, and the old
names are still accepted as input. create_task now validates its status. Saved kanban column settings
ignore retired names instead of mapping them, so a hidden 'ready' column does not hide Queued.
2026-10-25 21:53:09 +03:00

496 lines
22 KiB
Python

"""Headless agent sessions: one detached tmux session per task, rooted under sessions/task-<id>.
A task picks a model and, independently, a harness (tasks.harness): 'claude-code' runs Claude Code,
'dsh' runs the DeepSeek Harness. Claude Code is a headless terminal session in a detached tmux session
the viewer attaches to. A 'dsh' task is NOT a board-owned process: it runs on one shared dsh web server
you run yourself (AGENTS_DSH_WEB_PORT, default 3080); the board only records a deep link carrying the
task id, and the taskboard-deeplink plugin on that server opens the task's session and seeds the prompt.
Session state for Claude Code is written by its hooks (session_hook.py) into tasks.session_state.
"""
import json
import os
import shlex
import shutil
import subprocess
import sys
import uuid
from db import DB_PATH, connect, log_event
from paths import CODE_DIR as HERE, SESSIONS_ROOT # noqa: E402
HOOK_SCRIPT = os.path.join(HERE, "session_hook.py")
TOKEN_HELPER = os.path.join(HERE, "subrouter", "token.mjs")
SUBROUTER_BIN = os.path.join(HERE, "subrouter", "node_modules", ".bin", "subrouter")
MODELS = {
"claude-opus-5-5": "Opus 5.5",
"claude-opus-4-8": "Opus 4.8",
"claude-sonnet-4-6": "Sonnet 4.6",
"claude-haiku-4-5": "Haiku 4.5",
"deepseek-chat": "DeepSeek Chat",
"deepseek-flash": "DeepSeek Flash",
}
# A task picks a model (above) and, independently, a harness to run it in. Claude Code runs
# Claude models only; the DeepSeek Harness ('dsh') runs Claude models (provider 'claude', via
# dsh's connected Claude accounts) and DeepSeek models (provider 'deepseek-official').
HARNESSES = {
"claude-code": "Claude Code",
"dsh": "DeepSeek Harness",
}
DEFAULT_HARNESS = "claude-code"
# A dsh task runs on ONE shared dsh web server that you run yourself (default port 3080). The board
# does not launch a process per dsh task; it just records a deep link carrying the task id. The
# taskboard-deeplink plugin on that server (see ../dsh-deeplink/) resolves the id to the task's
# workspace, opens a session there, seeds the worker prompt, and mounts the task-manager MCP.
DSH_WEB_PORT = int(os.environ.get("AGENTS_DSH_WEB_PORT", "3080"))
def dsh_deeplink_url(task_id):
return f"http://127.0.0.1:{DSH_WEB_PORT}/plugins/taskboard-deeplink/open?id={int(task_id)}"
# 'default' means "no reasoning-effort override" (use the model's own default); the others map 1:1 to
# Claude's reasoning levels (also what Claude Code's --effort takes).
EFFORTS = ("default", "low", "medium", "high", "xhigh", "max")
PERMISSION_MODES = {
"auto": "auto",
"manual": "default",
}
WORKSPACE_FOLDERS = ("reports", "notes", "tmp", "artifacts")
SESSION_STATES = ("starting", "working", "waiting_input", "idle", "error", "ended")
HOOK_EVENTS = {
"SessionStart": "idle",
"UserPromptSubmit": "working",
"PreToolUse": "working",
"PostToolUse": "working",
"Notification": "waiting_input",
"PermissionRequest": "waiting_input",
"Stop": "idle",
"StopFailure": "error",
"SessionEnd": "ended",
}
WORKER_PROMPT = """You are the agent for task #{id} on the task-manager board (MCP server "task-manager").
Start with get_task({id}) to read the task, its immediate parent and dependency results.
Then call get_context({id}) once to get your inherited context: it walks the whole parent chain up to the root and
returns every ancestor's title, description and reports/summary.md in a single call. Parents may keep hierarchy-wide
guidelines, conventions or a design document there; treat nearer-ancestor guidelines as binding and overriding farther
ones, and your own task as overriding all. Pass include=['summary','notes','artifacts'] for more per task, or
get_context(<id>, max_depth=0) to pull one specific task's documents when you want to go deeper.
Your task workspace is the current directory; get_workspace({id}) describes it. Keep your files there:
reports/ findings and final write-ups (finish with reports/summary.md)
notes/ running notes and plans
tmp/ scratch and intermediate data, safe to delete
artifacts/ outputs other tasks or the human should use
get_workspace({id}) also lists your parent's and children's workspace directories — sibling folders you can
read directly to pick up their work (and they can read yours).
Report progress with add_note, split work with create_subtask.
Finish with complete_task({id}, result=<summary>) or fail_task({id}, reason=...) when you cannot finish (the task becomes blocked).
The task-manager MCP server is developed in-house, so it can grow new tools and conventions. While you work, notice
where it slowed you down or where a tool call, note convention, or workflow shortcut would have made the job faster or
safer. At the end of the task, alongside your result, suggest concrete improvements to this MCP server: new or changed
tools and their arguments, extra fields to return, or habits future agents should adopt. Keep it to the few highest-value
ideas grounded in what this task actually needed; skip it only when nothing would have helped.
The human may type into this session at any time: follow their instructions."""
def session_name(task_id):
return f"task-{task_id}"
def session_directory(task_id):
return os.path.join(SESSIONS_ROOT, session_name(task_id))
TMUX_SOCKET = os.environ.get("AGENTS_TMUX_SOCKET")
def tmux_command(*args):
"""All agent sessions live on one tmux server; tests point AGENTS_TMUX_SOCKET at a private one."""
return ["tmux", *(["-L", TMUX_SOCKET] if TMUX_SOCKET else []), *args]
def tmux(*args, check=True):
return subprocess.run(tmux_command(*args), check=check, capture_output=True, text=True)
def is_alive(task_id):
return tmux("has-session", "-t", f"={session_name(task_id)}", check=False).returncode == 0
FAKE_AGENT_MODEL = "echo"
def provider_for_model(agent_model):
"""The dsh provider that serves a model: DeepSeek models via 'deepseek-official',
Claude models (incl. the legacy 'claude:<alias>' form) via dsh's 'claude' provider."""
return "deepseek-official" if agent_model.startswith("deepseek") else "claude"
def model_argument(agent_model):
"""Validate agent_model and return the Claude `--model` value, or None when the model takes no
`--model` flag ('echo' test agent, or a DeepSeek model). agent_model is a full model id
('claude-opus-5-5', 'deepseek-chat'), the legacy 'claude:<alias>' form, or 'echo'."""
if agent_model == FAKE_AGENT_MODEL:
return None
if agent_model.startswith("deepseek"):
return None
if agent_model.startswith("claude:"):
return agent_model.split(":", 1)[1] or None
if agent_model.startswith("claude-"):
return agent_model
raise ValueError(f"unknown agent_model {agent_model!r}")
def validate_harness(harness):
if harness not in HARNESSES:
raise ValueError(f"harness must be one of {tuple(HARNESSES)}")
return harness
def validate_model_harness(agent_model, harness):
"""Claude Code can only run Claude models; dsh runs either. Raises on an unsupported pair."""
validate_harness(harness)
if harness == "claude-code" and provider_for_model(agent_model) != "claude":
raise ValueError(f"harness 'claude-code' cannot run model {agent_model!r}; use the 'dsh' harness")
def mcp_config():
"""The task-manager MCP server as agent sessions launch it: this checkout's server.py, same data paths."""
server = {"command": "uv", "args": ["run", "--project", HERE, "python", os.path.join(HERE, "server.py")],
"env": {"TASKS_DB": DB_PATH, "AGENTS_SESSIONS_DIR": SESSIONS_ROOT}}
return {"mcpServers": {"task-manager": server}}
def hook_settings():
command = f"{shlex.quote(sys.executable)} {shlex.quote(HOOK_SCRIPT)}"
hooks = {event: [{"hooks": [{"type": "command", "command": f"{command} {event}"}]}] for event in HOOK_EVENTS}
return {"hooks": hooks}
def oauth_environment():
"""Use the subrouter subscription when it can produce a token; otherwise Claude Code keeps its own login."""
if not shutil.which("node") or not os.path.exists(TOKEN_HELPER):
return {}, "claude login"
result = subprocess.run(["node", TOKEN_HELPER], capture_output=True, text=True)
if result.returncode != 0 or not result.stdout:
return {}, f"claude login (subrouter unavailable: {result.stderr.strip()[:160]})"
return {"CLAUDE_CODE_OAUTH_TOKEN": result.stdout}, "subrouter"
def subrouter_status() -> dict:
"""Return subrouter account pool status as structured data."""
if not shutil.which("node") or not os.path.exists(SUBROUTER_BIN):
return {"available": False, "reason": "subrouter not installed"}
accounts_result = subprocess.run([SUBROUTER_BIN, "account", "list", "--json"],
capture_output=True, text=True)
status_result = subprocess.run([SUBROUTER_BIN, "status"], capture_output=True, text=True)
accounts = {}
if accounts_result.returncode == 0 and accounts_result.stdout.strip():
try:
accounts = json.loads(accounts_result.stdout)
except Exception:
pass
return {
"available": True,
"accounts": accounts,
"summary": status_result.stdout.strip() if status_result.returncode == 0 else status_result.stderr.strip(),
"login_hint": "Run 'subrouter login anthropic' to add or refresh an account. Run it again to add a second account to the rotation pool.",
}
def task_harness(task):
"""The task's harness, defaulting to Claude Code for rows written before the field existed."""
try:
return task["harness"] or DEFAULT_HARNESS
except (KeyError, IndexError):
return DEFAULT_HARNESS
def agent_command(task, session_uuid, resume):
# dsh tasks don't launch a process (see start()); only Claude Code / echo reach here.
if task["agent_model"] == FAKE_AGENT_MODEL:
return [sys.executable, os.path.join(HERE, "fake_agent.py")]
return claude_command(task, session_uuid, resume)
def claude_command(task, session_uuid, resume):
mode = task["permission_mode"] or "auto"
if mode not in PERMISSION_MODES:
raise ValueError(f"permission mode must be one of {tuple(PERMISSION_MODES)}")
command = ["claude", "--mcp-config", json.dumps(mcp_config()), "--strict-mcp-config", "--setting-sources", "project,local",
"--permission-mode", PERMISSION_MODES[mode], "--allowedTools", "mcp__task-manager",
"--settings", json.dumps(hook_settings())]
model = model_argument(task["agent_model"])
if model:
command += ["--model", model]
if task["effort"] and task["effort"] != "default":
if task["effort"] not in EFFORTS:
raise ValueError(f"effort must be one of {EFFORTS}")
command += ["--effort", task["effort"]]
if resume:
return command + ["--resume", session_uuid]
return command + ["--session-id", session_uuid, "-n", f"task-{task['id']}: {task['title']}",
WORKER_PROMPT.format(id=task["id"])]
def prepare_workspace(task_id):
directory = session_directory(task_id)
for folder in WORKSPACE_FOLDERS:
os.makedirs(os.path.join(directory, folder), exist_ok=True)
return directory
def workspace(task_id):
directory = session_directory(task_id)
files = []
if os.path.isdir(directory):
for root, _, names in os.walk(directory):
for name in names:
path = os.path.join(root, name)
files.append({"path": os.path.relpath(path, directory), "bytes": os.path.getsize(path)})
return {
"directory": directory,
"folders": {
"reports": "findings and final write-ups; finish with reports/summary.md",
"notes": "running notes and plans",
"tmp": "scratch and intermediate data, safe to delete",
"artifacts": "outputs other tasks or the human should use",
},
"files": sorted(files, key=lambda entry: entry["path"]),
}
CONTEXT_DOCS = {
"summary": os.path.join("reports", "summary.md"),
"notes": "notes",
"artifacts": "artifacts",
}
CONTEXT_DOC_LIMIT = 20000
def _read_text(path):
try:
with open(path, encoding="utf-8", errors="replace") as handle:
text = handle.read(CONTEXT_DOC_LIMIT + 1)
except OSError:
return None
if len(text) > CONTEXT_DOC_LIMIT:
return text[:CONTEXT_DOC_LIMIT] + "\n[...truncated]"
return text
def read_context_docs(task_id, parts):
"""Collect the requested workspace documents for one task. 'summary' is reports/summary.md; 'notes' and
'artifacts' return every readable file under those folders. Returns a part->content (or part->{path: content})
map holding only what actually exists."""
directory = session_directory(task_id)
docs = {}
for part in parts:
relative = CONTEXT_DOCS.get(part)
if relative is None:
continue
target = os.path.join(directory, relative)
if part == "summary":
text = _read_text(target)
if text is not None:
docs[part] = text
continue
if not os.path.isdir(target):
continue
folder = {}
for root, _, names in os.walk(target):
for name in sorted(names):
full = os.path.join(root, name)
text = _read_text(full)
if text is not None:
folder[os.path.relpath(full, directory)] = text
if folder:
docs[part] = folder
return docs
def conversation_path(directory, session_uuid):
"""Claude Code keeps each conversation under ~/.claude/projects/<cwd with '/' and '.' replaced by '-'>."""
project = os.path.realpath(directory).replace("/", "-").replace(".", "-")
return os.path.join(os.path.expanduser("~"), ".claude", "projects", project, f"{session_uuid}.jsonl")
def conversation_exists(directory, session_uuid):
return os.path.exists(conversation_path(directory, session_uuid))
def exit_marker_path(task_id):
return os.path.join(session_directory(task_id), "tmp", "session-exited")
def exit_log_path(task_id):
return os.path.join(session_directory(task_id), "tmp", "session-exit.log")
def last_exit_reason(task_id):
path = exit_log_path(task_id)
if not os.path.exists(path):
return None
with open(path, errors="replace") as log:
lines = [line.strip() for line in log.read().splitlines() if line.strip()]
return " | ".join(lines[-3:])[:300] if lines else None
def set_state(task_id, state, detail=None):
with connect() as conn:
conn.execute(
"UPDATE tasks SET session_state=?, session_detail=?, session_updated_at=CURRENT_TIMESTAMP WHERE id=?",
(state, detail, task_id))
def launch_settings(task):
return {"agent_model": task["agent_model"], "harness": task_harness(task), "effort": task["effort"] or None,
"permission_mode": task["permission_mode"] or "auto"}
def restart(task_id, fresh=False):
"""Stop the session and start it again with the task's current model/effort/mode.
fresh=False keeps the conversation (resume); fresh=True starts a new conversation."""
stop(task_id)
if fresh:
with connect() as conn:
conn.execute("UPDATE tasks SET claude_session=NULL WHERE id=?", (task_id,))
return start(task_id)
def start_dsh_link(task_id, task):
"""A dsh task runs on your shared dsh web server (AGENTS_DSH_WEB_PORT, default 3080), not a process
the board owns. Record the deep link carrying the task id; the taskboard-deeplink plugin on that
server opens the task's session and seeds the worker prompt. 'Open session' points the browser at it."""
prepare_workspace(task_id)
url = dsh_deeplink_url(task_id)
with connect() as conn:
conn.execute(
"""UPDATE tasks SET session_state='working', session_detail=?, session_updated_at=CURRENT_TIMESTAMP,
session_launch=?,
status=CASE WHEN status IN ('backlog', 'queued', 'blocked') THEN 'wip' ELSE status END,
updated_at=CURRENT_TIMESTAMP
WHERE id=?""",
(url, json.dumps(launch_settings(task)), task_id))
log_event(conn, task_id, "session", f"dsh link on shared server: {url}")
return describe(task_id)
def start(task_id):
"""Start the task's session headless, or resume it if it ended. Returns the session description."""
with connect() as conn:
task = conn.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
if task is None:
raise ValueError(f"task {task_id} not found")
if describe(task_id)["alive"]:
return describe(task_id)
if task_harness(task) == "dsh":
return start_dsh_link(task_id, task)
if is_alive(task_id):
tmux("kill-session", "-t", f"={session_name(task_id)}", check=False)
directory = prepare_workspace(task_id)
if os.path.exists(exit_marker_path(task_id)):
os.remove(exit_marker_path(task_id))
resume = task["claude_session"] is not None and conversation_exists(directory, task["claude_session"])
session_uuid = task["claude_session"] if resume else str(uuid.uuid4())
command = agent_command(task, session_uuid, resume)
environment, auth = oauth_environment()
environment.update({"TASK_ID": str(task_id), "TASKS_DB": DB_PATH})
if os.environ.get("AGENTS_EXIT_FIFO"):
environment["AGENTS_EXIT_FIFO"] = os.environ["AGENTS_EXIT_FIFO"]
exit_log = exit_log_path(task_id)
on_exit = (f"{shlex.quote(sys.executable)} {shlex.quote(os.path.join(HERE, 'session_exit.py'))} "
f"{task_id} \"$status\" {shlex.quote(exit_log)}")
shell = f"{shlex.join(command)} 2> >(tee {shlex.quote(exit_log)} >&2); status=$?; {on_exit}"
with connect() as conn:
conn.execute(
"""UPDATE tasks SET claude_session=?, tmux_window=?, session_state='starting', session_detail=?,
session_updated_at=CURRENT_TIMESTAMP, session_launch=?,
status=CASE WHEN status IN ('backlog', 'queued', 'blocked') THEN 'wip' ELSE status END,
updated_at=CURRENT_TIMESTAMP
WHERE id=?""",
(session_uuid, session_name(task_id), f"auth: {auth}", json.dumps(launch_settings(task)), task_id))
log_event(conn, task_id, "session",
f"{'resumed' if resume else 'started'} {task['agent_model']} effort={task['effort'] or 'default'} "
f"mode={task['permission_mode'] or 'auto'} "
f"in tmux {session_name(task_id)} ({auth})")
arguments = ["new-session", "-d", "-s", session_name(task_id), "-c", directory, "-x", "200", "-y", "50"]
for key, value in environment.items():
arguments += ["-e", f"{key}={value}"]
tmux(*arguments, "bash", "-c", shell)
return describe(task_id)
def send(task_id, message):
"""Type a message into the running session as if the human typed it."""
if not describe(task_id)["alive"]:
raise ValueError(f"session for task {task_id} is not running; start it first")
target = f"={session_name(task_id)}:"
tmux("send-keys", "-t", target, "-l", message)
tmux("send-keys", "-t", target, "Enter")
with connect() as conn:
log_event(conn, task_id, "message", message)
return describe(task_id)
def stop(task_id):
if is_alive(task_id):
tmux("kill-session", "-t", f"={session_name(task_id)}", check=False)
if describe(task_id)["state"] not in (None, "ended"):
set_state(task_id, "ended", "stopped from the board")
return describe(task_id)
def stop_for_deleted_task(task_id):
"""Kill the session of a task that no longer exists; its state row is gone with the task."""
if is_alive(task_id):
tmux("kill-session", "-t", f"={session_name(task_id)}", check=False)
def screen(task_id, lines=60):
if not is_alive(task_id):
return ""
result = tmux("capture-pane", "-p", "-t", f"={session_name(task_id)}:", "-S", f"-{lines}", check=False)
return result.stdout
def describe(task_id):
with connect() as conn:
row = conn.execute(
"SELECT session_state, session_detail, session_updated_at, session_launch, harness FROM tasks WHERE id=?",
(task_id,)).fetchone()
state = row["session_state"] if row and row["session_state"] else None
if row and (row["harness"] or DEFAULT_HARNESS) == "dsh":
# dsh tasks run on your shared dsh server, not a board-owned process; "alive" = a live link.
alive = state not in (None, "ended")
else:
agent_exited = os.path.exists(exit_marker_path(task_id))
alive = is_alive(task_id) and not agent_exited
if not alive and state not in (None, "ended"):
state = "ended"
return {
"task_id": task_id,
"alive": alive,
"state": state,
"detail": row["session_detail"] if row else None,
"updated_at": row["session_updated_at"] if row else None,
"launched_with": json.loads(row["session_launch"]) if row and row["session_launch"] else None,
"tmux_target": session_name(task_id),
"directory": session_directory(task_id),
}
def reconcile():
"""Sessions whose tmux session vanished are ended (e.g. after a reboot). dsh tasks have no
board-owned process (they live on your shared dsh server), so they are left untouched."""
with connect() as conn:
rows = conn.execute(
"SELECT id FROM tasks WHERE session_state IS NOT NULL AND session_state != 'ended' "
"AND (harness IS NULL OR harness != 'dsh')").fetchall()
for row in rows:
if not is_alive(row["id"]):
set_state(row["id"], "ended", "session process is gone")