Everything core from cicero-pas's .task-manager, with instance data reduced to skeleton: empty stage directories, the task template, empty local/ scaffolding, and a README covering install (clone into .task-manager/, vendored on purpose) and update (update.sh replaces core wholesale; local/ and tasks/ survive). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
108 lines
3.9 KiB
Python
108 lines
3.9 KiB
Python
"""Local commands: project-owned executables run against a task's worktree.
|
|
|
|
Core knows nothing about what any command does — the project does
|
|
(local/commands/<name>, executables the project owns and evolves). The
|
|
contract:
|
|
|
|
- env in: CMD_WORKTREE, CMD_BRANCH, CMD_TASK, CMD_REPO (+ BOARD_*)
|
|
- stdout/stderr → a log under local/state/commands/
|
|
- exit 0 = done; anything else = failed — either way the ticker narrates
|
|
the ending with the log's last line
|
|
|
|
One run per (task, command) at a time.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import config
|
|
import state
|
|
|
|
RUNNING: dict[str, dict] = {} # "task:name" -> record
|
|
|
|
|
|
def public() -> list[dict]:
|
|
with state.LOCK:
|
|
return [{"task": r["task"], "name": r["name"], "started": r["started"]}
|
|
for r in RUNNING.values()]
|
|
|
|
|
|
def run(name: str, filename: str) -> dict:
|
|
if not any(c["name"] == name for c in config.commands()):
|
|
raise ValueError(f"no such command: {name}")
|
|
if Path(filename).name != filename or not filename.endswith(".md"):
|
|
raise ValueError("bad filename")
|
|
key = f"{filename}:{name}"
|
|
with state.LOCK:
|
|
if key in RUNNING:
|
|
raise ValueError(f"{name} is already running on {filename}")
|
|
|
|
stem = filename[:-3]
|
|
branch = f"task/{stem}"
|
|
worktree = config.WORKTREES / stem
|
|
if not worktree.exists():
|
|
if subprocess.run(["git", "-C", str(config.REPO), "rev-parse", "--verify",
|
|
"--quiet", branch], capture_output=True).returncode != 0:
|
|
raise ValueError(f"no worktree and no branch {branch} — nothing to run against")
|
|
config.WORKTREES.mkdir(exist_ok=True)
|
|
result = subprocess.run(
|
|
["git", "-C", str(config.REPO), "worktree", "add", str(worktree), branch],
|
|
capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
raise ValueError(f"could not recreate the worktree: {result.stderr.strip()[:200]}")
|
|
|
|
log_dir = config.STATE / "commands"
|
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
log_path = log_dir / f"{stem}-{name}-{time.strftime('%H%M%S')}.log"
|
|
env = config.child_env()
|
|
env.update({
|
|
"CMD_WORKTREE": str(worktree),
|
|
"CMD_BRANCH": branch,
|
|
"CMD_TASK": filename,
|
|
"CMD_REPO": str(config.REPO),
|
|
"BOARD_PORT": str(state.serve_port),
|
|
})
|
|
log_file = log_path.open("wb")
|
|
try:
|
|
proc = subprocess.Popen(
|
|
[str(config.LOCAL / "commands" / name)], cwd=str(worktree), env=env,
|
|
stdout=log_file, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
|
|
except OSError as exc:
|
|
log_file.close()
|
|
raise ValueError(f"could not launch {name}: {exc}")
|
|
|
|
record = {"task": filename, "name": name, "started": time.time(),
|
|
"proc": proc, "log": str(log_path)}
|
|
with state.LOCK:
|
|
RUNNING[key] = record
|
|
state.record_board_event({
|
|
"kind": "agent", "actor": "board", "file": filename,
|
|
"summary": f"running {name} on {filename}'s worktree"})
|
|
state.broadcast({"type": "board"})
|
|
threading.Thread(target=_reap, args=(key, record, log_file), daemon=True).start()
|
|
return {"name": name, "task": filename}
|
|
|
|
|
|
def _reap(key: str, record: dict, log_file) -> None:
|
|
rc = record["proc"].wait()
|
|
log_file.close()
|
|
with state.LOCK:
|
|
RUNNING.pop(key, None)
|
|
try:
|
|
lines = [l.strip() for l in Path(record["log"]).read_text(
|
|
encoding="utf-8", errors="replace").splitlines() if l.strip()]
|
|
last = lines[-1][:150] if lines else ""
|
|
except OSError:
|
|
last = ""
|
|
verdict = "done" if rc == 0 else f"failed (rc={rc})"
|
|
state.record_board_event({
|
|
"kind": "agent", "actor": "board", "file": record["task"],
|
|
"summary": f"{record['name']} on {record['task']} {verdict}"
|
|
+ (f" — {last}" if last else "")})
|
|
state.broadcast({"type": "board"})
|