org_node_trigger.py 10 KB
"""c_org_node_trigger (03-C §3.3): subprocess wrapper skeleton.

U-005 scope: shell out `claude --agent <org_node> -p ...` and return the text
response -- "骨架,不含完整状态机" per development-plan.md. No task-lifecycle
state machine yet: TASK_NOT_READY/CONCURRENCY_LIMIT_REACHED/USAGE_LIMIT_REACHED
handling is U-010's job, not this one.

Identity comes entirely from `.claude/agents/<org_node>.md` in the target
project (see src/agent_profile.py) -- this module only assembles the prompt
and parses the response, no role-isolation logic of its own (03-C §3.3).

**`trigger_org_node_structured` (2026-08, work-graph-node-execution-
deblackbox-requirements.md)**: a sibling of `trigger_org_node` above, not a
replacement -- same flat `claude --agent` call, but also extracts a JSON
payload matching a caller-supplied output_schema from the free-text
response (05 §5.4.1.1's documented workaround: `--json-schema` silently
drops `structured_output` when combined with `--agent`, so ask for a single
```json fenced block in the prompt instead and parse it out, exactly the
convention `work_graph_plan.py::plan_work_graph()` already validated in
production). This is what closes the three remaining call sites that used
to render a one-off Dynamic Workflow `.js` script and run it via
`work_graph_execute.execute_work_graph()` purely to get "agent identity +
schema-shaped output" -- `plan_work_graph()` proves flat calls already do
both, so the Workflow indirection was never load-bearing for that need.
"""

import json
import subprocess

from src.agent_profile import ORG_NODES
from src.work_graph_plan import extract_json_payload


class OrgNodeTriggerError(Exception):
    """Raised when the claude subprocess exits non-zero or reports is_error."""


class StructuredOutputExtractionError(OrgNodeTriggerError):
    """trigger_org_node_structured got a well-formed claude response, but
    couldn't find any JSON payload in it matching output_schema's top-level
    required keys (bare or ```json-fenced) -- see extract_json_payload."""


# REQ-14's happy case cites "You've hit your session limit" as the real
# signal text (session/weekly/opus phrase matching, ported verbatim from
# work_graph_execute.py's own `_quota_limit_type` -- see this module's
# docstring for why trigger_org_node_structured needs THIS granular version
# (with a quota_limit_type of "session"/"weekly"/"opus") rather than
# org_node_lifecycle.py's simpler boolean `_looks_like_usage_limit`:
# work_node_run.raise_usage_limit_signal(quota_limit_type) and
# _make_run_fn's own quota_limit_type-keyed logging both need the specific
# type, not just a yes/no. org_node_lifecycle.py's own detection is a
# different call path (trigger_org_node_full, task-lifecycle transitions,
# untouched by this migration) and is deliberately left alone rather than
# unified here -- merging it would touch that unrelated flow's established,
# tested behavior for no benefit to this migration. work_graph_execute.py
# keeps its own copy too (frozen/historical per this migration's FR-6, not
# worth coupling a soon-to-be-unused module to this one) -- both copies
# implement the identical algorithm, so there is no *inconsistent* duplicate
# detection logic in play, only a frozen historical copy next to the live one.
_USAGE_LIMIT_PHRASES = ("hit your session limit", "hit your weekly limit", "usage limit")


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


class UsageLimitReachedError(OrgNodeTriggerError):
    """USAGE_LIMIT_REACHED -- 04-P p_usage_limit_wait. `quota_limit_type` is
    "session"/"weekly"/"opus" (see _quota_limit_type above); callers that
    already have a downgrade-retry mechanism (work_node_run.py's U-020
    model-downgrade retry, driven via work_node_run.raise_usage_limit_signal)
    consume it directly."""

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


_JSON_FENCE_INSTRUCTION = (
    "Your final answer MUST be a single JSON object wrapped in exactly one "
    "```json fenced code block, matching this schema: {schema}. Do not "
    "describe what you did or are about to do -- the fenced block's content "
    "IS the answer, not a summary of it."
)


