subtask-mcp 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- subtask/__init__.py +3 -0
- subtask/admission.py +254 -0
- subtask/capacity.py +90 -0
- subtask/cli.py +183 -0
- subtask/config.py +59 -0
- subtask/core.py +603 -0
- subtask/doctor.py +120 -0
- subtask/engines/__init__.py +1 -0
- subtask/engines/base.py +297 -0
- subtask/engines/claude_api.py +204 -0
- subtask/engines/codex.py +182 -0
- subtask/engines/demo.py +50 -0
- subtask/engines/dsh.py +96 -0
- subtask/engines/kimi.py +75 -0
- subtask/engines/opencode.py +87 -0
- subtask/engines/registry.py +124 -0
- subtask/events.py +105 -0
- subtask/evidence.py +103 -0
- subtask/fastserver.py +272 -0
- subtask/gc.py +99 -0
- subtask/install/__init__.py +1 -0
- subtask/install/clients.py +355 -0
- subtask/install/commands.py +122 -0
- subtask/install/doctor.py +112 -0
- subtask/install/fileops.py +69 -0
- subtask/install/installer.py +204 -0
- subtask/install/ownership.py +44 -0
- subtask/install/tomledit.py +44 -0
- subtask/jobs.py +256 -0
- subtask/ledger.py +268 -0
- subtask/narrate.py +134 -0
- subtask/policy.py +58 -0
- subtask/procid.py +97 -0
- subtask/progress.py +152 -0
- subtask/redact.py +61 -0
- subtask/roi.py +84 -0
- subtask/router.py +202 -0
- subtask/runners.py +99 -0
- subtask/schemacheck.py +131 -0
- subtask/server.py +192 -0
- subtask/skill/SKILL.md +96 -0
- subtask/telemetry.py +76 -0
- subtask/tools.json +237 -0
- subtask/worker.py +278 -0
- subtask_mcp-0.2.3.dist-info/METADATA +164 -0
- subtask_mcp-0.2.3.dist-info/RECORD +50 -0
- subtask_mcp-0.2.3.dist-info/WHEEL +5 -0
- subtask_mcp-0.2.3.dist-info/entry_points.txt +4 -0
- subtask_mcp-0.2.3.dist-info/licenses/LICENSE +21 -0
- subtask_mcp-0.2.3.dist-info/top_level.txt +1 -0
subtask/events.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Per-job event log: what really happened, in order. Feeds progress and the narrator.
|
|
2
|
+
|
|
3
|
+
One JSON line per event in jobs/<id>/progress.jsonl:
|
|
4
|
+
{"v":1,"job":..,"attempt":..,"seq":..,"ts":..,"phase":..,"state":..,"msg":..,"source":..}
|
|
5
|
+
|
|
6
|
+
Writers take an flock so `seq` is strictly increasing across processes.
|
|
7
|
+
Transient activity is capped per job; lifecycle and terminal events always land.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import fcntl
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import config
|
|
19
|
+
from .redact import redact
|
|
20
|
+
|
|
21
|
+
VERSION = 1
|
|
22
|
+
NAME = "progress.jsonl"
|
|
23
|
+
MAX_TRANSIENT = 2000 # activity lines per job; terminal and lifecycle events are never dropped
|
|
24
|
+
TERMINAL = {"done", "failed", "stopped"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _path(job_id: str) -> Path:
|
|
28
|
+
return config.JOBS / job_id / NAME
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def emit(
|
|
32
|
+
job_id: str,
|
|
33
|
+
phase: str,
|
|
34
|
+
msg: str = "",
|
|
35
|
+
state: str | None = None,
|
|
36
|
+
source: str = "lifecycle",
|
|
37
|
+
attempt: int | None = None,
|
|
38
|
+
) -> dict | None:
|
|
39
|
+
path = _path(job_id)
|
|
40
|
+
if not path.parent.is_dir():
|
|
41
|
+
return None
|
|
42
|
+
with path.open("a+", encoding="utf-8") as fh:
|
|
43
|
+
fcntl.flock(fh, fcntl.LOCK_EX)
|
|
44
|
+
last = _last(fh)
|
|
45
|
+
seq = int(last.get("seq", 0)) + 1
|
|
46
|
+
if source != "lifecycle" and state not in TERMINAL and seq > MAX_TRANSIENT:
|
|
47
|
+
return None
|
|
48
|
+
event = {
|
|
49
|
+
"v": VERSION,
|
|
50
|
+
"job": job_id,
|
|
51
|
+
"attempt": attempt,
|
|
52
|
+
"seq": seq,
|
|
53
|
+
"ts": round(time.time(), 3),
|
|
54
|
+
"phase": phase,
|
|
55
|
+
"state": state,
|
|
56
|
+
"msg": redact(msg)[:200],
|
|
57
|
+
"source": source,
|
|
58
|
+
}
|
|
59
|
+
fh.seek(0, os.SEEK_END)
|
|
60
|
+
fh.write(json.dumps(event, ensure_ascii=False) + "\n")
|
|
61
|
+
fh.flush()
|
|
62
|
+
return event
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _last(fh) -> dict:
|
|
66
|
+
fh.seek(0, os.SEEK_END)
|
|
67
|
+
size = fh.tell()
|
|
68
|
+
fh.seek(max(0, size - 4096))
|
|
69
|
+
lines = fh.read().splitlines()
|
|
70
|
+
for line in reversed(lines):
|
|
71
|
+
try:
|
|
72
|
+
return json.loads(line)
|
|
73
|
+
except json.JSONDecodeError:
|
|
74
|
+
continue
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_since(job_id: str, offset: int = 0, limit: int = 500) -> tuple[list[dict], int]:
|
|
79
|
+
"""Events after byte `offset`, and the new offset. Bounded: reads at most `limit` lines."""
|
|
80
|
+
path = _path(job_id)
|
|
81
|
+
try:
|
|
82
|
+
with path.open("rb") as fh:
|
|
83
|
+
fh.seek(offset)
|
|
84
|
+
out: list[dict] = []
|
|
85
|
+
while len(out) < limit:
|
|
86
|
+
line = fh.readline()
|
|
87
|
+
if not line or not line.endswith(b"\n"):
|
|
88
|
+
break # a half-written line is read next time
|
|
89
|
+
offset = fh.tell()
|
|
90
|
+
try:
|
|
91
|
+
out.append(json.loads(line))
|
|
92
|
+
except json.JSONDecodeError:
|
|
93
|
+
continue
|
|
94
|
+
return out, offset
|
|
95
|
+
except FileNotFoundError:
|
|
96
|
+
return [], offset
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def last(job_id: str) -> dict:
|
|
100
|
+
path = _path(job_id)
|
|
101
|
+
try:
|
|
102
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
103
|
+
return _last(fh)
|
|
104
|
+
except FileNotFoundError:
|
|
105
|
+
return {}
|
subtask/evidence.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""ResultEnvelope: what happened, with the source of every claim.
|
|
2
|
+
|
|
3
|
+
Levels:
|
|
4
|
+
- runner_observed: read by subtask itself (git refs, diff, commands seen in the engine's event stream);
|
|
5
|
+
- worker_reported: what the worker wrote (the summary); a claim, not a check;
|
|
6
|
+
- independently_verified: the parent's `verify` command, run by subtask on the recorded head.
|
|
7
|
+
No tests seen is reported as no tests, never as success.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import subprocess
|
|
13
|
+
|
|
14
|
+
from . import jobs, telemetry
|
|
15
|
+
from .engines import registry
|
|
16
|
+
|
|
17
|
+
VERSION = 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _git(repo: str, *args: str) -> str | None:
|
|
21
|
+
try:
|
|
22
|
+
done = subprocess.run(
|
|
23
|
+
["git", "-C", repo, *args], capture_output=True, text=True, timeout=30, check=False
|
|
24
|
+
)
|
|
25
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
26
|
+
return None
|
|
27
|
+
return done.stdout.strip() if done.returncode == 0 else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def envelope(job_id: str) -> dict:
|
|
31
|
+
from .core import summary_of
|
|
32
|
+
|
|
33
|
+
st, sp = jobs.status(job_id), jobs.spec(job_id)
|
|
34
|
+
folder = jobs.job_dir(job_id)
|
|
35
|
+
text = (folder / "result.md").read_text() if (folder / "result.md").is_file() else ""
|
|
36
|
+
adapter = registry.get(sp.get("engine", ""))
|
|
37
|
+
attempts = st.get("attempts") or []
|
|
38
|
+
last = attempts[-1] if attempts else {}
|
|
39
|
+
git: dict = {"level": "runner_observed"}
|
|
40
|
+
if sp.get("branch") and sp.get("repo"):
|
|
41
|
+
repo, base, branch = sp["repo"], sp["base"], sp["branch"]
|
|
42
|
+
head = _git(repo, "rev-parse", branch)
|
|
43
|
+
names = _git(repo, "diff", "--name-only", f"{base}..{branch}") or ""
|
|
44
|
+
git.update(
|
|
45
|
+
base=base,
|
|
46
|
+
head=head,
|
|
47
|
+
branch=branch,
|
|
48
|
+
commits=int(_git(repo, "rev-list", "--count", f"{base}..{branch}") or 0),
|
|
49
|
+
changed_files=[n for n in names.splitlines() if n][:200],
|
|
50
|
+
uncommitted=bool(_git(sp["cwd"], "status", "--porcelain")) if sp.get("cwd") else None,
|
|
51
|
+
)
|
|
52
|
+
commands = [c for a in attempts for c in a.get("commands") or []]
|
|
53
|
+
tests = [c for c in commands if _is_test(c.get("command", ""))]
|
|
54
|
+
verification = st.get("verification")
|
|
55
|
+
cost = {
|
|
56
|
+
"unit": "USD",
|
|
57
|
+
"observed": last.get("cost_observed"),
|
|
58
|
+
"charged_total": st.get("cost_usd"),
|
|
59
|
+
"reserved": sp.get("max_usd") if adapter and adapter.capabilities.paid else None,
|
|
60
|
+
"billing": adapter.capabilities.cost if adapter else "unknown",
|
|
61
|
+
"note": "charged = observed, or the whole reservation when the real cost is unknown",
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
"v": VERSION,
|
|
65
|
+
"id": job_id,
|
|
66
|
+
"engine": sp.get("engine"),
|
|
67
|
+
"model": sp.get("model") or (adapter.model if adapter else None),
|
|
68
|
+
"kind": sp.get("kind"),
|
|
69
|
+
"state": st.get("state"),
|
|
70
|
+
"detail": st.get("detail"),
|
|
71
|
+
"policy": sp.get("policy"),
|
|
72
|
+
"verification": verification
|
|
73
|
+
or {"level": "independently_verified", "status": "not_run", "note": "start(verify=...) to enable"},
|
|
74
|
+
"git": git,
|
|
75
|
+
"commands": {"level": "runner_observed", "seen": len(commands), "tests": tests[-20:]},
|
|
76
|
+
"tests_status": _tests_status(tests),
|
|
77
|
+
"summary": {"level": "worker_reported", "text": summary_of(text)},
|
|
78
|
+
"cost": cost,
|
|
79
|
+
"tokens": {
|
|
80
|
+
"level": "runner_observed" if last.get("tokens") else "missing",
|
|
81
|
+
**(last.get("tokens") or {}),
|
|
82
|
+
},
|
|
83
|
+
"attempts": len(attempts),
|
|
84
|
+
"round_trips": len(telemetry.calls(job_id)),
|
|
85
|
+
"error": st.get("error"),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_test(command: str) -> bool:
|
|
90
|
+
low = command.lower()
|
|
91
|
+
return any(
|
|
92
|
+
k in low
|
|
93
|
+
for k in ("pytest", "npm test", "go test", "cargo test", "unittest", "jest", "vitest", "make test")
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _tests_status(tests: list[dict]) -> str:
|
|
98
|
+
if not tests:
|
|
99
|
+
return "none_observed"
|
|
100
|
+
codes = [t.get("exit_code") for t in tests]
|
|
101
|
+
if codes[-1] is None:
|
|
102
|
+
return "observed_exit_unknown"
|
|
103
|
+
return "last_passed" if codes[-1] == 0 else "last_failed"
|
subtask/fastserver.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""MCP server (stdio) without the mcp library: JSON-RPC by hand, ~10x faster start.
|
|
2
|
+
|
|
3
|
+
Dual-era (MCP 2026-07-28 "Versioning and Compatibility"):
|
|
4
|
+
- modern requests carry `_meta["io.modelcontextprotocol/protocolVersion"]` and
|
|
5
|
+
are served statelessly, with `resultType` and `serverInfo` in every result;
|
|
6
|
+
`server/discover` answers; unknown versions get UnsupportedProtocolVersion (-32022);
|
|
7
|
+
- legacy clients open with `initialize` (2025-11-25 and earlier) and keep the
|
|
8
|
+
negotiated revision for this process.
|
|
9
|
+
|
|
10
|
+
Progress: a tools/call with `_meta.progressToken` gets `notifications/progress`
|
|
11
|
+
while it runs (see progress.py). `notifications/cancelled` ends the call: no
|
|
12
|
+
further message is sent for it, and the jobs keep running (stop() stops work).
|
|
13
|
+
|
|
14
|
+
Importing the mcp package costs ~560 ms; this file answers before `core` is
|
|
15
|
+
even imported. Tool names, descriptions and schemas come from tools.json,
|
|
16
|
+
generated from server.py (`subtask-mcp-fastmcp --dump-tools`).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from . import __version__
|
|
28
|
+
|
|
29
|
+
SPEC = json.loads((Path(__file__).with_name("tools.json")).read_text())
|
|
30
|
+
MODERN = ("2026-07-28",)
|
|
31
|
+
LEGACY = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
|
|
32
|
+
VERSIONS = MODERN + LEGACY
|
|
33
|
+
SERVER_INFO = {"name": "subtask", "version": __version__}
|
|
34
|
+
META = "io.modelcontextprotocol/"
|
|
35
|
+
WAIT_TICK_S = 0.5
|
|
36
|
+
CACHE = {"ttlMs": 3_600_000, "cacheScope": "public"} # MUST on discover and tools/list (2026-07-28)
|
|
37
|
+
|
|
38
|
+
_out = threading.Lock()
|
|
39
|
+
_pending: list[threading.Thread] = [] # calls still running when the client closes stdin
|
|
40
|
+
_core_lock = threading.Lock() # quick state-changing tools run one at a time in this process
|
|
41
|
+
_cancelled: dict[object, threading.Event] = {}
|
|
42
|
+
_state: dict[str, str | None] = {"legacy": None} # protocol version chosen by `initialize`
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _send(msg: dict) -> None:
|
|
46
|
+
line = json.dumps(msg, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
47
|
+
with _out: # one writer: lines never interleave
|
|
48
|
+
sys.stdout.write(line)
|
|
49
|
+
sys.stdout.flush()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Call:
|
|
53
|
+
"""One in-flight tools/call: its id, progress token, cancellation and era."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, msg_id: object, token: object, modern: bool) -> None:
|
|
56
|
+
self.id, self.token, self.modern = msg_id, token, modern
|
|
57
|
+
self.cancel = _cancelled.setdefault(msg_id, threading.Event())
|
|
58
|
+
self.reporter = None
|
|
59
|
+
if isinstance(token, (str, int)) and not isinstance(token, bool):
|
|
60
|
+
from .progress import Reporter
|
|
61
|
+
|
|
62
|
+
self.reporter = Reporter(self.progress)
|
|
63
|
+
|
|
64
|
+
def progress(self, value: float, message: str, total: float | None) -> None:
|
|
65
|
+
if self.cancel.is_set():
|
|
66
|
+
return # MUST NOT send anything for a cancelled request
|
|
67
|
+
params: dict = {"progressToken": self.token, "progress": value, "message": message}
|
|
68
|
+
if total is not None:
|
|
69
|
+
params["total"] = total
|
|
70
|
+
_send({"jsonrpc": "2.0", "method": "notifications/progress", "params": params})
|
|
71
|
+
|
|
72
|
+
def sleep(self, seconds: float) -> bool:
|
|
73
|
+
"""Sleep, polling progress; True when cancelled."""
|
|
74
|
+
end = time.monotonic() + seconds
|
|
75
|
+
while time.monotonic() < end:
|
|
76
|
+
if self.cancel.wait(min(WAIT_TICK_S, max(0.0, end - time.monotonic()))):
|
|
77
|
+
return True
|
|
78
|
+
if self.reporter:
|
|
79
|
+
self.reporter.poll()
|
|
80
|
+
return self.cancel.is_set()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _wait(
|
|
84
|
+
core, call: Call, ids: list[str], timeout: int = 300, any: bool = False, since: str | None = None
|
|
85
|
+
) -> str:
|
|
86
|
+
deadline = time.monotonic() + max(1, min(int(timeout), 600))
|
|
87
|
+
if call.reporter:
|
|
88
|
+
for job_id in ids:
|
|
89
|
+
if core.known(job_id):
|
|
90
|
+
call.reporter.add(job_id)
|
|
91
|
+
call.reporter.poll()
|
|
92
|
+
while True:
|
|
93
|
+
out = core.wait(ids, 0, any, since=since)
|
|
94
|
+
if not out.startswith("still running") or time.monotonic() >= deadline:
|
|
95
|
+
if call.reporter:
|
|
96
|
+
call.reporter.poll()
|
|
97
|
+
return out
|
|
98
|
+
if call.sleep(1.0):
|
|
99
|
+
return "wait cancelled; jobs keep running"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _call(name: str, args: dict, call: Call) -> str:
|
|
103
|
+
from . import core
|
|
104
|
+
|
|
105
|
+
if name == "wait":
|
|
106
|
+
return _wait(core, call, **args)
|
|
107
|
+
if name == "stop":
|
|
108
|
+
if call.reporter:
|
|
109
|
+
call.reporter.add(args["id"])
|
|
110
|
+
call.reporter.phase(f"stopping {args['id']}", force=True)
|
|
111
|
+
return core.stop(args["id"])
|
|
112
|
+
if name == "step":
|
|
113
|
+
a = {"decisions": None, "repo": None, **args}
|
|
114
|
+
return core.step(a["title"], a["path"], a["how"], a["decisions"], a["repo"])
|
|
115
|
+
if name == "result":
|
|
116
|
+
return core.result(args["id"], args.get("full", False), args.get("evidence", False))
|
|
117
|
+
if name == "engines":
|
|
118
|
+
return core.engines() + "\n" + core.spend()
|
|
119
|
+
with _core_lock:
|
|
120
|
+
if name == "start":
|
|
121
|
+
return core.start(**_start_args(args), progress=call.reporter)
|
|
122
|
+
if name == "start_many":
|
|
123
|
+
return core.start_many(args["tasks"], progress=call.reporter)
|
|
124
|
+
if name == "status":
|
|
125
|
+
return core.status(args.get("id"))
|
|
126
|
+
if name == "reply":
|
|
127
|
+
return core.reply(args["id"], args["message"])
|
|
128
|
+
if name == "cleanup":
|
|
129
|
+
return core.cleanup(args["id"])
|
|
130
|
+
raise KeyError(name)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
START_ARGS = {
|
|
134
|
+
"task", "kind", "repo", "cwd", "files", "readonly", "engine", "max_usd", "effort",
|
|
135
|
+
"base", "max_turns", "policy", "verify",
|
|
136
|
+
} # fmt: skip
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _start_args(args: dict) -> dict:
|
|
140
|
+
unknown = set(args) - START_ARGS
|
|
141
|
+
if unknown:
|
|
142
|
+
raise TypeError(f"unexpected argument(s): {', '.join(sorted(unknown))}")
|
|
143
|
+
if "task" not in args:
|
|
144
|
+
raise KeyError("task")
|
|
145
|
+
return args
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _with_meta(modern: bool, payload: dict) -> dict:
|
|
149
|
+
if modern:
|
|
150
|
+
return {"resultType": "complete", **payload, "_meta": {META + "serverInfo": SERVER_INFO}}
|
|
151
|
+
return payload
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _tool_call(msg_id: object, params: dict, modern: bool) -> None:
|
|
155
|
+
meta = params.get("_meta") or {}
|
|
156
|
+
call = Call(msg_id, meta.get("progressToken"), modern)
|
|
157
|
+
name, args = params.get("name", ""), params.get("arguments") or {}
|
|
158
|
+
try:
|
|
159
|
+
text, error = _call(name, args, call), False
|
|
160
|
+
except KeyError as exc:
|
|
161
|
+
text, error = f"unknown tool or missing argument: {exc}", True
|
|
162
|
+
except TypeError as exc:
|
|
163
|
+
text, error = f"bad arguments: {exc}", True
|
|
164
|
+
except Exception as exc: # noqa: BLE001 - a tool error is a result, never a crash
|
|
165
|
+
text, error = f"{type(exc).__name__}: {exc}", True
|
|
166
|
+
finally:
|
|
167
|
+
_cancelled.pop(msg_id, None)
|
|
168
|
+
if call.cancel.is_set():
|
|
169
|
+
return # cancelled: no response, no notification
|
|
170
|
+
from .redact import redact
|
|
171
|
+
|
|
172
|
+
payload = {"content": [{"type": "text", "text": redact(text)}], "isError": error}
|
|
173
|
+
_send({"jsonrpc": "2.0", "id": msg_id, "result": _with_meta(modern, payload)})
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _error(msg_id: object, code: int, message: str, data: object = None) -> None:
|
|
177
|
+
err: dict = {"code": code, "message": message}
|
|
178
|
+
if data is not None:
|
|
179
|
+
err["data"] = data
|
|
180
|
+
_send({"jsonrpc": "2.0", "id": msg_id, "error": err})
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _era(msg: dict) -> tuple[bool | None, str | None]:
|
|
184
|
+
"""(modern?, problem). Modern when the request declares a version in _meta."""
|
|
185
|
+
meta = (msg.get("params") or {}).get("_meta") or {}
|
|
186
|
+
version = meta.get(META + "protocolVersion")
|
|
187
|
+
if version is None:
|
|
188
|
+
return False, None
|
|
189
|
+
if version not in VERSIONS:
|
|
190
|
+
return None, str(version)
|
|
191
|
+
if version not in MODERN:
|
|
192
|
+
return False, None # a legacy version named per request: serve it as legacy
|
|
193
|
+
if META + "clientCapabilities" not in meta:
|
|
194
|
+
return None, "missing-capabilities"
|
|
195
|
+
return True, None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _handle(msg: dict) -> None:
|
|
199
|
+
method, msg_id = msg.get("method"), msg.get("id")
|
|
200
|
+
if msg_id is None:
|
|
201
|
+
if method == "notifications/cancelled":
|
|
202
|
+
event = _cancelled.get((msg.get("params") or {}).get("requestId"))
|
|
203
|
+
if event:
|
|
204
|
+
event.set()
|
|
205
|
+
return # other notifications are never answered
|
|
206
|
+
if method == "initialize":
|
|
207
|
+
asked = (msg.get("params") or {}).get("protocolVersion")
|
|
208
|
+
chosen = asked if asked in LEGACY else LEGACY[0]
|
|
209
|
+
_state["legacy"] = chosen
|
|
210
|
+
result = {
|
|
211
|
+
"protocolVersion": chosen,
|
|
212
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
213
|
+
"serverInfo": SERVER_INFO,
|
|
214
|
+
"instructions": SPEC["instructions"],
|
|
215
|
+
}
|
|
216
|
+
_send({"jsonrpc": "2.0", "id": msg_id, "result": result})
|
|
217
|
+
return
|
|
218
|
+
modern, problem = _era(msg)
|
|
219
|
+
if modern is None:
|
|
220
|
+
if problem == "missing-capabilities":
|
|
221
|
+
_error(msg_id, -32602, "Invalid params: missing _meta io.modelcontextprotocol/clientCapabilities")
|
|
222
|
+
else:
|
|
223
|
+
_error(
|
|
224
|
+
msg_id,
|
|
225
|
+
-32022,
|
|
226
|
+
"Unsupported protocol version",
|
|
227
|
+
{"supported": list(VERSIONS), "requested": problem},
|
|
228
|
+
)
|
|
229
|
+
return
|
|
230
|
+
if method == "server/discover":
|
|
231
|
+
result = {
|
|
232
|
+
"supportedVersions": list(VERSIONS),
|
|
233
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
234
|
+
"instructions": SPEC["instructions"],
|
|
235
|
+
**CACHE,
|
|
236
|
+
}
|
|
237
|
+
_send({"jsonrpc": "2.0", "id": msg_id, "result": _with_meta(True, result)})
|
|
238
|
+
elif method == "tools/list":
|
|
239
|
+
listing = {"tools": SPEC["tools"], **(CACHE if modern else {})}
|
|
240
|
+
_send({"jsonrpc": "2.0", "id": msg_id, "result": _with_meta(modern, listing)})
|
|
241
|
+
elif method == "tools/call":
|
|
242
|
+
_cancelled.setdefault(msg_id, threading.Event()) # a cancel may arrive before the thread runs
|
|
243
|
+
worker = threading.Thread(
|
|
244
|
+
target=_tool_call, args=(msg_id, msg.get("params") or {}, modern), daemon=True
|
|
245
|
+
)
|
|
246
|
+
worker.start()
|
|
247
|
+
_pending.append(worker)
|
|
248
|
+
elif method == "ping":
|
|
249
|
+
_send({"jsonrpc": "2.0", "id": msg_id, "result": _with_meta(modern, {})})
|
|
250
|
+
else:
|
|
251
|
+
_error(msg_id, -32601, f"no method {method}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def main() -> None:
|
|
255
|
+
for line in sys.stdin:
|
|
256
|
+
if not line.strip():
|
|
257
|
+
continue
|
|
258
|
+
try:
|
|
259
|
+
msg = json.loads(line)
|
|
260
|
+
except json.JSONDecodeError:
|
|
261
|
+
_error(None, -32700, "parse error")
|
|
262
|
+
continue
|
|
263
|
+
if isinstance(msg, dict):
|
|
264
|
+
_handle(msg)
|
|
265
|
+
else:
|
|
266
|
+
_error(None, -32600, "invalid request")
|
|
267
|
+
for worker in _pending:
|
|
268
|
+
worker.join(timeout=30) # stdin closed: still answer the calls already received
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
main()
|
subtask/gc.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Retention and disk usage: report first, delete only with --apply, never user work.
|
|
2
|
+
|
|
3
|
+
Removable: folders of finished jobs older than the retention, whose worktree is
|
|
4
|
+
already gone or clean (no uncommitted and no ignored files), telemetry older
|
|
5
|
+
than its retention. Never removed: active jobs, dirty worktrees, branches.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import config, jobs, telemetry
|
|
17
|
+
|
|
18
|
+
RETENTION_DAYS = int(os.environ.get("SUBTASK_JOB_RETENTION_DAYS", "30"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _size(path: Path) -> int:
|
|
22
|
+
total = 0
|
|
23
|
+
for root, _dirs, files in os.walk(path):
|
|
24
|
+
for name in files:
|
|
25
|
+
try:
|
|
26
|
+
total += (Path(root) / name).lstat().st_size
|
|
27
|
+
except OSError:
|
|
28
|
+
continue
|
|
29
|
+
return total
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def du() -> dict:
|
|
33
|
+
parts = {
|
|
34
|
+
"jobs": config.JOBS,
|
|
35
|
+
"worktrees": config.WORKTREES,
|
|
36
|
+
"ledger": config.HOME / "ledger",
|
|
37
|
+
"telemetry": config.HOME / "telemetry",
|
|
38
|
+
}
|
|
39
|
+
return {name: _size(path) if path.exists() else 0 for name, path in parts.items()}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _worktree_clean(path: str) -> bool | None:
|
|
43
|
+
if not Path(path).exists():
|
|
44
|
+
return None
|
|
45
|
+
done = subprocess.run(
|
|
46
|
+
["git", "-C", path, "status", "--porcelain", "--ignored"], capture_output=True, text=True, check=False
|
|
47
|
+
)
|
|
48
|
+
return done.returncode == 0 and not done.stdout.strip()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def plan(days: int = RETENTION_DAYS) -> list[dict]:
|
|
52
|
+
cutoff = time.time() - days * 86400
|
|
53
|
+
out = []
|
|
54
|
+
for job_id in jobs.all_jobs():
|
|
55
|
+
folder = jobs.job_dir(job_id)
|
|
56
|
+
st, sp = jobs.status(job_id), jobs.spec(job_id)
|
|
57
|
+
if st.get("state") not in jobs.FINAL:
|
|
58
|
+
continue
|
|
59
|
+
if float(st.get("updated") or folder.stat().st_mtime) > cutoff:
|
|
60
|
+
continue
|
|
61
|
+
item = {"job": job_id, "folder": str(folder), "bytes": _size(folder), "worktree": None}
|
|
62
|
+
if sp.get("branch") and sp.get("cwd"):
|
|
63
|
+
clean = _worktree_clean(sp["cwd"])
|
|
64
|
+
if clean is False:
|
|
65
|
+
item["skip"] = "worktree has uncommitted or ignored files"
|
|
66
|
+
elif clean:
|
|
67
|
+
item["worktree"] = sp["cwd"]
|
|
68
|
+
out.append(item)
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run(days: int = RETENTION_DAYS, apply: bool = False) -> str:
|
|
73
|
+
items = plan(days)
|
|
74
|
+
old_telemetry = telemetry.prune(dry_run=not apply)
|
|
75
|
+
removed = kept = freed = 0
|
|
76
|
+
lines = []
|
|
77
|
+
for item in items:
|
|
78
|
+
if item.get("skip"):
|
|
79
|
+
kept += 1
|
|
80
|
+
lines.append(f"keep {item['job']}: {item['skip']}")
|
|
81
|
+
continue
|
|
82
|
+
if apply:
|
|
83
|
+
if item["worktree"]:
|
|
84
|
+
sp = jobs.spec(item["job"])
|
|
85
|
+
done = subprocess.run(["git", "-C", sp["repo"], "worktree", "remove", item["worktree"]],
|
|
86
|
+
capture_output=True, text=True, check=False) # fmt: skip
|
|
87
|
+
if done.returncode != 0:
|
|
88
|
+
kept += 1
|
|
89
|
+
lines.append(f"keep {item['job']}: worktree not removed ({done.stderr.strip()[:80]})")
|
|
90
|
+
continue
|
|
91
|
+
shutil.rmtree(item["folder"])
|
|
92
|
+
removed += 1
|
|
93
|
+
freed += item["bytes"]
|
|
94
|
+
verb = "removed" if apply else "would remove"
|
|
95
|
+
head = f"{verb} {removed} job folders ({freed / 1e6:.1f} MB), kept {kept}; {verb} {len(old_telemetry)} telemetry files"
|
|
96
|
+
usage = ", ".join(f"{k} {v / 1e6:.1f} MB" for k, v in du().items())
|
|
97
|
+
return "\n".join(
|
|
98
|
+
[head, f"disk: {usage}", *lines[:20]] + ([] if apply else ["(dry run: add --apply to delete)"])
|
|
99
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Client installers: register the subtask MCP server and skill in coding-agent apps."""
|