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/__init__.py
ADDED
subtask/admission.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Global admission: at most MAX_RUNNING active jobs across every CLI and MCP process.
|
|
2
|
+
|
|
3
|
+
The active index (<HOME>/active.json) is the source of truth for slots and is
|
|
4
|
+
only changed under <HOME>/admission.lock. It is rebuildable: `rebuild()` scans
|
|
5
|
+
job folders when the index is missing or corrupt. Entries:
|
|
6
|
+
|
|
7
|
+
{"job": id, "phase": "admitting"|"running", "owner": identity, "worker": identity|None, "since": ts}
|
|
8
|
+
|
|
9
|
+
Lock order: a job lock, then admission.lock, then the ledger lock. Nothing
|
|
10
|
+
slow (git, spawn, network) runs while admission.lock is held.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import fcntl
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Iterator
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from . import config, procid
|
|
24
|
+
|
|
25
|
+
ADMIT_GRACE_S = 120 # an 'admitting' entry whose owner is alive but silent this long is stale
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _index() -> Path:
|
|
29
|
+
return config.HOME / "active.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _markers() -> Path:
|
|
33
|
+
"""One file per job between admission and its first status: survives a corrupt index."""
|
|
34
|
+
return config.HOME / "admitting"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def locked() -> Iterator[None]:
|
|
39
|
+
config.HOME.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
with (config.HOME / "admission.lock").open("a") as lock:
|
|
41
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
42
|
+
yield
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _valid_ident(ident: object) -> bool:
|
|
46
|
+
if not isinstance(ident, dict):
|
|
47
|
+
return False
|
|
48
|
+
try:
|
|
49
|
+
return int(ident.get("pid")) > 0 # type: ignore[arg-type]
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _valid_entry(entry: object) -> bool:
|
|
55
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("job"), str):
|
|
56
|
+
return False
|
|
57
|
+
for key in ("owner", "worker"):
|
|
58
|
+
if entry.get(key) is not None and not _valid_ident(entry[key]):
|
|
59
|
+
return False
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read() -> dict[str, dict] | None:
|
|
64
|
+
"""The index, or None when it is missing or corrupt (callers then rebuild)."""
|
|
65
|
+
try:
|
|
66
|
+
data = json.loads(_index().read_text())
|
|
67
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
68
|
+
return None
|
|
69
|
+
if not isinstance(data, dict) or not isinstance(data.get("jobs"), dict):
|
|
70
|
+
return None
|
|
71
|
+
return {k: v for k, v in data["jobs"].items() if _valid_entry(v)} # a bad entry is dropped, not fatal
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _write(entries: dict[str, dict]) -> None:
|
|
75
|
+
path = _index()
|
|
76
|
+
tmp = path.with_suffix(f".{os.getpid()}.tmp")
|
|
77
|
+
tmp.write_text(json.dumps({"v": 1, "jobs": entries}, indent=1))
|
|
78
|
+
os.replace(tmp, path)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _mark(entry: dict) -> None:
|
|
82
|
+
_markers().mkdir(parents=True, exist_ok=True)
|
|
83
|
+
tmp = _markers() / f".{entry['job']}.{os.getpid()}.tmp"
|
|
84
|
+
tmp.write_text(json.dumps(entry))
|
|
85
|
+
os.replace(tmp, _markers() / f"{entry['job']}.json")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _unmark(job_id: str) -> None:
|
|
89
|
+
(_markers() / f"{job_id}.json").unlink(missing_ok=True)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def rebuild() -> dict[str, dict]:
|
|
93
|
+
"""Index from the job folders (queued/running) and the admission markers."""
|
|
94
|
+
entries: dict[str, dict] = {}
|
|
95
|
+
if _markers().is_dir():
|
|
96
|
+
for path in _markers().glob("*.json"):
|
|
97
|
+
try:
|
|
98
|
+
entry = json.loads(path.read_text())
|
|
99
|
+
except (OSError, json.JSONDecodeError):
|
|
100
|
+
continue
|
|
101
|
+
if _valid_entry(entry):
|
|
102
|
+
entries[entry["job"]] = entry
|
|
103
|
+
if config.JOBS.is_dir():
|
|
104
|
+
for folder in config.JOBS.iterdir():
|
|
105
|
+
try:
|
|
106
|
+
status = json.loads((folder / "status.json").read_text())
|
|
107
|
+
except (OSError, json.JSONDecodeError):
|
|
108
|
+
continue
|
|
109
|
+
if status.get("state") in ("queued", "running"):
|
|
110
|
+
worker = status.get("proc") or ({"pid": status["pid"]} if status.get("pid") else None)
|
|
111
|
+
entry = entries.get(folder.name) or {"job": folder.name, "owner": None}
|
|
112
|
+
entry.update(
|
|
113
|
+
phase="running" if worker else entry.get("phase", "running"),
|
|
114
|
+
since=status.get("updated", time.time()),
|
|
115
|
+
)
|
|
116
|
+
if _valid_ident(worker):
|
|
117
|
+
entry["worker"] = worker
|
|
118
|
+
entries[folder.name] = entry
|
|
119
|
+
return entries
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _live(entry: dict) -> bool:
|
|
123
|
+
"""False only with proof the entry no longer holds a slot."""
|
|
124
|
+
status, attempt = _job_state(entry["job"]), _job_attempt(entry["job"])
|
|
125
|
+
newer = entry.get("phase") == "admitting" and int(entry.get("attempt") or 1) > attempt
|
|
126
|
+
if status in ("done", "failed", "stopped") and not newer:
|
|
127
|
+
return False
|
|
128
|
+
if entry.get("phase") == "running":
|
|
129
|
+
worker = entry.get("worker")
|
|
130
|
+
if worker:
|
|
131
|
+
return procid.alive(worker) != procid.DEAD
|
|
132
|
+
return status in ("queued", "running", None)
|
|
133
|
+
owner = entry.get("owner")
|
|
134
|
+
verdict = procid.alive(owner) if owner else procid.UNKNOWN
|
|
135
|
+
if verdict == procid.DEAD:
|
|
136
|
+
return False
|
|
137
|
+
return not (verdict == procid.UNKNOWN and time.time() - float(entry.get("since") or 0) > ADMIT_GRACE_S)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _job_state(job_id: str) -> str | None:
|
|
141
|
+
try:
|
|
142
|
+
return json.loads((config.JOBS / job_id / "status.json").read_text()).get("state")
|
|
143
|
+
except (OSError, json.JSONDecodeError, AttributeError):
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _job_attempt(job_id: str) -> int:
|
|
148
|
+
try:
|
|
149
|
+
return int(json.loads((config.JOBS / job_id / "status.json").read_text()).get("attempt") or 1)
|
|
150
|
+
except (OSError, json.JSONDecodeError, AttributeError, TypeError, ValueError):
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _drop(entry: dict) -> None:
|
|
155
|
+
"""An entry lost its slot. If its job never got a status, nothing ran: release its money."""
|
|
156
|
+
_unmark(entry["job"])
|
|
157
|
+
if entry.get("phase") == "admitting" and _job_state(entry["job"]) is None:
|
|
158
|
+
from . import ledger
|
|
159
|
+
|
|
160
|
+
for row in ledger.open_for(entry["job"]):
|
|
161
|
+
ledger.close(row["rid"], 0.0, "release")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _current() -> dict[str, dict]:
|
|
165
|
+
entries = _read()
|
|
166
|
+
if entries is None:
|
|
167
|
+
entries = rebuild()
|
|
168
|
+
live = {}
|
|
169
|
+
for key, entry in entries.items():
|
|
170
|
+
if _live(entry):
|
|
171
|
+
live[key] = entry
|
|
172
|
+
else:
|
|
173
|
+
_drop(entry)
|
|
174
|
+
return live
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def admit(job_id: str, limit: int | None = None, attempt: int = 1) -> str | None:
|
|
178
|
+
"""Take a slot for job_id. None on success, else the refusal reason. Caller holds nothing."""
|
|
179
|
+
with locked():
|
|
180
|
+
return admit_locked(job_id, limit, attempt)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def admit_locked(job_id: str, limit: int | None = None, attempt: int = 1) -> str | None:
|
|
184
|
+
"""Same as admit() for a caller already holding admission.lock."""
|
|
185
|
+
limit = config.MAX_RUNNING if limit is None else limit
|
|
186
|
+
entries = _current()
|
|
187
|
+
if len(entries) >= limit:
|
|
188
|
+
_write(entries)
|
|
189
|
+
return f"{limit} sub-tasks already running: use wait()"
|
|
190
|
+
entry = {
|
|
191
|
+
"job": job_id,
|
|
192
|
+
"attempt": attempt,
|
|
193
|
+
"phase": "admitting",
|
|
194
|
+
"owner": procid.identity(),
|
|
195
|
+
"worker": None,
|
|
196
|
+
"since": time.time(),
|
|
197
|
+
}
|
|
198
|
+
_mark(entry)
|
|
199
|
+
entries[job_id] = entry
|
|
200
|
+
_write(entries)
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def running(job_id: str, worker: dict) -> None:
|
|
205
|
+
with locked():
|
|
206
|
+
entries = _read()
|
|
207
|
+
if entries is None:
|
|
208
|
+
entries = rebuild()
|
|
209
|
+
entries[job_id] = {
|
|
210
|
+
**entries.get(job_id, {"job": job_id}),
|
|
211
|
+
"phase": "running",
|
|
212
|
+
"worker": worker,
|
|
213
|
+
"since": time.time(),
|
|
214
|
+
}
|
|
215
|
+
_write(entries)
|
|
216
|
+
_unmark(job_id)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _mine(entry: dict) -> bool:
|
|
220
|
+
"""May the calling process free this entry? Its owner or worker may; anyone may once they are dead."""
|
|
221
|
+
me = procid.identity()
|
|
222
|
+
holder = entry.get("worker") if entry.get("phase") == "running" else entry.get("owner")
|
|
223
|
+
if not holder:
|
|
224
|
+
return True
|
|
225
|
+
if int(holder.get("pid", -1)) == me["pid"] and holder.get("start") in (None, me.get("start")):
|
|
226
|
+
return True
|
|
227
|
+
return procid.alive(holder) == procid.DEAD
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def release(job_id: str) -> bool:
|
|
231
|
+
"""Free the job's slot, unless a newer attempt (another live process) holds it now."""
|
|
232
|
+
with locked():
|
|
233
|
+
entries = _read()
|
|
234
|
+
if entries is None:
|
|
235
|
+
entries = rebuild()
|
|
236
|
+
entry = entries.get(job_id)
|
|
237
|
+
if entry is not None and not _mine(entry):
|
|
238
|
+
return False # a stale release (e.g. an old worker after a new reply): keep the slot
|
|
239
|
+
entries.pop(job_id, None)
|
|
240
|
+
_unmark(job_id)
|
|
241
|
+
_write(entries)
|
|
242
|
+
return True
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def active() -> list[str]:
|
|
246
|
+
"""Jobs holding a slot right now. O(active), not O(history)."""
|
|
247
|
+
with locked():
|
|
248
|
+
entries = _current()
|
|
249
|
+
_write(entries)
|
|
250
|
+
return list(entries)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def count() -> int:
|
|
254
|
+
return len(active())
|
subtask/capacity.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""How much room each engine has: value, unit, source, freshness, confidence.
|
|
2
|
+
|
|
3
|
+
Unknown stays unknown (value None). The router may use a prior for unknown
|
|
4
|
+
engines, and it says so. Quota exhaustion seen in a real run (e.g. Codex
|
|
5
|
+
"usage limit") is remembered until its reset time.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from . import config, jobs
|
|
13
|
+
from .engines import registry
|
|
14
|
+
|
|
15
|
+
CACHE_TTL_S = 120
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _cache():
|
|
19
|
+
return config.HOME / "capacity.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _hints():
|
|
23
|
+
return config.HOME / "quota-hints.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def note_exhausted(engine: str, until: float | None, message: str) -> None:
|
|
27
|
+
"""Record that a run hit the engine's quota (from the worker)."""
|
|
28
|
+
config.HOME.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
hints = jobs.read_json(_hints())
|
|
30
|
+
hints[engine] = {"seen": time.time(), "until": until, "message": message[:200]}
|
|
31
|
+
jobs.write_json(_hints(), hints)
|
|
32
|
+
_cache().unlink(missing_ok=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _hint(engine: str) -> dict | None:
|
|
36
|
+
hint = jobs.read_json(_hints()).get(engine)
|
|
37
|
+
if not hint:
|
|
38
|
+
return None
|
|
39
|
+
until = hint.get("until") or hint["seen"] + 3600 # unknown reset: assume an hour
|
|
40
|
+
return hint if until > time.time() else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def snapshot(refresh: bool = False) -> dict[str, dict]:
|
|
44
|
+
cache = _cache()
|
|
45
|
+
if not refresh and cache.is_file() and time.time() - cache.stat().st_mtime < CACHE_TTL_S:
|
|
46
|
+
cached = jobs.read_json(cache)
|
|
47
|
+
if cached.get("v") == 2:
|
|
48
|
+
return cached["engines"]
|
|
49
|
+
out: dict[str, dict] = {}
|
|
50
|
+
for name, adapter in registry.all_engines().items():
|
|
51
|
+
try:
|
|
52
|
+
reading = adapter.capacity().as_dict()
|
|
53
|
+
except Exception as exc: # noqa: BLE001 - one broken source must not hide the others
|
|
54
|
+
reading = {"value": None, "unit": "pct_free", "source": "error", "ts": time.time(),
|
|
55
|
+
"confidence": "none", "note": f"{type(exc).__name__}: {exc}"[:120]} # fmt: skip
|
|
56
|
+
hint = _hint(name)
|
|
57
|
+
if hint:
|
|
58
|
+
reading = {**reading, "value": 0.0, "confidence": "high", "source": "last run: quota exhausted",
|
|
59
|
+
"note": hint["message"], "ts": hint["seen"], "resets_at": hint.get("until")} # fmt: skip
|
|
60
|
+
out[name] = reading
|
|
61
|
+
config.HOME.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
jobs.write_json(cache, {"v": 2, "engines": out}) # atomic: parallel readers never see half a file
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def report() -> str:
|
|
67
|
+
lines = []
|
|
68
|
+
for engine, info in snapshot(refresh=True).items():
|
|
69
|
+
value = "?" if info["value"] is None else f"{info['value']:.0f}%"
|
|
70
|
+
age = int(time.time() - float(info.get("ts") or time.time()))
|
|
71
|
+
lines.append(
|
|
72
|
+
f"{engine:12} free {value:>4} · conf {info['confidence']:6} · age {age}s · {info['note']}"[:150]
|
|
73
|
+
)
|
|
74
|
+
return "\n".join(lines)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def pick(kind: str = "code", max_usd: float | None = None, readonly: bool = False) -> tuple[str, str]:
|
|
78
|
+
"""v0 API, kept for callers: (engine, reason). The router does the work."""
|
|
79
|
+
from . import policy, router
|
|
80
|
+
|
|
81
|
+
engine, reason, _ = router.pick(kind, max_usd, policy.demand(readonly=readonly))
|
|
82
|
+
return engine, reason
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def __getattr__(name: str):
|
|
86
|
+
if name == "KINDS": # v0 API: the kind table now lives in the router
|
|
87
|
+
from .router import KINDS
|
|
88
|
+
|
|
89
|
+
return KINDS
|
|
90
|
+
raise AttributeError(name)
|
subtask/cli.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Command line twin of the MCP tools, for agents or humans without MCP.
|
|
2
|
+
|
|
3
|
+
subtask start "brief" [--engine auto] [--kind code] [--repo PATH] [--readonly] [--max-usd 2]
|
|
4
|
+
subtask wait ID [ID...] [--timeout 50]
|
|
5
|
+
subtask status [ID] | result ID [--full|--evidence] | reply ID "msg" | stop ID | cleanup ID | spend | engines
|
|
6
|
+
subtask install|uninstall --client claude|codex|kimi|opencode|all [--yes]
|
|
7
|
+
subtask doctor [--json] [--client X] | gc [--days 30] [--apply] | du | roi | routing explain|calibrate | watch ID...
|
|
8
|
+
|
|
9
|
+
Differences from the MCP tools: the CLI also has --model, --no-sandbox (same as
|
|
10
|
+
--policy worktree), --untrusted, spend, steps, install, doctor, gc, du, roi,
|
|
11
|
+
routing and watch. MCP `engines` returns capacity plus spend in one call.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from . import core
|
|
20
|
+
from .install import commands as install_commands
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> int:
|
|
24
|
+
p = argparse.ArgumentParser(
|
|
25
|
+
prog="subtask", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
|
26
|
+
)
|
|
27
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
28
|
+
s = sub.add_parser("start")
|
|
29
|
+
s.add_argument("task", help="brief, or - to read it from stdin")
|
|
30
|
+
s.add_argument("--engine", default="auto")
|
|
31
|
+
s.add_argument("--kind", default="code", choices=["read", "code", "review", "hard"])
|
|
32
|
+
s.add_argument("--repo")
|
|
33
|
+
s.add_argument("--cwd")
|
|
34
|
+
s.add_argument("--base", default="HEAD")
|
|
35
|
+
s.add_argument("--readonly", action="store_true")
|
|
36
|
+
s.add_argument("--max-usd", type=float)
|
|
37
|
+
s.add_argument("--max-turns", type=int, default=60)
|
|
38
|
+
s.add_argument("--effort", default="medium")
|
|
39
|
+
s.add_argument("--model")
|
|
40
|
+
s.add_argument("--no-sandbox", action="store_true")
|
|
41
|
+
s.add_argument("--file", action="append", dest="files", help="path the agent reads first")
|
|
42
|
+
s.add_argument("--policy", choices=["worktree", "sandbox", "isolated", "untrusted"])
|
|
43
|
+
s.add_argument("--untrusted", action="store_true")
|
|
44
|
+
s.add_argument("--verify", help="command subtask runs itself on the result (exit 0 = verified)")
|
|
45
|
+
w = sub.add_parser("wait")
|
|
46
|
+
w.add_argument("ids", nargs="+")
|
|
47
|
+
w.add_argument("--timeout", type=int, default=300)
|
|
48
|
+
w.add_argument("--no-results", action="store_true")
|
|
49
|
+
w.add_argument("--any", action="store_true", help="return when the first job ends")
|
|
50
|
+
w.add_argument("--since", help="cursor from a previous wait ('' to start): adds what changed")
|
|
51
|
+
st = sub.add_parser("status")
|
|
52
|
+
st.add_argument("id", nargs="?")
|
|
53
|
+
r = sub.add_parser("result")
|
|
54
|
+
r.add_argument("id")
|
|
55
|
+
r.add_argument("--full", action="store_true")
|
|
56
|
+
r.add_argument("--evidence", action="store_true", help="JSON envelope with sources")
|
|
57
|
+
rp = sub.add_parser("reply")
|
|
58
|
+
rp.add_argument("id")
|
|
59
|
+
rp.add_argument("message")
|
|
60
|
+
for name in ("stop", "cleanup"):
|
|
61
|
+
sub.add_parser(name).add_argument("id")
|
|
62
|
+
sp = sub.add_parser("step")
|
|
63
|
+
sp.add_argument("title")
|
|
64
|
+
sp.add_argument("--path", required=True)
|
|
65
|
+
sp.add_argument("--how", required=True)
|
|
66
|
+
sp.add_argument("--decision", action="append", dest="decisions")
|
|
67
|
+
sub.add_parser("steps")
|
|
68
|
+
sub.add_parser("spend")
|
|
69
|
+
sub.add_parser("engines")
|
|
70
|
+
install_commands.add_parsers(sub)
|
|
71
|
+
g = sub.add_parser("gc")
|
|
72
|
+
g.add_argument("--days", type=int, default=30)
|
|
73
|
+
g.add_argument("--apply", action="store_true")
|
|
74
|
+
sub.add_parser("du")
|
|
75
|
+
sub.add_parser("roi")
|
|
76
|
+
ro = sub.add_parser("routing")
|
|
77
|
+
ro.add_argument("action", choices=["explain", "calibrate"])
|
|
78
|
+
ro.add_argument("--kind", default="code", choices=["read", "code", "review", "hard"])
|
|
79
|
+
ro.add_argument("--policy", choices=["worktree", "sandbox", "isolated", "untrusted"])
|
|
80
|
+
ro.add_argument("--readonly", action="store_true")
|
|
81
|
+
wa = sub.add_parser("watch", help="terminal view of job progress (diagnostic, not the chat)")
|
|
82
|
+
wa.add_argument("ids", nargs="+")
|
|
83
|
+
wa.add_argument("--interval", type=float, default=2.0)
|
|
84
|
+
a = p.parse_args()
|
|
85
|
+
|
|
86
|
+
if a.cmd in ("install", "uninstall") or (a.cmd == "doctor" and a.client):
|
|
87
|
+
return install_commands.run(a)
|
|
88
|
+
|
|
89
|
+
if a.cmd == "start":
|
|
90
|
+
task = sys.stdin.read() if a.task == "-" else a.task
|
|
91
|
+
out = core.start(
|
|
92
|
+
task,
|
|
93
|
+
a.engine,
|
|
94
|
+
a.repo,
|
|
95
|
+
a.cwd,
|
|
96
|
+
a.base,
|
|
97
|
+
a.readonly,
|
|
98
|
+
a.max_usd,
|
|
99
|
+
a.max_turns,
|
|
100
|
+
a.effort,
|
|
101
|
+
a.model,
|
|
102
|
+
not a.no_sandbox,
|
|
103
|
+
a.kind,
|
|
104
|
+
a.files,
|
|
105
|
+
policy=a.policy,
|
|
106
|
+
verify=a.verify,
|
|
107
|
+
untrusted=a.untrusted,
|
|
108
|
+
)
|
|
109
|
+
elif a.cmd == "wait":
|
|
110
|
+
out = core.wait(a.ids, a.timeout, a.any, not a.no_results, since=a.since)
|
|
111
|
+
elif a.cmd == "status":
|
|
112
|
+
out = core.status(a.id)
|
|
113
|
+
elif a.cmd == "result":
|
|
114
|
+
out = core.result(a.id, a.full, a.evidence)
|
|
115
|
+
elif a.cmd == "reply":
|
|
116
|
+
out = core.reply(a.id, a.message)
|
|
117
|
+
elif a.cmd == "step":
|
|
118
|
+
out = core.step(a.title, a.path, a.how, a.decisions)
|
|
119
|
+
elif a.cmd == "steps":
|
|
120
|
+
out = core.steps()
|
|
121
|
+
elif a.cmd == "stop":
|
|
122
|
+
out = core.stop(a.id)
|
|
123
|
+
elif a.cmd == "cleanup":
|
|
124
|
+
out = core.cleanup(a.id)
|
|
125
|
+
elif a.cmd == "engines":
|
|
126
|
+
out = core.engines()
|
|
127
|
+
elif a.cmd == "doctor":
|
|
128
|
+
from . import doctor
|
|
129
|
+
|
|
130
|
+
out = doctor.report(a.json)
|
|
131
|
+
elif a.cmd == "gc":
|
|
132
|
+
from . import gc
|
|
133
|
+
|
|
134
|
+
out = gc.run(a.days, a.apply)
|
|
135
|
+
elif a.cmd == "du":
|
|
136
|
+
from . import gc
|
|
137
|
+
|
|
138
|
+
out = "\n".join(f"{k}: {v / 1e6:.1f} MB" for k, v in gc.du().items())
|
|
139
|
+
elif a.cmd == "roi":
|
|
140
|
+
from . import roi
|
|
141
|
+
|
|
142
|
+
out = roi.report()
|
|
143
|
+
elif a.cmd == "routing":
|
|
144
|
+
from . import policy, router
|
|
145
|
+
|
|
146
|
+
wanted = policy.demand(a.readonly, a.policy)
|
|
147
|
+
out = router.explain(a.kind, None, wanted) if a.action == "explain" else router.calibrate()
|
|
148
|
+
elif a.cmd == "watch":
|
|
149
|
+
return _watch(a.ids, a.interval)
|
|
150
|
+
else:
|
|
151
|
+
out = core.spend()
|
|
152
|
+
print(out)
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _watch(ids: list[str], interval: float) -> int:
|
|
157
|
+
import time
|
|
158
|
+
|
|
159
|
+
from . import jobs
|
|
160
|
+
from .progress import Reporter
|
|
161
|
+
|
|
162
|
+
reporter = Reporter(lambda _v, message, _t: print(message, flush=True), ids, min_interval=interval)
|
|
163
|
+
try:
|
|
164
|
+
while True:
|
|
165
|
+
reporter.poll()
|
|
166
|
+
if all(jobs.status(j).get("state") in jobs.FINAL for j in ids):
|
|
167
|
+
reporter.phase(reporter.summary(), force=True)
|
|
168
|
+
return 0
|
|
169
|
+
time.sleep(0.5)
|
|
170
|
+
except KeyboardInterrupt:
|
|
171
|
+
return 130
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def run() -> int:
|
|
175
|
+
try:
|
|
176
|
+
return main()
|
|
177
|
+
except ValueError as exc: # bad id or argument: one line, no traceback
|
|
178
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
179
|
+
return 2
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
sys.exit(run())
|
subtask/config.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Settings, all overridable from the environment. No personal data lives here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
HOME = Path(os.environ.get("SUBTASK_HOME", Path.home() / ".local" / "share" / "subtask"))
|
|
10
|
+
JOBS = HOME / "jobs"
|
|
11
|
+
WORKTREES = HOME / "worktrees"
|
|
12
|
+
LEDGER = HOME / "ledger.jsonl"
|
|
13
|
+
|
|
14
|
+
# KEY=VALUE file holding ANTHROPIC_API_KEY (and optionally ANTHROPIC_WORKSPACE_ID).
|
|
15
|
+
# Keep it chmod 600; it is read by the worker only, never returned by a tool.
|
|
16
|
+
ENV_FILE = Path(os.environ.get("SUBTASK_ENV_FILE", Path.home() / ".config" / "subtask" / "env"))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _money(name: str, default: str) -> float:
|
|
20
|
+
try:
|
|
21
|
+
value = float(os.environ.get(name, default))
|
|
22
|
+
except ValueError:
|
|
23
|
+
value = float(default)
|
|
24
|
+
return value if math.isfinite(value) and value >= 0 else float(default)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
MONTHLY_CAP_USD = _money("SUBTASK_MONTHLY_CAP_USD", "180")
|
|
28
|
+
DEFAULT_MAX_USD = _money("SUBTASK_DEFAULT_MAX_USD", "2")
|
|
29
|
+
MAX_JOB_USD = _money("SUBTASK_MAX_JOB_USD", "20")
|
|
30
|
+
MAX_RUNNING = int(_money("SUBTASK_MAX_RUNNING", "4")) or 4
|
|
31
|
+
SUMMARY_LINES = 12
|
|
32
|
+
|
|
33
|
+
# DeepSeek (engine "dsh"): key file and list price in USD per million tokens.
|
|
34
|
+
# Peak rates, checked 2026-09-10; override if the price list changes.
|
|
35
|
+
DSH_KEY_FILE = Path(os.environ.get("SUBTASK_DSH_KEY_FILE", Path.home() / ".config" / "deepseek" / "api-key"))
|
|
36
|
+
DSH_INPUT_USD_M = _money("SUBTASK_DSH_INPUT_USD_M", "0.30")
|
|
37
|
+
DSH_CACHED_USD_M = _money("SUBTASK_DSH_CACHED_USD_M", "0.006")
|
|
38
|
+
DSH_OUTPUT_USD_M = _money("SUBTASK_DSH_OUTPUT_USD_M", "1.20")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def deepseek_key() -> str:
|
|
42
|
+
"""DEEPSEEK_API_KEY from the env file, else the key file. Never from the environment."""
|
|
43
|
+
key = load_secrets().get("DEEPSEEK_API_KEY", "")
|
|
44
|
+
if not key and DSH_KEY_FILE.is_file():
|
|
45
|
+
key = DSH_KEY_FILE.read_text().strip()
|
|
46
|
+
return key
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_secrets() -> dict[str, str]:
|
|
50
|
+
"""Read the env file. The key is never taken from the environment."""
|
|
51
|
+
values: dict[str, str] = {}
|
|
52
|
+
if ENV_FILE.is_file():
|
|
53
|
+
for line in ENV_FILE.read_text().splitlines():
|
|
54
|
+
line = line.strip()
|
|
55
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
56
|
+
continue
|
|
57
|
+
key, _, value = line.removeprefix("export ").partition("=")
|
|
58
|
+
values[key.strip()] = value.strip().strip('"').strip("'")
|
|
59
|
+
return values
|