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/progress.py ADDED
@@ -0,0 +1,152 @@
1
+ """Turn job events into short progress lines while a tool call is running.
2
+
3
+ `Reporter` is protocol-agnostic: it gets a `send(progress, message, total)`
4
+ callable from the server. It never invents a percentage: `progress` is a
5
+ counter of updates (MCP requires it to grow), the jobs-finished ratio is in
6
+ the message, and elapsed time is wall time of this call (monotonic clock).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import time
13
+ from collections.abc import Callable
14
+
15
+ from . import events, jobs
16
+
17
+ MIN_INTERVAL_S = float(os.environ.get("SUBTASK_PROGRESS_MIN_S", "2"))
18
+ HEARTBEAT_S = float(os.environ.get("SUBTASK_PROGRESS_HEARTBEAT_S", "10"))
19
+ NO_DETAIL = "worker running; internal activity not available"
20
+ LABELS = {
21
+ "admission": "admitting",
22
+ "worktree": "preparing worktree",
23
+ "spawn": "starting worker",
24
+ "start": "worker started",
25
+ "tool": "",
26
+ "verify": "verifying",
27
+ "reply": "resuming",
28
+ }
29
+
30
+
31
+ def clock(seconds: float) -> str:
32
+ seconds = int(max(0, seconds))
33
+ return (
34
+ f"{seconds // 60:02d}:{seconds % 60:02d}"
35
+ if seconds < 3600
36
+ else f"{seconds // 3600}h{seconds % 3600 // 60:02d}"
37
+ )
38
+
39
+
40
+ class Reporter:
41
+ """Coalesces events of the given jobs into throttled notifications."""
42
+
43
+ def __init__(
44
+ self,
45
+ send: Callable[[float, str, float | None], None],
46
+ ids: list[str] | None = None,
47
+ label: str = "Subtask",
48
+ now: Callable[[], float] = time.monotonic,
49
+ min_interval: float = MIN_INTERVAL_S,
50
+ heartbeat: float = HEARTBEAT_S,
51
+ ) -> None:
52
+ self.send, self.label, self.now = send, label, now
53
+ self.min_interval, self.heartbeat = min_interval, heartbeat
54
+ self.t0 = now()
55
+ self.count = 0
56
+ self.last_sent = -1e9
57
+ self.last_text = ""
58
+ self.ids: list[str] = []
59
+ self.offsets: dict[str, int] = {}
60
+ self.activity: dict[str, str] = {}
61
+ self.states: dict[str, str] = {}
62
+ self.changed: dict[str, float] = {} # monotonic time of each job's last real change
63
+ self.detailed: dict[str, bool] = {} # True once the engine reported its own activity
64
+ self.sent_ok = 0
65
+ self.send_errors = 0
66
+ for job_id in ids or []:
67
+ self.add(job_id)
68
+
69
+ def add(self, job_id: str) -> None:
70
+ if job_id not in self.offsets:
71
+ self.ids.append(job_id)
72
+ # Start from the current end: only what happens during this call is news.
73
+ _, self.offsets[job_id] = events.read_since(job_id, 0, limit=10**9)
74
+ last = events.last(job_id)
75
+ self.activity[job_id] = _describe(last)
76
+ self.detailed[job_id] = last.get("source") == "adapter"
77
+ self.changed[job_id] = self.now()
78
+
79
+ def phase(self, text: str, force: bool = False) -> None:
80
+ """A step of this call itself (admission, worktree...), not of a job."""
81
+ self._maybe_send(text, significant=True, force=force)
82
+
83
+ def poll(self) -> None:
84
+ significant = False
85
+ for job_id in self.ids:
86
+ new, self.offsets[job_id] = events.read_since(job_id, self.offsets[job_id])
87
+ for event in new:
88
+ text = _describe(event)
89
+ if event.get("source") == "adapter":
90
+ self.detailed[job_id] = True
91
+ if text and text != self.activity.get(job_id):
92
+ self.activity[job_id] = text
93
+ self.changed[job_id] = self.now()
94
+ significant = True
95
+ state = jobs.status(job_id).get("state", "unknown")
96
+ if state != self.states.get(job_id):
97
+ self.states[job_id] = state
98
+ self.changed[job_id] = self.now()
99
+ significant = True
100
+ self._maybe_send(self.summary(), significant)
101
+
102
+ def summary(self) -> str:
103
+ if not self.ids:
104
+ return ""
105
+ ended = [j for j in self.ids if self.states.get(j) in jobs.FINAL]
106
+ running = [j for j in self.ids if j not in ended]
107
+ if len(self.ids) == 1:
108
+ job = self.ids[0]
109
+ state = self.states.get(job, "?")
110
+ if state in jobs.FINAL:
111
+ return f"{job} {state} — {self.activity.get(job) or state}"
112
+ return f"{job} — {self._what(job)}"
113
+ head = f"{len(ended)}/{len(self.ids)} ended"
114
+ if running:
115
+ job = max(running, key=lambda j: self.changed.get(j, 0.0)) # the latest real change
116
+ return f"{head} — {job} {self._what(job)}"
117
+ failed = sum(1 for j in ended if self.states.get(j) != "done")
118
+ return f"{head} — {len(ended) - failed} done, {failed} not done"
119
+
120
+ def _what(self, job: str) -> str:
121
+ """The job's latest activity; says so plainly when the engine exposes none."""
122
+ quiet = self.now() - self.changed.get(job, self.t0) > 5
123
+ if self.states.get(job) == "running" and not self.detailed.get(job) and quiet:
124
+ return NO_DETAIL
125
+ return self.activity.get(job) or NO_DETAIL
126
+
127
+ def _maybe_send(self, text: str, significant: bool, force: bool = False) -> None:
128
+ if not text:
129
+ return
130
+ t = self.now()
131
+ due_change = significant and text != self.last_text and t - self.last_sent >= self.min_interval
132
+ due_beat = t - self.last_sent >= self.heartbeat
133
+ if not (force or due_change or due_beat):
134
+ return
135
+ self.count += 1
136
+ self.last_sent, self.last_text = t, text
137
+ message = f"{self.label} · {clock(t - self.t0)} · {text}"
138
+ try:
139
+ self.send(float(self.count), message, None)
140
+ self.sent_ok += 1
141
+ except Exception: # noqa: BLE001 - a broken client never stops the work
142
+ self.send_errors += 1
143
+
144
+
145
+ def _describe(event: dict) -> str:
146
+ if not event:
147
+ return ""
148
+ phase, msg = str(event.get("phase") or ""), str(event.get("msg") or "")
149
+ if phase == "tool":
150
+ return msg
151
+ label = LABELS.get(phase, phase) or ""
152
+ return f"{label}: {msg}" if msg and label else msg or label
subtask/redact.py ADDED
@@ -0,0 +1,61 @@
1
+ """Remove secrets from text before it is saved or shown.
2
+
3
+ Not a guarantee: a pattern list cannot recognise every secret. It is one layer
4
+ next to a minimal environment and the isolated runner, never a replacement.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections.abc import Iterable
11
+
12
+ MASK = "[REDACTED]"
13
+
14
+ PATTERNS: list[tuple[str, re.Pattern[str]]] = [
15
+ (
16
+ "private-key",
17
+ re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"),
18
+ ),
19
+ ("anthropic", re.compile(r"sk-ant-[A-Za-z0-9_-]{8,}")),
20
+ ("openai", re.compile(r"\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}")),
21
+ ("github", re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})")),
22
+ ("gitlab", re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}")),
23
+ ("aws-key-id", re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b")),
24
+ ("google", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")),
25
+ ("slack", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}")),
26
+ ("stripe", re.compile(r"\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}")),
27
+ ("huggingface", re.compile(r"\bhf_[A-Za-z0-9]{30,}")),
28
+ ("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")),
29
+ ("url-credentials", re.compile(r"(?<=://)[^/\s:@]+:[^/\s@]{3,}(?=@)")),
30
+ (
31
+ "assignment",
32
+ re.compile(
33
+ r"(?i)\b([A-Z0-9_]*(?:API_?KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)[A-Z0-9_]*)"
34
+ r"(\s*[=:]\s*[\"']?)([^\s\"'`]{8,})"
35
+ ),
36
+ ),
37
+ ]
38
+
39
+ _known: set[str] = set()
40
+
41
+
42
+ def remember(values: Iterable[str]) -> None:
43
+ """Exact secret values subtask itself loaded: always masked, whatever their shape."""
44
+ for value in values:
45
+ if value and len(value) >= 8:
46
+ _known.add(value)
47
+
48
+
49
+ def redact(text: str) -> str:
50
+ if not text:
51
+ return text
52
+ for value in sorted(_known, key=len, reverse=True):
53
+ text = text.replace(value, MASK)
54
+ for name, pattern in PATTERNS:
55
+ if name == "assignment":
56
+ text = pattern.sub(lambda m: m.group(1) + m.group(2) + MASK, text)
57
+ elif name == "anthropic":
58
+ text = pattern.sub("sk-ant-" + MASK, text)
59
+ else:
60
+ text = pattern.sub(MASK, text)
61
+ return text
subtask/roi.py ADDED
@@ -0,0 +1,84 @@
1
+ """Delegation ROI from local telemetry: separate ratios with units, never one magic number.
2
+
3
+ What the server can see: worker time, charged/observed cost, outcomes,
4
+ verification, parent round trips, and the size of what the parent read
5
+ (summary) versus what it would have read (full result). It cannot see the
6
+ parent's own context or what the parent would have spent doing the task
7
+ itself: that baseline needs the benchmark harness (bench/e2e.py).
8
+ Token figures here are estimates (bytes / ratio) and are labelled so.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import statistics
14
+ from collections import defaultdict
15
+
16
+ from . import telemetry
17
+
18
+ RATIOS = (3.0, 4.0, 5.0) # bytes per token, for the sensitivity line
19
+
20
+
21
+ def _median(values: list[float]) -> float | None:
22
+ return round(statistics.median(values), 1) if values else None
23
+
24
+
25
+ def compute(rows: list[dict] | None = None) -> dict:
26
+ rows = telemetry.rows(months=12) if rows is None else rows
27
+ groups: dict[tuple[str, str], list[dict]] = defaultdict(list)
28
+ for row in rows:
29
+ groups[(row.get("engine") or "?", row.get("kind") or "?")].append(row)
30
+ out = {}
31
+ for (engine, kind), items in sorted(groups.items()):
32
+ jobs = {r.get("job") for r in items}
33
+ starts = [r for r in items if r.get("attempt_kind") == "start"]
34
+ verified = [r for r in items if r.get("verified") is True]
35
+ verified_jobs = {r.get("job") for r in verified}
36
+ done_unverified = {
37
+ r.get("job") for r in items if r.get("outcome") == "done" and r.get("verified") is None
38
+ }
39
+ first_ok = [r for r in starts if r.get("verified") is True]
40
+ usd = [float(r["cost_charged"]) for r in items if isinstance(r.get("cost_charged"), (int, float))]
41
+ paid = any(r.get("cost_reserved") for r in items)
42
+ saved = [max(0, (r.get("result_bytes") or 0) - (r.get("summary_bytes") or 0)) for r in items]
43
+ out[f"{engine}/{kind}"] = {
44
+ "jobs": len(jobs),
45
+ "attempts": len(items),
46
+ "replies": sum(1 for r in items if r.get("attempt_kind") == "reply"),
47
+ "accepted_verified": len(verified_jobs),
48
+ "done_unverified": len(done_unverified - verified_jobs),
49
+ "first_attempt_verified_rate": round(len(first_ok) / len(starts), 3) if starts else None,
50
+ "usd_total_incl_failures": round(sum(usd), 4) if paid else None,
51
+ "usd_per_accepted": round(sum(usd) / len(verified_jobs), 4) if paid and verified_jobs else None,
52
+ "billing": "usd" if paid else "subscription/quota (not dollars)",
53
+ "median_run_s": _median([float(r["run_s"]) for r in items if r.get("run_s") is not None]),
54
+ "median_queue_s": _median([float(r["queue_s"]) for r in items if r.get("queue_s") is not None]),
55
+ "context_saved_tokens_est": {f"bytes/{k:g}": int(sum(saved) / k) for k in RATIOS},
56
+ "summary_tokens_est_bytes/4": int(sum(r.get("summary_bytes") or 0 for r in items) / 4),
57
+ }
58
+ return out
59
+
60
+
61
+ def report(rows: list[dict] | None = None) -> str:
62
+ data = compute(rows)
63
+ if not data:
64
+ return "no telemetry yet"
65
+ lines = [
66
+ (
67
+ "engine/kind: jobs · verified · done-unverified · 1st-try verified · $ (incl. failures) · $/verified · "
68
+ "median run · context saved (tokens, est. bytes/4 [3..5])"
69
+ ),
70
+ ]
71
+ for key, d in data.items():
72
+ saved = d["context_saved_tokens_est"]
73
+ usd = "-" if d["usd_total_incl_failures"] is None else f"{d['usd_total_incl_failures']}$"
74
+ per = "-" if d["usd_per_accepted"] is None else f"{d['usd_per_accepted']}$"
75
+ rate = "-" if d["first_attempt_verified_rate"] is None else f"{d['first_attempt_verified_rate']:.0%}"
76
+ lines.append(
77
+ f"{key}: {d['jobs']} · {d['accepted_verified']} · {d['done_unverified']} · {rate} · {usd} · {per} · "
78
+ f"{d['median_run_s']}s · {saved['bytes/4']} [{saved['bytes/5']}..{saved['bytes/3']}]"
79
+ )
80
+ lines.append(
81
+ "Notes: unverified 'done' is not counted as accepted; subscription engines have no dollar cost here; "
82
+ "the parent-side baseline (doing it yourself) is measured only by bench/e2e.py."
83
+ )
84
+ return "\n".join(lines)
subtask/router.py ADDED
@@ -0,0 +1,202 @@
1
+ """Pick an engine: filter what is admissible, then rank by an explicit utility.
2
+
3
+ u = w_success * P(success | engine, kind) probability 0..1
4
+ - w_cost * expected_cost_usd / budget_usd 0..1 (paid engines only)
5
+ - w_latency * expected_minutes / 60 hours
6
+ - w_scarcity * (1 - free/100) 0..1; unknown free -> prior, flagged
7
+
8
+ P(success) is a prior from the kind's ranking unless the local history has at
9
+ least `min_samples` verified outcomes for that engine and kind. The weights
10
+ are configuration, not a proven optimum: `routing.json` in the config dir.
11
+ `legacy` mode keeps the original heuristic (free% - 5*rank) as a baseline.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ from pathlib import Path
19
+
20
+ from . import capacity, config, ledger, policy, telemetry
21
+ from .engines import registry
22
+ from .engines.base import field, record
23
+
24
+ # Which engines fit which kind of task, best first. Plain `opencode` is manual-only.
25
+ KINDS = {
26
+ "read": ["haiku-api", "dsh", "kimi", "opencode-go", "sonnet-api"],
27
+ "code": ["sonnet-api", "opencode-go", "dsh", "codex", "kimi"],
28
+ "review": ["sonnet-api", "codex", "kimi"],
29
+ "hard": ["opus-api", "codex", "sonnet-api"],
30
+ }
31
+ DEFAULTS = {
32
+ "mode": "utility",
33
+ "w_success": 100.0,
34
+ "w_cost": 20.0,
35
+ "w_latency": 10.0,
36
+ "w_scarcity": 100.0,
37
+ "unknown_free_prior": 50.0,
38
+ "min_free": 10.0,
39
+ "min_samples": 10,
40
+ "prior_success_top": 0.8,
41
+ "prior_success_step": 0.05,
42
+ "prior_minutes": 10.0,
43
+ "prior_cost_share": 0.5,
44
+ }
45
+
46
+
47
+ def _median(values: list[float]) -> float:
48
+ ordered = sorted(values)
49
+ mid = len(ordered) // 2
50
+ return ordered[mid] if len(ordered) % 2 else (ordered[mid - 1] + ordered[mid]) / 2
51
+
52
+
53
+ def settings() -> dict:
54
+ path = Path(os.environ.get("SUBTASK_ROUTING", config.ENV_FILE.parent / "routing.json"))
55
+ try:
56
+ data = json.loads(path.read_text())
57
+ except (OSError, json.JSONDecodeError):
58
+ data = {}
59
+ return {**DEFAULTS, **{k: v for k, v in data.items() if k in DEFAULTS}}
60
+
61
+
62
+ @record
63
+ class Option:
64
+ engine: str
65
+ admissible: bool
66
+ why: list[str] = field(default_factory=list)
67
+ level: str | None = None
68
+ terms: dict = field(default_factory=dict)
69
+ utility: float | None = None
70
+
71
+
72
+ def _history(kind: str) -> dict[str, list[dict]]:
73
+ by_engine: dict[str, list[dict]] = {}
74
+ for row in telemetry.rows():
75
+ if row.get("kind") == kind and row.get("attempt_kind") == "start":
76
+ by_engine.setdefault(row.get("engine", "?"), []).append(row)
77
+ return by_engine
78
+
79
+
80
+ def _installed(adapter) -> bool:
81
+ return adapter.available()
82
+
83
+
84
+ def rank(kind: str, max_usd: float | None, wanted: dict) -> list[Option]:
85
+ cfg = settings()
86
+ candidates = KINDS.get(kind, KINDS["code"])
87
+ snap = capacity.snapshot()
88
+ budget = max_usd or config.DEFAULT_MAX_USD
89
+ left = config.MONTHLY_CAP_USD - ledger.committed()
90
+ history = _history(kind) if cfg["mode"] == "utility" else {}
91
+ options: list[Option] = []
92
+ for rank_i, name in enumerate(candidates):
93
+ adapter = registry.get(name)
94
+ option = Option(name, True)
95
+ options.append(option)
96
+ if adapter is None:
97
+ option.admissible, option.why = False, ["not registered"]
98
+ continue
99
+ level, reason = policy.effective(adapter, wanted)
100
+ option.level = level
101
+ if level is None:
102
+ option.admissible = False
103
+ option.why.append(reason)
104
+ if not _installed(adapter):
105
+ option.admissible = False
106
+ option.why.append(f"{adapter.binary or getattr(adapter, 'module', adapter.name)} not installed")
107
+ if adapter.capabilities.paid and left < budget:
108
+ option.admissible = False
109
+ option.why.append(f"monthly cap: {left:.2f}$ left < {budget:.2f}$ budget")
110
+ reading = snap.get(name) or {}
111
+ free = reading.get("value")
112
+ if free is not None and free < cfg["min_free"]:
113
+ option.admissible = False
114
+ option.why.append(f"free {free:.0f}% < {cfg['min_free']:.0f}% ({reading.get('note', '')})")
115
+ if not option.admissible:
116
+ continue
117
+ assumed = free is None
118
+ free_used = cfg["unknown_free_prior"] if assumed else free
119
+ if cfg["mode"] == "legacy":
120
+ option.utility = free_used - rank_i * 5
121
+ option.terms = {"free": free_used, "free_assumed": assumed, "rank": rank_i}
122
+ continue
123
+ rows = history.get(name, [])
124
+ verified = [r for r in rows if r.get("verified") is not None]
125
+ if len(verified) >= cfg["min_samples"]:
126
+ wins = sum(1 for r in verified if r["verified"])
127
+ p_success, p_src = (wins + 1) / (len(verified) + 2), f"learned n={len(verified)}"
128
+ else:
129
+ p_success = max(0.05, cfg["prior_success_top"] - cfg["prior_success_step"] * rank_i)
130
+ p_src = f"prior (rank {rank_i}, n={len(verified)} < {cfg['min_samples']})"
131
+ minutes = [r["run_s"] / 60 for r in rows if isinstance(r.get("run_s"), (int, float))]
132
+ exp_min = _median(minutes) if len(minutes) >= 3 else cfg["prior_minutes"]
133
+ costs = [r["cost_charged"] for r in rows if isinstance(r.get("cost_charged"), (int, float))]
134
+ if adapter.capabilities.paid:
135
+ exp_cost = _median(costs) if len(costs) >= 3 else cfg["prior_cost_share"] * budget
136
+ else:
137
+ exp_cost = 0.0 # subscription or unknown: its scarcity is the quota term, not dollars
138
+ scarcity = 1 - free_used / 100
139
+ option.terms = {
140
+ "p_success": round(p_success, 3), "p_source": p_src,
141
+ "exp_cost_usd": round(exp_cost, 3), "exp_minutes": round(exp_min, 1),
142
+ "free_pct": round(free_used, 1), "free_assumed": assumed,
143
+ "free_source": reading.get("source"), "free_confidence": reading.get("confidence"),
144
+ } # fmt: skip
145
+ option.utility = round(
146
+ cfg["w_success"] * p_success
147
+ - cfg["w_cost"] * min(1.0, exp_cost / budget)
148
+ - cfg["w_latency"] * exp_min / 60
149
+ - cfg["w_scarcity"] * scarcity,
150
+ 2,
151
+ )
152
+ return sorted(options, key=lambda o: (o.admissible, o.utility or -1e9), reverse=True)
153
+
154
+
155
+ def pick(
156
+ kind: str = "code", max_usd: float | None = None, wanted: dict | None = None
157
+ ) -> tuple[str, str, list[Option]]:
158
+ """(engine, one-line reason, full ranking). engine '' when nothing is admissible."""
159
+ wanted = wanted or policy.demand()
160
+ options = rank(kind, max_usd, wanted)
161
+ best = next((o for o in options if o.admissible), None)
162
+ if best is None:
163
+ why = "; ".join(f"{o.engine}: {', '.join(o.why)}" for o in options)
164
+ return "", f"no engine can take a {kind} task ({why})"[:400], options
165
+ t = best.terms
166
+ if "p_success" in t:
167
+ reason = (
168
+ f"{kind} → {best.engine} (u={best.utility}: P={t['p_success']} {t['p_source']}, "
169
+ f"free {t['free_pct']:.0f}%{' assumed' if t['free_assumed'] else ''}, ~{t['exp_cost_usd']}$, "
170
+ f"~{t['exp_minutes']}m, level {best.level})"
171
+ )
172
+ else:
173
+ reason = (
174
+ f"{kind} → {best.engine} (legacy: free {t['free']:.0f}%, rank {t['rank']}, level {best.level})"
175
+ )
176
+ return best.engine, reason, options
177
+
178
+
179
+ def explain(kind: str, max_usd: float | None = None, wanted: dict | None = None) -> str:
180
+ _, reason, options = pick(kind, max_usd, wanted)
181
+ lines = [reason]
182
+ for o in options:
183
+ mark = "✓" if o.admissible else "✗"
184
+ lines.append(f"{mark} {o.engine:12} u={o.utility} {o.terms or ''} {'; '.join(o.why)}"[:220])
185
+ return "\n".join(lines)
186
+
187
+
188
+ def calibrate() -> str:
189
+ """Success counts per engine and kind from verified outcomes; which cells are learned."""
190
+ cfg = settings()
191
+ table: dict[tuple[str, str], list[bool]] = {}
192
+ for row in telemetry.rows(months=12):
193
+ if row.get("verified") is not None and row.get("attempt_kind") == "start":
194
+ table.setdefault((row.get("engine", "?"), row.get("kind", "?")), []).append(bool(row["verified"]))
195
+ if not table:
196
+ return "no verified outcomes yet: routing uses priors (start jobs with verify= to collect them)"
197
+ lines = []
198
+ for (engine, kind), wins in sorted(table.items()):
199
+ n = len(wins)
200
+ state = "learned" if n >= cfg["min_samples"] else f"prior (needs {cfg['min_samples']})"
201
+ lines.append(f"{engine:12} {kind:7} n={n:3} success={sum(wins) / n:.0%} → {state}")
202
+ return "\n".join(lines)
subtask/runners.py ADDED
@@ -0,0 +1,99 @@
1
+ """How the engine process is launched: directly, or inside a bubblewrap sandbox.
2
+
3
+ The isolated runner is a real boundary for the filesystem: the engine sees a
4
+ read-only system, its worktree and git dir, and a private HOME that only holds
5
+ the engine's own state directories. It does not hide the engine's own
6
+ credentials from the engine (it needs them), and model traffic keeps the network.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shutil
13
+ from pathlib import Path
14
+
15
+ SYSTEM_RO = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/lib32", "/etc", "/opt", "/nix", "/snap"]
16
+ BASE_ENV = ("PATH", "LANG", "LC_ALL", "TERM", "TZ", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS")
17
+
18
+ # Per engine family: state directories under HOME the engine needs (read-write).
19
+ STATE_DIRS = {
20
+ "codex": [".codex"],
21
+ "kimi": [".kimi-code", ".kimi", ".config/kimi", ".local/share/kimi", ".cache/kimi"],
22
+ "opencode": [".local/share/opencode", ".config/opencode", ".cache/opencode", ".local/state/opencode"],
23
+ "api": [".claude", ".claude.json"],
24
+ }
25
+
26
+
27
+ def minimal_env(extra: dict[str, str] | None = None) -> dict[str, str]:
28
+ env = {k: os.environ[k] for k in BASE_ENV if k in os.environ}
29
+ env["HOME"] = str(Path.home())
30
+ env.setdefault("PATH", "/usr/local/bin:/usr/bin:/bin")
31
+ env.update(extra or {})
32
+ return env
33
+
34
+
35
+ def bwrap_available() -> bool:
36
+ """bwrap installed AND able to create its namespaces here (some hosts forbid user namespaces)."""
37
+ exe = shutil.which("bwrap")
38
+ if not exe:
39
+ return False
40
+ import subprocess
41
+
42
+ try:
43
+ done = subprocess.run(
44
+ [exe, "--ro-bind", "/", "/", "--unshare-pid", "--", "true"],
45
+ capture_output=True, timeout=10, check=False,
46
+ ) # fmt: skip
47
+ except (OSError, subprocess.TimeoutExpired):
48
+ return False
49
+ return done.returncode == 0
50
+
51
+
52
+ def direct(argv: list[str], env: dict[str, str]) -> tuple[list[str], dict[str, str]]:
53
+ return argv, env
54
+
55
+
56
+ def isolated(spec: dict, family: str):
57
+ """A wrap() for RunContext that runs argv under bwrap, or raises if bwrap is missing."""
58
+ bwrap = shutil.which("bwrap")
59
+ if not bwrap or not bwrap_available():
60
+ raise RuntimeError("policy isolated needs a working bubblewrap (bwrap); refusing to run unisolated")
61
+ home = Path.home()
62
+
63
+ def wrap(argv: list[str], env: dict[str, str]) -> tuple[list[str], dict[str, str]]:
64
+ args = [
65
+ bwrap,
66
+ "--die-with-parent",
67
+ "--new-session",
68
+ "--unshare-pid",
69
+ "--unshare-ipc",
70
+ "--unshare-uts",
71
+ ]
72
+ for path in SYSTEM_RO:
73
+ if os.path.exists(path):
74
+ args += ["--ro-bind", path, path]
75
+ args += ["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--tmpfs", str(home)]
76
+ exe = shutil.which(argv[0], path=env.get("PATH"))
77
+ if exe:
78
+ for candidate in {Path(exe).parent, Path(exe).resolve().parent}:
79
+ if not any(str(candidate).startswith(p + "/") or str(candidate) == p for p in SYSTEM_RO):
80
+ # the engine binary lives outside the system dirs (e.g. ~/.local/bin): read-only
81
+ args += ["--ro-bind-try", str(candidate), str(candidate)]
82
+ real = Path(exe).resolve()
83
+ if home in real.parents:
84
+ parts = real.relative_to(home).parts
85
+ depth = 3 if parts[0] == ".local" else 1 # ~/.local/share/<tool>, or ~/.<tool>
86
+ if len(parts) > depth:
87
+ root = home.joinpath(*parts[:depth])
88
+ args += ["--ro-bind-try", str(root), str(root)]
89
+ for rel in STATE_DIRS.get(family, []):
90
+ target = home / rel
91
+ if target.exists():
92
+ args += ["--bind", str(target), str(target)]
93
+ for key in ("cwd", "git_dir"):
94
+ if spec.get(key):
95
+ args += ["--bind", spec[key], spec[key]]
96
+ args += ["--chdir", spec["cwd"], "--setenv", "HOME", str(home), "--"]
97
+ return args + argv, env
98
+
99
+ return wrap