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.
Files changed (66) hide show
  1. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/PKG-INFO +2 -2
  2. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/README.md +1 -1
  3. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/__init__.py +1 -1
  4. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/events.py +23 -5
  5. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/worker.py +7 -0
  6. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask_mcp.egg-info/PKG-INFO +2 -2
  7. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_concurrency.py +24 -0
  8. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_routing_results.py +11 -4
  9. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_security.py +16 -0
  10. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/LICENSE +0 -0
  11. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/pyproject.toml +0 -0
  12. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/setup.cfg +0 -0
  13. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/admission.py +0 -0
  14. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/capacity.py +0 -0
  15. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/cli.py +0 -0
  16. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/config.py +0 -0
  17. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/core.py +0 -0
  18. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/doctor.py +0 -0
  19. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/engines/__init__.py +0 -0
  20. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/engines/base.py +0 -0
  21. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/engines/claude_api.py +0 -0
  22. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/engines/codex.py +0 -0
  23. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/engines/demo.py +0 -0
  24. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/engines/dsh.py +0 -0
  25. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/engines/kimi.py +0 -0
  26. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/engines/opencode.py +0 -0
  27. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/engines/registry.py +0 -0
  28. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/evidence.py +0 -0
  29. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/fastserver.py +0 -0
  30. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/gc.py +0 -0
  31. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/install/__init__.py +0 -0
  32. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/install/claude_plugin.py +0 -0
  33. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/install/clients.py +0 -0
  34. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/install/commands.py +0 -0
  35. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/install/doctor.py +0 -0
  36. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/install/fileops.py +0 -0
  37. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/install/installer.py +0 -0
  38. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/install/ownership.py +0 -0
  39. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/install/tomledit.py +0 -0
  40. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/jobs.py +0 -0
  41. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/ledger.py +0 -0
  42. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/narrate.py +0 -0
  43. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/policy.py +0 -0
  44. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/procid.py +0 -0
  45. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/progress.py +0 -0
  46. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/redact.py +0 -0
  47. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/roi.py +0 -0
  48. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/router.py +0 -0
  49. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/runners.py +0 -0
  50. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/schemacheck.py +0 -0
  51. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/server.py +0 -0
  52. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/skill/SKILL.md +0 -0
  53. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/telemetry.py +0 -0
  54. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask/tools.json +0 -0
  55. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask_mcp.egg-info/SOURCES.txt +0 -0
  56. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask_mcp.egg-info/dependency_links.txt +0 -0
  57. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask_mcp.egg-info/entry_points.txt +0 -0
  58. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask_mcp.egg-info/requires.txt +0 -0
  59. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/subtask_mcp.egg-info/top_level.txt +0 -0
  60. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_engines.py +0 -0
  61. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_falsify.py +0 -0
  62. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_independent_client.py +0 -0
  63. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_install.py +0 -0
  64. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_protocol.py +0 -0
  65. {subtask_mcp-0.2.4 → subtask_mcp-0.2.7}/tests/test_schema.py +0 -0
  66. {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.4
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.4" # or: uv tool install /path/to/checkout
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.4" # or: uv tool install /path/to/checkout
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
@@ -1,3 +1,3 @@
1
1
  """subtask: background sub-agents for coding agents."""
2
2
 
3
- __version__ = "0.2.4"
3
+ __version__ = "0.2.7"
@@ -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 {}
@@ -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.4
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.4" # or: uv tool install /path/to/checkout
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
- t = time.perf_counter()
206
- for _ in range(20):
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