subtask-mcp 0.2.4__tar.gz → 0.2.7__tar.gz
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_mcp-0.2.4 → subtask_mcp-0.2.7}/PKG-INFO +2 -2
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/README.md +1 -1
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/__init__.py +1 -1
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/events.py +23 -5
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/worker.py +7 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask_mcp.egg-info/PKG-INFO +2 -2
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_concurrency.py +24 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_routing_results.py +11 -4
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_security.py +16 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/LICENSE +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/pyproject.toml +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/setup.cfg +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/admission.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/capacity.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/cli.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/config.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/core.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/doctor.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/engines/__init__.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/engines/base.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/engines/claude_api.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/engines/codex.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/engines/demo.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/engines/dsh.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/engines/kimi.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/engines/opencode.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/engines/registry.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/evidence.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/fastserver.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/gc.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/install/__init__.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/install/claude_plugin.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/install/clients.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/install/commands.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/install/doctor.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/install/fileops.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/install/installer.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/install/ownership.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/install/tomledit.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/jobs.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/ledger.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/narrate.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/policy.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/procid.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/progress.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/redact.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/roi.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/router.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/runners.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/schemacheck.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/server.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/skill/SKILL.md +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/telemetry.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/tools.json +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask_mcp.egg-info/SOURCES.txt +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask_mcp.egg-info/dependency_links.txt +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask_mcp.egg-info/entry_points.txt +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask_mcp.egg-info/requires.txt +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask_mcp.egg-info/top_level.txt +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_engines.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_falsify.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_independent_client.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_install.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_protocol.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_schema.py +0 -0
- {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_subtask.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: subtask-mcp
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
4
4
|
Summary: Background sub-agents for Claude Code, Codex, Kimi and OpenCode, over MCP or CLI, with a spend cap
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/tedin7/subtask
|
|
@@ -70,7 +70,7 @@ The distribution is `subtask-mcp` (the PyPI name `subtask` is an unrelated
|
|
|
70
70
|
project). Full guide, per client, with native commands: [docs/install.md](docs/install.md).
|
|
71
71
|
|
|
72
72
|
```sh
|
|
73
|
-
uv tool install "git+https://github.com/tedin7/subtask@v0.2.
|
|
73
|
+
uv tool install "git+https://github.com/tedin7/subtask@v0.2.7" # or: uv tool install /path/to/checkout
|
|
74
74
|
subtask install --client all # prints the plan for every detected client
|
|
75
75
|
subtask install --client all --yes # applies it: MCP server + skill
|
|
76
76
|
subtask doctor --client all # checks app, config entry and a real handshake
|
|
@@ -39,7 +39,7 @@ The distribution is `subtask-mcp` (the PyPI name `subtask` is an unrelated
|
|
|
39
39
|
project). Full guide, per client, with native commands: [docs/install.md](docs/install.md).
|
|
40
40
|
|
|
41
41
|
```sh
|
|
42
|
-
uv tool install "git+https://github.com/tedin7/subtask@v0.2.
|
|
42
|
+
uv tool install "git+https://github.com/tedin7/subtask@v0.2.7" # or: uv tool install /path/to/checkout
|
|
43
43
|
subtask install --client all # prints the plan for every detected client
|
|
44
44
|
subtask install --client all --yes # applies it: MCP server + skill
|
|
45
45
|
subtask doctor --client all # checks app, config entry and a real handshake
|
|
@@ -35,11 +35,24 @@ def emit(
|
|
|
35
35
|
state: str | None = None,
|
|
36
36
|
source: str = "lifecycle",
|
|
37
37
|
attempt: int | None = None,
|
|
38
|
+
) -> dict | None:
|
|
39
|
+
"""Append one event. Never raises: progress must not be able to fail a job."""
|
|
40
|
+
try:
|
|
41
|
+
return _emit(job_id, phase, msg, state, source, attempt)
|
|
42
|
+
except (OSError, ValueError, TypeError) as exc:
|
|
43
|
+
import sys
|
|
44
|
+
|
|
45
|
+
print(f"subtask: progress event not written: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _emit(
|
|
50
|
+
job_id: str, phase: str, msg: str, state: str | None, source: str, attempt: int | None
|
|
38
51
|
) -> dict | None:
|
|
39
52
|
path = _path(job_id)
|
|
40
53
|
if not path.parent.is_dir():
|
|
41
54
|
return None
|
|
42
|
-
with path.open("
|
|
55
|
+
with path.open("ab+") as fh:
|
|
43
56
|
fcntl.flock(fh, fcntl.LOCK_EX)
|
|
44
57
|
last = _last(fh)
|
|
45
58
|
seq = int(last.get("seq", 0)) + 1
|
|
@@ -57,16 +70,21 @@ def emit(
|
|
|
57
70
|
"source": source,
|
|
58
71
|
}
|
|
59
72
|
fh.seek(0, os.SEEK_END)
|
|
60
|
-
fh.write(json.dumps(event, ensure_ascii=False) + "\n")
|
|
73
|
+
fh.write((json.dumps(event, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
61
74
|
fh.flush()
|
|
62
75
|
return event
|
|
63
76
|
|
|
64
77
|
|
|
65
78
|
def _last(fh) -> dict:
|
|
79
|
+
"""The last complete event, reading only the tail (bytes: the cut may split a UTF-8 character)."""
|
|
66
80
|
fh.seek(0, os.SEEK_END)
|
|
67
81
|
size = fh.tell()
|
|
68
82
|
fh.seek(max(0, size - 4096))
|
|
69
|
-
|
|
83
|
+
data = fh.read()
|
|
84
|
+
text = data.decode("utf-8", errors="replace") if isinstance(data, bytes) else data
|
|
85
|
+
lines = text.splitlines()
|
|
86
|
+
if size > 4096 and lines:
|
|
87
|
+
lines = lines[1:] # the first line is cut by the seek: never trust it
|
|
70
88
|
for line in reversed(lines):
|
|
71
89
|
try:
|
|
72
90
|
return json.loads(line)
|
|
@@ -99,7 +117,7 @@ def read_since(job_id: str, offset: int = 0, limit: int = 500) -> tuple[list[dic
|
|
|
99
117
|
def last(job_id: str) -> dict:
|
|
100
118
|
path = _path(job_id)
|
|
101
119
|
try:
|
|
102
|
-
with path.open("
|
|
120
|
+
with path.open("rb") as fh:
|
|
103
121
|
return _last(fh)
|
|
104
|
-
except FileNotFoundError:
|
|
122
|
+
except (FileNotFoundError, OSError):
|
|
105
123
|
return {}
|
|
@@ -147,6 +147,13 @@ def main() -> int:
|
|
|
147
147
|
error=None,
|
|
148
148
|
)
|
|
149
149
|
emit("start", f"{spec['engine']} attempt {attempt}", state="running")
|
|
150
|
+
if adapter.capabilities.paid and not rid:
|
|
151
|
+
# Started by an older server that left the booking to the worker (v0 jobs):
|
|
152
|
+
# reserve here, so the spend still counts against the monthly cap.
|
|
153
|
+
rid = f"{job_id}#w{attempt}-{os.getpid()}"
|
|
154
|
+
if ledger.reserve(rid, job_id, float(spec["max_usd"])) is None:
|
|
155
|
+
rid = None
|
|
156
|
+
raise RuntimeError(f"monthly cap {config.MONTHLY_CAP_USD} $ reached")
|
|
150
157
|
if not Path(spec["cwd"]).is_dir():
|
|
151
158
|
raise RuntimeError(f"working folder is gone: {spec['cwd']} (cleaned up?)")
|
|
152
159
|
level = (spec.get("policy") or {}).get("effective")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: subtask-mcp
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
4
4
|
Summary: Background sub-agents for Claude Code, Codex, Kimi and OpenCode, over MCP or CLI, with a spend cap
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/tedin7/subtask
|
|
@@ -70,7 +70,7 @@ The distribution is `subtask-mcp` (the PyPI name `subtask` is an unrelated
|
|
|
70
70
|
project). Full guide, per client, with native commands: [docs/install.md](docs/install.md).
|
|
71
71
|
|
|
72
72
|
```sh
|
|
73
|
-
uv tool install "git+https://github.com/tedin7/subtask@v0.2.
|
|
73
|
+
uv tool install "git+https://github.com/tedin7/subtask@v0.2.7" # or: uv tool install /path/to/checkout
|
|
74
74
|
subtask install --client all # prints the plan for every detected client
|
|
75
75
|
subtask install --client all --yes # applies it: MCP server + skill
|
|
76
76
|
subtask doctor --client all # checks app, config entry and a real handshake
|
|
@@ -273,3 +273,27 @@ def test_missing_active_index_does_not_forget_running_jobs(home):
|
|
|
273
273
|
assert sorted(admission.active()) == sorted([first, second])
|
|
274
274
|
core.stop(first)
|
|
275
275
|
core.stop(second)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_job_started_by_an_old_server_still_books_its_spend(home, monkeypatch):
|
|
279
|
+
"""v0 spec/status (no attempts, no reservation): the worker reserves and settles itself."""
|
|
280
|
+
import signal
|
|
281
|
+
|
|
282
|
+
from subtask import jobs, ledger, worker
|
|
283
|
+
|
|
284
|
+
job = "st-v0v0v0v0v0"
|
|
285
|
+
folder = jobs.job_dir(job)
|
|
286
|
+
folder.mkdir(parents=True)
|
|
287
|
+
jobs.write_json(folder / "spec.json", {"task": "x", "engine": "fake", "cwd": str(home), "max_usd": 2.0,
|
|
288
|
+
"max_turns": 5, "effort": "low", "created": time.time()}) # fmt: skip
|
|
289
|
+
jobs.write_json(folder / "status.json", {"state": "queued"})
|
|
290
|
+
monkeypatch.setenv("SUBTASK_FAKE_SCRIPT", 'echo \'{"say":"ok"}\'')
|
|
291
|
+
monkeypatch.setattr(sys, "argv", ["worker", job])
|
|
292
|
+
old = signal.getsignal(signal.SIGTERM)
|
|
293
|
+
try:
|
|
294
|
+
worker.main()
|
|
295
|
+
finally:
|
|
296
|
+
signal.signal(signal.SIGTERM, old)
|
|
297
|
+
assert jobs.status(job)["state"] == "done"
|
|
298
|
+
assert ledger.committed() == pytest.approx(0.25)
|
|
299
|
+
assert ledger.open_for(job) == []
|
|
@@ -196,16 +196,23 @@ def test_doctor_reports_without_spending(home):
|
|
|
196
196
|
|
|
197
197
|
|
|
198
198
|
def test_active_index_makes_counting_independent_of_history(home):
|
|
199
|
+
"""Counting must not grow with finished jobs: compare with an empty history, not a fixed time."""
|
|
199
200
|
from subtask import admission, config, jobs
|
|
200
201
|
|
|
202
|
+
def per_call() -> float:
|
|
203
|
+
t = time.perf_counter()
|
|
204
|
+
for _ in range(20):
|
|
205
|
+
admission.count()
|
|
206
|
+
return (time.perf_counter() - t) / 20
|
|
207
|
+
|
|
208
|
+
admission.count()
|
|
209
|
+
empty = per_call()
|
|
201
210
|
for i in range(300):
|
|
202
211
|
folder = config.JOBS / f"st-old{i:05d}"
|
|
203
212
|
folder.mkdir(parents=True)
|
|
204
213
|
jobs.write_json(folder / "status.json", {"state": "done"})
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
admission.count()
|
|
208
|
-
assert (time.perf_counter() - t) / 20 < 0.02 # does not scan 300 finished jobs
|
|
214
|
+
full = per_call()
|
|
215
|
+
assert full < empty * 3 + 0.005, (empty, full) # O(history) would be ~300x
|
|
209
216
|
|
|
210
217
|
|
|
211
218
|
def test_narrator_context_includes_observed_activity_not_only_intentions(home):
|
|
@@ -180,3 +180,19 @@ def test_isolated_is_refused_when_bwrap_cannot_run(home, monkeypatch):
|
|
|
180
180
|
monkeypatch.setattr(runners, "bwrap_available", lambda: False)
|
|
181
181
|
out = core.start("x", engine="kimi", cwd=str(home), policy="isolated")
|
|
182
182
|
assert "bubblewrap" in out and "started" not in out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_progress_log_survives_multibyte_text_across_the_tail_boundary(home):
|
|
186
|
+
"""Reading the last event seeks back 4096 bytes: that may land inside a UTF-8 character."""
|
|
187
|
+
import json
|
|
188
|
+
|
|
189
|
+
from subtask import events, jobs
|
|
190
|
+
|
|
191
|
+
folder = jobs.job_dir("st-utf8utf800")
|
|
192
|
+
folder.mkdir(parents=True)
|
|
193
|
+
line = json.dumps({"seq": 1, "msg": "…" * 50}, ensure_ascii=False) + "\n" # "…" is 3 bytes
|
|
194
|
+
for pad in range(3): # one of the three alignments puts byte -4096 inside a "…"
|
|
195
|
+
body = ("x" * pad + "\n" + line * 40).encode()
|
|
196
|
+
(folder / "progress.jsonl").write_bytes(body)
|
|
197
|
+
assert events.emit("st-utf8utf800", "tool", "dopo", source="adapter") is not None, pad
|
|
198
|
+
assert events.last("st-utf8utf800")["msg"] == "dopo"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|