subtask-mcp 0.2.6__tar.gz → 0.2.8__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.
Files changed (67) hide show
  1. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/PKG-INFO +2 -2
  2. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/README.md +1 -1
  3. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/__init__.py +1 -1
  4. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/core.py +8 -2
  5. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/events.py +23 -5
  6. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/fastserver.py +3 -1
  7. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/server.py +3 -1
  8. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask_mcp.egg-info/PKG-INFO +2 -2
  9. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask_mcp.egg-info/SOURCES.txt +1 -0
  10. subtask_mcp-0.2.8/tests/test_live.py +76 -0
  11. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/tests/test_protocol.py +16 -0
  12. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/tests/test_security.py +16 -0
  13. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/LICENSE +0 -0
  14. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/pyproject.toml +0 -0
  15. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/setup.cfg +0 -0
  16. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/admission.py +0 -0
  17. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/capacity.py +0 -0
  18. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/cli.py +0 -0
  19. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/config.py +0 -0
  20. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/doctor.py +0 -0
  21. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/engines/__init__.py +0 -0
  22. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/engines/base.py +0 -0
  23. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/engines/claude_api.py +0 -0
  24. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/engines/codex.py +0 -0
  25. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/engines/demo.py +0 -0
  26. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/engines/dsh.py +0 -0
  27. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/engines/kimi.py +0 -0
  28. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/engines/opencode.py +0 -0
  29. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/engines/registry.py +0 -0
  30. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/evidence.py +0 -0
  31. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/gc.py +0 -0
  32. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/install/__init__.py +0 -0
  33. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/install/claude_plugin.py +0 -0
  34. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/install/clients.py +0 -0
  35. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/install/commands.py +0 -0
  36. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/install/doctor.py +0 -0
  37. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/install/fileops.py +0 -0
  38. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/install/installer.py +0 -0
  39. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/install/ownership.py +0 -0
  40. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/install/tomledit.py +0 -0
  41. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/jobs.py +0 -0
  42. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/ledger.py +0 -0
  43. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/narrate.py +0 -0
  44. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/policy.py +0 -0
  45. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/procid.py +0 -0
  46. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/progress.py +0 -0
  47. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/redact.py +0 -0
  48. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/roi.py +0 -0
  49. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/router.py +0 -0
  50. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/runners.py +0 -0
  51. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/schemacheck.py +0 -0
  52. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/skill/SKILL.md +0 -0
  53. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/telemetry.py +0 -0
  54. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/tools.json +0 -0
  55. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask/worker.py +0 -0
  56. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask_mcp.egg-info/dependency_links.txt +0 -0
  57. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask_mcp.egg-info/entry_points.txt +0 -0
  58. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask_mcp.egg-info/requires.txt +0 -0
  59. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/subtask_mcp.egg-info/top_level.txt +0 -0
  60. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/tests/test_concurrency.py +0 -0
  61. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/tests/test_engines.py +0 -0
  62. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/tests/test_falsify.py +0 -0
  63. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/tests/test_independent_client.py +0 -0
  64. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/tests/test_install.py +0 -0
  65. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/tests/test_routing_results.py +0 -0
  66. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/tests/test_schema.py +0 -0
  67. {subtask_mcp-0.2.6 → subtask_mcp-0.2.8}/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.6
3
+ Version: 0.2.8
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.6" # or: uv tool install /path/to/checkout
73
+ uv tool install "git+https://github.com/tedin7/subtask@v0.2.8" # 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.6" # or: uv tool install /path/to/checkout
42
+ uv tool install "git+https://github.com/tedin7/subtask@v0.2.8" # 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
@@ -1,3 +1,3 @@
1
1
  """subtask: background sub-agents for coding agents."""
2
2
 
3
- __version__ = "0.2.6"
3
+ __version__ = "0.2.8"
@@ -306,7 +306,12 @@ def _delta(job_ids: list[str], since: str) -> tuple[str, str]:
306
306
 
