work_graph_execute.py 7.4 KB
"""c_work_graph_execute (03-C §3.5): dynamic-workflow invocation, full implementation.

Writes an orchestration script into the target project's `.claude/workflows/
<name>.js` and invokes it via `claude -p 'Run /<name> with args {...}'`,
reusing the exact code path the A-1 spike validated (spikes/a1-dynamic-workflows/,
confirmed 2026-07-29).

U-012 scope, added on top of U-006's skeleton: CLASSIFIER_BLOCKED detection
(03-C §3.5) and USAGE_LIMIT_REACHED backoff-retry (04-P §4.4.1
p_usage_limit_wait -- REQ-14's happy case only; boundary/failure is U-020).
Both are pure, time-injectable logic wrapping the same subprocess call, no
dependency on U-013's work_node_execution rows (which don't exist yet --
see development-plan.md's 2026-07-31 scope note on this Unit for why the
retry loop operates at the whole-work-graph level rather than per node).
"""

import json
import os
import subprocess
import time

_USAGE_LIMIT_PHRASES = ("hit your session limit", "hit your weekly limit", "usage limit")
_BACKOFF_SCHEDULE_MINUTES = (5, 10, 20, 30)  # then repeats at 30 until the type's timeout
_TIMEOUT_SECONDS = {"session": 8 * 3600, "weekly": 9 * 24 * 3600}
# 03-C §3.5: "分类器连续拦截 3 次或累计 20 次" -- a single `-p` call's own
# permission_denials array already reflects that whole session's denials, so
# 3 is used as the practical per-call threshold (conservative relative to the
# 20 cumulative ceiling; true cross-call cumulative tracking needs U-013's
# per-node persistence and isn't attempted here).
_CLASSIFIER_BLOCKED_THRESHOLD = 3


class WorkGraphExecuteError(Exception):
    """Raised when the claude subprocess exits non-zero, reports is_error, or
    produces no structured_output, and it isn't one of the more specific
    errors below."""


class ClassifierBlockedError(WorkGraphExecuteError):
    """CLASSIFIER_BLOCKED -- 03-C §3.5. Not retryable; routes to
    blocked_pending_human, blocked_reason='classifier_blocked' (04-P)."""


class UsageLimitReachedError(WorkGraphExecuteError):
    """USAGE_LIMIT_REACHED -- 04-P p_usage_limit_wait. Retryable with backoff;
    see execute_work_graph_with_usage_limit_retry."""

    def __init__(self, message, quota_limit_type):
        super().__init__(message)
        self.quota_limit_type = quota_limit_type


class UsageLimitWaitTimeoutError(WorkGraphExecuteError):
    """p_usage_limit_wait exceeded its timeout -> blocked_pending_human,
    blocked_reason='usage_limit_wait_timeout' (04-P §4.4.1)."""


def _quota_limit_type(text):
    lowered = text.lower()
    if "weekly" in lowered and "hit your" in lowered:
        return "weekly"
    if "opus" in lowered and ("limit" in lowered or "quota" in lowered):
        return "opus"
    if any(phrase in lowered for phrase in _USAGE_LIMIT_PHRASES):
        return "session"
    return None


def write_workflow_script(target_folder_path, name, script_content):
    """Writes `<target>/.claude/workflows/<name>.js` -- versioned with the
    target project's own git history (03-C §3.5 step 1), not graphlemon's."""
    workflows_dir = os.path.join(target_folder_path, ".claude", "workflows")
    os.makedirs(workflows_dir, exist_ok=True)
    path = os.path.join(workflows_dir, name + ".js")
    with open(path, "w", encoding="utf-8") as f:
        f.write(script_content)
    return path


