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.
Files changed (50) hide show
  1. subtask/__init__.py +3 -0
  2. subtask/admission.py +254 -0
  3. subtask/capacity.py +90 -0
  4. subtask/cli.py +183 -0
  5. subtask/config.py +59 -0
  6. subtask/core.py +603 -0
  7. subtask/doctor.py +120 -0
  8. subtask/engines/__init__.py +1 -0
  9. subtask/engines/base.py +297 -0
  10. subtask/engines/claude_api.py +204 -0
  11. subtask/engines/codex.py +182 -0
  12. subtask/engines/demo.py +50 -0
  13. subtask/engines/dsh.py +96 -0
  14. subtask/engines/kimi.py +75 -0
  15. subtask/engines/opencode.py +87 -0
  16. subtask/engines/registry.py +124 -0
  17. subtask/events.py +105 -0
  18. subtask/evidence.py +103 -0
  19. subtask/fastserver.py +272 -0
  20. subtask/gc.py +99 -0
  21. subtask/install/__init__.py +1 -0
  22. subtask/install/clients.py +355 -0
  23. subtask/install/commands.py +122 -0
  24. subtask/install/doctor.py +112 -0
  25. subtask/install/fileops.py +69 -0
  26. subtask/install/installer.py +204 -0
  27. subtask/install/ownership.py +44 -0
  28. subtask/install/tomledit.py +44 -0
  29. subtask/jobs.py +256 -0
  30. subtask/ledger.py +268 -0
  31. subtask/narrate.py +134 -0
  32. subtask/policy.py +58 -0
  33. subtask/procid.py +97 -0
  34. subtask/progress.py +152 -0
  35. subtask/redact.py +61 -0
  36. subtask/roi.py +84 -0
  37. subtask/router.py +202 -0
  38. subtask/runners.py +99 -0
  39. subtask/schemacheck.py +131 -0
  40. subtask/server.py +192 -0
  41. subtask/skill/SKILL.md +96 -0
  42. subtask/telemetry.py +76 -0
  43. subtask/tools.json +237 -0
  44. subtask/worker.py +278 -0
  45. subtask_mcp-0.2.3.dist-info/METADATA +164 -0
  46. subtask_mcp-0.2.3.dist-info/RECORD +50 -0
  47. subtask_mcp-0.2.3.dist-info/WHEEL +5 -0
  48. subtask_mcp-0.2.3.dist-info/entry_points.txt +4 -0
  49. subtask_mcp-0.2.3.dist-info/licenses/LICENSE +21 -0
  50. 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."""