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
|
@@ -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()
|