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
subtask/ledger.py ADDED
@@ -0,0 +1,268 @@
1
+ """Spend ledger: reservations against a monthly cap, closed exactly once.
2
+
3
+ Files: <HOME>/ledger/YYYY-MM.jsonl, one JSON row per operation, under one lock
4
+ (<HOME>/ledger/.lock) so check-then-reserve is atomic across processes.
5
+
6
+ Row v2: {"v":2,"ts":..,"op":"reserve"|"settle"|"release","rid":..,"job":..,"usd":..,"month":"YYYY-MM"}
7
+ - reserve: books `usd` against the cap of `month` (the month it was made in);
8
+ - settle: closes the reservation with the cost actually charged;
9
+ - release: closes it at zero (nothing could have been spent).
10
+ A closing row goes to the file of its reservation's month, so a job that
11
+ crosses midnight at month end is charged once, to the month it was admitted in.
12
+ A second close of the same rid is a no-op (idempotent settlement).
13
+
14
+ The v0 single file ledger.jsonl is migrated once; the original is kept as
15
+ ledger.jsonl.v0.bak (rollback: move it back and delete the ledger folder).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import fcntl
21
+ import json
22
+ import math
23
+ import os
24
+ import time
25
+ from collections.abc import Iterator
26
+ from contextlib import contextmanager
27
+ from pathlib import Path
28
+
29
+ from . import config
30
+
31
+ VERSION = 2
32
+
33
+
34
+ def _dir() -> Path:
35
+ return config.HOME / "ledger"
36
+
37
+
38
+ def month_of(ts: str | None = None) -> str:
39
+ return (ts or now())[:7]
40
+
41
+
42
+ def now() -> str:
43
+ t = time.time()
44
+ return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(t)) + f".{int(t % 1 * 1e6):06d}+00:00"
45
+
46
+
47
+ @contextmanager
48
+ def locked() -> Iterator[None]:
49
+ folder = _dir()
50
+ folder.mkdir(parents=True, exist_ok=True)
51
+ with (folder / ".lock").open("a") as lock:
52
+ fcntl.flock(lock, fcntl.LOCK_EX)
53
+ _migrate_v0()
54
+ yield
55
+
56
+
57
+ def _rows(month: str) -> list[dict]:
58
+ path = _dir() / f"{month}.jsonl"
59
+ try:
60
+ lines = path.read_text().splitlines()
61
+ except FileNotFoundError:
62
+ return []
63
+ out = []
64
+ for line in lines:
65
+ try:
66
+ row = json.loads(line)
67
+ except json.JSONDecodeError:
68
+ continue # a torn last line after a crash: ignored, never fatal
69
+ if isinstance(row, dict):
70
+ out.append(row)
71
+ return out
72
+
73
+
74
+ def _append(month: str, row: dict) -> None:
75
+ path = _dir() / f"{month}.jsonl"
76
+ with path.open("ab+") as fh:
77
+ fh.seek(0, 2)
78
+ if fh.tell():
79
+ fh.seek(-1, 2)
80
+ if fh.read(1) != b"\n":
81
+ fh.write(b"\n") # a torn last line must not swallow this row
82
+ fh.write((json.dumps(row) + "\n").encode())
83
+ fh.flush()
84
+ os.fsync(fh.fileno())
85
+
86
+
87
+ def _months() -> list[str]:
88
+ if not _dir().is_dir():
89
+ return []
90
+ return sorted((p.stem for p in _dir().glob("????-??.jsonl")), reverse=True)
91
+
92
+
93
+ def _usd(value: object) -> float | None:
94
+ """A finite, non-negative amount, or None."""
95
+ try:
96
+ amount = float(value) # type: ignore[arg-type]
97
+ except (TypeError, ValueError):
98
+ return None
99
+ return amount if math.isfinite(amount) and amount >= 0 else None
100
+
101
+
102
+ def _summary(month: str) -> tuple[float, dict[str, dict], set[str]]:
103
+ """(settled usd, open reservations by rid, closed rids) for one month."""
104
+ settled, open_, closed = 0.0, {}, set()
105
+ for row in _rows(month):
106
+ op, rid = row.get("op"), row.get("rid")
107
+ if op == "reserve" and rid not in closed:
108
+ open_[rid] = row
109
+ elif op in ("settle", "release") and rid not in closed:
110
+ closed.add(rid)
111
+ open_.pop(rid, None)
112
+ settled += _usd(row.get("usd")) or 0.0
113
+ return settled, open_, closed
114
+
115
+
116
+ def _committed(month: str) -> float:
117
+ settled, open_, _ = _summary(month)
118
+ return settled + sum(_usd(r.get("usd")) or 0.0 for r in open_.values())
119
+
120
+
121
+ def committed(month: str | None = None) -> float:
122
+ """Spent plus still reserved this month (what counts against the cap)."""
123
+ with locked():
124
+ return _committed(month or month_of())
125
+
126
+
127
+ def reserve(rid: str, job: str, usd: float, cap: float | None = None) -> float | None:
128
+ """Book `usd`; None when it would overshoot the cap or is not a sane amount.
129
+
130
+ Idempotent per rid: reserving an rid that already exists returns None.
131
+ """
132
+ if not isinstance(usd, (int, float)) or not math.isfinite(usd) or usd <= 0:
133
+ return None
134
+ cap = config.MONTHLY_CAP_USD if cap is None else cap
135
+ with locked():
136
+ month = month_of()
137
+ if _find(rid) is not None:
138
+ return None
139
+ total = _committed(month)
140
+ if total + usd > cap:
141
+ return None
142
+ _append(
143
+ month,
144
+ {
145
+ "v": VERSION,
146
+ "ts": now(),
147
+ "op": "reserve",
148
+ "rid": rid,
149
+ "job": job,
150
+ "usd": float(usd),
151
+ "month": month,
152
+ },
153
+ )
154
+ return total + usd
155
+
156
+
157
+ def _find(rid: str) -> tuple[str, dict | None] | None:
158
+ """(month, open reserve row or None if closed) for an rid, newest month first."""
159
+ for month in _months():
160
+ _, open_, closed = _summary(month)
161
+ if rid in open_:
162
+ return month, open_[rid]
163
+ if rid in closed:
164
+ return month, None
165
+ return None
166
+
167
+
168
+ def close(rid: str, usd: float, op: str = "settle") -> float | None:
169
+ """Close an open reservation once. Returns what was booked, None when nothing was open.
170
+
171
+ A settle is capped at the reservation (the cap must hold); an unknown cost
172
+ (NaN, inf, negative, not a number) is charged the whole reservation. Both
173
+ cases are noted on the row.
174
+ """
175
+ if op not in ("settle", "release"):
176
+ raise ValueError(op)
177
+ reported = _usd(usd)
178
+ with locked():
179
+ found = _find(rid)
180
+ if not found or found[1] is None:
181
+ return None
182
+ month, row = found[0], found[1] or {}
183
+ reserved = _usd(row.get("usd")) or 0.0
184
+ booked = 0.0 if op == "release" else reserved if reported is None else min(reported, reserved)
185
+ closing = {"v": VERSION, "ts": now(), "op": op, "rid": rid, "job": row.get("job"),
186
+ "usd": round(booked, 6), "month": month} # fmt: skip
187
+ if op == "settle" and reported is None:
188
+ closing["note"] = "unknown cost: charged the reservation"
189
+ elif op == "settle" and reported > reserved:
190
+ closing["note"] = f"reported {reported:.4f} above the reservation: capped"
191
+ _append(month, closing)
192
+ return booked
193
+
194
+
195
+ def is_open(rid: str) -> bool:
196
+ with locked():
197
+ found = _find(rid)
198
+ return bool(found and found[1] is not None)
199
+
200
+
201
+ def open_for(job: str) -> list[dict]:
202
+ with locked():
203
+ out = []
204
+ for month in _months()[:3]:
205
+ _, open_, _ = _summary(month)
206
+ out += [r for r in open_.values() if r.get("job") == job]
207
+ return out
208
+
209
+
210
+ def spend_rows(month: str | None = None) -> list[dict]:
211
+ with locked():
212
+ return _rows(month or month_of())
213
+
214
+
215
+ def _migrate_v0() -> None:
216
+ """Split the v0 single-file ledger into monthly v2 files, once (lock held).
217
+
218
+ Rows that cannot be read are skipped and listed in ledger-migration.log; an
219
+ unreadable cost closes its reservation at the reserved amount (prudential).
220
+ """
221
+ old = config.HOME / "ledger.jsonl"
222
+ if not old.is_file():
223
+ return
224
+ counters: dict[str, int] = {}
225
+ open_rid: dict[str, tuple[str, float]] = {}
226
+ skipped: list[str] = []
227
+
228
+ def row_for(op: str, rid: str, job: str, usd: float, ts: str, month: str) -> dict:
229
+ return {"v": VERSION, "ts": ts, "op": op, "rid": rid, "job": job, "usd": usd, "month": month}
230
+
231
+ for line in old.read_text(errors="replace").splitlines():
232
+ try:
233
+ row = json.loads(line)
234
+ except json.JSONDecodeError:
235
+ if line.strip():
236
+ skipped.append(line[:200])
237
+ continue
238
+ if not isinstance(row, dict):
239
+ skipped.append(line[:200])
240
+ continue
241
+ job, ts = str(row.get("job", "?")), str(row.get("ts") or now())
242
+ month = month_of(ts) if len(ts) >= 7 and ts[4] == "-" else month_of()
243
+ if "reserve_usd" in row:
244
+ usd = _usd(row["reserve_usd"])
245
+ if usd is None:
246
+ skipped.append(line[:200])
247
+ continue
248
+ counters[job] = counters.get(job, 0) + 1
249
+ rid = f"{job}#v0-{counters[job]}"
250
+ open_rid[job] = (rid, usd)
251
+ _append(month, row_for("reserve", rid, job, usd, ts, month))
252
+ continue
253
+ cost = _usd(row.get("cost_usd") or 0)
254
+ pending = open_rid.pop(job, None)
255
+ if pending is None:
256
+ if cost is None:
257
+ skipped.append(line[:200])
258
+ continue
259
+ counters[job] = counters.get(job, 0) + 1
260
+ rid = f"{job}#v0-{counters[job]}"
261
+ _append(month, row_for("reserve", rid, job, cost, ts, month))
262
+ _append(month, row_for("settle", rid, job, cost, ts, month))
263
+ continue
264
+ rid, reserved = pending
265
+ _append(month, row_for("settle", rid, job, reserved if cost is None else cost, ts, month))
266
+ if skipped:
267
+ (config.HOME / "ledger-migration.log").write_text("skipped v0 rows:\n" + "\n".join(skipped) + "\n")
268
+ old.rename(old.with_name("ledger.jsonl.v0.bak"))
subtask/narrate.py ADDED
@@ -0,0 +1,134 @@
1
+ """Independent narrator for significant steps.
2
+
3
+ A separate model (not the agent doing the work) reads the step the agent
4
+ declared plus the repository's real state, and explains it to the user in
5
+ plain words, pointing out anything that does not add up. One direct Messages
6
+ API call: fast and cheap, no agent loop, no tools.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import subprocess
14
+ import time
15
+ import urllib.request
16
+
17
+ from . import config, ledger
18
+
19
+ # (model, USD per million input tokens, USD per million output tokens).
20
+ # Override when prices change.
21
+ EXPLAINER = (
22
+ os.environ.get("SUBTASK_STEP_MODEL", "claude-haiku-5-5"),
23
+ config._money("SUBTASK_STEP_PRICE_IN", "1"),
24
+ config._money("SUBTASK_STEP_PRICE_OUT", "5"),
25
+ )
26
+ RESERVE_USD = 0.05
27
+ MAX_CONTEXT = 6000
28
+
29
+ EXPLAIN = (
30
+ "You are an independent observer. A coding agent is about to take a step. "
31
+ "You did not plan it. Explain it to the operator, who is smart but not technical: "
32
+ "what the agent is about to do, which road it takes, how, and the decisions it made "
33
+ "and why. Short sentences, plain words, no em dashes, bullets, at most 6 lines. "
34
+ "Keep two things apart: what the agent DECLARES it will do (an intention) and what "
35
+ "the repository state and the observed job activity SHOW (facts). If they disagree, say so. "
36
+ "Never invent facts. Write in this language: {lang}."
37
+ )
38
+
39
+
40
+ def _git(repo: str, *args: str) -> str:
41
+ try:
42
+ done = subprocess.run(
43
+ ["git", "-C", repo, *args], capture_output=True, text=True, timeout=10, check=False
44
+ )
45
+ except (OSError, subprocess.TimeoutExpired):
46
+ return ""
47
+ return done.stdout.strip() if done.returncode == 0 else ""
48
+
49
+
50
+ def repo_context(repo: str) -> str:
51
+ parts = [
52
+ ("branch", _git(repo, "branch", "--show-current")),
53
+ ("last commits", _git(repo, "log", "--oneline", "-5")),
54
+ ("changed files", "\n".join(_git(repo, "status", "--short").splitlines()[:30])),
55
+ (
56
+ "diff size",
57
+ _git(repo, "diff", "--stat", "HEAD").splitlines()[-1:]
58
+ and _git(repo, "diff", "--stat", "HEAD").splitlines()[-1],
59
+ ),
60
+ ]
61
+ parts.append(("observed sub-task activity", observed_activity(repo)))
62
+ text = "\n\n".join(f"{k}:\n{v}" for k, v in parts if v)
63
+ return text[:MAX_CONTEXT] or "(not a git repository)"
64
+
65
+
66
+ def observed_activity(repo: str, limit: int = 5) -> str:
67
+ """Last real event of the active sub-tasks working on this repository (no model involved)."""
68
+ from . import admission, events, jobs
69
+
70
+ lines = []
71
+ try:
72
+ active = admission.active()
73
+ except OSError:
74
+ return ""
75
+ for job_id in active[:limit]:
76
+ spec = jobs.spec(job_id)
77
+ if spec.get("repo") != repo and spec.get("cwd") != repo:
78
+ continue
79
+ last = events.last(job_id)
80
+ lines.append(f"{job_id} {spec.get('engine')}: {last.get('phase', '?')} {last.get('msg', '')}"[:150])
81
+ return "\n".join(lines)
82
+
83
+
84
+ def _call(spec: tuple[str, float, float], system: str, user: str) -> str:
85
+ """One Messages API call, booked in the ledger. Raises on any failure."""
86
+ model, price_in, price_out = spec
87
+ secrets = config.load_secrets()
88
+ key = secrets.get("ANTHROPIC_API_KEY")
89
+ if not key:
90
+ raise RuntimeError("no API key")
91
+ job = f"step-{time.time_ns()}"
92
+ rid = f"{job}#1"
93
+ if ledger.reserve(rid, job, RESERVE_USD) is None:
94
+ raise RuntimeError("monthly cap reached")
95
+ cost = 0.0
96
+ try:
97
+ body = {
98
+ "model": model,
99
+ "max_tokens": 800,
100
+ # Sonnet 5.5 refuses "disabled"; "between_tools" means no thinking there.
101
+ "thinking": {"type": "between_tools" if "sonnet-5" in model else "disabled"},
102
+ "system": system,
103
+ "messages": [{"role": "user", "content": user}],
104
+ }
105
+ headers = {
106
+ "x-api-key": key,
107
+ "anthropic-version": "2023-06-01",
108
+ "content-type": "application/json",
109
+ }
110
+ if secrets.get("ANTHROPIC_WORKSPACE_ID"):
111
+ headers["anthropic-workspace-id"] = secrets["ANTHROPIC_WORKSPACE_ID"]
112
+ req = urllib.request.Request(
113
+ "https://api.anthropic.com/v1/messages", data=json.dumps(body).encode(), headers=headers
114
+ )
115
+ with urllib.request.urlopen(req, timeout=60) as resp:
116
+ data = json.load(resp)
117
+ usage = data.get("usage", {})
118
+ cost = (usage.get("input_tokens", 0) * price_in + usage.get("output_tokens", 0) * price_out) / 1e6
119
+ return "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text").strip()
120
+ finally:
121
+ ledger.close(rid, cost)
122
+
123
+
124
+ def review(step_text: str, repo: str, lang: str) -> str:
125
+ """Explain the step with the cheap model: the text, or '(unavailable: reason)'.
126
+
127
+ Falsification does not run here: it belongs to finished, complex work and
128
+ runs as a separate sub-task on Sonnet.
129
+ """
130
+ user = f"Declared step:\n{step_text}\n\nRepository state:\n{repo_context(repo)}"
131
+ try:
132
+ return _call(EXPLAINER, EXPLAIN.format(lang=lang), user)
133
+ except Exception as exc: # noqa: BLE001 - the step must never block the work
134
+ return f"(unavailable: {type(exc).__name__}: {str(exc)[:80]})"
subtask/policy.py ADDED
@@ -0,0 +1,58 @@
1
+ """Security policy: what a job demands, and whether an engine can enforce it.
2
+
3
+ Levels, weakest first:
4
+ - worktree: git isolation only. The agent can still read and write the rest of
5
+ the host as the same user. Not a sandbox.
6
+ - sandbox: the engine's own sandbox for shell commands (Claude Code OS sandbox,
7
+ Codex workspace-write). Same user: a guard rail, not a strong boundary.
8
+ - isolated: subtask runs the engine under bubblewrap: read-only system,
9
+ writable worktree and git dir only, a private HOME holding only the engine's
10
+ own state, new pid/ipc/uts namespaces.
11
+
12
+ A job states a minimum. Engines that cannot enforce it are not eligible, and
13
+ nothing downgrades silently: start() fails instead.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .engines.base import LEVELS, Adapter
19
+
20
+ DEFAULT_LEVEL = "worktree"
21
+
22
+
23
+ def demand(readonly: bool = False, level: str | None = None, untrusted: bool = False) -> dict:
24
+ if level == "untrusted": # shorthand: sandbox at least, network off for commands
25
+ level, untrusted = "sandbox", True
26
+ level = level or ("sandbox" if untrusted else DEFAULT_LEVEL)
27
+ if level not in LEVELS:
28
+ raise ValueError(f"policy must be one of {', '.join(LEVELS)}, untrusted")
29
+ if untrusted and LEVELS.index(level) < LEVELS.index("sandbox"):
30
+ raise ValueError("untrusted needs policy sandbox or isolated")
31
+ return {"level": level, "readonly": bool(readonly), "untrusted": bool(untrusted)}
32
+
33
+
34
+ def effective(adapter: Adapter, wanted: dict) -> tuple[str | None, str]:
35
+ """(level the run will really have, reason). None = cannot satisfy."""
36
+ caps = adapter.capabilities
37
+ if wanted["readonly"]:
38
+ if not caps.readonly:
39
+ return None, f"{adapter.name} cannot run read-only"
40
+ return "readonly", "engine-enforced read-only"
41
+ if wanted["untrusted"] and not caps.command_network_off:
42
+ return None, f"{adapter.name} cannot deny network to commands (untrusted)"
43
+ floor = LEVELS.index(wanted["level"])
44
+ if wanted["level"] == "isolated":
45
+ if "isolated" in caps.levels:
46
+ return "isolated", "bubblewrap runner"
47
+ return None, f"{adapter.name} cannot run isolated"
48
+ # Strongest native level at or above the demand; isolation only when asked or needed.
49
+ native = [lvl for lvl in caps.levels if lvl != "isolated" and LEVELS.index(lvl) >= floor]
50
+ if native:
51
+ level = max(native, key=LEVELS.index)
52
+ return (
53
+ level,
54
+ "engine sandbox, same user" if level == "sandbox" else "git worktree only, not a host sandbox",
55
+ )
56
+ if "isolated" in caps.levels:
57
+ return "isolated", "bubblewrap runner (engine has no native sandbox)"
58
+ return None, f"{adapter.name} cannot enforce {wanted['level']}"
subtask/procid.py ADDED
@@ -0,0 +1,97 @@
1
+ """Process identity that survives PID reuse: pid + start time + boot id.
2
+
3
+ `alive()` answers "alive", "dead" or "unknown". Only "dead" is proof: callers
4
+ must not settle money or free a slot on "unknown".
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ ALIVE, DEAD, UNKNOWN = "alive", "dead", "unknown"
15
+
16
+
17
+ def boot_id() -> str:
18
+ try:
19
+ return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
20
+ except OSError:
21
+ return ""
22
+
23
+
24
+ def start_ticks(pid: int) -> str | None:
25
+ """Kernel start time of `pid` (clock ticks since boot on Linux, lstart elsewhere)."""
26
+ if Path("/proc/self/stat").exists(): # Linux: /proc is authoritative
27
+ try:
28
+ stat = Path(f"/proc/{pid}/stat").read_text()
29
+ return stat.rsplit(")", 1)[1].split()[19] # field 22: starttime
30
+ except FileNotFoundError:
31
+ return None
32
+ except (OSError, IndexError):
33
+ pass
34
+ if sys.platform == "win32":
35
+ return None
36
+ try:
37
+ done = subprocess.run(
38
+ ["ps", "-o", "lstart=", "-p", str(pid)], capture_output=True, text=True, timeout=5, check=False
39
+ )
40
+ except (OSError, subprocess.TimeoutExpired):
41
+ return None
42
+ return done.stdout.strip() or None
43
+
44
+
45
+ def identity(pid: int | None = None) -> dict:
46
+ pid = os.getpid() if pid is None else pid
47
+ return {"pid": pid, "start": start_ticks(pid), "boot": boot_id()}
48
+
49
+
50
+ def _exists(pid: int) -> bool | None:
51
+ try:
52
+ os.kill(pid, 0)
53
+ except ProcessLookupError:
54
+ return False
55
+ except PermissionError:
56
+ return True
57
+ except OSError:
58
+ return None
59
+ return True
60
+
61
+
62
+ def alive(ident: dict | None) -> str:
63
+ if not isinstance(ident, dict) or not ident.get("pid"):
64
+ return UNKNOWN
65
+ try:
66
+ pid = int(ident["pid"])
67
+ except (TypeError, ValueError):
68
+ return UNKNOWN # a corrupt identity proves nothing
69
+ if pid <= 0:
70
+ return UNKNOWN
71
+ try:
72
+ if os.waitpid(pid, os.WNOHANG)[0] == pid:
73
+ return DEAD # our own child that exited: reaped now
74
+ except ChildProcessError:
75
+ pass
76
+ except OSError:
77
+ pass
78
+ if ident.get("boot") and boot_id() and ident["boot"] != boot_id():
79
+ return DEAD # the machine rebooted since
80
+ exists = _exists(pid)
81
+ if exists is False:
82
+ return DEAD
83
+ if exists is None:
84
+ return UNKNOWN
85
+ if _zombie(pid):
86
+ return DEAD
87
+ recorded, now = ident.get("start"), start_ticks(pid)
88
+ if recorded and now:
89
+ return ALIVE if recorded == now else DEAD # different start time: pid reused
90
+ return UNKNOWN # exists, but its start time cannot be compared
91
+
92
+
93
+ def _zombie(pid: int) -> bool:
94
+ try:
95
+ return Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()[0] == "Z"
96
+ except (OSError, IndexError):
97
+ return False