diff --git a/agent/pyproject.toml b/agent/pyproject.toml index 17ae8cb64..fa86e5ea4 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -131,6 +131,16 @@ ignore = [ [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] +# Non-TTY output ergonomics. In a captured/piped context (the ECS build gate, CI, +# an agent's Bash tool) pytest's default is a wall of featureless progress dots +# with the "N passed" summary easy to lose in a tail — live-caught on ABCA-691, +# where an agent burned ~6 turns re-running the full suite with different flags +# because it couldn't confirm the result from the dots. ``count`` renders progress +# as an explicit "N/M passed" so a captured log always shows how far it got and how +# many passed; ``-ra`` appends a short summary of every non-passing outcome (with +# reasons) at the end. Both are display-only — they never change test results. +addopts = "-ra" +console_output_style = "count" # pytest-timeout: hard wall-clock cap PER TEST. A unit test that blocks (an # unmocked network/subprocess/Bedrock call with no timeout of its own) otherwise # hangs the whole `mise run build` until the 3600s build-verify ceiling kills it, @@ -143,6 +153,17 @@ pythonpath = ["src"] # but the build kept hanging ~55 min until the 3600s ceiling). signal is the real fix. timeout = 120 timeout_method = "signal" +# faulthandler backstop for hangs signal-timeout CANNOT interrupt. SIGALRM only +# fires in the MAIN thread during a test's call phase, so a hang in a WORKER +# thread (a deadlocked Barrier/join), a fixture, or a C-level socket read the +# main thread never returns from is invisible to it — exactly the ECS-only stall +# chased across ABCA-684/686/688 and again on the warm-cache run (53 min of +# silence, signal never fired). This arms faulthandler's dedicated C watchdog +# thread (immune to the GIL and blocked syscalls) to dump EVERY thread's Python +# stack after 300s in a test, so the next hang self-reports the exact file:line +# instead of stalling blind to the 3600s build ceiling. A session-level +# hard-exit watchdog for hangs OUTSIDE any test lives in tests/conftest.py. +faulthandler_timeout = 300 [tool.coverage.run] branch = true diff --git a/agent/src/channel_mcp.py b/agent/src/channel_mcp.py index 7407f9e28..2b762bb43 100644 --- a/agent/src/channel_mcp.py +++ b/agent/src/channel_mcp.py @@ -6,17 +6,33 @@ start and exposes the server's tools. Currently wired channels: -- ``linear`` → Linear hosted MCP (``mcp__linear-server__*`` tools) — functional. - ``jira`` → Atlassian Remote MCP entry — a NON-FUNCTIONAL placeholder. It is written for forward-compatibility but cannot connect from a headless agent (interactive OAuth 2.1 only); live outbound Jira comments go through the REST shim in ``jira_reactions.py``. See ``JIRA_MCP_URL`` below + ADR-015. -For all other channel sources this is a no-op: no MCP is written, and the -SDK sees no channel-specific tools. +Linear is NOT written here: ABCA runs Linear 100% deterministically (ADR-016 +"Linear is fully deterministic"). There is no Linear MCP — issue text, recent +comments, and attachments are pre-hydrated at the Lambda tier (the webhook +processor + ``linear-attachments.ts`` / ``linear-feedback.fetchRecentComments``), +and outbound reactions / state transitions go through direct GraphQL in +``linear_reactions.py`` (which reads ``LINEAR_API_TOKEN`` set by config.py — +independent of this module). The Linear MCP was removed after it proved +non-functional against a single OAuth app (actor=user data reads error; +actor=app can't re-consent an installed app). + +Beyond WRITING channel entries, this module also ENFORCES the Linear-no-MCP +guarantee: {@link strip_linear_mcp_servers} removes any Linear MCP server a repo +may have COMMITTED to its own ``.mcp.json`` before the SDK loads it (the prompt +prohibition is not a security boundary — the SDK loads ``project`` settings and +we export ``LINEAR_API_TOKEN``). The pipeline calls it after +``configure_channel_mcp`` on every task with a repo. + +For all other channel sources ``configure_channel_mcp`` is a no-op: no MCP is +written (though the scrub above still runs). See: cdk/src/handlers/{linear,jira}-webhook-processor.ts (inbound), -runner.py (SDK invocation). +runner.py (SDK invocation), ADR-016 (Linear determinism). """ from __future__ import annotations @@ -30,31 +46,6 @@ if TYPE_CHECKING: from collections.abc import Callable -# ─── Linear ────────────────────────────────────────────────────────────────── - -#: Linear MCP endpoint — hosted by Linear, Streamable HTTP transport. -LINEAR_MCP_URL = "https://mcp.linear.app/mcp" - -#: Key name inside ``mcpServers``. Tools surface as -#: ``mcp__linear-server__*`` in the Agent SDK. -LINEAR_MCP_SERVER_KEY = "linear-server" - -#: Env var name the MCP server entry reads via ``${LINEAR_API_TOKEN}`` -#: placeholder expansion. Populated from the OAuth secret by config.py. -LINEAR_API_TOKEN_ENV = "LINEAR_API_TOKEN" # noqa: S105 — env var *name*, not a secret value - - -def _linear_server_entry() -> dict[str, Any]: - """Build the `mcpServers` entry for Linear's hosted MCP.""" - return { - "type": "http", - "url": LINEAR_MCP_URL, - "headers": { - "Authorization": f"Bearer ${{{LINEAR_API_TOKEN_ENV}}}", - }, - } - - # ─── Jira (Atlassian Remote MCP — NON-FUNCTIONAL PLACEHOLDER) ──────────────── #: Atlassian Remote MCP endpoint — Streamable HTTP transport. @@ -99,7 +90,6 @@ def _jira_server_entry() -> dict[str, Any]: #: have a hosted MCP (api, webhook, slack) intentionally have no entry here — #: the gate in ``configure_channel_mcp`` short-circuits on missing keys. CHANNEL_MCP_BUILDERS: dict[str, tuple[str, Callable[[], dict[str, Any]]]] = { - "linear": (LINEAR_MCP_SERVER_KEY, _linear_server_entry), "jira": (JIRA_MCP_SERVER_KEY, _jira_server_entry), } @@ -124,7 +114,11 @@ def _read_existing_mcp_config(path: str) -> dict[str, Any]: return {} -def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool: +def configure_channel_mcp( + repo_dir: str, + channel_source: str, + channel_metadata: dict[str, str] | None = None, +) -> bool: """Write or merge a channel-specific ``.mcp.json`` into ``repo_dir``. Looks up ``channel_source`` in :data:`CHANNEL_MCP_BUILDERS`: @@ -136,11 +130,15 @@ def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool: Args: repo_dir: the cloned-repo working directory the SDK will use as ``cwd``. channel_source: inbound channel (``TaskConfig.channel_source``). + channel_metadata: per-task channel metadata (``TaskConfig.channel_metadata``). + Currently unused by the wired channels (Jira's entry is static); retained + for call-site compatibility and future channel builders that need it. Returns: True if a channel MCP entry was (re)written, False otherwise (channel unmapped, missing repo_dir, or write failure). """ + _ = channel_metadata # reserved for future channel builders (see docstring) builder_entry = CHANNEL_MCP_BUILDERS.get(channel_source) if builder_entry is None: return False @@ -184,3 +182,77 @@ def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool: "not MCP", ) return True + + +#: Substrings that mark an ``mcpServers`` entry as a Linear MCP server. Matched +#: (case-insensitively) against the server KEY and, defensively, the JSON of its +#: value (url / command / args / headers) so an entry named innocuously +#: (``"specs": {url:"https://mcp.linear.app/sse"}``) or reading the token +#: (``${LINEAR_API_TOKEN}``) is caught regardless of its key. +_LINEAR_MCP_MARKERS = ("linear", "mcp.linear.app", "linear_api_token") + + +def strip_linear_mcp_servers(repo_dir: str) -> int: + """Remove any Linear MCP server entry from the repo's ``.mcp.json``. + + ADR-016 ENFORCEMENT (not just convention): Linear is 100% deterministic and + the agent must have NO Linear MCP tools. The prompt says so, but a prompt is + not a security boundary — a repo could COMMIT a ``.mcp.json`` with a + ``linear-server`` entry using ``${LINEAR_API_TOKEN}``; the SDK loads + ``project`` settings + we export ``LINEAR_API_TOKEN``, so under + ``bypassPermissions`` those tools would authenticate and run. This scrubs any + such entry from the on-disk config BEFORE the SDK reads it, on every task + with a repo, regardless of channel_source. Jira's own entry (written by + ``configure_channel_mcp`` for jira tasks) never matches these markers. + + Returns the number of server entries removed (0 when none / no file). + Best-effort: a read/write failure logs and returns 0 (the prompt prohibition + + the absence of any platform-written Linear entry still hold). + """ + if not repo_dir or not os.path.isdir(repo_dir): + return 0 + mcp_path = os.path.join(repo_dir, ".mcp.json") + config = _read_existing_mcp_config(mcp_path) + servers = config.get("mcpServers") + if not isinstance(servers, dict) or not servers: + return 0 + + def _is_linear(key: str, value: object) -> bool: + hay = (key + " " + json.dumps(value, default=str)).lower() + return any(m in hay for m in _LINEAR_MCP_MARKERS) + + offending = [k for k, v in servers.items() if _is_linear(k, v)] + if not offending: + return 0 + + for k in offending: + del servers[k] + config["mcpServers"] = servers + + # If the Linear server(s) were the ONLY content, drop the whole file rather + # than leave an inert ``{"mcpServers": {}}`` behind — a repo that shipped a + # Linear-only .mcp.json ends up with no .mcp.json at all, which is the correct + # end state (the SDK then has nothing to load). Keep the file only when other + # MCP servers OR other top-level keys survive (a legit non-Linear server, or a + # Jira entry the platform just wrote for a jira task). + other_top_level_keys = [k for k in config if k != "mcpServers"] + try: + if not servers and not other_top_level_keys: + os.remove(mcp_path) + removed_file = True + else: + with open(mcp_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + f.write("\n") + removed_file = False + except OSError as e: + log("ERROR", f"strip_linear_mcp_servers: failed to rewrite/remove {mcp_path}: {e}") + return 0 + log( + "WARN", + f"Removed {len(offending)} Linear MCP server entr(ies) from {mcp_path} " + f"(ADR-016: Linear is deterministic; the agent has no Linear MCP). " + f"Keys: {', '.join(offending)}" + + ("; deleted the now-empty .mcp.json" if removed_file else ""), + ) + return len(offending) diff --git a/agent/src/clarification_tool.py b/agent/src/clarification_tool.py new file mode 100644 index 000000000..d69bda31f --- /dev/null +++ b/agent/src/clarification_tool.py @@ -0,0 +1,74 @@ +"""In-process ``request_clarification`` SDK tool (clarify-before-spend, UX #4). + +The customer-caught problem: a vague request like "make it faster" was answered +by GUESSING (shipping a plausible PR and charging for it) instead of asking what +was meant. The fix asks the agent to STOP and pose a question when a request is +too underspecified to implement without guessing. + +An earlier cut used a text sentinel the agent had to reproduce verbatim on its +final line. That proved unreliable live — the model either shipped a guess or +finished silently without emitting the exact string. A **tool call** is a +discrete, deterministic event: the model either invokes ``request_clarification`` +or it doesn't, and the runner sees the ``ToolUseBlock`` in the message stream (no +string-matching, no reproduction). This module defines that tool as an in-process +SDK MCP server; the runner registers it and captures the question, and the +pipeline treats a captured question as a hold-and-ask (no build, no PR). + +Kept tiny and side-effect-free: the tool just acknowledges the call. The +authoritative signal is the runner observing the call + its ``question`` arg. +""" + +from __future__ import annotations + +from typing import Any + +#: The in-process MCP server name. The SDK exposes each tool as +#: ``mcp____``, so the fully-qualified tool name the runner matches +#: on is :data:`CLARIFICATION_TOOL_NAME`. +CLARIFICATION_SERVER_NAME = "abca" +CLARIFICATION_TOOL_NAME = f"mcp__{CLARIFICATION_SERVER_NAME}__request_clarification" + + +def build_clarification_server() -> Any: + """Build the in-process SDK MCP server exposing ``request_clarification``. + + Returns the SDK server config dict for ``ClaudeAgentOptions(mcp_servers=...)``, + or ``None`` if the SDK is unavailable (defensive — the runner then simply + doesn't register it and the marker-based fallback still works). + """ + try: + from claude_agent_sdk import create_sdk_mcp_server, tool + except ImportError: # pragma: no cover - SDK always present in the container + return None + + @tool( + "request_clarification", + ( + "Ask the requester ONE clarifying question and STOP, instead of guessing, " + "when the task is too underspecified to implement without picking among " + "materially different interpretations (e.g. 'make it faster' with no target " + "or slow path named). Calling this opens NO pull request and charges nothing " + "for a guess — the platform surfaces your question to the requester. Only " + "call it for genuinely ambiguous goal-without-substance requests; a task that " + "names what to change is actionable, so just do it." + ), + {"question": str}, + ) + async def request_clarification(args: dict[str, Any]) -> dict[str, Any]: + question = str(args.get("question", "")).strip() + # The tool's own return is only feedback to the agent; the runner captures + # the question from the ToolUseBlock. Tell the agent to stop here. + return { + "content": [ + { + "type": "text", + "text": ( + "Clarifying question recorded and will be posted to the requester. " + "Do not make code changes or open a PR — end your turn now." + + (f" (question: {question})" if question else "") + ), + } + ] + } + + return create_sdk_mcp_server(name=CLARIFICATION_SERVER_NAME, tools=[request_clarification]) diff --git a/agent/src/config.py b/agent/src/config.py index 523f3174d..50d8969ee 100644 --- a/agent/src/config.py +++ b/agent/src/config.py @@ -17,14 +17,28 @@ # id whose ``requires_repo`` is false. Used by the load-failure fallback to # decide repo-optionality without loading the file. REPO_LESS_DEFAULT_WORKFLOW_ID = "default/agent-v1" -# First-party workflow ids that operate on an existing pull request. -PR_WORKFLOW_IDS = frozenset(("coding/pr-iteration-v1", "coding/pr-review-v1")) +# First-party workflow ids that operate on an existing pull request — they +# check out the existing PR branch instead of creating a fresh one. restack-v1 +# (#305) re-merges a changed predecessor into an existing stacked-child PR. +PR_WORKFLOW_IDS = frozenset(("coding/pr-iteration-v1", "coding/pr-review-v1", "coding/restack-v1")) +# Clarify-before-spend (customer UX #4): the exact marker a coding/new-task agent +# puts on the FIRST line of its final message when a request is too ambiguous to +# implement without guessing. Its presence tells the pipeline to hold — post the +# question, open NO PR, and surface it as "needs input" rather than a finished +# task. Kept as an unusual sentinel so it can't collide with ordinary prose. +NEEDS_INPUT_MARKER = "[[ABCA_NEEDS_INPUT]]" # First-party workflow ids that are writeable (NOT read-only). Used only by the # load-failure fallback to bias an unrecognised id toward read-only (fail closed # on the write-deny invariant). pr-review-v1 is intentionally excluded (it is # read-only); default/agent-v1 is excluded because its conservative posture # should fail closed too. -_KNOWN_WRITEABLE_WORKFLOW_IDS = frozenset(("coding/new-task-v1", "coding/pr-iteration-v1")) +_KNOWN_WRITEABLE_WORKFLOW_IDS = frozenset( + ( + "coding/new-task-v1", + "coding/pr-iteration-v1", + "coding/restack-v1", + ) +) def resolve_github_token() -> str: @@ -60,18 +74,17 @@ def resolve_linear_api_token(channel_metadata: dict[str, str] | None = None) -> Pass that dict in via ``channel_metadata`` (the pipeline does this automatically). We fetch the per-workspace secret, parse the token JSON, refresh if expiring, and cache the access_token in - ``LINEAR_API_TOKEN`` so downstream consumers (the Linear MCP's - ``${LINEAR_API_TOKEN}`` placeholder in ``.mcp.json`` and - ``linear_reactions.py``'s GraphQL Authorization header) keep working - unchanged. + ``LINEAR_API_TOKEN`` so the one remaining consumer — + ``linear_reactions.py``'s direct-GraphQL Authorization header (reactions + + state transitions) — keeps working. (ADR-016: there is no Linear MCP; this + token no longer feeds an ``.mcp.json`` placeholder.) For local development, a pre-set ``LINEAR_API_TOKEN`` env var short-circuits the lookup so the agent can run outside the runtime. - Returns an empty string when the credential is absent — the agent-side - MCP config then renders with an unresolved ``${LINEAR_API_TOKEN}`` - placeholder and the Linear MCP fails closed. This function is only - called when ``channel_source == 'linear'``. + Returns an empty string when the credential is absent — ``linear_reactions`` + then skips its reactions/state calls (best-effort, logged). This function is + only called when ``channel_source == 'linear'``. Phase 2.0a (parked) used AgentCore Identity. Phase 2.0b-O2 reads Secrets Manager directly because AgentCore Identity's USER_FEDERATION @@ -105,7 +118,7 @@ def resolve_linear_api_token(channel_metadata: dict[str, str] | None = None) -> from botocore.exceptions import BotoCoreError, ClientError except ImportError as e: log("WARN", f"resolve_linear_api_token: boto3 unavailable ({e}); skipping") - # nosemgrep: py-silent-success-masking -- optional Linear MCP; boto3 unavailable + # nosemgrep: py-silent-success-masking -- optional Linear reactions token; boto3 unavailable return "" sm = boto3.client("secretsmanager", region_name=region) @@ -116,8 +129,8 @@ def _fetch_token() -> dict | None: Returns the parsed dict, or None if the SM payload can't be decoded as JSON (corrupted byte, missing SecretString key, etc.). The caller treats None like a missing secret — agent - proceeds without Linear MCP rather than crashing the task - pipeline thread on a raw traceback. + proceeds without the Linear reactions token rather than crashing + the task pipeline thread on a raw traceback. """ resp = sm.get_secret_value(SecretId=secret_arn) try: @@ -316,7 +329,7 @@ def _refresh(current: dict) -> dict | None: return "" if token_obj is None: # Corrupted secret JSON; already logged inside _fetch_token. - # Fail closed — Linear MCP renders with unresolved placeholder. + # Corrupted secret JSON → no token; linear_reactions skips (best-effort). return "" if _is_expiring(token_obj.get("expires_at", "")): @@ -510,9 +523,13 @@ def build_config( dry_run: bool = False, task_id: str = "", system_prompt_overrides: str = "", + build_command: str = "", + lint_command: str = "", resolved_workflow: dict | None = None, branch_name: str = "", pr_number: str = "", + base_branch: str | None = None, + merge_branches: list[str] | None = None, channel_source: str = "", channel_metadata: dict[str, str] | None = None, trace: bool = False, @@ -533,7 +550,7 @@ def build_config( resolved_github_token = github_token or resolve_github_token() resolved_aws_region = aws_region or os.environ.get("AWS_REGION", "") resolved_anthropic_model = anthropic_model or os.environ.get( - "ANTHROPIC_MODEL", "us.anthropic.claude-sonnet-4-6" + "ANTHROPIC_MODEL", "us.anthropic.claude-opus-4-8" ) # Small/fast auxiliary model (WebFetch summarization etc.). Falls back to the # deployed ANTHROPIC_DEFAULT_HAIKU_MODEL env, then the platform default. Must @@ -623,6 +640,8 @@ def build_config( max_turns=max_turns, max_budget_usd=max_budget_usd, system_prompt_overrides=system_prompt_overrides, + build_command=build_command, + lint_command=lint_command, resolved_workflow=workflow, policy_principal=policy_principal, read_only=workflow_read_only, @@ -631,6 +650,8 @@ def build_config( is_pr_workflow=is_pr_workflow, branch_name=branch_name, pr_number=pr_number, + base_branch=base_branch, + merge_branches=merge_branches or [], task_id=task_id or uuid.uuid4().hex[:12], channel_source=channel_source, channel_metadata=channel_metadata or {}, @@ -653,7 +674,7 @@ def get_config() -> TaskConfig: issue_number=os.environ.get("ISSUE_NUMBER", ""), github_token=os.environ.get("GITHUB_TOKEN", ""), anthropic_model=os.environ.get("ANTHROPIC_MODEL", ""), - max_turns=int(os.environ.get("MAX_TURNS", "100")), + max_turns=int(os.environ.get("MAX_TURNS", "200")), max_budget_usd=float(os.environ.get("MAX_BUDGET_USD", "0")) or None, aws_region=os.environ.get("AWS_REGION", ""), dry_run=os.environ.get("DRY_RUN", "").lower() in ("1", "true", "yes"), diff --git a/agent/src/hooks.py b/agent/src/hooks.py index 19f3bafab..a17f04e82 100644 --- a/agent/src/hooks.py +++ b/agent/src/hooks.py @@ -4,15 +4,15 @@ REQUIRE_APPROVAL). The REQUIRE_APPROVAL path writes a pending approval row + transitions the task to AWAITING_APPROVAL atomically, polls for a human decision, then resumes / denies per the user's input. See - ``docs/design/CEDAR_HITL_GATES.md`` §6.5. + ``docs/design/CEDAR_HITL_GATES.md``. - PostToolUse: output scanner for secrets/PII. - Stop: between-turns hook dispatcher. Cancel → nudge → denial injection - in that order (cancel-wins semantics, finding #2). Each producer - appends synthetic user-message strings that get reinjected via the - SDK's ``decision: "block"`` mechanism. + in that order (cancel-wins semantics). Each producer appends synthetic + user-message strings that get reinjected via the SDK's + ``decision: "block"`` mechanism. -A module-level registry ``between_turns_hooks`` lets phases (Phase 2 -nudges, Phase 3 denial injections) append additional synthetic-message +A module-level registry ``between_turns_hooks`` lets other producers +(nudges, denial injections) append additional synthetic-message producers without touching the Stop hook callback itself. """ @@ -41,31 +41,31 @@ from telemetry import _TrajectoryWriter # --------------------------------------------------------------------------- -# Chunk 3 constants (§6.5 pseudocode) +# Approval-gate constants # --------------------------------------------------------------------------- -FLOOR_30S: int = FLOOR_TIMEOUT_S # §6 decision #6: sourced from contracts/constants.json -CLEANUP_MARGIN_120S: int = 120 # §6.5 lifetime-margin reserve for cleanup -# Poll cadence per §3 decision #3 and IMPL-12: 2s for the first 30s, 5s -# thereafter. Exact counts vary with ``timeout_s``; these pin the -# user-observable "fast for a bit, then slack off" behavior. +FLOOR_30S: int = FLOOR_TIMEOUT_S # minimum approval window; sourced from contracts/constants.json +CLEANUP_MARGIN_120S: int = 120 # lifetime-margin reserve for cleanup +# Poll cadence: 2s for the first 30s, 5s thereafter. Exact counts vary with +# ``timeout_s``; these pin the user-observable "fast for a bit, then slack off" +# behavior. POLL_FAST_INTERVAL_S: float = 2.0 POLL_FAST_DURATION_S: float = 30.0 POLL_SLOW_INTERVAL_S: float = 5.0 -POLL_DEGRADED_FAILS: int = 3 # emit approval_poll_degraded at this count (§13.2) -POLL_MAX_CONSECUTIVE_FAILS: int = 10 # treat as TIMED_OUT at this count (§13.2) -TOOL_INPUT_PREVIEW_MAX: int = 256 # §6.5: strip-ANSI, truncate +POLL_DEGRADED_FAILS: int = 3 # emit approval_poll_degraded at this many consecutive failures +POLL_MAX_CONSECUTIVE_FAILS: int = 10 # treat as TIMED_OUT at this many consecutive failures +TOOL_INPUT_PREVIEW_MAX: int = 256 # cap for the strip-ANSI, truncated input preview ELLIPSIS_LEN: int = 3 # chars reserved for the "..." truncation marker # ANSI CSI / OSC escape sequence stripper for ``tool_input_preview`` + -# ``permissionDecisionReason`` fields (§12.7). Re-derives the pattern from -# the canonical definition; kept local to avoid adding a cross-module -# dependency for one regex. +# ``permissionDecisionReason`` fields, so persisted/logged reasons can't carry +# terminal-escape injection. Kept local to avoid a cross-module dependency for +# one regex. _ANSI_ESCAPE_RE = re.compile(r"\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1B]*(?:\x07|\x1B\\)|[@-Z\\-_])") def _strip_ansi(text: str) -> str: - """Remove ANSI CSI / OSC sequences to prevent terminal injection (§12.7).""" + """Remove ANSI CSI / OSC sequences to prevent terminal injection.""" return _ANSI_ESCAPE_RE.sub("", text) @@ -101,7 +101,7 @@ def _tool_input_preview(tool_input: Any, max_len: int = TOOL_INPUT_PREVIEW_MAX) # --------------------------------------------------------------------------- -# Egress-denial detection (#251, Phase 1) +# Egress-denial detection # --------------------------------------------------------------------------- # # Best-effort, heuristic scan of a tool's output for the signatures a blocked @@ -133,15 +133,15 @@ def _tool_input_preview(tool_input: Any, max_len: int = TOOL_INPUT_PREVIEW_MAX) ) -# #251 carry-path: the most recent agent-detected blocker, as a canonical -# ``BLOCKED[]: …`` reason string (see ``format_blocker_reason``). Blockers -# are detected mid-turn in the hooks (egress in PostToolUse, fail-closed in -# PreToolUse) but the terminal ``error_message`` is assembled later in the -# pipeline. This latch bridges the two: the pipeline promotes it into -# ``TaskResult.error`` ONLY when the task errored without a more specific -# reason, so the CDK classifier surfaces a precise remedy. Process-lifetime -# (== one task) like ``_INJECTED_NUDGES``; last-writer-wins is fine — the most -# recent blocker is the most relevant to a terminal failure. +# The most recent agent-detected blocker, as a canonical ``BLOCKED[]: …`` +# reason string (see ``format_blocker_reason``). Blockers are detected mid-turn +# in the hooks (egress in PostToolUse, fail-closed in PreToolUse) but the +# terminal ``error_message`` is assembled later in the pipeline. This latch +# bridges the two: the pipeline promotes it into ``TaskResult.error`` ONLY when +# the task errored without a more specific reason, so the error classifier +# surfaces a precise remedy. Process-lifetime (== one task) like +# ``_INJECTED_NUDGES``; last-writer-wins is fine — the most recent blocker is +# the most relevant to a terminal failure. _LAST_BLOCKER_REASON: str | None = None @@ -154,7 +154,7 @@ def _record_blocker_reason(kind: str, detail: str, resource: str | None = None) def last_blocker_reason() -> str | None: - """Return the latched canonical blocker reason for terminal promotion (#251).""" + """Return the latched canonical blocker reason for terminal promotion.""" return _LAST_BLOCKER_REASON @@ -173,8 +173,8 @@ def reset_blocker_reason() -> None: _reset_blocker_reason_for_tests = reset_blocker_reason -# ABCA-662: latch of the stuck-guard's "why it's spinning" summary, refreshed by -# the between-turns hook. Read by the pipeline's terminal path so a max_turns +# Latch of the stuck-guard's "why it's spinning" summary, refreshed by the +# between-turns hook. Read by the pipeline's terminal path so a max_turns # failure can say WHY it capped ("Exceeded max turns — spinning on failing tool # calls: git push → invalid credentials") vs. a task that genuinely used its # turns. Process-lifetime (one task), last-writer-wins (the most recent window @@ -195,7 +195,7 @@ def reset_stuck_summary() -> None: def detect_egress_denial(text: str) -> tuple[bool, str | None]: - """Scan tool output for an egress-denial signature (#251). + """Scan tool output for an egress-denial signature. Returns ``(detected, host_or_None)``: ``(True, host)`` on the first matching signature (``host`` populated when a pattern captured one so the emitted @@ -222,7 +222,7 @@ def _deny_response(reason: str) -> dict: Guaranteed surface: ``permissionDecisionReason`` is ANSI-stripped and truncated to 500 chars so it can never carry terminal-escape injection - or overflow a log line (§6.5, §12.7). + or overflow a log line. """ return { "hookSpecificOutput": { @@ -244,6 +244,124 @@ def _allow_response(reason: str = "permitted") -> dict: } +# Workspace-integrity guard. The repo is ALREADY cloned and checked out at the +# agent's cwd (``repo_dir``); the platform tracks that dir for +# commit/branch/PR/push. In practice the agent sometimes ran its OWN +# ``gh repo clone``/``git clone`` of the SAME repo into a sibling dir +# (``/workspace/``) and did all its work there, leaving repo_dir empty +# → the platform saw no commits (delivery gate: ``deliverable=lost``) and, for a +# stacked child, the successor had no branch to stack on. The industry norm is a +# filesystem sandbox that makes a divergent clone impossible; lacking that (raw +# Bash + bypassPermissions + open GitHub egress), a harness-enforced deny of the +# re-clone is the closest robust analog — and Claude Code hook denies fire even +# under bypassPermissions and survive prompt-injection. We match ONLY a re-clone +# of the TASK's OWN repo (not legitimate dependency/submodule clones), keyed off +# the ``owner/repo`` slug in any of the URL forms gh/git accept. +# +# The clone verb must appear as an actual COMMAND — at the start of the string or +# right after a shell separator (``&&``, ``||``, ``|``, ``;``, newline, ``(``) — +# NOT as text buried inside a quoted argument. Without that anchoring, a +# ``gh pr create --body "…do not run gh repo clone…"`` was wrongly blocked because +# the phrase appeared inside the PR body. Anchoring to command position (and, in +# ``_is_self_reclone``, truncating at the first argument that carries free text) +# removes that false positive while still catching every real re-clone. +_CLONE_CMD_RE = re.compile( + r"(?:^|[;&|\n(]|&&|\|\|)\s*(?:gh\s+repo\s+clone|git\s+(?:-C\s+\S+\s+)?clone)\b", + re.IGNORECASE, +) + +# Arguments after which the rest of the command is free-form TEXT (a PR/issue/ +# commit body or title), where a mention of "gh repo clone" is prose, not a +# command. Used to ignore text that merely QUOTES a clone command. +# +# NOTE ``-b`` is deliberately here (``gh pr create -b`` is ``--body``) AND is +# ``git clone``'s own ``--branch`` short form. So this list can only be applied +# to the text FOLLOWING a clone verb, never to the text preceding it: truncating +# the command at the first match before looking for the verb let +# ``git clone -b main `` through entirely, because the truncation cut +# the command off ahead of the verb it was meant to find. +# Shell separators that start a fresh command. Splitting on these means a +# free-text argument can only swallow the rest of ITS OWN segment, so a clone +# chained after an unrelated ``-m`` is still seen. +# +# A bare newline is deliberately NOT a separator here. A multi-line ``--body`` / +# ``-m`` value is the normal way an agent writes a PR or commit body, and its +# quoted text routinely contains a clone command as documentation. Splitting on +# ``\n`` put that quoted line in its own segment with no preceding free-text +# flag, so the guard blocked a legitimate ``gh pr create`` — the exact +# false-positive the free-text list exists to prevent. +# A backslash at end-of-line continues the SAME command onto the next line. +_LINE_CONTINUATION_RE = re.compile(r"\\[ \t]*\r?\n[ \t]*") + +_SHELL_SEPARATOR_RE = re.compile(r"(?:&&|\|\||[;&|])") + +_FREE_TEXT_ARG_RE = re.compile( + r"(?:^|\s)(?:-m|--message|--body|--body-file|-b|--title|-t|-F|--field|--raw-field)\b", + re.IGNORECASE, +) + + +def _repo_slug_variants(repo_url: str) -> list[str]: + """Return the ``owner/repo`` slug forms a clone command could name. + + ``config.repo_url`` is the canonical ``owner/repo``. A clone command may + reference it as ``owner/repo``, ``https://github.com/owner/repo(.git)``, or + ``git@github.com:owner/repo(.git)`` — all normalize to the same slug, so we + just need the bare ``owner/repo`` (with and without ``.git``) to substring- + match against the command text after normalization.""" + slug = (repo_url or "").strip().removesuffix(".git").strip("/").lower() + if not slug: + return [] + return [slug, f"{slug}.git"] + + +def _is_self_reclone(command: str, repo_url: str) -> bool: + """True when *command* clones the task's OWN repo (any URL form). + + Deliberately narrow on two axes so legitimate commands pass: + * the clone verb must be in COMMAND position (start / after a shell + separator), not inside a quoted argument — see ``_CLONE_CMD_RE``; and + * the scan stops at the first free-text argument (``--body``/``-m``/…), so a + PR/commit body that merely QUOTES "gh repo clone" is not treated as a + clone; and + * a clone of a DIFFERENT repo (dependency, fixture, example) is allowed — + only a re-clone of the TASK repo threatens the workspace invariant.""" + if not command or not repo_url: + return False + # Check each shell segment on its own. A free-text argument only swallows the + # REST OF ITS OWN segment, so `git commit -m wip && gh repo clone ` + # must still be caught: the `-m` belongs to the commit, not to the clone. + # + # Within a segment, find the clone verb FIRST and only then ask whether it + # sits inside free text. Truncating at the first free-text flag *before* + # searching is what let `git clone -b main ` through: `-b` is both + # gh's `--body` and git clone's `--branch`, so the cut landed ahead of the + # very verb it was meant to find. + variants = _repo_slug_variants(repo_url) + if not variants: + return False + # Join backslash-continuations first: ``git clone \`` + newline + url is ONE + # command, and leaving the break in place separated the verb from the repo so + # the slug was never found in the verb's segment — a wrapped self re-clone + # walked straight through. + normalized = _LINE_CONTINUATION_RE.sub(" ", command) + for segment in _SHELL_SEPARATOR_RE.split(normalized): + clone = _CLONE_CMD_RE.search(segment) + if clone is None: + continue + free_text = _FREE_TEXT_ARG_RE.search(segment) + if free_text is not None and free_text.start() < clone.start(): + # The verb appears inside a --body/-m/--title value: prose, not a run. + continue + # The repo of a real clone always follows its verb. Normalize the URL + # punctuation to the bare slug space so `.../owner/repo.git` and + # `git@github.com:owner/repo` both reduce to text carrying `owner/repo`. + haystack = segment[clone.start() :].lower().replace("git@github.com:", "github.com/") + if any(v in haystack for v in variants): + return True + return False + + async def pre_tool_use_hook( hook_input: Any, tool_use_id: str | None, @@ -255,27 +373,27 @@ async def pre_tool_use_hook( user_id: str | None = None, progress: Any = None, task_state_module: Any = None, + repo_url: str | None = None, ) -> dict: - """PreToolUse hook: three-outcome Cedar policy enforcement (§6.5). + """PreToolUse hook: three-outcome Cedar policy enforcement. Returns a dict with hookSpecificOutput containing: - permissionDecision: "allow" or "deny" - permissionDecisionReason: explanation string - The REQUIRE_APPROVAL path (Chunk 3, §6.5) pauses here: writes a - pending approval row + transitions the task to AWAITING_APPROVAL - atomically, polls for a human decision with 2s→5s backoff, then - returns allow / deny based on the decision. On TIMED_OUT a - ConditionCheckFailed from the best-effort status write triggers the - IMPL-24 re-read — if the user's decision landed between our poll - and write, we honor it instead of falsely denying. - - ``task_id`` / ``user_id`` / ``progress`` are optional to preserve - the Phase 1 test call shape. Without them the REQUIRE_APPROVAL path - falls through to fail-closed DENY (state-write infrastructure is - missing), so legacy callers still see coherent behaviour. - ``task_state_module`` is a test seam for injecting a mocked - ``task_state`` namespace; production callers rely on the default. + The REQUIRE_APPROVAL path pauses here: writes a pending approval row + + transitions the task to AWAITING_APPROVAL atomically, polls for a + human decision with 2s→5s backoff, then returns allow / deny based on + the decision. On TIMED_OUT a ConditionCheckFailed from the best-effort + status write triggers a re-read — if the user's decision landed between + our poll and write, we honor it instead of falsely denying. + + ``task_id`` / ``user_id`` / ``progress`` are optional to preserve the + older test call shape. Without them the REQUIRE_APPROVAL path falls + through to fail-closed DENY (state-write infrastructure is missing), so + legacy callers still see coherent behaviour. ``task_state_module`` is a + test seam for injecting a mocked ``task_state`` namespace; production + callers rely on the default. """ ts_module = task_state_module if task_state_module is not None else task_state @@ -303,13 +421,34 @@ async def pre_tool_use_hook( log("WARN", f"PreToolUse hook received non-dict tool_input — denying {tool_name}") return _deny_response("tool input is not an object") + # Block a Bash re-clone of the task's OWN repo before Cedar eval. The repo is + # already checked out at the agent's cwd; a self-reclone into a sibling dir + # strands the work off the platform-tracked branch (the delivery gate then + # fails it as ``deliverable=lost``). Deny it here with a redirect so the agent + # works in place instead. Scoped to the task repo only (dependency/fixture + # clones pass). Fires even under bypassPermissions. + if tool_name == "Bash" and repo_url: + command = tool_input.get("command", "") + if isinstance(command, str) and _is_self_reclone(command, repo_url): + reason = ( + f"The repository '{repo_url}' is ALREADY cloned and checked out in " + "your working directory — do NOT clone it again or work in another " + "directory. Your commits must land in the current working directory " + "so the platform can open the pull request on the correct branch. " + "Remove the clone command and work in place (edit, commit, and push " + "from where you already are)." + ) + log("POLICY", f"DENIED self-reclone of {repo_url}: {command[:160]}") + if trajectory: + trajectory.write_policy_decision("Bash", False, "self_reclone_blocked", 0) + return _deny_response(reason) + decision = engine.evaluate_tool_use(tool_name, tool_input) # Telemetry: ALLOW "permitted" is the quiet happy path; everything else # is worth a trajectory event. Treat REQUIRE_APPROVAL as "not allowed" - # for the legacy ``allowed=False`` field so the Phase 2 trajectory - # schema stays coherent — the specific outcome is already on the - # ``reason`` string. + # for the legacy ``allowed=False`` field so the trajectory schema stays + # coherent — the specific outcome is already on the ``reason`` string. if trajectory and decision.reason != "permitted": trajectory.write_policy_decision( tool_name, decision.allowed, decision.reason, decision.duration_ms @@ -319,27 +458,26 @@ async def pre_tool_use_hook( return _allow_response(decision.reason or "permitted") if decision.outcome == Outcome.DENY: - # IMPL-23: when the DENY arrived from the recent-decision cache - # (evaluate_tool_use Step 2.5), emit a ``policy_decision`` - # milestone with ``decision_source="recent_decision_cache"`` to - # TaskEventsTable so cache-driven denies are visible in the live - # stream + 90d audit record (§12.8). No new approval row is - # written and the gate counter is NOT bumped — the original - # gate already accounted for the decision. + # When the DENY arrived from the recent-decision cache, emit a + # ``policy_decision`` milestone with + # ``decision_source="recent_decision_cache"`` to TaskEventsTable so + # cache-driven denies are visible in the live stream + retained audit + # record. No new approval row is written and the gate counter is NOT + # bumped — the original gate already accounted for the decision. if progress is not None and decision.cache_hit_metadata is not None: _try_progress( progress, "write_policy_decision_cached", **decision.cache_hit_metadata, ) - # #251 (decision E): a fail-closed deny means the Cedar engine errored - # or was unavailable — an ENVIRONMENTAL fault (misconfiguration), NOT - # an intentional hard-deny. Emit a ``policy_fail_closed`` blocker event - # so it renders distinctly and carries a remediation hint. We branch on - # the structured ``decision.fail_closed`` flag, never a reason-string - # prefix. Intentional hard-denies / cache-denies (fail_closed=False) - # never emit. Emitted strictly BEFORE the deny; the deny itself is - # unchanged, so this adds observability without altering the outcome. + # A fail-closed deny means the Cedar engine errored or was unavailable — + # an ENVIRONMENTAL fault (misconfiguration), NOT an intentional hard-deny. + # Emit a ``policy_fail_closed`` blocker event so it renders distinctly and + # carries a remediation hint. We branch on the structured + # ``decision.fail_closed`` flag, never a reason-string prefix. Intentional + # hard-denies / cache-denies (fail_closed=False) never emit. Emitted + # strictly BEFORE the deny; the deny itself is unchanged, so this adds + # observability without altering the outcome. if getattr(decision, "fail_closed", False): _record_blocker_reason("policy_fail_closed", decision.reason) if progress is not None: @@ -359,7 +497,7 @@ async def pre_tool_use_hook( log("POLICY", f"DENIED: {tool_name} — {decision.reason}") return _deny_response(decision.reason) - # -- REQUIRE_APPROVAL path (§6.5) --------------------------------------- + # -- REQUIRE_APPROVAL path ---------------------------------------------- return await _handle_require_approval( decision=decision, tool_name=tool_name, @@ -385,19 +523,18 @@ async def _handle_require_approval( ) -> dict: """REQUIRE_APPROVAL branch of ``pre_tool_use_hook``. - Split out of the main hook for readability — the control-flow in - §6.5 is long enough that inlining it obscures the top-level three-way - branch. + Split out of the main hook for readability — the control flow is long + enough that inlining it obscures the top-level three-way branch. """ - # Missing task infrastructure → fail closed with a clear reason. This - # lines up with §13.15: every exceptional branch ends in DENY. + # Missing task infrastructure → fail closed with a clear reason. Every + # exceptional branch here ends in DENY. if not task_id: log("WARN", "REQUIRE_APPROVAL hit without task_id — fail-closed deny") return _deny_response("approval system unavailable (no task_id)") request_id = _generate_ulid() - # Step 1 — per-task cap. §12.9: cap exceeded fails closed and the + # Step 1 — per-task cap. Cap exceeded fails closed, and the # cap_exceeded milestone carries the configured cap so dashboards # reflect the blueprint override. if engine.approval_gate_count >= engine.approval_gate_cap: @@ -425,9 +562,9 @@ async def _handle_require_approval( ) return _deny_response(f"approval-gate rate limit exceeded ({APPROVAL_RATE_LIMIT}/min)") - # Step 3 — effective timeout with floor/ceiling math (§6.5). Emit + # Step 3 — effective timeout with floor/ceiling math. Emit # ``approval_timeout_capped`` when the caller's ask is clipped so the - # user can see why (IMPL-26). + # user can see why. remaining_lifetime = _remaining_maxlifetime_s() effective_timeout, clip_reason, requested_timeout = _compute_effective_timeout( decision_timeout_s=decision.timeout_s, @@ -446,9 +583,9 @@ async def _handle_require_approval( list(decision.matching_rule_ids) if clip_reason == "rule_annotation" else None ), ) - # IMPL-26: once per task, surface the "gates will have small windows - # from here on" ceiling-shrinking milestone when the remaining - # lifetime is approaching the 2x-task-default threshold. + # Once per task, surface the "gates will have small windows from here + # on" ceiling-shrinking milestone when the remaining lifetime is + # approaching the 2x-task-default threshold. if ( remaining_lifetime is not None and remaining_lifetime - CLEANUP_MARGIN_120S < 2 * engine.task_default_timeout_s @@ -464,15 +601,15 @@ async def _handle_require_approval( task_default_timeout_s=engine.task_default_timeout_s, ) - # Step 4 — insufficient lifetime remaining for a valid approval - # (§13.7). Below the floor we DENY immediately without writing the - # approval row; no point waking the user to a guaranteed-dead gate. + # Step 4 — insufficient lifetime remaining for a valid approval. + # Below the floor we DENY immediately without writing the approval + # row; no point waking the user to a guaranteed-dead gate. if remaining_lifetime is not None and remaining_lifetime - CLEANUP_MARGIN_120S < FLOOR_30S: return _deny_response( f"insufficient maxLifetime remaining ({remaining_lifetime}s) for approval" ) - # Step 5 — build the approval row per §10.1 schema. + # Step 5 — build the approval row. tool_input_sha256 = _sha256_tool_input_for_row(tool_input) row = { "task_id": task_id, @@ -499,14 +636,13 @@ async def _handle_require_approval( engine.increment_approval_gate_count() engine.record_approval_gate_timestamp() - # Chunk 7 (§13.6): best-effort atomic increment of the persisted - # ``approval_gate_count`` on TaskTable. The session counter - # enforces the cap within THIS container; the persisted counter - # exists so a restarted container re-seeds from a non-zero value - # instead of re-exposing the user to another ``approval_gate_cap`` - # worth of gates. Failure is best-effort per §13.6 — "counter is - # a safety bound, not a correctness bound" — so we keep going on - # error and accept the (bounded) restart-retry amplification. + # Best-effort atomic increment of the persisted ``approval_gate_count`` + # on TaskTable. The session counter enforces the cap within THIS + # container; the persisted counter exists so a restarted container + # re-seeds from a non-zero value instead of re-exposing the user to + # another ``approval_gate_cap`` worth of gates. Failure is best-effort — + # the counter is a safety bound, not a correctness bound — so we keep + # going on error and accept the (bounded) restart-retry amplification. if task_id: await asyncio.to_thread(ts.increment_approval_gate_count_in_ddb, task_id) @@ -569,9 +705,8 @@ async def _handle_require_approval( ts=ts, ) - # Step 10 — IMPL-24 VM-throttle + late-approval race. Best-effort - # flip to TIMED_OUT; if ConditionCheckFailed, the user beat us — read - # and honor. + # Step 10 — VM-throttle + late-approval race. Best-effort flip to + # TIMED_OUT; if ConditionCheckFailed, the user beat us — read and honor. if outcome["status"] == "TIMED_OUT": try: wrote = await asyncio.to_thread( @@ -583,14 +718,14 @@ async def _handle_require_approval( ) except Exception as exc: log("WARN", f"approval TIMED_OUT write raised: {type(exc).__name__}: {exc}") - # Fall into the IMPL-24 re-read path. A transient DDB write - # error MUST NOT bypass the late-approval check — the user's - # APPROVED decision may already be on the row, and skipping - # the re-read would falsely deny their tool call. Setting - # ``wrote = False`` triggers the ConsistentRead below; if - # the row still says PENDING the re-read is a no-op and we - # keep the TIMED_OUT outcome. If it says APPROVED/DENIED, - # ``_reconcile_late_decision`` honors the user's choice. + # Fall into the re-read path. A transient DDB write error MUST + # NOT bypass the late-approval check — the user's APPROVED + # decision may already be on the row, and skipping the re-read + # would falsely deny their tool call. Setting ``wrote = False`` + # triggers the ConsistentRead below; if the row still says + # PENDING the re-read is a no-op and we keep the TIMED_OUT + # outcome. If it says APPROVED/DENIED, ``_reconcile_late_decision`` + # honors the user's choice. wrote = False if not wrote: # User's decision beat our timer; re-read with ConsistentRead. @@ -649,7 +784,7 @@ async def _handle_require_approval( request_id=request_id, scope=scope, decided_at=outcome.get("decided_at"), - # Chunk 8a: propagate the row's ``created_at`` so the + # Propagate the row's ``created_at`` so the # ApprovalMetricsPublisher can compute decision latency. created_at=row.get("created_at"), ) @@ -657,12 +792,12 @@ async def _handle_require_approval( # DENIED or TIMED_OUT — cache + queue injection. cache_decision = "DENIED" if status == "DENIED" else "TIMED_OUT" - # IMPL-23: thread the user's ``decided_at`` into the cache entry so - # subsequent cache-hit events surface the ORIGINAL decision timestamp, - # not the wall-clock time the cache was populated (which is ~the same - # but technically wrong). ``decided_at`` for TIMED_OUT is the - # agent-side clock moment the timeout fired; for DENIED it's the - # user's deny timestamp from the Lambda audit row. + # Thread the user's ``decided_at`` into the cache entry so subsequent + # cache-hit events surface the ORIGINAL decision timestamp, not the + # wall-clock time the cache was populated (which is ~the same but + # technically wrong). ``decided_at`` for TIMED_OUT is the agent-side + # clock moment the timeout fired; for DENIED it's the user's deny + # timestamp from the Lambda audit row. engine.recent_decisions.record( tool_name, tool_input_sha256, @@ -671,12 +806,11 @@ async def _handle_require_approval( original_decision_ts=outcome.get("decided_at"), ) - # Rule-level cache (§12.8 extension): on DENIED, record an entry - # per matching_rule_id so semantic retries — same rule, different - # input — get fast-denied without a new approval round-trip. Only - # populate on DENIED because TIMED_OUT is ambiguous (user was - # away, not actively refusing); TIMED_OUT cache entries stay - # input-hash-scoped. + # Rule-level cache: on DENIED, record an entry per matching_rule_id + # so semantic retries — same rule, different input — get fast-denied + # without a new approval round-trip. Only populate on DENIED because + # TIMED_OUT is ambiguous (user was away, not actively refusing); + # TIMED_OUT cache entries stay input-hash-scoped. if status == "DENIED": for rule_id in decision.matching_rule_ids: engine.recent_decisions.record_rule_decision( @@ -700,7 +834,7 @@ async def _handle_require_approval( request_id=request_id, reason=outcome.get("reason", ""), decided_at=outcome.get("decided_at"), - # Chunk 8a: propagate the row's ``created_at`` so the + # Propagate the row's ``created_at`` so the # ApprovalMetricsPublisher can compute decision latency. created_at=row.get("created_at"), ) @@ -710,24 +844,24 @@ async def _handle_require_approval( "write_approval_timed_out", request_id=request_id, timeout_s=effective_timeout, - # Chunk 8a: propagate the row's ``created_at`` + - # ``matching_rule_ids`` + the post-clip effective timeout - # so the ApprovalMetricsPublisher can emit the decision - # latency + the ``ApprovalTimeoutBreakdown`` histogram - # with a normalized ``rule_id`` dimension. + # Propagate the row's ``created_at`` + ``matching_rule_ids`` + + # the post-clip effective timeout so the + # ApprovalMetricsPublisher can emit the decision latency + the + # ``ApprovalTimeoutBreakdown`` histogram with a normalized + # ``rule_id`` dimension. created_at=row.get("created_at"), effective_timeout_s=effective_timeout, matching_rule_ids=list(decision.matching_rule_ids), ) - # Guaranteed surface (§6.5): truncated reason even when denial - # injection is pre-empted by a concurrent cancel. Wrap the user's - # reason in authoritative stop-language — E2E Phase 4 observed - # the agent treating bare "User denied" as "try a different - # approach" and burning through max_turns retrying the same rule - # with trivial variations. The explicit AUTHORITATIVE-prefixed - # wording, combined with the rule-level recent-deny cache (§12.8), - # makes retries fail fast with clear feedback. + # Guaranteed surface: truncated reason even when denial injection is + # pre-empted by a concurrent cancel. Wrap the user's reason in + # authoritative stop-language — end-to-end testing showed the agent + # treating a bare "User denied" as "try a different approach" and + # burning through max_turns retrying the same rule with trivial + # variations. The explicit AUTHORITATIVE-prefixed wording, combined + # with the rule-level recent-deny cache, makes retries fail fast with + # clear feedback. raw_reason = outcome.get("reason") or f"User {status.lower() if status else 'denied'}" if status == "DENIED": rule_hint = ( @@ -754,12 +888,12 @@ def _reconcile_late_decision( progress: Any, request_id: str, ) -> dict: - """IMPL-24: rebuild outcome from a re-read row after a TIMED_OUT race. + """Rebuild outcome from a re-read row after a TIMED_OUT race. - ``row["status"] == "APPROVED"`` → rebuild as APPROVED (allow flow). - ``row["status"] == "DENIED"`` → rebuild as DENIED (deny flow). - Anything else (row gone, still PENDING) → fall through with the - original TIMED_OUT (§13.12 fail-closed branch). + original TIMED_OUT (the fail-closed branch). Emits ``approval_late_win`` for APPROVED or DENIED races so operator telemetry can count them. @@ -810,10 +944,10 @@ async def _poll_for_decision( """Poll the approval row until terminal or timeout. Cadence: ``POLL_FAST_INTERVAL_S`` for ``POLL_FAST_DURATION_S``, then - ``POLL_SLOW_INTERVAL_S`` (IMPL-12). Each iteration uses - ConsistentRead; after ``POLL_DEGRADED_FAILS`` consecutive failures we - emit ``approval_poll_degraded``; at ``POLL_MAX_CONSECUTIVE_FAILS`` we - fall through as TIMED_OUT with a distinct reason (§13.2). + ``POLL_SLOW_INTERVAL_S``. Each iteration uses ConsistentRead; after + ``POLL_DEGRADED_FAILS`` consecutive failures we emit + ``approval_poll_degraded``; at ``POLL_MAX_CONSECUTIVE_FAILS`` we + fall through as TIMED_OUT with a distinct reason. Returns an outcome dict mirroring the approval row's terminal fields. """ @@ -890,7 +1024,7 @@ def _compute_effective_timeout( task_default_timeout_s: int, remaining_lifetime_s: int | None, ) -> tuple[int, str | None, int]: - """Compute the effective timeout per §6.5. + """Compute the effective approval timeout. ``min(rule-annotation timeout, task default, remaining lifetime - cleanup margin)``, floored at FLOOR_30S. The engine's @@ -947,8 +1081,8 @@ def _remaining_maxlifetime_s() -> int | None: (ISO 8601, optional). Returns ``None`` if the start timestamp is unavailable; the hook treats this as "unknown, don't clip" so the gate still fires with the task default (fail-open on the optional - signal rather than pre-DENY when unknown). A future Chunk wires - these from the task launch path; for now they are optional hints. + signal rather than pre-DENY when unknown). These are wired from the + task launch path when available; otherwise they are optional hints. """ try: max_lifetime = int(os.environ.get("AGENTCORE_MAX_LIFETIME_S", "28800")) @@ -983,7 +1117,8 @@ def _sha256_tool_input_for_row(tool_input: Any) -> str: Re-derives hashing here (rather than importing ``policy._sha256_tool_input``) so the hook's failure-mode is independent of the engine's internals and to keep import graphs shallow. The engine's own cache uses the same - algorithm; §6.5 row + ``RecentDecisionCache`` need the same key shape. + algorithm; the approval row and ``RecentDecisionCache`` need the same + key shape. """ import hashlib @@ -1001,7 +1136,7 @@ def _iso_now() -> str: return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) -# S10: per-method consecutive-failure counters for ``_try_progress``. +# Per-method consecutive-failure counters for ``_try_progress``. # Progress writes are best-effort, but a hard infrastructure failure # (DDB throttle, IAM regression, missing table) would otherwise be # masked as a stream of WARN lines with no escalation. After @@ -1020,12 +1155,12 @@ def _try_progress(progress: Any, method_name: str, /, **kwargs: Any) -> None: Progress is best-effort observability; a throttled DDB write must not break the approval flow. ``_emit_nudge_milestone`` uses a similar - pattern for the Phase 2 nudges. + pattern for nudges. - S10 escalation: repeated consecutive failures of the same - ``method_name`` are tracked per-process and escalated to ERROR - after ``_TRY_PROGRESS_ESCALATE_AFTER`` so a silent live-stream - outage becomes operator-visible. + Escalation: repeated consecutive failures of the same ``method_name`` + are tracked per-process and escalated to ERROR after + ``_TRY_PROGRESS_ESCALATE_AFTER`` so a silent live-stream outage + becomes operator-visible. """ if getattr(progress, "_disabled", False) is True: log("WARN", f"progress {method_name!r} skipped: circuit breaker open") @@ -1060,8 +1195,8 @@ async def post_tool_use_hook( hook_context: Any, *, trajectory: _TrajectoryWriter | None = None, - progress: Any = None, stuck_guard: StuckGuard | None = None, + progress: Any = None, ) -> dict: """PostToolUse hook: screen tool output for secrets/PII. @@ -1070,14 +1205,14 @@ async def post_tool_use_hook( redacted version (steered enforcement — content is sanitized, not blocked). - ``progress`` is optional (preserves the Phase 1 test call shape). When + ``progress`` is optional (preserves the older test call shape). When present, an egress-denial signature in the tool output emits an - ``egress_denied`` blocker event (#251) — best-effort observability, - never alters the screening decision. + ``egress_denied`` blocker event — best-effort observability, never + alters the screening decision. - K7: when a ``stuck_guard`` is supplied, every tool result is recorded so a - between-turns hook can detect a repeating failing command (the ABCA-483 - spin loop) and steer / bail. Recording is best-effort and never alters the + When a ``stuck_guard`` is supplied, every tool result is recorded so a + between-turns hook can detect a repeating failing command (a spin + loop) and steer / bail. Recording is best-effort and never alters the screening outcome. """ _PASS_THROUGH: dict = {"hookSpecificOutput": {"hookEventName": "PostToolUse"}} @@ -1104,7 +1239,7 @@ async def post_tool_use_hook( if not isinstance(tool_response, str): tool_response = str(tool_response) - # #251: best-effort egress-denial detection. A blocked outbound connection + # Best-effort egress-denial detection. A blocked outbound connection # (non-allowlisted host hitting the DNS Firewall, refused connection, name # resolution failure) surfaces in the tool's stderr here. Emit an # ``egress_denied`` blocker naming the host to allowlist. Observability @@ -1141,7 +1276,7 @@ async def post_tool_use_hook( resource=host, ) - # K7: feed the stuck-guard (best-effort — a tracking error must never block + # Feed the stuck-guard (best-effort — a tracking error must never block # the screening path that follows). if stuck_guard is not None: try: @@ -1176,7 +1311,7 @@ async def post_tool_use_hook( # --------------------------------------------------------------------------- -# Between-turns hook registry (Phase 2 nudges, extensible for Phase 3) +# Between-turns hook registry (nudges, extensible for more producers) # --------------------------------------------------------------------------- # A hook takes a context dict (currently ``{"task_id": str}``) and returns a @@ -1255,10 +1390,10 @@ def _nudge_between_turns_hook(ctx: dict) -> list[str]: ``mark_consumed`` repeatedly fails. Emits a ``nudge_acknowledged`` ``agent_milestone`` event **before** - returning the injected user-message list (combined-turn ack, see - ``INTERACTIVE_AGENTS.md`` §AD-5) so the durable event stream records - the ack in the same turn the nudge is consumed. Emission is - best-effort: if the progress writer's circuit breaker has tripped + returning the injected user-message list (combined-turn ack) so the + durable event stream records the ack in the same turn the nudge is + consumed. Emission is best-effort: if the progress writer's circuit + breaker has tripped (repeated DDB write failures) or no ``progress`` ref is stamped on ``ctx``, the ack is logged but skipped and the injection still proceeds — better to steer the agent than block on a flaky event @@ -1273,8 +1408,8 @@ def _nudge_between_turns_hook(ctx: dict) -> list[str]: # break in :func:`stop_hook` which short-circuits the dispatcher as soon as # any earlier hook sets ``_cancel_requested``. That assumes # ``_cancel_between_turns_hook`` runs BEFORE this hook — true for the - # module-level ``between_turns_hooks`` registry today (line 340), but a - # future reorder (or a test that rebinds the list without preserving + # module-level ``between_turns_hooks`` registry today, but a future + # reorder (or a test that rebinds the list without preserving # order) would silently reintroduce the bug: ``read_pending`` + # ``mark_consumed`` would flip the DDB rows to consumed and stamp # ``_INJECTED_NUDGES`` for a dying agent that will never see the text. @@ -1331,7 +1466,7 @@ def _nudge_between_turns_hook(ctx: dict) -> list[str]: details = f"{count} nudge(s) acknowledged (ids=…{ids}): {first_msg}" + ( "…" if count > 1 or len(first_msg) == _NUDGE_PREVIEW_LEN else "" ) - # AD-5: emit the ack BEFORE returning the injection list. + # Emit the ack BEFORE returning the injection list. _emit_nudge_milestone(ctx, "nudge_acknowledged", details) return [formatted] if formatted else [] @@ -1341,16 +1476,15 @@ def _denial_between_turns_hook(ctx: dict) -> list[str]: """Drain queued denial injections into ```` XML blocks. Registered AFTER ``_cancel_between_turns_hook`` and - ``_nudge_between_turns_hook`` (cancel-wins semantics, finding #2). - If cancel flagged this turn we early-return — denial injection is - explicitly best-effort on cancelled tasks, and the guaranteed - surface is the ``permissionDecisionReason`` that - ``pre_tool_use_hook`` already returned on the deny response - (§6.5 line 922). + ``_nudge_between_turns_hook`` (cancel-wins semantics). If cancel + flagged this turn we early-return — denial injection is explicitly + best-effort on cancelled tasks, and the guaranteed surface is the + ``permissionDecisionReason`` that ``pre_tool_use_hook`` already + returned on the deny response. ``ctx["engine"]`` is expected when the pipeline wires the approval - engine through; absent it this hook is a no-op (Phase 1 call sites - that don't thread an engine ref through still work). + engine through; absent it this hook is a no-op (call sites that don't + thread an engine ref through still work). """ if ctx.get("_cancel_requested"): return [] @@ -1385,8 +1519,8 @@ def _denial_between_turns_hook(ctx: dict) -> list[str]: log("POLICY", f"Injecting {count} denial(s) for task {ctx.get('task_id', '')}") # Emit a single ``user_denial_injected`` milestone per Stop seam so # the durable event stream records the ack. Mirrors - # ``nudge_acknowledged`` (§AD-5); this is an additive milestone not - # in the §11.1 enumerated list, so keep the name distinct from the + # ``nudge_acknowledged``; this is an additive milestone not in the + # enumerated milestone list, so keep the name distinct from the # enumerated ``approval_*`` prefix. _emit_nudge_milestone( ctx, @@ -1432,7 +1566,7 @@ def _cancel_between_turns_hook(ctx: dict) -> list[str]: def _stuck_guard_between_turns_hook(ctx: dict) -> list[str]: - """K7: nudge the agent when it repeats the SAME failing command (ABCA-483). + """Nudge the agent when it repeats the SAME failing command. Reads the per-task :class:`StuckGuard` stamped on ``ctx`` (by :func:`stop_hook`). When the same command has failed with identical output @@ -1457,10 +1591,10 @@ def _stuck_guard_between_turns_hook(ctx: dict) -> list[str]: return [] try: action = guard.evaluate() - # ABCA-662: refresh the "why it's spinning" latch every turn. When the - # trailing window is failure-dominated this returns a one-liner; otherwise - # None (which clears the latch — a task that recovered isn't "stuck"). Read - # by the terminal path so a later max_turns cap explains itself. + # Refresh the "why it's spinning" latch every turn. When the trailing + # window is failure-dominated this returns a one-liner; otherwise None + # (which clears the latch — a task that recovered isn't "stuck"). Read by + # the terminal path so a later max_turns cap explains itself. global _LAST_STUCK_SUMMARY _LAST_STUCK_SUMMARY = guard.recent_failure_summary() except Exception as exc: @@ -1481,19 +1615,19 @@ def _stuck_guard_between_turns_hook(ctx: dict) -> list[str]: # dispatcher breaks out of the loop as soon as ``_cancel_requested`` is set, # and :func:`_nudge_between_turns_hook` early-returns when the flag is already # present — belt-and-braces in case a future ``append`` reorders this list. -# Phase 3 (approval gates) should ``append`` additional hooks AFTER the +# Additional producers (e.g. approval gates) should ``append`` hooks AFTER the # nudge reader to preserve cancel-wins semantics. between_turns_hooks: list[BetweenTurnsHook] = [ _cancel_between_turns_hook, - # K7 stuck-guard (advisory): injects a one-time "stop retrying X" nudge when + # Stuck-guard (advisory): injects a one-time "stop retrying X" nudge when # the same command keeps failing identically. Runs after cancel (never steer # a dying agent); order vs nudge/denial is cosmetic since it never bails. _stuck_guard_between_turns_hook, _nudge_between_turns_hook, - # Chunk 3 (finding #2): denial injection runs LAST so both cancel and - # nudge short-circuits pre-empt it. The hook explicitly re-checks - # ``_cancel_requested`` so re-ordering doesn't silently break cancel - # semantics (belt-and-braces, matching ``_nudge_between_turns_hook``). + # Denial injection runs LAST so both cancel and nudge short-circuits + # pre-empt it. The hook explicitly re-checks ``_cancel_requested`` so + # re-ordering doesn't silently break cancel semantics (belt-and-braces, + # matching ``_nudge_between_turns_hook``). _denial_between_turns_hook, ] @@ -1523,7 +1657,8 @@ async def stop_hook( so hooks can emit their own milestone / progress events without holding a module-global reference to it. ``engine`` is threaded through for ``_denial_between_turns_hook`` which needs to drain queued denials; - absent it the denial hook is a no-op (Phase 1 / Phase 2 call paths). + absent it the denial hook is a no-op (call paths that don't wire an + engine through). """ ctx = { "task_id": task_id, @@ -1541,7 +1676,7 @@ async def stop_hook( # returned ``continue_=False`` below. Breaking out of the loop as soon # as any hook sets ``_cancel_requested`` guarantees subsequent hooks # (notably the nudge reader) never run, so DDB state is never mutated - # for work the agent will never do. The registry at line 340 keeps + # for work the agent will never do. The registry keeps # ``_cancel_between_turns_hook`` first so this break fires before the # nudge hook gets a chance. ``_nudge_between_turns_hook`` also carries # an internal cancel-check as belt-and-braces in case a future refactor @@ -1588,6 +1723,7 @@ def build_hook_matchers( task_id: str = "", progress: Any = None, user_id: str = "", + repo_url: str = "", ) -> dict: """Build hook matchers dict for ClaudeAgentOptions. @@ -1600,7 +1736,7 @@ def build_hook_matchers( ``progress`` is forwarded to both the PreToolUse hook (approval gate milestones) and the Stop hook (nudge/denial acks). ``user_id`` is written onto the approval row so ownership checks on the REST side - can enforce §12.2 (user can only approve their own gates). + can enforce that a user can only approve their own gates. """ from claude_agent_sdk.types import ( HookContext, @@ -1611,7 +1747,7 @@ def build_hook_matchers( SyncHookJSONOutput, ) - # K7: one stuck-guard per task (== per build_hook_matchers call). The + # One stuck-guard per task (== per build_hook_matchers call). The # PostToolUse closure feeds it every tool result; the Stop closure reads it # between turns to steer / bail on a repeating failing command. _stuck_guard = StuckGuard() @@ -1638,6 +1774,7 @@ async def _pre( task_id=task_id or None, user_id=user_id or None, progress=progress, + repo_url=repo_url or None, ) except Exception as exc: log( diff --git a/agent/src/linear_reactions.py b/agent/src/linear_reactions.py index 95a3074b2..1a8a4b6a8 100644 --- a/agent/src/linear_reactions.py +++ b/agent/src/linear_reactions.py @@ -14,13 +14,14 @@ errors are logged and swallowed — a transient Linear API failure must never fail the task itself (reactions are advisory UX, not load-bearing). -Why a direct GraphQL call instead of MCP: Linear's MCP v1 does not expose -a reactions tool (confirmed 2026-05-06). Once an MCP ``create_reaction`` -tool ships, this module should be retired in favour of a prompt addendum -that has the agent call it directly. - -See: ``agent/src/channel_mcp.py`` for the parallel MCP gate, and -``~/.claude/plans/linear-mcp-findings.md`` for the locked spec. +Why a direct GraphQL call: under ADR-016 Linear is 100% deterministic — +the agent has NO Linear tools and never calls Linear itself. Reactions +and state transitions are posted by the platform on its own credential +(here, on the agent tier; the Lambda tier owns the rest). This is the +permanent design, not a stopgap: there is no future in which the agent +drives Linear through an MCP tool, so this module is not "awaiting" one. + +See: ``agent/src/channel_mcp.py`` for why there is no Linear MCP. """ from __future__ import annotations @@ -35,7 +36,9 @@ from shell import log -#: Linear GraphQL endpoint. The same auth flow the MCP server uses. +#: Linear GraphQL endpoint. Authenticated with the per-workspace +#: ``actor=app`` OAuth token (``LINEAR_API_TOKEN``, set by config.py from the +#: workspace's OAuth bundle) sent as the ``Authorization`` header. LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql" #: Request timeout — reactions are fire-and-forget status UX; never block @@ -83,6 +86,41 @@ query Viewer { viewer { id } } """.strip() +#: Workflow-state mirroring: fetch the issue's current state + its team's full +#: workflow-state list, +#: so we can pick the right target state (by type, with a name preference) and +#: never move the issue BACKWARD along the lifecycle. +_ISSUE_STATES_QUERY = """ +query IssueStates($id: String!) { + issue(id: $id) { + state { id name type position } + team { states(first: 50) { nodes { id name type position } } } + } +} +""".strip() + +#: Set an issue's workflow state by id. +_SET_STATE_MUTATION = """ +mutation SetIssueState($id: String!, $stateId: String!) { + issueUpdate(id: $id, input: { stateId: $stateId }) { success } +} +""".strip() + +#: Lifecycle rank by Linear state TYPE (backlog → unstarted → started → +#: completed/canceled). A move to a strictly higher rank — or a higher +#: position within the same type — is FORWARD; anything else is a no-op so a +#: human who already advanced/closed the issue is never demoted. Mirrors the +#: platform's ``transitionIssueState`` guard so the single-task and +#: orchestration paths agree. +_STATE_TYPE_RANK = { + "backlog": 0, + "triage": 0, + "unstarted": 1, + "started": 2, + "completed": 3, + "canceled": 3, +} + #: Reactions we own and want to clear before a fresh run. _BGAGENT_EMOJIS = frozenset({EMOJI_STARTED, EMOJI_SUCCESS, EMOJI_FAILURE}) @@ -216,6 +254,62 @@ def _get_viewer_id() -> str | None: return None +def _transition_issue_state(issue_id: str, target_type: str, preferred_names: list[str]) -> None: + """Move the issue to a workflow state of ``target_type``, forward-only. + + A plain single task (a direct ``abca`` label) previously left the issue in + Backlog for its whole run — only orchestration parents got a state change (via the + epic panel). This is the missing single-task equivalent: on start move to + 'started' (preferring "In Progress"), on clean finish to 'started' (preferring + "In Review") — the same targets the orchestration panel uses. + + Forward-only via ``_STATE_TYPE_RANK`` (+ position within a type): never demote + an issue a human already advanced or closed. Best-effort — every failure is a + logged no-op (state mirroring is advisory UX, like the reaction). + """ + data = _graphql(_ISSUE_STATES_QUERY, {"id": issue_id}) + if not data: + return + issue = data.get("issue") or {} + states = ((issue.get("team") or {}).get("states") or {}).get("nodes") or [] + if not states: + log("WARN", f"linear_reactions: no team states for issue {issue_id}; skipping transition") + return + + of_type = [s for s in states if s.get("type") == target_type] + if not of_type: + log("DEBUG", f"linear_reactions: no '{target_type}' state on the team; skipping transition") + return + target = None + lowered = [n.lower() for n in preferred_names] + for name in lowered: + target = next((s for s in of_type if (s.get("name") or "").lower() == name), None) + if target: + break + if target is None: + target = sorted(of_type, key=lambda s: s.get("position", 0))[0] + + current = issue.get("state") or {} + if current.get("id") == target.get("id"): + return # already there — idempotent no-op + cur_rank = _STATE_TYPE_RANK.get(current.get("type", ""), 0) + tgt_rank = _STATE_TYPE_RANK.get(target.get("type", ""), 0) + backward = cur_rank > tgt_rank or ( + cur_rank == tgt_rank and current.get("position", 0) >= target.get("position", 0) + ) + if backward: + log( + "TASK", + f"linear_reactions: skipping backward state move " + f"{current.get('name')!r} → {target.get('name')!r}", + ) + return + + result = _graphql(_SET_STATE_MUTATION, {"id": issue_id, "stateId": target["id"]}) + if result is not None: + log("TASK", f"linear_reactions: issue {issue_id} → {target.get('name')!r}") + + def _sweep_stale_reactions_safe(issue_id: str, exclude_id: str | None = None) -> None: """Top-level wrapper for the sweep daemon thread. @@ -300,6 +394,7 @@ def _sweep_stale_reactions(issue_id: str, exclude_id: str | None = None) -> None def react_task_started( channel_source: str, channel_metadata: dict[str, str] | None, + transition_state: bool = False, ) -> str | None: """Post 👀 on the Linear issue. Return the reaction id (or None on failure/no-op). @@ -352,6 +447,13 @@ def react_task_started( name="linear-reactions-sweep", ).start() + # Workflow-state transition: a writeable single task moves the issue + # Backlog → In Progress, so it + # doesn't sit in Backlog for the whole run. Off for read-only/planning tasks + # (pr-review) — the orchestration panel owns the parent state. + if transition_state: + _transition_issue_state(issue_id, "started", ["In Progress"]) + log( "TASK", f"linear_reactions: react_task_started EXIT (sweep dispatched) " @@ -365,6 +467,7 @@ def react_task_finished( channel_metadata: dict[str, str] | None, success: bool, started_reaction_id: str | None = None, + transition_state: bool = False, ) -> None: """Delete the 👀 (if we have its id) and post ✅/❌ as a replacement.""" issue_id = _enabled(channel_source, channel_metadata) @@ -376,3 +479,11 @@ def react_task_finished( _CREATE_MUTATION, {"issueId": issue_id, "emoji": EMOJI_SUCCESS if success else EMOJI_FAILURE}, ) + # Workflow-state transition: on a clean finish, a writeable single task + # moves the issue to + # In Review (work done, awaiting human merge) — the same target the + # orchestration panel uses on clean completion. On failure, leave the + # state as-is (In Progress); the ❌ reaction conveys the outcome, and the + # user can reply to retry. Off for read-only/planning tasks. + if transition_state and success: + _transition_issue_state(issue_id, "started", ["In Review"]) diff --git a/agent/src/models.py b/agent/src/models.py index d7687c4e7..0a3c4d0c3 100644 --- a/agent/src/models.py +++ b/agent/src/models.py @@ -143,17 +143,18 @@ def version_supported(self) -> Self: class TaskConfig(BaseModel): model_config = ConfigDict(validate_assignment=True) - # repo_url / github_token default to "" so a repo-less TaskConfig (#248 - # Phase 3) is constructible. The _validate_requires_repo_has_repo validator - # below enforces that a repo-BOUND config (requires_repo=True, the default) - # still carries a repo_url — so dropping the field-level requirement does not - # weaken the coding-path invariant. + # repo_url / github_token default to "" so a repo-less TaskConfig (a + # knowledge workflow with no repo) is constructible. The + # _validate_requires_repo_has_repo validator below enforces that a repo-BOUND + # config (requires_repo=True, the default) still carries a repo_url — so + # dropping the field-level requirement does not weaken the coding-path + # invariant. repo_url: str = "" issue_number: str = "" task_description: str = "" github_token: str = "" aws_region: str - anthropic_model: str = "us.anthropic.claude-sonnet-4-6" + anthropic_model: str = "us.anthropic.claude-opus-4-8" # The "small/fast" model Claude Code uses for auxiliary work (e.g. WebFetch # page summarization). Must be a cross-region INFERENCE-PROFILE id (``us.`` # prefix), not a bare foundation-model id — Claude 4.x cannot be invoked @@ -163,23 +164,30 @@ class TaskConfig(BaseModel): max_turns: int = 10 max_budget_usd: float | None = None system_prompt_overrides: str = "" + # Per-repo build/lint verification commands. When set (from the blueprint, + # via the payload), the agent runs these instead of the hardcoded + # ``mise run build`` / ``mise run lint`` to gate build/lint regressions. + # Empty → default to mise. Set for non-mise repos (e.g. ``npm run build``) so + # gating actually runs the repo's real command. + build_command: str = "" + lint_command: str = "" # The pinned workflow this task runs ({"id", "version"}), resolved at the - # create-task boundary and threaded through the payload (#248). None on - # local/batch runs, where the pipeline defaults to coding/new-task-v1. + # create-task boundary and threaded through the payload. None on local/batch + # runs, where the pipeline defaults to coding/new-task-v1. resolved_workflow: dict | None = None # The Cedar principal identity derived from the resolved workflow # (id→legacy map, else "new_task"). The Agent::TaskAgent::"" principal - # scheme is unchanged; since #248 Phase 2a, read-only enforcement no longer - # keys off this principal — it keys off ``read_only`` below. + # scheme is unchanged; read-only enforcement no longer keys off this + # principal — it keys off ``read_only`` below. policy_principal: str = "new_task" # Whether the resolved workflow is read-only (may not mutate the working # tree). Threaded into the Cedar request ``context.read_only`` so the - # hard-deny Write/Edit rules fire for *any* read-only workflow (#248 - # Phase 2a), and drives the runner's allowed_tools tightening. + # hard-deny Write/Edit rules fire for *any* read-only workflow, and drives the + # runner's allowed_tools tightening. read_only: bool = False # The SDK tool surface for this task, from the resolved workflow's - # ``agent_config.allowed_tools`` (#248). This is the second enforcement layer - # the design promises alongside ``read_only``: ``run_agent`` passes it to + # ``agent_config.allowed_tools``. This is the second enforcement layer + # alongside ``read_only``: ``run_agent`` passes it to # ``ClaudeAgentOptions.allowed_tools`` verbatim, and drops ``Write``/``Edit`` # when ``read_only`` is true. Empty list means "fall back to the built-in # full surface" so legacy/batch callers that never resolved a workflow keep @@ -187,10 +195,9 @@ class TaskConfig(BaseModel): # non-empty list (every shipped workflow does). allowed_tools: list[str] = Field(default_factory=list) # Whether the resolved workflow requires a repo. False for repo-less - # knowledge workflows (#248 Phase 3): the pipeline skips clone/build/PR and - # drives the agent + deliver_artifact steps through the workflow runner. - # Defaults True so coding tasks (and any caller that omits it) keep the - # repo-bound path. + # knowledge workflows: the pipeline skips clone/build/PR and drives the agent + # + deliver_artifact steps through the workflow runner. Defaults True so + # coding tasks (and any caller that omits it) keep the repo-bound path. requires_repo: bool = True # True when the resolved workflow operates on an existing PR (pr_* coding # workflows) — gates the "resume existing branch / resolve PR" behavior that @@ -207,42 +214,47 @@ class TaskConfig(BaseModel): channel_metadata: dict[str, str] = Field(default_factory=dict) # Platform user_id (Cognito ``sub``) threaded from the orchestrator # payload. Required ONLY when ``trace`` is true — the agent writes - # the trajectory dump to ``traces//.jsonl.gz`` - # (design §10.1), and the ``get-trace-url`` handler's per-caller- - # prefix guard refuses to presign keys outside the caller's own - # ``traces//`` prefix. Empty-string default for local - # batch runs (no orchestrator in the loop; no trace upload). + # the trajectory dump to ``traces//.jsonl.gz``, + # and the ``get-trace-url`` handler's per-caller-prefix guard refuses + # to presign keys outside the caller's own ``traces//`` + # prefix. Empty-string default for local batch runs (no orchestrator + # in the loop; no trace upload). user_id: str = "" - # Opt-in debug preview cap (design §10.1). Threaded to BOTH the - # pipeline.py milestone writer AND the runner.py turn/tool writer — - # the runner's writer is where thinking/tool_input/tool_result - # previews live, so dropping ``trace`` here silently no-ops the - # feature for the fields that matter. + # Opt-in debug trajectory capture. Threaded to BOTH the pipeline.py + # milestone writer AND the runner.py turn/tool writer — the runner's + # writer is where thinking/tool_input/tool_result previews live, so + # dropping ``trace`` here silently no-ops the feature for the fields + # that matter. trace: bool = False # Enriched mid-flight by pipeline.py: cedar_policies: list[str] = [] - # Cedar HITL (§7.3, §10.2). Per-task approval defaults threaded + # Cedar human-in-the-loop approvals. Per-task approval defaults threaded # from the orchestrator payload; consumed by PolicyEngine at # construction so the engine seeds ApprovalAllowlist and adopts # the per-task timeout default. approval_timeout_s: int | None = None initial_approvals: list[str] = [] - # Chunk 7: TaskTable-persisted ``approval_gate_count`` seeded into - # the session counter so container restarts (§13.6) resume the - # cumulative gate budget without resetting to 0. Threaded from the - # orchestrator payload; zero default preserves legacy callers. + # TaskTable-persisted ``approval_gate_count`` seeded into the session + # counter so container restarts resume the cumulative gate budget + # without resetting to 0. Threaded from the orchestrator payload; zero + # default preserves legacy callers. initial_approval_gate_count: int = 0 - # Chunk 7b (§4 step 5, decision #13): per-task approval-gate cap - # resolved at task submit-time from ``Blueprint.security.approvalGateCap`` - # (or the platform default of 50). Persisted on the TaskRecord so - # it survives container restarts and mid-task blueprint edits do - # not shift the cap beneath a running task. ``None`` when the - # orchestrator payload did not include the field (legacy tasks); - # PolicyEngine falls back to its own default of 50 in that case. + # Per-task approval-gate cap resolved at task submit-time from + # ``Blueprint.security.approvalGateCap`` (or the platform default of 50). + # Persisted on the TaskRecord so it survives container restarts and + # mid-task blueprint edits do not shift the cap beneath a running task. + # ``None`` when the orchestrator payload did not include the field + # (legacy tasks); PolicyEngine falls back to its own default of 50 in + # that case. approval_gate_cap: int | None = None issue: GitHubIssue | None = None base_branch: str | None = None - # Attachments from the orchestrator payload (Phase 3). Validated as + # Predecessor branches to merge into this child's branch before work, + # for a diamond child (2+ predecessors) that branches off the default + # branch but must see all predecessors' code. Empty for root + linear + # children (linear children stack via ``base_branch`` instead). + merge_branches: list[str] = Field(default_factory=list) + # Attachments from the orchestrator payload. Validated as # AttachmentConfig models. Empty list for tasks without attachments. attachments: list[AttachmentConfig] = Field(default_factory=list) @@ -251,8 +263,8 @@ def _validate_trace_requires_user_id(self) -> Self: """Fail at construction when trace=True without a user_id. The trace trajectory is uploaded to - ``traces//.jsonl.gz`` (design §10.1). An empty - ``user_id`` produces ``traces//.jsonl.gz``, which the + ``traces//.jsonl.gz``. An empty ``user_id`` + produces ``traces//.jsonl.gz``, which the ``get-trace-url`` handler's per-caller-prefix guard refuses. Catching this at construction time surfaces the misconfiguration locally / in CI instead of deferring to runtime S3 upload. @@ -270,7 +282,7 @@ def _validate_trace_requires_user_id(self) -> Self: @model_validator(mode="after") def _validate_requires_repo_has_repo(self) -> Self: - """Fail at construction when a repo-bound config has no repo (#248 Phase 3). + """Fail at construction when a repo-bound config has no repo. ``requires_repo`` defaults True, so a config that requires a repo but carries an empty ``repo_url`` is an illegal state the repo-bound pipeline @@ -299,6 +311,30 @@ class RepoSetup(BaseModel): build_before: bool = True lint_before: bool = True default_branch: str = "main" + # True when the build verification command is INERT — it could not run + # at all (no build task / command not found) AND no explicit build_command + # was configured. In that state build-regression gating is effectively OFF + # (a change that breaks the build still reports success), so the agent + # surfaces a one-time warning on the PR. Distinct from a genuinely red build + # (command ran, exited non-zero), which IS meaningful gating signal. + build_gate_inert: bool = False + # Same notion for lint. True when the lint verification command is INERT + # — could not run at all (no lint task / command not found) AND no explicit + # lint_command was configured. In that state lint verification is meaningless + # (the default ``mise run lint`` fails for "no such task", not a real lint + # error), so lint_passed is treated as inert rather than a genuine FAIL. + # Mirrors build_gate_inert. Lint never gates the task verdict regardless + # (only a workflow declaring a gating verify_lint step opts in), so this + # affects reporting + the persisted lint_passed signal, not pass/fail gating. + lint_gate_inert: bool = False + # The branch HEAD sha captured right after checkout, BEFORE the agent runs. + # On a PR-iteration the post-hooks compare the final HEAD to this to decide + # whether the iteration actually committed anything — a question-only comment + # ("where is the login page?") makes no commit, and the platform must report + # "answered / no change" rather than a misleading "✅ Updated — PR #N". Empty + # when the sha couldn't be read (treated as "unknown" → defaults to the + # change-made path, the safe-for-back-compat side). + head_sha_before: str = "" class TokenUsage(BaseModel): @@ -322,24 +358,31 @@ class AgentResult(BaseModel): usage: TokenUsage | None = None # The agent's final result text (ResultMessage.result on success). For a # repo-less knowledge task this IS the deliverable that deliver_artifact - # uploads/posts (#248 Phase 3). Empty for coding tasks (their product is the - # PR, not the text). + # uploads/posts. Empty for coding tasks (their product is the PR, not the + # text). result_text: str = "" + # Clarify-before-spend: the question text captured when the agent + # called the ``request_clarification`` tool instead of doing the work. A + # non-empty value is the deterministic hold-and-ask signal — the pipeline + # skips build/PR and surfaces this question to the requester (no charge for a + # guess). Empty when the agent proceeded normally. Preferred over the older + # NEEDS_INPUT_MARKER text sentinel (a tool call can't be mis-reproduced). + clarification_question: str = "" class TaskResult(BaseModel): status: str agent_status: str = "unknown" pr_url: str | None = None - # Tri-state (#515): True/False once the post-run gate runs; None when it did - # not (repo-less workflow has no build/lint; a crash before post-hooks). The + # Tri-state: True/False once the post-run gate runs; None when it did not + # (repo-less workflow has no build/lint; a crash before post-hooks). The # None case is persisted as "absent" by write_terminal's `is not None` guard, # so the replay bundle reports verification:null rather than a fictional # build_passed:false for a gate that never executed. build_passed: bool | None = None lint_passed: bool | None = None cost_usd: float | None = None - # Rev-5 DATA-1: historically the `turns` field was set to the SDK's + # Historically the `turns` field was set to the SDK's # `ResultMessage.num_turns`, which INCLUDES the attempted turn that # tripped a cap (so `max_turns=6` yields `turns=7` under # `agent_status='error_max_turns'`). That confused operators. We @@ -369,13 +412,31 @@ class TaskResult(BaseModel): # the task did not run with ``--trace`` / the upload was skipped or # failed. Threaded into ``task_state.write_terminal`` so the # TaskRecord's ``trace_s3_uri`` field is set atomically with the - # terminal-status transition (design §10.1). + # terminal-status transition. trace_s3_uri: str | None = None - # S3 URI of a repo-less workflow's delivered artifact (deliver_artifact, #248 - # Phase 3), or ``None`` for coding tasks / when no artifact was delivered. + # S3 URI of a repo-less workflow's delivered artifact (deliver_artifact), + # or ``None`` for coding tasks / when no artifact was delivered. # Surfaced on TaskDetail so the user can retrieve the knowledge-task output. artifact_uri: str | None = None + # True when this run advanced the PR branch HEAD (a real commit landed), + # False when it ran but the branch is unchanged (a question-only iteration), + # None when not a PR-iteration / unknown (no baseline sha). The Linear/Slack + # settle reply reads this: False → "💬 answered, no change", True/None → the + # existing "✅ Updated — PR #N". None defaults to the change-made side for + # back-compat with pre-fix tasks. + code_changed: bool | None = None + # The agent's final answer text, surfaced verbatim on a no-change iteration + # reply so a question gets an actual answer (not an empty "✅ Updated"). + # Distinct from result_text's repo-less-artifact role; populated only for + # the no-op-iteration reply path. Empty otherwise. + answer_text: str = "" + # The branch HEAD sha AFTER this run pushed (PR workflows). The screenshot + # webhook matches a deploy's commit sha → the iteration task that pushed it, + # so the preview thumbnail lands on the RIGHT iteration's reply when two + # iterations on one PR overlap (else "newest task" mis-attributes it). Empty + # when unknown (rev-parse failed / non-PR run) → webhook falls back to newest. + head_sha: str = "" # OTEL trace id (32-char hex) of the task's root span, captured at terminal - # write so the replay bundle (#515) can correlate the task to its - # CloudWatch/X-Ray trace. ``None`` when tracing is unavailable (local/dev). + # write so the replay bundle can correlate the task to its CloudWatch/X-Ray + # trace. ``None`` when tracing is unavailable (local/dev). otel_trace_id: str | None = None diff --git a/agent/src/pipeline.py b/agent/src/pipeline.py index 78b2972ac..32bf80e80 100644 --- a/agent/src/pipeline.py +++ b/agent/src/pipeline.py @@ -6,6 +6,7 @@ import hashlib import inspect import os +import subprocess import sys import time from typing import TYPE_CHECKING @@ -14,9 +15,10 @@ import memory as agent_memory import task_state -from channel_mcp import configure_channel_mcp +from channel_mcp import configure_channel_mcp, strip_linear_mcp_servers from config import ( AGENT_WORKSPACE, + NEEDS_INPUT_MARKER, build_config, clear_jira_task_credentials, get_config, @@ -36,6 +38,7 @@ _extract_agent_notes, ensure_committed, ensure_pr, + reconcile_agent_branch, verify_build, verify_lint, ) @@ -109,9 +112,9 @@ def _maybe_upload_trace( both the happy path (post-hooks complete) and the crash path (top-level ``except``) so a crashing task still produces a debuggable artifact — which is exactly when ``--trace`` is most - useful (K2 review Finding #1). + useful. - Gates (K2 Stage 3 review Finding #1): + Gates: - ``config.trace`` must be true. - ``config.user_id`` must be non-empty, else we would write to ``traces//.jsonl.gz`` — an unreachable key that no @@ -161,6 +164,48 @@ def _maybe_upload_trace( return trace_s3_uri +def _deliver_plan_artifact( + workflow, + config, + hydrated, + progress, + trajectory, + setup, + prompt: str, + agent_result, +) -> str | None: + """Deliver a repo-ful artifact workflow's result as the task artifact. + + Such a workflow is one whose primary terminal outcome is an ARTIFACT — a + document — rather than a PR. It clones the repo for context but produces no code + change, so the build/PR post-hooks do not apply. This uploads the agent's final + result text via the SAME ``deliver_artifact`` uploader web-research uses + (``artifacts/{task_id}/``), returning the ``s3://`` URI. Raises on delivery + failure — delivery is the terminal side effect, so a failure must surface as a + FAILED task (caught by the pipeline's outer handler), not a silent success. + """ + from workflow import StepContext + from workflow.deliverers import deliver as deliver_artifact + + deliver_ctx = StepContext( + workflow=workflow, + config=config, + hydrated=hydrated, + progress=progress, + trajectory=trajectory, + setup=setup, + system_prompt="", + user_prompt=prompt, + ) + deliver_ctx.agent_result = agent_result + result = deliver_artifact("s3", deliver_ctx) + artifact_uri = result.artifact_uri + log("POST", f"artifact delivered: {artifact_uri}") + if artifact_uri: + progress.write_agent_milestone("artifact_delivered", artifact_uri) + return artifact_uri + + def _execute_agent_step( prompt: str, system_prompt: str, @@ -461,20 +506,66 @@ def _apply_post_hook_gates( return gates_ok +def _starts_with_needs_input_marker(result_text: str | None) -> bool: + """True when the agent's final message opens with the clarify-and-hold marker. + + Clarify-before-spend (UX #4): the new_task workflow tells the agent to put + :data:`NEEDS_INPUT_MARKER` on the FIRST line of its final message when it + needs to ask instead of guess. We match the FIRST non-empty line only (a + marker buried mid-answer is not a hold signal — it prevents a stray mention + of the token in prose from tripping the hold). + """ + if not result_text: + return False + for line in result_text.splitlines(): + stripped = line.strip() + if not stripped: + continue + return stripped.startswith(NEEDS_INPUT_MARKER) + return False + + +def _strip_needs_input_marker(result_text: str) -> str: + """Remove the leading NEEDS_INPUT_MARKER line/token so the reviewer sees only + the clarifying question, never our internal sentinel.""" + text = result_text.strip() + if text.startswith(NEEDS_INPUT_MARKER): + text = text[len(NEEDS_INPUT_MARKER) :] + return text.strip() + + def _resolve_overall_task_status( agent_result: AgentResult, *, build_ok: bool, pr_url: str | None, + build_timed_out: bool = False, + build_infra_failed: bool = False, ) -> tuple[str, str | None]: - """Map agent outcome + build gate to (overall_status, error_for_task_result).""" + """Map agent outcome + build gate to (overall_status, error_for_task_result). + + ``build_timed_out`` distinguishes a build-gate failure that was actually a + TIMEOUT (the verify command exceeded its wall-clock ceiling and was killed) + from a genuine red build. When the agent itself finished cleanly but the + build gate failed ONLY because it timed out, the error_message carries a + ``build_ok=timeout`` marker so the platform surfaces "build timed out" + rather than the misleading "build/tests failed". + + ``build_infra_failed`` marks a build KILLED by an environment fault (out of + disk / OOM) — we could not VERIFY the code on this host. This forces an error + verdict EVEN IF the regression-only gate would otherwise pass (a build that + was also infra-killed BEFORE the agent looks "already red → not a regression", + which would wrongly report ✅ success on unverified code — an observed false + ✅). The ``build_ok=infra`` marker makes the platform surface a retryable + infrastructure fault, not "build/tests failed" or a bogus success. + """ agent_status = agent_result.status err = agent_result.error - # ABCA-662: a max_turns cap is a CORRECT classification, but on its own it - # doesn't say WHETHER the task genuinely needed the turns or SPUN on a failing - # operation until it ran out (662 thrashed on a failing `git push` → invalid - # credentials, retried every which way, and capped). When the stuck-guard's + # A max_turns cap is a CORRECT classification, but on its own it doesn't say + # WHETHER the task genuinely needed the turns or SPUN on a failing operation + # until it ran out (one observed run thrashed on a failing `git push` → + # invalid credentials, retried every which way, and capped). When the stuck-guard's # trailing window was failure-dominated, append its one-line summary so the # reason distinguishes "ran long" from "looped on an error" — the classifier # still buckets it as max_turns, but a human sees the real cause. Only enriches @@ -486,6 +577,13 @@ def _resolve_overall_task_status( if stuck and stuck not in err: err = f"{err} — {stuck}" + # Infra-killed build (ENOSPC/OOM) → we have NO valid build verdict. Surface a + # retryable infra fault regardless of the regression gate, so it neither reads + # as a false ✅ (regression-only saw red-before+red-after) nor as "your build + # failed". Checked before the success short-circuit for exactly that reason. + if build_infra_failed and agent_status in ("success", "end_turn"): + return "error", (f"Task did not succeed (agent_status={agent_status!r}, build_ok=infra)") + if agent_status in ("success", "end_turn") and build_ok: return "success", err @@ -519,12 +617,112 @@ def _resolve_overall_task_status( return "error", merged if not err: + # #251: a latched blocker (e.g. egress_denied naming a host) is the more + # specific, authoritative terminal reason — prefer it over the generic + # build-gate copy so the classifier attaches the precise remedy. if blocker: return "error", blocker - err = f"Task did not succeed (agent_status={agent_status!r}, build_ok={build_ok})" + # The agent finished cleanly but the build gate failed. If that failure + # was a TIMEOUT, mark it distinctly (``build_ok=timeout``) so the + # platform's failure copy reads "timed out", not "build/tests failed". + build_marker = "timeout" if build_timed_out else build_ok + err = f"Task did not succeed (agent_status={agent_status!r}, build_ok={build_marker})" return "error", err +def _branch_has_new_commits(repo_dir: str, default_branch: str) -> bool: + """True if HEAD carries commits beyond ``origin/``. + + The same ``origin/..HEAD`` diff ``ensure_pr`` consults to + decide whether there is anything to open a PR from (see + ``post_hooks._ensure_pr``). Used by the delivery gate to tell + "a commit landed but the PR failed to open" (recoverable) from "no commit + ever reached the branch — the work was lost". For a stacked child of an + orchestrated issue graph (#247) ``default_branch`` IS its predecessor + branch (``config.base_branch``), so + this asks the right question: did this child add anything on top of its + base? Best-effort — a git failure returns ``False`` (assume nothing landed, + the conservative read for a gate whose job is to catch a lost deliverable).""" + try: + res = subprocess.run( + ["git", "log", f"origin/{default_branch}..HEAD", "--oneline"], + cwd=repo_dir, + check=False, + capture_output=True, + text=True, + timeout=60, + ) + except (OSError, subprocess.SubprocessError): + return False + return res.returncode == 0 and bool(res.stdout.strip()) + + +def _apply_delivery_gate( + overall_status: str, + result_error: str | None, + *, + workflow_read_only: bool, + artifact_workflow: bool, + needs_input: bool, + ensure_pr_strategy: str, + pr_url: str | None, + commit_landed: bool, +) -> tuple[str, str | None]: + """Fail a new-work coding task that reported success but shipped nothing + (no PR AND no commit on its branch) — the deliverable was lost. + + The invariant: a task that reports success but ships nothing must FAIL, or + the platform reports success for work that no longer exists anywhere. + + Observed cause: a stacked child of an orchestrated issue graph (#247) edited + its files in a NESTED working tree + (persistent-storage residue left the clone one level deep inside repo_dir), + so every pipeline git op ran against the outer clean tree — + ``ensure_committed`` saw nothing, ``ensure_pr`` found no commits and skipped — + yet the task reported COMPLETED/build_passed. That silent success poisoned the + integration: the child's feature never reached the branch its successor + stacked on. This gate turns that into a loud, retryable FAILED. + + Mirrors the repo-LESS artifact gate (``run_task`` arm 2): a sanctioned no-op + is EXPECTED to ship nothing, so this fires ONLY for create-strategy new work — + * read-only (pr_review) / artifact workflows ship no PR by design; + * push_resolve / resolve (pr_iteration) legitimately add no NEW PR, and a + question-only iteration is handled via the ``code_changed=False`` + "answered" path — none MUST open a PR; + * clarify-and-hold (needs_input) is the one intentional new_task no-op, and + the caller forces it to success right after this gate. + + ``commit_landed`` (from :func:`_branch_has_new_commits`) distinguishes + "a commit reached the branch but the PR failed to open" (``deliverable=no_pr`` + — recoverable, the work is safe on the branch) from "no commit ever landed" + (``deliverable=lost`` — the work is gone) so the failure copy is honest. + Returns the (possibly unchanged) ``(overall_status, result_error)``. + """ + delivery_expected = ( + not workflow_read_only + and not artifact_workflow + and not needs_input + and ensure_pr_strategy == "create" + ) + if not (overall_status == "success" and delivery_expected and not pr_url): + return overall_status, result_error + if commit_landed: + reason = ( + "Task did not succeed (agent_status=success, deliverable=no_pr): a " + "commit reached the branch but no PR was opened — the change is on the " + "branch but was not delivered." + ) + else: + reason = ( + "Task did not succeed (agent_status=success, deliverable=lost): the " + "coding task reported success but no commit reached the branch and no " + "PR was opened — the agent's changes did not land in the task's " + "repository." + ) + log("WARN", f"Delivery gate: {reason}") + return "error", reason + + def _compute_turns_completed( agent_status: str, turns_attempted: int | None, @@ -532,7 +730,7 @@ def _compute_turns_completed( ) -> int | None: """Clamp ``turns_completed`` to ``max_turns`` when the SDK hit the limit. - Rev-5 DATA-1 — the Claude Agent SDK reports ``num_turns = max_turns + 1`` + The Claude Agent SDK reports ``num_turns = max_turns + 1`` on ``error_max_turns`` because the aborted attempt is counted. Clamping at the final write keeps ``turns_completed`` truthful ("how many turns actually executed") while ``turns_attempted`` keeps the raw SDK value @@ -611,11 +809,15 @@ def run_task( task_id: str = "", hydrated_context: dict | None = None, system_prompt_overrides: str = "", + build_command: str = "", + lint_command: str = "", prompt_version: str = "", memory_id: str = "", resolved_workflow: dict | None = None, branch_name: str = "", pr_number: str = "", + base_branch: str | None = None, + merge_branches: list[str] | None = None, cedar_policies: list[str] | None = None, approval_timeout_s: int | None = None, initial_approvals: list[str] | None = None, @@ -658,9 +860,13 @@ def run_task( aws_region=aws_region, task_id=task_id, system_prompt_overrides=system_prompt_overrides, + build_command=build_command, + lint_command=lint_command, resolved_workflow=resolved_workflow, branch_name=branch_name, pr_number=pr_number, + base_branch=base_branch, + merge_branches=merge_branches, channel_source=channel_source, channel_metadata=channel_metadata, trace=trace, @@ -724,8 +930,8 @@ def run_task( from hooks import reset_blocker_reason, reset_stuck_summary reset_blocker_reason() - # ABCA-662: same per-task reset for the stuck-guard recent-failure latch, - # so a prior task's observation can't leak into this task's max_turns copy. + # Same per-task reset for the stuck-guard recent-failure latch, so a + # prior task's observation can't leak into this task's max_turns copy. reset_stuck_summary() # --trace accumulator (design §10.1): when the task opted into # trace, ``_TrajectoryWriter`` keeps an in-memory copy of each @@ -733,7 +939,7 @@ def run_task( # S3 on terminal. Owned by the pipeline rather than the runner # so the accumulator outlives ``run_agent``'s scope. trajectory = _TrajectoryWriter(config.task_id, accumulate=trace) - # K2 review Finding #3 — surface accumulator truncation to the + # Surface accumulator truncation to the # user via a ``trace_truncated`` milestone on TaskEventsTable # (visible in ``bgagent watch``). Fire-once by design: the # downloaded artifact's header reports the final drop count. @@ -853,9 +1059,10 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # pre-agent baseline build in setup_repo(). On a large repo that # baseline is minutes (up to the build-verify ceiling); posting the # 👀 only *after* it left the issue looking dead for the whole phase - # (no reaction, comment, or state change). None of these calls needs - # the cloned repo — they act on the channel issue via its API token + - # issue id from channel metadata — so they belong before the build. + # (observed in practice as no reaction, comment, or state change for + # 30+ min). None of these calls needs the cloned repo — they act on + # the channel issue via its API token + issue id from channel + # metadata — so they belong before the clone/build. # # Resolve the per-channel access token from Secrets Manager first # (react_task_started/comment_task_started read the env var it sets). @@ -869,9 +1076,17 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # No-op for non-Linear tasks. Best-effort; failures are logged # but do not block the pipeline. Capture the reaction id so we # can delete it at terminal status (👀 → ✅/❌). + # Workflow-state transition: a writeable coding task (new-task / + # pr-iteration) also moves + # the Linear issue Backlog → In Progress so it doesn't sit in Backlog + # for the whole run. read_only tasks (planning, + # pr-review) never transition — the orchestration panel owns the + # parent's state, and a planning run shouldn't advance the issue. + linear_transition_state = not config.read_only linear_eyes_reaction_id = react_task_started( config.channel_source, config.channel_metadata, + transition_state=linear_transition_state, ) # "Starting" comment on the Jira issue through the Forge app actor @@ -883,10 +1098,11 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: ) # Move the Jira card To Do → In Progress so the board reflects that - # work has started (issue #572). No-op for non-Jira tasks. Part of - # the early ACK (before setup_repo) so the board reflects "started" - # during the baseline build, not only after it. Best-effort; failures - # are logged and never block the pipeline. + # work has started (issue #572). No-op for non-Jira tasks. + # Best-effort; failures are logged and never block the pipeline. + # Part of the Early-ACK block (moved before setup_repo with the 👀 + # and start comment) so board state updates immediately, not after + # the multi-minute baseline build. transition_task_started( config.channel_source, config.channel_metadata, @@ -896,8 +1112,10 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # pre-agent baseline build raises here; it needs no local handler — # the outer ``except Exception`` at the bottom of this ``try`` writes # the task FAILED, swaps the 👀 (posted above) to ❌, and posts the - # failure comment. Posting the 👀 earlier is what makes the outer - # handler's ❌-swap actually visible for setup failures. + # failure comment. Before the Early-ACK move the 👀 didn't exist yet + # at this point, so a setup failure left the issue silently stuck + # with no visible signal; posting the 👀 earlier is what makes the + # outer handler's ❌-swap actually visible for setup failures. with task_span("task.repo_setup") as setup_span: setup = setup_repo(config, progress=progress) setup_span.set_attribute("build.before", setup.build_before) @@ -914,6 +1132,13 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # repo dir. (Token resolution + the 👀/start ACK moved earlier so # the user gets immediate feedback; see the Early ACK block above.) configure_channel_mcp(setup.repo_dir, config.channel_source) + # ADR-016 ENFORCEMENT: strip any Linear MCP server a repo may have + # COMMITTED to its own .mcp.json before the SDK reads it — the prompt + # prohibition ("you have no Linear tools") is not a security boundary, + # and we export LINEAR_API_TOKEN + load project settings under + # bypassPermissions. Runs for every channel (defense-in-depth); never + # matches Jira's own entry. + strip_linear_mcp_servers(setup.repo_dir) # Download attachments from S3 (version-pinned, integrity-verified) prepared_attachments: list = [] @@ -1089,35 +1314,177 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: ) ensure_pr_strategy = "create" + # A REPO-FUL workflow whose primary terminal outcome is an ARTIFACT + # clones the repo for context but produces a document, not a PR. Skip + # the build/PR post-hooks and deliver the agent's result text as the + # artifact. + # + # No workflow currently shipped takes this branch — the repo-ful + # artifact workflows live downstream — but the generic capability stays + # because it is declared by the workflow contract + # (terminal_outcomes.primary + requires_repo), not by a workflow id, and + # the alternative is silently opening a PR for a document task. + # + # BOTH conditions matter: a repo-LESS artifact workflow + # (default/agent-v1, web-research) never reaches this repo-bound + # branch, but default/agent-v1 is repo-OPTIONAL — run WITH a repo it + # takes THIS path yet still expects a PR (primary: artifact but + # requires_repo: false). So gate on requires_repo too, or a + # repo-optional default-agent run would wrongly skip its PR. + artifact_workflow = bool( + _workflow + and getattr(_workflow.terminal_outcomes, "primary", None) == "artifact" + and getattr(_workflow, "requires_repo", False) + ) + artifact_uri: str | None = None # set by the artifact branch below + + # Clarify-before-spend (UX #4): a writeable, PR-producing task + # (new_task) whose agent judged the request too ambiguous to + # implement emits NEEDS_INPUT_MARKER on the first line of its final + # message. Treat that as a HOLD: no build, no commit, no PR — the + # deliverable is the clarifying question, surfaced by the platform as + # "needs input" rather than a finished task, so we don't charge for a + # guess. Scoped OFF for artifact workflows (they emit a document, not a + # question) and PR workflows (pr_iteration already has its own + # answer-only path). Fail-safe: if the marker is somehow present on a + # read-only task we still just hold (nothing to lose). + # Primary signal: the agent CALLED the request_clarification tool + # (deterministic — a tool call, captured by the runner). Fallback: + # the legacy first-line text sentinel (kept so a model that types the + # marker instead of calling the tool still holds). Either → hold. + clarification_q = (agent_result.clarification_question or "").strip() + needs_input = bool( + not artifact_workflow + and not config.is_pr_workflow + and (clarification_q or _starts_with_needs_input_marker(agent_result.result_text)) + ) + # Post-hooks (agent_result is guaranteed set by the try/except above) with task_span("task.post_hooks") as post_span: - # Safety net: commit any uncommitted tracked changes (skip for read-only tasks) - safety_committed = False if workflow_read_only else ensure_committed(setup.repo_dir) - post_span.set_attribute("safety_net.committed", safety_committed) - - build_passed = verify_build(setup.repo_dir) - lint_passed = verify_lint(setup.repo_dir) - pr_url = ensure_pr( - config, - setup, - build_passed, - lint_passed, - agent_result=agent_result, - strategy=ensure_pr_strategy, - ) - post_span.set_attribute("build.passed", build_passed) - post_span.set_attribute("lint.passed", lint_passed) - post_span.set_attribute("pr.url", pr_url or "") + if needs_input: + # Hold-and-ask: skip build/lint/PR entirely. The agent asked a + # question and made no changes; there is nothing to verify or ship. + build_passed = True + lint_passed = True + build_timed_out = False + build_inert = False + build_infra_failed = False + safety_committed = False + pr_url = None + log("POST", "Clarify-before-spend: agent asked for input — holding (no PR)") + elif artifact_workflow: + # Plan-only task: no build/lint/PR gate — the plan IS the deliverable. + build_passed = True + lint_passed = True + build_timed_out = False + build_inert = False + build_infra_failed = False + safety_committed = False + pr_url = None + artifact_uri = _deliver_plan_artifact( + _workflow, config, hc, progress, trajectory, setup, prompt, agent_result + ) + post_span.set_attribute("artifact.uri", artifact_uri or "") + else: + # A leading cause of lost deliverables: if the agent switched + # off the platform branch (it sometimes runs + # `git checkout -b ` and commits/opens its PR + # there — observed in practice), + # re-point the platform branch at the agent's HEAD so the + # safety-net commit, build verify, PR, and push below all run on + # the branch the platform tracks. No-op on the healthy case + # (agent stayed on the platform branch). Skip for read-only + # (no commit/PR to deliver). The delivery gate stays as the + # backstop if reconcile can't recover the work. + if not workflow_read_only: + reconcile_agent_branch(setup.repo_dir, setup.branch) + # Safety net: commit any uncommitted tracked changes (skip read-only tasks) + safety_committed = ( + False if workflow_read_only else ensure_committed(setup.repo_dir) + ) + post_span.set_attribute("safety_net.committed", safety_committed) + + build_outcome = verify_build(setup.repo_dir, config.build_command) + build_passed = build_outcome.passed + # Distinct diagnosis: a build that exceeded BUILD_VERIFY_TIMEOUT_S + # was KILLED, not failed — surface "timed out" rather than the + # misleading "build/tests failed" (a build that never finished is + # a different problem than a broken build). Threaded into the task + # error_message below so the platform's failure copy reflects it. + build_timed_out = build_outcome.timed_out + # The build was KILLED by an environment fault (out + # of disk / OOM) — we could NOT verify the code. Unlike inert, do + # NOT treat this as passing: an infra-killed build gives no + # verdict, and if the pre-agent baseline was ALSO infra-killed the + # regression-only gate would wrongly conclude "already red → not a + # regression → success" (the false ✅). Threaded into the verdict + # + error_message (build_ok=infra) so the platform reports a + # retryable infra fault, not "build failed" and not a bogus ✅. + build_infra_failed = build_outcome.infra_failed + # An INERT build gate (exit 127 / no-such-task — the command + # couldn't run, e.g. yarn missing) verified NOTHING. Treat it like + # the lint-inert path: do NOT gate on it (it's a config problem, + # not the agent's code), and treat build as passing for the gate + # so we don't emit a false "build failed". The honest signal is + # carried in error_message (build_ok=inert) for the platform copy. + build_inert = build_outcome.inert + if build_inert: + log( + "POST", + "Post-agent build gate is INERT (command couldn't run) " + "— not gating on it; surfacing as inert, not a failure", + ) + build_passed = True + # #72: when lint is INERT for this repo (no runnable lint task and + # no configured lint_command — see repo.py setup), running the + # default `mise run lint` would just fail "no such task" and + # record a misleading lint_passed=False. Skip the post-agent lint + # run entirely in that case and treat lint as passing (it never + # gates the verdict regardless; this keeps the persisted signal + # honest rather than a false red). + if getattr(setup, "lint_gate_inert", False): + log( + "POST", + "Skipping post-agent lint verification " + "(lint gating is INERT for this repo)", + ) + lint_passed = True + else: + lint_passed = verify_lint(setup.repo_dir, config.lint_command).passed + pr_url = ensure_pr( + config, + setup, + build_passed, + lint_passed, + agent_result=agent_result, + strategy=ensure_pr_strategy, + ) + post_span.set_attribute("build.passed", build_passed) + post_span.set_attribute("lint.passed", lint_passed) + post_span.set_attribute("pr.url", pr_url or "") if pr_url: progress.write_agent_milestone("pr_created", pr_url) # Move the Jira card In Progress → In Review now that a PR is - # open (issue #572). Only fires when a PR was actually opened — - # failed / no-PR tasks leave the card where humans can see the - # failure comment. No-op for non-Jira tasks; best-effort. - transition_pr_opened( - config.channel_source, - config.channel_metadata, - ) + # open (issue #572) — but ONLY when the build passed. ensure_pr + # deliberately opens a PR even on a FAILED build (so the human + # sees the broken diff), so gating on pr_url alone moved the card + # to In Review on a red build, telling the board the work is ready + # for review when it isn't (review blocker #9a). Gate on build_ok + # to mirror the Linear twin, which only transitions on success + # (react_task_finished: `if transition_state and success`). A + # build-failed PR leaves the card In Progress with the failure + # comment. No-op for non-Jira tasks; best-effort. + if build_passed: + transition_pr_opened( + config.channel_source, + config.channel_metadata, + ) + else: + log( + "TASK", + "Jira card NOT moved to In Review — PR opened with a FAILED build " + "(build_ok=False); leaving it In Progress with the failure comment (#9a).", + ) # Memory write — capture task episode and repo learnings memory_written = False @@ -1156,7 +1523,47 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: agent_result, build_ok=build_ok, pr_url=pr_url, + build_timed_out=build_timed_out, + build_infra_failed=build_infra_failed, + ) + # Delivery gate: a create-strategy new-work task that + # reported success but opened no PR AND landed no commit shipped + # nothing — fail it loudly (retryable) instead of a false COMPLETED + # that would poison a stacked orchestration DAG (#247). The branch-diff is only run + # when the gate is actually in play (success + no pr_url) so a normal + # PR-producing run pays nothing. See :func:`_apply_delivery_gate`. + gate_in_play = ( + overall_status == "success" + and not pr_url + and not workflow_read_only + and not artifact_workflow + and not needs_input + and ensure_pr_strategy == "create" + ) + commit_landed = ( + _branch_has_new_commits(setup.repo_dir, setup.default_branch) + if gate_in_play + else False ) + overall_status, result_error = _apply_delivery_gate( + overall_status, + result_error, + workflow_read_only=workflow_read_only, + artifact_workflow=artifact_workflow, + needs_input=needs_input, + ensure_pr_strategy=ensure_pr_strategy, + pr_url=pr_url, + commit_landed=commit_landed, + ) + # Clarify-before-spend: a hold-for-input run is a SUCCESSFUL outcome + # (the agent did the right thing by asking), not a failure — the + # deliverable is the question. Force success + clear any error so the + # platform surfaces "needs input", not ❌. (The agent emitted a normal + # ResultMessage, so overall_status is already 'success' in the common + # case; this guards the edge where a gate/marker interaction differs.) + if needs_input: + overall_status = "success" + result_error = None # ✅/❌ on the Linear issue (removes the 👀 first so the final # status stands alone). No-op for non-Linear tasks. @@ -1165,6 +1572,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: config.channel_metadata, success=(overall_status == "success"), started_reaction_id=linear_eyes_reaction_id, + transition_state=linear_transition_state, ) # NOTE: the terminal status comment on the Jira issue is NOT posted @@ -1186,6 +1594,41 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # still produces a usable debug artifact. trace_s3_uri = _maybe_upload_trace(config, trajectory, progress) + # Did this PR-iteration actually advance the branch HEAD? + # Compare the final HEAD to the sha captured at checkout. Unchanged + # ⇒ a question-only iteration (no commit) ⇒ the settle reply reports + # "answered / no change" instead of a false "✅ Updated". Only + # meaningful for a PR workflow with a baseline sha; otherwise None + # (the change-made / back-compat side). Best-effort — a rev-parse + # failure leaves it None, never flips the verdict. + code_changed: bool | None = None + head_sha_after = "" + if config.is_pr_workflow and setup.head_sha_before: + head_after_res = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=setup.repo_dir, + check=False, + capture_output=True, + text=True, + timeout=60, + ) + if head_after_res.returncode == 0: + head_sha_after = head_after_res.stdout.strip() + code_changed = head_sha_after != setup.head_sha_before + # The agent's final text — surfaced as the answer on a no-change + # iteration so a question gets an actual reply. + answer_text = (agent_result.result_text or "").strip() + # Clarify-before-spend: reuse the SAME "no change → 💬 answered" surface + # the pr-iteration answer path uses (code_changed=False + answer_text). + # A new_task hold makes no commit, so code_changed is naturally False; + # set it explicitly and strip the marker line so the reviewer sees only + # the question, not our internal sentinel. + if needs_input: + code_changed = False + # Prefer the tool's ``question`` arg (clean, no marker); fall back + # to the final message with the legacy sentinel stripped. + answer_text = clarification_q or _strip_needs_input_marker(answer_text) + # Build TaskResult usage = agent_result.usage turns_attempted = agent_result.num_turns or agent_result.turns @@ -1219,6 +1662,15 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: cache_read_input_tokens=usage.cache_read_input_tokens if usage else None, cache_creation_input_tokens=usage.cache_creation_input_tokens if usage else None, trace_s3_uri=trace_s3_uri, + # An artifact workflow carries its artifact + # URI here so the platform can read the plan and seed sub-issues; + # None for a normal PR workflow. + artifact_uri=artifact_uri, + code_changed=code_changed, + # Only carry the answer text on a no-change iteration (where it + # becomes the reply); a normal edit's reply is the PR link. + answer_text=answer_text if code_changed is False else "", + head_sha=head_sha_after, otel_trace_id=current_otel_trace_id(), ) @@ -1264,7 +1716,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # Ensure the task is marked FAILED in DynamoDB even if the pipeline # crashes before reaching the normal terminal-state write. # - # K2 review Finding #1 — crash-path trace upload. The + # Crash-path trace upload. The # trajectory accumulator is exactly the artifact the user # enabled ``--trace`` to capture the failure with; dropping # it on the crash path is a silent regression against the @@ -1333,30 +1785,23 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: _RUN_TASK_PARAMS = frozenset(inspect.signature(run_task).parameters) #: Orchestrator payload keys we KNOW about that ``run_task`` does not (yet) -#: accept as a parameter. Dropping one of these is expected today (e.g. -#: ``base_branch`` is consumed via ``hydrated_context``, not a run_task kwarg; -#: ``github_token_secret_arn`` is resolved before this call), but a key that +#: accept as a parameter. Dropping one of these is expected today, but a key that #: shows up here AND is silently dropped is exactly the "wired one side of an -#: orchestrator→agent field, forgot the other" no-op that ABCA-487 was — so we -#: WARN when we drop one, making a future contract gap visible instead of silent. +#: orchestrator→agent field, forgot the other" no-op we have already hit once — +#: so we WARN when we drop one, making a future contract gap visible instead of +#: silent. #: Keys not in this set (genuinely foreign) are dropped quietly as before. +#: +#: NB (merge note): on this branch ``run_task`` DOES accept ``build_command``, +#: ``lint_command``, ``base_branch`` and ``merge_branches`` (see its signature), +#: so those are forwarded — NOT dropped — and must NOT be listed here (they would +#: never hit the drop path). ``github_token_secret_arn`` is deliberately omitted +#: too: it is ALWAYS present and ALWAYS resolved via the +#: ``GITHUB_TOKEN_SECRET_ARN`` env in build_config, so listing it would fire the +#: WARN on 100% of ECS boots — pure noise. It falls through as a quiet +#: foreign-key drop instead. _KNOWN_ORCHESTRATOR_KEYS = frozenset( { - "build_command", - # ``lint_command``'s sibling: neither is a run_task param today (the build/ - # lint commands are consumed via repo config, not passed through here), but - # listing both makes a future "wired build_command, forgot lint_command" - # contract gap WARN instead of drop silently (N4). - "lint_command", - "merge_branches", - "base_branch", - # NB: ``github_token_secret_arn`` is deliberately NOT listed (N3). It is - # ALWAYS in the payload and ALWAYS dropped here (resolved via the - # ``GITHUB_TOKEN_SECRET_ARN`` env in build_config, never a run_task - # param), so listing it would fire the known-key WARN on 100% of ECS - # boots — pure noise that dilutes the channel meant to surface genuine - # future "wired one side, forgot the other" gaps. It falls through as a - # quiet foreign-key drop instead. # AgentCore's server.py exports task_started_at as TASK_STARTED_AT, which # hooks._remaining_maxlifetime_s() uses to clip the Cedar HITL approval-gate # maxLifetime. The ECS boot path bypasses server.py and does not (yet) set @@ -1375,8 +1820,8 @@ def run_task_from_payload(payload: dict) -> dict: The ECS compute path (``ecs-strategy.ts``) hands the agent the *entire* orchestrator payload (via the #502 S3 pointer). Previously the ECS boot command hand-listed a subset of ``run_task`` kwargs and silently dropped the - rest — most visibly ``channel_source``/``channel_metadata`` (no Linear/Jira - reactions or channel MCP on ECS — ABCA-487), plus ``build_command``, + rest — most visibly ``channel_source``/``channel_metadata``, whose absence + meant no Linear/Jira reactions and no channel MCP on ECS, plus ``build_command``, ``cedar_policies``, ``base_branch``/``merge_branches``, ``attachments``, etc. This maps the payload to ``run_task``'s real signature so no field can be @@ -1397,8 +1842,7 @@ def run_task_from_payload(payload: dict) -> dict: # Not a run_task parameter — ignore. A KNOWN orchestrator key being # dropped is expected today but worth a breadcrumb: if run_task ever # grows a matching param, this WARN is where a "forgot to wire it - # through" no-op surfaces (N4 / ABCA-487 class). Foreign keys are - # dropped quietly. + # through" no-op surfaces. Foreign keys are dropped quietly. if key in _KNOWN_ORCHESTRATOR_KEYS and value is not None: log( "WARN", @@ -1417,7 +1861,7 @@ def run_task_from_payload(payload: dict) -> dict: # non-str coercion, so it also guards the surprising int() cases the # orchestrator never emits but a hand-edited payload might: a bool # (``int(True) == 1``) and a non-integral float (``int(3.9) == 3``) - # would both silently become a bogus turn count (N4). + # would both silently become a bogus turn count. if isinstance(value, bool) or not isinstance(value, (int, float, str)): log("WARN", f"run_task_from_payload: ignoring non-integer max_turns {value!r}") continue diff --git a/agent/src/post_hooks.py b/agent/src/post_hooks.py index 058a1e4c8..e6e7f0de2 100644 --- a/agent/src/post_hooks.py +++ b/agent/src/post_hooks.py @@ -2,8 +2,11 @@ from __future__ import annotations +import os import re +import shlex import subprocess +from dataclasses import dataclass from typing import TYPE_CHECKING from shell import log, run_cmd @@ -11,45 +14,240 @@ if TYPE_CHECKING: from models import AgentResult, RepoSetup, TaskConfig +# Default verification commands. A repo that uses mise gets these for free; a +# non-mise repo sets ``pipeline.buildCommand`` / ``lintCommand`` in its +# blueprint (threaded to the agent as build_command / lint_command) so gating +# runs the repo's real command. +DEFAULT_BUILD_COMMAND = "mise run build" +DEFAULT_LINT_COMMAND = "mise run lint" + +# Wall-clock ceiling for a single build/lint verification subprocess. The old +# hardcoded 600s (run_cmd's default) was too low for a real CI-parity build +# (install + compile + full test suite + synth) — a heavy repo's legitimate +# build exceeded it and was reported as a build FAILURE, which is the wrong +# diagnosis (the build didn't fail, it didn't finish in time). Raised to 30min +# and made env-overridable; well under the orchestrator's 9h durable ceiling. +# When the ceiling IS hit we now surface a distinct "timed out" reason (see +# VerifyOutcome.timed_out → pipeline error_message → platform failure copy) +# rather than a generic "build failed". +BUILD_VERIFY_TIMEOUT_S = int(os.environ.get("BUILD_VERIFY_TIMEOUT_S") or 1800) + + +@dataclass +class VerifyOutcome: + """Result of a build/lint verification run. + + ``passed`` drives gating exactly as the old bare-bool return did. The two + other flags distinguish WHY a not-passed result happened, so the platform + can report an honest, actionable reason instead of a blanket "build failed": + + - ``timed_out`` — the command exceeded ``BUILD_VERIFY_TIMEOUT_S`` and was + killed (a build that never finished, not a build that failed). + - ``inert`` — the command could not RUN at all: exit 127 (command not + found, e.g. ``yarn`` missing) or mise "no such task". This is a CONFIG + problem (the gate isn't actually verifying anything), NOT the agent's + code being broken. Without this, an inert exit-127 gate was silently + reported as ``build_passed=False`` — a false "your code is broken" for a + repo we never managed to build. ``is_verify_command_inert`` already + existed but was only consulted at repo SETUP; now the post-agent gate + consults it too. + + - ``infra_failed`` — the command was KILLED by an environment fault (the + build box ran out of disk/ENOSPC or memory/OOM), so the build could not + complete on this host. Like ``inert`` this is NOT the agent's code being + broken — it's an infrastructure fault that a retry (fresh host) or more + capacity clears. This was a real failure mode: concurrent builds filled + the Fargate root fs → ENOSPC mid-build → bogus ``build_passed=False``. + + A timeout / inert / infra_failed result still counts as not-passed for + gating, but the pipeline surfaces each as its own reason. + """ -def verify_build(repo_dir: str) -> bool: - """Run mise run build after agent completion to verify the build.""" - log("POST", "Running post-agent build verification (mise run build)...") - try: - result = run_cmd( - ["mise", "run", "build"], - label="mise-run-build-post", - cwd=repo_dir, - check=False, - ) - except subprocess.TimeoutExpired: - log("WARN", "Post-agent build timed out — treating as failed") - return False - if result.returncode != 0: - log("POST", "Post-agent build FAILED") - return False - log("POST", "Post-agent build: OK") - return True + passed: bool + timed_out: bool = False + inert: bool = False + infra_failed: bool = False + + +# POSIX shell exit code for "command not found" — an inert build signal (the +# configured verify command isn't installed), not a genuine build failure. +SHELL_COMMAND_NOT_FOUND = 127 + + +def is_verify_command_inert(returncode: int, stderr: str) -> bool: + """True when a verify command did not actually RUN (vs ran-and-failed). + + Distinguishes the inert-gate state — the build/lint command isn't + runnable in this repo, so gating is effectively OFF — from a genuine red + build (command executed, exited non-zero), which IS meaningful signal. + + Heuristics (conservative — only the unambiguous "couldn't run" signals): + - exit 127: shell "command not found" (e.g. ``gradle`` not installed). + - mise "no tasks defined" / "no task named" / "not found": the configured + (or default ``mise run build``) task does not exist in the repo. + A repo that genuinely fails its build returns some other non-zero code with + real compiler/test output, which this does NOT flag. + """ + if returncode == SHELL_COMMAND_NOT_FOUND: + return True + s = (stderr or "").lower() + return ( + "no tasks defined" in s + or "no task named" in s + or ("mise" in s and "not found" in s) + or "command not found" in s + ) + + +# Exit code for a process killed by SIGKILL (128 + 9) — how the OOM-killer and +# some disk-full kills surface. Paired with the ENOSPC/OOM stderr signatures. +SIGKILL_EXIT = 137 + + +def is_infra_failure(returncode: int, stderr: str) -> bool: + """True when a verify command was killed by an ENVIRONMENT fault, not a real + build failure — the build box ran out of disk or memory. + + Distinct from :func:`is_verify_command_inert` (the command isn't runnable — + a CONFIG problem) and from a genuine red build (command ran, tests failed). + An out-of-disk / OOM kill means the build *couldn't complete on this host*, + so reporting ``build_passed=False`` is a false "your code is broken" — it's + an infrastructure fault a retry (on a fresh host) or more capacity clears. + This was a real failure mode: concurrent builds filled the Fargate root fs → + ``ENOSPC: no space left on device`` mid-build → bogus build-fail. + + A bare SIGKILL (137) with no accompanying signature is ALSO treated as infra: + the container-runtime / cgroup OOM-killer delivers SIGKILL and writes its + "Killed process …" line to the KERNEL log, not the build process's own stderr, + so an OOM'd `mise run build` frequently exits 137 with NO "killed"/"out of + memory" string captured (observed: a post-agent build OOM at 137 fell through + to the inert heuristic and was mislabeled "command not found"; a 137 with + plain build output would fall through to a GENUINE build FAILURE → a false + gate on healthy code). SIGKILL is never something a healthy + `mise run build` does to itself — a real test failure exits with the runner's + own non-zero code (1/2), not 137. So 137 ⇒ resource kill ⇒ infra, and this is + checked BEFORE the inert/genuine-failure paths in ``_run_verify``. + """ + s = (stderr or "").lower() + disk_full = "no space left on device" in s or "enospc" in s or "errno 28" in s + oom = "out of memory" in s or "oomkilled" in s or "cannot allocate memory" in s + # A SIGKILL (137) is a resource/OOM kill by the runtime, not a build result — + # infra regardless of what (if anything) reached the captured stderr. + return disk_full or oom or returncode == SIGKILL_EXIT + + +# Shell metacharacters that mean the command can't be a single argv exec and +# must run through a shell to behave as written. Without this, a configured +# ``npm ci && npm run lint && npm test`` was shlex-split into one ``npm`` call +# with ``&&``/``npm``/… as bogus args — ``npm ci`` ran, ignored the rest, exited +# 0, and the chain's lint/test NEVER ran, so a broken build reported "OK". +_SHELL_OPERATORS = ("&&", "||", "|", ";", ">", "<", "$(", "`") + +# A leading ``VAR=value`` env-assignment prefix (one or more) is shell syntax: +# ``MISE_EXPERIMENTAL=1 mise //cdk:eslint`` only sets the env when run through a +# shell. Exec'd directly (shlex-split), the FIRST token ``MISE_EXPERIMENTAL=1`` +# is treated as the program name → ``FileNotFoundError``. Detect it so such a +# command is routed through ``bash -lc`` like the operator case. NAME must be a +# valid POSIX env identifier so a plain arg like ``a=b`` in a real program's +# args (unusual as a leading token, but be precise) is matched only when it truly +# leads. Without this, a configured ``lint_command`` of ``MISE_EXPERIMENTAL=1 mise +# //cdk:eslint`` crashed the whole task at exit 1 before the build ran. +_ENV_ASSIGN_PREFIX = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") + + +def resolve_verify_argv(command: str | None, default: str) -> list[str]: + """Resolve a configured verify command into an argv for :func:`run_cmd`. + + Empty/whitespace/None ``command`` → the default (mise). A plain command with + args (``npm run build``) is ``shlex``-split and exec'd directly. A command + that needs a shell to behave as written — it contains shell operators (``&&``, + ``|``, ``;``, redirects, command substitution) OR begins with a ``VAR=value`` + env-assignment prefix — is wrapped as ``bash -lc ''``; otherwise the + operators/assignment are passed as literal args to (or AS) the first program + and mis-run (chained build commands would silently no-op; an env-prefixed + lint command would exec ``VAR=value`` as the binary and crash). + """ + cmd = (command or "").strip() or default + needs_shell = any(op in cmd for op in _SHELL_OPERATORS) or bool(_ENV_ASSIGN_PREFIX.match(cmd)) + if needs_shell: + return ["bash", "-lc", cmd] + return shlex.split(cmd) -def verify_lint(repo_dir: str) -> bool: - """Run mise run lint after agent completion to verify lint passes.""" - log("POST", "Running post-agent lint verification (mise run lint)...") +def _run_verify(repo_dir: str, command: str, default: str, label: str) -> VerifyOutcome: + """Run a configured verify command and classify the outcome. + + Returns a :class:`VerifyOutcome` so callers can distinguish a TIMEOUT (the + command exceeded ``BUILD_VERIFY_TIMEOUT_S`` and was killed — the build did + not *fail*, it did not *finish*) from a genuine non-zero exit. Both are + not-passed for gating, but the pipeline surfaces them as different reasons. + """ + argv = resolve_verify_argv(command, default) + log("POST", f"Running post-agent {label} ({' '.join(argv)})...") try: result = run_cmd( - ["mise", "run", "lint"], - label="mise-run-lint-post", + argv, + label=label, cwd=repo_dir, check=False, + timeout=BUILD_VERIFY_TIMEOUT_S, + # Stream the build/lint output live → full log reaches CloudWatch + # verbatim (a buffered summary would hide which sub-task failed). + stream=True, ) except subprocess.TimeoutExpired: - log("WARN", "Post-agent lint timed out — treating as failed") - return False + log( + "WARN", + f"Post-agent {label} TIMED OUT after {BUILD_VERIFY_TIMEOUT_S}s " + "— reporting as timed out (not a build failure)", + ) + return VerifyOutcome(passed=False, timed_out=True) if result.returncode != 0: - log("POST", "Post-agent lint FAILED") - return False - log("POST", "Post-agent lint: OK") - return True + stderr = getattr(result, "stderr", "") or "" + # An ENVIRONMENT fault (out of disk / OOM) means the build couldn't + # complete on this host — NOT that the code is broken. Check this BEFORE + # the inert/genuine-failure paths: it's the most specific signal, and a + # disk-full mid-build otherwise looks like a random non-zero exit and gets + # mis-reported as "build/tests failed" (concurrent builds filling the + # Fargate root fs → ENOSPC → bogus build-fail). Surface as infra so the + # platform reports "retry / needs more capacity", not the agent's code. + if is_infra_failure(result.returncode, stderr): + log( + "WARN", + f"Post-agent {label} was KILLED by an environment fault (exit " + f"{result.returncode}: out of disk/memory) — infrastructure issue, " + "not a build failure", + ) + return VerifyOutcome(passed=False, infra_failed=True) + # Distinguish "couldn't RUN" (exit 127 / no-such-task → the gate is + # inert, a config problem) from "ran and failed" (real red build). An + # inert gate verified nothing, so reporting it as a build FAILURE is a + # false "your code is broken" — surface it as inert instead. + if is_verify_command_inert(result.returncode, stderr): + log( + "WARN", + f"Post-agent {label} could not RUN (exit {result.returncode}) " + "— gate is INERT (command not found / no such task), not a build failure", + ) + return VerifyOutcome(passed=False, inert=True) + log("POST", f"Post-agent {label} FAILED (exit {result.returncode})") + return VerifyOutcome(passed=False) + log("POST", f"Post-agent {label}: OK") + return VerifyOutcome(passed=True) + + +def verify_build(repo_dir: str, command: str = "") -> VerifyOutcome: + """Run the configured build command (default ``mise run build``) to verify the build. + + Returns a :class:`VerifyOutcome` (``.passed`` for gating, ``.timed_out`` to + distinguish "exceeded the time limit" from "ran and failed"). + """ + return _run_verify(repo_dir, command, DEFAULT_BUILD_COMMAND, "verify-build-post") + + +def verify_lint(repo_dir: str, command: str = "") -> VerifyOutcome: + """Run the configured lint command (default ``mise run lint``) to verify lint passes.""" + return _run_verify(repo_dir, command, DEFAULT_LINT_COMMAND, "verify-lint-post") def ensure_committed(repo_dir: str) -> bool: @@ -126,6 +324,97 @@ def ensure_committed(repo_dir: str) -> bool: return False +def _current_branch(repo_dir: str) -> str | None: + """Return the checked-out branch name, or None if detached / git fails.""" + try: + res = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=repo_dir, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + return None + if res.returncode != 0: + return None + name = res.stdout.strip() + # "HEAD" is git's sentinel for a detached HEAD — not a branch name. + return name or None if name != "HEAD" else None + + +def reconcile_agent_branch(repo_dir: str, branch: str) -> bool: + """Make delivery tolerant of the agent switching off the platform-provided + branch. + + The platform checks out ``branch`` (``config.branch_name``, e.g. + ``bgagent//``) BEFORE the agent runs, and every delivery git op + (ensure_committed / ensure_pr's commit-diff / ensure_pushed) is keyed to it. + But the agent doesn't always stay there: it sometimes ran + ``git checkout -b ``, committed + opened its own PR + there, and left the platform branch empty. ensure_pr then saw no commits on + ``branch`` → skipped → the task looked delivered-nothing, and for a stacked + child the successor had no branch to stack on. It is NON-deterministic (the + same task succeeds on a retry when the agent happens to stay put), so a + prompt tweak alone can't be relied on. + + This reconciles deterministically: if HEAD is on a DIFFERENT branch than the + platform ``branch`` and that HEAD carries commits, fast-forward the platform + branch to the agent's HEAD (``git branch -f`` + checkout) so all downstream + delivery runs against the branch the platform tracks. The agent's commits are + preserved verbatim — we only re-point the platform branch label at them. + + Returns True if it moved the platform branch, False otherwise (already on it, + detached HEAD with no branch to adopt, or a git failure — all handled by the + existing ensure_committed/ensure_pr/delivery-gate chain). Best-effort: never + raises, so a reconcile failure degrades to the pre-existing behavior.""" + head_branch = _current_branch(repo_dir) + if head_branch is None or head_branch == branch: + # On the platform branch already (the common, healthy case) or detached + # with nothing to adopt — nothing to reconcile. + return False + log( + "POST", + f"Agent left the platform branch: HEAD is on '{head_branch}', expected " + f"'{branch}'. Reconciling the platform branch to the agent's commits so " + "the work is delivered on the tracked branch.", + ) + try: + # Re-point the platform branch at the agent's HEAD (force: the platform + # branch was created empty at setup, so this only ever fast-forwards it + # to the work the agent actually did). Then check it out so ensure_committed + # / ensure_pr / ensure_pushed all operate on the tracked branch. + force_res = run_cmd( + ["git", "branch", "-f", branch, "HEAD"], + label="reconcile-branch-force", + cwd=repo_dir, + check=False, + ) + if force_res.returncode != 0: + stderr = (force_res.stderr or "").strip()[:200] + log("WARN", f"reconcile: git branch -f failed (exit {force_res.returncode}): {stderr}") + return False + checkout_res = run_cmd( + ["git", "checkout", branch], + label="reconcile-branch-checkout", + cwd=repo_dir, + check=False, + ) + if checkout_res.returncode != 0: + stderr = (checkout_res.stderr or "").strip()[:200] + log( + "WARN", + f"reconcile: checkout '{branch}' failed (exit {checkout_res.returncode}): {stderr}", + ) + return False + except (OSError, subprocess.SubprocessError) as e: + log("WARN", f"reconcile: git op raised {type(e).__name__}: {e}") + return False + log("POST", f"Reconciled: platform branch '{branch}' now points at the agent's work") + return True + + def ensure_pushed(repo_dir: str, branch: str) -> bool: """Push the branch if there are unpushed commits.""" result = subprocess.run( @@ -200,6 +489,79 @@ def _note_unpushed_commits(repo_dir: str, branch: str, config: TaskConfig) -> No log("WARN", f"Failed to post un-pushed-commits note: {type(e).__name__}: {e}") +def _reconcile_pr_base(repo_dir: str, branch: str, config: TaskConfig, expected_base: str) -> None: + """Deterministically retarget an existing PR onto ``expected_base``. + + The PR is created by the AGENT (its own ``gh pr create`` in the prompt + workflow), so the ``--base`` it chose is a model judgment call, not the + orchestrator's. Observed on a stacked-chain stress test: a stacked child + that branched off its predecessor's branch STILL opened its PR against + ``main`` — the agent reasoned "this was based off the predecessor branch, + let me open the PR against main" — and even a root whose + ``detect_default_branch`` correctly returned a non-``main`` default was + pointed at ``main``. Wrong base ⇒ the PR shows the whole default-branch + divergence (100s of files) instead of the child's real delta, and a stacked + child's PR merges onto the wrong branch. + + ``expected_base`` is ``setup.default_branch`` — which is the orchestrator's + ``base_branch`` for a stacked child (the predecessor's branch, or ``main`` + for a diamond) and ``detect_default_branch`` for a root. This post-hook + reads the PR's current base and, if it disagrees, retargets it via + ``gh pr edit --base`` — removing the agent's discretion without forbidding + it from opening the PR (which keeps the agent-authored title/body). + + Best-effort: any failure is logged, never fatal — a mis-based PR is a + presentation/merge-target defect, not a reason to fail the whole task. + """ + try: + view = subprocess.run( + [ + "gh", + "pr", + "view", + branch, + "--repo", + config.repo_url, + "--json", + "baseRefName", + "-q", + ".baseRefName", + ], + cwd=repo_dir, + capture_output=True, + text=True, + timeout=60, + ) + except (OSError, subprocess.SubprocessError) as exc: + log("WARN", f"Could not read PR base to reconcile ({type(exc).__name__}) — leaving as-is") + return + if view.returncode != 0 or not view.stdout.strip(): + # No PR / unreadable base — nothing to reconcile (creation path handles + # the no-PR case; a transient read error just leaves the base as-is). + return + current_base = view.stdout.strip() + if current_base == expected_base: + return + log( + "POST", + f"Retargeting PR base '{current_base}' → '{expected_base}' " + f"(deterministic; agent chose the wrong base)", + ) + result = run_cmd( + ["gh", "pr", "edit", branch, "--repo", config.repo_url, "--base", expected_base], + label="reconcile-pr-base", + cwd=repo_dir, + check=False, + ) + if result.returncode != 0: + stderr_msg = result.stderr.strip()[:200] if result.stderr else "(no stderr)" + log( + "WARN", + f"Failed to retarget PR base to '{expected_base}' (gh exit {result.returncode}): " + f"{stderr_msg} — PR remains based on '{current_base}'", + ) + + def ensure_pr( config: TaskConfig, setup: RepoSetup, @@ -211,7 +573,7 @@ def ensure_pr( """Realize the PR per the workflow's ``ensure_pr`` strategy. Strategy (provider-neutral, from the workflow step — replaces the former - ``task_type`` self-inspection, #248): + ``task_type`` self-inspection): - ``create``: create a new PR if one doesn't exist (the new_task path). - ``push_resolve``: push follow-up commits, then resolve the existing PR URL @@ -293,6 +655,10 @@ def ensure_pr( if result.returncode == 0 and result.stdout.strip(): pr_url = result.stdout.strip() log("POST", f"PR already exists: {pr_url}") + # The agent opened this PR and picked its own --base; correct it to the + # orchestrator-supplied / detected base if it disagrees (stacked-child + # + root wrong-base fix). + _reconcile_pr_base(repo_dir, branch, config, default_branch) return pr_url # Check if there are any commits on this branch beyond the default branch @@ -343,19 +709,35 @@ def ensure_pr( build_status = "PASS" if build_passed else "FAIL" lint_status = "PASS" if lint_passed else "FAIL" + # Show the actual commands run (default mise), not a hardcoded label. + build_label = (config.build_command or DEFAULT_BUILD_COMMAND).strip() + lint_label = (config.lint_command or DEFAULT_LINT_COMMAND).strip() cost_line = "" if agent_result and agent_result.cost_usd is not None: cost_line = f"- Agent cost: **${agent_result.cost_usd:.4f}**\n" + # When build-regression gating is inert (no runnable build command, none + # configured), say so plainly — otherwise a green "build: PASS" misleads: + # nothing was actually verified. + gate_warning = "" + if getattr(setup, "build_gate_inert", False): + gate_warning = ( + "> ⚠️ **Build-regression gating is OFF for this repo.** No runnable " + f"`{DEFAULT_BUILD_COMMAND}` task was found and no build command is configured, " + "so a change that breaks the build still reports success. To enable gating, set " + "`pipeline.buildCommand` in this repo's ABCA blueprint (e.g. `npm run build`).\n\n" + ) + pr_body = ( f"## Summary\n\n" f"{task_source}" f"### Commits\n\n" f"```\n{commits}\n```\n\n" f"## Verification\n\n" - f"- `mise run build` (post-agent): **{build_status}**\n" - f"- `mise run lint` (post-agent): **{lint_status}**\n" + f"{gate_warning}" + f"- `{build_label}` (post-agent): **{build_status}**\n" + f"- `{lint_label}` (post-agent): **{lint_status}**\n" f"{cost_line}\n" f"---\n\n" f"By submitting this pull request, I confirm that you can use, modify, copy, " diff --git a/agent/src/prompt_builder.py b/agent/src/prompt_builder.py index 6a262cbba..3582575d6 100644 --- a/agent/src/prompt_builder.py +++ b/agent/src/prompt_builder.py @@ -6,7 +6,7 @@ import os from typing import TYPE_CHECKING -from config import AGENT_WORKSPACE +from config import AGENT_WORKSPACE, NEEDS_INPUT_MARKER from prompts import get_system_prompt from sanitization import sanitize_external_content as sanitize_memory_content from shell import log @@ -30,6 +30,10 @@ def build_system_prompt( system_prompt = system_prompt.replace("{branch_name}", setup.branch) system_prompt = system_prompt.replace("{default_branch}", setup.default_branch) system_prompt = system_prompt.replace("{max_turns}", str(config.max_turns)) + # Clarify-before-spend (UX #4): the new_task workflow references this marker + # in its "ask instead of guess" branch. Harmless no-op for prompts that don't + # contain the placeholder. + system_prompt = system_prompt.replace("{needs_input_marker}", NEEDS_INPUT_MARKER) setup_notes = ( "\n".join(f"- {n}" for n in setup.notes) if setup.notes @@ -132,9 +136,21 @@ def _render_memory_context(hydrated_context: HydratedContext | None) -> str: def _channel_prompt_addendum(config: TaskConfig) -> str: """Return channel-specific prompt guidance, or empty string. - For Linear-origin tasks, instruct the agent to post progress comments and - transition state using the already-loaded Linear MCP tools. The tool names - are stated explicitly so the agent doesn't grope for them. + Linear-origin tasks (ADR-016 "Linear is fully deterministic"): the agent has + NO Linear MCP and NO Linear write access. All Linear I/O is handled by the + platform, not the agent: + * inbound context — the issue title/description, recent human comments, + project wiki-document CONTENT, and attachments are ALREADY pre-hydrated + into the task description + ``attachments`` at admission time + (linear-webhook-processor + linear-attachments.ts + + linear-feedback.fetchRecentComments + linear-issue-context-probe's doc + fetch). There is nothing to fetch at runtime. + * outbound status — 👀/✅/❌ reactions and Backlog→In Progress→In Review + state transitions are posted deterministically by ``linear_reactions.py``; + the "🤖 Starting" and PR-opened comments are posted at the Lambda tier; + the terminal ✅/⚠️/❌ summary (cost/turns/PR link) is posted by the + fan-out plane. So the addendum's whole job now is to tell the agent to + do the code work and NOT attempt any Linear calls. Jira-origin tasks intentionally get NO addendum: Atlassian's Remote MCP requires an interactive OAuth flow a headless agent can't complete, so the @@ -145,36 +161,39 @@ def _channel_prompt_addendum(config: TaskConfig) -> str: """ if config.channel_source != "linear": return "" + # A synthetic orchestration integration node (#247) has NO real Linear + # sub-issue — `linear_issue_id` is intentionally omitted from its + # channel_metadata (see orchestration-release.ts). Without a target issue + # there is nothing issue-specific to say; the parent panel is the surface. + if not config.channel_metadata.get("linear_issue_id"): + return "" issue_identifier = config.channel_metadata.get("linear_issue_identifier") or "" issue_ref = f" (`{issue_identifier}`)" if issue_identifier else "" + return ( - "\n\n## Linear issue progress updates (REQUIRED)\n\n" - f"This task was submitted from Linear issue{issue_ref}. The Linear MCP " - "server is loaded. You MUST perform these updates; they are part of " - "the task contract, not optional:\n\n" - "1. **At start** — call `mcp__linear-server__save_comment` with a short " - '"🤖 Starting on this issue…" message, then call ' - "`mcp__linear-server__save_issue` to transition the issue state. Use " - "`mcp__linear-server__list_issue_statuses` first if you don't already " - "know the state ids; pick the one named `In Progress` (fall back to " - "`Todo` if that state doesn't exist). If the issue is already in " - "`In Progress` or any later state (`In Review`, `Done`), skip the " - "transition. If neither exists, skip — the comment alone is enough. " - "Do not invent state names or loop on `list_issue_statuses`.\n" - "2. **When you open the PR** — call `mcp__linear-server__save_comment` " - "with the PR URL, then call `mcp__linear-server__save_issue` to " - "transition the issue state to `In Review` (fall back to `In Progress` " - "if that state doesn't exist). If neither exists, skip the state " - "transition — the PR comment alone is enough. Do not invent state " - "names or loop on `list_issue_statuses`.\n\n" - "**Do NOT post a final 'task completed' or 'task failed' comment.** " - "The platform fan-out plane (issue #239) posts a structured " - "✅/⚠️/❌ summary on terminal events with cost / turns / duration / " - "PR-link metrics that you don't have visibility into. A redundant " - "agent-side completion comment would just stack two near-identical " - "comments on the issue.\n\n" - "Keep the start + PR-opened comments concise. Do not mirror the full " - "agent transcript back to Linear." + "\n\n## Linear issue\n\n" + f"This task was submitted from Linear issue{issue_ref}. The platform " + "manages ALL Linear interaction for you — you have no Linear tools and " + "must not try to call any:\n\n" + "- **Context is already here.** The issue title, description, recent " + "human comments, the reporter's uploaded files (inline images and " + "paperclip attachments), and the content of any project wiki documents " + "have been pre-fetched and included in your task description (see the " + "`## Project documents` and `## Recent comments` sections if present) + " + "attachments. You have no way to fetch more from Linear, so work from " + "what you've been given. If the task clearly references material that " + "ISN'T in your context (an external link, a file you can't see, or a doc " + "noted as present-but-not-included), don't guess — say so in the PR and " + "proceed with best effort on what you have.\n" + "- **Status is automatic.** The platform posts the issue reactions " + "(👀 on start, ✅/❌ on finish), moves the issue through its workflow " + "states (In Progress → In Review), posts the start + PR-opened comments, " + "and posts the final ✅/⚠️/❌ summary with cost/PR-link metrics. Do NOT " + "post Linear comments or change the issue state yourself — you'd only " + "duplicate the platform's messages.\n\n" + "Just do the code work: make the change, open the PR, and let the " + "platform narrate it. Reference issues/PRs in your GitHub PR description " + "as usual.\n" ) diff --git a/agent/src/prompts/__init__.py b/agent/src/prompts/__init__.py index 60c5b2c03..c4b6e2621 100644 --- a/agent/src/prompts/__init__.py +++ b/agent/src/prompts/__init__.py @@ -1,9 +1,9 @@ """Prompt module — selects the system prompt template by resolved workflow id. -In Phases 1-3 the runner uses these built-in prompt modules (the workflow file's -``prompt.template: registry://...`` is resolved by the registry only in Phase 4, -per WORKFLOWS.md). The lookup is keyed by workflow id; ``DEFAULT_WORKFLOW_ID`` is -the fallback for an unknown/absent id. +Today the runner uses these built-in prompt modules. A planned later stage will +instead resolve a workflow file's ``prompt.template: registry://...`` through a +prompt registry (see WORKFLOWS.md). The lookup is keyed by workflow id; +``DEFAULT_WORKFLOW_ID`` is the fallback for an unknown/absent id. """ from shell import log @@ -13,23 +13,27 @@ from .new_task import NEW_TASK_WORKFLOW from .pr_iteration import PR_ITERATION_WORKFLOW from .pr_review import PR_REVIEW_WORKFLOW +from .restack import RESTACK_WORKFLOW from .web_research import WEB_RESEARCH_PROMPT DEFAULT_WORKFLOW_ID = "coding/new-task-v1" -# The fallback template for a repo-less id without its own registered prompt -# (e.g. knowledge/web-research-v1 until its prompt ships in the #246 registry). -# Falling back to the *coding* default would leak {repo_url}/{branch_name} -# placeholders the repo-less prompt builder cannot substitute. +# The fallback template for a repo-less workflow id that has no registered prompt +# of its own. Falling back to the *coding* default would leak +# {repo_url}/{branch_name} placeholders the repo-less prompt builder cannot +# substitute, so repo-less ids fall back to a repo-less prompt instead. REPO_LESS_DEFAULT_WORKFLOW_ID = "default/agent-v1" _PROMPTS = { "coding/new-task-v1": BASE_PROMPT.replace("{workflow}", NEW_TASK_WORKFLOW), "coding/pr-iteration-v1": BASE_PROMPT.replace("{workflow}", PR_ITERATION_WORKFLOW), "coding/pr-review-v1": BASE_PROMPT.replace("{workflow}", PR_REVIEW_WORKFLOW), - # Repo-less knowledge workflow (#248 Phase 3) — no git/branch/PR placeholders. + # Re-stack: re-merge a changed predecessor branch into an existing + # stacked-child branch. Pushes to the existing PR; not new work. + "coding/restack-v1": BASE_PROMPT.replace("{workflow}", RESTACK_WORKFLOW), + # Repo-less knowledge workflow — no git/branch/PR placeholders. "default/agent-v1": DEFAULT_AGENT_PROMPT, - # Repo-less reference knowledge workflow (#248) — research-specialized prompt - # so it no longer silently degrades to the generic default-agent prompt. + # Repo-less reference knowledge workflow — a research-specialized prompt so it + # no longer silently degrades to the generic default-agent prompt. "knowledge/web-research-v1": WEB_RESEARCH_PROMPT, } @@ -37,17 +41,16 @@ def get_system_prompt(workflow_id: str = DEFAULT_WORKFLOW_ID, *, repo_less: bool = False) -> str: """Return the system prompt template for the given resolved workflow id. - Falls back to a built-in template for an id without its own (e.g. a - registry-only workflow in Phase 4). The fallback is the **repo-less** - default-agent prompt when ``repo_less`` is set (so a knowledge workflow never - inherits the coding prompt's git/branch/PR placeholders), else the coding - default. + Falls back to a built-in template for an id that has no registered prompt of + its own. The fallback is the **repo-less** default-agent prompt when + ``repo_less`` is set (so a knowledge workflow never inherits the coding + prompt's git/branch/PR placeholders), else the coding default. """ fallback = REPO_LESS_DEFAULT_WORKFLOW_ID if repo_less else DEFAULT_WORKFLOW_ID if workflow_id not in _PROMPTS: - # No registered prompt for this id (typo, or a registry-only workflow - # whose prompt ships in Phase 4). Surface it: silently substituting a - # generic prompt degrades agent behavior with zero signal otherwise. + # No registered prompt for this id (a typo, or a workflow whose prompt + # has not shipped yet). Surface it: silently substituting a generic + # prompt degrades agent behavior with zero signal otherwise. log( "WARN", f"no registered system prompt for workflow {workflow_id!r}; " diff --git a/agent/src/prompts/base.py b/agent/src/prompts/base.py index c831d00af..be6c81a58 100644 --- a/agent/src/prompts/base.py +++ b/agent/src/prompts/base.py @@ -19,7 +19,13 @@ ## Environment - You are running inside an isolated container with shell access. -- The repository `{repo_url}` is already cloned at `{workspace}/{task_id}`. +- The repository `{repo_url}` is already cloned and checked out at \ +`{workspace}/{task_id}`, which is your current working directory. **Work there \ +— do NOT run `git clone` or `gh repo clone` to fetch it again, and do NOT `cd` \ +to a different copy.** The platform tracks THIS directory to open your pull \ +request; commits made in any other clone are invisible to it and will be \ +discarded as lost work. If you ever find yourself outside `{workspace}/{task_id}`, \ +`cd` back to it before committing. - You are on branch `{branch_name}`. - The repository default branch is `{default_branch}`. - Git is configured and authenticated — `git push` works without extra setup. diff --git a/agent/src/prompts/new_task.py b/agent/src/prompts/new_task.py index 2496fd844..ac4bc6675 100644 --- a/agent/src/prompts/new_task.py +++ b/agent/src/prompts/new_task.py @@ -9,11 +9,50 @@ Read relevant files, check the project structure, look at existing tests, \ build scripts, and CI configuration. Understand the project before changing it. -2. **Work on the task** - Make the necessary code changes. Be thorough but focused — only change what \ -the task requires. Do not refactor unrelated code. +2. **Decide: can you act on this safely, or do you need to ask first?** + Before writing any code, judge whether the request tells you WHAT to change \ +and WHAT "done" looks like. Most tasks do — proceed. But some requests name a \ +GOAL without saying what to actually do, so any PR would be a guess at the \ +requester's intent. You MUST ask instead of guessing when the request is a bare \ +quality/direction adjective with no concrete target, metric, scope, or named \ +problem, e.g.: + - "make it faster" / "improve performance" — no page/flow named, no metric \ +or target (which part is slow? by how much? what's the budget?) + - "make it better" / "improve the UI" / "clean it up" — no direction + - "make the site nicer" / "it feels a bit plain" / "make it pop" / "more \ +modern" — a whole-site or whole-page aesthetic verdict is NOT a concrete target: \ +no page or element is named and "nicer"/"plain" doesn't say what to change. An \ +adjective describing how something FEELS is a direction-without-substance, not a \ +named problem — ask which page/section and what "nicer" means to them (colours? \ +spacing? imagery? animation?) rather than picking a redesign and shipping it. + - "fix the bug" — no reproduction, no error, and none findable in the code + In these cases do NOT pick a plausible interpretation and ship it (even a \ +"safe, universally-good" change is still a guess at what they wanted, and they \ +get charged for it). Instead, **call the `request_clarification` tool** with ONE \ +short, specific question that names exactly what you need and offers concrete \ +options (e.g. "Which feels slow — initial page load, navigation, or images? And \ +is there a target, like under 1s?"). Calling that tool opens NO pull request and \ +charges nothing for a guess — the platform posts your question to the requester \ +and ends the task. After calling it, STOP: do not commit, do not run the build, \ +do not open a PR. (If the `request_clarification` tool is not available, instead \ +make your FINAL message the question, prefixed on its own first line with the \ +exact marker `{needs_input_marker}`.) + - This is ONLY for goal-without-substance requests. A request that names \ +what to change (even loosely) is actionable — make the reasonable call on \ +low-stakes details and note it in the PR (step 5). When you can name a specific, \ +concrete, low-risk deliverable that unambiguously satisfies the request, do it; \ +when you'd be picking among materially different interpretations, ask. -3. **Test your changes** +3. **Work on the task** + Make the necessary code changes. Be thorough but focused — implement exactly \ +what the task asks for. Do NOT add features, endpoints, buttons, or behavior \ +that weren't requested, and do NOT refactor unrelated code. If, while working, \ +you find the task implies something surprising or much larger than it first \ +appeared (e.g. a one-word request that would touch many files), do the \ +smallest faithful interpretation and call out the surprising scope in the PR \ +description rather than silently building it all. + +4. **Test your changes** This step is MANDATORY — do NOT skip it. - Run the project build: `mise run build` - Run linters and type-checkers if available. @@ -22,7 +61,7 @@ check, dry-run) and note this in the PR. - Report test and build results in the PR description — both passes and failures. -4. **Commit and push frequently** +5. **Commit and push frequently** After each logical unit of work, commit and push: ``` git add @@ -35,16 +74,23 @@ Do NOT accumulate large uncommitted changes — pushing frequently is your \ durability mechanism. -5. **Create a Pull Request** +6. **Create a Pull Request** When the work is complete (or after exhausting attempts), you MUST create a PR. \ -Do NOT skip this step or tell the user to do it manually. +Do NOT skip this step or tell the user to do it manually. (The one exception is \ +the clarify-and-hold case in step 2 — if you asked a clarifying question and \ +made no changes, do NOT open a PR.) The PR body must include a section titled "## Agent notes" with: - What went well and what was difficult - Any patterns or conventions you discovered about this repo - Suggestions for future tasks on this repo - Run: + Run this EXACTLY — the `--base` value is chosen for you (for a stacked \ +sub-issue it is the predecessor's branch so the PR shows only your delta; for \ +a normal task it is the repo default). Do NOT substitute a different base such \ +as `main`/`master` even if this branch was cut from another branch — targeting \ +the wrong base makes the PR show the whole branch divergence instead of your \ +change: ``` gh pr create --repo {repo_url} --head {branch_name} --base {default_branch} --title "(): " --body "" ``` diff --git a/agent/src/prompts/pr_iteration.py b/agent/src/prompts/pr_iteration.py index 8289b2c5f..83ae04c07 100644 --- a/agent/src/prompts/pr_iteration.py +++ b/agent/src/prompts/pr_iteration.py @@ -3,10 +3,29 @@ PR_ITERATION_WORKFLOW = """\ ## Workflow -You are iterating on an existing pull request (PR #{pr_number}). Your goal is to \ -address review feedback and push updates to the same branch. +You are responding to a comment on an existing pull request (PR #{pr_number}). -Follow these steps in order: +**First, decide what the comment is asking for:** + +- **A QUESTION or request for information** (e.g. "where is the login page?", \ +"why did you use JWT?", "does this handle logout?"). ANSWER it directly and \ +concisely from the code/PR. Do NOT invent a code change to justify a commit — \ +if no change is actually needed, make none. Post your answer as a PR comment \ +(`gh pr comment {pr_number} --repo {repo_url} --body ""`) and STOP. Your \ +final message should BE the answer (it is surfaced back to the requester). Do not \ +push an empty or cosmetic commit just to have "done something". + +- **A CHANGE REQUEST** (e.g. "rename this", "add validation", "fix the bug", \ +"make the header blue"). Address it by editing code and pushing to the branch, \ +following the steps below. + +If genuinely ambiguous whether it's a question or a change, treat it as a \ +question and ask for clarification rather than guessing at a change. + +--- + +For a CHANGE REQUEST, your goal is to address the feedback and push updates to \ +the same branch. Follow these steps in order: 1. **Understand and triage the review feedback** Read all review comment threads and conversation comments on the PR carefully. \ diff --git a/agent/src/prompts/restack.py b/agent/src/prompts/restack.py new file mode 100644 index 000000000..0f44e64b7 --- /dev/null +++ b/agent/src/prompts/restack.py @@ -0,0 +1,59 @@ +"""Workflow section for restack — re-merge a changed predecessor. + +A stacked child's predecessor PR was edited after the child already merged the +predecessor's code in, so the child is stale. The platform re-runs the child on +its EXISTING branch with the updated predecessor branch(es) merged into the +working tree before the agent starts (the same merge mechanism used when a child +is first built on top of its predecessors). The agent's job is narrow: +reconcile, verify, push to the same branch — NOT new feature work. +""" + +RESTACK_WORKFLOW = """\ +## Workflow + +You are RE-STACKING an existing pull request branch (`{branch_name}`). A +predecessor branch this work was built on has changed, and its updated code has +already been merged into your working tree before you started. Your only job is +to reconcile that update — do NOT add features or change scope. + +Follow these steps in order: + +1. **Assess the merged-in predecessor changes** + The setup notes above record which predecessor branch(es) were merged in and + whether the merge was clean or left conflicts. Read them first. + - If a merge was aborted due to conflicts, the predecessor branch is fetched + as `origin/`; merge it now and resolve the conflicts so your + branch contains both your original work AND the updated predecessor code. + - If the merge was clean, just verify your original changes still apply on top + of the updated predecessor code (the predecessor may have moved code you + depended on). + +2. **Reconcile — keep BOTH sides** + The goal is a branch that has your sub-issue's changes correctly layered on + the predecessor's NEW code. Do not drop your work, and do not revert the + predecessor's update. Resolve conflicts by integrating both intents. + +3. **Test your changes (MANDATORY)** + - Run the project build: `mise run build` + - Run linters/type-checkers if available. + - Run tests if the project has them (`npm test`, `pytest`, `make test`). + - The combined result must build — a re-stack that doesn't build is worse + than the stale state it replaced. + +4. **Commit and push to `{branch_name}` (the SAME branch — do not create a new one)** + ``` + git add + git commit -m "chore(restack): re-merge updated predecessor into {branch_name}" + git push origin {branch_name} + ``` + Pushing to the existing branch updates the existing PR in place — the + platform does NOT open a new PR for a re-stack. + +5. **Post a brief summary comment on the PR** + ``` + gh pr comment {pr_number} --repo {repo_url} --body "" + ``` + Note which predecessor change was absorbed, any conflicts resolved, and the + build/test result. Keep it concise — this is a maintenance update, not a new + review.\ +""" diff --git a/agent/src/repo.py b/agent/src/repo.py index 59929556d..19f2aa7ec 100644 --- a/agent/src/repo.py +++ b/agent/src/repo.py @@ -8,11 +8,31 @@ from models import RepoSetup, TaskConfig from shell import log, run_cmd, run_cmd_with_backoff, slugify +# Directories never worth scanning for nested mise configs (huge, and any +# ``mise.toml`` inside a dependency tree is not ours to trust). Bounds the walk +# on a large clone so the trust step stays fast. +_MISE_CONFIG_SKIP_DIRS = frozenset({".git", "node_modules", ".venv", "cdk.out", "dist", "build"}) + + +def _find_mise_configs(repo_dir: str) -> list[str]: + """Return every ``mise.toml`` under *repo_dir* EXCEPT the root one (already + trusted by ``mise trust ``), skipping vendored/build dirs. + + A monorepo has per-package config roots (``cdk/mise.toml`` etc.); each must + be trusted or ``mise run `` fanning into it fails at the trust gate. + """ + configs: list[str] = [] + for dirpath, dirnames, filenames in os.walk(repo_dir): + dirnames[:] = [d for d in dirnames if d not in _MISE_CONFIG_SKIP_DIRS] + if "mise.toml" in filenames and os.path.abspath(dirpath) != os.path.abspath(repo_dir): + configs.append(os.path.join(dirpath, "mise.toml")) + return configs + def _clone_backoff_reporter(progress: Any, label: str): """Build an ``on_retry`` callback that emits a ``dependency_unreachable`` - blocker event per transient retry (#251, Phase 2) — auditable in the live - stream + 90d record. Returns ``None`` when no progress writer is wired so + blocker event per transient retry — auditable in the live stream and the + retained event record. Returns ``None`` when no progress writer is wired so ``run_cmd_with_backoff`` simply logs to CMD.""" if progress is None: return None @@ -35,10 +55,11 @@ def _on_retry(attempt: int, max_attempts: int, stderr: str) -> None: class DependencyUnreachableError(RuntimeError): - """Raised when repo setup cannot reach a dependency after bounded retries - (#251, Phase 2). Its message is the canonical ``BLOCKED[dependency_unreachable]`` - reason so the crash path carries it into the terminal ``error`` verbatim and - the CDK classifier attaches a precise remedy.""" + """Raised when repo setup cannot reach a dependency after bounded retries. + + Its message is the canonical ``BLOCKED[dependency_unreachable]`` reason so the + crash path carries it into the terminal ``error`` verbatim and the platform's + error classifier attaches a precise remedy.""" def _fail_setup_command(label: str, resource: str, stderr: str, progress: Any) -> None: @@ -56,7 +77,7 @@ def _fail_setup_command(label: str, resource: str, stderr: str, progress: Any) - 2. **Transient** — a DNS/registry blip that survived the retries with no nameable host: report a retryable ``dependency_unreachable`` blocker. 3. **Permanent** — repo not found, auth denied: re-raise a plain - ``RuntimeError`` carrying the redacted git stderr, preserving the pre-#251 + ``RuntimeError`` carrying the redacted git stderr, preserving the original ``check=True`` behavior so the classifier routes it to the right (auth / not-found) remedy rather than mislabeling it retryable. @@ -92,7 +113,7 @@ def _fail_setup_command(label: str, resource: str, stderr: str, progress: Any) - if not is_transient_cmd_failure(stderr): # (3) Permanent. Redact before raising — this message is persisted to - # TaskResult.error (DynamoDB, `bgagent status`). The pre-#251 check=True + # TaskResult.error (DynamoDB, `bgagent status`). The original check=True # path redacted here (shell.py run_cmd); preserve that so a credential in # git stderr never lands in cleartext. snippet = redact_secrets((stderr or "").strip()[:500]) @@ -121,6 +142,49 @@ def _fail_setup_command(label: str, resource: str, stderr: str, progress: Any) - ) +def _prepare_clone_dir(repo_dir: str, notes: list[str]) -> None: + """Guarantee a clean slate at *repo_dir* before cloning. + + On persistent session storage the workspace path (``/workspace/``) + can carry residue from a prior run/task sharing the id or mount. If repo_dir + already exists and is NON-EMPTY, ``gh repo clone `` exits 128 + ("directory not empty") WITHOUT nesting — but the agent, whose cwd IS + repo_dir, then works against whatever stale tree is there (or re-clones into a + subdir), while every pipeline git op (ensure_committed / ensure_pr / + ensure_pushed) runs against repo_dir's ROOT. Those trees diverge silently: + the agent's edits/commits land in the inner tree, the outer tree stays clean, + ensure_committed sees nothing to commit, ensure_pr finds no commits, and the + task reports a false COMPLETED with the work lost. Removing the residue so the + clone lands DIRECTLY in an empty repo_dir closes that divergence at the + source; :func:`_assert_clone_root` is the post-clone backstop.""" + if os.path.exists(repo_dir) and os.listdir(repo_dir): + log( + "SETUP", + f"Workspace {repo_dir} is non-empty before clone (persistent-storage " + "residue) — clearing it so the clone lands at the root, not nested", + ) + notes.append("cleared pre-existing workspace residue before clone") + import shutil + + shutil.rmtree(repo_dir, ignore_errors=True) + + +def _assert_clone_root(repo_dir: str) -> None: + """Backstop: assert the clone produced a git root AT *repo_dir*. + + If ``.git`` is missing here the working tree the agent will edit (cwd=repo_dir) + is NOT a checkout the pipeline's git ops can see — every commit/PR step would + silently no-op and the task would report a false success with the work lost. + Fail loudly at setup (a plain RuntimeError → terminal FAILED with a clear + reason) instead of after the agent has burned a run.""" + if not os.path.isdir(os.path.join(repo_dir, ".git")): + raise RuntimeError( + "clone did not produce a git repository at the workspace root " + f"({repo_dir}/.git missing after clone) — the agent's working tree " + "would not be the tracked checkout; refusing to run so no work is lost" + ) + + def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: """Clone repo, create branch, configure git auth, run mise install. @@ -128,16 +192,28 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: lint_before, and default_branch. ``progress`` is optional (preserves legacy/test call shape). When present, - transient clone/fetch retries emit ``dependency_unreachable`` blocker - events (#251, Phase 2). + transient clone/fetch retries emit ``dependency_unreachable`` blocker events. """ repo_dir = f"{AGENT_WORKSPACE}/{config.task_id}" notes: list[str] = [] - if config.is_pr_workflow and config.branch_name: + # Always use the platform-provided branch name verbatim when present. + # The platform computes branch_name (gateway.ts generateBranchName/slugify) + # and persists it on the TaskRecord AND, for stacked children, as the + # predecessor's child_branch_name that the reconciler hands to the next + # child as its base. If the agent re-derives the slug here it produces a + # DIFFERENT string (shell.py slugify strips dots vs gateway's dash, and + # truncates at 40 vs 50) — e.g. ``...guide.html`` → agent ``guidehtml`` vs + # platform ``guide-html``. That divergence means a stacked child's + # ``git fetch origin `` 404s and it silently falls back + # to branching off main (breaking the stack). Use config.branch_name as-is. + if config.branch_name: branch = config.branch_name else: - # Derive branch slug from issue title or task description + # Fallback only when the platform supplied no branch (older callers / + # direct invocations). Derive a slug from the issue title or task + # description. NOTE: this path's slug may differ from the platform's; + # it exists for resilience, not for the orchestrated/standard flow. title = "" if config.issue: title = config.issue.title @@ -146,6 +222,9 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: slug = slugify(title) branch = f"bgagent/{config.task_id}/{slug}" + # Guarantee a clean slate at repo_dir BEFORE cloning. + _prepare_clone_dir(repo_dir, notes) + # Mark the repo directory as safe for git. On persistent session storage # the mount may be owned by a different UID than the container user, # triggering git's "dubious ownership" check on clone/resume. @@ -154,7 +233,7 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: label="safe-directory", ) - # Clone — bounded retry on transient network/registry failures (#251). + # Clone — bounded retry on transient network/registry failures. log("SETUP", f"Cloning {config.repo_url}...") clone_result = run_cmd_with_backoff( ["gh", "repo", "clone", config.repo_url, repo_dir], @@ -164,6 +243,9 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: if clone_result.returncode != 0: _fail_setup_command("clone", config.repo_url, clone_result.stderr, progress) + # Backstop: assert the clone produced a git root AT repo_dir. + _assert_clone_root(repo_dir) + # Pin the remote to the plain https URL (no embedded credentials) and # authenticate git push via gh's credential helper. Embedding the token # in the remote URL would persist it in .git/config inside the workspace @@ -190,6 +272,19 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: ) # Branch setup + head_sha_before = "" + # For a DIAMOND child (2+ predecessors → base_branch set AND merge_branches + # non-empty), the orchestrator hardcodes the literal 'main' as base_branch + # (it has no server-side record of the repo's real default branch). On a repo + # whose real default is NOT main, branching a diamond off 'main' makes its PR + # target main and show the whole default↔main divergence (tens of thousands + # of lines) instead of the child's small delta. Resolve the REAL default here + # (the same detect_default_branch the root path already uses reliably) and use + # it for BOTH the checkout base and the PR base (setup.default_branch below), + # so the diamond stacks on the real default and its small delta is reviewable. + # Only a diamond overrides; a LINEAR child's base_branch is its predecessor + # branch (correct — the PR stacks on it) and must be left untouched. + resolved_diamond_base: str | None = None if config.is_pr_workflow and config.branch_name: log("SETUP", f"Checking out existing PR branch: {branch}") fetch_result = run_cmd_with_backoff( @@ -205,17 +300,102 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: label="checkout-pr-branch", cwd=repo_dir, ) + # Snapshot the branch HEAD BEFORE the agent runs. The post-hooks compare + # the final HEAD to this to tell a real edit (HEAD advanced) from a + # question-only iteration (HEAD unchanged → no commit), so the platform + # reports "answered / no change" rather than a false "✅ Updated". Capture + # AFTER any predecessor merges would advance HEAD — but pr_iteration / + # pr_review pass no merge_branches, so the checkout HEAD is the baseline. + # (Restack DOES merge predecessors and isn't a comment-iteration, so its + # HEAD-advance is expected and never reaches the no-op reply path.) + sha_res = run_cmd( + ["git", "rev-parse", "HEAD"], + label="head-sha-before", + cwd=repo_dir, + check=False, + ) + if sha_res.returncode == 0: + head_sha_before = sha_res.stdout.strip() + # Re-stack: a predecessor branch changed; merge its UPDATED code into + # this existing PR branch so the child is no longer stale. + # (pr_iteration / pr_review pass no merge_branches, so this is a no-op + # for them — only the restack path threads predecessors here.) + for pred_branch in config.merge_branches: + _merge_predecessor_branch(repo_dir, pred_branch, notes) + elif config.base_branch: + # Stacked child. Branch from the predecessor's branch (linear) or from + # the repo default (diamond) so the child sees predecessor code without + # waiting for a human merge. fetch the base first — it is an unmerged + # sibling branch that the fresh clone may not have locally. + base_branch = config.base_branch + # A diamond rewrites the orchestrator's 'main' literal to the real + # default before fetch/checkout (see the block comment above); a linear + # child (no merge_branches) keeps its predecessor base untouched. + if config.merge_branches: + detected = detect_default_branch(config.repo_url, repo_dir) + if detected != base_branch: + log( + "SETUP", + f"Diamond base: server passed '{base_branch}', using detected " + f"repo default '{detected}' instead (F1 wrong-base guard)", + ) + base_branch = detected + resolved_diamond_base = base_branch + log("SETUP", f"Creating branch {branch} from base {base_branch}") + fetch_res = run_cmd( + ["git", "fetch", "origin", base_branch], + label="fetch-base-branch", + cwd=repo_dir, + check=False, + ) + if fetch_res.returncode == 0: + run_cmd( + ["git", "checkout", "-b", branch, f"origin/{base_branch}"], + label="create-branch-from-base", + cwd=repo_dir, + ) + else: + # Base branch not found on origin (e.g. predecessor PR already + # merged + branch deleted, or a transient fetch error). Fall + # back to a normal branch off the current HEAD so the child + # still runs rather than failing setup; the predecessor's code + # is likely in the default branch by now anyway. + notes.append(f"base branch '{base_branch}' not fetchable; branched off default instead") + log("SETUP", f"Base branch not found; creating {branch} off HEAD") + run_cmd(["git", "checkout", "-b", branch], label="create-branch", cwd=repo_dir) + + # Diamond: merge each predecessor branch into this child's branch + # so it sees ALL predecessors' code (the base only gave it one). + for pred_branch in config.merge_branches: + _merge_predecessor_branch(repo_dir, pred_branch, notes) else: log("SETUP", f"Creating branch: {branch}") run_cmd(["git", "checkout", "-b", branch], label="create-branch", cwd=repo_dir) - # Trust mise config files in the cloned repo (required before mise install) + # Trust mise config files in the cloned repo (required before mise install + # AND before every `mise run `). ``mise trust `` trusts only the + # ROOT ``mise.toml`` — but a monorepo has per-package config ROOTS + # (``cdk/mise.toml``, ``cli/mise.toml``, ``agent/mise.toml``, ``docs/mise.toml`` + # here). When ``mise run build`` fans out into ``//cdk:eslint`` etc. it loads + # the nested config, which is UNtrusted → ``mise ERROR Config files … are not + # trusted`` → exit 1, and the whole build/lint gate dies in seconds BEFORE + # anything compiles. (`mise trust --all` only covers cwd + PARENTS, not + # children, so it doesn't help.) Trust every ``mise.toml`` in the clone. + # Without this, fresh-clone builds failed the baseline at the trust gate, + # indistinguishable in the log from a red build. run_cmd( ["mise", "trust", repo_dir], label="mise-trust", cwd=repo_dir, check=False, ) + for cfg in _find_mise_configs(repo_dir): + run_cmd( + ["mise", "trust", cfg], + label="mise-trust-nested", + cwd=repo_dir, + check=False, + ) # mise install (deterministic — not left to the LLM) log("SETUP", "Running mise install...") @@ -231,48 +411,210 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: else: notes.append("mise install: OK") - # Initial build (record whether the project builds before agent changes) - log("SETUP", "Running initial build (mise run build)...") - result = run_cmd( - ["mise", "run", "build"], - label="mise-run-build-pre", - cwd=repo_dir, - check=False, + # Initial build (record whether the project builds before agent changes). + # Use the repo's configured build command (default mise run build). + from post_hooks import ( + BUILD_VERIFY_TIMEOUT_S, + DEFAULT_BUILD_COMMAND, + DEFAULT_LINT_COMMAND, + is_infra_failure, + is_verify_command_inert, + resolve_verify_argv, ) - if result.returncode != 0: - note = "Initial build (mise run build) FAILED before agent changes" - notes.append(note) - build_before = False - else: - notes.append("Initial build (mise run build): OK") - build_before = True - # Initial lint baseline (record whether lint passes before agent changes) - log("SETUP", "Running initial lint (mise run lint)...") - result = run_cmd( - ["mise", "run", "lint"], - label="mise-run-lint-pre", - cwd=repo_dir, - check=False, - ) - if result.returncode != 0: - note = "Initial lint (mise run lint) FAILED before agent changes" - notes.append(note) - lint_before = False - else: - notes.append("Initial lint (mise run lint): OK") + # A read_only workflow clones, reads/greps, and + # emits an artifact — it NEVER edits code, runs the post-agent build/lint + # gate, or opens a PR. Running the full pre-agent `mise run build` + lint + # baseline for it is pure waste: on a big repo that baseline is the + # multi-minute CI-parity build, and it will not fit the smaller memory budget + # provisioned for a read-only planning task (it would stall or OOM the planner + # before it ever reads a file). Skip both baselines for read_only and record + # neutral "OK" values (there is no regression to gate against — nothing gets + # committed). No baseline is ever compared for a read_only run, so these + # values are informational only. + build_gate_inert = False + lint_gate_inert = False + if config.read_only: + log("SETUP", "Skipping build/lint baseline for read-only workflow (no build, no PR)") + notes.append("Read-only workflow: skipped pre-agent build/lint baseline (planning only)") + build_before = True lint_before = True - - # Detect default branch - # For PR tasks (pr_iteration, pr_review): use base_branch from orchestrator if available - if config.is_pr_workflow and config.base_branch: - default_branch = config.base_branch else: - default_branch = detect_default_branch(config.repo_url, repo_dir) + build_argv = resolve_verify_argv(config.build_command, DEFAULT_BUILD_COMMAND) + build_cmd_str = " ".join(build_argv) + log("SETUP", f"Running initial build ({build_cmd_str})...") + # Use the same generous wall-clock ceiling as the POST-agent gate + # (BUILD_VERIFY_TIMEOUT_S, 30min) — NOT run_cmd's 600s default — and GUARD + # the timeout. A heavy CI-parity baseline build (install + compile + full + # test suite + synth) legitimately runs longer than 10min; at 600s it + # would raise TimeoutExpired here (this call had no try/except) and crash + # the whole task BEFORE the agent ever ran, so the issue got no PR and sat + # in Backlog — indistinguishable from a real failure. The baseline is only + # informational (it seeds regression gating); a timeout means "no usable + # baseline", NOT "the agent broke it", so we treat it as no-known-regression + # and let the run proceed. The post-agent gate re-runs the build with the + # same ceiling and surfaces an honest "timed out" if it's genuinely too slow. + try: + result = run_cmd( + build_argv, + label="verify-build-pre", + cwd=repo_dir, + check=False, + timeout=BUILD_VERIFY_TIMEOUT_S, + # Stream live → the full baseline-build log reaches CloudWatch + # verbatim (buffered capture would hide the failing sub-task). + stream=True, + ) + except subprocess.TimeoutExpired: + log( + "WARN", + f"Initial build ({build_cmd_str}) did not finish within " + f"{BUILD_VERIFY_TIMEOUT_S}s — skipping baseline (not a regression)", + ) + notes.append( + f"Initial build ({build_cmd_str}) did not finish within " + f"{BUILD_VERIFY_TIMEOUT_S}s — baseline skipped (not treated as a regression)" + ) + build_before = True + else: + if result.returncode != 0 and is_infra_failure(result.returncode, result.stderr): + # An ENVIRONMENT fault (OOM / exit 137 / out of disk) means the + # baseline build was KILLED, not that the code is broken. Treat it + # exactly like the timeout case above: there is NO usable baseline, + # so record no-known-regression (build_before=True) rather than the + # false "the project was already broken" (build_before=False). + # + # This was a real failure mode: several heavy CI-parity builds + # shared one host, the baseline was OOM-killed (exit 137), and the + # generic non-zero branch below mislabeled it build_before=False. + # That false "already red" flag then told the regression gate + # "red-before → red-after isn't the agent's fault → ✅" AND flowed + # into the orchestration gate as a node failure — for a task that + # GitHub built green. The post-agent gate already had this OOM + # check (is_infra_failure); the pre-agent baseline was missing it. + log( + "WARN", + f"Initial build ({build_cmd_str}) was KILLED by an environment " + f"fault (exit {result.returncode}: out of memory/disk) — no usable " + "baseline, treating as no-known-regression (not 'already broken')", + ) + notes.append( + f"Initial build ({build_cmd_str}) hit an environment fault " + f"(exit {result.returncode}: out of memory/disk) before agent " + "changes — baseline skipped (not treated as a regression)" + ) + build_before = True + elif result.returncode != 0: + note = f"Initial build ({build_cmd_str}) FAILED before agent changes" + notes.append(note) + build_before = False + # If the build command could not RUN (no task / not found) AND no + # explicit build_command was configured, build-regression gating is + # INERT — flag it so the agent warns on the PR rather than silently + # passing every task. A configured command that fails to run is the + # operator's typo, not the silent-default trap, so only flag the + # unconfigured (mise-default) case. + if not config.build_command and is_verify_command_inert( + result.returncode, result.stderr + ): + build_gate_inert = True + notes.append( + "⚠️ Build-regression gating is INERT: no runnable `mise run build` task " + "in this repo and no build command configured. A change that breaks the " + "build will still report success. Set pipeline.buildCommand in the repo's " + "blueprint (e.g. 'npm run build') to enable gating." + ) + else: + notes.append(f"Initial build ({build_cmd_str}): OK") + build_before = True + + # Initial lint baseline (record whether lint passes before agent changes) + lint_argv = resolve_verify_argv(config.lint_command, DEFAULT_LINT_COMMAND) + lint_cmd_str = " ".join(lint_argv) + log("SETUP", f"Running initial lint ({lint_cmd_str})...") + # Same generous ceiling + timeout guard as the build baseline above (a + # slow lint must not crash the task before the agent runs). A timeout → + # no usable lint baseline → treat as not-a-regression. + try: + result = run_cmd( + lint_argv, + label="verify-lint-pre", + cwd=repo_dir, + check=False, + timeout=BUILD_VERIFY_TIMEOUT_S, + stream=True, # full lint output → CloudWatch verbatim + ) + except subprocess.TimeoutExpired: + log( + "WARN", + f"Initial lint ({lint_cmd_str}) did not finish within " + f"{BUILD_VERIFY_TIMEOUT_S}s — skipping baseline (not a regression)", + ) + notes.append( + f"Initial lint ({lint_cmd_str}) did not finish within " + f"{BUILD_VERIFY_TIMEOUT_S}s — baseline skipped (not treated as a regression)" + ) + lint_before = True + result = None + if result is not None and result.returncode != 0: + # Distinguish "lint couldn't RUN" (no `mise run lint` task and no + # configured lint_command — the default fired and the task doesn't exist) + # from a genuine lint failure. The former is INERT: recording it as a + # red lint baseline is misleading (e.g. a Node repo with no mise lint + # task perpetually shows lint FAIL). Only flag inert for the + # unconfigured-default case, mirroring build_gate_inert. + if not config.lint_command and is_verify_command_inert( + result.returncode, result.stderr + ): + lint_gate_inert = True + lint_before = True # no real lint baseline → don't treat as a regression source + notes.append( + f"Initial lint ({lint_cmd_str}) did not run (no runnable lint task); " + "lint verification is INERT for this repo. Set pipeline.lintCommand in the " + "repo's blueprint (e.g. 'npm run lint') to enable lint reporting." + ) + else: + note = f"Initial lint ({lint_cmd_str}) FAILED before agent changes" + notes.append(note) + lint_before = False + elif result is not None: + # Ran and passed (the timeout path already noted + set lint_before). + notes.append(f"Initial lint ({lint_cmd_str}): OK") + lint_before = True + + # Detect default branch (used as the PR base + the commit-diff range). + # - PR tasks: base_branch from the orchestrator (the PR's real base). + # - Linear stacked children: base_branch is the predecessor's branch — the + # child's PR targets it. + # - Diamond children: base_branch was the orchestrator's 'main' literal; we + # resolved the REAL repo default above (resolved_diamond_base) so the PR + # targets it, not stale main. + # - Otherwise: detect the repo default (main/master). + default_branch = ( + resolved_diamond_base + or config.base_branch + or detect_default_branch(config.repo_url, repo_dir) + ) # Install prepare-commit-msg hook for code attribution _install_commit_hook(repo_dir) + # Ensure the cloned HEAD sha is captured for NON-PR workflows too (the PR + # branch above already set it). A read-only planner echoes this + # into its plan's ``repo_digest_sha`` so a later revise run can tell if the + # repo moved since the digest was built. Best-effort: a read failure leaves it + # '' (the platform's sha-shape guard then just treats the digest as + # un-versioned — trust-but-reverify). + if not head_sha_before: + head_res = run_cmd( + ["git", "rev-parse", "HEAD"], + label="head-sha-after-setup", + cwd=repo_dir, + check=False, + ) + if head_res.returncode == 0: + head_sha_before = head_res.stdout.strip() + return RepoSetup( repo_dir=repo_dir, branch=branch, @@ -280,7 +622,54 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: build_before=build_before, lint_before=lint_before, default_branch=default_branch, + build_gate_inert=build_gate_inert, + lint_gate_inert=lint_gate_inert, + head_sha_before=head_sha_before, + ) + + +def _merge_predecessor_branch(repo_dir: str, pred_branch: str, notes: list[str]) -> None: + """Merge a predecessor branch into the current child branch (diamond stack). + + Fetches the predecessor branch and merges it so the child sees its + code. On a clean merge: done. On a CONFLICT: abort the merge (leaving + the working tree clean) and record a note. We deliberately do NOT leave + the repo in a conflicted state — the agent runs AFTER setup and a + half-merged tree would break its build/lint baseline. Instead the + predecessor branch remains fetched (``origin/``) and the + note tells the agent to integrate it as part of its task. This keeps + conflict resolution agent-driven without corrupting the deterministic + setup phase. + """ + fetch_res = run_cmd( + ["git", "fetch", "origin", pred_branch], + label="fetch-predecessor", + cwd=repo_dir, + check=False, + ) + if fetch_res.returncode != 0: + notes.append(f"predecessor branch '{pred_branch}' not fetchable; skipped merge") + log("SETUP", f"Predecessor branch not found, skipping merge: {pred_branch}") + return + + merge_res = run_cmd( + ["git", "merge", "--no-edit", f"origin/{pred_branch}"], + label="merge-predecessor", + cwd=repo_dir, + check=False, + ) + if merge_res.returncode == 0: + log("SETUP", f"Merged predecessor branch: {pred_branch}") + notes.append(f"merged predecessor branch '{pred_branch}'") + return + + # Conflict (or other merge failure): abort to keep the tree clean. + run_cmd(["git", "merge", "--abort"], label="merge-abort", cwd=repo_dir, check=False) + notes.append( + f"predecessor branch '{pred_branch}' conflicts with this branch; " + f"merge aborted — integrate origin/{pred_branch} as part of the task" ) + log("SETUP", f"Predecessor merge conflicted, aborted: {pred_branch}") def _install_commit_hook(repo_dir: str) -> None: diff --git a/agent/src/runner.py b/agent/src/runner.py index 949021976..c88f1194f 100644 --- a/agent/src/runner.py +++ b/agent/src/runner.py @@ -1,7 +1,7 @@ """Agent invocation: environment setup and Claude Agent SDK execution. -Between-turns injection seam (Phase 2 Nudges) ---------------------------------------------- +Between-turns injection seam (nudges) +------------------------------------- User nudges and other synthetic mid-task steering messages are injected via the Claude Agent SDK's ``Stop`` hook (registered in ``hooks.build_hook_matchers``), NOT the message-receive loop below. @@ -15,8 +15,8 @@ ``{"decision": "block", "reason": ""}`` causes the SDK to continue the conversation with ```` as the next user message, which is exactly the semantics we need for nudge injection. - * A module-level registry ``hooks.between_turns_hooks`` lets Phase 3 - approval gates add additional hooks without touching this file. + * A module-level registry ``hooks.between_turns_hooks`` lets other + producers (e.g. approval gates) add hooks without touching this file. Turn counting (``result.turns``) is incremented on ``AssistantMessage`` only and is NOT affected by the Stop hook's block/continue decision — nudge @@ -30,6 +30,11 @@ from typing import Any, Literal from urllib.parse import quote +from clarification_tool import ( + CLARIFICATION_SERVER_NAME, + CLARIFICATION_TOOL_NAME, + build_clarification_server, +) from config import AGENT_WORKSPACE from models import AgentResult, TaskConfig, TokenUsage from progress_writer import _ProgressWriter @@ -60,7 +65,7 @@ def _parse_token_usage(raw_usage: Any) -> TokenUsage: def _setup_bedrock_cost_attribution(config: TaskConfig) -> None: - """Wire Bedrock cost attribution for the Claude Code subprocess (#215). + """Wire Bedrock cost attribution for the Claude Code subprocess. Claude Code makes the ``InvokeModel`` calls, so attribution is configured through *its* credential + header channels, not the agent's boto3: @@ -248,11 +253,10 @@ def _initialize_policy_engine_and_hooks( Handles: * Threading per-task approval params (``initial_approvals``, - ``approval_timeout_s``, and Chunk 7's ``initial_approval_gate_count``) + ``approval_timeout_s``, and ``initial_approval_gate_count``) through to ``PolicyEngine.__init__``. - * Emitting the ``pre_approvals_loaded`` milestone (§4 step 7, §11.1) - unconditionally so "no pre-approvals seeded" is explicit rather than - inferred from silence. + * Emitting the ``pre_approvals_loaded`` milestone unconditionally so + "no pre-approvals seeded" is explicit rather than inferred from silence. * Building the SDK hook matchers that route PreToolUse / PostToolUse / Stop invocations through the engine. """ @@ -260,25 +264,22 @@ def _initialize_policy_engine_and_hooks( from policy import PolicyEngine cedar_policies = config.cedar_policies - # Cedar HITL (§7.3, §10.2) — per-task approval defaults threaded - # from the orchestrator payload. Engine clamps invalid values - # at construction. + # Per-task approval defaults threaded from the orchestrator payload. + # Engine clamps invalid values at construction. engine_kwargs: dict = {} if config.initial_approvals: engine_kwargs["initial_approvals"] = list(config.initial_approvals) if config.approval_timeout_s is not None: engine_kwargs["task_default_timeout_s"] = config.approval_timeout_s - # Chunk 7 (§13.6): seed the session counter from the TaskTable - # persisted value so a container restart mid-task resumes the - # cumulative gate budget and the ``approval_gate_cap`` remains - # the terminal bound across restarts. + # Seed the session counter from the TaskTable persisted value so a + # container restart mid-task resumes the cumulative gate budget and the + # ``approval_gate_cap`` remains the terminal bound across restarts. if config.initial_approval_gate_count: engine_kwargs["initial_approval_gate_count"] = config.initial_approval_gate_count - # Chunk 7b (§4 step 5, decision #13): adopt the per-task cap - # resolved at submit-time (blueprint override or platform default, - # frozen on the TaskRecord). When absent (legacy task predating - # Chunk 7b), ``PolicyEngine`` falls back to DEFAULT_APPROVAL_GATE_CAP - # so the behavior matches pre-Chunk-7b deploys. + # Adopt the per-task cap resolved at submit-time (blueprint override or + # platform default, frozen on the TaskRecord). When absent (a legacy task + # that predates the persisted cap), ``PolicyEngine`` falls back to + # DEFAULT_APPROVAL_GATE_CAP. if config.approval_gate_cap is not None: engine_kwargs["approval_gate_cap"] = config.approval_gate_cap policy_engine = PolicyEngine( @@ -288,19 +289,18 @@ def _initialize_policy_engine_and_hooks( extra_policies=cedar_policies if cedar_policies else None, **engine_kwargs, ) - # Chunk 7c: surface the resolved cap + its source so operators can - # distinguish a blueprint-threaded value from the engine's compile-time - # default on a container restart. Mirrors the ``approval_gate_cap_source`` - # field on the handler's "Task created" log so both ends of the cascade - # carry the same key name — CloudWatch Insights queries can - # filter/group by ``approval_gate_cap_source`` across handler + - # agent events. Value domains differ intentionally: the handler - # distinguishes ``blueprint`` vs ``platform_default``, but the - # agent only sees the threaded number (blueprint-set or default-50 - # frozen on the TaskRecord both look the same from here), so it - # emits ``threaded`` vs ``engine_default`` (the latter only fires - # for legacy tasks that predate Chunk 7b and have no cap on the - # TaskRecord at all). Cross-reference handler log at + # Surface the resolved cap + its source so operators can distinguish a + # blueprint-threaded value from the engine's compile-time default on a + # container restart. Mirrors the ``approval_gate_cap_source`` field on the + # handler's "Task created" log so both ends of the cascade carry the same + # key name — CloudWatch Insights queries can filter/group by + # ``approval_gate_cap_source`` across handler + agent events. Value domains + # differ intentionally: the handler distinguishes ``blueprint`` vs + # ``platform_default``, but the agent only sees the threaded number + # (blueprint-set or default-50 frozen on the TaskRecord both look the same + # from here), so it emits ``threaded`` vs ``engine_default`` (the latter + # only fires for legacy tasks that have no cap on the TaskRecord at all). + # Cross-reference the handler log at # ``create-task-core.ts::logger.info('Task created', ...)`` for the # ground-truth blueprint-vs-default distinction. if config.approval_gate_cap is not None: @@ -314,11 +314,10 @@ def _initialize_policy_engine_and_hooks( + cap_log, ) - # §4 step 7, §11.1: surface the starting pre-approval posture to the - # live SSE stream + 90d DDB record so operators can see exactly which - # scopes were seeded at task start. Emit unconditionally (count=0, - # scopes=[]) so "no pre-approvals seeded" is explicit rather than - # inferred from silence. + # Surface the starting pre-approval posture to the live SSE stream + + # retained DDB record so operators can see exactly which scopes were + # seeded at task start. Emit unconditionally (count=0, scopes=[]) so "no + # pre-approvals seeded" is explicit rather than inferred from silence. progress.write_approval_pre_approvals_loaded( count=len(config.initial_approvals), scopes=list(config.initial_approvals), @@ -330,6 +329,7 @@ def _initialize_policy_engine_and_hooks( task_id=config.task_id or "", progress=progress, user_id=config.user_id or "", + repo_url=config.repo_url or "", ) return policy_engine, hooks @@ -340,6 +340,19 @@ def _initialize_policy_engine_and_hooks( # Tools that mutate the working tree — dropped from the SDK surface for any # read-only workflow. _WRITE_TOOLS = frozenset(("Write", "Edit")) +# Clarify-before-spend (UX #4): workflows that do NOT get the request_clarification +# tool. pr-iteration already has its own answer-only path; web/default artifact +# tasks don't open PRs. Only the plain PR-producing new_task path benefits from an +# ask-instead-of-guess signal. +_NO_CLARIFICATION_WORKFLOW_IDS = frozenset( + ( + "coding/pr-iteration-v1", + "coding/pr-review-v1", + "coding/restack-v1", + "default/agent-v1", + "web/research-v1", + ) +) # Tools that DEFER work off-session and are hard-blocked for every task. These # launch detached / cross-session orchestration that a one-shot headless agent @@ -479,7 +492,7 @@ def _on_stderr(line: str) -> None: # When the caller (pipeline.py) injects a pre-built ``trajectory`` we # use it as-is so the pipeline can retain access to the accumulator # after ``run_agent`` returns (the --trace S3 upload runs in - # pipeline.py on terminal state — see design §10.1). For standalone + # pipeline.py on terminal state). For standalone # invocations we fall back to a fresh writer with no accumulator. if trajectory is None: trajectory = _TrajectoryWriter(config.task_id or "unknown") @@ -502,6 +515,25 @@ def _on_stderr(line: str) -> None: progress=progress, ) + # Clarify-before-spend (UX #4): register the in-process request_clarification + # tool for writeable PR-producing workflows (new_task). It lets the agent STOP + # and ask a question instead of guessing on a vague request; the runner + # captures the call below. Gated OFF for read-only workflows (pr-review) and + # artifact planners — they have their own terminal shapes and + # shouldn't grow an ask-instead path. Best-effort: a null server (SDK missing) + # just means the tool isn't offered. + mcp_servers: dict[str, Any] = {} + workflow_id = (config.resolved_workflow or {}).get("id", "") + offer_clarification = not config.read_only and workflow_id not in _NO_CLARIFICATION_WORKFLOW_IDS + if offer_clarification: + clar_server = build_clarification_server() + if clar_server is not None: + mcp_servers[CLARIFICATION_SERVER_NAME] = clar_server + # Under bypassPermissions MCP tools surface without being in + # allowed_tools, but list it explicitly so intent is clear + robust + # to a future permission-mode change. + allowed_tools = [*allowed_tools, CLARIFICATION_TOOL_NAME] + options = ClaudeAgentOptions( model=config.anthropic_model, system_prompt=system_prompt, @@ -518,6 +550,7 @@ def _on_stderr(line: str) -> None: hooks=hooks, max_budget_usd=config.max_budget_usd, stderr=_on_stderr, + **({"mcp_servers": mcp_servers} if mcp_servers else {}), ) result = AgentResult() @@ -562,7 +595,22 @@ def _on_stderr(line: str) -> None: turn_text += block.text + "\n" elif isinstance(block, ToolUseBlock): tool_input = block.input - if block.name == "Bash": + # Clarify-before-spend (UX #4): the agent called the + # request_clarification tool → capture its question. This + # is the deterministic hold signal (a tool call, not a + # reproduced sentinel). Last call wins if it somehow asks + # twice; the pipeline treats any non-empty value as a hold. + if block.name == CLARIFICATION_TOOL_NAME: + q = "" + if isinstance(tool_input, dict): + q = str(tool_input.get("question", "")).strip() + # Any non-empty value flags the hold; " " if the arg + # was blank so the signal still fires. + result.clarification_question = ( + q or result.clarification_question or " " + ) + log("TOOL", f"request_clarification: {truncate(q, 300)}") + elif block.name == "Bash": cmd = tool_input.get("command", "") log("TOOL", f"Bash: {truncate(cmd, 300)}") elif block.name in ("Read", "Glob", "Grep"): @@ -629,8 +677,8 @@ def _on_stderr(line: str) -> None: err_payload = getattr(message, "result", None) is_terminal_error = bool(getattr(message, "is_error", False)) # On a non-error result, ``message.result`` is the agent's final - # text — the deliverable for a repo-less knowledge task (#248 - # Phase 3). Capture it so deliver_artifact can upload/post it. + # text — the deliverable for a repo-less knowledge task. Capture + # it so deliver_artifact can upload/post it. if not is_terminal_error and err_payload: result.result_text = str(err_payload) # The Claude Code CLI may emit ResultMessage with subtype "success" diff --git a/agent/src/server.py b/agent/src/server.py index 9045716a4..452c984a5 100644 --- a/agent/src/server.py +++ b/agent/src/server.py @@ -308,8 +308,8 @@ def _extract_workload_access_token(request: Request) -> str: """Read AgentCore's workload access token off the inbound request. AgentCore Runtime delivers the token on `/invocations` requests under - one of two header spellings (both observed 2026-05-18 on a single - request via diagnostic logging in us-east-1): + one of two header spellings (both observed on a single request via + diagnostic logging): 1. ``WorkloadAccessToken`` — the SDK's documented header in ``bedrock_agentcore.runtime.models::ACCESS_TOKEN_HEADER``. 2. ``x-amzn-bedrock-agentcore-runtime-workload-accesstoken`` — @@ -390,11 +390,15 @@ def _run_task_background( session_id: str = "", hydrated_context: dict | None = None, system_prompt_overrides: str = "", + build_command: str = "", + lint_command: str = "", prompt_version: str = "", memory_id: str = "", resolved_workflow: dict | None = None, branch_name: str = "", pr_number: str = "", + base_branch: str | None = None, + merge_branches: list[str] | None = None, cedar_policies: list[str] | None = None, approval_timeout_s: int | None = None, initial_approvals: list[str] | None = None, @@ -434,8 +438,8 @@ def _run_task_background( except (ImportError, AttributeError) as e: _warn_cw( f"bedrock_agentcore workload-token bridge unavailable " - f"({type(e).__name__}: {e}); Linear MCP will resolve via " - "Secrets Manager fallback", + f"({type(e).__name__}: {e}); the Linear reactions token will " + "resolve via Secrets Manager fallback", task_id=task_id, ) @@ -476,11 +480,15 @@ def _run_task_background( task_id=task_id, hydrated_context=hydrated_context, system_prompt_overrides=system_prompt_overrides, + build_command=build_command, + lint_command=lint_command, prompt_version=prompt_version, memory_id=memory_id, resolved_workflow=resolved_workflow, branch_name=branch_name, pr_number=pr_number, + base_branch=base_branch, + merge_branches=merge_branches, cedar_policies=cedar_policies, approval_timeout_s=approval_timeout_s, initial_approvals=initial_approvals, @@ -525,7 +533,10 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: inp.get("model_id") or inp.get("anthropic_model") or os.environ.get("ANTHROPIC_MODEL", "") ) system_prompt_overrides = inp.get("system_prompt_overrides", "") - max_turns = int(inp.get("max_turns", 0)) or int(os.environ.get("MAX_TURNS", "100")) + # #1: per-repo build/lint verification commands. Empty → agent defaults to mise. + build_command = inp.get("build_command", "") + lint_command = inp.get("lint_command", "") + max_turns = int(inp.get("max_turns", 0)) or int(os.environ.get("MAX_TURNS", "200")) max_budget_usd = float(inp.get("max_budget_usd", 0)) or None aws_region = inp.get("aws_region") or os.environ.get("AWS_REGION", "") task_id = inp.get("task_id", "") @@ -535,6 +546,12 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: resolved_workflow = inp.get("resolved_workflow") branch_name = inp.get("branch_name", "") pr_number = str(inp.get("pr_number", "")) + # Stacked-child base branch + (diamond) predecessor branches + # to merge in. The orchestrator sets these from the orchestration row; + # absent for ordinary tasks (agent branches off main as today). + base_branch = inp.get("base_branch") or None + merge_branches_raw = inp.get("merge_branches") or [] + merge_branches = [b for b in merge_branches_raw if isinstance(b, str)] cedar_policies = inp.get("cedar_policies") or [] # Cedar HITL (§7.3) — per-task approval defaults + seeded allowlist. # Both are forwarded verbatim to the pipeline; the engine @@ -636,11 +653,15 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: "session_id": session_id, "hydrated_context": hydrated_context, "system_prompt_overrides": system_prompt_overrides, + "build_command": build_command, + "lint_command": lint_command, "prompt_version": prompt_version, "memory_id": memory_id, "resolved_workflow": resolved_workflow, "branch_name": branch_name, "pr_number": pr_number, + "base_branch": base_branch, + "merge_branches": merge_branches, "cedar_policies": cedar_policies, "approval_timeout_s": approval_timeout_s, "initial_approvals": initial_approvals, @@ -662,7 +683,8 @@ def _validate_required_params(params: dict) -> list[str]: workflow requires ``repo_url``; a repo-less workflow (``requires_repo:false``, #248 Phase 3) does not. All non-PR workflows need either an ``issue_number`` or ``task_description``; PR workflows (``coding/pr-iteration-v1`` / - ``coding/pr-review-v1``) additionally require ``pr_number``. + ``coding/pr-review-v1`` / ``coding/restack-v1``) require ``pr_number`` + instead and carry no description. """ missing: list[str] = [] workflow_id = (params.get("resolved_workflow") or {}).get("id", "coding/new-task-v1") @@ -687,7 +709,7 @@ def _validate_required_params(params: dict) -> list[str]: if requires_repo and not params.get("repo_url"): missing.append("repo_url") - if workflow_id in ("coding/pr-iteration-v1", "coding/pr-review-v1"): + if workflow_id in ("coding/pr-iteration-v1", "coding/pr-review-v1", "coding/restack-v1"): if not params.get("pr_number"): missing.append("pr_number") else: diff --git a/agent/src/shell.py b/agent/src/shell.py index 79411ed24..5d91ed4f8 100644 --- a/agent/src/shell.py +++ b/agent/src/shell.py @@ -31,14 +31,14 @@ def log(prefix: str, text: str): def log_error_cw(message: str, *, task_id: str | None = None) -> None: """Emit an ERROR line to stdout AND the APPLICATION_LOGS CloudWatch group. - Chunk 10 observability gap: ``log("ERROR", ...)`` writes to container - stdout, which AgentCore routes to + Observability gap: ``log("ERROR", ...)`` writes to container stdout, + which AgentCore routes to ``/aws/bedrock-agentcore/runtimes/-DEFAULT`` rather than the APPLICATION_LOGS group that ``TaskDashboard`` LogQueryWidgets and ``bgagent status`` read. Agent-fatal errors were therefore invisible in the two places operators normally look — discovered - during E2E 2026-05-11 T2.2 when a ``missing built-in hard-deny - policies`` crash surfaced only as a cryptic "unknown" on the CLI. + in testing when a ``missing built-in hard-deny policies`` crash + surfaced only as a cryptic "unknown" on the CLI. This helper mirrors the ERROR line to APPLICATION_LOGS via a fire-and-forget daemon thread (so it cannot block the failing @@ -209,7 +209,8 @@ def _surface_failure_lines(stdout: str) -> list[str]: tail — a parallel task DAG interleaves output so the red line is often in the middle) followed by a trailing-context tail. Deduped, order-preserving, capped. This is the fix for build-gate failures that a plain tail couldn't - explain (ABCA-662: the tail was a passing package's coverage table).""" + explain (the tail was often a passing package's coverage table, not the + error).""" lines = stdout.strip().splitlines() matched: list[str] = [] for ln in lines: @@ -237,46 +238,156 @@ def _surface_failure_lines(stdout: str) -> list[str]: return out or tail +# A process killed by a signal surfaces on ``Popen.returncode`` as the NEGATIVE +# signal number (SIGKILL -> -9), whereas a shell reports the same death as +# ``128 + signal`` (-> 137). Downstream classification keys on the shell +# convention, so normalize here — the one place the distinction is still visible +# — rather than teaching every consumer both encodings. +# +# This matters for diagnosis, not tidiness: the container/cgroup OOM-killer writes +# its "Killed process" line to the KERNEL log, not the build's stderr, so an OOM'd +# build often has NO stderr signature and the exit status is the only evidence. +# Left raw, a directly-exec'd ``mise run build`` OOM-killed at -9 misses the 137 +# check and is reported as a GENUINE build failure — telling the user their code is +# broken when the box merely ran out of memory. +_SIGNAL_EXIT_BASE = 128 + + +def _exit_status(returncode: int | None) -> int: + """Normalize a ``Popen`` return code to the shell's ``128 + signal`` form. + + ``None`` means the process was not yet reaped, which cannot happen after + ``wait()``. Map it to a non-zero failure rather than ``0``: treating an unknown + outcome as success is the one unsafe answer, since it would let a build that + was never verified report as passing. + """ + if returncode is None: + return -1 + if returncode < 0: + return _SIGNAL_EXIT_BASE - returncode + return returncode + + +def _run_cmd_streaming( + cmd: list[str], label: str, cwd: str | None, timeout: int +) -> subprocess.CompletedProcess: + """Run *cmd* streaming BOTH pipes live to the log while capturing them. + + The buffered ``subprocess.run(capture_output=True)`` path holds the ENTIRE + output in memory and never writes it to container stdout — so awslogs (→ + CloudWatch) never sees the raw stream, and the only record is whatever the + caller's post-hoc summary chooses to emit. For a heavy, long, opaque command + (``mise run build``: 4 parallel packages, 3000+ tests, ~30 min) that meant a + build failure was diagnosable ONLY if the summary happened to capture the + right lines — a failing sub-task's error would be buffered away and never + shipped, making such failures very hard to investigate. + + Streaming fixes that at the source: every line is written to the log (→ + CloudWatch verbatim, redacted) AS IT HAPPENS, so the full build log always + exists — no curated slice, no guessing which lines matter, plus live progress + instead of a silent multi-minute gap. Two drain threads (one per pipe) avoid + the classic single-thread PIPE deadlock and keep stdout/stderr SEPARATE so the + returned ``CompletedProcess`` matches ``subprocess.run``'s contract exactly + (callers still read ``.stdout`` / ``.stderr`` / ``.returncode`` unchanged). + """ + proc = subprocess.Popen( + cmd, + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, # line-buffered so each line streams promptly + env=_clean_env(), + ) + out_lines: list[str] = [] + err_lines: list[str] = [] + + def _drain(pipe, buf: list[str]) -> None: + # log() redacts each line; the buffer keeps the raw text for the caller + # (the failure classifier redacts again before it re-emits anything). + try: + for line in pipe: + line = line.rstrip("\n") + buf.append(line) + log("CMD", f" {line}") + finally: + pipe.close() + + t_out = threading.Thread(target=_drain, args=(proc.stdout, out_lines), daemon=True) + t_err = threading.Thread(target=_drain, args=(proc.stderr, err_lines), daemon=True) + t_out.start() + t_err.start() + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + # Let the drain threads finish flushing the pipes the kill closes, so + # nothing is lost, then re-raise for the caller's timeout handling. + t_out.join(timeout=10) + t_err.join(timeout=10) + raise + t_out.join(timeout=10) + t_err.join(timeout=10) + return subprocess.CompletedProcess( + cmd, _exit_status(proc.returncode), "\n".join(out_lines), "\n".join(err_lines) + ) + + def run_cmd( cmd: list[str], label: str, cwd: str | None = None, timeout: int = 600, check: bool = True, + stream: bool = False, ) -> subprocess.CompletedProcess: - """Run a command with logging.""" + """Run a command with logging. + + ``stream=True`` tees the command's output to the log line-by-line as it runs + (so the FULL output reaches CloudWatch verbatim + gives live progress) — + used for the long/opaque build & lint verify commands where a buffered, + post-hoc summary hid the real failure. Default (buffered) is unchanged for + the many short commands (git/gh/mise-install) where a curated summary is + plenty and streaming would just add noise. + """ log("CMD", redact_secrets(f"{label}: {' '.join(cmd)}")) - result = subprocess.run( - cmd, - cwd=cwd, - capture_output=True, - text=True, - timeout=timeout, - env=_clean_env(), - ) + if stream: + result = _run_cmd_streaming(cmd, label, cwd, timeout) + else: + result = subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + text=True, + timeout=timeout, + env=_clean_env(), + ) if result.returncode != 0: log("CMD", f"{label}: FAILED (exit {result.returncode})") - if result.stderr: - for line in result.stderr.strip().splitlines()[:20]: - log("CMD", f" {line}") - # ALSO surface stdout on failure. Build/test tooling (jest, tsc, the mise - # task DAG) writes the ACTUAL failing-task error to STDOUT, not stderr — - # stderr often carries only the runner's plan echo. Logging stderr alone - # made build-gate failures undebuggable: a red ``mise run build`` showed - # every task STARTING but never WHICH one failed or why (ABCA-662). - # - # A plain tail is NOT enough for a PARALLEL task DAG: `mise run build` - # runs 4 packages concurrently and interleaves their output, so the - # failing task's error scrolls into the MIDDLE while the tail captures - # whatever finished LAST (e.g. a passing package's coverage table) — - # ABCA-662 follow-up: the tail showed a coverage table, not the red task. - # So FIRST scan the whole output for failure-signature lines and surface - # those (this is what names the failing sub-task), THEN a larger tail for - # trailing context. Redact — repo build output is untrusted. - if result.stdout: - surfaced = _surface_failure_lines(result.stdout) - for line in surfaced: - log("CMD", f" {redact_secrets(line)}") + # When streaming, the FULL output already reached the log live — don't + # re-dump it. Just point at the surfaced failure lines for a quick jump. + if stream: + surfaced = _surface_failure_lines(result.stdout or "") + if surfaced: + log("CMD", f"{label}: failing lines (full output streamed above):") + for line in surfaced: + log("CMD", f" {redact_secrets(line)}") + else: + if result.stderr: + for line in result.stderr.strip().splitlines()[:20]: + log("CMD", f" {line}") + # ALSO surface stdout on failure. Build/test tooling (jest, tsc, the + # mise task DAG) writes the ACTUAL failing-task error to STDOUT, not + # stderr. A plain tail is NOT enough for a PARALLEL task DAG (the + # failing line scrolls into the MIDDLE while the tail is a passing + # package's coverage table), so scan the whole output for + # failure-signature lines FIRST, then a tail for context. Redact — + # repo build output is untrusted. (Buffered path only; the streaming + # path above already emitted everything verbatim.) + if result.stdout: + surfaced = _surface_failure_lines(result.stdout) + for line in surfaced: + log("CMD", f" {redact_secrets(line)}") if check: stderr_snippet = redact_secrets(result.stderr.strip()[:500]) if result.stderr else "" raise RuntimeError(f"{label} failed (exit {result.returncode}): {stderr_snippet}") @@ -288,7 +399,7 @@ def run_cmd( # Signatures a transient (retryable) dependency/registry failure leaves in a # command's stderr — network blips, DNS hiccups, registry 5xx / rate limits. # NOT a permanent auth/not-found error: those are re-run-won't-help and would -# just waste backoff time. Deliberately conservative (#251 dependency_unreachable). +# just waste backoff time. Deliberately conservative. _TRANSIENT_CMD_SIGNATURES: tuple[str, ...] = ( "could not resolve host", "temporary failure in name resolution", @@ -321,7 +432,7 @@ def is_transient_cmd_failure(stderr: str) -> bool: # DNS name-resolution failures that NAME a host — a firewalled / non-existent # endpoint whose name cannot be resolved. Retrying never helps, so backoff bails -# immediately (#251 review). Deliberately NARROWER than hooks.detect_egress_denial: +# immediately. Deliberately NARROWER than hooks.detect_egress_denial: # a TCP-connect failure ("Failed to connect to ... Connection timed out") # to an ALLOWLISTED host is genuinely transient and must stay retryable — so # ``Failed to connect``/``Connection refused`` are excluded here. A persistent @@ -336,7 +447,7 @@ def is_transient_cmd_failure(stderr: str) -> bool: def _names_unresolvable_host(stderr: str) -> bool: - """True when *stderr* is a DNS name-resolution failure naming a host (#251). + """True when *stderr* is a DNS name-resolution failure naming a host. Such a host cannot be reached no matter how often we retry (non-existent or firewalled at DNS), so backoff bails immediately rather than burn its budget @@ -357,7 +468,7 @@ def run_cmd_with_backoff( on_retry=None, sleep=time.sleep, ) -> subprocess.CompletedProcess: - """Run ``cmd`` with bounded retries on *transient* failures (#251, Phase 2). + """Run ``cmd`` with bounded retries on *transient* failures. Retries up to ``max_attempts`` times with exponential backoff (``base_delay_s * 2**(attempt-1)``) ONLY when the failure looks transient @@ -385,9 +496,9 @@ def run_cmd_with_backoff( return result stderr = result.stderr or "" # A named-host failure is a firewalled/non-existent endpoint — retrying - # never helps and would emit misleading dependency_unreachable events - # (#251 review). Bail immediately so _fail_setup_command reclassifies it - # to the non-retryable egress_denied remedy. + # never helps and would emit misleading dependency_unreachable events. + # Bail immediately so _fail_setup_command reclassifies it to the + # non-retryable egress_denied remedy. exhausted = attempt >= max_attempts if exhausted or not is_transient_cmd_failure(stderr) or _names_unresolvable_host(stderr): break diff --git a/agent/src/task_state.py b/agent/src/task_state.py index fbb95c4dd..bf40d712f 100644 --- a/agent/src/task_state.py +++ b/agent/src/task_state.py @@ -7,7 +7,7 @@ import os import time -from typing import Any, TypedDict +from typing import TypedDict from shell import log, log_error_cw @@ -15,13 +15,12 @@ class ApprovalRow(TypedDict): """Schema for the approval row written by ``transact_write_approval_request``. - Mirrors the DDB column layout described in design §10.1 and the - TypeScript ``ApprovalRecord`` discriminated union in - ``cdk/src/handlers/shared/types.ts``. Used as the typed contract - between the PreToolUse hook (which builds the row) and the - transactional writer (which serializes it to DDB attributes). - Pre-S7 the function accepted a bare ``dict`` so missing or - misspelled fields would fail at runtime, not at the call site. + Mirrors the DDB column layout and the TypeScript ``ApprovalRecord`` + discriminated union in ``cdk/src/handlers/shared/types.ts``. Used as + the typed contract between the PreToolUse hook (which builds the row) + and the transactional writer (which serializes it to DDB attributes). + Earlier the function accepted a bare ``dict`` so missing or misspelled + fields would fail at runtime, not at the call site. """ task_id: str @@ -246,7 +245,9 @@ def write_terminal(task_id: str, status: str, result: dict | None = None) -> Non return now = _now_iso() expr_names = {"#s": "status"} - expr_values: dict[str, Any] = { + # Mixed value types: most are strings, but build_passed/lint_passed are + # persisted as native booleans (the reconciler reads them via .BOOL). + expr_values: dict[str, object] = { ":s": status, ":t": now, ":sca": f"{status}#{now}", @@ -257,9 +258,8 @@ def write_terminal(task_id: str, status: str, result: dict | None = None) -> Non # mid-gate can still record the terminal transition. Without # it, a crash while the user is deciding leaves the task # stuck until the stranded-task reconciler catches it (~2h). - # Cedar HITL state machine (design §9): RUNNING ↔ - # AWAITING_APPROVAL, both can transition straight to a - # terminal state. + # Approval state machine: RUNNING ↔ AWAITING_APPROVAL, both can + # transition straight to a terminal state. ":awaiting_approval": "AWAITING_APPROVAL", } update_parts = ["#s = :s", "completed_at = :t", "status_created_at = :sca"] @@ -280,8 +280,8 @@ def write_terminal(task_id: str, status: str, result: dict | None = None) -> Non if result.get("turns") is not None: update_parts.append("turns = :turns") expr_values[":turns"] = str(result["turns"]) - # Rev-5 DATA-1: dual counters so operators can distinguish - # SDK-attempted vs pipeline-completed turn counts. + # Dual counters so operators can distinguish SDK-attempted vs + # pipeline-completed turn counts. if result.get("turns_attempted") is not None: update_parts.append("turns_attempted = :ta") expr_values[":ta"] = str(result["turns_attempted"]) @@ -294,30 +294,51 @@ def write_terminal(task_id: str, status: str, result: dict | None = None) -> Non if result.get("memory_written") is not None: update_parts.append("memory_written = :mw") expr_values[":mw"] = result["memory_written"] - # Verification verdict (#515 replay bundle). build_passed/lint_passed - # were historically dropped here (present on TaskResult but never - # written), so TaskDetail.build_passed was always null. Persist both - # so the replay bundle carries a structured verification signal. + # Persist the post-hook verify outcomes so they're observable on the + # task record (orchestration reconciler / dashboards / replay + # bundle), not just consumed in-process by the gate. build_passed/ + # lint_passed were historically dropped here (present on TaskResult but + # never written) — persist both so a consumer sees WHY a task passed/ + # failed verification, as a structured signal. if result.get("build_passed") is not None: update_parts.append("build_passed = :bp") expr_values[":bp"] = bool(result["build_passed"]) if result.get("lint_passed") is not None: update_parts.append("lint_passed = :lp") expr_values[":lp"] = bool(result["lint_passed"]) - # OTEL trace id (#515) for cross-plane correlation. Absent on tasks + # Whether a PR-iteration advanced the branch HEAD (a real commit + # landed) vs. ran with no change (a question-only comment). + # The Linear/Slack settle reply reads this to avoid a false + # "✅ Updated" on a no-op iteration. None ⇒ not persisted (the + # consumer defaults to the change-made side, back-compat). + if result.get("code_changed") is not None: + update_parts.append("code_changed = :cc") + expr_values[":cc"] = bool(result["code_changed"]) + # The pushed HEAD sha — lets the screenshot webhook match a deploy's + # commit to the iteration task that pushed it (correct preview-reply + # attribution when two iterations overlap on one PR). Skip empties. + if result.get("head_sha"): + update_parts.append("head_sha = :hsha") + expr_values[":hsha"] = str(result["head_sha"]) + if result.get("answer_text"): + update_parts.append("answer_text = :ans") + # Bound the persisted answer so a verbose agent can't bloat the + # row; the reply renderer truncates again for display. + expr_values[":ans"] = str(result["answer_text"])[:2000] + # OTEL trace id for cross-plane correlation. Absent on tasks # that predate this field and when tracing is unavailable. if result.get("otel_trace_id"): update_parts.append("otel_trace_id = :otid") expr_values[":otid"] = result["otel_trace_id"] - # --trace artifact URI (design §10.1). Written atomically - # with the terminal-status transition so a consumer that - # reads TaskRecord.trace_s3_uri immediately after - # status becomes terminal sees a consistent view. + # --trace artifact URI. Written atomically with the + # terminal-status transition so a consumer that reads + # TaskRecord.trace_s3_uri immediately after status becomes + # terminal sees a consistent view. if result.get("trace_s3_uri"): update_parts.append("trace_s3_uri = :ts3") expr_values[":ts3"] = result["trace_s3_uri"] - # Repo-less delivered artifact URI (#248 Phase 3). Persisted with the - # terminal write so TaskDetail.artifact_uri surfaces the deliverable + # Repo-less delivered artifact URI. Persisted with the terminal + # write so TaskDetail.artifact_uri surfaces the deliverable # — otherwise the S3 object exists but its URI is undiscoverable. if result.get("artifact_uri"): update_parts.append("artifact_uri = :au") @@ -342,9 +363,9 @@ def write_terminal(task_id: str, status: str, result: dict | None = None) -> Non "[task_state] write_terminal skipped: " "status precondition not met (task may have been cancelled)", ) - # K2 final review SIG-1: ConditionalCheckFailed on the - # happy path after a successful S3 trace upload orphans - # the S3 object — the URI never lands on the TaskRecord, + # A ConditionalCheckFailed on the happy path after a + # successful S3 trace upload orphans the S3 object — the URI + # never lands on the TaskRecord, # so ``get-trace-url`` will 404 ``TRACE_NOT_AVAILABLE`` # indefinitely. Without this dedicated log the orphan # is invisible; the generic skip message above doesn't @@ -479,35 +500,35 @@ def get_task(task_id: str) -> dict | None: # --------------------------------------------------------------------------- -# Cedar HITL approval primitives (§6.5, §9.1, IMPL-24) +# Approval primitives (Cedar human-in-the-loop gates) # --------------------------------------------------------------------------- # -# ``TaskApprovalsTable`` and the AWAITING_APPROVAL status transitions land -# physically in Chunk 4 (CDK). The agent-side helpers below are written to -# that contract and exposed so Chunk 3's ``pre_tool_use_hook`` can be -# implemented + unit-tested now (via mocked boto3 clients); Chunk 4 sets -# ``TASK_APPROVALS_TABLE_NAME`` + grants IAM and the same helpers start -# making real DDB calls with no further code change on the agent side. +# ``TaskApprovalsTable`` and the AWAITING_APPROVAL status transitions are +# provisioned by the CDK stack. The agent-side helpers below are written to +# that contract and exposed so the ``pre_tool_use_hook`` can be implemented + +# unit-tested (via mocked boto3 clients); once the stack sets +# ``TASK_APPROVALS_TABLE_NAME`` + grants IAM, the same helpers start making +# real DDB calls with no further code change on the agent side. # # Primitives exposed: # - ``transact_write_approval_request`` — atomic Put(TaskApprovals) + # Update(TaskTable: RUNNING → AWAITING_APPROVAL). Raises # ``ApprovalWriteError`` on ``TransactionCanceledException`` so the -# hook can return DENY + ``approval_write_failed`` (§13.1). +# hook can return DENY + ``approval_write_failed``. # - ``transact_resume_from_approval`` — atomic Update(TaskTable: # AWAITING_APPROVAL → RUNNING) gated on # ``awaiting_approval_request_id = request_id``. Raises -# ``ApprovalResumeError`` on cancellation (§13.9). +# ``ApprovalResumeError`` on cancellation. # - ``best_effort_update_approval_status`` — conditional Update on the # approval row (``status = :pending`` guard). Returns ``False`` on -# ``ConditionCheckFailed`` so IMPL-24's re-read re-read path fires. +# ``ConditionCheckFailed`` so the late-approval re-read path fires. # - ``get_approval_row`` — strongly-consistent GetItem; default -# ``consistent_read=True`` because IMPL-24's race fix relies on it. +# ``consistent_read=True`` because the race fix relies on it. # # Errors beyond the structural conditions (unreachable DDB, IAM drift, # missing env var) raise ``ApprovalTablesUnavailable`` so the hook can -# fail CLOSED without guessing. The hook maps that to DENY so a -# pre-Chunk-4 deploy cannot silently bypass gates. +# fail CLOSED without guessing. The hook maps that to DENY so a deploy +# without the approvals table cannot silently bypass gates. TASK_APPROVALS_TABLE_ENV = "TASK_APPROVALS_TABLE_NAME" TASK_TABLE_ENV = "TASK_TABLE_NAME" @@ -521,9 +542,8 @@ def get_task(task_id: str) -> dict | None: class ApprovalTablesUnavailable(RuntimeError): """Either ``TASK_APPROVALS_TABLE_NAME`` or ``TASK_TABLE_NAME`` is unset. - Hook maps to DENY (fail-closed); see §13.15. Distinct from - ``TaskFetchError`` so callers do not collapse a config problem with a - transient read failure. + Hook maps to DENY (fail-closed). Distinct from ``TaskFetchError`` so + callers do not collapse a config problem with a transient read failure. """ @@ -533,7 +553,7 @@ class ApprovalWriteError(RuntimeError): Fired when the cross-table atomic write is cancelled — either the TaskTable precondition fails (task already cancelled / advanced past RUNNING) or the approval row already exists. Hook maps to DENY + - ``approval_write_failed`` (§13.1). The underlying cancellation reasons + ``approval_write_failed``. The underlying cancellation reasons are stashed on ``.cancellation_reasons`` for triage. """ @@ -546,7 +566,7 @@ class ApprovalResumeError(RuntimeError): """``transact_resume_from_approval`` TransactionCanceledException. Fired when the resume transition fails — typically because the user - cancelled the task mid-approval (§13.9). Hook maps to DENY + + cancelled the task mid-approval. Hook maps to DENY + ``approval_resume_failed``. """ @@ -589,8 +609,8 @@ def _py_to_ddb_attr(value): Handles the subset we actually write: ``str``, ``int``, ``bool``, ``None``, lists-of-str. More exotic types would need marshalling - support; ``approval_row`` values are constrained to the §10.1 schema - which falls entirely inside this subset. + support; ``approval_row`` values are constrained to the approval-row + schema, which falls entirely inside this subset. """ if value is None: return {"NULL": True} @@ -722,7 +742,7 @@ def transact_resume_from_approval( The condition ``status = AWAITING_APPROVAL AND awaiting_approval_request_id = :rid`` prevents: - - resuming a task that's been cancelled mid-approval (§13.9); + - resuming a task that's been cancelled mid-approval; - resuming with a stale request_id after a race with the reconciler / a concurrent approval. @@ -776,10 +796,10 @@ def best_effort_update_approval_status( ) -> bool: """Conditionally flip ``status`` on an approval row. - The condition ``status = :pending`` is the design-doc guard from §6.5. - Used on the TIMED_OUT write path: if the row has already transitioned - to APPROVED or DENIED, the update fails and the caller (the hook) must - re-read the row with ConsistentRead (IMPL-24). + The condition ``status = :pending`` guards the write. Used on the + TIMED_OUT write path: if the row has already transitioned to APPROVED + or DENIED, the update fails and the caller (the hook) must re-read the + row with ConsistentRead. Returns ``True`` on successful write, ``False`` on ``ConditionalCheckFailedException``. All other errors propagate. @@ -820,11 +840,11 @@ def get_approval_row( consistent_read: bool = True, client=None, ) -> dict | None: - """Fetch an approval row. Defaults to strongly-consistent read (IMPL-24). + """Fetch an approval row. Defaults to a strongly-consistent read. Returns a Python dict with unmarshalled attribute values, or ``None`` if the row does not exist (TTL reaped, wrong IDs, etc.). Callers use the - ``None`` return to detect the row-gone branch in §13.12. + ``None`` return to detect the row-gone branch of the late-approval race. """ _, approvals_table = _require_tables() ddb = _get_ddb_client(client=client) @@ -846,19 +866,19 @@ def increment_approval_gate_count_in_ddb( ) -> bool: """Best-effort atomic increment of ``approval_gate_count`` on TaskTable. - Chunk 7 persistence layer for decision #13's per-task gate counter. The - session counter (``PolicyEngine._approval_gate_count``) stays - authoritative WITHIN a container — this write exists so that a container - restart (§13.6) can seed the new container's counter from the persisted - value instead of resetting to 0 and re-exposing the user to another - ``approval_gate_cap`` worth of gates. + Persistence layer for the per-task gate counter. The session counter + (``PolicyEngine._approval_gate_count``) stays authoritative WITHIN a + container — this write exists so that a container restart can seed the + new container's counter from the persisted value instead of resetting to + 0 and re-exposing the user to another ``approval_gate_cap`` worth of + gates. - **Best-effort semantics (§13.6):** the counter is a safety bound, not a + **Best-effort semantics:** the counter is a safety bound, not a correctness bound. A DDB write failure here MUST NOT block the gate — the session counter still enforces the cap within this container, and - the §13.6 analysis accepts at most one lost increment per restart as - acceptable damage. Returns ``True`` on success, ``False`` on any - failure (config missing, IAM drift, throttling). Never raises. + losing at most one increment per restart is acceptable damage. Returns + ``True`` on success, ``False`` on any failure (config missing, IAM + drift, throttling). Never raises. Uses a pure ADD UpdateExpression without a ConditionExpression — the counter is monotonic and concurrent writes from different hooks on the @@ -867,9 +887,9 @@ def increment_approval_gate_count_in_ddb( applying the ADD, matching the CreateTaskFn seed of ``approval_gate_count: 0`` (see cdk/src/handlers/shared/create-task-core.ts). - Deliberately kept separate from the resume TransactWriteItems (§6.5): the + Deliberately kept separate from the resume TransactWriteItems: the joint-update invariant on ``status`` + ``awaiting_approval_request_id`` - (§10.2) must not be burdened with a non-safety-critical counter bump. + must not be burdened with a non-safety-critical counter bump. """ try: task_table, _ = _require_tables() diff --git a/agent/src/workflow/deliverers.py b/agent/src/workflow/deliverers.py index 0245da566..50b8529a2 100644 --- a/agent/src/workflow/deliverers.py +++ b/agent/src/workflow/deliverers.py @@ -1,4 +1,4 @@ -"""Registry of ``deliver_artifact`` deliverers (#248, ADR-014 addendum 2026-06-08). +"""Registry of ``deliver_artifact`` deliverers (see ADR-014). A workflow's ``deliver_artifact`` step names a *deliverer* in its ``target`` field; that name resolves here. This mirrors the step-handler registry pattern @@ -7,15 +7,15 @@ closed set of valid names lives in ``DELIVERERS`` rather than a JSON-Schema enum. Each deliverer declares the terminal outcomes it ``produces`` so the cross-field -validator (rule 11) can check a workflow's declared ``terminal_outcomes.primary`` -is actually produced by some step — the single source of truth for the old -``_DELIVER_TARGET_OUTCOMES`` map, now registry-driven. - -The shared *plumbing* contract every deliverer builds on is frozen in the -ADR-014 addendum: artifacts upload to a task-scoped key ``artifacts/{task_id}/`` -in the platform artifacts bucket (``ARTIFACTS_BUCKET_NAME``), the agent -SessionRole carries a prefix-scoped IAM grant, a per-artifact size limit -applies, and the delivered URL surfaces on ``TaskDetail`` (``artifact_uri``). +validator can check a workflow's declared ``terminal_outcomes.primary`` is +actually produced by some step. This registry is the single source of truth for +that mapping. + +The shared *plumbing* contract every deliverer builds on: artifacts upload to a +task-scoped key ``artifacts/{task_id}/`` in the platform artifacts bucket +(``ARTIFACTS_BUCKET_NAME``), the agent SessionRole carries a prefix-scoped IAM +grant, a per-artifact size limit applies, and the delivered URL surfaces on +``TaskDetail`` (``artifact_uri``). """ from __future__ import annotations @@ -85,13 +85,13 @@ def _strip_code_and_urls(text: str) -> str: def _reject_if_deferral(text: str) -> None: """Fail the deliver step if the final message defers instead of delivering. - Guards the exact silently-wrong-artifact failure observed on a repo-less - research task: the agent launched a background workflow and its final text - promised results elsewhere, which would have uploaded as the artifact. - Raising here routes to a terminal FAILED (see the pipeline delivery gate) so - the placeholder is never presented as the result. Matches full deferral - phrases against prose with code/URLs stripped, to avoid false-FAILing a - genuine answer that merely quotes such a phrase in a link or snippet. + Guards against a silently-wrong artifact: on a repo-less research task the + agent can launch a background workflow and end with text that promises + results elsewhere, which would then be uploaded as the artifact. Raising here + routes to a terminal FAILED (see the pipeline delivery gate) so the + placeholder is never presented as the result. Matches full deferral phrases + against prose with code/URLs stripped, to avoid false-FAILing a genuine + answer that merely quotes such a phrase in a link or snippet. """ lowered = _strip_code_and_urls(text).lower() hit = next((m for m in _DEFERRAL_MARKERS if m in lowered), None) @@ -117,18 +117,18 @@ class Deliverer: ``produces`` is the set of ``terminal_outcomes`` values this deliverer can satisfy (e.g. an S3 upload produces ``artifact``; a comment post produces - ``comment``). Used by validator rule 11 and by the runtime ``deliver`` - dispatcher. + ``comment``). Used by the terminal-outcome validator and by the runtime + ``deliver`` dispatcher. """ name: str produces: frozenset[str] = field(default_factory=frozenset) -# First-party deliverers. The three names preserve the exact produced-outcome -# sets of the pre-addendum ``_DELIVER_TARGET_OUTCOMES`` enum, so no existing -# workflow / fixture / golden vector changes behavior — the closed enum is -# widened to an open string + this registry, not redefined. +# First-party deliverers. These three names and their produced-outcome sets match +# the earlier hardcoded target→outcomes mapping this registry replaced, so no +# existing workflow / fixture / golden vector changes behavior — the closed set +# is widened to an open string + this registry, not redefined. DELIVERERS: dict[str, Deliverer] = { "s3": Deliverer("s3", frozenset({"artifact"})), "comment": Deliverer("comment", frozenset({"comment"})), @@ -138,13 +138,12 @@ class Deliverer: # The target a ``deliver_artifact`` step uses when it omits ``target``. This is # the SINGLE source of truth for that default — both the runtime (runner.py's # ``_handle_deliver_artifact``) and the validator (``produced_outcomes(None)``) -# key off it, so the two can never disagree about what an unset target delivers -# (PR review #296 finding #7). +# key off it, so the two can never disagree about what an unset target delivers. DEFAULT_DELIVER_TARGET = "s3" def _artifact_body(ctx: StepContext) -> bytes: - """The deliverable bytes: the agent's final result text (#248 Phase 3).""" + """The deliverable bytes: the agent's final result text.""" text = ctx.agent_result.result_text if ctx.agent_result else "" if not text: raise ValueError("deliver_artifact: agent produced no result text to deliver") @@ -154,10 +153,9 @@ def _artifact_body(ctx: StepContext) -> bytes: # Bound memory BEFORE encoding. UTF-8 uses ≥1 byte per character, so a string # whose character count already exceeds the byte cap cannot possibly fit — # reject it without materializing a second full copy as bytes. This is what - # makes the cap actually cap memory on the constrained MicroVM: previously the - # bytes were encoded first and the check ran after, so a multi-hundred-MB - # result had both the str and its bytes resident before the cap fired - # (PR review #296 finding #9). + # makes the cap actually cap memory in the agent's memory-constrained sandbox: + # encoding first and checking after would leave a multi-hundred-MB result with + # both the str and its bytes resident before the cap fired. if len(text) > MAX_ARTIFACT_BYTES: raise ValueError( f"deliver_artifact: artifact text is {len(text)} characters, exceeds the " @@ -204,10 +202,11 @@ def _upload_to_s3(ctx: StepContext) -> str: def _post_comment(ctx: StepContext) -> bool: """Record the deliverable as a ``delivered_comment`` progress milestone. - The agent has no direct comment channel for a repo-less task (no GitHub repo; - Linear MCP is channel-gated). This records the result text as a - ``delivered_comment`` milestone on TaskEventsTable — visible in the live event - stream (``bgagent watch``) and to any consumer of the task's events. + The agent has no direct comment channel for a repo-less task (there is no + GitHub repo, and the agent posts nothing to the issue tracker directly). This + records the result text as a ``delivered_comment`` milestone on + TaskEventsTable — visible in the live event stream (``bgagent watch``) and to + any consumer of the task's events. NOTE: rendering this milestone to an external channel (Slack/email/GitHub) is NOT yet wired — ``delivered_comment`` is not in the fan-out's @@ -225,8 +224,9 @@ def _post_comment(ctx: StepContext) -> bool: def deliver(target: str, ctx: StepContext) -> DeliveryResult: """Run the named deliverer against the step context. - Raises ``ValueError`` for an unknown target (the validator's rule-8 should - prevent this reaching runtime, but fail loud rather than silently no-op). + Raises ``ValueError`` for an unknown target (the validator's handler-coverage + check should prevent this reaching runtime, but fail loud rather than + silently no-op). """ if target not in DELIVERERS: raise ValueError( @@ -242,7 +242,7 @@ def deliver(target: str, ctx: StepContext) -> DeliveryResult: # Terminal outcomes that any deliver_artifact deliverer can produce (union over -# the registry) — the set rule 11 treats as "deliver_artifact-backed". +# the registry) — the set the validator treats as "deliver_artifact-backed". DELIVER_OUTCOMES: frozenset[str] = frozenset().union(*(d.produces for d in DELIVERERS.values())) @@ -250,12 +250,11 @@ def produced_outcomes(target: str | None) -> frozenset[str]: """Terminal outcomes a deliver_artifact ``target`` produces. An unset target resolves to {@link DEFAULT_DELIVER_TARGET} — the SAME default - the runtime applies — so the validator models exactly what will run. (It was - previously lenient, returning the full set, which let a ``primary: comment`` + the runtime applies — so the validator models exactly what will run. (Being + lenient here and returning the full set would let a ``primary: comment`` workflow with no ``target`` pass validation while the runtime silently - delivered only to ``s3`` and never posted the comment — PR review #296 - finding #7.) An unknown name returns the empty set (it produces nothing the - validator can vouch for). + delivered only to ``s3`` and never posted the comment.) An unknown name + returns the empty set (it produces nothing the validator can vouch for). """ resolved = DEFAULT_DELIVER_TARGET if target is None else target deliverer = DELIVERERS.get(resolved) diff --git a/agent/src/workflow/runner.py b/agent/src/workflow/runner.py index b7bde5b25..ba7a7bc31 100644 --- a/agent/src/workflow/runner.py +++ b/agent/src/workflow/runner.py @@ -1,4 +1,4 @@ -"""The agent-side workflow step runner (#248). +"""The agent-side workflow step runner. Per `ADR-014 <../../../docs/decisions/ADR-014-workflow-driven-tasks.md>`_ the runner lives *in the container* and interprets ``workflow.steps`` — it drives @@ -388,8 +388,8 @@ def _handle_clone_repo(step: Step, ctx: StepContext) -> StepOutcome: reused = ctx.setup is not None if not reused: - # Thread progress so bounded-retry blocker events (#251, dependency_ - # unreachable / egress_denied during clone/fetch backoff) reach the live + # Thread progress so bounded-retry blocker events (e.g. dependency + # unreachable / egress denied during clone/fetch backoff) reach the live # stream — matching the inline pipeline path. Terminal reason still # propagates via the raised exception even when progress is None. ctx.setup = setup_repo(ctx.config, progress=ctx.progress) @@ -414,7 +414,7 @@ def _handle_hydrate_context(step: Step, ctx: StepContext) -> StepOutcome: the workflow path produces the same system prompt as ``pipeline.run_task`` (repo_url/branch/workspace/max_turns/setup_notes/memory_context + overrides + channel guidance). Without this the agent loop would run with an empty - system prompt (code-review finding). Requires a prior ``clone_repo`` for + system prompt. Requires a prior ``clone_repo`` for the ``RepoSetup``; when absent (repo-less workflows) the system prompt is left to the caller, since ``build_system_prompt`` is repo-shaped today. """ @@ -493,11 +493,10 @@ def gate_status( """Map a verify result + the step's ``gate`` to a step status. Single place the verify-gate semantics live, shared by ``verify_build`` and - ``verify_lint`` (the two were near-identical twins that drifted on the - ``read_only`` rule — see the code-review finding). Since #301 it is also the - implementation behind the coding lane's inline post-hook gating - (``pipeline._apply_post_hook_gates``), so both lanes honor a step's - declared ``gate`` through this one function: + ``verify_lint`` (the two were near-identical twins that had drifted on the + ``read_only`` rule). It is also the implementation behind the coding lane's + inline post-hook gating (``pipeline._apply_post_hook_gates``), so both lanes + honor a step's declared ``gate`` through this one function: - ``informational`` (or a ``read_only`` workflow) — never gates. - ``strict`` — any failure gates. @@ -519,11 +518,12 @@ def gate_status( def _handle_verify_build(step: Step, ctx: StepContext) -> StepOutcome: - """Run ``mise run build``. Gating vs informational is the step's ``gate``.""" + """Run the repo's build command (default ``mise run build``); gating is the step's ``gate``.""" from post_hooks import verify_build repo_dir = ctx.setup.repo_dir if ctx.setup else "" - passed = verify_build(repo_dir) + outcome = verify_build(repo_dir, ctx.config.build_command) + passed = outcome.passed # was_passing_before defaults True (assume green-before, so a post-agent # failure IS a regression) — the same conservative default pipeline.py uses. was_passing_before = ctx.setup.build_before if ctx.setup else True @@ -533,21 +533,28 @@ def _handle_verify_build(step: Step, ctx: StepContext) -> StepOutcome: read_only=ctx.workflow.read_only, was_passing_before=was_passing_before, ) + # Distinguish a timeout from a genuine red build in the step error too. + fail_reason = ( + "post-agent build timed out" + if outcome.timed_out + else "post-agent build failed (regression)" + ) return StepOutcome( kind=step.kind, name=_step_key(step), status=status, - error=None if status == "succeeded" else "post-agent build failed (regression)", + error=None if status == "succeeded" else fail_reason, data={"build_passed": passed}, ) def _handle_verify_lint(step: Step, ctx: StepContext) -> StepOutcome: - """Run ``mise run lint`` (typically an advisory ``on_failure: continue`` gate).""" + """Run the repo's lint command (default ``mise run lint``; usually an advisory gate).""" from post_hooks import verify_lint repo_dir = ctx.setup.repo_dir if ctx.setup else "" - passed = verify_lint(repo_dir) + outcome = verify_lint(repo_dir, ctx.config.lint_command) + passed = outcome.passed was_passing_before = ctx.setup.lint_before if ctx.setup else True status = gate_status( passed=passed, @@ -555,11 +562,14 @@ def _handle_verify_lint(step: Step, ctx: StepContext) -> StepOutcome: read_only=ctx.workflow.read_only, was_passing_before=was_passing_before, ) + fail_reason = ( + "post-agent lint timed out" if outcome.timed_out else "post-agent lint failed (regression)" + ) return StepOutcome( kind=step.kind, name=_step_key(step), status=status, - error=None if status == "succeeded" else "post-agent lint failed (regression)", + error=None if status == "succeeded" else fail_reason, data={"lint_passed": passed}, ) @@ -568,10 +578,9 @@ def _handle_ensure_pr(step: Step, ctx: StepContext) -> StepOutcome: """Create / push+resolve / resolve a PR per the step's ``strategy``. The provider-neutral intent dispatches through the existing GitHub - realization. ``ensure_pr`` now takes the strategy explicitly (``create`` | - ``push_resolve`` | ``resolve``) instead of self-inspecting the removed - ``task_type`` (#248 task 8), so the workflow's declared strategy drives the - behavior. + realization. ``ensure_pr`` takes the strategy explicitly (``create`` | + ``push_resolve`` | ``resolve``) instead of inferring it from the now-removed + ``task_type``, so the workflow's declared strategy drives the behavior. """ from post_hooks import ensure_pr @@ -606,9 +615,9 @@ def _handle_post_review(step: Step, ctx: StepContext) -> StepOutcome: No first-party workflow declares a ``post_review`` step — ``coding/pr-review-v1`` resolves its PR via ``ensure_pr(strategy: resolve)`` instead. The handler is - registered so the handler-coverage check (validator rule 8) stays honest and - fails loudly rather than silently no-opping; it is implemented when a workflow - that posts a GitHub Reviews-API review (vs an issue comment) ships. + registered so the validator's handler-coverage check stays honest and fails + loudly rather than silently no-opping; it is implemented when a workflow that + posts a GitHub Reviews-API review (vs an issue comment) ships. """ raise NotImplementedError( "post_review has no shipped workflow yet — coding/pr-review-v1 uses " @@ -617,7 +626,7 @@ def _handle_post_review(step: Step, ctx: StepContext) -> StepOutcome: def _handle_deliver_artifact(step: Step, ctx: StepContext) -> StepOutcome: - """Deliver a produced artifact (repo-less knowledge work, #248 Phase 3). + """Deliver a produced artifact (repo-less knowledge work). Routes through the named deliverer (``step.target`` → ``workflow.deliverers``): an ``s3``-producing target uploads the agent's result text to diff --git a/agent/tests/conftest.py b/agent/tests/conftest.py index 8d304f178..0ea21a8bb 100644 --- a/agent/tests/conftest.py +++ b/agent/tests/conftest.py @@ -14,7 +14,7 @@ # in the MAIN thread during a test's *call* phase, so a deadlock in a WORKER # thread, a fixture, collection, or a C-level socket read the main thread never # returns from stalls the whole `mise run build` silently — up to the platform's -# 3600s build-verify ceiling (the ECS-only stall on ABCA-684/686/688, and the +# 3600s build-verify ceiling (the ECS-only stall we chased for weeks, and the # scoped-session S3 hang the _clean_env reset below guards against: 40+ min of # dead air, container never reaped). # @@ -35,7 +35,7 @@ # # ``pytest_sessionfinish`` cancels the timer on a clean finish (below), so a # legitimately slow-but-passing run that lands near 600s — e.g. still in teardown -# / coverage write — is NOT hard-exited into a bewildering red (#616 review N2). +# / coverage write — is NOT hard-exited into a bewildering red. # ``os._exit`` skips atexit + buffer flush, so it must only fire on a TRUE hang. _HANG_REAP_DEADLINE_S = 600 @@ -59,7 +59,7 @@ def _reap_on_hang() -> None: def pytest_sessionfinish(session, exitstatus): - """Cancel the hang watchdog on a clean session finish (#616 review N2). + """Cancel the hang watchdog on a clean session finish. Without this, a legitimately slow-but-passing suite that finishes just after the 600s deadline (e.g. during teardown / coverage write) would be hard-exited diff --git a/agent/tests/test_aws_session.py b/agent/tests/test_aws_session.py index c57b1e23d..3a7a67d5d 100644 --- a/agent/tests/test_aws_session.py +++ b/agent/tests/test_aws_session.py @@ -84,7 +84,7 @@ def test_blank_role_arn_treated_as_unset(self, monkeypatch): # autouse fixture. This file's `_reset` fixture (above) itself does # `monkeypatch.delenv(SESSION_ROLE_ARN_ENV)`, which would MASK whether conftest's # `_clean_env` performs the scrub — a guard placed here passes even if the -# conftest line is deleted (#616 review B1). Only a fixture-free module truly +# conftest line is deleted. Only a fixture-free module truly # guards the fix. @@ -145,7 +145,7 @@ def _worker() -> None: # container memory pressure, or thread creation throttled) — every # survivor then hangs here and the main thread hangs in join() below, # stalling the whole `mise run build` until the 3600s ceiling. This is - # the ECS-only flaky hang chased across ABCA-684/686/688 (pytest-timeout + # the ECS-only flaky hang we chased for weeks (pytest-timeout # only fixed the SYMPTOM; this Barrier is the ROOT cause). A timeout # makes the barrier raise BrokenBarrierError so the test fails fast. start.wait(timeout=30) diff --git a/agent/tests/test_cedar_parity.py b/agent/tests/test_cedar_parity.py index b166adada..e9eccbfc3 100644 --- a/agent/tests/test_cedar_parity.py +++ b/agent/tests/test_cedar_parity.py @@ -23,7 +23,7 @@ # Hard import (not importorskip): the parity contract REQUIRES cedarpy. # A dependency regression that drops cedarpy must fail loudly, not be # silently skipped — skipping would let divergence reach production. -# See silent-failure audit finding #8 (Chunk 1 review, 2026-05-07). +# Surfaced by a silent-failure audit of this suite. import cedarpy import pytest @@ -115,8 +115,8 @@ def _recover_rule_ids(policies: str, matching_policy_ids: list[str]) -> list[str Dropping unannotated matches would silently hide genuine cross-engine disagreement (e.g. one engine matching the base ``permit`` alongside a ``forbid``) — the whole point of this test is to fail such disagreement, - not bury it. See silent-failure audit finding #1 (Chunk 1 review, - 2026-05-07). Fixture policies are expected to annotate every rule + not bury it (surfaced by a silent-failure audit of this suite). + Fixture policies are expected to annotate every rule including the base permit (``@rule_id("base_permit")``); a missing annotation raises rather than silently coerces to empty. """ diff --git a/agent/tests/test_channel_mcp.py b/agent/tests/test_channel_mcp.py index d7d3321aa..31d3f4dbb 100644 --- a/agent/tests/test_channel_mcp.py +++ b/agent/tests/test_channel_mcp.py @@ -1,4 +1,9 @@ -"""Unit tests for channel_mcp.configure_channel_mcp — Linear/Jira MCP gating + merge.""" +"""Unit tests for channel_mcp.configure_channel_mcp — Jira MCP gating + merge. + +Linear is NOT tested here: ABCA runs Linear 100% deterministically (ADR-016), +so there is no Linear MCP entry. The gate below asserts that channel_source=='linear' +is now a no-op (no .mcp.json written). +""" from __future__ import annotations @@ -9,10 +14,8 @@ JIRA_API_TOKEN_ENV, JIRA_MCP_SERVER_KEY, JIRA_MCP_URL, - LINEAR_API_TOKEN_ENV, - LINEAR_MCP_SERVER_KEY, - LINEAR_MCP_URL, configure_channel_mcp, + strip_linear_mcp_servers, ) @@ -23,7 +26,23 @@ def _read_mcp(repo_dir: str) -> dict: class TestChannelGate: - """Only channel_source=='linear' writes anything — everything else is a no-op.""" + """Only channel_source with a wired MCP writes anything — everything else is a no-op.""" + + def test_no_op_for_linear_channel(self, tmp_path): + # ADR-016: Linear is fully deterministic — no Linear MCP is written. + wrote = configure_channel_mcp(str(tmp_path), "linear") + assert wrote is False + assert not (tmp_path / ".mcp.json").exists() + + def test_no_op_for_linear_channel_ignores_gateway_url(self, tmp_path): + # A stale gateway_url in metadata must not resurrect a Linear MCP entry. + wrote = configure_channel_mcp( + str(tmp_path), + "linear", + {"gateway_url": "https://gw.example/mcp"}, + ) + assert wrote is False + assert not (tmp_path / ".mcp.json").exists() def test_no_op_for_slack_channel(self, tmp_path): wrote = configure_channel_mcp(str(tmp_path), "slack") @@ -46,101 +65,16 @@ def test_no_op_for_empty_channel(self, tmp_path): assert not (tmp_path / ".mcp.json").exists() -class TestLinearWrite: - """channel_source=='linear' writes .mcp.json with the linear-server entry.""" - - def test_creates_mcp_json_with_linear_server_key(self, tmp_path): - wrote = configure_channel_mcp(str(tmp_path), "linear") - assert wrote is True - config = _read_mcp(str(tmp_path)) - assert LINEAR_MCP_SERVER_KEY in config["mcpServers"] - - def test_renders_linear_url_and_token_placeholder(self, tmp_path): - configure_channel_mcp(str(tmp_path), "linear") - entry = _read_mcp(str(tmp_path))["mcpServers"][LINEAR_MCP_SERVER_KEY] - assert entry["type"] == "http" - assert entry["url"] == LINEAR_MCP_URL - assert entry["headers"]["Authorization"] == f"Bearer ${{{LINEAR_API_TOKEN_ENV}}}" - - def test_server_key_is_linear_server(self): - # If this ever changes, tools surface under a different mcp__ prefix and - # the agent prompt (prompt_builder._channel_prompt_addendum) must be - # updated in lockstep. - assert LINEAR_MCP_SERVER_KEY == "linear-server" - - -class TestMerge: - """Existing .mcp.json must not be clobbered.""" - - def test_adds_linear_to_existing_empty_mcp_json(self, tmp_path): - (tmp_path / ".mcp.json").write_text("{}") - wrote = configure_channel_mcp(str(tmp_path), "linear") - assert wrote is True - assert LINEAR_MCP_SERVER_KEY in _read_mcp(str(tmp_path))["mcpServers"] - - def test_preserves_existing_mcp_servers(self, tmp_path): - existing = { - "mcpServers": { - "other-server": {"type": "stdio", "command": "/usr/bin/my-mcp"}, - }, - } - (tmp_path / ".mcp.json").write_text(json.dumps(existing)) - - configure_channel_mcp(str(tmp_path), "linear") - merged = _read_mcp(str(tmp_path)) - assert "other-server" in merged["mcpServers"] - assert merged["mcpServers"]["other-server"]["command"] == "/usr/bin/my-mcp" - assert LINEAR_MCP_SERVER_KEY in merged["mcpServers"] - - def test_overwrites_existing_linear_server_entry(self, tmp_path): - # If someone committed a stale Linear entry with a wrong token var, we - # want the fresh ABCA-written entry to win — otherwise the MCP would - # fail to auth. - existing = { - "mcpServers": { - LINEAR_MCP_SERVER_KEY: { - "type": "http", - "url": "https://stale.example", - "headers": {"Authorization": "Bearer stale"}, - }, - }, - } - (tmp_path / ".mcp.json").write_text(json.dumps(existing)) - - configure_channel_mcp(str(tmp_path), "linear") - entry = _read_mcp(str(tmp_path))["mcpServers"][LINEAR_MCP_SERVER_KEY] - assert entry["url"] == LINEAR_MCP_URL - assert "stale" not in entry["headers"]["Authorization"] - - def test_tolerates_mcp_json_without_mcpservers_key(self, tmp_path): - # A .mcp.json that only has unrelated top-level keys should still - # gain an mcpServers map. - (tmp_path / ".mcp.json").write_text(json.dumps({"version": 1})) - configure_channel_mcp(str(tmp_path), "linear") - merged = _read_mcp(str(tmp_path)) - assert merged["version"] == 1 - assert LINEAR_MCP_SERVER_KEY in merged["mcpServers"] - - def test_malformed_mcp_json_is_replaced(self, tmp_path): - # Malformed JSON is treated as absent (logged as a warning in shell.log) - # rather than crashing the pipeline. - (tmp_path / ".mcp.json").write_text("{not json") - wrote = configure_channel_mcp(str(tmp_path), "linear") - assert wrote is True - merged = _read_mcp(str(tmp_path)) - assert LINEAR_MCP_SERVER_KEY in merged["mcpServers"] - - class TestRepoDirGuard: """Missing repo_dir must not raise — the pipeline should keep going.""" def test_missing_repo_dir(self, tmp_path): missing = tmp_path / "does-not-exist" - wrote = configure_channel_mcp(str(missing), "linear") + wrote = configure_channel_mcp(str(missing), "jira") assert wrote is False def test_empty_repo_dir_string(self): - wrote = configure_channel_mcp("", "linear") + wrote = configure_channel_mcp("", "jira") assert wrote is False @@ -169,6 +103,12 @@ def test_server_key_is_jira_server(self): class TestJiraMerge: """Jira entry must coexist with other servers and overwrite stale jira entries.""" + def test_adds_jira_to_existing_empty_mcp_json(self, tmp_path): + (tmp_path / ".mcp.json").write_text("{}") + wrote = configure_channel_mcp(str(tmp_path), "jira") + assert wrote is True + assert JIRA_MCP_SERVER_KEY in _read_mcp(str(tmp_path))["mcpServers"] + def test_preserves_existing_mcp_servers(self, tmp_path): existing = { "mcpServers": { @@ -200,13 +140,111 @@ def test_overwrites_existing_jira_server_entry(self, tmp_path): assert entry["url"] == JIRA_MCP_URL assert "stale" not in entry["headers"]["Authorization"] - def test_linear_and_jira_can_coexist(self, tmp_path): - # Belt-and-braces: a repo that committed a Linear entry and then - # gets onboarded to Jira (or vice-versa) must keep both. The current - # code path only writes one channel per run, but this test guards - # against a future refactor that writes the wrong key. - configure_channel_mcp(str(tmp_path), "linear") + def test_tolerates_mcp_json_without_mcpservers_key(self, tmp_path): + (tmp_path / ".mcp.json").write_text(json.dumps({"version": 1})) configure_channel_mcp(str(tmp_path), "jira") merged = _read_mcp(str(tmp_path)) - assert LINEAR_MCP_SERVER_KEY in merged["mcpServers"] + assert merged["version"] == 1 + assert JIRA_MCP_SERVER_KEY in merged["mcpServers"] + + def test_malformed_mcp_json_is_replaced(self, tmp_path): + # Malformed JSON is treated as absent (logged as a warning in shell.log) + # rather than crashing the pipeline. + (tmp_path / ".mcp.json").write_text("{not json") + wrote = configure_channel_mcp(str(tmp_path), "jira") + assert wrote is True + merged = _read_mcp(str(tmp_path)) assert JIRA_MCP_SERVER_KEY in merged["mcpServers"] + + +class TestStripLinearMcpServers: + """ADR-016 ENFORCEMENT (review finding #1): a repo can't smuggle a Linear MCP + server in via a committed .mcp.json — it's stripped before the SDK reads it.""" + + def test_removes_linear_server_by_key(self, tmp_path): + (tmp_path / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "linear-server": {"type": "http", "url": "https://mcp.linear.app/sse"}, + "other": {"command": "some-tool"}, + } + } + ) + ) + removed = strip_linear_mcp_servers(str(tmp_path)) + assert removed == 1 + servers = _read_mcp(str(tmp_path))["mcpServers"] + assert "linear-server" not in servers + assert "other" in servers # unrelated servers survive + + def test_removes_entry_named_innocuously_but_referencing_linear_url(self, tmp_path): + # A non-obvious key can't hide a Linear MCP — the value is scanned too. + # Linear was the ONLY server → the now-empty .mcp.json is deleted entirely. + (tmp_path / ".mcp.json").write_text( + json.dumps( + {"mcpServers": {"specs": {"type": "http", "url": "https://mcp.linear.app/sse"}}} + ) + ) + removed = strip_linear_mcp_servers(str(tmp_path)) + assert removed == 1 + assert not (tmp_path / ".mcp.json").exists() + + def test_keeps_file_when_other_servers_survive(self, tmp_path): + # A repo's legit non-Linear server means the file stays (Linear entry gone). + (tmp_path / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "linear-server": {"url": "https://mcp.linear.app/sse"}, + "my-tool": {"command": "/usr/bin/my-mcp"}, + } + } + ) + ) + removed = strip_linear_mcp_servers(str(tmp_path)) + assert removed == 1 + assert (tmp_path / ".mcp.json").exists() + assert _read_mcp(str(tmp_path))["mcpServers"] == {"my-tool": {"command": "/usr/bin/my-mcp"}} + + def test_keeps_file_when_other_top_level_keys_survive(self, tmp_path): + # Linear was the only server but the file carries other config → keep it, + # just with an empty mcpServers. + (tmp_path / ".mcp.json").write_text( + json.dumps( + { + "version": 3, + "mcpServers": {"linear-server": {"url": "https://mcp.linear.app/sse"}}, + } + ) + ) + removed = strip_linear_mcp_servers(str(tmp_path)) + assert removed == 1 + assert (tmp_path / ".mcp.json").exists() + merged = _read_mcp(str(tmp_path)) + assert merged["version"] == 3 + assert merged["mcpServers"] == {} + + def test_removes_entry_reading_linear_api_token(self, tmp_path): + (tmp_path / ".mcp.json").write_text( + json.dumps( + {"mcpServers": {"lin": {"command": "mcp", "env": {"TOKEN": "${LINEAR_API_TOKEN}"}}}} + ) + ) + removed = strip_linear_mcp_servers(str(tmp_path)) + assert removed == 1 + + def test_leaves_jira_and_other_servers_untouched(self, tmp_path): + configure_channel_mcp(str(tmp_path), "jira") # writes jira-server + removed = strip_linear_mcp_servers(str(tmp_path)) + assert removed == 0 + assert JIRA_MCP_SERVER_KEY in _read_mcp(str(tmp_path))["mcpServers"] + + def test_noop_when_no_file(self, tmp_path): + assert strip_linear_mcp_servers(str(tmp_path)) == 0 + + def test_noop_when_no_linear_entry(self, tmp_path): + (tmp_path / ".mcp.json").write_text( + json.dumps({"mcpServers": {"jira-server": {"type": "http", "url": JIRA_MCP_URL}}}) + ) + assert strip_linear_mcp_servers(str(tmp_path)) == 0 diff --git a/agent/tests/test_clarification_tool.py b/agent/tests/test_clarification_tool.py new file mode 100644 index 000000000..0f2c817d3 --- /dev/null +++ b/agent/tests/test_clarification_tool.py @@ -0,0 +1,33 @@ +"""Tests for the request_clarification in-process SDK tool (clarify-before-spend).""" + +from clarification_tool import ( + CLARIFICATION_SERVER_NAME, + CLARIFICATION_TOOL_NAME, + build_clarification_server, +) + + +class TestClarificationTool: + def test_tool_name_is_the_mcp_qualified_form(self): + # The runner matches on the fully-qualified mcp____ name. + assert f"mcp__{CLARIFICATION_SERVER_NAME}__request_clarification" == CLARIFICATION_TOOL_NAME + + def test_build_server_returns_sdk_config(self): + server = build_clarification_server() + # SDK present in the venv → a dict server config with the sdk type + name. + assert server is not None + assert server["type"] == "sdk" + assert server["name"] == CLARIFICATION_SERVER_NAME + assert "instance" in server + + def test_registered_tool_exposes_the_question_param(self): + # The registered tool must accept a ``question`` arg — that's what the + # runner reads off the ToolUseBlock as the clarifying question. + from claude_agent_sdk import tool + + @tool("request_clarification", "ask", {"question": str}) + async def rc(args): # pragma: no cover - handler body not exercised here + return {"content": [{"type": "text", "text": "ok"}]} + + assert rc.name == "request_clarification" + assert "question" in rc.input_schema diff --git a/agent/tests/test_config.py b/agent/tests/test_config.py index f1b630c54..a848e3a56 100644 --- a/agent/tests/test_config.py +++ b/agent/tests/test_config.py @@ -508,7 +508,7 @@ def test_network_failure_during_refresh_returns_stale_token(self, monkeypatch): monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) def test_corrupted_secret_json_returns_empty_with_error_log(self, monkeypatch): - """B3: corrupted SM payload → empty string return, no traceback.""" + """A corrupted SM payload → empty string return, no traceback.""" monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) monkeypatch.setenv("AWS_REGION", "us-east-1") diff --git a/agent/tests/test_conftest_env_scrub.py b/agent/tests/test_conftest_env_scrub.py index 9c515b989..7391e4b6a 100644 --- a/agent/tests/test_conftest_env_scrub.py +++ b/agent/tests/test_conftest_env_scrub.py @@ -1,4 +1,4 @@ -"""Fixture-free regression guard for the ECS test-hang scrub (#615 / #616 B1). +"""Fixture-free regression guard for the ECS test-hang scrub (#615 / #616). This module deliberately has NO local autouse fixture. It exists to prove that conftest's ``_clean_env`` autouse fixture strips ``AGENT_SESSION_ROLE_ARN`` from @@ -8,7 +8,7 @@ Why a separate module: ``test_aws_session.py`` has its OWN autouse ``_reset`` fixture that does ``monkeypatch.delenv(SESSION_ROLE_ARN_ENV)``, so a guard placed there passes even if the conftest scrub is deleted (it asserts what the local -fixture guarantees, not what the fix does — #616 review B1). Here, only conftest's +fixture guarantees, not what the fix does). Here, only conftest's ``_clean_env`` is in play. Why set the var at MODULE IMPORT time (not in the test body): pytest autouse diff --git a/agent/tests/test_entrypoint.py b/agent/tests/test_entrypoint.py index c25740006..96afdb3b2 100644 --- a/agent/tests/test_entrypoint.py +++ b/agent/tests/test_entrypoint.py @@ -500,3 +500,94 @@ def test_selects_pr_review_prompt(self): assert "READ-ONLY" in prompt assert "must NOT modify" in prompt assert "55" in prompt + + +# --------------------------------------------------------------------------- +# _build_system_prompt — Linear channel addendum +# --------------------------------------------------------------------------- + + +class TestBuildSystemPromptLinearChannel: + """The Linear-channel addendum is appended only for channel_source=='linear'.""" + + def _setup(self) -> RepoSetup: + return RepoSetup( + repo_dir="/workspace/t1", + branch="b", + default_branch="main", + notes=[], + ) + + def test_no_addendum_when_channel_is_blank(self): + config = TaskConfig( + repo_url="o/r", + task_id="t1", + max_turns=10, + github_token="ghp_test", + aws_region="us-east-1", + ) + prompt = _build_system_prompt(config, self._setup(), None, "") + assert "## Linear issue" not in prompt + + def test_no_addendum_for_slack_channel(self): + config = TaskConfig( + repo_url="o/r", + task_id="t1", + max_turns=10, + github_token="ghp_test", + aws_region="us-east-1", + channel_source="slack", + ) + prompt = _build_system_prompt(config, self._setup(), None, "") + assert "## Linear issue" not in prompt + + def test_addendum_present_for_linear_channel(self): + config = TaskConfig( + repo_url="o/r", + task_id="t1", + max_turns=10, + github_token="ghp_test", + aws_region="us-east-1", + channel_source="linear", + channel_metadata={ + "linear_issue_id": "issue-uuid-1", + "linear_issue_identifier": "ABC-42", + "linear_project_id": "project-uuid-1", + }, + ) + prompt = _build_system_prompt(config, self._setup(), None, "") + assert "## Linear issue" in prompt + assert "ABC-42" in prompt + + def test_linear_addendum_references_no_mcp_tools(self): + # ADR-016: Linear is fully deterministic — the agent has no Linear MCP. + # The addendum must NOT name any mcp__linear-server__* tool (a leftover + # reference would send the agent groping for a tool that doesn't exist). + config = TaskConfig( + repo_url="o/r", + task_id="t1", + max_turns=10, + github_token="ghp_test", + aws_region="us-east-1", + channel_source="linear", + channel_metadata={"linear_issue_id": "issue-uuid-1"}, + ) + prompt = _build_system_prompt(config, self._setup(), None, "") + assert "mcp__linear-server" not in prompt + + def test_linear_addendum_states_context_prehydrated_and_status_automatic(self): + # The agent must be told (a) inbound context is already provided (nothing + # to fetch) and (b) not to post Linear comments or change state. + config = TaskConfig( + repo_url="o/r", + task_id="t1", + max_turns=10, + github_token="ghp_test", + aws_region="us-east-1", + channel_source="linear", + channel_metadata={"linear_issue_id": "i"}, + ) + prompt = _build_system_prompt(config, self._setup(), None, "") + assert "Context is already here" in prompt + assert "Status is automatic" in prompt + assert "Do NOT post Linear comments" in prompt diff --git a/agent/tests/test_hooks.py b/agent/tests/test_hooks.py index 075fd5f23..f78f0588e 100644 --- a/agent/tests/test_hooks.py +++ b/agent/tests/test_hooks.py @@ -8,6 +8,7 @@ cedarpy = pytest.importorskip("cedarpy") from hooks import ( + _is_self_reclone, _reset_blocker_reason_for_tests, _stuck_guard_between_turns_hook, build_hook_matchers, @@ -194,6 +195,193 @@ def test_denies_direct_non_dict_tool_input(self): ) +class TestSelfRecloneGuard: + """Lost-deliverable defense, layer 1: block a Bash re-clone of the task's + OWN repo (the agent + sometimes cloned into a sibling dir and stranded its work off the tracked + branch). Scoped to the task repo — dependency/fixture clones must pass.""" + + def test_matches_gh_repo_clone_bare_slug(self): + assert _is_self_reclone("cd /workspace && gh repo clone owner/repo", "owner/repo") + + def test_matches_git_clone_https_url(self): + assert _is_self_reclone("git clone https://github.com/owner/repo.git /tmp/x", "owner/repo") + + def test_matches_git_clone_scp_form(self): + assert _is_self_reclone("git clone git@github.com:owner/repo.git", "owner/repo") + + def test_matches_git_dash_c_clone(self): + assert _is_self_reclone("git -C /workspace clone owner/repo", "owner/repo") + + def test_case_insensitive_and_dotgit_config(self): + # config.repo_url may carry a trailing .git or mixed case. + assert _is_self_reclone("gh repo clone Owner/Repo", "owner/repo.git") + + def test_ignores_clone_of_a_different_repo(self): + # A dependency/fixture clone is legitimate and must NOT be blocked. + assert not _is_self_reclone("git clone https://github.com/other/dep.git", "owner/repo") + + def test_ignores_non_clone_git_command(self): + assert not _is_self_reclone("git status && git commit -am wip", "owner/repo") + + def test_ignores_mention_of_repo_without_clone_verb(self): + # Naming the repo in a non-clone command (e.g. a gh pr create) is fine. + assert not _is_self_reclone("gh pr create --repo owner/repo --title x", "owner/repo") + + def test_no_repo_url_is_noop(self): + assert not _is_self_reclone("gh repo clone owner/repo", "") + + def test_ignores_clone_phrase_inside_pr_body(self): + # Observed false positive: the clone command quoted inside a --body value + # is PROSE, not an executed clone. Must NOT be blocked. + cmd = ( + 'gh pr create --repo owner/repo --base main --title "add marker" ' + '--body "I deliberately did NOT run gh repo clone owner/repo; I worked in place."' + ) + assert not _is_self_reclone(cmd, "owner/repo") + + def test_ignores_clone_phrase_in_body_file_and_commit_message(self): + assert not _is_self_reclone( + "gh pr create --repo owner/repo --body-file /tmp/b.md", "owner/repo" + ) + assert not _is_self_reclone( + 'git commit -m "note: do not gh repo clone owner/repo again"', "owner/repo" + ) + + def test_still_blocks_clone_before_a_body_arg(self): + # A REAL clone chained before a body-carrying command must still be caught + # (the free-text truncation only drops what's AFTER the first body arg). + cmd = 'gh repo clone owner/repo && gh pr create --repo owner/repo --body "x"' + assert _is_self_reclone(cmd, "owner/repo") + + def test_blocks_clone_using_the_branch_short_flag(self): + # ``-b`` is git clone's own ``--branch`` short form AND gh's ``--body``. + # The guard used to cut the command at the first free-text flag BEFORE + # looking for the clone verb, so this ordinary command slipped through + # entirely — reopening the stranded-work failure the guard exists to stop. + assert _is_self_reclone( + "git clone -b main https://github.com/owner/repo /w/repo", "owner/repo" + ) + assert _is_self_reclone("gh repo clone -b main owner/repo", "owner/repo") + assert _is_self_reclone( + "git clone -b feature/x git@github.com:owner/repo.git /w/repo", "owner/repo" + ) + assert _is_self_reclone( + "cd /tmp && git clone -b main --depth 1 https://github.com/owner/repo x", + "owner/repo", + ) + + def test_blocks_clone_chained_after_an_unrelated_message_flag(self): + # The ``-m`` belongs to the commit, not to the clone that follows it. A + # free-text argument must only swallow the rest of ITS OWN shell segment. + assert _is_self_reclone( + "git commit -m wip && gh repo clone owner/repo /w/repo", "owner/repo" + ) + + def test_still_ignores_prose_after_a_body_flag_in_the_same_segment(self): + # The counterpart: within one segment, a verb appearing after the body + # value opens is prose. These must stay allowed. + assert not _is_self_reclone( + 'gh pr create --body "do not run gh repo clone owner/repo"', "owner/repo" + ) + assert not _is_self_reclone( + "gh issue comment -m 'see gh repo clone owner/repo for context'", "owner/repo" + ) + + def test_blocks_a_clone_wrapped_across_lines_with_a_backslash(self): + """A trailing backslash continues the SAME command onto the next line. + + Splitting on a bare newline separated the verb from the repo, so the slug + was never found in the verb's segment and a wrapped self re-clone walked + through — including the ``-b`` form this guard was hardened to catch.""" + assert _is_self_reclone( + "git clone \\\n https://github.com/owner/repo /w/repo", "owner/repo" + ) + assert _is_self_reclone( + "git clone -b main \\\n https://github.com/owner/repo /w/repo", "owner/repo" + ) + assert _is_self_reclone( + "git clone --depth 1 \\\n https://github.com/owner/repo x", "owner/repo" + ) + # The continuation can split the VERB itself, not just its arguments — + # this is the case that needs the joining, since no amount of segment + # handling reunites ``git`` with ``clone`` across the break. + assert _is_self_reclone("git \\\n clone https://github.com/owner/repo x", "owner/repo") + + def test_ignores_a_clone_quoted_inside_a_MULTI_LINE_body(self): + """A multi-line --body/-m value is the normal way an agent writes a PR body. + + Its quoted text routinely documents a clone command. Treating a bare + newline as a command separator put that line in its own segment with no + preceding free-text flag, so a legitimate ``gh pr create`` was denied.""" + body = "## Setup\ngit clone https://github.com/owner/repo\ncd repo\nmise run setup" + assert not _is_self_reclone( + f'gh pr create --title "docs: onboarding" --body "{body}"', + "owner/repo", + ) + assert not _is_self_reclone( + 'git commit -m "steps:\n git clone https://github.com/owner/repo"', + "owner/repo", + ) + + def test_requires_command_position_not_substring(self): + # The verb must be in command position (start / after a separator), not an + # arbitrary substring like a path or flag value. + assert _is_self_reclone("echo hi; gh repo clone owner/repo", "owner/repo") + assert not _is_self_reclone("ls /tmp/gh-repo-clone-notes/owner/repo", "owner/repo") + + def test_hook_denies_self_reclone_with_redirect(self): + engine = PolicyEngine(task_type="new_task", repo="owner/repo") + hook_input = { + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "cd /workspace && gh repo clone owner/repo"}, + "tool_use_id": "test-reclone", + "session_id": "sess-1", + "transcript_path": "/tmp/t", + "cwd": "/workspace", + } + result = _run( + pre_tool_use_hook(hook_input, "test-reclone", {}, engine=engine, repo_url="owner/repo") + ) + assert result["hookSpecificOutput"]["permissionDecision"] == "deny" + reason = result["hookSpecificOutput"]["permissionDecisionReason"] + assert "already cloned" in reason.lower() + assert "work in place" in reason.lower() + + def test_hook_allows_dependency_clone(self): + engine = PolicyEngine(task_type="new_task", repo="owner/repo") + hook_input = { + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "git clone https://github.com/other/dep.git vendor/dep"}, + "tool_use_id": "test-dep", + "session_id": "sess-1", + "transcript_path": "/tmp/t", + "cwd": "/workspace", + } + result = _run( + pre_tool_use_hook(hook_input, "test-dep", {}, engine=engine, repo_url="owner/repo") + ) + # Not blocked by the reclone guard — falls through to Cedar (which permits). + assert result["hookSpecificOutput"]["permissionDecision"] == "allow" + + def test_hook_without_repo_url_does_not_block(self): + # Legacy call shape (no repo_url threaded) must not crash or over-block. + engine = PolicyEngine(task_type="new_task", repo="owner/repo") + hook_input = { + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "gh repo clone owner/repo"}, + "tool_use_id": "test-legacy", + "session_id": "sess-1", + "transcript_path": "/tmp/t", + "cwd": "/workspace", + } + result = _run(pre_tool_use_hook(hook_input, "test-legacy", {}, engine=engine)) + assert result["hookSpecificOutput"]["permissionDecision"] == "allow" + + class TestTruncate: def test_returns_text_when_under_max(self): from hooks import _truncate @@ -1703,7 +1891,7 @@ def test_unparseable_started_at_returns_none(self, monkeypatch): class TestStuckGuardHookIntegration: - """K7: PostToolUse feeds the guard; the between-turns hook steers (advisory).""" + """PostToolUse feeds the guard; the between-turns hook steers (advisory).""" def _oom(self): return "[//cdk:test] FAILED (exit 134)\nJavaScript heap out of memory" @@ -1725,7 +1913,7 @@ def test_post_tool_use_records_failures_into_the_guard(self): assert guard.evaluate().kind == "steer" def test_between_turns_hook_latches_stuck_summary_from_the_guard(self): - # #599 N2: pin the PRODUCTION write path for the _LAST_STUCK_SUMMARY latch + # Pin the PRODUCTION write path for the _LAST_STUCK_SUMMARY latch # (hooks.py:1464-1465). The enrichment tests monkeypatch the getter, so # without this test deleting those two lines would leave the latch # permanently None and every test would still pass. Drive the real hook diff --git a/agent/tests/test_linear_reactions.py b/agent/tests/test_linear_reactions.py index 9b47f9ce6..2f545e6d2 100644 --- a/agent/tests/test_linear_reactions.py +++ b/agent/tests/test_linear_reactions.py @@ -3,6 +3,7 @@ from __future__ import annotations import threading +from typing import ClassVar from unittest.mock import MagicMock, patch import pytest @@ -520,3 +521,137 @@ def test_403_treated_same_as_401(self, monkeypatch): for _ in range(3): linear_reactions._graphql("query Q { x }", {}) assert linear_reactions._auth_circuit_open is True + + +class TestTransitionIssueState: + """Workflow-state transition: a writeable single task moves the issue + Backlog → In Progress → + In Review, forward-only. Mocks ``_graphql`` directly so we assert on the + decision logic, not the wire format (covered elsewhere).""" + + _STATES: ClassVar[list[dict]] = [ + {"id": "s-backlog", "name": "Backlog", "type": "backlog", "position": 0}, + {"id": "s-todo", "name": "Todo", "type": "unstarted", "position": 1}, + {"id": "s-prog", "name": "In Progress", "type": "started", "position": 2}, + {"id": "s-review", "name": "In Review", "type": "started", "position": 1002}, + {"id": "s-done", "name": "Done", "type": "completed", "position": 3}, + ] + + def _issue(self, current_state: dict) -> dict: + return {"issue": {"state": current_state, "team": {"states": {"nodes": self._STATES}}}} + + def _cur(self, name: str) -> dict: + return next(s for s in self._STATES if s["name"] == name) + + def test_backlog_to_in_progress_moves_forward(self): + set_calls = [] + + def fake_graphql(query, variables): + if "IssueStates" in query: + return self._issue(self._cur("Backlog")) + set_calls.append(variables) + return {"issueUpdate": {"success": True}} + + with patch("linear_reactions._graphql", side_effect=fake_graphql): + linear_reactions._transition_issue_state("issue-1", "started", ["In Progress"]) + assert len(set_calls) == 1 + assert set_calls[0]["stateId"] == "s-prog" # preferred name wins + + def test_in_progress_to_in_review_on_finish(self): + set_calls = [] + + def fake_graphql(query, variables): + if "IssueStates" in query: + return self._issue(self._cur("In Progress")) + set_calls.append(variables) + return {"issueUpdate": {"success": True}} + + with patch("linear_reactions._graphql", side_effect=fake_graphql): + linear_reactions._transition_issue_state("issue-1", "started", ["In Review"]) + assert set_calls[0]["stateId"] == "s-review" + + def test_never_demotes_a_completed_issue(self): + """A human already marked it Done — a start transition must NOT reopen it.""" + set_calls = [] + + def fake_graphql(query, variables): + if "IssueStates" in query: + return self._issue(self._cur("Done")) + set_calls.append(variables) + return {"issueUpdate": {"success": True}} + + with patch("linear_reactions._graphql", side_effect=fake_graphql): + linear_reactions._transition_issue_state("issue-1", "started", ["In Progress"]) + assert set_calls == [] # backward move (completed → started) skipped + + def test_no_op_when_already_in_target_state(self): + set_calls = [] + + def fake_graphql(query, variables): + if "IssueStates" in query: + return self._issue(self._cur("In Progress")) + set_calls.append(variables) + return {"issueUpdate": {"success": True}} + + with patch("linear_reactions._graphql", side_effect=fake_graphql): + linear_reactions._transition_issue_state("issue-1", "started", ["In Progress"]) + assert set_calls == [] + + def test_gate_off_means_started_does_not_transition(self, monkeypatch): + """react_task_started with transition_state=False posts 👀 but never + touches issue state (the read_only/planning path).""" + monkeypatch.setenv("LINEAR_API_TOKEN", "lin_api_test") + with ( + patch("linear_reactions._transition_issue_state") as trans, + patch("linear_reactions.requests.post", side_effect=_clean_start_calls("r-x")), + ): + react_task_started("linear", {"linear_issue_id": "issue-1"}, transition_state=False) + _join_sweep_thread() + trans.assert_not_called() + + def test_gate_on_transitions_started_to_in_progress(self, monkeypatch): + monkeypatch.setenv("LINEAR_API_TOKEN", "lin_api_test") + with ( + patch("linear_reactions._transition_issue_state") as trans, + patch("linear_reactions.requests.post", side_effect=_clean_start_calls("r-x")), + ): + react_task_started("linear", {"linear_issue_id": "issue-1"}, transition_state=True) + _join_sweep_thread() + trans.assert_called_once_with("issue-1", "started", ["In Progress"]) + + def test_finish_success_transitions_to_in_review_when_gated(self, monkeypatch): + monkeypatch.setenv("LINEAR_API_TOKEN", "lin_api_test") + with ( + patch("linear_reactions._transition_issue_state") as trans, + patch( + "linear_reactions.requests.post", + side_effect=[_ok_delete_response(), _ok_response("r-term")], + ), + ): + react_task_finished( + "linear", + {"linear_issue_id": "issue-1"}, + success=True, + started_reaction_id="r-eyes", + transition_state=True, + ) + trans.assert_called_once_with("issue-1", "started", ["In Review"]) + + def test_finish_failure_does_not_transition(self, monkeypatch): + """A failed run leaves the state (In Progress) — the ❌ conveys it.""" + monkeypatch.setenv("LINEAR_API_TOKEN", "lin_api_test") + with ( + patch("linear_reactions._transition_issue_state") as trans, + patch( + "linear_reactions.requests.post", + side_effect=[_ok_delete_response(), _ok_response("r-term")], + ), + ): + react_task_finished( + "linear", + {"linear_issue_id": "issue-1"}, + success=False, + started_reaction_id="r-eyes", + transition_state=True, + ) + trans.assert_not_called() diff --git a/agent/tests/test_models.py b/agent/tests/test_models.py index 49cbd93a1..fd05818fe 100644 --- a/agent/tests/test_models.py +++ b/agent/tests/test_models.py @@ -282,6 +282,21 @@ def test_required_fields(self): assert config.is_pr_workflow is False assert config.cedar_policies == [] assert config.issue is None + # Defaults for stacked-child fields (#247). + assert config.base_branch is None + assert config.merge_branches == [] + + def test_stacked_child_base_branch_fields(self): + # Diamond child: base off main + predecessor branches to merge in. + config = TaskConfig( + repo_url="owner/repo", + github_token="ghp_test", + aws_region="us-east-1", + base_branch="main", + merge_branches=["bgagent/taskB/b", "bgagent/taskC/c"], + ) + assert config.base_branch == "main" + assert config.merge_branches == ["bgagent/taskB/b", "bgagent/taskC/c"] def test_mutable_assignment(self): config = TaskConfig( diff --git a/agent/tests/test_nudge_hook.py b/agent/tests/test_nudge_hook.py index b116563fd..96aafd52e 100644 --- a/agent/tests/test_nudge_hook.py +++ b/agent/tests/test_nudge_hook.py @@ -321,7 +321,7 @@ def test_multiple_hooks_joined(self): def test_registry_default_contains_cancel_then_nudge(self): # Freshly-imported registry: cancel runs FIRST so it short-circuits # nudge injection on cancelled tasks; nudge runs AFTER it for running - # tasks. The K7 stuck-guard is inserted between them (it also wants to + # tasks. The stuck-guard is inserted between them (it also wants to # short-circuit before the nudge reader mutates DDB on a bail), so the # invariant we assert is the relative ORDER (cancel < stuck-guard < # nudge), not exact adjacency. diff --git a/agent/tests/test_pipeline.py b/agent/tests/test_pipeline.py index 58644fa18..93cc435fe 100644 --- a/agent/tests/test_pipeline.py +++ b/agent/tests/test_pipeline.py @@ -8,6 +8,7 @@ from models import AgentResult, RepoSetup, TaskConfig from pipeline import _chain_prior_agent_error, _resolve_overall_task_status +from post_hooks import VerifyOutcome # Minimal Linear channel metadata for the early-ACK ordering tests. _LINEAR_META = {"issue_id": "ABCA-1", "workspace_id": "ws-1"} @@ -56,8 +57,8 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch( "pipeline.ensure_pr", return_value="https://github.com/org/repo/pull/1", @@ -124,8 +125,8 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch( "pipeline.ensure_pr", return_value="https://github.com/org/repo/pull/1", @@ -194,8 +195,8 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch( "pipeline.ensure_pr", return_value="https://github.com/org/repo/pull/1", @@ -532,8 +533,8 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", return_value="https://github.com/org/repo/pull/1"), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), @@ -555,6 +556,98 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N assert result["status"] == "success" assert result["pr_url"] == "https://github.com/org/repo/pull/1" + @patch("runner.run_agent") + @patch("pipeline.build_system_prompt") + @patch("pipeline.discover_project_config") + @patch("repo.setup_repo") + @patch("pipeline.task_span") + @patch("pipeline.task_state") + def test_repoful_artifact_workflow_delivers_artifact_and_skips_pr( + self, + _mock_task_state, + mock_task_span, + mock_setup_repo, + _mock_discover, + _mock_build_prompt, + mock_run_agent, + monkeypatch, + ): + # A REPO-FUL artifact workflow clones for context but its terminal outcome + # is a DOCUMENT, not a PR. It must take the repo-bound path (clone), then + # deliver the result as an artifact and SKIP the build/PR post-hooks. + # + # No such workflow ships today, so this uses a synthetic one: the branch is + # selected by the workflow CONTRACT (terminal_outcomes.primary == artifact + # AND requires_repo), not by a workflow id, and without a test the branch + # could be deleted or inverted silently. Getting it wrong means opening an + # empty PR for a document task, or running a build that was never wanted. + monkeypatch.setenv("GITHUB_TOKEN", "ghp_test") + monkeypatch.setenv("AWS_REGION", "us-east-1") + mock_setup_repo.return_value = RepoSetup( + repo_dir="/workspace/repo", + branch="bgagent/test/branch", + build_before=True, + ) + artifact_text = '{"summary": "two features", "items": []}' + + async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=None): + return AgentResult( + status="success", turns=3, cost_usd=0.05, num_turns=3, result_text=artifact_text + ) + + mock_run_agent.side_effect = fake_run_agent + mock_task_span.return_value = self._mock_span() + + # Synthesize the workflow by copying the real coding workflow and flipping + # ONLY the two fields that select this branch, so the test cannot drift from + # the production contract the way a hand-built stub would. + from workflow import load_workflow as real_load_workflow + + base_wf = real_load_workflow("coding/new-task-v1") + artifact_wf = base_wf.model_copy( + update={ + "id": "synthetic/artifact-repoful-v1", + "requires_repo": True, + "terminal_outcomes": base_wf.terminal_outcomes.model_copy( + update={"primary": "artifact"} + ), + } + ) + + with ( + patch("workflow.load_workflow", return_value=artifact_wf), + patch( + "pipeline._deliver_plan_artifact", + return_value="s3://artifacts-bkt/artifacts/artifact-1/result.md", + ) as mock_deliver, + patch("pipeline.ensure_pr") as mock_ensure_pr, + patch("pipeline.verify_build") as mock_verify_build, + patch("pipeline.ensure_committed") as mock_ensure_committed, + patch("pipeline.get_disk_usage", return_value=0), + patch("pipeline.print_metrics"), + patch("pipeline._maybe_upload_trace", return_value=None), + ): + from pipeline import run_task + + result = run_task( + repo_url="owner/repo", + task_description="Add auth + billing + admin", + github_token="ghp_test", + aws_region="us-east-1", + task_id="artifact-1", + resolved_workflow={"id": "synthetic/artifact-repoful-v1", "version": "1.0.0"}, + ) + + # Repo-bound path ran (clone), plan delivered as artifact, PR/build skipped. + mock_setup_repo.assert_called_once() + mock_deliver.assert_called_once() + mock_ensure_pr.assert_not_called() + mock_verify_build.assert_not_called() + mock_ensure_committed.assert_not_called() + assert result["status"] == "success" + assert result["pr_url"] is None + assert result["artifact_uri"] == "s3://artifacts-bkt/artifacts/artifact-1/result.md" + class TestChainPriorAgentError: def test_none_agent_result_returns_exception_only(self): @@ -605,6 +698,28 @@ def test_success_with_build_failed(self): assert "agent_status='success'" in err assert "build_ok=False" in err + def test_success_with_build_TIMED_OUT_marks_timeout_distinctly(self): + # A build that exceeded the time limit must read as a + # TIMEOUT, not a generic build failure. The error_message carries + # ``build_ok=timeout`` so the platform's failure copy says "timed out". + ar = AgentResult(status="success") + status, err = _resolve_overall_task_status( + ar, build_ok=False, pr_url="https://pr", build_timed_out=True + ) + assert status == "error" + assert err is not None + assert "build_ok=timeout" in err + assert "build_ok=False" not in err # not the generic-failure marker + + def test_build_failed_but_not_timeout_keeps_false_marker(self): + ar = AgentResult(status="success") + _, err = _resolve_overall_task_status( + ar, build_ok=False, pr_url="https://pr", build_timed_out=False + ) + assert err is not None + assert "build_ok=False" in err + assert "timeout" not in err + def test_unknown_always_error_even_with_pr_and_build(self): """agent_status=unknown must always fail — never infer success from PR/build.""" ar = AgentResult(status="unknown") @@ -760,8 +875,8 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", mock_ensure_pr), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), @@ -783,6 +898,129 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= mock_ensure_pr.assert_called_once() + @patch("runner.run_agent") + @patch("pipeline.build_system_prompt") + @patch("pipeline.discover_project_config") + @patch("repo.setup_repo") + @patch("pipeline.task_span") + def test_jira_card_not_moved_to_in_review_on_build_failure( + self, + mock_task_span, + mock_setup_repo, + _mock_discover, + _mock_build_prompt, + mock_run_agent, + monkeypatch, + ): + """review blocker #9a: ensure_pr opens a PR even on a FAILED build (so the + human sees the broken diff), so the Jira In Progress → In Review transition + must gate on build_passed — not merely on pr_url — or the board lies that + the work is ready for review. Mirrors the Linear success-only twin.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_test") + monkeypatch.setenv("AWS_REGION", "us-east-1") + mock_setup_repo.return_value = RepoSetup( + repo_dir="/workspace/repo", + branch="bgagent/test/branch", + build_before=True, + ) + + async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory=None): + return AgentResult(status="success", turns=2, cost_usd=0.01, num_turns=2) + + mock_run_agent.side_effect = fake_run_agent + + mock_span = MagicMock() + mock_span.__enter__ = MagicMock(return_value=mock_span) + mock_span.__exit__ = MagicMock(return_value=False) + mock_task_span.return_value = mock_span + + mock_transition = MagicMock() + with ( + patch("pipeline.ensure_committed", return_value=False), + # FAILED build — ensure_pr still opens the PR below. + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=False)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), + patch("pipeline.ensure_pr", return_value="https://github.com/o/r/pull/9"), + patch("pipeline.transition_pr_opened", mock_transition), + patch("pipeline.get_disk_usage", return_value=0), + patch("pipeline.print_metrics"), + patch("pipeline.task_state") as mock_task_state_mod, + ): + mock_task_state_mod.get_task = MagicMock(return_value={"status": "RUNNING"}) + mock_task_state_mod.TaskFetchError = Exception # type: ignore[attr-defined] + from pipeline import run_task + + run_task( + repo_url="o/r", + task_description="x", + github_token="ghp_test", + aws_region="us-east-1", + task_id="t-jira-failbuild", + channel_source="jira", + channel_metadata={"jira_issue_key": "ABC-1"}, + ) + # PR opened, but the Jira card was NOT advanced to In Review on the red build. + mock_transition.assert_not_called() + + @patch("runner.run_agent") + @patch("pipeline.build_system_prompt") + @patch("pipeline.discover_project_config") + @patch("repo.setup_repo") + @patch("pipeline.task_span") + def test_jira_card_moved_to_in_review_on_build_success( + self, + mock_task_span, + mock_setup_repo, + _mock_discover, + _mock_build_prompt, + mock_run_agent, + monkeypatch, + ): + """The positive twin: a PASSING build DOES move the Jira card to In Review.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_test") + monkeypatch.setenv("AWS_REGION", "us-east-1") + mock_setup_repo.return_value = RepoSetup( + repo_dir="/workspace/repo", + branch="bgagent/test/branch", + build_before=True, + ) + + async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory=None): + return AgentResult(status="success", turns=2, cost_usd=0.01, num_turns=2) + + mock_run_agent.side_effect = fake_run_agent + + mock_span = MagicMock() + mock_span.__enter__ = MagicMock(return_value=mock_span) + mock_span.__exit__ = MagicMock(return_value=False) + mock_task_span.return_value = mock_span + + mock_transition = MagicMock() + with ( + patch("pipeline.ensure_committed", return_value=False), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), + patch("pipeline.ensure_pr", return_value="https://github.com/o/r/pull/9"), + patch("pipeline.transition_pr_opened", mock_transition), + patch("pipeline.get_disk_usage", return_value=0), + patch("pipeline.print_metrics"), + patch("pipeline.task_state") as mock_task_state_mod, + ): + mock_task_state_mod.get_task = MagicMock(return_value={"status": "RUNNING"}) + mock_task_state_mod.TaskFetchError = Exception # type: ignore[attr-defined] + from pipeline import run_task + + run_task( + repo_url="o/r", + task_description="x", + github_token="ghp_test", + aws_region="us-east-1", + task_id="t-jira-okbuild", + channel_source="jira", + channel_metadata={"jira_issue_key": "ABC-1"}, + ) + mock_transition.assert_called_once() + @patch("runner.run_agent") @patch("pipeline.build_system_prompt") @patch("pipeline.discover_project_config") @@ -837,8 +1075,8 @@ def flaky_load(workflow_id): with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", mock_ensure_pr), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), @@ -869,7 +1107,7 @@ def _running_span() -> MagicMock: # --------------------------------------------------------------------------- -# Chunk K1 — trace threading into TaskConfig (design §10.1) +# Trace threading into TaskConfig (design §10.1) # --------------------------------------------------------------------------- @@ -921,8 +1159,8 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch( "pipeline.ensure_pr", return_value="https://github.com/org/repo/pull/1", @@ -989,8 +1227,8 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch( "pipeline.ensure_pr", return_value="https://github.com/org/repo/pull/1", @@ -1056,8 +1294,8 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch( "pipeline.ensure_pr", return_value="https://github.com/org/repo/pull/1", @@ -1124,8 +1362,8 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch( "pipeline.ensure_pr", return_value="https://github.com/org/repo/pull/1", @@ -1149,7 +1387,7 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N class TestTraceS3Upload: - """K2 Stage 4 — pipeline triggers the S3 trace upload only when + """Pipeline triggers the S3 trace upload only when ``trace=True`` AND ``user_id`` is non-empty; threads the resulting ``trace_s3_uri`` into ``task_state.write_terminal`` so the TaskRecord update is atomic with terminal-status.""" @@ -1197,8 +1435,8 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", return_value=None), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), @@ -1267,8 +1505,8 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", return_value=None), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), @@ -1338,8 +1576,8 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", return_value=None), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), @@ -1403,9 +1641,13 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), - patch("pipeline.ensure_pr", return_value=None), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), + # A DELIVERED task (a PR was opened) — this test is about trace-upload + # fail-open, not the no-deliverable delivery gate. Returning a PR keeps + # the delivery gate out of the picture so the assertion isolates the + # trace behavior (a real no-PR task is covered in TestDeliveryGate). + patch("pipeline.ensure_pr", return_value="https://github.com/owner/repo/pull/1"), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), ): @@ -1730,7 +1972,7 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= with ( patch("pipeline.ensure_committed", return_value=False), patch("pipeline.verify_build", side_effect=RuntimeError("build verify boom")), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", return_value=None), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), @@ -1808,7 +2050,7 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= with ( patch("pipeline.ensure_committed", return_value=False), patch("pipeline.verify_build", side_effect=ValueError("original pipeline error")), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", return_value=None), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), @@ -1919,8 +2161,8 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= patch("pipeline.transition_task_started") as m_transition, patch("pipeline.configure_channel_mcp"), patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", return_value="https://github.com/o/r/pull/1"), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), diff --git a/agent/tests/test_pipeline_outcomes.py b/agent/tests/test_pipeline_outcomes.py index d2b89a6a8..f3fb0001d 100644 --- a/agent/tests/test_pipeline_outcomes.py +++ b/agent/tests/test_pipeline_outcomes.py @@ -2,15 +2,47 @@ import pytest +from config import NEEDS_INPUT_MARKER from hooks import _record_blocker_reason, _reset_blocker_reason_for_tests from models import AgentResult from pipeline import ( + _apply_delivery_gate, _chain_prior_agent_error, _compute_turns_completed, _resolve_overall_task_status, + _starts_with_needs_input_marker, + _strip_needs_input_marker, ) +class TestNeedsInputMarker: + """Clarify-before-spend (UX #4): detect + strip the hold-and-ask marker.""" + + def test_detects_marker_on_first_line(self): + text = f"{NEEDS_INPUT_MARKER}\nWhich page feels slow — the dashboard or the list?" + assert _starts_with_needs_input_marker(text) is True + + def test_detects_marker_after_leading_blank_lines(self): + text = f"\n\n{NEEDS_INPUT_MARKER} What target latency are you aiming for?" + assert _starts_with_needs_input_marker(text) is True + + def test_ignores_marker_buried_mid_message(self): + # A stray mention deep in prose is NOT a hold signal — only the first line. + text = f"I made the change.\nBy the way {NEEDS_INPUT_MARKER} is our sentinel." + assert _starts_with_needs_input_marker(text) is False + + def test_none_or_empty_is_not_a_hold(self): + assert _starts_with_needs_input_marker(None) is False + assert _starts_with_needs_input_marker("") is False + assert _starts_with_needs_input_marker("Just a normal answer.") is False + + def test_strip_removes_leading_marker_only(self): + text = f"{NEEDS_INPUT_MARKER}\nWhich part is slow?" + assert _strip_needs_input_marker(text) == "Which part is slow?" + # Idempotent-ish: no marker → unchanged (trimmed). + assert _strip_needs_input_marker(" plain question? ") == "plain question?" + + @pytest.fixture(autouse=True) def _reset_blocker_latch(): """#251 carry-path latch is module-level; reset around every test so a @@ -27,6 +59,33 @@ def test_success_end_turn_with_build_ok(self): assert overall == "success" assert err is None + def test_infra_failed_build_forces_error_even_when_gate_would_pass(self): + # The build was killed by ENOSPC/OOM (build_infra_failed). + # Even if the regression-only gate would pass (build_ok=True — e.g. the + # pre-agent baseline was ALSO infra-killed, so "already red → not a + # regression"), we must NOT report a false ✅ on unverified code. Forces + # an error with a build_ok=infra marker for the platform's honest copy. + ar = AgentResult(status="success", error=None) + overall, err = _resolve_overall_task_status( + ar, + build_ok=True, + pr_url="https://pr", + build_infra_failed=True, + ) + assert overall == "error" + assert "build_ok=infra" in (err or "") + + def test_infra_failed_marker_present_when_gate_also_fails(self): + ar = AgentResult(status="end_turn", error=None) + overall, err = _resolve_overall_task_status( + ar, + build_ok=False, + pr_url=None, + build_infra_failed=True, + ) + assert overall == "error" + assert "build_ok=infra" in (err or "") + def test_unknown_is_always_error_even_with_pr(self): ar = AgentResult(status="unknown", error=None) overall, err = _resolve_overall_task_status( @@ -102,6 +161,98 @@ def test_error_status_preserves_bedrock_entitlement_message(self): assert "not available" in err +class TestDeliveryGate: + """A create-strategy new-work task that reported success but shipped + NOTHING (no PR AND no commit) must be failed loudly, not COMPLETED — + otherwise the platform reports success for work that was lost. + + Common args factory keeps each case to just the axis under test.""" + + @staticmethod + def _gate( + overall_status: str = "success", + result_error: str | None = None, + *, + workflow_read_only: bool = False, + artifact_workflow: bool = False, + needs_input: bool = False, + ensure_pr_strategy: str = "create", + pr_url: str | None = None, + commit_landed: bool = False, + ) -> tuple[str, str | None]: + # Explicit typed kwargs (not **overrides) so `ty` can check each arg + # against _apply_delivery_gate's signature. + return _apply_delivery_gate( + overall_status, + result_error, + workflow_read_only=workflow_read_only, + artifact_workflow=artifact_workflow, + needs_input=needs_input, + ensure_pr_strategy=ensure_pr_strategy, + pr_url=pr_url, + commit_landed=commit_landed, + ) + + def test_success_no_pr_no_commit_becomes_lost(self): + # The lost-deliverable case: agent-success, create strategy, nothing shipped. + overall, err = self._gate() + assert overall == "error" + assert err is not None + assert "deliverable=lost" in err + + def test_success_no_pr_but_commit_landed_is_no_pr(self): + # A commit reached the branch but the PR failed to open — recoverable, + # and the copy must say the work is NOT gone. + overall, err = self._gate(commit_landed=True) + assert overall == "error" + assert err is not None + assert "deliverable=no_pr" in err + + def test_success_with_pr_is_untouched(self): + overall, err = self._gate(pr_url="https://github.com/o/r/pull/1") + assert overall == "success" + assert err is None + + def test_read_only_success_no_pr_stays_success(self): + # pr_review ships no PR by design — never gate it. + overall, err = self._gate(workflow_read_only=True) + assert overall == "success" + assert err is None + + def test_artifact_workflow_success_no_pr_stays_success(self): + # An artifact workflow delivers an artifact_uri, not a PR. + overall, err = self._gate(artifact_workflow=True) + assert overall == "success" + assert err is None + + def test_needs_input_success_no_pr_stays_success(self): + # Clarify-and-hold is the one sanctioned new_task no-op. + overall, err = self._gate(needs_input=True) + assert overall == "success" + assert err is None + + def test_non_create_strategy_success_no_pr_stays_success(self): + # pr_iteration (push_resolve/resolve) legitimately opens no NEW PR. + overall, err = self._gate(ensure_pr_strategy="push_resolve") + assert overall == "success" + assert err is None + + def test_already_error_is_left_error(self): + # A task that already failed for another reason keeps that reason — + # the gate only promotes a FALSE success, never rewrites a real error. + overall, err = self._gate(overall_status="error", result_error="build failed") + assert overall == "error" + assert err == "build failed" + + def test_lost_reason_matches_classifier_pattern(self): + # Contract with the CDK error-classifier: the reason must carry the + # ``deliverable=lost`` token the classifier keys on. + _, err = self._gate() + assert err is not None + assert "agent_status=success" in err + assert "deliverable=lost" in err + + class TestChainPriorAgentError: def test_no_agent_result(self): msg = _chain_prior_agent_error(None, ValueError("post-hook failed")) @@ -164,7 +315,7 @@ def test_zero_turns_attempted_round_trips(self): class TestMaxTurnsStuckEnrichment: - """N3 wiring seam (#600): _resolve_overall_task_status enriches a max_turns + """Wiring seam (#600): _resolve_overall_task_status enriches a max_turns reason with the stuck-guard summary (hooks.last_stuck_summary). Previously tested only at its two pure endpoints; this drives the append itself.""" diff --git a/agent/tests/test_pipeline_post_hook_gates.py b/agent/tests/test_pipeline_post_hook_gates.py index 32ed74cc2..8428fd8bd 100644 --- a/agent/tests/test_pipeline_post_hook_gates.py +++ b/agent/tests/test_pipeline_post_hook_gates.py @@ -21,6 +21,7 @@ from models import AgentResult, RepoSetup from pipeline import _apply_post_hook_gates +from post_hooks import VerifyOutcome from workflow import Workflow, gate_status, load_workflow @@ -324,8 +325,8 @@ async def fake_run_agent(_p, _s, _c, cwd=None, trajectory=None): patch("pipeline.task_span", return_value=self._span()), patch("pipeline.task_state"), patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=build_passed), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=build_passed)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", mock_ensure_pr), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), diff --git a/agent/tests/test_post_hooks.py b/agent/tests/test_post_hooks.py index c39c21770..b52b755e2 100644 --- a/agent/tests/test_post_hooks.py +++ b/agent/tests/test_post_hooks.py @@ -6,6 +6,7 @@ ``shell.run_cmd`` (mutating git/gh commands) — both faked with recorders. """ +import subprocess from types import SimpleNamespace import post_hooks @@ -45,10 +46,18 @@ def __call__(self, cmd, **kwargs): return _cp() -def _pr_view(url: str) -> _SubprocessRunRecorder: - """Recorder whose ``gh pr view`` returns *url* (other calls rc=0, empty).""" +def _pr_view(url: str, base: str = "main") -> _SubprocessRunRecorder: + """Recorder for the two ``gh pr view`` shapes ensure_pr issues. + + The URL query (``--json url``) returns *url*; the base query + (``--json baseRefName``, used by ``_reconcile_pr_base``) returns *base*. + Defaulting *base* to ``main`` matches ``_setup``'s default_branch so the + reconcile is a no-op unless a test opts into a mismatch. + """ def responder(cmd): + if "view" in cmd and "baseRefName" in cmd: + return _cp(returncode=0, stdout=base + "\n") if "view" in cmd: return _cp(returncode=0, stdout=url + "\n") return _cp() @@ -182,17 +191,24 @@ def _ensure_pushed(d, b): class TestEnsurePrCreate: def test_returns_existing_pr_when_already_open(self, monkeypatch): - # First `gh pr view` returns a URL -> short-circuit, no creation. - sub = _pr_view("https://github.com/o/r/pull/1") + # First `gh pr view` returns a URL -> short-circuit, no creation. The + # existing PR's base ("main") already matches default_branch, so the + # base reconcile is a no-op (no `gh pr edit`). + sub = _pr_view("https://github.com/o/r/pull/1", base="main") run_cmd = _RunCmdRecorder() monkeypatch.setattr(post_hooks.subprocess, "run", sub) monkeypatch.setattr(post_hooks, "run_cmd", run_cmd) url = post_hooks.ensure_pr( - _config(), _setup(), build_passed=True, lint_passed=True, strategy="create" + _config(), + _setup(default_branch="main"), + build_passed=True, + lint_passed=True, + strategy="create", ) assert url == "https://github.com/o/r/pull/1" assert "create-pr" not in run_cmd.labels() + assert "reconcile-pr-base" not in run_cmd.labels() def test_no_commits_means_no_pr(self, monkeypatch): # pr view -> empty (no existing PR); git log diff -> empty (no commits). @@ -249,3 +265,190 @@ def responder(cmd): assert "Resolves #55" in body assert "**PASS**" in body # build passed assert "**FAIL**" in body # lint failed + + +class TestReconcileAgentBranch: + """A leading cause of lost deliverables: reconcile the platform branch when + the agent committed on its OWN branch instead of the pre-checked-out + platform branch. + + Real git (tmp_path) — this is pure git plumbing, so a real repo gives far + higher confidence than faking subprocess. The two seams (subprocess.run for + the branch read, run_cmd for the mutating ops) both hit the tmp repo.""" + + @staticmethod + def _git(repo, *args): + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True) + + def _make_repo(self, tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + self._git(repo, "init", "-q") + self._git(repo, "config", "user.email", "t@t") + self._git(repo, "config", "user.name", "t") + (repo / "f.txt").write_text("base\n") + self._git(repo, "add", "-A") + self._git(repo, "commit", "-qm", "base") + # Rename default branch to a stable name for the test. + self._git(repo, "branch", "-M", "main") + return str(repo) + + def _head_sha(self, repo): + return subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo, check=True, capture_output=True, text=True + ).stdout.strip() + + def _sha_of(self, repo, ref): + return subprocess.run( + ["git", "rev-parse", ref], cwd=repo, check=True, capture_output=True, text=True + ).stdout.strip() + + def test_reconciles_when_agent_on_own_branch(self, tmp_path): + repo = self._make_repo(tmp_path) + platform = "bgagent/task-1/fix" + # Platform creates its (empty) branch, as setup_repo does. + self._git(repo, "checkout", "-qb", platform) + # Agent goes rogue: its own branch + a commit (the case observed in practice). + self._git(repo, "checkout", "-qb", "agent-own-branch") + (tmp_path / "repo" / "f.txt").write_text("base\nagent change\n") + self._git(repo, "commit", "-qam", "agent work") + agent_head = self._head_sha(repo) + + moved = post_hooks.reconcile_agent_branch(repo, platform) + + assert moved is True + # Platform branch now points at the agent's commit … + assert self._sha_of(repo, platform) == agent_head + # … and it is the checked-out branch, so downstream delivery uses it. + assert post_hooks._current_branch(repo) == platform + + def test_noop_when_already_on_platform_branch(self, tmp_path): + repo = self._make_repo(tmp_path) + platform = "bgagent/task-1/fix" + self._git(repo, "checkout", "-qb", platform) + (tmp_path / "repo" / "f.txt").write_text("base\non platform\n") + self._git(repo, "commit", "-qam", "work on platform") + before = self._head_sha(repo) + + moved = post_hooks.reconcile_agent_branch(repo, platform) + + assert moved is False + assert self._sha_of(repo, platform) == before + assert post_hooks._current_branch(repo) == platform + + def test_noop_on_detached_head(self, tmp_path): + repo = self._make_repo(tmp_path) + platform = "bgagent/task-1/fix" + self._git(repo, "checkout", "-qb", platform) + # Detach HEAD at the current commit. + self._git(repo, "checkout", "-q", "--detach") + + moved = post_hooks.reconcile_agent_branch(repo, platform) + + assert moved is False # nothing to adopt + + def test_current_branch_reports_none_when_detached(self, tmp_path): + repo = self._make_repo(tmp_path) + self._git(repo, "checkout", "-q", "--detach") + assert post_hooks._current_branch(repo) is None + + +class TestReconcilePrBase: + """The agent picks its own PR --base; ensure_pr corrects it deterministically + to setup.default_branch (the orchestrator's base for a stacked child / the + detected repo default for a root). Observed in practice on an orchestrated + chain (#247): a stacked child + a root both opened against a wrong 'main'.""" + + def test_retargets_when_base_mismatches(self, monkeypatch): + # Existing PR is based on 'main' but the stacked child's real base is + # the predecessor branch -> ensure_pr issues `gh pr edit --base `. + pred = "bgagent/task-x/abca-1-predecessor" + sub = _pr_view("https://github.com/o/r/pull/7", base="main") + run_cmd = _RunCmdRecorder() + monkeypatch.setattr(post_hooks.subprocess, "run", sub) + monkeypatch.setattr(post_hooks, "run_cmd", run_cmd) + + url = post_hooks.ensure_pr( + _config(), + _setup(default_branch=pred), + build_passed=True, + lint_passed=True, + strategy="create", + ) + assert url == "https://github.com/o/r/pull/7" + assert "reconcile-pr-base" in run_cmd.labels() + edit_cmd = run_cmd.cmd_for("reconcile-pr-base") + assert "edit" in edit_cmd + assert edit_cmd[edit_cmd.index("--base") + 1] == pred + + def test_noop_when_base_matches(self, monkeypatch): + # PR base already == default_branch -> no `gh pr edit`. + sub = _pr_view("https://github.com/o/r/pull/7", base="develop") + run_cmd = _RunCmdRecorder() + monkeypatch.setattr(post_hooks.subprocess, "run", sub) + monkeypatch.setattr(post_hooks, "run_cmd", run_cmd) + + url = post_hooks.ensure_pr( + _config(), + _setup(default_branch="develop"), + build_passed=True, + lint_passed=True, + strategy="create", + ) + assert url == "https://github.com/o/r/pull/7" + assert "reconcile-pr-base" not in run_cmd.labels() + + def test_retarget_failure_warns_and_is_not_fatal(self, monkeypatch): + # `gh pr edit` fails -> WARN naming the consequence, URL still returned. + pred = "bgagent/task-x/abca-1-predecessor" + sub = _pr_view("https://github.com/o/r/pull/7", base="main") + run_cmd = _RunCmdRecorder(returncodes={"reconcile-pr-base": 1}) + monkeypatch.setattr(post_hooks.subprocess, "run", sub) + monkeypatch.setattr(post_hooks, "run_cmd", run_cmd) + warns: list[str] = [] + monkeypatch.setattr( + post_hooks, "log", lambda lvl, msg: warns.append(msg) if lvl == "WARN" else None + ) + + url = post_hooks.ensure_pr( + _config(), + _setup(default_branch=pred), + build_passed=True, + lint_passed=True, + strategy="create", + ) + assert url == "https://github.com/o/r/pull/7" + assert any("PR remains based on 'main'" in w for w in warns) + + def test_reconcile_skipped_for_freshly_created_pr_path(self, monkeypatch): + # When the agent did NOT pre-create the PR, ensure_pr creates it with the + # correct --base directly; no separate reconcile needed (create path + # already uses default_branch). Guards against double-work. + def responder(cmd): + if "view" in cmd: + return _cp(returncode=1, stderr="no pr") + if "log" in cmd and "--reverse" in cmd: + return _cp(returncode=0, stdout="feat: x\n") + if "log" in cmd: + return _cp(returncode=0, stdout="feat: x\n\n---") + return _cp() + + sub = _SubprocessRunRecorder(responder=responder) + run_cmd = _RunCmdRecorder(stdouts={"create-pr": "https://github.com/o/r/pull/8\n"}) + monkeypatch.setattr(post_hooks, "ensure_pushed", lambda d, b: True) + monkeypatch.setattr(post_hooks.subprocess, "run", sub) + monkeypatch.setattr(post_hooks, "run_cmd", run_cmd) + + pred = "bgagent/task-x/abca-1-predecessor" + url = post_hooks.ensure_pr( + _config(), + _setup(default_branch=pred), + build_passed=True, + lint_passed=True, + strategy="create", + ) + assert url == "https://github.com/o/r/pull/8" + # create path used the right base; no post-creation reconcile fired. + create_cmd = run_cmd.cmd_for("create-pr") + assert create_cmd[create_cmd.index("--base") + 1] == pred + assert "reconcile-pr-base" not in run_cmd.labels() diff --git a/agent/tests/test_prompts.py b/agent/tests/test_prompts.py index b26e13aac..08e42a422 100644 --- a/agent/tests/test_prompts.py +++ b/agent/tests/test_prompts.py @@ -31,16 +31,55 @@ def test_no_channel_returns_empty(self): def test_api_channel_returns_empty(self): assert _channel_prompt_addendum(_config(channel_source="api")) == "" - def test_linear_channel_includes_linear_tools(self): + def test_linear_addendum_is_deterministic_no_mcp(self): + # ADR-016: Linear is fully deterministic. The agent has no Linear tools, + # so the addendum must NOT reference any mcp__linear-server__* tool and + # must tell the agent context is pre-hydrated + status is automatic. addendum = _channel_prompt_addendum( _config( channel_source="linear", - channel_metadata={"linear_issue_identifier": "ABC-42"}, + channel_metadata={ + "linear_issue_id": "issue-uuid-1", + "linear_issue_identifier": "ABC-42", + }, ) ) - assert "Linear issue progress updates" in addendum - assert "mcp__linear-server__save_comment" in addendum assert "ABC-42" in addendum + assert "mcp__linear-server" not in addendum + # Inbound context is pre-hydrated; outbound status is platform-managed. + assert "already" in addendum.lower() + assert "Do NOT post Linear comments" in addendum + + def test_linear_addendum_same_regardless_of_workflow(self): + # There is no longer per-workflow MCP choreography — new-task, iteration, + # and review all get the same deterministic "platform manages Linear" + # guidance, and none of them mention MCP tools. + base_meta = {"linear_issue_id": "issue-uuid-1", "linear_issue_identifier": "ABC-42"} + for wf in (None, "coding/pr-iteration-v1", "coding/pr-review-v1", "coding/new-task-v1"): + overrides: dict[str, Any] = {"channel_source": "linear", "channel_metadata": base_meta} + if wf is not None: + overrides["resolved_workflow"] = {"id": wf, "version": "1.0.0"} + addendum = _channel_prompt_addendum(_config(**overrides)) + assert "mcp__linear-server" not in addendum + assert "🤖 Starting on this issue" not in addendum + assert "Linear context discovery" not in addendum + + def test_linear_integration_node_gets_no_addendum(self): + # The synthetic orchestration integration node (#247) is a Linear + # task but has NO real sub-issue — channel_metadata omits + # linear_issue_id. No issue id → no addendum (the parent panel is the + # surface). + addendum = _channel_prompt_addendum( + _config( + channel_source="linear", + channel_metadata={ + "orchestration_id": "orch_abc", + "orchestration_sub_issue_id": "orch_abc__integration", + "parent_linear_issue_id": "parent-uuid", + }, + ) + ) + assert addendum == "" def test_jira_channel_gets_no_addendum(self): # Jira comments are posted out-of-band by jira_reactions (REST shim); @@ -63,6 +102,17 @@ def test_new_task_returns_prompt_with_create_pr(self): assert "{branch_name}" in prompt assert "{workflow}" not in prompt + def test_new_task_has_clarify_before_spend_branch(self): + # Clarify-before-spend (UX #4): the new_task workflow must tell the agent + # to ASK via the request_clarification tool instead of guessing on a + # genuinely vague request, and to not build unrequested scope. + prompt = get_system_prompt("coding/new-task-v1") + assert "request_clarification" in prompt # the deterministic tool signal + assert "{needs_input_marker}" in prompt # marker fallback, substituted at build time + assert "clarifying question" in prompt or "clarification" in prompt + # Scope discipline (the typo->button case). + assert "weren't requested" in prompt or "not requested" in prompt + def test_pr_iteration_returns_prompt_with_update_pr(self): prompt = get_system_prompt("coding/pr-iteration-v1") assert "Post a summary comment on the PR" in prompt @@ -74,6 +124,16 @@ def test_pr_iteration_returns_prompt_with_update_pr(self): assert "{branch_name}" in prompt assert "{workflow}" not in prompt + def test_pr_iteration_distinguishes_question_from_change(self): + # A question-only comment ("where is the login page?") must be + # answered without forcing a code change, or the platform reports a + # false "✅ Updated". The prompt must carry the triage. + prompt = get_system_prompt("coding/pr-iteration-v1") + assert "QUESTION" in prompt + assert "CHANGE REQUEST" in prompt + # It must explicitly forbid inventing a commit to justify "doing something". + assert "empty or cosmetic commit" in prompt or "Do NOT invent a code change" in prompt + def test_pr_review_returns_prompt_with_review_workflow(self): prompt = get_system_prompt("coding/pr-review-v1") assert "READ-ONLY" in prompt @@ -84,8 +144,27 @@ def test_pr_review_returns_prompt_with_review_workflow(self): assert "Write and Edit are not available" in prompt assert "{workflow}" not in prompt + def test_restack_returns_prompt_with_remerge_workflow(self): + prompt = get_system_prompt("coding/restack-v1") + assert "RE-STACKING" in prompt + assert "predecessor" in prompt + assert ( + "do NOT add features" in prompt + or "NOT new feature work" in prompt + or "not new feature" in prompt.lower() + ) + assert "{branch_name}" in prompt # pushes to the SAME existing branch + assert "{pr_number}" in prompt + assert "{repo_url}" in prompt + assert "{workflow}" not in prompt + def test_all_workflows_contain_shared_base_sections(self): - for workflow_id in ("coding/new-task-v1", "coding/pr-iteration-v1", "coding/pr-review-v1"): + for workflow_id in ( + "coding/new-task-v1", + "coding/pr-iteration-v1", + "coding/pr-review-v1", + "coding/restack-v1", + ): prompt = get_system_prompt(workflow_id) assert "## Environment" in prompt, f"Missing Environment in {workflow_id}" has_rules = "## Rules" in prompt or "## Rules override" in prompt diff --git a/agent/tests/test_repo.py b/agent/tests/test_repo.py index a217808ed..0091fe555 100644 --- a/agent/tests/test_repo.py +++ b/agent/tests/test_repo.py @@ -33,6 +33,12 @@ def _patch_common(monkeypatch, fake: FakeRunCmd): # _install_commit_hook touches the filesystem; stub it out (it's its own # best-effort path and not under test here). monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) + # The clone is faked here, so the real workspace never gets a + # `.git`. Stub the pre-clean + git-root backstop (tested directly in + # TestCloneWorkspaceGuards) so the hermetic clone tests don't trip the + # assertion on a non-existent/empty workspace path. + monkeypatch.setattr(repo, "_prepare_clone_dir", lambda repo_dir, notes: None) + monkeypatch.setattr(repo, "_assert_clone_root", lambda repo_dir: None) class TestSetupRepoHappyPath: @@ -90,6 +96,334 @@ def test_pr_branch_checkout_path(self, monkeypatch): # base_branch from orchestrator wins for PR workflows — no detection call. assert setup.default_branch == "develop" + def test_non_pr_task_captures_head_sha_for_digest(self, monkeypatch): + # A NON-PR workflow (e.g. coding/pr-review-v1) must also + # capture the cloned HEAD sha (via the post-setup rev-parse) so the planner + # can echo it into repo_digest_sha. The PR path captures its own sha; this + # covers the else/default clone path such a workflow uses. + fake = _fake_run_cmd(stdouts={"head-sha-after-setup": "deadbeefcafe1234\n"}) + _patch_common(monkeypatch, fake) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + + setup = repo.setup_repo(_config()) + + assert "head-sha-after-setup" in fake.labels() + assert setup.head_sha_before == "deadbeefcafe1234" + + def test_pr_workflow_does_not_double_capture_head_sha(self, monkeypatch): + # The PR path already set head_sha_before, so the post-setup fallback must + # NOT run (guarded on `if not head_sha_before`). + fake = _fake_run_cmd(stdouts={"head-sha-before": "aaaa1111bbbb2222\n"}) + _patch_common(monkeypatch, fake) + + setup = repo.setup_repo( + _config(is_pr_workflow=True, branch_name="feature/x", base_branch="develop") + ) + + assert setup.head_sha_before == "aaaa1111bbbb2222" + assert "head-sha-after-setup" not in fake.labels() + + +class TestDiamondBaseBranch: + """A diamond child (base_branch + merge_branches) + is handed the server's 'main' literal as its base, which is WRONG on a fork + whose real default isn't main. repo.py must resolve the real default and use + it for BOTH the checkout base and the PR base. A linear child (base_branch, no + merge_branches) must be left untouched.""" + + def test_diamond_resolves_real_default_over_server_main_literal(self, monkeypatch): + fake = _fake_run_cmd() + _patch_common(monkeypatch, fake) + # Fork's real default is linear-vercel, but the server passed 'main'. + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "linear-vercel") + + setup = repo.setup_repo( + _config( + base_branch="main", + merge_branches=["bgagent/task-x/feat-a", "bgagent/task-y/feat-b"], + ) + ) + + # Checkout base was rewritten to the detected default, not 'main'. + fetch_cmd = fake.cmd_for("fetch-base-branch") + assert fetch_cmd is not None + assert fetch_cmd[-1] == "linear-vercel" + create_cmd = fake.cmd_for("create-branch-from-base") + assert create_cmd is not None + assert create_cmd[-1] == "origin/linear-vercel" + # PR base (setup.default_branch) follows — so the diamond PR targets the + # real default, not stale main (the whole point of F1). + assert setup.default_branch == "linear-vercel" + # The predecessor branches are still merged in (diamond sees all preds). + assert "merge-predecessor" in " ".join(fake.labels()) or any( + "merge" in lbl for lbl in fake.labels() + ) + + def test_diamond_no_op_when_server_base_already_matches_detected_default(self, monkeypatch): + fake = _fake_run_cmd() + _patch_common(monkeypatch, fake) + # Server 'main' already IS the real default (upstream/main case) → no rewrite. + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + + setup = repo.setup_repo( + _config(base_branch="main", merge_branches=["bgagent/task-x/feat-a"]) + ) + assert fake.cmd_for("fetch-base-branch")[-1] == "main" + assert setup.default_branch == "main" + + def test_linear_child_base_branch_is_left_untouched(self, monkeypatch): + # A LINEAR child (single predecessor → base_branch set, NO merge_branches) + # stacks on its predecessor's branch; that base must NOT be rewritten to + # the repo default (it's intentionally the predecessor branch). + fake = _fake_run_cmd() + _patch_common(monkeypatch, fake) + # If detect_default_branch were (wrongly) consulted, it'd return this — the + # test proves it is NOT used for a linear child. + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "linear-vercel") + + setup = repo.setup_repo( + _config(base_branch="bgagent/task-pred/feat-pred", merge_branches=[]) + ) + assert fake.cmd_for("fetch-base-branch")[-1] == "bgagent/task-pred/feat-pred" + assert fake.cmd_for("create-branch-from-base")[-1] == "origin/bgagent/task-pred/feat-pred" + # PR base is the predecessor branch (the stack target), unchanged. + assert setup.default_branch == "bgagent/task-pred/feat-pred" + + +class TestReadOnlyBaselineSkip: + """ECS rightsized planning: a read_only workflow (coding/pr-review-v1) + never edits code, runs the post-agent gate, or opens a PR, so the pre-agent + build + lint baseline is pure waste — and on a big repo the full CI-parity + `mise run build` won't fit the 8 GB read-only planning task def (it would + stall/OOM before the planner reads a file). setup_repo must skip both + baselines for read_only and still return neutral OK values.""" + + def test_read_only_skips_build_and_lint_baseline(self, monkeypatch): + fake = _fake_run_cmd() + _patch_common(monkeypatch, fake) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + + setup = repo.setup_repo(_config(read_only=True)) + + labels = fake.labels() + # The heavy build + lint baselines must NOT run. + assert "verify-build-pre" not in labels + assert "verify-lint-pre" not in labels + # But the clone/branch/mise-install setup still happens. + assert "clone" in labels + assert "mise-install" in labels + # Neutral OK baselines (nothing gets committed, so nothing to gate). + assert setup.build_before is True + assert setup.lint_before is True + assert setup.build_gate_inert is False + assert setup.lint_gate_inert is False + assert any("Read-only workflow" in n for n in setup.notes) + + def test_non_read_only_still_runs_build_and_lint_baseline(self, monkeypatch): + # Regression guard: the default (write) workflow must still run both + # baselines — the skip is gated strictly on read_only. + fake = _fake_run_cmd() + _patch_common(monkeypatch, fake) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + + setup = repo.setup_repo(_config(read_only=False)) + + labels = fake.labels() + assert "verify-build-pre" in labels + assert "verify-lint-pre" in labels + assert setup.build_before is True # fake run_cmd returns rc 0 + assert setup.lint_before is True + + +class TestBaselineBuildTimeout: + """The pre-agent baseline build/lint must run with the SAME generous + wall-clock ceiling as the post-agent gate (BUILD_VERIFY_TIMEOUT_S, 30min) + — NOT run_cmd's 600s default — and a TIMEOUT must be GUARDED so a + slow-but-valid CI-parity build no longer raises out of setup_repo and crashes + the whole task before the agent ever runs (the observed symptom: no PR, issue + stuck in Backlog, indistinguishable from a real failure).""" + + class _RecordingFake: + """run_cmd fake that records the ``timeout`` kwarg and can raise + TimeoutExpired for a label substring.""" + + def __init__( + self, + timeout_on: str | None = None, + rc_on: tuple[str, int, str] | None = None, + ): + self.calls: list[dict] = [] + self._timeout_on = timeout_on + # (label_substring, returncode, stderr) to force a non-zero exit on a + # specific labelled command (e.g. an OOM-killed baseline build). + self._rc_on = rc_on + + def __call__(self, cmd, label, cwd=None, timeout=600, check=True, **kwargs): + self.calls.append({"label": label, "timeout": timeout}) + if self._timeout_on and self._timeout_on in label: + raise subprocess.TimeoutExpired(cmd=cmd, timeout=timeout) + if self._rc_on and self._rc_on[0] in label: + return SimpleNamespace(returncode=self._rc_on[1], stdout="", stderr=self._rc_on[2]) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + def timeout_for(self, label: str) -> int | None: + for c in self.calls: + if label in c["label"]: + return c["timeout"] + return None + + def test_baseline_build_uses_the_generous_verify_ceiling_not_600s(self, monkeypatch): + from post_hooks import BUILD_VERIFY_TIMEOUT_S + + fake = self._RecordingFake() + monkeypatch.setattr(repo, "run_cmd", fake) + monkeypatch.setattr(repo, "run_cmd_with_backoff", fake) + monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) + # The clone is faked here, so the real workspace never gets a + # `.git`. Stub the pre-clean + git-root backstop (tested directly in + # TestCloneWorkspaceGuards) so the hermetic clone tests don't trip the + # assertion on a non-existent/empty workspace path. + monkeypatch.setattr(repo, "_prepare_clone_dir", lambda repo_dir, notes: None) + monkeypatch.setattr(repo, "_assert_clone_root", lambda repo_dir: None) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + + repo.setup_repo(_config(read_only=False)) + + assert fake.timeout_for("verify-build-pre") == BUILD_VERIFY_TIMEOUT_S + assert fake.timeout_for("verify-lint-pre") == BUILD_VERIFY_TIMEOUT_S + assert BUILD_VERIFY_TIMEOUT_S > 600 # the whole point: not the old default + + def test_baseline_build_timeout_is_guarded_not_a_task_crash(self, monkeypatch): + # The heavy build times out. setup_repo must NOT propagate TimeoutExpired + # (which crashed the task pre-agent); it degrades to "no baseline" and the + # run proceeds with build_before=True (a timeout is not a regression). + fake = self._RecordingFake(timeout_on="verify-build-pre") + monkeypatch.setattr(repo, "run_cmd", fake) + monkeypatch.setattr(repo, "run_cmd_with_backoff", fake) + monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) + # The clone is faked here, so the real workspace never gets a + # `.git`. Stub the pre-clean + git-root backstop (tested directly in + # TestCloneWorkspaceGuards) so the hermetic clone tests don't trip the + # assertion on a non-existent/empty workspace path. + monkeypatch.setattr(repo, "_prepare_clone_dir", lambda repo_dir, notes: None) + monkeypatch.setattr(repo, "_assert_clone_root", lambda repo_dir: None) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + + setup = repo.setup_repo(_config(read_only=False)) # must not raise + + assert setup.build_before is True # timeout → not treated as a regression + assert setup.build_gate_inert is False + assert any("did not finish within" in n for n in setup.notes) + + def test_baseline_lint_timeout_is_guarded(self, monkeypatch): + fake = self._RecordingFake(timeout_on="verify-lint-pre") + monkeypatch.setattr(repo, "run_cmd", fake) + monkeypatch.setattr(repo, "run_cmd_with_backoff", fake) + monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) + # The clone is faked here, so the real workspace never gets a + # `.git`. Stub the pre-clean + git-root backstop (tested directly in + # TestCloneWorkspaceGuards) so the hermetic clone tests don't trip the + # assertion on a non-existent/empty workspace path. + monkeypatch.setattr(repo, "_prepare_clone_dir", lambda repo_dir, notes: None) + monkeypatch.setattr(repo, "_assert_clone_root", lambda repo_dir: None) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + + setup = repo.setup_repo(_config(read_only=False)) # must not raise + + assert setup.lint_before is True + assert setup.lint_gate_inert is False + assert any("Initial lint" in n and "did not finish within" in n for n in setup.notes) + + def test_baseline_build_OOM_kill_is_not_a_regression(self, monkeypatch): + # Root cause of an observed false ✅: the baseline build was OOM-KILLED (exit + # 137) because several heavy CI-parity builds shared one ECS box. Exit 137 + # is an ENVIRONMENT fault, NOT broken code — so build_before must be True + # (no usable baseline, no known regression), NOT False ("already broken"). + # A False here poisons the whole verdict: the regression gate reads + # "red-before → red-after isn't the agent's fault → ✅" while the absolute + # orchestration gate fails the node — a task GitHub built green. + fake = self._RecordingFake(rc_on=("verify-build-pre", 137, "Killed")) + monkeypatch.setattr(repo, "run_cmd", fake) + monkeypatch.setattr(repo, "run_cmd_with_backoff", fake) + monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) + # The clone is faked here, so the real workspace never gets a + # `.git`. Stub the pre-clean + git-root backstop (tested directly in + # TestCloneWorkspaceGuards) so the hermetic clone tests don't trip the + # assertion on a non-existent/empty workspace path. + monkeypatch.setattr(repo, "_prepare_clone_dir", lambda repo_dir, notes: None) + monkeypatch.setattr(repo, "_assert_clone_root", lambda repo_dir: None) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + + setup = repo.setup_repo(_config(read_only=False)) + + assert setup.build_before is True # OOM → no baseline, NOT "already broken" + assert setup.build_gate_inert is False # an OOM kill is not an inert gate + assert any("environment fault" in n for n in setup.notes) + # And it must NOT be recorded as a pre-existing build failure. + assert not any("FAILED before agent changes" in n for n in setup.notes) + + def test_baseline_build_genuine_failure_still_marks_regression_baseline(self, monkeypatch): + # Guard the other side: a REAL red build (exit 1, not an infra signal) must + # still record build_before=False so genuine regressions are gated. + fake = self._RecordingFake(rc_on=("verify-build-pre", 1, "TS2345: type error")) + monkeypatch.setattr(repo, "run_cmd", fake) + monkeypatch.setattr(repo, "run_cmd_with_backoff", fake) + monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) + # The clone is faked here, so the real workspace never gets a + # `.git`. Stub the pre-clean + git-root backstop (tested directly in + # TestCloneWorkspaceGuards) so the hermetic clone tests don't trip the + # assertion on a non-existent/empty workspace path. + monkeypatch.setattr(repo, "_prepare_clone_dir", lambda repo_dir, notes: None) + monkeypatch.setattr(repo, "_assert_clone_root", lambda repo_dir: None) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + + setup = repo.setup_repo(_config(read_only=False)) + + assert setup.build_before is False # a real red build IS the baseline + assert any("FAILED before agent changes" in n for n in setup.notes) + + +class TestFindMiseConfigs: + """`mise trust ` trusts only the ROOT config; + a monorepo's per-package `mise.toml` roots must ALSO be trusted or + `mise run build` fanning into `//cdk:*` etc. dies at the trust gate.""" + + def _mk(self, root, rels): + import os + + for r in rels: + d = os.path.dirname(r) + if d: + os.makedirs(os.path.join(root, d), exist_ok=True) + open(os.path.join(root, r), "w").close() + + def _rel(self, root, configs): + import os + + return sorted(os.path.relpath(c, root) for c in configs) + + def test_returns_nested_configs_excluding_root(self, tmp_path): + root = str(tmp_path) + self._mk(root, ["mise.toml", "cdk/mise.toml", "cli/mise.toml", "agent/mise.toml"]) + got = self._rel(root, repo._find_mise_configs(root)) + # root already trusted by `mise trust `, so it's excluded + assert "mise.toml" not in got + assert got == ["agent/mise.toml", "cdk/mise.toml", "cli/mise.toml"] + + def test_skips_vendored_and_build_dirs(self, tmp_path): + root = str(tmp_path) + self._mk( + root, + ["mise.toml", "cdk/mise.toml", "node_modules/pkg/mise.toml", "cdk/cdk.out/a/mise.toml"], + ) + got = self._rel(root, repo._find_mise_configs(root)) + assert got == ["cdk/mise.toml"] # node_modules + cdk.out pruned + + def test_no_nested_configs_returns_empty(self, tmp_path): + root = str(tmp_path) + self._mk(root, ["mise.toml"]) # only the root + assert repo._find_mise_configs(root) == [] + class TestDetectDefaultBranch: def test_returns_detected_branch(self, monkeypatch): @@ -143,6 +477,56 @@ def fake_run(*args, **kwargs): assert repo.detect_default_branch("owner/repo", "/tmp/x") == "main" +class TestPlatformBranchNameVerbatim: + """The agent MUST use the platform-provided ``config.branch_name`` verbatim + when present, for EVERY workflow — never re-deriving its own slug. A + re-derived slug diverges from the platform's (shell.py slugify strips + dots / truncates at 40; gateway.ts uses dashes / truncates at 50), which + silently breaks stacked-PR chaining (#247): a stacked child fetches the + predecessor's platform-named branch, the agent pushed a differently-named + one, the fetch 404s, and the child falls back to main (#14).""" + + def test_uses_platform_branch_name_verbatim_for_new_task(self, monkeypatch): + # new_task (is_pr_workflow=False) with a platform branch_name carrying a + # dotted/dashed slug. The agent must NOT re-slugify it. + fake = _fake_run_cmd() + _patch_common(monkeypatch, fake) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + setup = repo.setup_repo( + _config( + is_pr_workflow=False, + branch_name="bgagent/01TESTTASKID/abca-166-add-seville-guide-html", + task_description="ABCA-166: Add seville-guide.html", + ) + ) + assert setup.branch == "bgagent/01TESTTASKID/abca-166-add-seville-guide-html" + + def test_uses_platform_branch_name_verbatim_for_pr_workflow(self, monkeypatch): + fake = _fake_run_cmd() + _patch_common(monkeypatch, fake) + setup = repo.setup_repo( + _config( + is_pr_workflow=True, + branch_name="bgagent/01TESTTASKID/abca-167-stacked-child", + base_branch="bgagent/01PREDTASK/abca-166-predecessor", + ) + ) + assert setup.branch == "bgagent/01TESTTASKID/abca-167-stacked-child" + + def test_falls_back_to_derived_slug_only_when_no_branch_name(self, monkeypatch): + # No platform branch_name → the agent derives its own slug (legacy path). + fake = _fake_run_cmd() + _patch_common(monkeypatch, fake) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + setup = repo.setup_repo( + _config( + is_pr_workflow=False, + task_description="ABCA-168: derive me", + ) + ) + assert setup.branch.startswith("bgagent/") + + class _RecordingProgress: """Progress double recording write_agent_blocked calls (mirrors test_hooks).""" @@ -165,6 +549,12 @@ def _patch_backoff_to_fail(self, monkeypatch, fake, *, transient_stderr): report/raise path fires, while run_cmd (non-clone commands) stays faked.""" monkeypatch.setattr(repo, "run_cmd", fake) monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) + # The clone is faked here, so the real workspace never gets a + # `.git`. Stub the pre-clean + git-root backstop (tested directly in + # TestCloneWorkspaceGuards) so the hermetic clone tests don't trip the + # assertion on a non-existent/empty workspace path. + monkeypatch.setattr(repo, "_prepare_clone_dir", lambda repo_dir, notes: None) + monkeypatch.setattr(repo, "_assert_clone_root", lambda repo_dir: None) def fake_backoff(cmd, label, *, on_retry=None, **kwargs): # Mirror the real helper: on_retry only fires for transient failures. @@ -202,6 +592,12 @@ def fake_run_cmd(cmd, label, cwd=None, timeout=600, check=True, **kwargs): monkeypatch.setattr("shell.run_cmd", fake_run_cmd) monkeypatch.setattr(repo, "run_cmd", fake_run_cmd) monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) + # The clone is faked here, so the real workspace never gets a + # `.git`. Stub the pre-clean + git-root backstop (tested directly in + # TestCloneWorkspaceGuards) so the hermetic clone tests don't trip the + # assertion on a non-existent/empty workspace path. + monkeypatch.setattr(repo, "_prepare_clone_dir", lambda repo_dir, notes: None) + monkeypatch.setattr(repo, "_assert_clone_root", lambda repo_dir: None) progress = _RecordingProgress() import pytest @@ -323,6 +719,12 @@ def test_exhausted_pr_branch_fetch_reports_branch_as_resource(self, monkeypatch) fake = _fake_run_cmd() monkeypatch.setattr(repo, "run_cmd", fake) monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) + # The clone is faked here, so the real workspace never gets a + # `.git`. Stub the pre-clean + git-root backstop (tested directly in + # TestCloneWorkspaceGuards) so the hermetic clone tests don't trip the + # assertion on a non-existent/empty workspace path. + monkeypatch.setattr(repo, "_prepare_clone_dir", lambda repo_dir, notes: None) + monkeypatch.setattr(repo, "_assert_clone_root", lambda repo_dir: None) def fake_backoff(cmd, label, *, on_retry=None, **kwargs): # Clone succeeds; only the PR-branch fetch fails transiently. @@ -366,3 +768,58 @@ def test_remediation_does_not_widen_creds_or_egress(self, monkeypatch): assert "set-remote-url" not in labels assert "configure-git-credential-helper" not in labels assert "safe-directory" in labels # only the pre-clone step ran + + +class TestCloneWorkspaceGuards: + """The clone-time slate-clean + git-root backstop that stop a + stacked child from silently editing a NESTED working tree the pipeline's + git ops never see (which reported a false COMPLETED with the work lost).""" + + def test_prepare_clears_non_empty_workspace_and_notes(self, tmp_path): + repo_dir = tmp_path / "task-abc" + repo_dir.mkdir() + (repo_dir / "stale.txt").write_text("residue from a prior run") + (repo_dir / "nested").mkdir() + notes: list[str] = [] + + repo._prepare_clone_dir(str(repo_dir), notes) + + # The whole dir is removed so the clone lands directly at the root. + assert not repo_dir.exists() + assert any("residue" in n for n in notes) + + def test_prepare_leaves_empty_workspace_untouched_no_note(self, tmp_path): + repo_dir = tmp_path / "task-abc" + repo_dir.mkdir() # exists but empty — a normal fresh workspace + notes: list[str] = [] + + repo._prepare_clone_dir(str(repo_dir), notes) + + assert repo_dir.exists() # empty dir is fine; clone lands into it + assert notes == [] + + def test_prepare_absent_workspace_is_noop(self, tmp_path): + repo_dir = tmp_path / "does-not-exist" + notes: list[str] = [] + + repo._prepare_clone_dir(str(repo_dir), notes) # must not raise + + assert not repo_dir.exists() + assert notes == [] + + def test_assert_root_passes_when_git_dir_present(self, tmp_path): + repo_dir = tmp_path / "task-abc" + (repo_dir / ".git").mkdir(parents=True) + + repo._assert_clone_root(str(repo_dir)) # must not raise + + def test_assert_root_raises_when_git_missing(self, tmp_path): + import pytest + + repo_dir = tmp_path / "task-abc" + repo_dir.mkdir() + # A nested clone: .git lives one level deep, NOT at repo_dir root. + (repo_dir / "inner" / ".git").mkdir(parents=True) + + with pytest.raises(RuntimeError, match="git repository at the workspace root"): + repo._assert_clone_root(str(repo_dir)) diff --git a/agent/tests/test_run_task_from_payload.py b/agent/tests/test_run_task_from_payload.py index a65107a61..9784ca9f1 100644 --- a/agent/tests/test_run_task_from_payload.py +++ b/agent/tests/test_run_task_from_payload.py @@ -1,6 +1,6 @@ """Unit tests for pipeline.run_task_from_payload — the ECS payload→run_task map. -Regression cover for ABCA-487: the ECS boot command used to hand-list a subset +Regression cover for a silent contract gap: the ECS boot command used to hand-list a subset of run_task kwargs and silently dropped channel_source/channel_metadata (no Linear/Jira reactions on ECS), build_command, cedar_policies, base_branch, etc. run_task_from_payload maps the WHOLE payload so nothing is dropped again. @@ -35,7 +35,7 @@ def test_renames_prompt_and_model_id(self): assert "prompt" not in seen assert "model_id" not in seen - def test_forwards_channel_fields_ABCA_487(self): + def test_forwards_every_channel_field_to_the_agent(self): # THE regression: channel_source/channel_metadata must reach run_task so # the Linear/Jira reaction + channel MCP fire on ECS. cm = {"linear_issue_id": "iss-1", "linear_oauth_secret_arn": "arn:sm:...:lin"} @@ -43,44 +43,32 @@ def test_forwards_channel_fields_ABCA_487(self): assert seen["channel_source"] == "linear" assert seen["channel_metadata"] == cm - def test_forwards_cedar_attachments_trace_user_fields(self): - # HITL guardrails (cedar_policies, approval_*), attachments, trace, and - # user_id are real run_task params here — they must reach run_task on ECS + def test_forwards_build_and_lint_and_cedar_and_branch_fields(self): + # On this branch build_command/lint_command/base_branch/merge_branches ARE + # real run_task params (configurable verify #1 + #247 stacking), alongside + # cedar_policies/attachments/trace/user_id — all must reach run_task on ECS # (the hand-listed boot command used to drop them). seen = _capture( { + "build_command": "npm ci && npm test", + "lint_command": "npm run lint", "cedar_policies": ["p1", "p2"], + "base_branch": "epic-tip", + "merge_branches": ["a", "b"], "attachments": [{"filename": "x.png"}], "trace": True, "user_id": "user-9", } ) + assert seen["build_command"] == "npm ci && npm test" + assert seen["lint_command"] == "npm run lint" assert seen["cedar_policies"] == ["p1", "p2"] + assert seen["base_branch"] == "epic-tip" + assert seen["merge_branches"] == ["a", "b"] assert seen["attachments"] == [{"filename": "x.png"}] assert seen["trace"] is True assert seen["user_id"] == "user-9" - def test_drops_payload_keys_that_are_not_yet_run_task_params(self): - # build_command/lint_command (configurable verify, #1) and base_branch/ - # merge_branches (orchestration stacking, #247) are emitted by the - # orchestrator but are NOT run_task parameters on this branch. The mapper - # filters against run_task's REAL signature, so they are dropped rather - # than smuggled through as an invalid kwarg. When those params land, - # they forward automatically with no change here — that's the point of - # keying off inspect.signature instead of a hand-list. - seen = _capture( - { - "repo_url": "org/repo", - "build_command": "npm ci && npm test", - "lint_command": "npm run lint", - "base_branch": "epic-tip", - "merge_branches": ["a", "b"], - } - ) - assert seen["repo_url"] == "org/repo" - for not_yet in ("build_command", "lint_command", "base_branch", "merge_branches"): - assert not_yet not in seen - def test_coerces_issue_and_pr_number_to_str_and_max_turns_to_int(self): seen = _capture({"issue_number": 42, "pr_number": 7, "max_turns": "50"}) assert seen["issue_number"] == "42" @@ -103,7 +91,7 @@ def test_ignores_unknown_payload_keys(self): assert "sources" not in seen def test_github_token_secret_arn_dropped_quietly(self): - # N3: github_token_secret_arn is ALWAYS present and ALWAYS resolved via + # github_token_secret_arn is ALWAYS present and ALWAYS resolved via # the GITHUB_TOKEN_SECRET_ARN env (never a run_task param), so its drop is # 100% expected and must NOT fire the known-key WARN — that channel is for # genuine future contract gaps, not this always-dropped key. @@ -111,7 +99,7 @@ def test_github_token_secret_arn_dropped_quietly(self): with patch("pipeline.log", side_effect=lambda level, msg, **kw: logs.append((level, msg))): _capture({"github_token_secret_arn": "arn:aws:secretsmanager:...", "repo_url": "r"}) assert not [m for level, m in logs if "github_token_secret_arn" in m], ( - "github_token_secret_arn must drop quietly (N3) — it is always resolved via env" + "github_token_secret_arn must drop quietly — it is always resolved via env" ) def test_drops_none_values_so_run_task_defaults_apply(self): @@ -156,7 +144,7 @@ def test_every_forwarded_key_is_a_real_run_task_param(self): assert set(seen).issubset(accepted) def test_max_turns_rejects_surprising_inputs(self): - # N4: int() accepts a bool (int(True)==1) and truncates a float + # int() accepts a bool (int(True)==1) and truncates a float # (int(3.9)==3). The orchestrator always emits a real int, but a corrupt # / hand-edited payload must not silently become a bogus turn count — # drop with a breadcrumb and let run_task's default apply. @@ -167,17 +155,6 @@ def test_max_turns_rejects_surprising_inputs(self): assert _capture({"repo_url": "r", "max_turns": "50"})["max_turns"] == 50 assert _capture({"repo_url": "r", "max_turns": 50.0})["max_turns"] == 50 - def test_warns_when_dropping_a_known_orchestrator_key(self): - # N4: a KNOWN orchestrator key that run_task doesn't accept is dropped - # (expected today) but logged, so a future "wired one side, forgot the - # other" contract gap (the ABCA-487 class) is visible, not silent. - logs: list[tuple[str, str]] = [] - with patch("pipeline.log", side_effect=lambda level, msg, **kw: logs.append((level, msg))): - _capture({"build_command": "mise run build", "repo_url": "r"}) - assert [m for level, m in logs if level == "WARN" and "build_command" in m], ( - "expected a WARN when dropping the known orchestrator key build_command" - ) - def test_does_NOT_warn_when_dropping_a_foreign_key(self): # A genuinely-foreign key (not a known orchestrator field) is dropped # quietly — no log noise for keys we never expected to forward. @@ -189,7 +166,9 @@ def test_does_NOT_warn_when_dropping_a_foreign_key(self): def test_warns_when_dropping_task_started_at_HITL_parity(self): # task_started_at drives the AgentCore HITL maxLifetime clip via # TASK_STARTED_AT; the ECS path doesn't set it yet, so its drop must WARN - # (surface the AgentCore↔ECS parity gap, not silently fail-open). + # (surface the AgentCore↔ECS parity gap, not silently fail-open). It is the + # one _KNOWN_ORCHESTRATOR_KEYS entry on this branch (build_command etc. are + # real run_task params here and forward, so they never hit the drop path). logs: list[tuple[str, str]] = [] with patch("pipeline.log", side_effect=lambda level, msg, **kw: logs.append((level, msg))): _capture({"task_started_at": "2026-07-14T00:00:00Z", "repo_url": "r"}) diff --git a/agent/tests/test_server.py b/agent/tests/test_server.py index a839cd0dd..32383703d 100644 --- a/agent/tests/test_server.py +++ b/agent/tests/test_server.py @@ -343,6 +343,26 @@ def test_validate_required_params_pr_workflows_require_pr_number(): ) assert missing == [] + # Restack (#305) is a PR workflow — pr_number suffices, NO description + # required (regression: it previously fell into the non-PR branch and + # 400'd on missing issue_number_or_task_description). + missing = server._validate_required_params( + { + "repo_url": "o/r", + "resolved_workflow": {"id": "coding/restack-v1", "version": "1.0.0"}, + "pr_number": "113", + } + ) + assert missing == [] + missing = server._validate_required_params( + { + "repo_url": "o/r", + "resolved_workflow": {"id": "coding/restack-v1", "version": "1.0.0"}, + "pr_number": "", + } + ) + assert missing == ["pr_number"] + # A non-PR workflow needs issue OR description. missing = server._validate_required_params( { @@ -629,7 +649,7 @@ def test_trace_1_does_NOT_enable_trace(self): class TestExtractUserId: - """K2 Stage 3: ``user_id`` is the platform Cognito ``sub`` threaded + """``user_id`` is the platform Cognito ``sub`` threaded from the orchestrator. The agent uses it to construct the trace S3 key ``traces//.jsonl.gz``. A non-string value must be coerced to empty so a surprise ``None`` / int doesn't flow @@ -809,3 +829,75 @@ def test_none_stays_none(self): self._fake_req(), ) assert params["approval_gate_cap"] is None + + +class TestInvocationParamContract: + """The invocation boundary is wired as: + + params = _extract_invocation_params(inp, request) # a dict + _run_task_background(**params) # kwargs unpack + + The ONLY thing keeping these in sync is that every dict key is a valid + parameter name of ``_run_task_background`` (and vice-versa for required + fields). A mismatch is invisible until runtime and crashes EVERY task + with a ``NameError`` / ``TypeError`` — exactly the stacked-child regression + (#247) where ``base_branch`` was passed to ``run_task`` but never extracted + into the params dict. These tests lock that contract structurally so + the next field added on one side but not the other fails in CI. + """ + + def _fake_req(self) -> Any: + return _FakeRequest() + + def _payload(self, **extra): + return {"repo_url": "org/repo", "task_description": "x", "task_id": "t-1", **extra} + + def test_every_extracted_key_is_a_valid_background_param(self): + import inspect + + params = server._extract_invocation_params(self._payload(), self._fake_req()) + sig = inspect.signature(server._run_task_background) + bg_param_names = set(sig.parameters) + + unknown = set(params) - bg_param_names + assert not unknown, ( + f"_extract_invocation_params returns keys that _run_task_background " + f"does not accept (would crash on **kwargs unpack): {sorted(unknown)}" + ) + + def test_extracted_params_unpack_into_background_signature(self): + # Binding the extracted dict against the real signature is exactly + # what `_run_task_background(**params)` does — this raises TypeError + # if a key is unknown OR a required (no-default) param is missing. + import inspect + + params = server._extract_invocation_params(self._payload(), self._fake_req()) + sig = inspect.signature(server._run_task_background) + # Should not raise. + sig.bind(**params) + + def test_base_branch_and_merge_branches_extracted_and_accepted(self): + # The specific stacked-child fields whose omission caused the regression. + import inspect + + params = server._extract_invocation_params( + self._payload(base_branch="bgagent/taskA/a", merge_branches=["b1", "b2"]), + self._fake_req(), + ) + assert params["base_branch"] == "bgagent/taskA/a" + assert params["merge_branches"] == ["b1", "b2"] + # And they are real parameters of the background runner. + bg = set(inspect.signature(server._run_task_background).parameters) + assert {"base_branch", "merge_branches"} <= bg + + def test_stacking_fields_default_safely_when_absent(self): + params = server._extract_invocation_params(self._payload(), self._fake_req()) + assert params["base_branch"] is None + assert params["merge_branches"] == [] + + def test_merge_branches_non_string_entries_filtered(self): + params = server._extract_invocation_params( + self._payload(merge_branches=["ok", 123, None, "ok2"]), + self._fake_req(), + ) + assert params["merge_branches"] == ["ok", "ok2"] diff --git a/agent/tests/test_shell.py b/agent/tests/test_shell.py index 51968735e..225e2df83 100644 --- a/agent/tests/test_shell.py +++ b/agent/tests/test_shell.py @@ -191,7 +191,7 @@ def test_backoff_delays_are_exponential(self): class TestRunCmdFailureLogging: """A failing command must surface its ACTUAL error. Build/test tooling (jest, tsc, the mise task DAG) writes the failing-task error to STDOUT, not stderr — - so logging stderr alone made build-gate failures undebuggable (ABCA-662: a red + so logging stderr alone made build-gate failures undebuggable (a red ``mise run build`` showed every task starting but never WHICH one failed).""" def _completed(self, rc, stdout="", stderr=""): @@ -231,7 +231,7 @@ def test_no_markers_falls_back_to_tail(self): assert "line 0" not in blob # earliest lines dropped def test_failure_line_in_the_MIDDLE_is_surfaced(self): - # ABCA-662 root cause of the tooling gap: a PARALLEL mise DAG interleaves + # Root cause of the tooling gap: a PARALLEL mise DAG interleaves # output, so the failing task's line is in the MIDDLE while the tail is a # passing package's coverage table. The failing line MUST be surfaced. mid = "[//cdk:test] FAIL test/handlers/foo.test.ts — expected 1 got 2" @@ -269,7 +269,7 @@ def test_benign_zero_errors_line_not_surfaced_as_failure(self): assert "ok 19" in blob def test_marker_line_with_noise_term_is_filtered_by_allowlist(self): - # N3: the LOAD-BEARING allowlist case. A line that hits a real marker + # The LOAD-BEARING allowlist case. A line that hits a real marker # (`error:`) AND a noise term (`0 errors`) must be filtered OUT of the # surfaced set. Placed in the MIDDLE (before the tail window) so it is # only reachable via the marker scan — if _FAILURE_LINE_NOISE were @@ -287,7 +287,7 @@ def test_marker_line_with_noise_term_is_filtered_by_allowlist(self): assert "0 errors after autofix retry" not in blob # noisy marker filtered by allowlist def test_surfaced_failure_lines_are_capped_and_truncation_marked(self): - # N4: a genuinely huge red run (more than _MAX_SURFACED_FAILURE_LINES + # A genuinely huge red run (more than _MAX_SURFACED_FAILURE_LINES # marker lines) must be capped, with an explicit truncation breadcrumb — # so it can't flood CloudWatch. Exercises the cap branch the other tests # never reach. @@ -318,3 +318,83 @@ def test_success_does_not_dump_stdout(self): logs = self._run_capturing_logs(proc) blob = "\n".join(text for _, text in logs) assert "lots of build output" not in blob + + +class TestRunCmdStreaming: + """stream=True tees the command's output to the log LINE-BY-LINE as it runs + (so the full log reaches CloudWatch verbatim) AND returns a CompletedProcess + matching subprocess.run's contract. Uses real `sh -c` — exercises the actual + Popen + drain-thread path (the buffered summary hid build failures).""" + + def _run(self, argv, check=False): + logs = [] + with patch("shell.log", side_effect=lambda prefix, text: logs.append((prefix, text))): + result = run_cmd(argv, "verify-build-post", check=check, stream=True) + blob = "\n".join(text for _, text in logs) + return result, blob + + def test_streams_stdout_lines_live_and_returns_captured(self): + result, blob = self._run(["sh", "-c", "echo out-line-A; echo out-line-B"]) + assert result.returncode == 0 + # every line reached the log (verbatim, live) + assert "out-line-A" in blob and "out-line-B" in blob + # and the CompletedProcess still carries stdout for callers + assert "out-line-A" in result.stdout and "out-line-B" in result.stdout + + def test_keeps_stdout_and_stderr_separate(self): + result, _ = self._run(["sh", "-c", "echo to-out; echo to-err 1>&2"]) + assert "to-out" in result.stdout + assert "to-err" in result.stderr + assert "to-err" not in result.stdout # streams not merged + + def test_nonzero_exit_surfaces_failing_line(self): + # A mid-stream failure line is streamed AND flagged in the failing-lines + # pointer — the whole reason streaming exists. + result, blob = self._run(["sh", "-c", "echo passing; echo 'FAIL test/x.test.ts'; exit 1"]) + assert result.returncode == 1 + assert "FAIL test/x.test.ts" in blob + assert "failing lines" in blob # the streamed-path pointer + + def test_stream_redacts_secrets_in_live_output(self): + # Redaction happens inside the real log(); assert redact_secrets covers the + # streamed line (the test patches log(), so check the redactor directly on + # what the drain thread hands it — that's the line that reaches CloudWatch). + from shell import redact_secrets + + assert "ghp_streamedsecretABC123" not in redact_secrets(" token=ghp_streamedsecretABC123") + + def test_stream_raises_on_check_true_failure(self): + import pytest + + with pytest.raises(RuntimeError): + self._run(["sh", "-c", "exit 3"], check=True) + + def test_stream_normalizes_a_signal_kill_to_the_shell_convention(self): + # A signal death arrives from Popen as a NEGATIVE signal number, but the + # OOM classifier keys on the shell's 128+signal form. Without normalizing, + # an OOM-killed build reports as a genuine build failure — "your code is + # broken" when the box ran out of memory. + from post_hooks import is_infra_failure + + # 137 = 128 + SIGKILL(9). `kill -9 $$` makes the shell kill itself, so the + # child really dies by signal rather than exiting with a code. + result, _ = self._run(["sh", "-c", "kill -9 $$"]) + assert result.returncode == 137, "SIGKILL must surface as 137, not -9" + # The point of the normalization: the classifier now sees infra, not a + # genuine red build, with no stderr signature to help it. + assert is_infra_failure(result.returncode, "") is True + + def test_stream_maps_an_unreaped_process_to_failure_not_success(self): + # `returncode is None` cannot happen after wait(), but if it ever did, + # mapping it to 0 would let an unverified build report as passing. The one + # unsafe answer, so it is pinned. + from shell import _exit_status + + assert _exit_status(None) != 0 + assert _exit_status(None) == -1 + # Ordinary exits pass through untouched. + assert _exit_status(0) == 0 + assert _exit_status(1) == 1 + assert _exit_status(137) == 137 + # SIGTERM (-15) also normalizes. + assert _exit_status(-15) == 143 diff --git a/agent/tests/test_stuck_guard.py b/agent/tests/test_stuck_guard.py index 942246e2a..d3ca649bf 100644 --- a/agent/tests/test_stuck_guard.py +++ b/agent/tests/test_stuck_guard.py @@ -1,4 +1,4 @@ -"""Tests for the stuck/runaway guard (K7, live-caught ABCA-483).""" +"""Tests for the stuck/runaway guard.""" from __future__ import annotations @@ -120,7 +120,7 @@ def test_healthy_varied_work_never_trips(self): assert g.evaluate().kind == "none" def test_iterating_agent_same_command_DIFFERENT_failures_never_steers(self): - # K10 false-positive guard: the agent re-runs the SAME test command as + # False-positive guard: the agent re-runs the SAME test command as # it fixes failures one by one — each run fails on a DIFFERENT test. # That's progress, not a loop. The streak resets on each new output, so # it never even reaches the (advisory) steer threshold. @@ -171,7 +171,7 @@ def test_interleaved_success_on_OTHER_sig_does_not_clear_the_loop(self): class TestWindowSpin: - """ABCA-662: the loop-of-VARIATIONS the per-signature streak can't see — the + """The loop-of-VARIATIONS the per-signature streak can't see — the agent tries a different command each turn toward the same failing goal (a git push that keeps failing on 'invalid credentials'). No single signature reaches STEER_THRESHOLD, but the trailing window is failure-dominated.""" @@ -226,7 +226,7 @@ def test_healthy_iteration_below_window_threshold_no_steer(self): assert g.recent_failure_summary() is None def test_window_steers_at_exactly_the_threshold(self): - # N4 boundary: exactly WINDOW_FAIL_THRESHOLD (5) same-fingerprint failures + # Boundary: exactly WINDOW_FAIL_THRESHOLD (5) same-fingerprint failures # in a FULL window of WINDOW (6) — the `>=` edge where an off-by-one would # hide. One OK dilutes the window to 5/6 fails, still == the threshold. assert WINDOW == 6 and WINDOW_FAIL_THRESHOLD == 5 # pin the constants @@ -240,7 +240,7 @@ def test_window_steers_at_exactly_the_threshold(self): assert g.recent_failure_summary() is not None def test_no_steer_when_window_not_yet_full(self): - # N4 boundary: WINDOW_FAIL_THRESHOLD failures but the window has fewer than + # Boundary: WINDOW_FAIL_THRESHOLD failures but the window has fewer than # WINDOW entries — _dominant_window_failure requires a FULL window, so 5 # identical failures in a length-5 history must NOT steer yet. g = StuckGuard() diff --git a/agent/tests/test_task_state.py b/agent/tests/test_task_state.py index bf565260a..b0eef03d9 100644 --- a/agent/tests/test_task_state.py +++ b/agent/tests/test_task_state.py @@ -252,7 +252,7 @@ def update_item(self, **kwargs): class TestWriteTerminalTraceS3Uri: - """K2 Stage 4 — ``write_terminal`` persists ``trace_s3_uri`` from + """``write_terminal`` persists ``trace_s3_uri`` from the result dict so the ``get-trace-url`` handler (which reads the field off the TaskRecord) sees a consistent view the moment the task reaches terminal.""" @@ -411,13 +411,13 @@ def test_conditional_check_failed_with_trace_uri_logs_orphan_diagnostic( monkeypatch, capfd, ): - """K2 final review SIG-1: when ``write_terminal``'s precondition + """When ``write_terminal``'s precondition fails (typically: concurrent cancel) and a ``trace_s3_uri`` was already uploaded, the orphaned S3 object needs a dedicated log line — otherwise the generic ``skipped: precondition not met`` message hides silently-lost trace URIs. - L4 extension: after the orphan log prints, the self-heal + Extension: after the orphan log prints, the self-heal ``write_trace_uri_conditional`` fires; when the second UpdateItem succeeds, the self-heal log also prints.""" from botocore.exceptions import ClientError diff --git a/agent/tests/test_telemetry_redaction.py b/agent/tests/test_telemetry_redaction.py index 3fc9bb6e1..adb50a5e8 100644 --- a/agent/tests/test_telemetry_redaction.py +++ b/agent/tests/test_telemetry_redaction.py @@ -4,7 +4,7 @@ under ``_METRICS_REDACT_KEYS`` (just ``error``). That swallowed legitimate structural-error strings like ``missing built-in hard-deny policies: /app/policies/hard_deny.cedar`` whose diagnostic value is -high and secret-risk is zero — see E2E 2026-05-11 T2.2. +high and secret-risk is zero (observed during end-to-end testing). The new implementation routes ``error`` values through ``output_scanner.scan_tool_output`` and only substitutes diff --git a/agent/tests/test_trace_upload.py b/agent/tests/test_trace_upload.py index f9f54a497..bbb685942 100644 --- a/agent/tests/test_trace_upload.py +++ b/agent/tests/test_trace_upload.py @@ -1,13 +1,13 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: MIT-0 -"""Tests for the K2 Stage 4 --trace upload path (design §10.1). +"""Tests for the --trace upload path (design §10.1). Covers: * ``_TrajectoryWriter`` accumulator behavior (enabled/disabled, bounded, JSONL header shape) * ``upload_trace_to_s3`` fail-open semantics and contract enforcement - from the K2 Stage 3 review (empty user_id -> skip + warn, never + (empty user_id -> skip + warn, never write ``traces//`` keys) """ @@ -404,7 +404,7 @@ def test_non_accumulating_writer_retains_no_state(self): class TestAccumulatorWhenCloudWatchDisabled: - """K2 review Finding #9: accumulator must capture events even when + """The accumulator must capture events even when the CloudWatch path is disabled (no log group env, or circuit breaker open). The S3 artifact is independent of CW health by design.""" @@ -423,7 +423,7 @@ def test_captures_when_circuit_breaker_open(self): class TestTruncationCallback: - """K2 review Finding #3: accumulator cap trips fire a one-shot + """Accumulator cap trips fire a one-shot callback so the pipeline can surface ``trace_truncated`` in ``bgagent watch``.""" diff --git a/agent/tests/test_verify_commands.py b/agent/tests/test_verify_commands.py new file mode 100644 index 000000000..02b7a5162 --- /dev/null +++ b/agent/tests/test_verify_commands.py @@ -0,0 +1,256 @@ +"""Tests for the configurable build/lint verification command (#1 build-gate fix).""" + +from __future__ import annotations + +import subprocess +from types import SimpleNamespace + +import post_hooks +from post_hooks import ( + DEFAULT_BUILD_COMMAND, + DEFAULT_LINT_COMMAND, + is_verify_command_inert, + resolve_verify_argv, + verify_build, + verify_lint, +) + + +class TestResolveVerifyArgv: + def test_empty_falls_back_to_default(self): + assert resolve_verify_argv("", DEFAULT_BUILD_COMMAND) == ["mise", "run", "build"] + assert resolve_verify_argv(" ", DEFAULT_LINT_COMMAND) == ["mise", "run", "lint"] + + def test_none_falls_back_to_default(self): + assert resolve_verify_argv(None, DEFAULT_BUILD_COMMAND) == ["mise", "run", "build"] + + def test_configured_command_splits_to_argv(self): + assert resolve_verify_argv("npm run build", "") == ["npm", "run", "build"] + assert resolve_verify_argv("gradle build", "") == ["gradle", "build"] + + def test_quoted_args_preserved(self): + assert resolve_verify_argv('make "target with spaces"', DEFAULT_BUILD_COMMAND) == [ + "make", + "target with spaces", + ] + + def test_chained_command_runs_through_a_shell(self): + # #72: a && / | / ; chain must run via `bash -lc` so the WHOLE chain + # executes. Previously shlex-split into one `npm` call with `&&`/`npm`/… + # as bogus args — `npm ci` ran, ignored the rest, exited 0, and a broken + # lint/test in the chain NEVER ran (false "build OK"). + assert resolve_verify_argv("npm ci && npm run lint && npm test", "") == [ + "bash", + "-lc", + "npm ci && npm run lint && npm test", + ] + + def test_other_shell_operators_also_wrap(self): + for cmd in ("eslint . | tee out.txt", "make build; make test", "tsc > /dev/null"): + argv = resolve_verify_argv(cmd, "") + assert argv[:2] == ["bash", "-lc"], cmd + assert argv[2] == cmd + + def test_plain_command_still_direct_argv(self): + # No operators → still a direct exec (no shell wrapper). + assert resolve_verify_argv("npm run build", "") == ["npm", "run", "build"] + + def test_env_assignment_prefix_wraps_in_shell(self): + # A leading VAR=value env-prefix is shell syntax. Exec'd + # directly, shlex-split makes the FIRST token the "program" (VAR=value) → + # FileNotFoundError, crashing the task before the build runs. Must route + # through bash -lc so the assignment takes effect. (Observed in practice: a + # lint_command of `MISE_EXPERIMENTAL=1 mise //cdk:eslint` crashed at exit 1.) + assert resolve_verify_argv("MISE_EXPERIMENTAL=1 mise //cdk:eslint", "") == [ + "bash", + "-lc", + "MISE_EXPERIMENTAL=1 mise //cdk:eslint", + ] + + def test_multiple_env_assignments_wrap(self): + cmd = "FOO=1 BAR=2 make build" + assert resolve_verify_argv(cmd, "") == ["bash", "-lc", cmd] + + def test_equals_not_at_start_is_not_an_env_prefix(self): + # An `=` inside a later arg (not a leading VAR= token) is NOT an env prefix + # — a plain command with such an arg still execs directly. + assert resolve_verify_argv("npm run build --define=X=1", "") == [ + "npm", + "run", + "build", + "--define=X=1", + ] + + +class TestVerifyBuildHonorsCommand: + def _capture_argv(self, monkeypatch): + seen = {} + + def fake_run_cmd(argv, **kw): + seen["argv"] = argv + seen["kw"] = kw + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(post_hooks, "run_cmd", fake_run_cmd) + return seen + + def test_build_defaults_to_mise(self, monkeypatch): + seen = self._capture_argv(monkeypatch) + outcome = verify_build("/repo") + assert outcome.passed is True + assert outcome.timed_out is False + assert seen["argv"] == ["mise", "run", "build"] + + def test_build_uses_configured_command(self, monkeypatch): + seen = self._capture_argv(monkeypatch) + assert verify_build("/repo", "npm run build").passed is True + assert seen["argv"] == ["npm", "run", "build"] + + def test_verify_passes_the_build_timeout(self, monkeypatch): + # The verify subprocess must run under BUILD_VERIFY_TIMEOUT_S (not + # run_cmd's 600s default) so a real CI-parity build can finish. + seen = self._capture_argv(monkeypatch) + verify_build("/repo", "mise run build") + assert seen["kw"].get("timeout") == post_hooks.BUILD_VERIFY_TIMEOUT_S + + def test_lint_uses_configured_command(self, monkeypatch): + seen = self._capture_argv(monkeypatch) + assert verify_lint("/repo", "ruff check .").passed is True + assert seen["argv"] == ["ruff", "check", "."] + + def test_nonzero_returncode_is_failure_not_timeout(self, monkeypatch): + monkeypatch.setattr(post_hooks, "run_cmd", lambda argv, **kw: SimpleNamespace(returncode=1)) + outcome = verify_build("/repo", "npm run build") + assert outcome.passed is False + assert outcome.timed_out is False # ran-and-failed, not a timeout + + def test_timeout_is_not_passed_AND_flagged_timed_out(self, monkeypatch): + # The key distinction: a timeout must read as "timed + # out", not a generic build failure. passed=False (a build that never + # finished isn't green) but timed_out=True so the reason differs. + def boom(argv, **kw): + raise subprocess.TimeoutExpired(cmd=argv, timeout=1) + + monkeypatch.setattr(post_hooks, "run_cmd", boom) + outcome = verify_build("/repo", "npm run build") + assert outcome.passed is False + assert outcome.timed_out is True + + def test_exit_127_is_INERT_not_a_build_failure(self, monkeypatch): + # Command-not-found (e.g. yarn missing) means the gate couldn't run — + # a CONFIG problem, not the agent's code. Must flag inert, not a failure, + # so the platform doesn't emit a false "build failed". + monkeypatch.setattr( + post_hooks, + "run_cmd", + lambda argv, **kw: SimpleNamespace(returncode=127, stderr="yarn: command not found"), + ) + outcome = verify_build("/repo", "yarn install && yarn build") + assert outcome.passed is False + assert outcome.inert is True + assert outcome.timed_out is False + + def test_no_such_mise_task_is_INERT(self, monkeypatch): + no_task = "mise ERROR no task named 'build'" + monkeypatch.setattr( + post_hooks, + "run_cmd", + lambda argv, **kw: SimpleNamespace(returncode=1, stderr=no_task), + ) + assert verify_build("/repo", "mise run build").inert is True + + def test_genuine_nonzero_is_a_failure_NOT_inert(self, monkeypatch): + # A real compiler/test failure (exit 1/2 with real output) must NOT be + # mislabeled inert — that would hide a genuine red build. + monkeypatch.setattr( + post_hooks, + "run_cmd", + lambda argv, **kw: SimpleNamespace(returncode=2, stderr="tsc: 3 type errors"), + ) + outcome = verify_build("/repo", "mise //cdk:compile") + assert outcome.passed is False + assert outcome.inert is False + + def test_ENOSPC_is_INFRA_failure_not_a_build_failure(self, monkeypatch): + # Disk-full mid-build means the build couldn't COMPLETE on + # this host — an infra fault, not broken code. Must flag infra_failed + # (not a plain failure, not inert) so the platform reports "retry / needs + # capacity", not "build/tests failed". + enospc = "yarn error ENOSPC: no space left on device, write" + monkeypatch.setattr( + post_hooks, + "run_cmd", + lambda argv, **kw: SimpleNamespace(returncode=1, stderr=enospc), + ) + outcome = verify_build("/repo", "mise run build") + assert outcome.passed is False + assert outcome.infra_failed is True + assert outcome.inert is False # NOT a config problem + + def test_OOM_sigkill_137_is_INFRA_failure(self, monkeypatch): + monkeypatch.setattr( + post_hooks, + "run_cmd", + lambda argv, **kw: SimpleNamespace(returncode=137, stderr="Killed"), + ) + outcome = verify_build("/repo", "mise run build") + assert outcome.passed is False + assert outcome.infra_failed is True + + def test_bare_sigkill_137_no_stderr_signature_is_INFRA_not_inert(self, monkeypatch): + # Regression observed in practice: the container/cgroup OOM-killer delivers + # SIGKILL and writes "Killed process …" to the KERNEL log, not the build + # process's own stderr — so an OOM'd `mise run build` exits 137 with NO + # "killed"/"out of memory" string captured. Such a 137 was mislabeled + # INERT (the greedy `mise`+`not found` heuristic matched an unrelated + # webhook-test fixture line in the big streamed log). A bare 137 must be + # INFRA (retry/capacity), never inert (config) and never a build failure. + mise_and_notfound = ( + "[//agent:test] tests/test_attachments.py ...\n" + "[//cdk:test] Linear project is not found — skipping\n" + "ERROR task failed" + ) + monkeypatch.setattr( + post_hooks, + "run_cmd", + lambda argv, **kw: SimpleNamespace(returncode=137, stderr=mise_and_notfound), + ) + outcome = verify_build("/repo", "mise run build") + assert outcome.passed is False + assert outcome.infra_failed is True + assert outcome.inert is False # infra checked BEFORE inert + + def test_bare_sigkill_137_plain_output_is_INFRA_not_a_build_failure(self, monkeypatch): + # The dangerous fall-through: a 137 whose captured output trips NEITHER the + # inert nor OOM string heuristics would have been reported as a GENUINE + # build FAILURE → a false gate blocking healthy code (and, in an epic, + # poisoning the dependent cascade). SIGKILL is a resource kill, not a + # test result, so it must be infra. + monkeypatch.setattr( + post_hooks, + "run_cmd", + lambda argv, **kw: SimpleNamespace(returncode=137, stderr="compiling...\naborting"), + ) + outcome = verify_build("/repo", "mise run build") + assert outcome.passed is False + assert outcome.infra_failed is True + assert outcome.inert is False + + +class TestIsVerifyCommandInert: + def test_mise_no_tasks_defined_is_inert(self): + assert is_verify_command_inert(1, "mise ERROR no tasks defined in /repo") is True + + def test_command_not_found_exit_127_is_inert(self): + assert is_verify_command_inert(127, "gradle: command not found") is True + + def test_no_task_named_is_inert(self): + assert is_verify_command_inert(1, "mise ERROR: no task named 'build'") is True + + def test_genuine_build_failure_is_NOT_inert(self): + # Real compiler/test output, exited non-zero → meaningful gating signal. + real_failure = "TypeError: cannot read property 'x'\n1 test failed" + assert is_verify_command_inert(2, real_failure) is False + + def test_clean_exit_is_not_inert(self): + assert is_verify_command_inert(0, "") is False diff --git a/agent/tests/test_workflow_runner.py b/agent/tests/test_workflow_runner.py index a23c5d68a..d71494dde 100644 --- a/agent/tests/test_workflow_runner.py +++ b/agent/tests/test_workflow_runner.py @@ -13,6 +13,7 @@ import pytest +from post_hooks import VerifyOutcome from workflow import Step, StepContext, StepOutcome, Workflow, run_workflow from workflow.runner import StepHandler, WorkflowCheckpoint, _step_key @@ -492,7 +493,8 @@ def test_verify_build_regression_only_passes_when_broken_before(self, monkeypatc from workflow.runner import _handle_verify_build # build red after, but it was already red before → not a regression. - monkeypatch.setattr("post_hooks.verify_build", lambda _d: False) + red = VerifyOutcome(passed=False) + monkeypatch.setattr("post_hooks.verify_build", lambda _d, _c="": red) wf = _workflow( [ {"kind": "verify_build", "name": "build", "gate": "regression_only"}, @@ -509,7 +511,8 @@ def test_verify_build_regression_only_fails_on_regression(self, monkeypatch): from models import RepoSetup from workflow.runner import _handle_verify_build - monkeypatch.setattr("post_hooks.verify_build", lambda _d: False) + red = VerifyOutcome(passed=False) + monkeypatch.setattr("post_hooks.verify_build", lambda _d, _c="": red) wf = _workflow( [ {"kind": "verify_build", "name": "build", "gate": "regression_only"}, @@ -525,7 +528,8 @@ def test_verify_lint_read_only_is_informational(self, monkeypatch): from workflow.runner import _handle_verify_lint # read_only workflow: a lint failure must not gate (symmetry with build). - monkeypatch.setattr("post_hooks.verify_lint", lambda _d: False) + red = VerifyOutcome(passed=False) + monkeypatch.setattr("post_hooks.verify_lint", lambda _d, _c="": red) wf = _workflow( [ {"kind": "clone_repo"}, diff --git a/agent/workflows/coding/restack-v1.yaml b/agent/workflows/coding/restack-v1.yaml new file mode 100644 index 000000000..f0115598f --- /dev/null +++ b/agent/workflows/coding/restack-v1.yaml @@ -0,0 +1,53 @@ +# A6 re-stack (#305): re-merge a CHANGED predecessor branch into an existing +# stacked-child PR so the child is no longer stale. Like pr-iteration it +# operates on an existing PR branch (push_resolve — no new PR), but it also +# receives the updated predecessor branch(es) as merge_branches, which repo.py +# merges into the working tree before the agent runs. The agent reconciles +# conflicts, verifies the build, and pushes the same branch. +# +# Triggered by the platform (the A6 re-stack handler off a pull_request +# webhook), not by a user. Writeable; Cedar principal "new_task" (the +# id→legacy map has no restack entry, so it falls to new_task — a writeable +# coding identity, correct here). +id: coding/restack-v1 +version: 1.0.0 +domain: coding +description: Re-merge a changed predecessor branch into an existing stacked-child PR (#305 A6). +requires_repo: true +read_only: false +prompt: + template: registry://prompt/coding-restack-workflow + placeholders: + - repo_url + - task_id + - workspace + - branch_name + - default_branch + - max_turns + - setup_notes + - memory_context + - pr_number +hydration: + sources: [pull_request, memory, task_description] +agent_config: + tier: standard + allowed_tools: [Bash, Read, Write, Edit, Glob, Grep, WebFetch] + cedar_policy_modules: [builtin/hard_deny, builtin/soft_deny] +repo_config: + provider: github + discover: true +required_inputs: + all_of: [pr_number] +steps: + - { kind: clone_repo, name: setup } + - { kind: hydrate_context, name: context } + - { kind: run_agent, name: restack } + - { kind: verify_build, name: build, gate: regression_only } + - { kind: ensure_pr, name: resolve_pr, strategy: push_resolve } +terminal_outcomes: + primary: pr_url +limits: + max_turns: 100 +promotion_gate: + requires: [tests:agent/restack] +status: production diff --git a/cdk/src/constructs/bedrock-models.ts b/cdk/src/constructs/bedrock-models.ts index df3e127c1..a563e3f17 100644 --- a/cdk/src/constructs/bedrock-models.ts +++ b/cdk/src/constructs/bedrock-models.ts @@ -34,6 +34,13 @@ import { Node } from 'constructs'; export const DEFAULT_BEDROCK_MODEL_IDS: readonly string[] = [ 'anthropic.claude-sonnet-4-6', 'anthropic.claude-opus-4-20250514-v1:0', + // Claude Opus 4.8 — the agent's fallback model when a repo pins none + // (``agent/src/config.py``). REQUIRED in this grant list or the agent's + // InvokeModel gets AccessDenied at turn 0: both the AgentCore runtime and the + // ECS task role scope Bedrock to these IDs via resolveBedrockModelIds. Keep + // this entry and that default in the same change — a fallback the role cannot + // invoke fails every task on the stack, not just an edge case. + 'anthropic.claude-opus-4-8', 'anthropic.claude-haiku-4-5-20251001-v1:0', ]; diff --git a/cdk/src/handlers/shared/workflows.ts b/cdk/src/handlers/shared/workflows.ts index 9f11719fd..84524d059 100644 --- a/cdk/src/handlers/shared/workflows.ts +++ b/cdk/src/handlers/shared/workflows.ts @@ -136,6 +136,16 @@ const DESCRIPTORS: Record = { readOnly: true, requiredInputs: { allOf: ['pr_number'] }, }, + // Re-stack: re-merge a changed predecessor branch into an existing stacked + // child PR. Writeable, repo-bound, and operates on an existing PR + // (``pr_number``). Platform-issued by the restack path, not user-facing. + 'coding/restack-v1': { + id: 'coding/restack-v1', + version: '1.0.0', + requiresRepo: true, + readOnly: false, + requiredInputs: { allOf: ['pr_number'] }, + }, 'default/agent-v1': { id: 'default/agent-v1', version: '1.0.0', diff --git a/cdk/test/constructs/bedrock-models.test.ts b/cdk/test/constructs/bedrock-models.test.ts index e39ff504d..f0c3b84c8 100644 --- a/cdk/test/constructs/bedrock-models.test.ts +++ b/cdk/test/constructs/bedrock-models.test.ts @@ -17,6 +17,8 @@ * SOFTWARE. */ +import * as fs from 'fs'; +import * as path from 'path'; import { App, Stack } from 'aws-cdk-lib'; import { BEDROCK_MODELS_CONTEXT_KEY, @@ -70,3 +72,28 @@ describe('resolveBedrockModelIds', () => { ).toThrow(/bare foundation-model IDs/); }); }); + +/** + * Drift guard: the agent picks a fallback model when a repo pins none, and the + * IAM grant that lets it invoke that model is derived from + * DEFAULT_BEDROCK_MODEL_IDS. If the two disagree, every task on the stack fails + * at turn 0 with AccessDenied — and nothing else in the suite notices, because + * the agent-side default and the CDK-side grant live in different languages. + */ +describe('DEFAULT_BEDROCK_MODEL_IDS covers the agent runtime default', () => { + it('grants the fallback model the agent falls back to', () => { + const configPy = fs.readFileSync( + path.resolve(__dirname, '../../../agent/src/config.py'), 'utf8', + ); + // The fallback is the second argument to the ANTHROPIC_MODEL env lookup. + const match = configPy.match(/"ANTHROPIC_MODEL",\s*"([^"]+)"/); + expect(match).not.toBeNull(); + const agentDefault = match![1]; + + // The agent names the US inference profile (`us.anthropic.…`); the grant list + // holds bare foundation-model IDs and both grant sites add the `us.` prefix. + expect(agentDefault).toMatch(/^us\./); + const bare = agentDefault.replace(/^us\./, ''); + expect(DEFAULT_BEDROCK_MODEL_IDS).toContain(bare); + }); +}); diff --git a/cdk/test/handlers/shared/workflows.test.ts b/cdk/test/handlers/shared/workflows.test.ts index 51abe0682..bdc7cbbaa 100644 --- a/cdk/test/handlers/shared/workflows.test.ts +++ b/cdk/test/handlers/shared/workflows.test.ts @@ -228,7 +228,9 @@ describe('CDK descriptors stay in sync with agent/workflows/**', () => { const configPy = fs.readFileSync( path.resolve(__dirname, '../../../../agent/src/config.py'), 'utf8', ); - const match = configPy.match(/_KNOWN_WRITEABLE_WORKFLOW_IDS\s*=\s*frozenset\(\(([^)]*)\)\)/s); + // Tolerate ruff's formatting of the frozenset: it may render single-line + // ``frozenset(("a", "b"))`` or multi-line with whitespace between the parens. + const match = configPy.match(/_KNOWN_WRITEABLE_WORKFLOW_IDS\s*=\s*frozenset\(\s*\(([^)]*)\)\s*\)/s); expect(match).not.toBeNull(); const agentWriteable = new Set( [...match![1].matchAll(/"([^"]+)"/g)].map(m => m[1]),