def execute_work_graph(target_folder_path, name, args, output_schema, timeout=600):
    """Runs the workflow already written to `.claude/workflows/<name>.js`
    (write_workflow_script) via `claude -p 'Run /<name> with args {...}'`.

    No `--bare` (A-1 spike: skips credential/login discovery, unrelated to
    workflow presence). `--permission-mode auto`, never `bypassPermissions`/
    `--dangerously-skip-permissions` (05 §5.3.1). Returns the parsed
    `structured_output` dict. Raises ClassifierBlockedError or
    UsageLimitReachedError instead of the generic WorkGraphExecuteError when
    the response matches those specific signals.
    """
    prompt = "Run /{} with args {}".format(name, json.dumps(args))
    result = subprocess.run(
        [
            "claude", "-p", prompt,
            "--output-format", "json",
            "--json-schema", json.dumps(output_schema),
            "--permission-mode", "auto",
        ],
        cwd=target_folder_path,
        stdin=subprocess.DEVNULL,
        capture_output=True,
        text=True,
        timeout=timeout,
    )
    if result.returncode != 0:
        quota_type = _quota_limit_type(result.stderr)
        if quota_type:
            raise UsageLimitReachedError(
                "workflow {} exited {}: {}".format(name, result.returncode, result.stderr), quota_type
            )
        raise WorkGraphExecuteError(
            "workflow {} exited {}: {}".format(name, result.returncode, result.stderr)
        )

    payload = json.loads(result.stdout)
    if payload.get("is_error"):
        denials = payload.get("permission_denials") or []
        if len(denials) >= _CLASSIFIER_BLOCKED_THRESHOLD:
            raise ClassifierBlockedError(
                "workflow {} blocked by permission classifier ({} denials): {}".format(
                    name, len(denials), payload
                )
            )
        quota_type = _quota_limit_type(str(payload.get("result", "")))
        if quota_type:
            raise UsageLimitReachedError("workflow {} hit a usage limit: {}".format(name, payload), quota_type)
        raise WorkGraphExecuteError("workflow {} reported is_error: {}".format(name, payload))

    structured_output = payload.get("structured_output")
    if structured_output is None:
        raise WorkGraphExecuteError("workflow {} produced no structured_output: {}".format(name, payload))
    return structured_output


def execute_work_graph_with_usage_limit_retry(
    target_folder_path, name, args, output_schema, timeout=600,
    sleep=time.sleep, now=time.monotonic,
):
    """Wraps execute_work_graph with p_usage_limit_wait (04-P §4.4.1): a
    session/weekly quota hit backs off 5->10->20->30min (repeating at 30)
    and retries until that type's timeout, then raises
    UsageLimitWaitTimeoutError. An opus quota hit skips backoff entirely
    (04-P: "直接...降级到非 Opus 模型重试" -- the actual model swap is a
    caller-level decision, not this function's; it raises immediately so the
    caller can decide). ClassifierBlockedError is never retried (03-C:
    not retryable, needs human review) and propagates immediately.

    `sleep`/`now` are injectable so tests don't block on real 5-30 minute waits.
    """
    start = now()
    backoff_index = 0
    while True:
        try:
            return execute_work_graph(target_folder_path, name, args, output_schema, timeout=timeout)
        except UsageLimitReachedError as e:
            if e.quota_limit_type == "opus":
                raise UsageLimitWaitTimeoutError(
                    "opus quota exhausted -- caller should retry on a non-opus model: " + str(e)
                ) from e
            elapsed = now() - start
            timeout_seconds = _TIMEOUT_SECONDS[e.quota_limit_type]
            if elapsed >= timeout_seconds:
                raise UsageLimitWaitTimeoutError(
                    "{} quota wait exceeded {}s timeout after {:.0f}s: {}".format(
                        e.quota_limit_type, timeout_seconds, elapsed, e
                    )
                ) from e
            wait_minutes = _BACKOFF_SCHEDULE_MINUTES[min(backoff_index, len(_BACKOFF_SCHEDULE_MINUTES) - 1)]
            sleep(wait_minutes * 60)
            backoff_index += 1