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
@@ -0,0 +1,204 @@
1
+ """Plan and apply install / uninstall for one client, with rollback and ownership."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from importlib import resources
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from . import ownership
12
+ from .clients import Client, InstallError, Step
13
+ from .fileops import Snapshot, atomic_write, read_bytes, sha256, snapshot
14
+
15
+
16
+ def skill_source() -> bytes:
17
+ return resources.files("subtask").joinpath("skill/SKILL.md").read_bytes()
18
+
19
+
20
+ @dataclass
21
+ class Outcome:
22
+ client: str
23
+ scope: str
24
+ status: str = "planned" # planned | done | unchanged | collision | skipped | error
25
+ steps: list[str] = field(default_factory=list)
26
+ notes: list[str] = field(default_factory=list)
27
+
28
+ def as_dict(self) -> dict[str, Any]:
29
+ return {"client": self.client, "scope": self.scope, "status": self.status,
30
+ "steps": self.steps, "notes": self.notes} # fmt: skip
31
+
32
+
33
+ @dataclass
34
+ class _Plan:
35
+ mcp_steps: list[Step] = field(default_factory=list)
36
+ skill_write: bytes | None = None
37
+ skill_remove: bool = False
38
+ relink: str | None = None # a symlinked skill folder replaced by a real one (target kept for rollback)
39
+ blocked: bool = False
40
+
41
+
42
+ def _mcp_plan(c: Client, scope: str, argv: list[str], force: bool, out: Outcome, plan: _Plan) -> None:
43
+ existing = c.read_entry(scope)
44
+ rec = ownership.get(ownership.key(c.name, scope, "mcp", c.config_path(scope)))
45
+ if existing is None:
46
+ plan.mcp_steps = c.add_steps(scope, argv)
47
+ return
48
+ if Client.target(existing) == argv:
49
+ out.notes.append("MCP entry already present with this command")
50
+ return
51
+ ours = rec is not None and rec.get("entry") == existing
52
+ if not ours and not force:
53
+ out.notes.append(f"collision: a '{c.name}' entry named subtask runs {Client.target(existing)}; "
54
+ "use --force to replace it") # fmt: skip
55
+ plan.blocked = True
56
+ return
57
+ plan.mcp_steps = c.remove_steps(scope) + c.add_steps(scope, argv)
58
+
59
+
60
+ def _skill_plan(c: Client, scope: str, force: bool, out: Outcome, plan: _Plan) -> None:
61
+ dst = c.skill_path(scope)
62
+ src = skill_source()
63
+ if dst.parent.is_symlink():
64
+ broken = not dst.parent.exists()
65
+ if not broken and not force:
66
+ out.notes.append(
67
+ f"collision: {dst.parent} is a link to {os.readlink(dst.parent)}; use --force to replace it"
68
+ )
69
+ plan.blocked = True
70
+ return
71
+ plan.relink = str(os.readlink(dst.parent))
72
+ out.notes.append(
73
+ f"{'broken ' if broken else ''}link {dst.parent} -> {plan.relink} replaced by a folder"
74
+ )
75
+ plan.skill_write = src
76
+ return
77
+ cur = read_bytes(dst)
78
+ if cur == src:
79
+ return
80
+ rec = ownership.get(ownership.key(c.name, scope, "skill", dst))
81
+ if cur is not None and not force and not (rec and rec.get("sha") == sha256(cur)):
82
+ out.notes.append(f"collision: {dst} exists and is not ours; use --force to replace it")
83
+ plan.blocked = True
84
+ return
85
+ plan.skill_write = src
86
+
87
+
88
+ def _apply(c: Client, scope: str, argv: list[str], plan: _Plan, out: Outcome) -> None:
89
+ dst = c.skill_path(scope)
90
+ snaps: list[Snapshot] = []
91
+ try:
92
+ if plan.mcp_steps:
93
+ snaps.append(snapshot(c.config_path(scope)))
94
+ if (plan.skill_write is not None or plan.skill_remove) and plan.relink is None:
95
+ snaps.append(snapshot(dst, keep_copy=False)) # skill dirs are scanned: no stray copies
96
+ for step in plan.mcp_steps:
97
+ c.apply(step, scope, argv)
98
+ if plan.relink is not None:
99
+ dst.parent.unlink() # only the link: its target is never touched
100
+ if plan.skill_write is not None:
101
+ atomic_write(dst, plan.skill_write)
102
+ elif plan.skill_remove:
103
+ dst.unlink(missing_ok=True)
104
+ _rmdir_empty(dst.parent)
105
+ except Exception as exc: # noqa: BLE001 - any failure must roll back, then is reported
106
+ for snap in reversed(snaps):
107
+ snap.restore()
108
+ if plan.relink is not None and not dst.parent.is_symlink():
109
+ dst.unlink(missing_ok=True)
110
+ _rmdir_empty(dst.parent)
111
+ if not dst.parent.exists():
112
+ os.symlink(plan.relink, dst.parent)
113
+ out.status = "error"
114
+ out.notes.append(f"{exc}; rolled back")
115
+ return
116
+ out.notes += [f"backup: {s.backup}" for s in snaps if s.backup]
117
+ out.status = "done"
118
+
119
+
120
+ def _rmdir_empty(path: Path) -> None:
121
+ try:
122
+ path.rmdir()
123
+ except OSError:
124
+ pass
125
+
126
+
127
+ def install(c: Client, scope: str, argv: list[str], yes: bool, force: bool) -> Outcome:
128
+ out = Outcome(c.name, scope)
129
+ try:
130
+ c.check_scope(scope)
131
+ plan = _Plan()
132
+ _mcp_plan(c, scope, argv, force, out, plan)
133
+ _skill_plan(c, scope, force, out, plan)
134
+ except InstallError as exc:
135
+ out.status = "error"
136
+ out.notes.append(str(exc))
137
+ return out
138
+ out.steps = [s.describe() for s in plan.mcp_steps]
139
+ if plan.skill_write is not None:
140
+ out.steps.append(f"copy: SKILL.md -> {c.skill_path(scope)}")
141
+ if plan.blocked:
142
+ out.status = "collision"
143
+ return out
144
+ if not out.steps:
145
+ out.status = "unchanged"
146
+ elif not yes:
147
+ return out
148
+ else:
149
+ _apply(c, scope, argv, plan, out)
150
+ if out.status in ("done", "unchanged"):
151
+ _record(c, scope)
152
+ return out
153
+
154
+
155
+ def _record(c: Client, scope: str) -> None:
156
+ entry = c.read_entry(scope)
157
+ if entry is not None:
158
+ ownership.put(ownership.key(c.name, scope, "mcp", c.config_path(scope)), {"entry": entry})
159
+ cur = read_bytes(c.skill_path(scope))
160
+ if cur is not None and cur == skill_source():
161
+ ownership.put(ownership.key(c.name, scope, "skill", c.skill_path(scope)), {"sha": sha256(cur)})
162
+
163
+
164
+ def uninstall(c: Client, scope: str, yes: bool) -> Outcome:
165
+ out = Outcome(c.name, scope)
166
+ plan = _Plan()
167
+ mkey = ownership.key(c.name, scope, "mcp", c.config_path(scope))
168
+ skey = ownership.key(c.name, scope, "skill", c.skill_path(scope))
169
+ try:
170
+ c.check_scope(scope)
171
+ existing = c.read_entry(scope)
172
+ rec = ownership.get(mkey)
173
+ if existing is not None:
174
+ if rec is None:
175
+ out.notes.append("MCP entry 'subtask' was not installed by subtask: left alone")
176
+ elif rec.get("entry") != existing:
177
+ out.notes.append("MCP entry 'subtask' was modified since install: left alone")
178
+ else:
179
+ plan.mcp_steps = c.remove_steps(scope)
180
+ cur = read_bytes(c.skill_path(scope))
181
+ srec = ownership.get(skey)
182
+ if cur is not None:
183
+ if srec and srec.get("sha") == sha256(cur):
184
+ plan.skill_remove = True
185
+ else:
186
+ out.notes.append(f"{c.skill_path(scope)} is not ours or was modified: left alone")
187
+ except InstallError as exc:
188
+ out.status = "error"
189
+ out.notes.append(str(exc))
190
+ return out
191
+ out.steps = [s.describe() for s in plan.mcp_steps]
192
+ if plan.skill_remove:
193
+ out.steps.append(f"delete: {c.skill_path(scope)}")
194
+ if not out.steps:
195
+ out.status = "unchanged"
196
+ return out
197
+ if yes:
198
+ _apply(c, scope, [], plan, out)
199
+ if out.status == "done":
200
+ if plan.mcp_steps:
201
+ ownership.drop(mkey)
202
+ if plan.skill_remove:
203
+ ownership.drop(skey)
204
+ return out
@@ -0,0 +1,44 @@
1
+ """Record of what `subtask install` wrote, so uninstall removes only that."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .fileops import atomic_write
11
+
12
+
13
+ def store_path() -> Path:
14
+ base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
15
+ return Path(base) / "subtask" / "installs.json"
16
+
17
+
18
+ def load() -> dict[str, Any]:
19
+ path = store_path()
20
+ try:
21
+ data = json.loads(path.read_text())
22
+ except FileNotFoundError:
23
+ return {}
24
+ except (OSError, ValueError) as exc:
25
+ raise RuntimeError(f"ownership file {path} is unreadable: {exc}") from exc
26
+ return data if isinstance(data, dict) else {}
27
+
28
+
29
+ def key(client: str, scope: str, kind: str, where: Path) -> str:
30
+ return f"{client}:{scope}:{kind}:{where}"
31
+
32
+
33
+ def get(k: str) -> dict[str, Any] | None:
34
+ return load().get(k)
35
+
36
+
37
+ def put(k: str, record: dict[str, Any]) -> None:
38
+ data = {**load(), k: record}
39
+ atomic_write(store_path(), (json.dumps(data, indent=2, sort_keys=True) + "\n").encode())
40
+
41
+
42
+ def drop(k: str) -> None:
43
+ data = {name: rec for name, rec in load().items() if name != k}
44
+ atomic_write(store_path(), (json.dumps(data, indent=2, sort_keys=True) + "\n").encode())
@@ -0,0 +1,44 @@
1
+ """Minimal text-level edits of a Codex config.toml: touch one key, keep every other byte."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ _HEADER = re.compile(r"^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(#.*)?$")
8
+
9
+
10
+ def _table_span(lines: list[str], table: str) -> tuple[int, int] | None:
11
+ """Index of the `[table]` header line and the index where its body ends."""
12
+ start = None
13
+ for i, line in enumerate(lines):
14
+ m = _HEADER.match(line)
15
+ if not m:
16
+ continue
17
+ name = m.group(1).replace('"', "").replace(" ", "")
18
+ if start is not None:
19
+ return start, i
20
+ if name == table:
21
+ start = i
22
+ return (start, len(lines)) if start is not None else None
23
+
24
+
25
+ def set_key(text: str, table: str, key: str, value: str) -> str:
26
+ """Set `key = value` (value already TOML-encoded) inside `[table]`; other lines untouched."""
27
+ lines = text.splitlines(keepends=True)
28
+ span = _table_span(lines, table)
29
+ if span is None:
30
+ raise ValueError(f"table [{table}] not found")
31
+ start, end = span
32
+ key_re = re.compile(rf"^\s*{re.escape(key)}\s*=")
33
+ for i in range(start + 1, end):
34
+ if key_re.match(lines[i]):
35
+ eol = "\n" if lines[i].endswith("\n") else ""
36
+ lines[i] = f"{key} = {value}{eol}"
37
+ return "".join(lines)
38
+ insert_at = end
39
+ while insert_at > start + 1 and not lines[insert_at - 1].strip():
40
+ insert_at -= 1
41
+ if insert_at > 0 and not lines[insert_at - 1].endswith("\n"):
42
+ lines[insert_at - 1] += "\n"
43
+ lines.insert(insert_at, f"{key} = {value}\n")
44
+ return "".join(lines)
subtask/jobs.py ADDED
@@ -0,0 +1,256 @@
1
+ """Job store on disk: one folder per job, JSON files under a per-job lock.
2
+
3
+ status.json v1: state, attempt, attempts[], proc (pid + start time + boot id),
4
+ liveness, cost_usd, session_id... A worker is declared dead only with proof
5
+ (procid.alive == "dead"); "unknown" stays visible and keeps its slot and money.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fcntl
11
+ import json
12
+ import os
13
+ import secrets
14
+ import signal
15
+ import time
16
+ from collections.abc import Iterator
17
+ from contextlib import contextmanager
18
+ from pathlib import Path
19
+
20
+ from . import config, procid
21
+
22
+ FINAL = {"done", "failed", "stopped"}
23
+ ACTIVE = {"queued", "running"}
24
+ VERSION = 1
25
+
26
+
27
+ def new_id() -> str:
28
+ return "st-" + secrets.token_hex(5)
29
+
30
+
31
+ def job_dir(job_id: str) -> Path:
32
+ if not isinstance(job_id, str) or not job_id.startswith("st-") or "/" in job_id or ".." in job_id:
33
+ raise ValueError(f"invalid id: {job_id}")
34
+ return config.JOBS / job_id
35
+
36
+
37
+ def read_json(path: Path, default: dict | None = None) -> dict:
38
+ try:
39
+ data = json.loads(path.read_text())
40
+ except (FileNotFoundError, json.JSONDecodeError, NotADirectoryError):
41
+ return dict(default or {})
42
+ return data if isinstance(data, dict) else dict(default or {})
43
+
44
+
45
+ def write_json(path: Path, data: dict) -> None:
46
+ tmp = path.with_suffix(f"{path.suffix}.{os.getpid()}.tmp")
47
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1))
48
+ os.replace(tmp, path)
49
+
50
+
51
+ def spec(job_id: str) -> dict:
52
+ return read_json(job_dir(job_id) / "spec.json")
53
+
54
+
55
+ @contextmanager
56
+ def job_lock(job_id: str) -> Iterator[None]:
57
+ with (job_dir(job_id) / ".lock").open("a") as lock:
58
+ fcntl.flock(lock, fcntl.LOCK_EX)
59
+ yield
60
+
61
+
62
+ def update_status(job_id: str, locked: bool = False, **fields: object) -> dict:
63
+ """Read-modify-write under the job lock (pass locked=True if already held)."""
64
+ path = job_dir(job_id) / "status.json"
65
+ if locked:
66
+ data = {"v": VERSION, **read_json(path), **fields, "updated": time.time()}
67
+ write_json(path, data)
68
+ return data
69
+ with job_lock(job_id):
70
+ data = {"v": VERSION, **read_json(path), **fields, "updated": time.time()}
71
+ write_json(path, data)
72
+ return data
73
+
74
+
75
+ def _proc(data: dict) -> dict | None:
76
+ if data.get("proc"):
77
+ return data["proc"]
78
+ if data.get("pid"): # v0 status: pid only, guarded by the cmdline check below
79
+ return {"pid": data["pid"], "start": None, "boot": None, "legacy": True}
80
+ return None
81
+
82
+
83
+ def liveness(job_id: str, data: dict) -> str:
84
+ proc = _proc(data)
85
+ if not proc:
86
+ return procid.UNKNOWN
87
+ verdict = procid.alive(proc)
88
+ if verdict == procid.UNKNOWN and proc.get("legacy"):
89
+ try:
90
+ cmdline = Path(f"/proc/{proc['pid']}/cmdline").read_bytes()
91
+ except FileNotFoundError:
92
+ return procid.DEAD
93
+ except OSError:
94
+ return verdict
95
+ return procid.ALIVE if b"subtask.worker" in cmdline and job_id.encode() in cmdline else procid.DEAD
96
+ return verdict
97
+
98
+
99
+ def status(job_id: str) -> dict:
100
+ """Current state; books a provably dead worker as failed and settles its reservation."""
101
+ data = read_json(job_dir(job_id) / "status.json", {"state": "unknown"})
102
+ if data.get("state") not in ACTIVE:
103
+ return data
104
+ if _proc(data):
105
+ verdict = liveness(job_id, data)
106
+ if verdict == procid.DEAD:
107
+ return _close_dead(job_id, data)
108
+ if verdict != data.get("liveness"):
109
+ return update_status(job_id, liveness=verdict)
110
+ return data
111
+ if data.get("state") == "queued" and time.time() - float(data.get("updated") or 0) > 60:
112
+ return _close_dead(job_id, data, error="worker never started")
113
+ return data
114
+
115
+
116
+ def _close_dead(job_id: str, seen: dict, error: str = "worker died before finishing") -> dict:
117
+ from . import admission, events, ledger
118
+
119
+ with job_lock(job_id):
120
+ data = read_json(job_dir(job_id) / "status.json")
121
+ if data.get("state") not in ACTIVE or _proc(data) != _proc(seen):
122
+ return data # it finished or was restarted meanwhile
123
+ charged = 0.0
124
+ for row in ledger.open_for(job_id):
125
+ # Spend possible but unknown: charge the whole reservation (prudential), else release.
126
+ # v0 statuses (no "v") never recorded spend_possible: stay prudential for them.
127
+ amount = float(row.get("usd") or 0) if data.get("spend_possible", "v" not in data) else 0.0
128
+ booked = ledger.close(row["rid"], amount, "settle" if amount else "release")
129
+ charged += booked or 0.0
130
+ data = update_status(
131
+ job_id,
132
+ locked=True,
133
+ state="failed",
134
+ pid=None,
135
+ proc=None,
136
+ liveness=procid.DEAD,
137
+ cost_usd=round(float(data.get("cost_usd") or 0) + charged, 4),
138
+ error=error,
139
+ )
140
+ admission.release(job_id)
141
+ events.emit(job_id, "end", error, state="failed")
142
+ return data
143
+
144
+
145
+ def _group_alive(pgid: int) -> bool:
146
+ try:
147
+ os.killpg(pgid, 0)
148
+ except ProcessLookupError:
149
+ return False
150
+ except PermissionError:
151
+ return True
152
+ return True
153
+
154
+
155
+ def _kill_group(pgid: int, grace_s: float) -> bool:
156
+ """SIGTERM the worker's process group, then SIGKILL whatever is left. True when nothing is left."""
157
+ for sig, wait in ((signal.SIGTERM, grace_s), (signal.SIGKILL, 3.0)):
158
+ try:
159
+ os.killpg(pgid, sig)
160
+ except ProcessLookupError:
161
+ return True
162
+ except PermissionError:
163
+ return False
164
+ deadline = time.time() + wait
165
+ while time.time() < deadline:
166
+ try:
167
+ os.waitpid(pgid, os.WNOHANG) # reap our own child, if it is one
168
+ except (ChildProcessError, OSError):
169
+ pass
170
+ if not _group_alive(pgid):
171
+ return True
172
+ time.sleep(0.1)
173
+ return not _group_alive(pgid)
174
+
175
+
176
+ def stop(job_id: str, grace_s: float = 8.0) -> bool:
177
+ """Stop the work. True only when no process of the job is left."""
178
+ current = status(job_id)
179
+ if current.get("state") not in ACTIVE:
180
+ return False
181
+ proc = _proc(current)
182
+ if not proc:
183
+ # Spawned but not recorded yet: ask the worker to stop itself, then wait for its identity.
184
+ update_status(job_id, stop_requested=True)
185
+ deadline = time.time() + grace_s
186
+ while time.time() < deadline:
187
+ current = read_json(job_dir(job_id) / "status.json")
188
+ proc = _proc(current)
189
+ if proc or current.get("state") not in ACTIVE:
190
+ break
191
+ time.sleep(0.1)
192
+ if current.get("state") not in ACTIVE:
193
+ return current.get("state") == "stopped"
194
+ if not proc:
195
+ return False # no worker to prove stopped; the request stays and the worker honours it
196
+ if liveness(job_id, current) == procid.ALIVE and not _kill_group(int(proc["pid"]), grace_s):
197
+ return False # something survived SIGKILL: do not pretend
198
+ data = status(job_id) # books a killed worker as failed and settles its reservation
199
+ if data.get("state") in ACTIVE and _proc(data) and liveness(job_id, data) != procid.DEAD:
200
+ return False
201
+ if read_json(job_dir(job_id) / "status.json").get("state") != "stopped":
202
+ from . import admission, events
203
+
204
+ update_status(job_id, state="stopped", pid=None, proc=None)
205
+ admission.release(job_id)
206
+ events.emit(job_id, "end", "stopped by request", state="stopped")
207
+ return True
208
+
209
+
210
+ def all_jobs() -> list[str]:
211
+ if not config.JOBS.is_dir():
212
+ return []
213
+ dirs = []
214
+ for d in config.JOBS.iterdir():
215
+ if d.name.startswith("st-"):
216
+ try:
217
+ dirs.append((d.stat().st_mtime, d.name))
218
+ except FileNotFoundError:
219
+ continue
220
+ return [name for _, name in sorted(dirs, reverse=True)]
221
+
222
+
223
+ def running_count() -> int:
224
+ from . import admission
225
+
226
+ return admission.count()
227
+
228
+
229
+ # ---- compatibility wrappers over ledger.py (v0 API)
230
+
231
+
232
+ def reserve(job_id: str, amount: float) -> float | None:
233
+ from . import ledger
234
+
235
+ return ledger.reserve(f"{job_id}#1", job_id, amount)
236
+
237
+
238
+ def unsettled(job_id: str) -> bool:
239
+ from . import ledger
240
+
241
+ return bool(ledger.open_for(job_id))
242
+
243
+
244
+ def settle_open(job_id: str, cost: float) -> float:
245
+ from . import ledger
246
+
247
+ booked = 0.0
248
+ for i, row in enumerate(ledger.open_for(job_id)):
249
+ booked += ledger.close(row["rid"], cost if i == 0 else 0.0) or 0.0
250
+ return booked
251
+
252
+
253
+ def month_spent() -> float:
254
+ from . import ledger
255
+
256
+ return ledger.committed()