307
307
 
308
308
  def wait(
309
- job_ids: list[str], timeout: int = 50, any_: bool = False, results: bool = True, since: str | None = None
309
+ job_ids: list[str],
310
+ timeout: int = 50,
311
+ any_: bool = False,
312
+ results: bool = True,
313
+ since: str | None = None,
314
+ count: bool = True,
310
315
  ) -> str:
311
316
  """Block until all jobs end (or the first one, with any_), or timeout.
312
317
 
@@ -320,7 +325,8 @@ def wait(
320
325
  unknown = [j for j in job_ids if not known(j)]
321
326
  if unknown:
322
327
  return "unknown job: " + ", ".join(unknown)
323
- telemetry.count_call(job_ids, "wait")
328
+ if count: # servers poll wait() every second inside one tool call: count that call once
329
+ telemetry.count_call(job_ids, "wait")
324
330
  deadline = time.time() + max(0, min(timeout, 600))
325
331
  while True:
326
332
  ended = [j for j in job_ids if jobs.status(j).get("state") in jobs.FINAL]
@@ -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("a+", encoding="utf-8") as fh:
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
- lines = fh.read().splitlines()
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("r", encoding="utf-8") as fh:
120
+ with path.open("rb") as fh:
103
121
  return _last(fh)
104
- except FileNotFoundError:
122
+ except (FileNotFoundError, OSError):
105
123
  return {}
@@ -89,8 +89,10 @@ def _wait(
89
89
  if core.known(job_id):
90
90
  call.reporter.add(job_id)
91
91
  call.reporter.poll()
92
+ first = True
92
93
  while True:
93
- out = core.wait(ids, 0, any, since=since)
94
+ out = core.wait(ids, 0, any, since=since, count=first)
95
+ first = False
94
96
  if not out.startswith("still running") or time.monotonic() >= deadline:
95
97
  if call.reporter:
96
98
  call.reporter.poll()
@@ -77,12 +77,14 @@ async def wait(
77
77
 
78
78
  reporter = Reporter(send, [i for i in ids if core.known(i)])
79
79
  deadline = loop.time() + max(1, min(timeout, 600))
80
+ first = True
80
81
  while True:
81
82
  if reporter:
82
83
  reporter.poll()
83
84
  while pending:
84
85
  await pending.pop(0) # never after the result: progress stops at completion
85
- out = core.wait(ids, 0, any, since=since)
86
+ out = core.wait(ids, 0, any, since=since, count=first)
87
+ first = False
86
88
  if not out.startswith("still running") or loop.time() >= deadline:
87
89
  return out
88
90
  await asyncio.sleep(1)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: subtask-mcp
3
- Version: 0.2.6
3
+ Version: 0.2.8
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.6" # or: uv tool install /path/to/checkout
73
+ uv tool install "git+https://github.com/tedin7/subtask@v0.2.8" # 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
@@ -57,6 +57,7 @@ tests/test_engines.py
57
57
  tests/test_falsify.py
58
58
  tests/test_independent_client.py
59
59
  tests/test_install.py
60
+ tests/test_live.py
60
61
  tests/test_protocol.py
61
62
  tests/test_routing_results.py
62
63
  tests/test_schema.py
@@ -0,0 +1,76 @@
1
+ """Live adapter checks: one tiny real job per engine, through subtask itself.
2
+
3
+ Marked `live`: skipped by default (pytest.ini addopts "-m 'not live'"). Run with
4
+ `pytest -m live` when spending is authorized. Each job is capped at 0.20 USD
5
+ (paid engines) and asks for one shell command and a one-word answer, so the
6
+ contract with the real CLI/SDK is exercised: argv accepted, events parsed,
7
+ session id found, result written, cost booked.
8
+ Engines that are not installed or not logged in are skipped, never faked.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+
21
+ import pytest
22
+
23
+ pytestmark = pytest.mark.live
24
+
25
+ TASK = "Run the shell command `echo subtask-live-ok` once, then answer with exactly that output."
26
+ ENGINES = ["haiku-api", "opencode-go", "kimi", "codex"]
27
+
28
+
29
+ def _cli(home: Path, *args: str, timeout: float = 300) -> str:
30
+ env = {**os.environ, "SUBTASK_HOME": str(home), "SUBTASK_MAX_RUNNING": "4"}
31
+ done = subprocess.run([sys.executable, "-m", "subtask.cli", *args], env=env, capture_output=True,
32
+ text=True, timeout=timeout, check=False) # fmt: skip
33
+ return done.stdout + done.stderr
34
+
35
+
36
+ def _available(engine: str) -> str | None:
37
+ from subtask import config
38
+ from subtask.engines import registry
39
+
40
+ adapter = registry.get(engine)
41
+ if adapter is None or not adapter.available():
42
+ return f"{engine} not installed"
43
+ if (
44
+ adapter.capabilities.paid
45
+ and engine.endswith("-api")
46
+ and not config.load_secrets().get("ANTHROPIC_API_KEY")
47
+ ):
48
+ return "no ANTHROPIC_API_KEY in the subtask env file"
49
+ cap = adapter.capacity()
50
+ if cap.value is not None and cap.value < 10:
51
+ return f"{engine} has no quota left ({cap.note})"
52
+ return None
53
+
54
+
55
+ @pytest.mark.parametrize("engine", ENGINES)
56
+ def test_engine_runs_a_tiny_real_job(engine, tmp_path):
57
+ reason = _available(engine)
58
+ if reason:
59
+ pytest.skip(reason)
60
+ if shutil.which("git") is None:
61
+ pytest.skip("git missing")
62
+ work = tmp_path / "work"
63
+ work.mkdir()
64
+ out = _cli(tmp_path / "home", "start", TASK, "--engine", engine, "--cwd", str(work), "--max-usd", "0.2",
65
+ "--max-turns", "6", "--effort", "low") # fmt: skip
66
+ assert " started " in out, out
67
+ job = out.split()[0]
68
+ t0 = time.time()
69
+ final = _cli(tmp_path / "home", "wait", job, "--timeout", "280")
70
+ assert " done " in final, final
71
+ env = json.loads(_cli(tmp_path / "home", "result", job, "--evidence"))
72
+ assert "subtask-live-ok" in env["summary"]["text"], env["summary"]
73
+ assert env["commands"]["seen"] >= 1, "no shell command observed: event format changed?"
74
+ if env["cost"]["reserved"] is not None:
75
+ assert 0 <= (env["cost"]["charged_total"] or 0) <= 0.2
76
+ assert time.time() - t0 < 300
@@ -262,3 +262,19 @@ def test_reporter_follows_the_latest_change_and_admits_missing_detail(home):
262
262
  assert rep._what(b) == progress.NO_DETAIL # kimi plain output: no structured activity
263
263
  core.stop(a)
264
264
  core.stop(b)
265
+
266
+
267
+ def test_a_long_wait_counts_as_one_round_trip(home):
268
+ """The server polls core.wait every second; that is not a parent round trip."""
269
+ from subtask import telemetry
270
+
271
+ fake_cli(home, "kimi", "sleep 4; echo done")
272
+ c = Client(_env())
273
+ c.send({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
274
+ "params": {"name": "start", "arguments": {"task": "x", "engine": "kimi", "cwd": str(home)}}}) # fmt: skip
275
+ job = c.until(1)[0]["result"]["content"][0]["text"].split()[0]
276
+ c.send({"jsonrpc": "2.0", "id": 2, "method": "tools/call",
277
+ "params": {"name": "wait", "arguments": {"ids": [job], "timeout": 30}}}) # fmt: skip
278
+ c.until(2, timeout=40)
279
+ c.close()
280
+ assert [x["tool"] for x in telemetry.calls(job)].count("wait") == 1
@@ -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