def trigger_org_node_structured(org_node, prompt, target_folder_path, output_schema, model=None, timeout=300):
    """Like trigger_org_node(), but also extracts a JSON payload matching
    output_schema's top-level required keys from the free-text response --
    see this module's docstring. `_JSON_FENCE_INSTRUCTION` is appended to
    `prompt` automatically, callers don't repeat this boilerplate themselves.

    `model`: real CLI `--model` override (FR-4) -- empirically verified for
    real (RUN_REAL_AGENT_E2E=1, see this module's own test file) that
    `claude --agent <name> -p ... --model <model>` (1) still loads the
    target org_node's persona, (2) genuinely runs the requested model (the
    JSON response's own `modelUsage` key names it), and (3) the ```json
    fence-extraction convention above still works on that real output --
    05 §5.4.1.5's "CLI top-level args and DSL-internal options are two
    different paths, don't assume one from the other" lesson, checked here
    rather than assumed.
    """
    if org_node not in ORG_NODES:
        raise ValueError(org_node)

    args = [
        "claude", "--agent", org_node,
        "-p", prompt + "\n\n" + _JSON_FENCE_INSTRUCTION.format(schema=json.dumps(output_schema)),
    ]
    if model:
        args += ["--model", model]
    args += ["--output-format", "json", "--permission-mode", "auto"]

    result = subprocess.run(
        args, 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) or _quota_limit_type(result.stdout)
        detail = "stderr: {}\nstdout (truncated): {}".format(result.stderr, result.stdout[:4000])
        if quota_type:
            raise UsageLimitReachedError(
                "trigger_org_node_structured for {} exited {}\n{}".format(org_node, result.returncode, detail),
                quota_type,
            )
        raise OrgNodeTriggerError(
            "trigger_org_node_structured for {} exited {}\n{}".format(org_node, result.returncode, detail)
        )

    payload = json.loads(result.stdout)
    if payload.get("is_error"):
        quota_type = _quota_limit_type(str(payload.get("result", "")))
        if quota_type:
            raise UsageLimitReachedError(
                "trigger_org_node_structured for {} hit a usage limit: {}".format(org_node, payload), quota_type
            )
        raise OrgNodeTriggerError(
            "trigger_org_node_structured for {} reported is_error: {}".format(org_node, payload)
        )

    required_keys = list(output_schema.get("required", []))
    extracted = extract_json_payload(payload.get("result", ""), required_keys=required_keys)
    if extracted is None:
        # 2026-08-15 (real incident, task b243dfe8): this used to truncate
        # to [:2000] chars -- fine for the "process exited nonzero" error
        # paths above, where a short excerpt is enough context, but actively
        # counterproductive here: the WHOLE POINT of this exception is
        # diagnosing why extraction failed (genuine model-side truncation
        # vs some other JSON-validity issue), and a truncated preview can
        # LOOK like the response was cut off mid-generation when that's
        # really just this display limit -- indistinguishable from the
        # real thing without the full text, which is exactly what happened
        # investigating this live. No length cap: this flows into
        # task_execution._mark_blocked's str(exc), stored as a plain TEXT
        # execution_event column with no practical size limit, so keeping
        # the complete response is the honest, actually-diagnosable choice.
        raise StructuredOutputExtractionError(
            "could not extract a payload matching required keys {} from org_node={}'s response: {!r}".format(
                required_keys, org_node, payload.get("result", "")
            )
        )
    return extracted


def trigger_org_node(org_node, prompt, target_folder_path, timeout=300):
    """Runs `claude --agent <org_node> -p <prompt>` with cwd=target_folder_path.

    `.claude/agents/<org_node>.md` must already be deployed in target_folder_path
    (agent_profile.deploy_agent_definitions) -- this function does not deploy it.
    Returns the response text (the JSON `result` field).
    """
    if org_node not in ORG_NODES:
        raise ValueError(org_node)

    result = subprocess.run(
        [
            "claude", "--agent", org_node,
            "-p", prompt,
            "--output-format", "json",
            "--permission-mode", "auto",
        ],
        cwd=target_folder_path,
        stdin=subprocess.DEVNULL,
        capture_output=True,
        text=True,
        timeout=timeout,
    )
    if result.returncode != 0:
        # 2026-07-31 fix: a real Coder call once failed with returncode=1 and
        # empty stderr, and the exception message (stderr-only, as it used to
        # be) gave zero diagnostic signal -- whatever claude actually said
        # about the failure was in stdout, which this branch was discarding.
        # Both are now included (truncated -- stdout in JSON mode can be
        # large) so a future real failure is actually debuggable.
        raise OrgNodeTriggerError(
            "claude --agent {} exited {}\nstderr: {}\nstdout (truncated): {}".format(
                org_node, result.returncode, result.stderr, result.stdout[:4000]
            )
        )

    payload = json.loads(result.stdout)
    if payload.get("is_error"):
        raise OrgNodeTriggerError("claude --agent {} reported is_error: {}".format(org_node, payload))
    return payload["result"]