#!/usr/bin/env python3 """Idle-subscription dispatcher for the O'Reilly Consulting writing pipeline. Spec: specs/idle-draft.spec.md Design contract: ~/dev/claude/writing/oreillyconsulting/IDLE-DRAFT-PLAN.md """ from __future__ import annotations import atexit import concurrent.futures import fcntl import hashlib import json import os import re import signal import subprocess import sys import tempfile import threading import time from datetime import datetime, timezone from pathlib import Path from string import Template # === Constants replicated from scripts/agent-subscriptions (keep in sync) === # idle-draft must resolve the same credential paths agent-subscriptions probes # with, to run the credential-parity check (IDLE-DRAFT-PLAN.md, finding C2). ANTHROPIC_TOKEN_PATH = Path("~/dev/claude/secrets/anthropic/api_key").expanduser() MINIMAX_SOPS_PATH = Path( "~/dev/claude/projects/agent-runtime-secrets/providers/minimax/v1/provider.sops.env" ).expanduser() MINIMAX_SOPS_KEY = Path("~/dev/claude/secrets/sops/provider-age-key.txt").expanduser() MINIMAX_DOTENV_KEY = "ANTHROPIC_AUTH_TOKEN" # === Work-item / pipeline constants === WORK_TYPES = ("research", "draft", "review", "topic_ideas") STAGE_RANK = {"review": 3, "draft": 2, "research": 1, "topic_ideas": 0} SUFFIX = {"research": "research", "draft": "draft", "review": "review"} PROMPT_TEMPLATE_FILES = { "research": "research.md", "draft": "draft.md", "review": "review-suggest.md", "topic_ideas": "topic-ideas.md", } # Expected first-line heading prefix per work type, used by output validation. EXPECTED_HEADING_PREFIX = { "research": "# ", "draft": "# ", "review": "# ", "topic_ideas": "## ", } ALLOWED_ITEM_KEYS = { "human_edit_done", "research_sampled", "approved", "blocked", "attempts", } ALLOWED_ATTEMPT_WORK_TYPES = {"research", "draft", "review"} DEFAULT_MAX_TURNS = 25 DEFAULT_TASK_TIMEOUT_SECONDS = 1800 DEFAULT_MAX_ATTEMPTS = 2 DEFAULT_PARALLEL = 2 REQUIRED_CONFIG_KEYS = ( "parallel", "providers", "work_types", "dossiers", "review_score_threshold", "max_unreviewed_research_per_dossier", "max_open_topic_proposals", "evidence_dirs", ) RETRYABLE_STDERR_MARKERS = ( "429", "rate limit", "rate_limit", "500", "502", "503", "504", "overloaded", "temporarily unavailable", "connection reset", "econnreset", "timed out", "timeout", ) ITEM_FILENAME_RE = re.compile(r"^(\d+)-(.+)\.overview\.md$") ABS_PATH_RE = re.compile(r"/home/[^\s`)\]\"'>,;]+") LINE_RANGE_SUFFIX_RE = re.compile(r"^(.*/[^/:]+):\d+(?:-\d+)?$") SCRIPT_PATH = Path(__file__).resolve() REPO_SELF = SCRIPT_PATH.parent.parent # small-scripts repo (holds data/idle-draft) PROMPTS_DIR = REPO_SELF / "data" / "idle-draft" / "prompts" # === Errors === class ConfigError(Exception): pass class StateValidationError(Exception): pass # === Small utilities === def expand(p: str | Path) -> Path: return Path(str(p)).expanduser() def iso_now() -> str: return datetime.now(timezone.utc).isoformat() def sha256_bytes(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def sha256_text(text: str) -> str: return sha256_bytes(text.encode("utf-8")) def atomic_write_json(path: Path, data: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.") try: with os.fdopen(fd, "w") as f: json.dump(data, f, indent=2, sort_keys=True) f.write("\n") os.replace(tmp_name, path) finally: if os.path.exists(tmp_name): os.unlink(tmp_name) def atomic_write_text(path: Path, text: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.") try: with os.fdopen(fd, "w") as f: f.write(text) os.replace(tmp_name, path) finally: if os.path.exists(tmp_name): os.unlink(tmp_name) # === Logging === def rotate_log_if_needed(repo: Path) -> None: log_path = repo / "idle-draft.log" try: if log_path.exists() and log_path.stat().st_size > 5 * 1024 * 1024: rotated = repo / "idle-draft.log.1" os.replace(log_path, rotated) except OSError: pass def log_event(repo: Path, message: str) -> None: line = f"{iso_now()} {message}" print(line, file=sys.stderr) try: log_path = repo / "idle-draft.log" with open(log_path, "a") as f: f.write(line + "\n") except OSError: pass # === Locking === def acquire_lock(repo: Path): """Returns an open file object holding the lock, or None if already locked.""" lock_path = repo / ".idle-draft.lock" f = open(lock_path, "w") try: fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: f.close() return None return f # === Config === def load_config(path: Path) -> dict: if not path.exists(): raise ConfigError(f"config file not found: {path}") try: data = json.loads(path.read_text()) except json.JSONDecodeError as e: raise ConfigError(f"config file is not valid JSON: {e}") from e if not isinstance(data, dict): raise ConfigError("config file must be a JSON object") missing = [k for k in REQUIRED_CONFIG_KEYS if k not in data] if missing: raise ConfigError(f"config missing required key(s): {', '.join(missing)}") if not isinstance(data["providers"], dict) or not data["providers"]: raise ConfigError("config 'providers' must be a non-empty object") if not isinstance(data["work_types"], dict) or not data["work_types"]: raise ConfigError("config 'work_types' must be a non-empty object") if not isinstance(data["dossiers"], list) or not data["dossiers"]: raise ConfigError("config 'dossiers' must be a non-empty array") if not isinstance(data["evidence_dirs"], list): raise ConfigError("config 'evidence_dirs' must be an array") for pname, pcfg in data["providers"].items(): for key in ("profile", "threshold_pct", "five_hour_ceiling", "min_idle"): if key not in pcfg: raise ConfigError(f"provider '{pname}' missing key '{key}'") for wtype, wcfg in data["work_types"].items(): if "providers" not in wcfg or not isinstance(wcfg["providers"], list): raise ConfigError(f"work_type '{wtype}' missing 'providers' list") if "allowed_tools" in wcfg: at = wcfg["allowed_tools"] if not isinstance(at, list) or not all(isinstance(x, str) for x in at): raise ConfigError(f"work_type '{wtype}' 'allowed_tools' must be a list of strings") return data # === State === def default_state() -> dict: return {"items": {}} def load_state(path: Path, repo: Path) -> dict: if not path.exists(): return default_state() try: data = json.loads(path.read_text()) except json.JSONDecodeError as e: raise StateValidationError(f"state file is not valid JSON: {e}") from e validate_state(data, repo) return data def validate_state(data: dict, repo: Path) -> None: if not isinstance(data, dict): raise StateValidationError("state file must be a JSON object") extra_top = set(data.keys()) - {"items"} if extra_top: raise StateValidationError(f"state file has unknown top-level key(s): {sorted(extra_top)}") items = data.get("items", {}) if not isinstance(items, dict): raise StateValidationError("state 'items' must be an object") for key, entry in items.items(): if "/" not in key: raise StateValidationError(f"state item key '{key}' is not of the form dossier/NN-slug") dossier, slug = key.split("/", 1) overview = repo / dossier / f"{slug}.overview.md" if not overview.exists(): raise StateValidationError( f"state item key '{key}' does not resolve to an existing overview file: {overview}" ) if not isinstance(entry, dict): raise StateValidationError(f"state item '{key}' value must be an object") extra_keys = set(entry.keys()) - ALLOWED_ITEM_KEYS if extra_keys: raise StateValidationError(f"state item '{key}' has unknown key(s): {sorted(extra_keys)}") for bkey in ("human_edit_done", "research_sampled", "approved"): if bkey in entry and not isinstance(entry[bkey], bool): raise StateValidationError(f"state item '{key}'.{bkey} must be a bool") if "blocked" in entry and entry["blocked"] is not None and not isinstance(entry["blocked"], str): raise StateValidationError(f"state item '{key}'.blocked must be a string or null") if "attempts" in entry: attempts = entry["attempts"] if not isinstance(attempts, dict): raise StateValidationError(f"state item '{key}'.attempts must be an object") for wtype, count in attempts.items(): if wtype not in ALLOWED_ATTEMPT_WORK_TYPES: raise StateValidationError( f"state item '{key}'.attempts has unknown work type '{wtype}'" ) if not isinstance(count, int) or isinstance(count, bool) or count < 0: raise StateValidationError( f"state item '{key}'.attempts.{wtype} must be a non-negative int" ) def get_item_state(state: dict, key: str) -> dict: entry = state.get("items", {}).get(key, {}) return { "human_edit_done": entry.get("human_edit_done", False), "research_sampled": entry.get("research_sampled", False), "approved": entry.get("approved", False), "blocked": entry.get("blocked"), "attempts": dict(entry.get("attempts", {})), } def save_state_atomic(path: Path, state: dict) -> None: atomic_write_json(path, state) # === provider.env parsing (replicates scripts/claude-profile ~L320-440) === def parse_provider_env(path: Path) -> dict: """Parse a provider.env file the way claude-profile does. Returns {"model_id": str|None, "base_url": str|None, "api_key_file": str|None, "extra": {KEY: value, ...}}. Lines are KEY=value; blank lines and lines starting with '#' are skipped. Unrecognised keys fall into 'extra' verbatim, mirroring claude-profile's PROVIDER_EXTRA_ENV accumulation. """ result: dict = {"model_id": None, "base_url": None, "api_key_file": None, "extra": {}} if not path.exists(): return result for raw_line in path.read_text().splitlines(): line = raw_line.strip() if not line or line.startswith("#"): continue if "=" not in line: continue key, _, value = line.partition("=") key = key.strip() value = value.strip() if key == "MODEL_ID": result["model_id"] = value elif key == "ANTHROPIC_BASE_URL": result["base_url"] = value elif key == "ANTHROPIC_API_KEY_FILE": result["api_key_file"] = value else: result["extra"][key] = value return result def build_child_env(profile_dir: Path, base_env: dict | None = None) -> dict: """Build the environment for a headless `claude -p` child, replicating claude-profile's export logic (L423-435) without invoking claude-profile.""" env = dict(base_env if base_env is not None else os.environ) env["CLAUDE_CONFIG_DIR"] = str(profile_dir) provider_env_path = profile_dir / "provider.env" parsed = parse_provider_env(provider_env_path) if parsed["base_url"]: env["ANTHROPIC_BASE_URL"] = parsed["base_url"] if parsed["api_key_file"]: key_file = expand(parsed["api_key_file"]) if key_file.exists(): env["ANTHROPIC_API_KEY"] = key_file.read_text().strip() for k, v in parsed["extra"].items(): env[k] = v return env def resolve_model_id(profile_dir: Path) -> str | None: parsed = parse_provider_env(profile_dir / "provider.env") return parsed["model_id"] # === Credential parity (IDLE-DRAFT-PLAN.md finding C2) === def _credential_parity_from_values(profile_value: str, probe_value: str) -> tuple[bool, str]: """Pure comparison: do two credential strings hash the same? Testable without touching any file, secret, or subprocess.""" if not profile_value or not probe_value: return False, "one or both credential values are empty" a, b = sha256_text(profile_value), sha256_text(probe_value) if a == b: return True, "match" return False, f"hash mismatch (profile={a[:12]}... probe={b[:12]}...)" def resolve_profile_credential_value(provider_name: str, profile_dir: Path) -> str: """Read the credential the given profile would export. Raises on failure.""" parsed = parse_provider_env(profile_dir / "provider.env") if parsed["api_key_file"]: key_file = expand(parsed["api_key_file"]) return key_file.read_text().strip() if provider_name == "anthropic": return ANTHROPIC_TOKEN_PATH.read_text().strip() raise ValueError( f"profile '{profile_dir}' has no provider.env ANTHROPIC_API_KEY_FILE and " f"provider '{provider_name}' has no fallback credential source" ) def read_anthropic_probe_credential() -> str: return ANTHROPIC_TOKEN_PATH.read_text().strip() def _parse_dotenv_key(content: str, key: str) -> str: for line in content.splitlines(): line = line.strip() if not line or line.startswith("#"): continue if "=" in line: k, _, v = line.partition("=") elif ": " in line: k, _, v = line.partition(": ") else: continue if k.strip() == key: v = v.strip() if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"): v = v[1:-1] return v raise KeyError(f"Key '{key}' not found in decrypted content") def read_minimax_probe_credential() -> str: env = {**os.environ, "SOPS_AGE_KEY_FILE": str(MINIMAX_SOPS_KEY)} result = subprocess.run( ["sops", "--decrypt", "--output-type", "dotenv", str(MINIMAX_SOPS_PATH)], capture_output=True, text=True, check=True, env=env, ) return _parse_dotenv_key(result.stdout, MINIMAX_DOTENV_KEY) PROBE_CREDENTIAL_READERS = { "anthropic": read_anthropic_probe_credential, "minimax": read_minimax_probe_credential, } def credential_parity(provider_name: str, profile_dir: Path, probe_credential_fn=None) -> tuple[bool, str]: """Full parity check for one provider: resolves both sides and compares. `probe_credential_fn` is injectable (tests pass a fixture callable instead of hitting sops/secrets); defaults to the real reader for `provider_name`. """ reader = probe_credential_fn or PROBE_CREDENTIAL_READERS.get(provider_name) if reader is None: return False, f"no probe credential reader for provider '{provider_name}'" try: profile_value = resolve_profile_credential_value(provider_name, profile_dir) except Exception as e: return False, f"profile credential unreadable: {e}" try: probe_value = reader() except Exception as e: return False, f"probe credential unreadable: {e}" return _credential_parity_from_values(profile_value, probe_value) # === Probe === def run_probe(probe_json_path: Path | None) -> dict: if probe_json_path is not None: return json.loads(probe_json_path.read_text()) result = subprocess.run( ["agent-subscriptions", "--output", "json"], capture_output=True, text=True, timeout=30, ) if result.returncode != 0: raise RuntimeError(f"agent-subscriptions exited {result.returncode}: {result.stderr.strip()}") return json.loads(result.stdout) def provider_probe_lookup(report: dict, provider_key: str) -> dict | None: name_map = {"anthropic": "anthropic", "minimax": "minimax"} wanted = name_map.get(provider_key, provider_key).lower() for entry in report.get("providers", []): if str(entry.get("provider", "")).lower() == wanted: return entry return None # === Gates === def compute_gate(provider_cfg: dict, provider_probe: dict | None) -> dict: result = { "eligible": False, "idle_points": None, "five_hour_pct": None, "seven_day_pct": None, "elapsed_pct": None, "reason": "", } if provider_probe is None: result["reason"] = "no probe data for this provider" return result if not provider_probe.get("available"): result["reason"] = f"provider unavailable: {provider_probe.get('error', 'unknown')}" return result windows = provider_probe.get("windows", {}) sd = windows.get("seven_day", {}) or {} fh = windows.get("five_hour", {}) or {} elapsed = sd.get("elapsed_pct") usage_sd = sd.get("utilization_pct") usage_fh = fh.get("utilization_pct") result["elapsed_pct"] = elapsed result["seven_day_pct"] = usage_sd result["five_hour_pct"] = usage_fh if elapsed is None: result["reason"] = "seven_day.elapsed_pct is null (cannot pace)" return result if usage_sd is None: result["reason"] = "seven_day.utilization_pct missing" return result if usage_fh is None: result["reason"] = "five_hour.utilization_pct missing" return result threshold_pct = provider_cfg["threshold_pct"] min_idle = provider_cfg["min_idle"] five_hour_ceiling = provider_cfg["five_hour_ceiling"] idle_points = threshold_pct * elapsed - usage_sd result["idle_points"] = idle_points if idle_points <= min_idle: result["reason"] = f"idle_points {idle_points:.2f} <= min_idle {min_idle}" return result if not (usage_fh < five_hour_ceiling): result["reason"] = f"five_hour.utilization_pct {usage_fh} >= ceiling {five_hour_ceiling}" return result result["eligible"] = True result["reason"] = "ok" return result def compute_all_gates(config: dict, probe_report: dict) -> dict: gates = {} for pname, pcfg in config["providers"].items(): probe = provider_probe_lookup(probe_report, pname) gates[pname] = compute_gate(pcfg, probe) return gates # === Work-item discovery / stage derivation === def list_items(repo: Path, dossier: str) -> list[tuple[str, str]]: """Returns [(nn_str, slug), ...] sorted by numeric prefix ascending.""" dossier_dir = repo / dossier if not dossier_dir.is_dir(): return [] found = [] for f in dossier_dir.glob("*.overview.md"): m = ITEM_FILENAME_RE.match(f.name) if m: found.append((m.group(1), m.group(2))) found.sort(key=lambda t: int(t[0])) return found def item_files(repo: Path, dossier: str, nn_slug: str) -> dict: """nn_slug is the full on-disk identifier, e.g. '03-shadow-agents...' โ€” NOT the bare slug returned as the second element of list_items()'s tuples.""" base = repo / dossier return { "overview": (base / f"{nn_slug}.overview.md").exists(), "agent": (base / f"{nn_slug}.agent.md").exists(), "research": (base / f"{nn_slug}.research.md").exists(), "draft": (base / f"{nn_slug}.draft.md").exists(), "review": (base / f"{nn_slug}.review.md").exists(), } def next_work_type_for_item(files: dict, state_entry: dict) -> tuple[str | None, str]: if state_entry.get("blocked"): return None, f"blocked: {state_entry['blocked']}" if state_entry.get("approved"): return None, "approved (terminal)" if not files["agent"]: return None, "missing commissioning brief (.agent.md)" if not files["research"]: return "research", "research not yet produced" if not files["draft"]: return "draft", "research complete, draft not yet produced" if files["review"]: return None, "review complete, awaiting human revise/approve" if not state_entry.get("human_edit_done"): return None, "waiting on human edit of draft" return "review", "draft human-edited, ready for review" def count_unreviewed_research(repo: Path, dossier: str, state: dict) -> int: count = 0 for nn, slug in list_items(repo, dossier): files = item_files(repo, dossier, f"{nn}-{slug}") if not files["research"]: continue entry = get_item_state(state, f"{dossier}/{nn}-{slug}") if not entry["research_sampled"]: count += 1 return count def count_open_topic_proposals(repo: Path, dossier: str) -> int: proposals_path = repo / dossier / "TOPIC-PROPOSALS.md" if not proposals_path.exists(): return 0 text = proposals_path.read_text() return len(re.findall(r"^##\s+", text, flags=re.MULTILINE)) def build_ready_queue(repo: Path, config: dict, state: dict, in_flight: set) -> list[dict]: candidates: list[dict] = [] dossiers = config["dossiers"] for idx, dossier in enumerate(dossiers): unreviewed = count_unreviewed_research(repo, dossier, state) max_unreviewed = config["max_unreviewed_research_per_dossier"] for nn, slug in list_items(repo, dossier): key = f"{dossier}/{nn}-{slug}" if key in in_flight: continue files = item_files(repo, dossier, f"{nn}-{slug}") entry = get_item_state(state, key) wtype, reason = next_work_type_for_item(files, entry) if wtype == "research" and unreviewed >= max_unreviewed: continue if wtype is None: continue candidates.append({ "kind": "item", "dossier": dossier, "slug": f"{nn}-{slug}", "item_key": key, "work_type": wtype, "rank": STAGE_RANK[wtype], "nn": int(nn), "dossier_idx": idx, "reason": reason, }) if not candidates: for idx, dossier in enumerate(dossiers): if f"{dossier}/topic_ideas" in in_flight: continue open_count = count_open_topic_proposals(repo, dossier) if open_count < config["max_open_topic_proposals"]: candidates.append({ "kind": "dossier", "dossier": dossier, "slug": None, "item_key": f"{dossier}/topic_ideas", "work_type": "topic_ideas", "rank": STAGE_RANK["topic_ideas"], "nn": 0, "dossier_idx": idx, "reason": f"no other work eligible; {open_count} open proposals", }) candidates.sort(key=lambda c: (-c["rank"], c["nn"], c["dossier_idx"])) return candidates def select_provider(work_type: str, config: dict, gates: dict, credential_ok: dict) -> tuple[str | None, str]: providers = config["work_types"].get(work_type, {}).get("providers", []) for pname in providers: gate = gates.get(pname, {}) if gate.get("eligible") and credential_ok.get(pname, (False, ""))[0]: return pname, "eligible" return None, "no eligible provider" # === Prompt rendering === def load_prompt_template(work_type: str) -> str: fname = PROMPT_TEMPLATE_FILES[work_type] path = PROMPTS_DIR / fname return path.read_text() def render_prompt(work_type: str, mapping: dict) -> str: template = Template(load_prompt_template(work_type)) return template.substitute(mapping) def build_prompt_mapping(repo: Path, dossier: str, slug: str | None, work_type: str, config: dict, output_path: Path) -> dict: base = repo / dossier evidence_dirs_text = "\n".join(f"- {d}" for d in config["evidence_dirs"]) mapping = { "dossier": dossier, "slug": slug or "", "overview_path": str(base / f"{slug}.overview.md") if slug else "", "agent_path": str(base / f"{slug}.agent.md") if slug else "", "research_path": str(base / f"{slug}.research.md") if slug else "", "draft_path": str(base / f"{slug}.draft.md") if slug else "", "output_path": str(output_path), "style_dir": str(repo / "style"), "source_register_path": str(base / "SOURCE-REGISTER.md"), "agents_root_path": str(repo / "AGENTS.md"), "dossier_agents_path": str(base / "AGENTS.md"), "review_prompt_path": str(repo / "style" / "review-prompt.md"), "review_score_threshold": str(config["review_score_threshold"]), "evidence_dirs": evidence_dirs_text, "existing_titles": "", "max_new": str(config["max_open_topic_proposals"]), } if work_type == "topic_ideas": mapping["existing_titles"] = "\n".join(f"- {t}" for t in collect_existing_titles(repo, dossier)) return mapping def collect_existing_titles(repo: Path, dossier: str) -> list[str]: titles = [] for nn, slug in list_items(repo, dossier): overview = repo / dossier / f"{nn}-{slug}.overview.md" try: first_line = overview.read_text().splitlines()[0] titles.append(first_line.lstrip("#").strip()) except (IndexError, OSError): titles.append(f"{nn}-{slug}") proposals_path = repo / dossier / "TOPIC-PROPOSALS.md" if proposals_path.exists(): for line in proposals_path.read_text().splitlines(): if line.startswith("## "): titles.append(line[3:].strip()) return titles # === Output validation === def extract_absolute_paths(text: str) -> list[str]: return ABS_PATH_RE.findall(text) def strip_line_range(p: str) -> str: """Strip a trailing ':' or ':-' citation line-range suffix. Only strips when the remainder still looks like a path (has a filename segment before the colon); a plain path with no suffix is unchanged. """ m = LINE_RANGE_SUFFIX_RE.match(p) return m.group(1) if m else p def validate_output(work_type: str, text: str) -> tuple[bool, str]: if not text or not text.strip(): return False, "output is empty" first_line = next((l for l in text.splitlines() if l.strip()), "") prefix = EXPECTED_HEADING_PREFIX[work_type] if not first_line.startswith(prefix): return False, f"first non-blank line does not start with '{prefix}': {first_line!r}" if work_type == "research": dead = [p for p in extract_absolute_paths(text) if not Path(strip_line_range(p.rstrip(".,;:"))).exists()] if dead: return False, f"dead cited path(s): {dead}" return True, "ok" # === Claude argv / execution === def build_claude_argv( model_id: str | None, max_turns: int, add_dirs: list[str], allowed_tools: list[str] | None = None ) -> list[str]: argv = ["claude", "-p", "--max-turns", str(max_turns)] if model_id: argv += ["--model", model_id] for d in add_dirs: argv += ["--add-dir", d] if allowed_tools: argv += ["--allowedTools", ",".join(allowed_tools)] return argv def classify_failure(returncode: int, stderr: str, timed_out: bool) -> str | None: if timed_out: return "transient" if returncode == 0: return None lowered = (stderr or "").lower() if any(marker in lowered for marker in RETRYABLE_STDERR_MARKERS): return "transient" return "content" def canonical_output_path(repo: Path, candidate: dict) -> Path: dossier = candidate["dossier"] work_type = candidate["work_type"] if work_type == "topic_ideas": return repo / dossier / "TOPIC-PROPOSALS.md" slug = candidate["slug"] return repo / dossier / f"{slug}.{SUFFIX[work_type]}.md" def rejected_output_path(canonical: Path) -> Path: return canonical.with_name(canonical.name + ".rejected") def git_commit(repo: Path, paths: list[str], message: str) -> tuple[bool, str]: add = subprocess.run(["git", "-C", str(repo), "add", "--"] + paths, capture_output=True, text=True) if add.returncode != 0: return False, f"git add failed: {add.stderr.strip()}" commit = subprocess.run( ["git", "-C", str(repo), "commit", "-m", message, "--"] + paths, capture_output=True, text=True, ) if commit.returncode != 0: return False, f"git commit failed: {commit.stderr.strip()}" return True, "ok" def resolve_add_dirs(config: dict, work_type: str) -> list[str]: if work_type in ("research", "topic_ideas"): return [str(expand(d)) for d in config["evidence_dirs"]] return [] def resolve_allowed_tools(config: dict, work_type: str) -> list[str]: """Tool names granted to the headless child via --allowedTools, from config["work_types"][work_type]["allowed_tools"]. Empty/absent -> [] (caller omits the flag; headless runs cannot answer permission prompts, so only tools explicitly listed here are usable by that work type).""" return list(config["work_types"].get(work_type, {}).get("allowed_tools") or []) def dispatch_preview(repo: Path, config: dict, candidate: dict, max_turns: int) -> dict: """Compute everything a dryrun needs to print, without executing anything.""" dossier = candidate["dossier"] work_type = candidate["work_type"] provider = candidate["provider"] profile_dir = expand(config["providers"][provider]["profile"]) model_id = resolve_model_id(profile_dir) add_dirs = resolve_add_dirs(config, work_type) allowed_tools = resolve_allowed_tools(config, work_type) argv = build_claude_argv(model_id, max_turns, add_dirs, allowed_tools) canonical = canonical_output_path(repo, candidate) tmp_path = tmp_output_path(canonical) parsed_env = parse_provider_env(profile_dir / "provider.env") return { "dossier": dossier, "slug": candidate["slug"], "work_type": work_type, "provider": provider, "prompt_template": str(PROMPTS_DIR / PROMPT_TEMPLATE_FILES[work_type]), "argv": argv, "cwd": str(repo), "env_config_dir": str(profile_dir), "env_base_url": parsed_env["base_url"], "env_has_api_key": bool(parsed_env["api_key_file"]), "output_tmp_path": str(tmp_path), "output_canonical_path": str(canonical), } # === Child process tracking, unique tmp paths, stale-tmp sweep === # # Concurrency-protection hardening (see specs/idle-draft.spec.md "Concurrency # protection"): the flock (ยง1) already keeps two dispatcher instances from # running concurrently in the common case. The gap it does *not* close is a # dispatcher that dies without releasing its children (SIGTERM/SIGKILL, # machine reboot, OOM kill): the flock is released when the fd closes, but # `claude` grandchildren spawned via subprocess survive as orphans and can # still be mid-write to a tmp file when a fresh cron-fired instance starts. # Two independent mechanisms close that gap: # (a) children run in their own process group (`start_new_session=True`); # a caught signal (SIGTERM/SIGINT) or normal interpreter exit kills # every live child's group via os.killpg. This cannot catch SIGKILL of # the dispatcher itself -- hence (b). # (b) every task's tmp output path is unique per dispatcher invocation # (`.tmp.`), so even an orphan that outlives # its dispatcher can never collide with the tmp path a *new* dispatcher # instance uses for the same item. At startup a new instance sweeps and # deletes tmp files older than the task timeout -- they are inert # litter (never promoted by anyone) but worth reclaiming. _live_children_lock = threading.Lock() _live_children: set[int] = set() def register_child(pid: int) -> None: with _live_children_lock: _live_children.add(pid) def unregister_child(pid: int) -> None: with _live_children_lock: _live_children.discard(pid) def live_children() -> set[int]: """Snapshot of currently-tracked child pids. Test/inspection hook.""" with _live_children_lock: return set(_live_children) def kill_all_children() -> None: """Kill the process group of every tracked live child. Safe to call repeatedly / with a stale or empty registry -- a dead or nonexistent pid just raises ProcessLookupError, which is swallowed. Each child was started with start_new_session=True, so its pid is also its pgid.""" with _live_children_lock: pids = list(_live_children) _live_children.clear() for pid in pids: try: os.killpg(pid, signal.SIGKILL) except (ProcessLookupError, PermissionError, OSError): pass def install_signal_handlers() -> None: """Install SIGTERM/SIGINT handlers so a killed dispatcher takes its in-flight `claude` children down with it rather than orphaning them. Only meaningful in the main thread of a real (non-dryrun) dispatch run, where children actually get spawned.""" def _handler(signum, frame): kill_all_children() sys.exit(1) signal.signal(signal.SIGTERM, _handler) signal.signal(signal.SIGINT, _handler) # Belt-and-braces: even a normal unhandled-exception exit (no signal # involved) should not leave children behind. atexit fires on interpreter # shutdown from any cause except os._exit()/SIGKILL. atexit.register(kill_all_children) TMP_SUFFIX_RE = re.compile(r"\.tmp\.\d+$") def tmp_output_path(canonical: Path, pid: int | None = None) -> Path: """Per-invocation-unique tmp path for a task's output. Defaults to this process's pid (shared by all worker threads of one dispatcher run, so every task in one invocation uses the same suffix); a pid can be passed explicitly for testing or preview purposes.""" pid = pid if pid is not None else os.getpid() return canonical.with_name(canonical.name + f".tmp.{pid}") def find_tmp_files(repo: Path, dossiers: list) -> list: """Every leftover `*.tmp.` file directly under any configured dossier directory (where canonical outputs and their tmp siblings live).""" found = [] for dossier in dossiers: d = repo / dossier if not d.is_dir(): continue for f in d.iterdir(): if f.is_file() and TMP_SUFFIX_RE.search(f.name): found.append(f) return found def sweep_stale_tmp_files(repo: Path, dossiers: list, task_timeout: int, now: float | None = None) -> list: """Delete tmp files whose mtime is older than task_timeout seconds -- orphaned litter from a dispatcher that died mid-task (GAP 2b). Tmp files younger than the timeout are left alone: they could belong to a live orphan still finishing up, or to another concurrent dispatch cycle's in-flight task. Since a tmp file is only ever promoted by the exact run_task() call that created it, an untouched one is inert at worst -- never silently adopted by anyone else. Returns the paths actually deleted, for logging by the caller.""" now = now if now is not None else time.time() deleted = [] for f in find_tmp_files(repo, dossiers): try: mtime = f.stat().st_mtime except OSError: continue if now - mtime > task_timeout: try: f.unlink() deleted.append(f) except OSError: continue return deleted # === Real task execution === def run_task(repo: Path, config: dict, candidate: dict, max_turns: int, task_timeout: int) -> dict: dossier = candidate["dossier"] work_type = candidate["work_type"] provider = candidate["provider"] slug = candidate["slug"] canonical = canonical_output_path(repo, candidate) tmp_path = tmp_output_path(canonical) mapping = build_prompt_mapping(repo, dossier, slug, work_type, config, tmp_path) prompt_text = render_prompt(work_type, mapping) profile_dir = expand(config["providers"][provider]["profile"]) model_id = resolve_model_id(profile_dir) add_dirs = resolve_add_dirs(config, work_type) allowed_tools = resolve_allowed_tools(config, work_type) argv = build_claude_argv(model_id, max_turns, add_dirs, allowed_tools) env = build_child_env(profile_dir) timed_out = False returncode = -1 stderr = "" try: proc = subprocess.Popen( argv, cwd=str(repo), env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True, ) except OSError as e: return {"outcome": "transient", "candidate": candidate, "reason": f"failed to start claude subprocess: {e}"} # GAP 2a: own process group + tracked pid, so a killed dispatcher can # take this (and any grandchildren it spawns) down with it instead of # orphaning it. See "Child process tracking" section above. register_child(proc.pid) try: try: _, stderr = proc.communicate(input=prompt_text, timeout=task_timeout) returncode = proc.returncode except subprocess.TimeoutExpired: timed_out = True try: os.killpg(proc.pid, signal.SIGKILL) except (ProcessLookupError, PermissionError, OSError): pass try: proc.communicate(timeout=5) except Exception: pass finally: unregister_child(proc.pid) failure = classify_failure(returncode, stderr, timed_out) if failure == "transient": if tmp_path.exists(): tmp_path.unlink() return {"outcome": "transient", "candidate": candidate, "reason": "timeout" if timed_out else stderr} if failure == "content": rejected = rejected_output_path(canonical) if tmp_path.exists(): os.replace(tmp_path, rejected) return {"outcome": "content_failure", "candidate": candidate, "reason": stderr or "non-zero exit"} # returncode == 0: validate text = tmp_path.read_text() if tmp_path.exists() else "" ok, reason = validate_output(work_type, text) if not ok: rejected = rejected_output_path(canonical) if tmp_path.exists(): os.replace(tmp_path, rejected) return {"outcome": "content_failure", "candidate": candidate, "reason": reason} if work_type == "topic_ideas": existing = canonical.read_text() if canonical.exists() else "" atomic_write_text(canonical, existing + ("\n" if existing and not existing.endswith("\n") else "") + text) if tmp_path.exists(): tmp_path.unlink() else: os.replace(tmp_path, canonical) return {"outcome": "success", "candidate": candidate, "canonical": canonical} # === Dispatch loop === def get_config_defaults(config: dict) -> dict: return { "max_turns": config.get("max_turns", DEFAULT_MAX_TURNS), "task_timeout": config.get("task_timeout_seconds", DEFAULT_TASK_TIMEOUT_SECONDS), "max_attempts": config.get("max_attempts", DEFAULT_MAX_ATTEMPTS), } def evaluate_credentials(config: dict) -> dict: result = {} for pname in config["providers"]: profile_dir = expand(config["providers"][pname]["profile"]) result[pname] = credential_parity(pname, profile_dir) return result def log_gates(repo: Path, gates: dict, credential_ok: dict) -> None: for pname, gate in gates.items(): cred_ok, cred_reason = credential_ok.get(pname, (False, "not checked")) eligible = gate["eligible"] and cred_ok log_event( repo, f"gate {pname}: idle_points={gate['idle_points']} five_hour_pct={gate['five_hour_pct']} " f"eligible={eligible} reason={gate['reason']!r} credential_parity={cred_ok} ({cred_reason})", ) def apply_success_state(state: dict, candidate: dict) -> None: # Successful dispatch doesn't itself set a human gate; nothing to flip here. # (human_edit_done / research_sampled / approved are human-only via `mark`.) # Present for symmetry with apply_failure_state and as the extension point # if a future work type needs to record something on success. return def apply_failure_state(state: dict, candidate: dict, max_attempts: int) -> str | None: if candidate["kind"] != "item": return None key = candidate["item_key"] work_type = candidate["work_type"] if work_type not in ALLOWED_ATTEMPT_WORK_TYPES: return None items = state.setdefault("items", {}) entry = items.setdefault(key, {}) attempts = entry.setdefault("attempts", {}) attempts[work_type] = attempts.get(work_type, 0) + 1 blocked_reason = None if attempts[work_type] >= max_attempts: blocked_reason = f"{work_type} failed {attempts[work_type]} times (max_attempts={max_attempts})" entry["blocked"] = blocked_reason return blocked_reason def run_dispatch(args, config: dict, repo: Path, state_path: Path) -> int: rotate_log_if_needed(repo) defaults = get_config_defaults(config) lock = None if not args.dryrun: lock = acquire_lock(repo) if lock is None: log_event(repo, "lock held by another invocation; exiting") return 0 # Held for the full dispatch loop, including while children run (see # module-level "Child process tracking" section) -- a second cron-fired # invocation exits 0 immediately per the check above, it never races # this instance even if this instance runs for hours. install_signal_handlers() for swept in sweep_stale_tmp_files(repo, config["dossiers"], defaults["task_timeout"]): log_event(repo, f"swept stale tmp file (orphaned litter): {swept}") try: state = load_state(state_path, repo) except StateValidationError as e: log_event(repo, f"FATAL: state validation failed: {e}") return 2 parallel = args.parallel or config.get("parallel", DEFAULT_PARALLEL) try: probe_report = run_probe(args.probe_json) except Exception as e: log_event(repo, f"FATAL: probe failed: {e}") return 1 credential_ok = evaluate_credentials(config) gates = compute_all_gates(config, probe_report) log_gates(repo, gates, credential_ok) in_flight: set = set() queue = build_ready_queue(repo, config, state, in_flight) if args.dryrun: slots = 1 if args.once else parallel picked = [] for cand in queue: if len(picked) >= slots: break provider, why = select_provider(cand["work_type"], config, gates, credential_ok) if provider is None: continue cand = dict(cand) cand["provider"] = provider in_flight.add(cand["item_key"]) picked.append(cand) if not picked: print("[dryrun] No eligible (item, provider) pair to dispatch this cycle.") return 0 print(f"[dryrun] Would dispatch {len(picked)} task(s) (initial wave; re-probe not simulated):") for cand in picked: preview = dispatch_preview(repo, config, cand, defaults["max_turns"]) print(f"\n item: {preview['dossier']}/{preview['slug'] or '(dossier-level)'}") print(f" work_type: {preview['work_type']}") print(f" provider: {preview['provider']}") print(f" prompt: {preview['prompt_template']}") print(f" cwd: {preview['cwd']}") print(f" env: CLAUDE_CONFIG_DIR={preview['env_config_dir']}" + (f" ANTHROPIC_BASE_URL={preview['env_base_url']}" if preview['env_base_url'] else "") + (" ANTHROPIC_API_KEY=" if preview['env_has_api_key'] else "")) print(f" argv: {preview['argv']}") print(f" output: {preview['output_tmp_path']} -> {preview['output_canonical_path']}") return 0 dispatched_count = 0 fatal = False with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, parallel)) as executor: futures = {} def try_submit(): nonlocal dispatched_count if args.once and dispatched_count >= 1: return nonlocal queue queue = build_ready_queue(repo, config, state, in_flight) for cand in queue: provider, _ = select_provider(cand["work_type"], config, gates, credential_ok) if provider is None: continue cand = dict(cand) cand["provider"] = provider cand["before_pct"] = gates.get(provider, {}).get("seven_day_pct") in_flight.add(cand["item_key"]) dispatched_count += 1 log_event(repo, f"dispatch: {cand['item_key']} work_type={cand['work_type']} provider={provider}") fut = executor.submit(run_task, repo, config, cand, defaults["max_turns"], defaults["task_timeout"]) futures[fut] = cand return try_submit() while futures: done, _ = concurrent.futures.wait(futures, return_when=concurrent.futures.FIRST_COMPLETED) for fut in done: cand = futures.pop(fut) in_flight.discard(cand["item_key"]) try: result = fut.result() except Exception as e: log_event(repo, f"FATAL task exception for {cand['item_key']}: {e}") fatal = True continue outcome = result["outcome"] if outcome == "success": apply_success_state(state, cand) save_state_atomic(state_path, state) canonical = result["canonical"] try: after_probe = run_probe(args.probe_json) after_gate = compute_gate(config["providers"][cand["provider"]], provider_probe_lookup(after_probe, cand["provider"])) after_pct = after_gate["seven_day_pct"] gates.update(compute_all_gates(config, after_probe)) except Exception as e: log_event(repo, f"re-probe after completion failed: {e}") after_pct = None before_pct = cand.get("before_pct") msg = ( f"idle-draft: {cand['work_type']} {cand['item_key']} via {cand['provider']} " f"(7d {before_pct}%โ†’{after_pct}%)" ) rel_canonical = str(Path(canonical).relative_to(repo)) ok, reason = git_commit(repo, [rel_canonical, "idle-draft.state.json"], msg) log_event(repo, f"success: {cand['item_key']} -> {rel_canonical}; commit={ok} ({reason})") elif outcome == "content_failure": blocked_reason = apply_failure_state(state, cand, defaults["max_attempts"]) save_state_atomic(state_path, state) log_event( repo, f"content failure: {cand['item_key']} work_type={cand['work_type']} " f"reason={result['reason']!r} blocked={blocked_reason!r}", ) else: # transient log_event(repo, f"transient failure: {cand['item_key']} reason={result['reason']!r}") if not (args.once and dispatched_count >= 1): try_submit() if lock is not None: lock.close() return 1 if fatal else 0 # === status / mark === def cmd_status(args, config: dict, repo: Path, state_path: Path) -> int: try: state = load_state(state_path, repo) except StateValidationError as e: print(f"FATAL: state validation failed: {e}", file=sys.stderr) return 2 try: probe_report = run_probe(args.probe_json) credential_ok = evaluate_credentials(config) gates = compute_all_gates(config, probe_report) except Exception as e: print(f"Warning: probe failed: {e}", file=sys.stderr) gates, credential_ok = {}, {} print("Providers") print("=========") for pname, gate in gates.items(): cred_ok, cred_reason = credential_ok.get(pname, (False, "not checked")) print(f" {pname}: idle_points={gate['idle_points']} five_hour_pct={gate['five_hour_pct']} " f"eligible={gate['eligible'] and cred_ok} reason={gate['reason']} credential_parity={cred_ok} ({cred_reason})") print() print("Items") print("=====") for dossier in config["dossiers"]: for nn, slug in list_items(repo, dossier): key = f"{dossier}/{nn}-{slug}" files = item_files(repo, dossier, f"{nn}-{slug}") entry = get_item_state(state, key) wtype, reason = next_work_type_for_item(files, entry) flags = [] if entry["human_edit_done"]: flags.append("edited") if entry["research_sampled"]: flags.append("sampled") if entry["approved"]: flags.append("approved") if entry["blocked"]: flags.append(f"blocked({entry['blocked']})") flags_str = ",".join(flags) if flags else "-" next_action = wtype or "none" print(f" {key}: next={next_action} flags=[{flags_str}] reason={reason!r}") return 0 def cmd_mark(args, config: dict, repo: Path, state_path: Path) -> int: item = args.item action = args.action if "/" not in item: print(f"Error: item must be of the form dossier/NN-slug, got: {item}", file=sys.stderr) return 2 dossier, slug = item.split("/", 1) overview = repo / dossier / f"{slug}.overview.md" if not overview.exists(): print(f"Error: no such item (overview file not found): {overview}", file=sys.stderr) return 2 try: state = load_state(state_path, repo) except StateValidationError as e: print(f"FATAL: state validation failed: {e}", file=sys.stderr) return 2 items = state.setdefault("items", {}) entry = items.setdefault(item, {}) if action == "edited": entry["human_edit_done"] = True elif action == "sampled": entry["research_sampled"] = True elif action == "approved": entry["approved"] = True elif action == "unblock": entry["blocked"] = None else: print(f"Error: unknown mark action: {action}", file=sys.stderr) return 2 validate_state(state, repo) save_state_atomic(state_path, state) print(f"Marked {item}: {action}") return 0 # === CLI === HELP = """\ idle-draft โ€” idle-subscription dispatcher for the writing pipeline Usage: idle-draft [OPTIONS] idle-draft mark edited|sampled|approved|unblock [OPTIONS] idle-draft status [OPTIONS] Options: --config FILE Path to config file (default: ./idle-draft.config.json) --repo DIR Writing repo root (default: config file's directory) --once Dispatch at most one task, then exit --parallel N Max concurrent tasks (overrides config) --dryrun, -n Preview dispatch without executing or mutating anything --probe-json FILE Read probe output from FILE instead of running agent-subscriptions --help, -h Show this help and exit Spec: specs/idle-draft.spec.md """ def parse_args(argv: list[str]): class Args: pass args = Args() args.subcommand = None args.item = None args.action = None args.config = Path("idle-draft.config.json") args.repo = None args.once = False args.parallel = None args.dryrun = False args.probe_json = None rest = list(argv) if rest and rest[0] in ("mark", "status"): args.subcommand = rest[0] rest = rest[1:] if args.subcommand == "mark": positionals = [a for a in rest if not a.startswith("-")] if len(positionals) < 2: print("Error: mark requires ", file=sys.stderr) sys.exit(2) args.item, args.action = positionals[0], positionals[1] rest = [a for a in rest if a not in (args.item, args.action)] i = 0 while i < len(rest): a = rest[i] if a in ("-h", "--help"): print(HELP) sys.exit(0) elif a == "--config": args.config = Path(rest[i + 1]); i += 2 elif a == "--repo": args.repo = Path(rest[i + 1]); i += 2 elif a == "--once": args.once = True; i += 1 elif a == "--parallel": args.parallel = int(rest[i + 1]); i += 2 elif a in ("--dryrun", "-n"): args.dryrun = True; i += 1 elif a == "--probe-json": args.probe_json = Path(rest[i + 1]); i += 2 else: print(f"Error: unknown option: {a}", file=sys.stderr) sys.exit(2) return args def main() -> None: args = parse_args(sys.argv[1:]) try: config = load_config(args.config) except ConfigError as e: print(f"FATAL: {e}", file=sys.stderr) sys.exit(2) repo = args.repo if args.repo is not None else args.config.resolve().parent state_path = repo / "idle-draft.state.json" if args.subcommand == "mark": sys.exit(cmd_mark(args, config, repo, state_path)) elif args.subcommand == "status": sys.exit(cmd_status(args, config, repo, state_path)) else: sys.exit(run_dispatch(args, config, repo, state_path)) if __name__ == "__main__": main()