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
subtask/core.py
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
"""Operations shared by the MCP servers and the CLI. Every answer is short on purpose.
|
|
2
|
+
|
|
3
|
+
start: validate -> select (router) -> admit slot + reserve money (one critical
|
|
4
|
+
section) -> prepare worktree -> spawn -> record worker identity. Every failure
|
|
5
|
+
after admission compensates: release the reservation, free the slot.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import admission, config, events, jobs, ledger, policy, procid, router, telemetry
|
|
21
|
+
from .engines import registry
|
|
22
|
+
|
|
23
|
+
EFFORTS = ("low", "medium", "high", "xhigh", "max")
|
|
24
|
+
KINDS = ("read", "code", "review", "hard")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _git(repo: str, *args: str) -> str:
|
|
28
|
+
return subprocess.run(
|
|
29
|
+
["git", "-C", repo, *args], capture_output=True, text=True, check=True
|
|
30
|
+
).stdout.strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _spawn(job_id: str, *extra: str) -> dict:
|
|
34
|
+
with (jobs.job_dir(job_id) / "worker.log").open("a") as log:
|
|
35
|
+
proc = subprocess.Popen(
|
|
36
|
+
[sys.executable, "-m", "subtask.worker", job_id, *extra],
|
|
37
|
+
stdout=log,
|
|
38
|
+
stderr=log,
|
|
39
|
+
stdin=subprocess.DEVNULL,
|
|
40
|
+
start_new_session=True,
|
|
41
|
+
cwd=Path(__file__).resolve().parents[1],
|
|
42
|
+
)
|
|
43
|
+
return procid.identity(proc.pid)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _phase(progress, job_id: str | None, phase: str, msg: str) -> None:
|
|
47
|
+
if job_id:
|
|
48
|
+
events.emit(job_id, phase, msg)
|
|
49
|
+
if progress is not None:
|
|
50
|
+
progress.phase(f"{job_id or 'subtask'} — {msg}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _validate(task, engine, kind, budget, max_turns, base, effort) -> str | None:
|
|
54
|
+
if not isinstance(task, str) or not task.strip():
|
|
55
|
+
return "empty task"
|
|
56
|
+
if engine != "auto" and registry.get(engine) is None:
|
|
57
|
+
return f"unknown engine: {engine}. Valid: auto, {', '.join(registry.names())}"
|
|
58
|
+
if kind not in KINDS:
|
|
59
|
+
return f"kind must be {'|'.join(KINDS)}"
|
|
60
|
+
if not (isinstance(budget, (int, float)) and math.isfinite(budget) and 0 < budget <= config.MAX_JOB_USD):
|
|
61
|
+
return f"max_usd must be between 0 and {config.MAX_JOB_USD}"
|
|
62
|
+
if not isinstance(max_turns, int) or isinstance(max_turns, bool) or not 1 <= max_turns <= 500:
|
|
63
|
+
return "max_turns must be between 1 and 500"
|
|
64
|
+
if not isinstance(base, str) or base.startswith("-"):
|
|
65
|
+
return "invalid base"
|
|
66
|
+
if effort not in EFFORTS:
|
|
67
|
+
return "effort must be " + "|".join(EFFORTS)
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def start(
|
|
72
|
+
task: str,
|
|
73
|
+
engine: str = "auto",
|
|
74
|
+
repo: str | None = None,
|
|
75
|
+
cwd: str | None = None,
|
|
76
|
+
base: str = "HEAD",
|
|
77
|
+
readonly: bool = False,
|
|
78
|
+
max_usd: float | None = None,
|
|
79
|
+
max_turns: int = 60,
|
|
80
|
+
effort: str = "medium",
|
|
81
|
+
model: str | None = None,
|
|
82
|
+
sandbox: bool = True,
|
|
83
|
+
kind: str = "code",
|
|
84
|
+
files: list[str] | None = None,
|
|
85
|
+
policy: str | None = None,
|
|
86
|
+
verify: str | None = None,
|
|
87
|
+
untrusted: bool = False,
|
|
88
|
+
progress=None,
|
|
89
|
+
) -> str:
|
|
90
|
+
budget = config.DEFAULT_MAX_USD if max_usd is None else max_usd
|
|
91
|
+
problem = _validate(task, engine, kind, budget, max_turns, base, effort)
|
|
92
|
+
if problem:
|
|
93
|
+
return problem
|
|
94
|
+
level = policy or (None if sandbox else "worktree")
|
|
95
|
+
try:
|
|
96
|
+
wanted = _policy_demand(readonly, level, untrusted)
|
|
97
|
+
except ValueError as exc:
|
|
98
|
+
return str(exc)
|
|
99
|
+
reason = ""
|
|
100
|
+
_phase(progress, None, "admission", "choosing engine")
|
|
101
|
+
if engine == "auto":
|
|
102
|
+
engine, reason, _ = router.pick(kind, max_usd, wanted)
|
|
103
|
+
if not engine:
|
|
104
|
+
return reason
|
|
105
|
+
adapter = registry.get(engine)
|
|
106
|
+
assert adapter is not None
|
|
107
|
+
effective, why = _effective(adapter, wanted)
|
|
108
|
+
if effective is None:
|
|
109
|
+
return why
|
|
110
|
+
repo = str(Path(repo).resolve()) if repo else None
|
|
111
|
+
if files:
|
|
112
|
+
task += "\n\nRead these first: " + ", ".join(str(f) for f in files)
|
|
113
|
+
|
|
114
|
+
job_id = jobs.new_id()
|
|
115
|
+
rid = f"{job_id}#1" if adapter.capabilities.paid else None
|
|
116
|
+
with admission.locked(): # slot and money in one critical section, nothing slow inside
|
|
117
|
+
refused = admission.admit_locked(job_id)
|
|
118
|
+
if refused:
|
|
119
|
+
return refused
|
|
120
|
+
try:
|
|
121
|
+
booked = ledger.reserve(rid, job_id, budget) if rid else True
|
|
122
|
+
except BaseException:
|
|
123
|
+
_drop_slot_locked(job_id)
|
|
124
|
+
raise
|
|
125
|
+
if booked is None:
|
|
126
|
+
_drop_slot_locked(job_id)
|
|
127
|
+
return f"monthly cap {config.MONTHLY_CAP_USD:.0f} $ reached: {ledger.committed():.2f} $ committed"
|
|
128
|
+
|
|
129
|
+
folder = jobs.job_dir(job_id)
|
|
130
|
+
spawned = False
|
|
131
|
+
try:
|
|
132
|
+
folder.mkdir(parents=True)
|
|
133
|
+
branch = git_dir = None
|
|
134
|
+
if repo and not readonly:
|
|
135
|
+
_phase(progress, job_id, "worktree", f"preparing worktree from {base}")
|
|
136
|
+
branch = f"subtask/{job_id}"
|
|
137
|
+
workdir = str(config.WORKTREES / job_id)
|
|
138
|
+
config.WORKTREES.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
try:
|
|
140
|
+
base = _git(repo, "rev-parse", "--verify", "--end-of-options", base + "^{commit}")
|
|
141
|
+
_git(repo, "worktree", "add", "-q", "-b", branch, workdir, base)
|
|
142
|
+
git_dir = str(Path(repo, _git(repo, "rev-parse", "--git-common-dir")).resolve())
|
|
143
|
+
except subprocess.CalledProcessError as exc:
|
|
144
|
+
raise _Compensate(f"worktree not created: {exc.stderr.strip()[:200]}") from exc
|
|
145
|
+
task += f"\n\nYou work in an isolated git worktree on branch {branch}. Commit your work there; never push."
|
|
146
|
+
else:
|
|
147
|
+
workdir = str(Path(cwd or repo or os.getcwd()).resolve())
|
|
148
|
+
if not Path(workdir).is_dir():
|
|
149
|
+
raise _Compensate(f"working folder does not exist: {workdir}")
|
|
150
|
+
if effective == "isolated" and adapter.family not in ("api",):
|
|
151
|
+
from .runners import bwrap_available
|
|
152
|
+
|
|
153
|
+
if not bwrap_available():
|
|
154
|
+
raise _Compensate("policy isolated needs bubblewrap (bwrap); refusing to run unisolated")
|
|
155
|
+
spec = {
|
|
156
|
+
"v": 1,
|
|
157
|
+
"task": task,
|
|
158
|
+
"engine": engine,
|
|
159
|
+
"kind": kind,
|
|
160
|
+
"repo": repo,
|
|
161
|
+
"base": base if branch else None,
|
|
162
|
+
"branch": branch,
|
|
163
|
+
"git_dir": git_dir,
|
|
164
|
+
"cwd": workdir,
|
|
165
|
+
"readonly": readonly,
|
|
166
|
+
"policy": {**wanted, "effective": effective, "why": why},
|
|
167
|
+
"verify": verify,
|
|
168
|
+
"model": model,
|
|
169
|
+
"max_usd": budget,
|
|
170
|
+
"max_turns": max_turns,
|
|
171
|
+
"effort": effort,
|
|
172
|
+
"routing": reason or "fixed",
|
|
173
|
+
"created": time.time(),
|
|
174
|
+
}
|
|
175
|
+
jobs.write_json(folder / "spec.json", spec)
|
|
176
|
+
attempt = {"n": 1, "kind": "start", "reservation": rid, "created": time.time()}
|
|
177
|
+
jobs.update_status(job_id, state="queued", attempt=1, attempts=[attempt])
|
|
178
|
+
events.emit(job_id, "admission", f"{engine} · level {effective}", state="queued", attempt=1)
|
|
179
|
+
_phase(progress, job_id, "spawn", f"starting {engine}")
|
|
180
|
+
try:
|
|
181
|
+
ident = _spawn(job_id)
|
|
182
|
+
except OSError as exc:
|
|
183
|
+
raise _Compensate(f"worker not started: {exc}") from exc
|
|
184
|
+
spawned = True # from here on the worker owns the slot and the money: never compensate
|
|
185
|
+
jobs.update_status(job_id, proc=ident, liveness=procid.ALIVE)
|
|
186
|
+
admission.running(job_id, ident)
|
|
187
|
+
except _Compensate as exc:
|
|
188
|
+
_compensate(job_id, rid, folder)
|
|
189
|
+
return str(exc)
|
|
190
|
+
except BaseException:
|
|
191
|
+
if not spawned:
|
|
192
|
+
_compensate(job_id, rid, folder)
|
|
193
|
+
raise
|
|
194
|
+
telemetry.count_call([job_id], "start")
|
|
195
|
+
where = f"branch {branch}" if branch else workdir
|
|
196
|
+
if progress is not None:
|
|
197
|
+
progress.add(job_id)
|
|
198
|
+
note = "" if effective in ("sandbox", "isolated", "readonly") else " · not a host sandbox"
|
|
199
|
+
return f"{job_id} started · {engine} · {where} · {effective}{note}" + (f"\n({reason})" if reason else "")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class _Compensate(Exception):
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _policy_demand(readonly: bool, level: str | None, untrusted: bool) -> dict:
|
|
207
|
+
return policy.demand(readonly, level, untrusted)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _effective(adapter, wanted: dict):
|
|
211
|
+
return policy.effective(adapter, wanted)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _drop_slot_locked(job_id: str) -> None:
|
|
215
|
+
entries = admission._read()
|
|
216
|
+
if entries is None:
|
|
217
|
+
entries = admission.rebuild()
|
|
218
|
+
entries.pop(job_id, None)
|
|
219
|
+
admission._write(entries)
|
|
220
|
+
admission._unmark(job_id)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _compensate(job_id: str, rid: str | None, folder: Path) -> None:
|
|
224
|
+
"""Undo an admission that did not reach a running worker: money, slot, files."""
|
|
225
|
+
if rid:
|
|
226
|
+
ledger.close(rid, 0.0, "release")
|
|
227
|
+
admission.release(job_id)
|
|
228
|
+
sp = jobs.read_json(folder / "spec.json")
|
|
229
|
+
if sp.get("branch") and sp.get("repo"):
|
|
230
|
+
subprocess.run(
|
|
231
|
+
["git", "-C", sp["repo"], "worktree", "remove", "--force", sp["cwd"]],
|
|
232
|
+
capture_output=True,
|
|
233
|
+
check=False,
|
|
234
|
+
)
|
|
235
|
+
if folder.is_dir():
|
|
236
|
+
if (folder / "status.json").is_file():
|
|
237
|
+
jobs.update_status(job_id, state="failed", error="start failed before the worker ran", proc=None)
|
|
238
|
+
else:
|
|
239
|
+
for child in folder.iterdir():
|
|
240
|
+
child.unlink()
|
|
241
|
+
folder.rmdir()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _line(job_id: str) -> str:
|
|
245
|
+
st, sp = jobs.status(job_id), jobs.spec(job_id)
|
|
246
|
+
age = int((time.time() - sp.get("created", time.time())) / 60)
|
|
247
|
+
adapter = registry.get(sp.get("engine", ""))
|
|
248
|
+
cost = f"{st.get('cost_usd', 0):.3f}$" if adapter and adapter.capabilities.paid else "-"
|
|
249
|
+
final = st.get("state") in jobs.FINAL
|
|
250
|
+
extra = (
|
|
251
|
+
st.get("error")
|
|
252
|
+
or (st.get("detail") if final else events.last(job_id).get("msg") or st.get("last"))
|
|
253
|
+
or ""
|
|
254
|
+
)
|
|
255
|
+
live = " · liveness unknown" if st.get("liveness") == procid.UNKNOWN and not final else ""
|
|
256
|
+
verified = ""
|
|
257
|
+
if st.get("verification"):
|
|
258
|
+
verified = " · verified" if st["verification"].get("exit_code") == 0 else " · verify FAILED"
|
|
259
|
+
return f"{job_id} {st.get('state')} · {sp.get('engine')} · {age}m · {cost}{verified}{live} · {extra}"[
|
|
260
|
+
:160
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def status(job_id: str | None = None, limit: int = 8) -> str:
|
|
265
|
+
ids = [job_id] if job_id else jobs.all_jobs()[:limit]
|
|
266
|
+
if not ids:
|
|
267
|
+
return "no sub-tasks"
|
|
268
|
+
if job_id:
|
|
269
|
+
telemetry.count_call([job_id], "status")
|
|
270
|
+
return "\n".join(_line(j) for j in ids)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def known(job_id: str) -> bool:
|
|
274
|
+
try:
|
|
275
|
+
return (jobs.job_dir(job_id) / "spec.json").is_file()
|
|
276
|
+
except ValueError:
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
_known = known
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _encode(offsets: dict[str, int]) -> str:
|
|
284
|
+
return base64.urlsafe_b64encode(json.dumps(offsets, separators=(",", ":")).encode()).decode().rstrip("=")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _decode(cursor: str) -> dict[str, int]:
|
|
288
|
+
if not cursor:
|
|
289
|
+
return {}
|
|
290
|
+
try:
|
|
291
|
+
data = json.loads(base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)))
|
|
292
|
+
except (ValueError, json.JSONDecodeError):
|
|
293
|
+
return {}
|
|
294
|
+
return {k: int(v) for k, v in data.items() if isinstance(v, int)} if isinstance(data, dict) else {}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _delta(job_ids: list[str], since: str) -> tuple[str, str]:
|
|
298
|
+
"""Significant events since the cursor (at most 3 per job) and the new cursor."""
|
|
299
|
+
offsets = _decode(since)
|
|
300
|
+
lines = []
|
|
301
|
+
for job_id in job_ids:
|
|
302
|
+
new, offsets[job_id] = events.read_since(job_id, offsets.get(job_id, 0))
|
|
303
|
+
for event in new[-3:]:
|
|
304
|
+
lines.append(f" {job_id} {event.get('phase')}: {event.get('msg', '')}"[:120])
|
|
305
|
+
return "\n".join(lines), _encode(offsets)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def wait(
|
|
309
|
+
job_ids: list[str], timeout: int = 50, any_: bool = False, results: bool = True, since: str | None = None
|
|
310
|
+
) -> str:
|
|
311
|
+
"""Block until all jobs end (or the first one, with any_), or timeout.
|
|
312
|
+
|
|
313
|
+
One call replaces many status() polls, and with results=True it also
|
|
314
|
+
returns each finished job's summary. `since` (a cursor from a previous
|
|
315
|
+
wait, or "" to start) adds what changed meanwhile: the fallback for
|
|
316
|
+
clients that do not show progress notifications.
|
|
317
|
+
"""
|
|
318
|
+
if not isinstance(job_ids, list) or not job_ids:
|
|
319
|
+
return "no job ids"
|
|
320
|
+
unknown = [j for j in job_ids if not known(j)]
|
|
321
|
+
if unknown:
|
|
322
|
+
return "unknown job: " + ", ".join(unknown)
|
|
323
|
+
telemetry.count_call(job_ids, "wait")
|
|
324
|
+
deadline = time.time() + max(0, min(timeout, 600))
|
|
325
|
+
while True:
|
|
326
|
+
ended = [j for j in job_ids if jobs.status(j).get("state") in jobs.FINAL]
|
|
327
|
+
if ended and (any_ or len(ended) == len(job_ids)):
|
|
328
|
+
break
|
|
329
|
+
if time.time() >= deadline:
|
|
330
|
+
out = "still running:\n" + "\n".join(_line(j) for j in job_ids)
|
|
331
|
+
if since is not None:
|
|
332
|
+
delta, cursor = _delta(job_ids, since)
|
|
333
|
+
out += (
|
|
334
|
+
"\nsince last wait:\n" + delta if delta else "\nno new activity"
|
|
335
|
+
) + f"\ncursor={cursor}"
|
|
336
|
+
return out
|
|
337
|
+
time.sleep(1)
|
|
338
|
+
if not results:
|
|
339
|
+
return "\n".join(_line(j) for j in job_ids)
|
|
340
|
+
return "\n\n".join(result(j, _count=False) if j in ended else _line(j) for j in job_ids)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def start_many(tasks: list[dict], progress=None) -> str:
|
|
344
|
+
"""Start several jobs in one call; each dict takes start()'s arguments.
|
|
345
|
+
|
|
346
|
+
Partial success is normal: the reply lists every started id and every
|
|
347
|
+
refused item, in order. Nothing already started is rolled back.
|
|
348
|
+
"""
|
|
349
|
+
if not isinstance(tasks, list):
|
|
350
|
+
return "tasks must be a list"
|
|
351
|
+
out = []
|
|
352
|
+
for i, spec in enumerate(tasks, 1):
|
|
353
|
+
if not isinstance(spec, dict):
|
|
354
|
+
out.append(f"bad task: expected an object, got {type(spec).__name__}")
|
|
355
|
+
continue
|
|
356
|
+
if progress is not None:
|
|
357
|
+
progress.phase(f"starting {i}/{len(tasks)}")
|
|
358
|
+
try:
|
|
359
|
+
out.append(start(**spec, progress=progress))
|
|
360
|
+
except TypeError as exc:
|
|
361
|
+
out.append(f"bad task {str(spec.get('task', ''))[:40]!r}: {exc}")
|
|
362
|
+
except Exception as exc: # noqa: BLE001 - report it and keep the ids already started
|
|
363
|
+
out.append(f"bad task {str(spec.get('task', ''))[:40]!r}: {type(exc).__name__}: {exc}")
|
|
364
|
+
return "\n".join(out)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def summary_of(text: str) -> str:
|
|
368
|
+
marker = text.rfind("## Summary")
|
|
369
|
+
lines = [line for line in text[max(marker, 0) :].splitlines() if line.strip()]
|
|
370
|
+
if marker >= 0:
|
|
371
|
+
return "\n".join(lines[: config.SUMMARY_LINES + 1])
|
|
372
|
+
return "\n".join(lines[-config.SUMMARY_LINES :]) # no marker: the end is the conclusion
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
_summary = summary_of
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def result(job_id: str, full: bool = False, evidence: bool = False, _count: bool = True) -> str:
|
|
379
|
+
folder = jobs.job_dir(job_id)
|
|
380
|
+
st, sp = jobs.status(job_id), jobs.spec(job_id)
|
|
381
|
+
if _count:
|
|
382
|
+
telemetry.count_call([job_id], "result")
|
|
383
|
+
if st.get("state") not in jobs.FINAL:
|
|
384
|
+
return _line(job_id)
|
|
385
|
+
if evidence:
|
|
386
|
+
from . import evidence as ev
|
|
387
|
+
|
|
388
|
+
return json.dumps(ev.envelope(job_id), ensure_ascii=False, indent=1)
|
|
389
|
+
text = (folder / "result.md").read_text() if (folder / "result.md").is_file() else ""
|
|
390
|
+
head = _line(job_id)
|
|
391
|
+
if sp.get("branch") and sp.get("repo"):
|
|
392
|
+
try:
|
|
393
|
+
commits = _git(sp["repo"], "rev-list", "--count", f"{sp['base']}..{sp['branch']}")
|
|
394
|
+
head += f"\nbranch {sp['branch']} · {commits} commits on top of {sp['base'][:9]}"
|
|
395
|
+
except subprocess.CalledProcessError:
|
|
396
|
+
pass
|
|
397
|
+
verification = st.get("verification")
|
|
398
|
+
if verification:
|
|
399
|
+
code = verification.get("exit_code")
|
|
400
|
+
head += (
|
|
401
|
+
f"\nverify `{verification.get('command')}` → exit {code} on {str(verification.get('head'))[:9]}"
|
|
402
|
+
)
|
|
403
|
+
body = text if full else summary_of(text)
|
|
404
|
+
return f"{head}\n{body}"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def reply(job_id: str, message: str) -> str:
|
|
408
|
+
if not isinstance(message, str) or not message.strip():
|
|
409
|
+
return "empty message"
|
|
410
|
+
if not known(job_id):
|
|
411
|
+
return f"unknown job: {job_id}"
|
|
412
|
+
telemetry.count_call([job_id], "reply")
|
|
413
|
+
jobs.status(job_id) # settle a dead worker first: it takes the lock itself
|
|
414
|
+
sp = jobs.spec(job_id)
|
|
415
|
+
adapter = registry.get(sp.get("engine", ""))
|
|
416
|
+
with jobs.job_lock(job_id): # two replies at once must not both start a worker
|
|
417
|
+
st = jobs.read_json(jobs.job_dir(job_id) / "status.json")
|
|
418
|
+
if st.get("state") not in jobs.FINAL:
|
|
419
|
+
return f"{job_id} is still running: wait or stop it"
|
|
420
|
+
if not st.get("session_id"):
|
|
421
|
+
return f"{job_id} has no session to resume"
|
|
422
|
+
n = int(st.get("attempt") or 1) + 1
|
|
423
|
+
# a fresh rid per try: a failed reply never blocks the next one
|
|
424
|
+
rid = f"{job_id}#{n}-{secrets.token_hex(3)}" if adapter and adapter.capabilities.paid else None
|
|
425
|
+
with admission.locked():
|
|
426
|
+
refused = admission.admit_locked(job_id, attempt=n)
|
|
427
|
+
if refused:
|
|
428
|
+
return refused
|
|
429
|
+
try:
|
|
430
|
+
booked = (
|
|
431
|
+
ledger.reserve(rid, job_id, float(sp.get("max_usd") or config.DEFAULT_MAX_USD))
|
|
432
|
+
if rid
|
|
433
|
+
else True
|
|
434
|
+
)
|
|
435
|
+
except BaseException:
|
|
436
|
+
_drop_slot_locked(job_id)
|
|
437
|
+
raise
|
|
438
|
+
if booked is None:
|
|
439
|
+
_drop_slot_locked(job_id)
|
|
440
|
+
return f"monthly cap {config.MONTHLY_CAP_USD:.0f} $ reached"
|
|
441
|
+
reply_file = jobs.job_dir(job_id) / f"reply-{n}.md"
|
|
442
|
+
try:
|
|
443
|
+
reply_file.write_text(message)
|
|
444
|
+
attempts = list(st.get("attempts") or []) + [
|
|
445
|
+
{"n": n, "kind": "reply", "reservation": rid, "created": time.time()}
|
|
446
|
+
]
|
|
447
|
+
jobs.update_status(
|
|
448
|
+
job_id, state="queued", last="reply", attempt=n, attempts=attempts, spend_possible=False,
|
|
449
|
+
stop_requested=False, locked=True,
|
|
450
|
+
) # fmt: skip
|
|
451
|
+
except BaseException:
|
|
452
|
+
_undo_reply(job_id, rid, st, locked=True)
|
|
453
|
+
raise
|
|
454
|
+
events.emit(job_id, "reply", message[:80], state="queued", attempt=n)
|
|
455
|
+
try:
|
|
456
|
+
ident = _spawn(job_id, "--reply-file", str(reply_file))
|
|
457
|
+
except OSError as exc:
|
|
458
|
+
_undo_reply(job_id, rid, st, locked=False)
|
|
459
|
+
return f"{job_id} failed to resume: {exc}"
|
|
460
|
+
jobs.update_status(job_id, proc=ident, liveness=procid.ALIVE)
|
|
461
|
+
admission.running(job_id, ident)
|
|
462
|
+
return f"{job_id} resumed"
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _undo_reply(job_id: str, rid: str | None, previous: dict, locked: bool) -> None:
|
|
466
|
+
"""A reply that did not reach a worker: free its money and slot, restore the finished state."""
|
|
467
|
+
if rid:
|
|
468
|
+
ledger.close(rid, 0.0, "release")
|
|
469
|
+
admission.release(job_id)
|
|
470
|
+
keep = {k: previous.get(k) for k in ("state", "attempt", "attempts", "last", "error", "spend_possible")}
|
|
471
|
+
jobs.update_status(job_id, locked=locked, **keep)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def stop(job_id: str) -> str:
|
|
475
|
+
if not known(job_id):
|
|
476
|
+
return f"unknown job: {job_id}"
|
|
477
|
+
telemetry.count_call([job_id], "stop")
|
|
478
|
+
if jobs.stop(job_id):
|
|
479
|
+
return f"{job_id} stopped"
|
|
480
|
+
state = jobs.status(job_id).get("state")
|
|
481
|
+
return (
|
|
482
|
+
f"{job_id} was not running"
|
|
483
|
+
if state in jobs.FINAL
|
|
484
|
+
else f"{job_id}: could not prove it stopped ({state})"
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def cleanup(job_id: str) -> str:
|
|
489
|
+
if not known(job_id):
|
|
490
|
+
return f"unknown job: {job_id}"
|
|
491
|
+
sp = jobs.spec(job_id)
|
|
492
|
+
if jobs.status(job_id).get("state") not in jobs.FINAL:
|
|
493
|
+
return f"{job_id} is still running"
|
|
494
|
+
if not (sp.get("branch") and sp.get("repo")):
|
|
495
|
+
return f"{job_id}: nothing to clean"
|
|
496
|
+
if not Path(sp["cwd"]).exists():
|
|
497
|
+
return f"{job_id}: worktree already gone, branch {sp['branch']} kept"
|
|
498
|
+
dirty = subprocess.run(
|
|
499
|
+
["git", "-C", sp["cwd"], "status", "--porcelain", "--ignored"],
|
|
500
|
+
capture_output=True,
|
|
501
|
+
text=True,
|
|
502
|
+
check=False,
|
|
503
|
+
)
|
|
504
|
+
if dirty.returncode != 0 or dirty.stdout.strip():
|
|
505
|
+
return f"{job_id}: worktree has uncommitted or ignored files, not removed ({sp['cwd']})"
|
|
506
|
+
done = subprocess.run(
|
|
507
|
+
["git", "-C", sp["repo"], "worktree", "remove", sp["cwd"]],
|
|
508
|
+
capture_output=True,
|
|
509
|
+
text=True,
|
|
510
|
+
check=False,
|
|
511
|
+
)
|
|
512
|
+
if done.returncode != 0:
|
|
513
|
+
return f"{job_id}: worktree not removed: {done.stderr.strip()[:200]}"
|
|
514
|
+
return f"{job_id}: worktree removed, branch {sp['branch']} kept"
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def spend() -> str:
|
|
518
|
+
spent = ledger.committed()
|
|
519
|
+
return f"month: {spent:.2f}$ committed of {config.MONTHLY_CAP_USD:.0f}$ · {config.MONTHLY_CAP_USD - spent:.2f}$ left"
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def engines() -> str:
|
|
523
|
+
from . import capacity
|
|
524
|
+
|
|
525
|
+
return capacity.report()
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
STEPS = config.HOME / "steps.jsonl"
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _repo_of(path: str) -> str | None:
|
|
532
|
+
"""The git work tree containing `path` (a file or a directory), if any."""
|
|
533
|
+
p = Path(path).expanduser()
|
|
534
|
+
if not p.is_absolute():
|
|
535
|
+
return None
|
|
536
|
+
while not p.exists() and p != p.parent:
|
|
537
|
+
p = p.parent
|
|
538
|
+
if p.is_file():
|
|
539
|
+
p = p.parent
|
|
540
|
+
try:
|
|
541
|
+
done = subprocess.run(
|
|
542
|
+
["git", "-C", str(p), "rev-parse", "--show-toplevel"],
|
|
543
|
+
capture_output=True,
|
|
544
|
+
text=True,
|
|
545
|
+
timeout=5,
|
|
546
|
+
check=False,
|
|
547
|
+
)
|
|
548
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
549
|
+
return None
|
|
550
|
+
return done.stdout.strip() or None if done.returncode == 0 else None
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def step(
|
|
554
|
+
title: str,
|
|
555
|
+
path: str,
|
|
556
|
+
how: str,
|
|
557
|
+
decisions: list[str] | None = None,
|
|
558
|
+
repo: str | None = None,
|
|
559
|
+
lang: str | None = None,
|
|
560
|
+
) -> str:
|
|
561
|
+
"""Record a significant step: a separate cheap model explains it.
|
|
562
|
+
|
|
563
|
+
The agent declares the step (intention); the narrator reads it with the
|
|
564
|
+
repository's real state and the recent observed job activity, and keeps
|
|
565
|
+
the two apart. Without a narrator the declared step is returned as is.
|
|
566
|
+
"""
|
|
567
|
+
from . import narrate
|
|
568
|
+
|
|
569
|
+
if not title.strip() or not path.strip() or not how.strip():
|
|
570
|
+
return "title, path and how are required"
|
|
571
|
+
where = repo or _repo_of(path.strip()) or os.getcwd()
|
|
572
|
+
entry = {
|
|
573
|
+
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
574
|
+
"repo": where,
|
|
575
|
+
"title": title.strip(),
|
|
576
|
+
"path": path.strip(),
|
|
577
|
+
"how": how.strip(),
|
|
578
|
+
"decisions": [d.strip() for d in decisions or [] if d.strip()],
|
|
579
|
+
}
|
|
580
|
+
lines = [f"STEP: {entry['title']}", f"- path: {entry['path']}", f"- how: {entry['how']}"]
|
|
581
|
+
lines += [f"- decision: {d}" for d in entry["decisions"]]
|
|
582
|
+
declared = "\n".join(lines)
|
|
583
|
+
t0 = time.monotonic()
|
|
584
|
+
explained = narrate.review(declared, where, lang or os.environ.get("SUBTASK_STEP_LANG", "Italian"))
|
|
585
|
+
entry["narrator_ms"] = round((time.monotonic() - t0) * 1000)
|
|
586
|
+
if explained.startswith("(unavailable"):
|
|
587
|
+
out = f"{declared}\n{explained}"
|
|
588
|
+
entry["narrator"] = "fallback"
|
|
589
|
+
else:
|
|
590
|
+
out = f"STEP: {entry['title']}\n{explained}"
|
|
591
|
+
entry["narrator"] = "model"
|
|
592
|
+
entry["announcement"] = out
|
|
593
|
+
config.HOME.mkdir(parents=True, exist_ok=True)
|
|
594
|
+
with STEPS.open("a", encoding="utf-8") as fh:
|
|
595
|
+
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
596
|
+
return out
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def steps(limit: int = 10) -> str:
|
|
600
|
+
if not STEPS.is_file():
|
|
601
|
+
return "no steps"
|
|
602
|
+
rows = [json.loads(x) for x in STEPS.read_text(encoding="utf-8").splitlines()[-limit:] if x.strip()]
|
|
603
|
+
return "\n".join(f"{r['ts']} {Path(r['repo']).name}: {r['title']} ({r['path']})" for r in rows)
|