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/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)