From 3a38156caf578a22e413f838f38435281d397d2c Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Fri, 24 Jul 2026 23:06:26 +0100 Subject: [PATCH 1/7] feat(carve S3): the agent runtime (Linear no-MCP, attachments, build/lint gate, plan/restack) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3 of the carve — the full autonomous-agent runtime, landed as ONE unit. Stacked on S2. Unlike the CDK subsystems, the agent Python evolved as a tightly- coupled whole: the changes to pipeline.py / repo.py / post_hooks.py / shell.py / models.py interleave WITHIN shared files (a single pipeline.py carries the no-MCP, decompose-plan, restack, and clarify-resume hunks). Splitting it along the CDK subsystem lines produces non-compiling intermediate states, so it lands atomically: all 27 agent/src modules + their tests + the two workflow definitions. What it brings: - Linear no-MCP: strip the Linear MCP server; the platform pre-hydrates comments + attachments deterministically instead of the agent fetching them at runtime. - Per-repo build/lint verification gate before opening a PR (build_command / lint_command), with a structured verify outcome (passed / timed-out / infra-failed). - Stacked-child branch handling + predecessor re-merge (for the sub-issue DAG). - Agent-native decompose + restack workflows (clone, plan/re-stack, emit artifact). - Attachment download + screening + version-pinned integrity read. Behavioral note for reviewers: this also moves the default agent model and a couple of runtime defaults to their current values — intrinsic to the runtime as it runs today; called out here rather than split out, to keep the runtime internally consistent. Gates: agent ruff + ty + full pytest (1421 tests) green. Files are verbatim from the source branch (combination-verified: 0 diffs vs source). Comment cleanup for public readability follows as a separate commit on this branch. --- agent/AGENTS.md | 2 - agent/README.md | 7 - agent/pyproject.toml | 21 + agent/src/channel_mcp.py | 147 +++- agent/src/clarification_tool.py | 74 ++ agent/src/config.py | 175 ++--- agent/src/hooks.py | 473 +++++++----- agent/src/jira_reactions.py | 360 +++------ agent/src/linear_reactions.py | 125 +++- agent/src/models.py | 169 +++-- agent/src/pipeline.py | 749 +++++++++++++++---- agent/src/policy.py | 249 +++--- agent/src/post_hooks.py | 446 ++++++++++- agent/src/prompt_builder.py | 225 +++++- agent/src/prompts/__init__.py | 47 +- agent/src/prompts/base.py | 8 +- agent/src/prompts/decompose.py | 126 ++++ agent/src/prompts/new_task.py | 62 +- agent/src/prompts/pr_iteration.py | 25 +- agent/src/prompts/restack.py | 59 ++ agent/src/repo.py | 485 ++++++++++-- agent/src/runner.py | 136 ++-- agent/src/server.py | 88 ++- agent/src/shared_constants.py | 29 - agent/src/shell.py | 165 ++-- agent/src/task_state.py | 154 ++-- agent/src/workflow/deliverers.py | 83 +- agent/src/workflow/runner.py | 55 +- agent/tests/test_channel_mcp.py | 236 +++--- agent/tests/test_clarification_tool.py | 33 + agent/tests/test_config.py | 122 --- agent/tests/test_entrypoint.py | 91 +++ agent/tests/test_hooks.py | 117 +++ agent/tests/test_jira_reactions.py | 108 --- agent/tests/test_linear_reactions.py | 134 ++++ agent/tests/test_models.py | 15 + agent/tests/test_pipeline.py | 314 ++++++-- agent/tests/test_pipeline_outcomes.py | 150 ++++ agent/tests/test_pipeline_post_hook_gates.py | 5 +- agent/tests/test_post_hooks.py | 212 +++++- agent/tests/test_prompts.py | 208 ++++- agent/tests/test_repo.py | 457 +++++++++++ agent/tests/test_run_task_from_payload.py | 51 +- agent/tests/test_server.py | 92 +++ agent/tests/test_shell.py | 50 ++ agent/tests/test_verify_commands.py | 256 +++++++ agent/tests/test_workflow_runner.py | 10 +- agent/workflows/coding/decompose-v1.yaml | 59 ++ agent/workflows/coding/restack-v1.yaml | 53 ++ 49 files changed, 5709 insertions(+), 1808 deletions(-) create mode 100644 agent/src/clarification_tool.py create mode 100644 agent/src/prompts/decompose.py create mode 100644 agent/src/prompts/restack.py create mode 100644 agent/tests/test_clarification_tool.py create mode 100644 agent/tests/test_verify_commands.py create mode 100644 agent/workflows/coding/decompose-v1.yaml create mode 100644 agent/workflows/coding/restack-v1.yaml diff --git a/agent/AGENTS.md b/agent/AGENTS.md index eec51f81e..d1e08b785 100644 --- a/agent/AGENTS.md +++ b/agent/AGENTS.md @@ -67,12 +67,10 @@ def _reset_shared_circuit_breaker_state(): yield _reset_circuit_breakers() - class TestGenerateUlid: def test_length_is_26(self): assert len(_generate_ulid()) == 26 - # ❌ Bad — no isolation, test order dependency def test_a(): _ProgressWriter._circuit_open = True # poisons test_b diff --git a/agent/README.md b/agent/README.md index f3903a524..5feb0f5ee 100644 --- a/agent/README.md +++ b/agent/README.md @@ -125,13 +125,6 @@ The `run.sh` script overrides the container's default CMD to run `python /app/sr | `DRY_RUN` | No | | Set to `1` to validate config and print the prompt without running the agent | | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | No | `anthropic.claude-haiku-4-5-20251001-v1:0` | Bedrock model ID for the pre-flight safety check (see below) | | `NUDGES_TABLE_NAME` | No | | **Phase 2.** DynamoDB table for mid-task user nudges (`` XML blocks injected between turns). If unset, the agent runs without nudge support — `nudge_reader.read_pending()` returns `[]` and logs a WARN once. Set automatically by the CDK stack on both AgentCore runtimes. | -| `JIRA_APP_ACTOR_PROXY_URL` | No | | Resolved per-task from the Jira tenant secret. Forge v2 web-trigger URL used for app-authored Jira comments and transitions. | -| `JIRA_APP_ACTOR_SHARED_SECRET` | No | | Resolved per-task from the Jira tenant secret. HMAC key for the Forge proxy; redacted from agent diagnostics. | -| `JIRA_API_TOKEN` | No | | Legacy per-task Jira 3LO token. Used for outbound writes only when no app actor is configured. | - -`run_task` removes all Jira credential variables before every invocation, -including non-Jira tasks, so a warm AgentCore process cannot expose one -tenant's OAuth or Forge credential to the next task. **Bedrock model access (main model):** Configuring `ANTHROPIC_MODEL` and IAM credentials is not enough. Your AWS account must be able to **invoke** that model in Amazon Bedrock: follow [Request access to models](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html) (Marketplace permissions on first use, Anthropic first-time use where required, valid payment method for Marketplace-backed models). Use an inference profile ID such as `us.anthropic.claude-sonnet-4-6` when Bedrock requires it. If the CLI stops with a message that the model is not available on your Bedrock deployment, fix model access in the console or switch `ANTHROPIC_MODEL` to an entitled profile, then retry. 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..a59585d9b 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. @@ -65,9 +56,11 @@ def _linear_server_entry() -> dict[str, Any]: #: and will NOT accept the stored REST OAuth token as a Bearer header, so it #: fails to connect in the runtime (``claude mcp list`` → "Failed to connect"). #: -#: The LIVE outbound path is ``agent/src/jira_reactions.py``. It uses the -#: signed Forge app proxy when configured and retains direct REST + OAuth only -#: as a migration fallback. See ADR-015 and ``agent/src/prompt_builder.py``. +#: The LIVE outbound path is the REST shim in ``agent/src/jira_reactions.py`` +#: (the "Plan B" that became Plan A), which posts comments via the Jira REST +#: v3 API using the same stored OAuth token. See ADR-015 and +#: ``agent/src/prompt_builder.py``. If Atlassian ever ships a token-compatible +#: MCP, this entry can be promoted and the REST shim retired. JIRA_MCP_URL = "https://mcp.atlassian.com/v1/sse" #: Key name inside ``mcpServers``. Tools surface as ``mcp__jira-server__*`` @@ -99,7 +92,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 +116,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 +132,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 @@ -176,11 +176,84 @@ def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool: # The Jira MCP entry is a non-functional placeholder (see JIRA_MCP_URL # docstring + ADR-015). Log it in-band so a "Failed to connect" line in # the agent logs isn't mistaken for the cause of a missing comment — - # the live outbound path is the Forge/app-auth aware jira_reactions.py. + # the live outbound path is the REST shim in jira_reactions.py. log( "TASK", "jira MCP entry is a placeholder and is EXPECTED to fail to connect; " - "outbound Jira writes use jira_reactions.py (Forge app actor when configured), " - "not MCP", + "outbound Jira comments use the REST shim (jira_reactions.py), 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..5e2df2395 100644 --- a/agent/src/config.py +++ b/agent/src/config.py @@ -13,18 +13,32 @@ # The platform default workflow id used when a payload omits resolved_workflow # (local/batch runs). Mirrors the create-task boundary's coding default. DEFAULT_WORKFLOW_ID = "coding/new-task-v1" -# The repo-less platform default workflow (#248 Phase 3) — the one first-party -# id whose ``requires_repo`` is false. Used by the load-failure fallback to -# decide repo-optionality without loading the file. +# The repo-less platform default workflow — the one first-party 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 +# 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: 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: @@ -55,27 +69,26 @@ def resolve_github_token() -> str: def resolve_linear_api_token(channel_metadata: dict[str, str] | None = None) -> str: """Resolve the Linear OAuth access token from Secrets Manager. - Phase 2.0b-O2: the orchestrator stamps ``linear_oauth_secret_arn`` - into the task record's ``channel_metadata`` at task-creation time. - 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. + The orchestrator stamps ``linear_oauth_secret_arn`` into the task + record's ``channel_metadata`` at task-creation time. 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 the one + remaining consumer — ``linear_reactions.py``'s direct-GraphQL + Authorization header (reactions + state transitions) — keeps working. + (Per 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 - flow has an open service-side bug (see memory/project_oauth_2_0b.md). + An earlier approach used AgentCore Identity; this reads Secrets Manager + directly because that Identity federation flow has an open service-side + bug. """ cached = os.environ.get("LINEAR_API_TOKEN", "") if cached: @@ -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: @@ -225,7 +238,7 @@ def _try_refresh_once(current: dict) -> tuple[str, dict | None]: "updated_at": now.isoformat().replace("+00:00", "Z"), } - # Phase 2.0b-O2 review item S1: agent runtime no longer has + # The agent runtime no longer has # `secretsmanager:PutSecretValue` on the OAuth secret prefix — # the agent executes untrusted repo code, and writing tokens # back means a compromised agent could overwrite any @@ -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", "")): @@ -330,29 +343,14 @@ def _refresh(current: dict) -> dict | None: return access -_JIRA_TASK_CREDENTIAL_ENV_VARS = ( - "JIRA_API_TOKEN", - "JIRA_APP_ACTOR_CONFIGURED", - "JIRA_APP_ACTOR_PROXY_URL", - "JIRA_APP_ACTOR_SHARED_SECRET", -) - - -def clear_jira_task_credentials() -> None: - """Remove Jira credentials left by an earlier task in this process.""" - for name in _JIRA_TASK_CREDENTIAL_ENV_VARS: - os.environ.pop(name, None) - - def resolve_jira_oauth_token(channel_metadata: dict[str, str] | None = None) -> str: - """Resolve Jira outbound credentials from Secrets Manager. + """Resolve the Jira Cloud OAuth access token from Secrets Manager. The orchestrator stamps ``jira_oauth_secret_arn`` into the task - record's ``channel_metadata`` at task-creation time. A tenant configured - with a Forge app actor gets ``JIRA_APP_ACTOR_*`` environment variables; - older tenants retain ``JIRA_API_TOKEN`` as an explicit migration fallback. - ``jira_reactions`` always prefers the app actor and never falls back to - OAuth when an app configuration is present but broken. + record's ``channel_metadata`` at task-creation time. We fetch the + per-tenant secret, parse the token JSON, and cache the access_token in + ``JIRA_API_TOKEN`` so the agent-side Jira REST calls + (``jira_reactions``) can authorize. **The agent never refreshes the token.** Unlike Linear, Atlassian *rotates the refresh_token on every use* — a successful refresh @@ -374,22 +372,20 @@ def resolve_jira_oauth_token(channel_metadata: dict[str, str] | None = None) -> session, so in practice the agent reads a freshly-written token with a full lifetime ahead of it. + For local development, a pre-set ``JIRA_API_TOKEN`` env var + short-circuits the lookup so the agent can run outside the runtime. + This function is only called when ``channel_source == 'jira'``. """ + cached = os.environ.get("JIRA_API_TOKEN", "") + if cached: + return cached + secret_arn = "" if channel_metadata: secret_arn = channel_metadata.get("jira_oauth_secret_arn", "") if not secret_arn: secret_arn = os.environ.get("JIRA_OAUTH_SECRET_ARN", "") - cached = os.environ.get("JIRA_API_TOKEN", "") - if cached and not secret_arn and channel_metadata is None: - return cached - - # AgentCore can reuse a warm process. Clear the prior task's tenant - # credentials before resolving a metadata-bound task so a missing ARN or - # failed SM read cannot reuse another tenant's token or proxy secret. - if channel_metadata is not None or secret_arn: - clear_jira_task_credentials() if not secret_arn: return "" @@ -406,7 +402,7 @@ def resolve_jira_oauth_token(channel_metadata: dict[str, str] | None = None) -> from botocore.exceptions import BotoCoreError, ClientError except ImportError as e: log("WARN", f"resolve_jira_oauth_token: boto3 unavailable ({e}); skipping") - return "" # nosemgrep: py-silent-success-masking -- Jira feedback is advisory + return "" sm = boto3.client("secretsmanager", region_name=region) @@ -420,7 +416,7 @@ def _fetch_token() -> dict | None: f"resolve_jira_oauth_token: secret '{secret_arn}' is not valid JSON " f"({type(e).__name__}: {e}); tenant requires re-onboarding", ) - return None # nosemgrep: py-silent-success-masking -- bad secret disables writes + return None def _is_expiring(expires_at_iso: str, threshold_seconds: int = 60) -> bool: try: @@ -442,52 +438,19 @@ def _is_expiring(expires_at_iso: str, threshold_seconds: int = 60) -> bool: is_hard_failure = code in ("AccessDeniedException", "ResourceNotFoundException") severity = "ERROR" if is_hard_failure else "WARN" log(severity, f"resolve_jira_oauth_token failed: {type(e).__name__}: {e}") - return "" # nosemgrep: py-silent-success-masking -- feedback cannot fail the task - if token_obj is None: return "" - if not isinstance(token_obj, dict): - log( - "ERROR", - f"resolve_jira_oauth_token: secret '{secret_arn}' contains non-object JSON; " - "tenant requires re-onboarding", - ) + if token_obj is None: return "" - app_actor_fields = ( - "app_actor_proxy_url", - "app_actor_shared_secret", - "app_actor_account_id", - "app_actor_display_name", - "app_actor_configured_at", - ) - raw_app_proxy_url = token_obj.get("app_actor_proxy_url", "") - raw_app_shared_secret = token_obj.get("app_actor_shared_secret", "") - app_proxy_url = raw_app_proxy_url if isinstance(raw_app_proxy_url, str) else "" - app_shared_secret = raw_app_shared_secret if isinstance(raw_app_shared_secret, str) else "" - if any(field in token_obj for field in app_actor_fields): - # The marker makes partial/corrupt app setup fail closed in - # jira_reactions. Do not silently post as the OAuth authorizing user. - os.environ["JIRA_APP_ACTOR_CONFIGURED"] = "1" - if app_proxy_url: - os.environ["JIRA_APP_ACTOR_PROXY_URL"] = app_proxy_url - if app_shared_secret: - os.environ["JIRA_APP_ACTOR_SHARED_SECRET"] = app_shared_secret - # Fail closed if the stored token is expiring — the agent cannot refresh # without burning Atlassian's rotating refresh_token (see docstring). The - # Lambda path owns refresh. A configured Forge app actor remains usable - # because its signed proxy credential is independent of 3LO expiry. + # Lambda path owns refresh; advisory Jira comments simply no-op here. if _is_expiring(token_obj.get("expires_at", "")): - suffix = ( - " Forge app-actor writes remain enabled." - if os.environ.get("JIRA_APP_ACTOR_CONFIGURED") == "1" - else " Jira OAuth fallback writes will be skipped for this task." - ) log( "WARN", "resolve_jira_oauth_token: stored token is expiring and the agent does not " "refresh (Atlassian rotates refresh_tokens; agent lacks PutSecretValue). " - f"Failing closed for OAuth.{suffix}", + "Failing closed — Jira comments will be skipped for this task.", ) return "" @@ -510,9 +473,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 +500,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 @@ -551,9 +518,9 @@ def build_config( is_pr_workflow = workflow_id in PR_WORKFLOW_IDS # Load the workflow up-front: it drives the Cedar principal, the read_only - # flag, AND whether a repo is required (#248 Phase 3). Fall back to id-based - # mapping when the file can't be loaded (e.g. a registry-only id in a future - # phase) — a repo-less default is the safe assumption only for non-coding. + # flag, AND whether a repo is required. Fall back to id-based mapping when + # the file can't be loaded (e.g. an id whose workflow file has not shipped + # yet) — a repo-less default is the safe assumption only for non-coding. from workflow import WorkflowValidationError, load_workflow, policy_principal_for try: @@ -623,6 +590,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 +600,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,13 +624,13 @@ 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"), - # Local-batch ``--trace`` parity (design §10.1). Without - # these env vars a developer running the agent outside - # AgentCore could never exercise the trace path. Both are + # Local-batch ``--trace`` parity. Without these env vars a + # developer running the agent outside AgentCore could never + # exercise the trace path. Both are # opt-in; empty ``USER_ID`` with ``TRACE=1`` logs a skip # warning (see ``pipeline.run_task``) rather than writing # an unreachable ``traces//`` key. diff --git a/agent/src/hooks.py b/agent/src/hooks.py index 19f3bafab..88e0ac031 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,86 @@ 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. We stop scanning for the clone verb at the first of these so a body +# quoting the clone command can't trip the guard. +_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 + # Only consider the segment BEFORE any free-text argument — a --body/-m value + # that quotes the clone command is prose, not an executed clone. + m = _FREE_TEXT_ARG_RE.search(command) + scan = command[: m.start()] if m else command + if not _CLONE_CMD_RE.search(scan): + return False + variants = _repo_slug_variants(repo_url) + if not variants: + return False + # Normalize the command's URL punctuation to the bare slug space: drop the + # scheme/host and the scp-style ``git@host:`` prefix so ``.../owner/repo.git`` + # and ``git@github.com:owner/repo`` both reduce to a substring carrying + # ``owner/repo``. Search the SAME pre-free-text segment. + haystack = scan.lower().replace("git@github.com:", "github.com/") + return any(v in haystack for v in variants) + + async def pre_tool_use_hook( hook_input: Any, tool_use_id: str | None, @@ -255,27 +335,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 +383,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 +420,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 +459,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 +485,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 +524,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 +545,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 +563,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 +598,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 +667,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 +680,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 +746,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 +754,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 +768,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 +796,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 +806,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 +850,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 +906,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 +986,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 +1043,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 +1079,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 +1098,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 +1117,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 +1157,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 +1167,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 +1201,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 +1238,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 +1273,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 +1352,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 +1370,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 +1428,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 +1438,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 +1481,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 +1528,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 +1553,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 +1577,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 +1619,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 +1638,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 +1685,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 +1698,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 +1709,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 +1736,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/jira_reactions.py b/agent/src/jira_reactions.py index 83a81ae28..b292ac805 100644 --- a/agent/src/jira_reactions.py +++ b/agent/src/jira_reactions.py @@ -5,28 +5,28 @@ REST API has no lightweight reaction primitive, so comments are the right tool). -It also moves the originating issue across its board as the task progresses -(issue #572): To Do → In Progress on start, → In Review on PR, via the Jira -transitions API. Like comments, transitions are best-effort — logged and -swallowed on any failure, sharing the same auth circuit breaker — so the Jira -board never gates the task. See the "Workflow transitions" section below. - -The *terminal* status comment is NOT posted from here: since issue #573 the -deterministic fan-out plane (``cdk/src/handlers/fanout-task-events.ts`` -``dispatchToJira``) owns it, so it carries cost/turns/duration and fires even -when this agent crashes before completing. This module only owns the start -comment. - -Why a signed app proxy instead of MCP: Atlassian's Remote MCP +It also moves the originating issue across its board as the task progresses: +To Do → In Progress on start, → In Review on PR, via the Jira transitions API. +Like comments, transitions are best-effort — logged and swallowed on any +failure, sharing the same auth circuit breaker — so the Jira board never gates +the task. See the "Workflow transitions" section below. + +The *terminal* status comment is NOT posted from here: the deterministic +fan-out plane (``cdk/src/handlers/fanout-task-events.ts`` ``dispatchToJira``) +owns it, so it carries cost/turns/duration and fires even when this agent +crashes before completing. This module only owns the start comment. + +Why a direct REST call instead of MCP: Atlassian's Remote MCP (``mcp.atlassian.com``) requires an interactive, browser-based OAuth 2.1 authorization flow with dynamic client registration — it does NOT accept the stored Jira REST OAuth token as a ``Bearer`` header, and a headless background agent cannot complete the interactive handshake. The MCP path therefore fails -to connect in the runtime (``claude mcp list`` → "Failed to connect"). -Configured tenants call a narrow HMAC-authenticated Forge web trigger, which -uses ``api.asApp().requestJira`` so Jira attributes writes to the app account. -Tenants without Forge configuration retain the user-delegated OAuth REST path -as an explicit migration fallback. +to connect in the runtime (``claude mcp list`` → "Failed to connect"). The +Jira *REST* API, by contrast, accepts the same stored OAuth access token (it +carries ``write:jira-work``), so we post comments via +``POST /rest/api/3/issue/{key}/comment`` on the cross-region +``api.atlassian.com/ex/jira/{cloudId}`` base. This is the "Plan B REST shim" +the ``channel_mcp`` module's comments anticipated. Gating: every function is a no-op unless ``channel_source == 'jira'`` and the issue key + cloud id are present in ``channel_metadata``. All network / auth @@ -39,28 +39,20 @@ from __future__ import annotations -import hashlib -import hmac -import json import os import threading -import time from http import HTTPStatus from typing import Any -from urllib.parse import quote, urlparse +from urllib.parse import quote import requests -from shared_constants import SHARED_CONSTANTS from shell import log #: Atlassian cross-region REST base. The ``{cloudId}`` segment scopes the #: call to the tenant; ``JIRA_API_TOKEN`` (populated by #: ``config.resolve_jira_oauth_token``) authorizes it. JIRA_API_BASE = "https://api.atlassian.com/ex/jira" -_JIRA_APP_ACTOR_CONSTANTS = SHARED_CONSTANTS["jira_app_actor"] -FORGE_WEBTRIGGER_SUFFIX = str(_JIRA_APP_ACTOR_CONSTANTS["forge_webtrigger_suffix"]) -APP_ACTOR_MIN_SECRET_LENGTH = int(_JIRA_APP_ACTOR_CONSTANTS["min_secret_length"]) #: Request timeout — comments are fire-and-forget status UX; never block the #: task pipeline for more than a couple of seconds. @@ -74,22 +66,6 @@ _consecutive_auth_failures: int = 0 _auth_circuit_open: bool = False _auth_state_lock = threading.Lock() -_PROXY_ERROR_CODES = frozenset( - { - "cloud_id_required", - "invalid_comment_request", - "invalid_issue_key", - "invalid_json", - "invalid_payload", - "invalid_signature", - "invalid_transition_request", - "jira_request_failed", - "method_not_allowed", - "payload_too_large", - "proxy_not_configured", - "unsupported_operation", - } -) def _circuit_open() -> bool: @@ -121,9 +97,8 @@ def _note_auth_status(status_code: int) -> None: log( "ERROR", "jira_reactions: auth circuit OPEN after " - f"{failures} consecutive {status_code}s — Jira outbound credential " - "or app permission is invalid. For Forge app writes, verify " - "BGAGENT_PROXY_SECRET matches JIRA_APP_ACTOR_SHARED_SECRET. Suppressing further " + f"{failures} consecutive {status_code}s — Jira token likely " + "revoked/expired without a working refresh. Suppressing further " "Jira calls for this container.", ) else: @@ -166,124 +141,6 @@ def _adf(text: str) -> dict[str, Any]: } -def _app_actor_intended() -> bool: - """True once app setup has written any Forge app-actor field.""" - return os.environ.get("JIRA_APP_ACTOR_CONFIGURED") == "1" or bool( - os.environ.get("JIRA_APP_ACTOR_PROXY_URL") or os.environ.get("JIRA_APP_ACTOR_SHARED_SECRET") - ) - - -def _app_actor_credentials() -> tuple[str, str] | None: - """Return validated Forge proxy credentials, failing closed when malformed.""" - proxy_url = os.environ.get("JIRA_APP_ACTOR_PROXY_URL", "") - shared_secret = os.environ.get("JIRA_APP_ACTOR_SHARED_SECRET", "") - try: - parsed = urlparse(proxy_url) - valid_url = ( - parsed.scheme == "https" - and bool(parsed.hostname) - and parsed.hostname.endswith(FORGE_WEBTRIGGER_SUFFIX) - and parsed.path.startswith("/public/") - and not parsed.username - and not parsed.password - and not parsed.query - and not parsed.fragment - ) - except ValueError: - valid_url = False - if not valid_url or len(shared_secret) < APP_ACTOR_MIN_SECRET_LENGTH: - log( - "ERROR", - "jira_reactions: configured Forge app actor is incomplete or invalid; " - "refusing OAuth fallback", - ) - return None - return proxy_url, shared_secret - - -def _app_actor_request( - cloud_id: str, - operation: str, - *, - issue_key: str | None = None, - body: dict[str, Any] | None = None, - transition_id: str | None = None, -) -> requests.Response | None: - """Call the signed, operation-allowlisted Forge app proxy.""" - credentials = _app_actor_credentials() - if not credentials: - return None - proxy_url, shared_secret = credentials - payload: dict[str, Any] = { - "version": 1, - "operation": operation, - "cloud_id": cloud_id, - } - if issue_key is not None: - payload["issue_key"] = issue_key - if body is not None: - payload["body"] = body - if transition_id is not None: - payload["transition_id"] = transition_id - - raw_body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) - timestamp = str(int(time.time())) - signature = hmac.new( - shared_secret.encode(), - f"{timestamp}.{raw_body}".encode(), - hashlib.sha256, - ).hexdigest() - try: - response = requests.post( - proxy_url, - data=raw_body.encode(), - headers={ - "Content-Type": "application/json", - "X-Bgagent-Timestamp": timestamp, - "X-Bgagent-Signature": f"sha256={signature}", - }, - timeout=REQUEST_TIMEOUT_SECONDS, - ) - if not HTTPStatus.OK <= response.status_code < HTTPStatus.MULTIPLE_CHOICES: - proxy_error = _proxy_error_code(response.text) - non_retryable = ( - response.status_code < HTTPStatus.INTERNAL_SERVER_ERROR - or proxy_error == "proxy_not_configured" - ) - level = "ERROR" if non_retryable else "WARN" - error_id = ( - "JIRA_APP_ACTOR_PROXY_REJECTED" - if non_retryable - else "JIRA_APP_ACTOR_PROXY_TRANSIENT" - ) - log( - level, - f"jira_reactions: Forge app proxy returned HTTP {response.status_code} " - f"error_id={error_id} operation={operation} " - f"proxy_error={proxy_error or 'unclassified'}", - ) - return response - except requests.RequestException as e: - log( - "WARN", - f"jira_reactions: Forge app proxy request failed ({type(e).__name__}): {e}", - ) - # nosemgrep: py-silent-success-masking -- Jira writes are advisory on network blips - return None - - -def _proxy_error_code(raw_body: str) -> str | None: - """Return a known proxy error code without logging arbitrary response text.""" - try: - payload = json.loads(raw_body) - except (json.JSONDecodeError, TypeError): - return None - if not isinstance(payload, dict): - return None - value = payload.get("error") - return value if isinstance(value, str) and value in _PROXY_ERROR_CODES else None - - def _post_comment(cloud_id: str, issue_key: str, text: str) -> bool: """POST a comment to the issue. Return True on success, False on any failure. @@ -296,40 +153,34 @@ def _post_comment(cloud_id: str, issue_key: str, text: str) -> bool: log("DEBUG", "jira_reactions: auth circuit still open; short-circuiting call") return False - if _app_actor_intended(): - resp = _app_actor_request( - cloud_id, - "comment", - issue_key=issue_key, - body=_adf(text), - ) - if resp is None: - return False - else: - token = os.environ.get("JIRA_API_TOKEN", "") - if not token: - log("WARN", "jira_reactions: JIRA_API_TOKEN not set; skipping comment") - return False - log("WARN", "jira_reactions: app actor not configured; using OAuth fallback") - url = ( - f"{JIRA_API_BASE}/{quote(cloud_id, safe='')}" - f"/rest/api/3/issue/{quote(issue_key, safe='')}/comment" + token = os.environ.get("JIRA_API_TOKEN", "") + if not token: + log("WARN", "jira_reactions: JIRA_API_TOKEN not set; skipping comment") + return False + + # URL-encode both path segments. cloud_id and issue_key originate from the + # verified webhook payload (stamped into channel_metadata by the + # processor), but encoding them keeps an unexpected value from injecting + # extra path segments into the gateway URL. `safe=""` so even "/" encodes. + url = ( + f"{JIRA_API_BASE}/{quote(cloud_id, safe='')}" + f"/rest/api/3/issue/{quote(issue_key, safe='')}/comment" + ) + try: + resp = requests.post( + url, + json={"body": _adf(text)}, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + timeout=REQUEST_TIMEOUT_SECONDS, ) - try: - resp = requests.post( - url, - json={"body": _adf(text)}, - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - "Accept": "application/json", - }, - timeout=REQUEST_TIMEOUT_SECONDS, - ) - except requests.RequestException as e: - log("WARN", f"jira_reactions: request failed ({type(e).__name__}): {e}") - # nosemgrep: py-silent-success-masking -- Jira comments are best-effort on network blips - return False + except requests.RequestException as e: + log("WARN", f"jira_reactions: request failed ({type(e).__name__}): {e}") + # nosemgrep: py-silent-success-masking -- Jira comments are best-effort on network blips + return False if resp.status_code in (401, 403): _note_auth_status(resp.status_code) @@ -362,16 +213,16 @@ def comment_task_started( log("TASK", f"jira_reactions: comment_task_started issue={issue_key} ok={ok}") -# NOTE: there is deliberately no ``comment_task_finished`` here. Since issue -# #573 the deterministic fan-out plane -# (``cdk/src/handlers/fanout-task-events.ts`` ``dispatchToJira``) owns the Jira -# terminal comment — it carries cost/turns/duration and, crucially, fires even -# when the agent crashes before completing (max-turns, OOM). The agent only -# posts the *start* comment (``comment_task_started`` above); posting a terminal -# comment here too would double-comment on the issue. +# NOTE: there is deliberately no ``comment_task_finished`` here. The +# deterministic fan-out plane (``cdk/src/handlers/fanout-task-events.ts`` +# ``dispatchToJira``) owns the Jira terminal comment — it carries +# cost/turns/duration and, crucially, fires even when the agent crashes before +# completing (max-turns, OOM). The agent only posts the *start* comment +# (``comment_task_started`` above); posting a terminal comment here too would +# double-comment on the issue. -# ── Workflow transitions (issue #572) ────────────────────────────────────────── +# ── Workflow transitions ──────────────────────────────────────────────────────── # # Move the originating Jira card across its board as the task progresses so the # board reflects reality: To Do → In Progress on start, → In Review on PR. Jira @@ -397,14 +248,14 @@ def comment_task_started( #: Preferred destination *name* for the start transition (matched #: case-insensitively before the category fallback). Both ``In Progress`` and #: ``Blocked`` share the ``indeterminate`` category, so a name match is what -#: keeps a category-only heuristic from landing on ``Blocked`` (#605). +#: keeps a category-only heuristic from landing on ``Blocked``. _START_STATUS_NAME = "in progress" #: Preferred destination names for the PR-opened transition, tried in order. #: Mirrors Linear's "In Review → In Progress" fallback while also matching #: common review-column names, so a "Code Review" column isn't silently skipped #: and a stock board (no review status) still advances to / stays at In Progress -#: rather than no-opping (#605). Ends with In Progress as the safe fallback. +#: rather than no-opping. Ends with In Progress as the safe fallback. _REVIEW_STATUS_NAMES = ( "in review", "code review", @@ -416,7 +267,7 @@ def comment_task_started( #: Destination names the category fallback must never auto-pick. ``Blocked`` #: shares the ``indeterminate`` category with ``In Progress``; a bare -#: first-indeterminate heuristic could land there (#605), which is never what +#: first-indeterminate heuristic could land there, which is never what #: "move to In Progress" means. A configured override can still target it. _CATEGORY_FALLBACK_DENY = ("blocked",) @@ -427,34 +278,29 @@ def _get_issue_transitions( """GET the issue's current status + the transitions valid from it. Fetches ``?fields=status&expand=transitions`` so a single call yields both - the current ``status`` (to skip moving a card backward, #605) and the + the current ``status`` (to skip moving a card backward) and the ``transitions`` list. Returns ``(current_status, transitions)`` on success, or ``None`` on any failure. An empty transitions list is normal — Jira returns one when the OAuth user lacks the *Transition Issues* permission — and callers treat it as "nothing to do", not an error. """ - if _app_actor_intended(): - resp = _app_actor_request(cloud_id, "get_transitions", issue_key=issue_key) - if resp is None: - return None - else: - url = ( - f"{JIRA_API_BASE}/{quote(cloud_id, safe='')}" - f"/rest/api/3/issue/{quote(issue_key, safe='')}?fields=status&expand=transitions" + url = ( + f"{JIRA_API_BASE}/{quote(cloud_id, safe='')}" + f"/rest/api/3/issue/{quote(issue_key, safe='')}?fields=status&expand=transitions" + ) + try: + resp = requests.get( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + }, + timeout=REQUEST_TIMEOUT_SECONDS, ) - try: - resp = requests.get( - url, - headers={ - "Authorization": f"Bearer {token}", - "Accept": "application/json", - }, - timeout=REQUEST_TIMEOUT_SECONDS, - ) - except requests.RequestException as e: - log("WARN", f"jira_reactions: issue GET failed ({type(e).__name__}): {e}") - # nosemgrep: py-silent-success-masking -- advisory Jira transition read - return None + except requests.RequestException as e: + log("WARN", f"jira_reactions: issue GET failed ({type(e).__name__}): {e}") + # nosemgrep: py-silent-success-masking -- Jira transitions are best-effort on network blips + return None if resp.status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): _note_auth_status(resp.status_code) @@ -473,7 +319,7 @@ def _get_issue_transitions( # A well-formed response is a JSON object. Guard against valid-but-unexpected # JSON (``null``, a bare list, a scalar) — ``.get`` would raise # AttributeError, which is NOT best-effort: it propagates out of the pipeline - # hook and flips the task to FAILED (#605). + # hook and flips the task to FAILED. if not isinstance(payload, dict): log("WARN", "jira_reactions: issue GET returned non-object JSON — skipping") return None @@ -556,35 +402,25 @@ def _select_transition( def _post_transition(cloud_id: str, issue_key: str, token: str, transition_id: str) -> bool: """POST a transition. Return True on success (204), False on any failure.""" - if _app_actor_intended(): - resp = _app_actor_request( - cloud_id, - "transition", - issue_key=issue_key, - transition_id=transition_id, - ) - if resp is None: - return False - else: - url = ( - f"{JIRA_API_BASE}/{quote(cloud_id, safe='')}" - f"/rest/api/3/issue/{quote(issue_key, safe='')}/transitions" + url = ( + f"{JIRA_API_BASE}/{quote(cloud_id, safe='')}" + f"/rest/api/3/issue/{quote(issue_key, safe='')}/transitions" + ) + try: + resp = requests.post( + url, + json={"transition": {"id": transition_id}}, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + timeout=REQUEST_TIMEOUT_SECONDS, ) - try: - resp = requests.post( - url, - json={"transition": {"id": transition_id}}, - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - "Accept": "application/json", - }, - timeout=REQUEST_TIMEOUT_SECONDS, - ) - except requests.RequestException as e: - log("WARN", f"jira_reactions: transition POST failed ({type(e).__name__}): {e}") - # nosemgrep: py-silent-success-masking -- advisory Jira transition write - return False + except requests.RequestException as e: + log("WARN", f"jira_reactions: transition POST failed ({type(e).__name__}): {e}") + # nosemgrep: py-silent-success-masking -- Jira transitions are best-effort on network blips + return False if resp.status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): _note_auth_status(resp.status_code) @@ -627,11 +463,9 @@ def _transition( return token = os.environ.get("JIRA_API_TOKEN", "") - if not _app_actor_intended() and not token: + if not token: log("WARN", "jira_reactions: JIRA_API_TOKEN not set; skipping transition") return - if not _app_actor_intended(): - log("WARN", "jira_reactions: app actor not configured; using OAuth fallback") result = _get_issue_transitions(cloud_id, issue_key, token) if result is None: @@ -720,7 +554,7 @@ def transition_pr_opened( Resolution: ``jira_status_on_pr`` override → a destination named ``In Review`` (or a common review-column synonym) → any ``indeterminate``-category destination (Linear's In Review→In Progress - fallback, so a stock board isn't silently skipped, #605). Skips only if the + fallback, so a stock board isn't silently skipped). Skips only if the issue is already Done. Best-effort; no-op for non-Jira tasks. """ override = (channel_metadata or {}).get("jira_status_on_pr") diff --git a/agent/src/linear_reactions.py b/agent/src/linear_reactions.py index 95a3074b2..3c469293f 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,40 @@ query Viewer { viewer { id } } """.strip() +#: PM-3: 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() + +#: PM-3: 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 +253,63 @@ def _get_viewer_id() -> str | None: return None +def _transition_issue_state(issue_id: str, target_type: str, preferred_names: list[str]) -> None: + """PM-3: move the issue to a workflow state of ``target_type``, forward-only. + + A plain single task (a direct ``abca`` label, or an ``:auto``/``:decompose`` + the planner declined to a single unit) 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,12 @@ def react_task_started( name="linear-reactions-sweep", ).start() + # PM-3: 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 + # (decompose-v1, 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 +466,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 +478,10 @@ def react_task_finished( _CREATE_MUTATION, {"issueId": issue_id, "emoji": EMOJI_SUCCESS if success else EMOJI_FAILURE}, ) + # PM-3: 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..e697915c8 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,11 +15,11 @@ 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, resolve_jira_oauth_token, resolve_linear_api_token, @@ -36,6 +37,7 @@ _extract_agent_notes, ensure_committed, ensure_pr, + reconcile_agent_branch, verify_build, verify_lint, ) @@ -109,9 +111,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 +163,49 @@ 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 decompose-planning agent's plan as the task artifact. + + ``coding/decompose-v1`` is a repo-ful workflow whose primary terminal outcome + is an ARTIFACT (the decomposition plan), not a PR. It clones the repo for full + planning context but produces no code change — so the build/PR post-hooks do + not apply. This uploads the agent's final result text (the plan JSON) 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 "planned nothing". + """ + 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"decompose plan delivered as artifact: {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, @@ -172,12 +217,12 @@ def _execute_agent_step( ): """Run the agentic step through the workflow step runner. - Post-cutover (#248 task 8), the workflow runner is the sole path: the single - ``run_agent`` step is dispatched through ``workflow.run_workflow`` — - exercising the real handler registry, step milestones, and result threading — - while clone, context assembly, and post-hooks stay inline (moving the full - step list onto the runner is a follow-up). The workflow is loaded from the - resolved ``{id, version}`` pinned at the create-task boundary. + The workflow runner is the sole path: the single ``run_agent`` step is + dispatched through ``workflow.run_workflow`` — exercising the real handler + registry, step milestones, and result threading — while clone, context + assembly, and post-hooks stay inline (moving the full step list onto the + runner is a follow-up). The workflow is loaded from the resolved + ``{id, version}`` pinned at the create-task boundary. Returns the ``AgentResult`` so the surrounding pipeline (cancel short-circuit, post-hooks, result assembly) is unchanged. @@ -233,7 +278,7 @@ def _run_repoless_task( memory_id: str, system_prompt_overrides: str, ) -> dict: - """Run a repo-less workflow (#248 Phase 3) and return the result dict. + """Run a repo-less workflow (knowledge work, no repo) and return the result dict. No clone / build / PR: the workflow runner drives the full repo-less step list (``hydrate_context`` → ``run_agent`` → ``deliver_artifact``) inside the @@ -263,7 +308,7 @@ def _run_repoless_task( # Drive the full repo-less step list: hydrate_context → run_agent → # deliver_artifact. The deliverer uploads the agent's result text to # artifacts/{task_id}/ (and/or surfaces it as a comment), so the declared - # terminal outcome is actually produced (#248 Phase 3). + # terminal outcome is actually produced. with task_span("task.agent_execution"): wf_result = run_workflow(wf, ctx) @@ -303,7 +348,7 @@ def _run_repoless_task( # Either way the task produced nothing the user can retrieve, so it is a loud # FAILED rather than a silent "succeeded with no deliverable". Arm 2 closes # the gap where a deliverer that returns without raising (but also without an - # S3 key) would otherwise pass the gate (code-review MEDIUM #1). + # S3 key) would otherwise pass the gate. artifact_uri = ctx.artifacts.get("artifact_uri") primary_outcome = wf.terminal_outcomes.primary if overall_status == "success" and not wf_result.succeeded: @@ -326,8 +371,8 @@ def _run_repoless_task( trace_s3_uri = _maybe_upload_trace(config, trajectory, progress) - # Episodic memory for a repo-less task is keyed on user:{user_id} (ADR-014 - # addendum) — the same namespace the orchestrator fallback + hydration read. + # Episodic memory for a repo-less task is keyed on user:{user_id} — the same + # namespace the orchestrator fallback + hydration read. # Fail-open: a memory write failure must not fail the task. memory_written = False effective_memory_id = memory_id or os.environ.get("MEMORY_ID", "") @@ -392,18 +437,17 @@ def _apply_post_hook_gates( build_before: bool, lint_before: bool, ) -> bool: - """Resolve the coding lane's post-hook verify gates against the workflow (#301). - - Decision (issue #301 acceptance criteria): the inline post-hook path - CONSULTS each declared ``verify_build`` / ``verify_lint`` step's ``gate`` - through the runner's ``gate_status`` — the single place gate semantics live — - rather than routing the post-hooks through the runner's step handlers. - Routing through the runner would also change failure-path side effects (a - gating ``verify_build`` with ``on_failure: fail`` stops the runner *before* - ``ensure_pr``, stranding committed work with no PR), which is the broader - half-migrated-runner unification the issue defers. Here the inline ordering - (verify → ensure_pr always runs) is preserved; only the task verdict honors - the declared gate. + """Resolve the coding lane's post-hook verify gates against the workflow. + + The inline post-hook path CONSULTS each declared ``verify_build`` / + ``verify_lint`` step's ``gate`` through the runner's ``gate_status`` — the + single place gate semantics live — rather than routing the post-hooks through + the runner's step handlers. Routing through the runner would also change + failure-path side effects (a gating ``verify_build`` with ``on_failure: fail`` + stops the runner *before* ``ensure_pr``, stranding committed work with no PR), + which is a broader half-migrated-runner unification left for later. Here the + inline ordering (verify → ensure_pr always runs) is preserved; only the task + verdict honors the declared gate. Per-step semantics: @@ -461,24 +505,71 @@ 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: 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. Without it, a + build that was ALSO infra-killed BEFORE the agent ran looks "already red → not + a regression", which would wrongly report ✅ success on code we never actually + verified. 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 - # 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 - # the max_turns reason; a task that used its turns productively adds nothing. + # 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 (e.g. a task that thrashes on a failing `git push` from + # invalid credentials, retries every which way, and hits the cap). 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 the max_turns reason; a task that used its turns + # productively adds nothing. if err and "error_max_turns" in err: from hooks import last_stuck_summary @@ -486,15 +577,21 @@ 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 - # #251 carry-path: a hook may have detected an environmental blocker mid-run - # (egress denial, policy fail-closed) that the SDK surfaced only as a generic - # failure or as a missing ResultMessage. Promote the canonical - # ``BLOCKED[]: …`` reason so the CDK classifier attaches a precise - # remedy. Import locally to avoid a module-load cycle (hooks imports - # pipeline-adjacent modules). + # A hook may have detected an environmental blocker mid-run (egress denial, + # policy fail-closed) that the SDK surfaced only as a generic failure or as a + # missing ResultMessage. Promote the canonical ``BLOCKED[]: …`` reason + # so the platform's error classifier attaches a precise remedy. Import locally + # to avoid a module-load cycle (hooks imports pipeline-adjacent modules). from hooks import last_blocker_reason blocker = last_blocker_reason() @@ -519,12 +616,107 @@ def _resolve_overall_task_status( return "error", merged if not err: + # 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 ``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. + + Observed cause: a stacked child 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 (decompose) 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"ABCA-815 delivery gate: {reason}") + return "error", reason + + def _compute_turns_completed( agent_status: str, turns_attempted: int | None, @@ -532,8 +724,8 @@ 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`` - on ``error_max_turns`` because the aborted attempt is counted. Clamping + 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 for debugging. @@ -611,11 +803,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, @@ -641,11 +837,6 @@ def run_task( from repo import setup_repo - # AgentCore can reuse this process for another task. Scrub every Jira - # credential before config or repository code runs, including for non-Jira - # tasks, so a prior tenant's long-lived Forge secret cannot cross tasks. - clear_jira_task_credentials() - # Build config config = build_config( repo_url=repo_url, @@ -658,9 +849,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, @@ -704,8 +899,8 @@ def run_task( "repo.url": config.repo_url, "issue.number": config.issue_number, "agent.model": config.anthropic_model, - # Correlation envelope (#245): user.id joins agent spans to - # orchestrator logs by the platform identity, not just task/repo. + # Correlation envelope: user.id joins agent spans to orchestrator + # logs by the platform identity, not just task/repo. **({"user.id": config.user_id} if config.user_id else {}), }, ) as root_span: @@ -716,27 +911,26 @@ def run_task( progress = _ProgressWriter( config.task_id, trace=trace, user_id=config.user_id, repo=config.repo_url ) - # #251: clear any blocker latched by a prior task. The agent container - # is one-task-per-process today, but the FastAPI server thread-pool can - # in principle dispatch a second run_task in the same process — reset - # here so a stale BLOCKED[...] reason can never leak into this task's - # terminal error_message (the latch is a scalar, not task_id-keyed). + # Clear any blocker latched by a prior task. The agent container is + # one-task-per-process today, but the FastAPI server thread-pool can in + # principle dispatch a second run_task in the same process — reset here so + # a stale BLOCKED[...] reason can never leak into this task's terminal + # error_message (the latch is a scalar, not task_id-keyed). 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 - # event so the pipeline can gzip+upload the full trajectory to - # S3 on terminal. Owned by the pipeline rather than the runner - # so the accumulator outlives ``run_agent``'s scope. + # --trace accumulator: when the task opted into trace, + # ``_TrajectoryWriter`` keeps an in-memory copy of each event so the + # pipeline can gzip+upload the full trajectory to 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 - # 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. + # 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. if trace: def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: @@ -801,20 +995,20 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: prompt = assemble_prompt(config) - # Repo-less path (#248 Phase 3): a knowledge workflow has no repo to - # clone, build, or PR. Drive its steps (hydrate_context → run_agent → - # deliver_artifact) through the workflow runner and assemble the - # terminal result, skipping the repo-coupled segment below entirely. + # Repo-less path: a knowledge workflow has no repo to clone, build, or + # PR. Drive its steps (hydrate_context → run_agent → deliver_artifact) + # through the workflow runner and assemble the terminal result, + # skipping the repo-coupled segment below entirely. # # ``requires_repo: false`` means repo-OPTIONAL, not repo-forbidden: - # create-task-core admits and persists a repo for such a workflow, - # and the orchestrator then assembles a repo-bound prompt (issue/PR - # fetch). Keying the repo-less branch on ``requires_repo`` ALONE made - # the agent skip the clone while the prompt promised a repo — the two - # halves disagreed (PR review #296 finding #3). So take the repo-less - # path only when no repo was actually supplied; when a repo IS present - # it is hydrated as context exactly like a coding task (clone → build → - # PR), honoring the repo-bound prompt the orchestrator built. + # task creation admits and persists a repo for such a workflow, and + # the orchestrator then assembles a repo-bound prompt (issue/PR fetch). + # Keying the repo-less branch on ``requires_repo`` ALONE would make the + # agent skip the clone while the prompt promised a repo — the two + # halves disagree. So take the repo-less path only when no repo was + # actually supplied; when a repo IS present it is hydrated as context + # exactly like a coding task (clone → build → PR), honoring the + # repo-bound prompt the orchestrator built. if not config.requires_repo and not config.repo_url: return _run_repoless_task( config=config, @@ -834,7 +1028,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # by Claude Code and the safety-net commit in post_hooks) WITHOUT # writing to any on-disk config. `--global` would clobber the real # ~/.gitconfig — harmless in the ephemeral container, but destructive - # when this pipeline runs on a developer workstation (#622). + # when this pipeline runs on a developer workstation. os.environ["GIT_AUTHOR_NAME"] = "bgagent" os.environ["GIT_AUTHOR_EMAIL"] = "bgagent@noreply.github.com" os.environ["GIT_COMMITTER_NAME"] = "bgagent" @@ -852,10 +1046,11 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # Acknowledge the task is picked up BEFORE the (potentially long) # 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. + # 👀 only *after* it would leave the issue looking dead for the whole + # phase (no reaction, comment, or state change for tens of minutes). + # 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,24 +1064,31 @@ 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 (👀 → ✅/❌). + # 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 (decompose-v1 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 - # (or legacy OAuth fallback). No-op for non-Jira tasks. - # Best-effort; failures are logged, never block. + # "Starting" comment on the Jira issue (REST shim — the Atlassian + # Remote MCP can't be used from a headless agent). No-op for + # non-Jira tasks. Best-effort; failures are logged, never block. comment_task_started( config.channel_source, config.channel_metadata, ) # 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. 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,7 +1098,9 @@ 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 + # failure comment. Before the Early-ACK move the 👀 didn't exist yet + # at this point, so a setup failure left the issue silently stuck (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) @@ -914,6 +1118,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 = [] @@ -1020,8 +1231,8 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: "task_cancelled_acknowledged", "Post-hooks skipped; terminal state already CANCELLED.", ) - # L4 item 1c: best-effort trace upload + conditional - # self-heal on the cancel path. ``write_terminal``'s + # Best-effort trace upload + conditional self-heal on the + # cancel path. ``write_terminal``'s # ConditionExpression rejects CANCELLED, so we cannot # persist ``trace_s3_uri`` atomically with the terminal # write — use ``write_trace_uri_conditional`` instead, @@ -1056,7 +1267,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # Resolve the post-hook gating inputs: read_only, the ensure_pr # strategy (create / push_resolve / resolve), and the verify steps' - # declared gates (#301) the workflow declares. + # declared gates the workflow declares. # # ``read_only`` comes from ``config`` — build_config already computed # it (with its own fail-soft fallback) and it drove Cedar during the @@ -1068,7 +1279,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # has already mutated / committed the tree, so a load failure here # must NOT strand the work as FAILED with no PR — it falls back to # the default "create" strategy + legacy regression-only gating and - # still opens the PR (PR review #296 finding #5). + # still opens the PR. from workflow import WorkflowValidationError, load_workflow workflow_read_only = config.read_only @@ -1089,35 +1300,172 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: ) ensure_pr_strategy = "create" + # Agent-native decompose: a REPO-FUL workflow whose primary terminal + # outcome is an ARTIFACT (coding/decompose-v1) clones the repo for + # context but produces a plan, not a PR. Skip the build/PR post-hooks; + # deliver the agent's result text (the plan JSON) as the artifact so + # the platform can read it and seed the sub-issues. + # + # 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 decompose (artifact) branch below + + # Clarify-before-spend: 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 (decompose emits JSON, 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: + # If the agent switched off the platform branch (it sometimes + # runs `git checkout -b ` and commits/opens its PR + # there), 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" (a false pass). Threaded into + # the verdict + error_message (build_ok=infra) so the platform + # reports a retryable infra fault, not "build failed" and not a + # bogus success. + 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 + # 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 — 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 would move the card to In Review + # on a red build, telling the board the work is ready for review + # when it isn't. 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 @@ -1140,7 +1488,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # Overall status: do not infer success from PR/build when the SDK never # emitted ResultMessage (agent_status=unknown) — that masks protocol gaps. # Gating honors each verify step's declared ``gate`` via the runner's - # gate_status (#301); an undeclared verify_lint never gates (legacy). + # gate_status; an undeclared verify_lint never gates (legacy). agent_status = agent_result.status build_ok = _apply_post_hook_gates( _workflow, @@ -1156,7 +1504,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 dependency graph. 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,10 +1553,11 @@ 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 - # here. Since issue #573 the deterministic fan-out plane + # here. The deterministic fan-out plane # (``cdk/src/handlers/fanout-task-events.ts`` ``dispatchToJira``) # owns the Jira final-status comment — it carries cost/turns/ # duration and, crucially, fires even if this agent crashes before @@ -1176,7 +1565,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # double-comment. The agent still posts the *start* comment # (``comment_task_started`` above) for in-flight progress. - # --trace trajectory S3 upload (design §10.1). Runs AFTER + # --trace trajectory S3 upload. Runs AFTER # post-hooks but BEFORE ``write_terminal`` so the resulting # ``trace_s3_uri`` can be persisted atomically with the # terminal-status transition. Fail-open: an S3 error does @@ -1186,6 +1575,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 +1643,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, + # A decompose (artifact) workflow carries the plan 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,13 +1697,12 @@ 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 - # 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 - # design intent. Fully wrapped in its own try/except so a - # trace upload failure cannot mask or replace the real - # exception (we re-raise ``e`` at the end). + # 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 + # design intent. Fully wrapped in its own try/except so a trace upload + # failure cannot mask or replace the real exception (we re-raise ``e`` + # at the end). crash_trace_s3_uri: str | None = None try: crash_trace_s3_uri = _maybe_upload_trace(config, trajectory, progress) @@ -1290,7 +1722,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: trace_s3_uri=crash_trace_s3_uri, # Still inside `with task_span()`, so the id is live — capture it # here too or FAILED tasks (the primary post-mortem case for the - # replay bundle, #515) persist otel_trace_id: null. + # replay bundle) persist otel_trace_id: null. otel_trace_id=current_otel_trace_id(), ) task_state.write_terminal(config.task_id, "FAILED", crash_result.model_dump()) @@ -1306,10 +1738,10 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: started_reaction_id=linear_eyes_reaction_id, ) # NOTE: no Jira failure comment here — the fan-out plane's - # ``dispatchToJira`` (issue #573) owns the Jira terminal comment - # and fires on the platform side even when this crash path runs, - # so posting here would double-comment. (Contrast the Linear ❌ - # reaction above, which the fan-out plane does not replicate.) + # ``dispatchToJira`` owns the Jira terminal comment and fires on the + # platform side even when this crash path runs, so posting here would + # double-comment. (Contrast the Linear ❌ reaction above, which the + # fan-out plane does not replicate.) raise @@ -1333,30 +1765,21 @@ 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 +#: orchestrator→agent field, forgot the other" no-op we want to catch — 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: ``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 @@ -1373,11 +1796,12 @@ def run_task_from_payload(payload: dict) -> dict: """Invoke :func:`run_task` from a full orchestrator payload 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``, - ``cedar_policies``, ``base_branch``/``merge_branches``, ``attachments``, etc. + orchestrator payload (via an S3 pointer, since the payload can exceed the + inline size limit). Previously the ECS boot command hand-listed a subset of + ``run_task`` kwargs and silently dropped the rest — most visibly + ``channel_source``/``channel_metadata`` (so ECS runs got no Linear/Jira + reactions or channel MCP), 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 silently dropped again: rename the aliased keys, filter to parameters @@ -1397,8 +1821,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 +1840,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/policy.py b/agent/src/policy.py index 3b399a568..057870db0 100644 --- a/agent/src/policy.py +++ b/agent/src/policy.py @@ -4,7 +4,7 @@ restrictions. See ``docs/design/CEDAR_HITL_GATES.md`` for the full design; short summary below. -**Three outcomes** (§2, §6.2). Each ``evaluate_tool_use`` call walks up to +**Three outcomes**. Each ``evaluate_tool_use`` call walks up to two Cedar evaluations interleaved with in-process caches: 1. Hard-deny Cedar eval (agent/policies/hard_deny.cedar + blueprint hard). @@ -14,27 +14,27 @@ patterns. 2.5 Recent-decision cache: same (tool_name, input_sha256) within 60s of a DENIED/TIMED_OUT outcome auto-denies. Session-scoped, cleared on - container restart (§12.8). + container restart. 3. Soft-deny Cedar eval (agent/policies/soft_deny.cedar + blueprint soft). Match → REQUIRE_APPROVAL with merged annotations; rule-scope allowlist match → ALLOW; no match → fall through to step 4. 4. Default ALLOW. -**Cedar-entity conventions** preserved from Phase 1: user-supplied values -(bash commands, file paths) use sentinel resource IDs (``Agent::File::file``, +**Cedar-entity conventions**: user-supplied values (bash commands, file +paths) use sentinel resource IDs (``Agent::File::file``, ``Agent::BashCommand::command``) with the real value in ``context.command`` / ``context.file_path``, because Cedar entity UIDs cannot contain arbitrary characters. -**Annotations** expected on every rule in hard_deny/soft_deny files -(§5.2): ``@rule_id`` (globally unique, kebab/snake_case), ``@tier`` +**Annotations** expected on every rule in hard_deny/soft_deny files: +``@rule_id`` (globally unique, kebab/snake_case), ``@tier`` ("hard"|"soft"), ``@approval_timeout_s`` (int seconds ≥ 30; soft-deny only), ``@severity`` ("low"|"medium"|"high"; soft-deny only), ``@category`` (free-form; UX grouping). Annotation recovery goes through cedarpy's ``policies_to_json_str()``; the round-trip contract is locked by ``tests/test_cedarpy_annotations_contract.py``. -**Fail-closed posture** (§13): any cedarpy exception during evaluation +**Fail-closed posture**: any cedarpy exception during evaluation returns ``Outcome.DENY`` with reason ``"fail-closed: "``. Invalid blueprint policies raise at ``PolicyEngine.__init__`` (task fails to start rather than running with broken rules). @@ -66,27 +66,50 @@ from pathlib import Path from typing import TYPE_CHECKING -from shared_constants import SHARED_CONSTANTS from shell import log if TYPE_CHECKING: from collections.abc import Callable, Iterable # --------------------------------------------------------------------------- -# Constants (§3, §5.2, §12.9) +# Constants # --------------------------------------------------------------------------- -WARN_TIMEOUT_S: int = 120 # IMPL-25: sub-120s emits WARN on blueprint load +WARN_TIMEOUT_S: int = 120 # a sub-120s approval timeout emits a WARN on blueprint load -_SHARED_CONSTANTS = SHARED_CONSTANTS +def _load_shared_constants() -> dict: + """Read ``contracts/constants.json`` (see ``contracts/constants.md``). + + Two candidate paths cover both the deployed image + (``/app/contracts/constants.json`` — Dockerfile copies ``contracts/`` + to ``/app/contracts``) and the local repo layout + (``/contracts/constants.json`` — for tests + dev). Fail-fast on + missing: a missing contract should crash import, not silently fall + back to literals that would re-introduce the drift the contract is + designed to prevent. + """ + here = Path(__file__).resolve() + candidates = [ + here.parent.parent / "contracts" / "constants.json", # /app/contracts/ + here.parent.parent.parent / "contracts" / "constants.json", # /contracts/ + ] + for path in candidates: + if path.is_file(): + return json.loads(path.read_text()) + raise FileNotFoundError( + "contracts/constants.json not found; checked: " + ", ".join(str(p) for p in candidates), + ) + + +_SHARED_CONSTANTS = _load_shared_constants() _AGC = _SHARED_CONSTANTS["approval_gate_cap"] -DEFAULT_APPROVAL_GATE_CAP: int = int(_AGC["default"]) # decision #13 default +DEFAULT_APPROVAL_GATE_CAP: int = int(_AGC["default"]) # default per-task approval-gate cap APPROVAL_GATE_CAP_MIN: int = int(_AGC["min"]) APPROVAL_GATE_CAP_MAX: int = int(_AGC["max"]) _ATS = _SHARED_CONSTANTS["approval_timeout_s"] -FLOOR_TIMEOUT_S: int = int(_ATS["min"]) # §6 decision #6: rejected below this at load -DEFAULT_TASK_TIMEOUT_S: int = int(_ATS["default"]) # §6 decision #6 default +FLOOR_TIMEOUT_S: int = int(_ATS["min"]) # approval timeouts below this are rejected at load +DEFAULT_TASK_TIMEOUT_S: int = int(_ATS["default"]) # default per-task approval timeout def _validate_constants() -> None: @@ -124,10 +147,10 @@ def _validate_constants() -> None: _validate_constants() -CACHE_MAX_ENTRIES: int = 50 # §12.9: decoupled from approvalGateCap -CACHE_TTL_S: float = 60.0 # §12.8 sliding-window TTL on DENIED/TIMED_OUT -POLICIES_MAX_BYTES: int = 64 * 1024 # finding #12: reject blueprints > 64 KB -APPROVAL_RATE_LIMIT: int = 20 # §12.9 per-container per-minute approval writes +CACHE_MAX_ENTRIES: int = 50 # cache bound, decoupled from approvalGateCap +CACHE_TTL_S: float = 60.0 # sliding-window TTL on DENIED/TIMED_OUT cache entries +POLICIES_MAX_BYTES: int = 64 * 1024 # reject blueprint policies larger than 64 KB +APPROVAL_RATE_LIMIT: int = 20 # per-container per-minute approval writes APPROVAL_RATE_WINDOW_S: float = 60.0 # sliding window paired with APPROVAL_RATE_LIMIT _SEVERITY_ORDER = {"low": 0, "medium": 1, "high": 2} @@ -135,8 +158,8 @@ def _validate_constants() -> None: _VALID_SEVERITIES = frozenset(_SEVERITY_ORDER) _VALID_TIERS = frozenset({"hard", "soft"}) -# Tool group membership (§6.4 decision #21). Resolves tool_group:file_write -# scope to {Write, Edit} at runtime. +# Tool group membership. Resolves tool_group:file_write scope to +# {Write, Edit} at runtime. TOOL_GROUPS: dict[str, frozenset[str]] = { "file_write": frozenset({"Write", "Edit"}), } @@ -147,7 +170,7 @@ def _validate_constants() -> None: # --------------------------------------------------------------------------- -# Outcome + PolicyDecision (§6.1) +# Outcome + PolicyDecision # --------------------------------------------------------------------------- @@ -169,12 +192,12 @@ class PolicyDecision: Fields beyond ``outcome`` + ``reason`` + ``duration_ms`` are populated only on ``REQUIRE_APPROVAL``. ``.allowed`` is the backward-compat shim - for Phase 1a/1b/2 callers that predate the three-outcome engine and - treat this as a simple allow/deny boolean. + for older callers that predate the three-outcome engine and treat this + as a simple allow/deny boolean. Not a dataclass — a custom ``__init__`` supports BOTH the new ``outcome=...``-keyed form and the legacy ``allowed=...``-keyed form - so Phase 1 tests keep working without caller changes. Instances are + so older tests keep working without caller changes. Instances are immutable by convention (no mutator methods); callers should treat them as read-only. """ @@ -194,7 +217,7 @@ def __init__( self, *, outcome: Outcome | None = None, - allowed: bool | None = None, # Legacy Phase 1 kwarg + allowed: bool | None = None, # legacy allow/deny kwarg (predates three-outcome) reason: str = "", timeout_s: int | None = None, severity: str | None = None, @@ -215,31 +238,31 @@ def __init__( self.severity = severity self.matching_rule_ids = matching_rule_ids self.duration_ms = duration_ms - # IMPL-23: populated only when Step 2.5 of evaluate_tool_use returns - # a cache-hit DENY. Contains the payload for the `policy_decision` - # milestone with `decision_source="recent_decision_cache"`; the hook - # forwards it to progress_writer.write_policy_decision_cached(). - # Observability-only — NOT part of __eq__/__hash__: two cache-hit - # decisions with different original_decision_ts values still - # represent the same deny outcome. + # Populated only when the recent-decision-cache step of + # evaluate_tool_use returns a cache-hit DENY. Contains the payload for + # the `policy_decision` milestone with + # `decision_source="recent_decision_cache"`; the hook forwards it to + # progress_writer.write_policy_decision_cached(). Observability-only — + # NOT part of __eq__/__hash__: two cache-hit decisions with different + # original_decision_ts values still represent the same deny outcome. self.cache_hit_metadata = cache_hit_metadata - # #251 (decision E): structured discriminator for environmental - # fail-closed denies (Cedar engine errored / unavailable) vs. - # intentional hard-denies and cache-driven denies. The hook branches - # on THIS flag — not a brittle ``reason.startswith("fail-closed:")`` - # string match — to decide whether to emit a ``policy_fail_closed`` - # blocker event. Set True ONLY at engine-error/unavailable deny sites; - # hard-deny and cache-deny leave it False. Like ``cache_hit_metadata``, - # NOT part of __eq__/__hash__ (it is derived from outcome+reason, and - # legacy equality-based tests predate the field). + # Structured discriminator for environmental fail-closed denies (Cedar + # engine errored / unavailable) vs. intentional hard-denies and + # cache-driven denies. The hook branches on THIS flag — not a brittle + # ``reason.startswith("fail-closed:")`` string match — to decide whether + # to emit a ``policy_fail_closed`` blocker event. Set True ONLY at + # engine-error/unavailable deny sites; hard-deny and cache-deny leave it + # False. Like ``cache_hit_metadata``, NOT part of __eq__/__hash__ (it is + # derived from outcome+reason, and legacy equality-based tests predate + # the field). self.fail_closed = fail_closed @property def allowed(self) -> bool: """True only when outcome == ALLOW. DENY and REQUIRE_APPROVAL both map to False so legacy ``if not decision.allowed: return deny`` callers - keep blocking soft-deny hits (preserving at-rest behavior until the - PreToolUse hook is extended to the three-outcome path in Chunk 3). + keep blocking soft-deny hits (preserving at-rest behavior for callers + that predate the three-outcome PreToolUse path). """ return self.outcome == Outcome.ALLOW @@ -315,7 +338,7 @@ def require_approval( # --------------------------------------------------------------------------- -# Allowlist (§6.4) +# Allowlist # --------------------------------------------------------------------------- @@ -326,8 +349,8 @@ class _CachedDecision: ``inserted_at`` is a monotonic timestamp used for TTL/LRU; it is NOT safe to surface in events (monotonic clocks aren't wall-clock and restart at container boot). ``original_decision_ts`` is the ISO-8601 - wall-clock string captured at record time so IMPL-23 cache-hit - events can report when the original decision landed. + wall-clock string captured at record time so cache-hit events can + report when the original decision landed. """ decision: str # "DENIED" | "TIMED_OUT" @@ -339,10 +362,10 @@ class _CachedDecision: class ApprovalAllowlist: """Runtime scope allowlist, seeded from ``initial_approvals`` at task start. - See §6.4. ``matches`` checks tool-scope fast paths (all_session, - tool_type, tool_group, bash_pattern, write_path); rule-scope matches - are checked POST soft-deny-eval in ``evaluate_tool_use`` because - rule_ids are not known until Cedar reports matching policies. + ``matches`` checks tool-scope fast paths (all_session, tool_type, + tool_group, bash_pattern, write_path); rule-scope matches are checked + POST soft-deny-eval in ``evaluate_tool_use`` because rule_ids are not + known until Cedar reports matching policies. """ def __init__(self, initial_scopes: list[str] | None = None) -> None: @@ -362,12 +385,11 @@ def add(self, scope: str) -> None: Whitespace around both the prefix and the value is stripped so ``"tool_type: Read"`` and ``" tool_type:Read "`` normalize to the same internal state; empty-after-strip values are rejected so - ``"tool_type:"`` fails loud (finding #6 from Chunk 2 review). - Case is preserved verbatim — ``"tool_type:read"`` will not match - the ``"Read"`` tool name at runtime. That's intentional (Cedar - `like` is case-sensitive) but the CLI surfaces a WARN on uppercase - ``write_path:`` globs to flag the dev-vs-prod fnmatch footgun - (§5.5 finding #15). + ``"tool_type:"`` fails loud. Case is preserved verbatim — + ``"tool_type:read"`` will not match the ``"Read"`` tool name at + runtime. That's intentional (Cedar `like` is case-sensitive) but + the CLI surfaces a WARN on uppercase ``write_path:`` globs to flag + the dev-vs-prod fnmatch footgun. """ normalized = scope.strip() if normalized == "all_session": @@ -410,7 +432,7 @@ def matches(self, tool_name: str, tool_input: dict) -> bool: return True if tool_name == "Bash": cmd = tool_input.get("command", "") - # fnmatch semantics documented as Cedar-`like` superset (§5.5). + # fnmatch semantics are a superset of Cedar's `like`. if any(fnmatch(cmd, pat) for pat in self._bash_patterns): return True if tool_name in ("Write", "Edit"): @@ -421,7 +443,7 @@ def matches(self, tool_name: str, tool_input: dict) -> bool: # --------------------------------------------------------------------------- -# Recent-decision cache (§6.2, §12.8, §12.9) +# Recent-decision cache # --------------------------------------------------------------------------- @@ -429,8 +451,8 @@ class RecentDecisionCache: """In-process LRU cache of recent DENIED/TIMED_OUT outcomes. Bounded at 50 entries (``CACHE_MAX_ENTRIES``) INDEPENDENT of the per-task - ``approvalGateCap`` (§12.9): a blueprint that raises the gate cap to 200 - does NOT get a larger cache. Two concerns, two bounds: cap = UX ceiling, + ``approvalGateCap``: a blueprint that raises the gate cap to 200 does NOT + get a larger cache. Two concerns, two bounds: cap = UX ceiling, cache = engine memory bound. TTL is 60s on each entry; ``get`` skips expired entries without eviction @@ -438,8 +460,8 @@ class RecentDecisionCache: DENIED/TIMED_OUT — NEVER on APPROVED (so a just-approved call does not auto-deny on the next identical invocation). - **Session-scoped**: cleared on container restart. Documented caveat in - §12.8 — not a bug. Persistent cache is §17.5 future work. + **Session-scoped**: cleared on container restart. This is a known + caveat, not a bug; a persistent cache is possible future work. """ def __init__( @@ -459,7 +481,7 @@ def __init__( # origin branch-b`` because both resolve to the same # ``force_push_any`` rule. Without this the agent can burn # through its max_turns budget hammering on variations the - # user has already said no to (observed in E2E Phase 4). + # user has already said no to (observed in end-to-end testing). self._rule_entries: OrderedDict[tuple[str, str], _CachedDecision] = OrderedDict() self._max_entries = max_entries self._ttl_s = ttl_s @@ -477,7 +499,7 @@ def record( ``original_decision_ts`` is the ISO-8601 wall-clock time of the original approval decision that seeded this cache entry. Surfaced - on subsequent cache-hit events (IMPL-23) so operators can correlate + on subsequent cache-hit events so operators can correlate cache-driven denies back to the originating gate. Falsy values (``None`` or empty string) fall back to "now" at record time, so legacy test callers and corrupted outcome rows keep working. @@ -569,7 +591,7 @@ def __len__(self) -> int: # --------------------------------------------------------------------------- -# Annotation handling (§5.2, §6.3, §12.4) +# Annotation handling # --------------------------------------------------------------------------- @@ -628,9 +650,9 @@ def _validate_tier(rules: list[_ParsedRule], expected_tier: str, source: str) -> Exception: ``base_permit`` is allowed without @tier ONLY when it's a ``permit`` effect — the neutral catch-all at the top of each tier. A misnamed ``forbid`` annotated ``@rule_id("base_permit")`` would - otherwise bypass validation entirely (silent-failure finding #7 from - Chunk 2 review); restricting the exemption to ``effect == "permit"`` - forces genuine forbid rules through the regular validation path. + otherwise bypass validation entirely; restricting the exemption to + ``effect == "permit"`` forces genuine forbid rules through the regular + validation path. """ seen_rule_ids: set[str] = set() for rule in rules: @@ -662,7 +684,7 @@ def _validate_tier(rules: list[_ParsedRule], expected_tier: str, source: str) -> f"floor {FLOOR_TIMEOUT_S}s" ) if rule.approval_timeout_s < WARN_TIMEOUT_S: - # IMPL-25: advisory WARN, not strict reject. + # Advisory WARN, not a strict reject. log( "WARN", f"{source}: rule {rule.rule_id!r} has " @@ -682,7 +704,7 @@ def _merge_annotations( matching_policy_ids: list[str], task_default_timeout_s: int, ) -> tuple[list[str], int, str]: - """Merge annotations across multiple matching soft-deny policies (§6.3). + """Merge annotations across multiple matching soft-deny policies. Timeout: min across rules (clamped by FLOOR_TIMEOUT_S). Severity: max. rule_ids preserved in order of match. If a matching rule has no @@ -742,7 +764,7 @@ def _sha256_tool_input(tool_input: dict) -> str: # --------------------------------------------------------------------------- -# PolicyEngine — the main three-outcome engine (§6.2) +# PolicyEngine — the main three-outcome engine # --------------------------------------------------------------------------- @@ -754,11 +776,11 @@ class PolicyEngine: disable-list validation), probes a test authorization to catch syntax errors early, and seeds the approval allowlist from ``initial_approvals``. - Legacy callers that pass ``extra_policies=[...]`` (Phase 1 shape) are + Legacy callers that pass ``extra_policies=[...]`` (the older shape) are supported in backward-compat mode: the extra text is appended to the - soft-deny tier WITHOUT strict annotation validation. New callers in - Chunks 3+ should use ``blueprint_hard_policies`` / ``blueprint_soft_policies`` - / ``blueprint_disable`` / ``initial_approvals`` / ``approval_gate_cap``. + soft-deny tier WITHOUT strict annotation validation. New callers should + use ``blueprint_hard_policies`` / ``blueprint_soft_policies`` / + ``blueprint_disable`` / ``initial_approvals`` / ``approval_gate_cap``. """ def __init__( @@ -782,7 +804,7 @@ def __init__( self._disabled = False self._task_default_timeout_s = task_default_timeout_s - # Bounds check on approval_gate_cap (decision #13). + # Bounds check on approval_gate_cap. if not APPROVAL_GATE_CAP_MIN <= approval_gate_cap <= APPROVAL_GATE_CAP_MAX: raise ValueError( f"approval_gate_cap must be in " @@ -797,27 +819,26 @@ def __init__( f"initial_approval_gate_count must be >= 0, got {initial_approval_gate_count}" ) - # §12.9 per-task gate counter + per-container sliding-window rate limit. + # Per-task gate counter + per-container sliding-window rate limit. # The counter is session-scoped within a container but seeded from the - # persisted TaskTable value (§13.6) so container restarts resume the - # cumulative gate budget instead of resetting to 0. The rate-limit - # window stays per-container by design (§13.6 finding #10 scenario). + # persisted TaskTable value so container restarts resume the cumulative + # gate budget instead of resetting to 0. The rate-limit window stays + # per-container by design. self._approval_gate_count: int = initial_approval_gate_count self._approvals_last_minute: deque[float] = deque() - # §6.5 queue consumed by ``_denial_between_turns_hook``. Each entry - # is ``{"request_id", "reason", "decided_at"}``; reason is already - # sanitized by DenyTaskFn (§12.6). + # Queue consumed by ``_denial_between_turns_hook``. Each entry is + # ``{"request_id", "reason", "decided_at"}``; reason is already + # sanitized upstream by the deny path. self._denial_injection_queue: list[dict] = [] - # IMPL-26: ``approval_ceiling_shrinking`` is emit-once per task. + # ``approval_ceiling_shrinking`` is emit-once per task. self._emitted_ceiling_shrinking: bool = False # ``task_type`` here is the workflow-derived Cedar principal identity # (config.policy_principal): new_task / pr_iteration / pr_review. - # Read-only enforcement no longer keys off this principal literal — - # since #248 Phase 2a it keys off the ``read_only`` context attribute - # (threaded below into every Cedar request), so the hard-deny Write/Edit - # rules fire for *any* read-only workflow, not just coding/pr-review. - # See ADR-014 addendum 2026-06-08. + # Read-only enforcement no longer keys off this principal literal — it + # keys off the ``read_only`` context attribute (threaded below into + # every Cedar request), so the hard-deny Write/Edit rules fire for *any* + # read-only workflow, not just coding/pr-review. See ADR-014. # Import cedarpy lazily so the module still loads in environments # without the native extension (tests can monkey-patch). @@ -858,10 +879,9 @@ def __init__( # annotation semantics on duplicate keys within a single policy # are implementation-defined (parse error in most versions), so # we REJECT legacy text that already declares @tier or @rule_id - # (finding #2 from Chunk 2 review) instead of silently picking - # one interpretation. Callers should migrate to - # blueprint_soft_policies / blueprint_hard_policies with fully - # annotated rules. + # instead of silently picking one interpretation. Callers should + # migrate to blueprint_soft_policies / blueprint_hard_policies with + # fully annotated rules. legacy_extra: list[str] = [] if extra_policies: for idx, policy_text in enumerate(extra_policies): @@ -879,8 +899,8 @@ def __init__( if legacy_extra: soft_text = soft_text + "\n" + "\n".join(legacy_extra) - # 64 KB cap on combined blueprint text (finding #12). Built-ins do - # not count against the cap — they are trusted platform content. + # 64 KB cap on combined blueprint text. Built-ins do not count + # against the cap — they are trusted platform content. blueprint_text = "".join(filter(None, [blueprint_hard_policies, blueprint_soft_policies])) if len(blueprint_text.encode("utf-8")) > POLICIES_MAX_BYTES: raise ValueError( @@ -905,8 +925,8 @@ def __init__( raise ValueError(f"soft-deny policy validation failed: {exc}") from exc # blueprint_disable: reject any entry that names a built-in hard-deny - # rule (finding #9, §5.1). Built-in hard rule IDs come from the - # original builtin_hard text, NOT the concatenated hard_text. + # rule. Built-in hard rule IDs come from the original builtin_hard + # text, NOT the concatenated hard_text. builtin_hard_rule_ids = { r.rule_id for r in _parse_policy_annotations(self._cedarpy, builtin_hard) @@ -916,7 +936,7 @@ def __init__( if disable_id in builtin_hard_rule_ids: raise ValueError( f"blueprint disable[{disable_id!r}]: cannot disable built-in " - f"hard-deny rule; hard-deny is absolute (§5.1, §12.5)" + f"hard-deny rule; hard-deny is absolute" ) # Disabled soft rules are filtered at evaluate_tool_use time: if a # soft-deny eval's matching rule_ids are ALL in the disable set, @@ -985,11 +1005,11 @@ def allowlist(self) -> ApprovalAllowlist: def recent_decisions(self) -> RecentDecisionCache: return self._cache - # ---- Approval-gate counters + denial queue (§6.5, §12.9) -------------- + # ---- Approval-gate counters + denial queue ---------------------------- @property def approval_gate_count(self) -> int: - """Session-scoped count of REQUIRE_APPROVAL gates emitted this task.""" + """Count of REQUIRE_APPROVAL gates emitted this task (session-scoped).""" return self._approval_gate_count def increment_approval_gate_count(self) -> None: @@ -1022,8 +1042,8 @@ def queue_denial_injection( ) -> None: """Append a denial-injection payload for ``_denial_between_turns_hook``. - Reason is expected to be pre-sanitized upstream (by ``DenyTaskFn``, - §12.6). The hook is responsible for XML-escaping at injection time. + Reason is expected to be pre-sanitized upstream by the deny path. The + hook is responsible for XML-escaping at injection time. """ self._denial_injection_queue.append( {"request_id": request_id, "reason": reason, "decided_at": decided_at} @@ -1036,7 +1056,7 @@ def drain_denial_injections(self) -> list[dict]: return out def mark_ceiling_shrinking_emitted(self) -> bool: - """Idempotency latch for ``approval_ceiling_shrinking`` (IMPL-26). + """Idempotency latch for ``approval_ceiling_shrinking``. Returns ``True`` the first time it is called (caller should emit the milestone) and ``False`` on every subsequent call. @@ -1160,7 +1180,7 @@ def evaluate_tool_use(self, tool_name: str, tool_input: dict) -> PolicyDecision: # Compute input_sha separately so a TypeError from json.dumps # surfaces with a distinct fail-closed reason instead of being - # mis-attributed to Cedar evaluation (finding #5 from review). + # mis-attributed to Cedar evaluation. try: input_sha = _sha256_tool_input(tool_input) except (TypeError, ValueError) as exc: @@ -1202,7 +1222,7 @@ def evaluate_tool_use(self, tool_name: str, tool_input: dict) -> PolicyDecision: # STEP 2.5 — Recent-decision cache. cached = self._cache.get(tool_name, input_sha) if cached is not None: - # IMPL-23: attach cache-hit metadata so the hook can emit a + # Attach cache-hit metadata so the hook can emit a # `policy_decision` milestone to TaskEventsTable. Keeps the # engine pure — policy.py never calls the progress writer. return PolicyDecision( @@ -1230,7 +1250,7 @@ def evaluate_tool_use(self, tool_name: str, tool_input: dict) -> PolicyDecision: all_matching_ids = _matching_rule_ids( self._soft_rules, soft_decision[1], tier_name="soft" ) - # Filter out blueprint-disabled rules (§5.1 `disable:` list). + # Filter out blueprint-disabled rules (the `disable:` list). # If ALL matches are disabled, the soft-deny hit is neutralized # and we fall through to default ALLOW. If some are disabled # but others remain, the surviving rules drive REQUIRE_APPROVAL. @@ -1249,7 +1269,7 @@ def evaluate_tool_use(self, tool_name: str, tool_input: dict) -> PolicyDecision: duration_ms=(time.monotonic() - start) * 1000, ) - # Rule-level recent-deny cache (§12.8 extension). + # Rule-level recent-deny cache. # If the user recently denied any of these rule_ids on # this tool, fast-deny without a new approval gate. # Catches semantic retries the input-hash cache misses @@ -1321,16 +1341,15 @@ def _eval_for_tool( """Run the appropriate Cedar eval(s) for a given tool + input. Returns the first deny decision + matching policy IDs, or None if - no eval at this tier matched anything. Mirrors the Phase 1 routing - so existing tests (invoke_tool sentinel for tool-type, write_file - for Write/Edit, execute_bash for Bash) keep working. + no eval at this tier matched anything. The routing (invoke_tool + sentinel for tool-type, write_file for Write/Edit, execute_bash for + Bash) keeps existing tests working. ``no_decision`` responses are logged at WARN — Cedar should always reach a definite allow/deny given the base_permit catch-all, so - no_decision means the catch-all is missing or malformed (finding - #9 from Chunk 2 review). Fall-through to subsequent action evals - continues either way; the log gives operators signal without - changing behavior. + no_decision means the catch-all is missing or malformed. Fall-through + to subsequent action evals continues either way; the log gives + operators signal without changing behavior. """ def _run(action: str, resource_type: str, resource_id: str, ctx: dict): @@ -1393,7 +1412,7 @@ def _matching_rule_ids( Logs WARN on any policy ID that doesn't resolve to a parsed rule — the condition indicates a state inconsistency (e.g. ``_hard_policies`` mutated without re-parsing) and was silently ignored in earlier - revisions (finding #3 from Chunk 2 review). + revisions. """ by_id = {r.policy_id: r for r in rules} resolved: list[str] = [] diff --git a/agent/src/post_hooks.py b/agent/src/post_hooks.py index 058a1e4c8..1f3546d66 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 (ABCA-815).", + ) + 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..d0f11df9d 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: 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 @@ -37,6 +41,43 @@ def build_system_prompt( ) system_prompt = system_prompt.replace("{setup_notes}", setup_notes) + # A revise-round decompose task carries the PRIOR run's repo_digest in + # channel_metadata (a NON-guardrail-screened channel — task_description is + # screened, this isn't). Inject it so the agent starts from the cached + # structural understanding instead of re-deriving it. Cache-key discipline: + # the prior run recorded the sha it cloned to (decompose_repo_digest_sha); if + # the repo has since moved, the agent is told the digest may be stale for + # changed areas and to re-verify there (drift handling is agent-side — the + # platform has no GitHub token to pre-check, by least-privilege design). + # Harmless no-op for a prompt without the placeholder or a first-round task + # with no prior digest. + system_prompt = system_prompt.replace( + "{prior_repo_digest}", + _render_prior_repo_digest(config, setup), + ) + # On a REVISION round the task carries the CURRENT breakdown (in the + # guardrail-screened task_description, as "Earlier proposed breakdown") plus + # the reviewer's requested change. Without explicit framing the decompose + # prompt reads as "plan this issue from scratch", so the agent re-derives from + # the issue text and silently reverts edits the reviewer had already accepted + # (a dropped node reappears, a reworded title snaps back). This directive — + # injected ONLY on a revision — reframes the task as EDIT-the-current-plan: + # apply only the requested change, keep everything else verbatim. It lives in + # the trusted system prompt (NOT the screened task_description, which can't + # carry imperatives without tripping the prompt-injection filter). Empty on + # the first round. NOTE: only the ESCALATION path reaches this agent now — + # most revises are applied deterministically in the webhook (interpret → edit + # the stored plan in code, no clone, no re-derive). + system_prompt = system_prompt.replace( + "{revision_directive}", + _render_revision_directive(config), + ) + # The sha the repo was cloned to, echoed into the plan JSON's + # ``repo_digest_sha`` so a later revise run can drift-check the cached digest. + # Empty when unknown (best-effort — the platform's sha-shape guard then just + # treats the digest as un-versioned). Harmless no-op without the placeholder. + system_prompt = system_prompt.replace("{repo_head_sha}", setup.head_sha_before or "") + # Inject memory context from orchestrator hydration memory_context_text = "(No previous knowledge available for this repository.)" if hydrated_context and hydrated_context.memory_context: @@ -84,7 +125,7 @@ def build_repoless_system_prompt( hydrated_context: HydratedContext | None, overrides: str, ) -> str: - """Assemble the system prompt for a repo-less workflow (#248 Phase 3). + """Assemble the system prompt for a repo-less workflow. The repo-bound :func:`build_system_prompt` requires a ``RepoSetup`` (branch, default_branch, setup notes); a repo-less task has none. This builds the @@ -112,6 +153,105 @@ def build_repoless_system_prompt( return system_prompt +def _render_prior_repo_digest(config: TaskConfig, setup: RepoSetup) -> str: + """Render the cached prior repo digest into the decompose prompt, or empty + string when there is none (first-round plan / non-decompose). + + A revise-round ``coding/decompose-v1`` task carries the previous run's + ``repo_digest`` + the sha it was built at in ``channel_metadata`` (keys + ``decompose_repo_digest`` / ``decompose_repo_digest_sha``). channel_metadata is + NOT guardrail-screened (unlike task_description), so a large structural blob + rides here safely. We inject it as reference DATA so the agent starts from the + prior structural understanding rather than re-deriving it — the exploration is + the expensive part of a revise round, and structural facts rarely change + between rounds. + + Drift: the prior run recorded the sha it cloned to. If the repo has since moved + (``head_sha_before`` differs), the digest may be stale for changed areas, so we + say so and tell the agent to re-verify there. The platform can't pre-check the + sha (no GitHub token, by least-privilege), so this agent-side compare IS the + drift handling. A blank prior sha (older task) is treated as "unknown → trust + but re-verify if anything looks off". + """ + cm = config.channel_metadata or {} + digest = (cm.get("decompose_repo_digest") or "").strip() + if not digest: + return "" # first round or no cached digest → the agent explores fresh + prior_sha = (cm.get("decompose_repo_digest_sha") or "").strip() + current_sha = (setup.head_sha_before or "").strip() + if prior_sha and current_sha and prior_sha != current_sha: + freshness = ( + "NOTE: the repository has changed since this digest was captured " + f"(digest @ {prior_sha[:8]}, repo now @ {current_sha[:8]}). Treat it as " + "a starting map, and re-verify any area your plan touches that may have " + "moved." + ) + else: + freshness = ( + "This reflects the repository at its current state; use it as your " + "starting map and only re-read files where this revision's feedback " + "requires deeper detail." + ) + return ( + "\n **Prior exploration of this repository (reuse this — don't re-derive " + "from scratch):**\n" + f" {freshness}\n" + " ```\n" + f" {digest}\n" + " ```" + ) + + +def _render_revision_directive(config: TaskConfig) -> str: + """Render the revise-in-place directive for a REVISION round, or empty string + for a first-time plan. + + A revise-round ``coding/decompose-v1`` task carries the CURRENT breakdown in + its (guardrail-screened) task_description as reference data, plus the + reviewer's requested change. Without explicit framing the decompose prompt + reads as "plan this issue from scratch" and the agent re-derives the whole + breakdown, silently reverting edits the reviewer had already accepted (dropped + nodes reappear, reworded titles snap back). + + This directive reframes the task as an EDIT of the current plan: start FROM it, + apply ONLY the requested change, keep every other sub-issue verbatim. It must + live in the trusted system prompt — the screened task_description can't carry + imperatives ("start from this plan and change only X") without tripping the + prompt-injection filter. Gated on ``decompose_revision_round`` (set by the + webhook only on a revise dispatch); a blank/zero/absent value → first round → + empty (no-op). + + NOTE: most revises never reach this agent — the webhook interprets the change + into structured edits and applies them to the stored plan DETERMINISTICALLY + (no clone, no re-derive). This directive only governs the ESCALATION path, + where a change genuinely needs the repo (feasibility / new scope). The + reviewer-facing "what changed" line is computed by the platform from the + before→after diff — the agent does NOT self-report it (an earlier cut had the + agent describe its own changes and it fabricated a justification for a + silently re-added dropped node). + """ + cm = config.channel_metadata or {} + raw_round = (cm.get("decompose_revision_round") or "").strip() + try: + revision_round = int(raw_round) + except ValueError: + revision_round = 0 + if revision_round <= 0: + return "" + return ( + "\n**This is a REVISION of an existing breakdown, not a fresh plan.** The " + "current breakdown and the reviewer's requested change are given below " + '(under "Earlier proposed breakdown" and "Requested changes"). Treat ' + "the current breakdown as your starting point: apply ONLY the change the " + "reviewer asked for and keep every other sub-issue EXACTLY as it is — same " + "titles, scopes, sizes, and dependencies — unless their change requires " + "touching it. Do NOT re-derive the whole breakdown from the issue text and " + "do NOT silently undo edits already reflected in the current breakdown " + "(e.g. a sub-issue that was dropped stays dropped; a reworded title stays " + "reworded).\n" + ) + + def _render_memory_context(hydrated_context: HydratedContext | None) -> str: """Render the memory-context block shared by repo-bound and repo-less prompts.""" if not (hydrated_context and hydrated_context.memory_context): @@ -132,49 +272,62 @@ 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 - MCP tools never load. Instructing the agent to use them just wastes turns. - Jira progress comments are posted out-of-band by ``jira_reactions`` through - the configured Forge app actor (or the legacy OAuth migration fallback), - not by the coding agent. + MCP tools never load. Jira progress comments are posted out-of-band by + ``jira_reactions`` (a REST shim wired into the pipeline), not by the agent. """ if config.channel_source != "linear": return "" + # A synthetic orchestration integration node 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..aef184031 100644 --- a/agent/src/prompts/__init__.py +++ b/agent/src/prompts/__init__.py @@ -1,35 +1,45 @@ """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 from .base import BASE_PROMPT +from .decompose import DECOMPOSE_WORKFLOW from .default_agent import DEFAULT_AGENT_PROMPT 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), + # Agent-native decomposition planning: clone the repo, decide whether to + # split the issue, draft a decomposition plan, and emit it as the artifact. + # Repo-ful (uses BASE_PROMPT's clone env) but opens no PR — the platform + # seeds sub-issues from the plan. + "coding/decompose-v1": BASE_PROMPT.replace("{workflow}", DECOMPOSE_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 +47,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/decompose.py b/agent/src/prompts/decompose.py new file mode 100644 index 000000000..59c3dd046 --- /dev/null +++ b/agent/src/prompts/decompose.py @@ -0,0 +1,126 @@ +"""Workflow section for ``coding/decompose-v1`` — decomposition planning run as +a real agent task, rather than as a blind two-call model invocation inside the +webhook Lambda. + +Unlike the other coding workflows this one does NOT change code or open a PR: it +clones the repo (so it plans with FULL repository context, which a planner +running inside the webhook Lambda never had — it lacked the repo, so it timed out +and planned blind), decides whether the issue should be decomposed, and if so +emits a structured decomposition-plan JSON as its final message. The platform's +existing write-back + seed machinery (idempotent issueCreate/issueRelationCreate) +consumes that plan and turns it into sub-issues; the agent does NOT create +sub-issues itself. + +Slots into BASE_PROMPT's {workflow} placeholder like the other coding variants, +so it inherits the clone/{repo_url}/{branch_name} environment — but its steps +override the PR-creation flow with a plan-emit deliverable (mirrors the +web_research "your final message IS the artifact" contract, for a repo-ful task). +""" + +DECOMPOSE_WORKFLOW = """\ +## Workflow — decomposition planning (no code changes, no PR) + +You are PLANNING how a fleet of autonomous coding agents should tackle this \ +issue in THIS repository. You will NOT write code, commit, or open a pull \ +request. Your only deliverable is a decomposition plan (see below). A separate \ +system turns your plan into Linear sub-issues and runs them — you do not create \ +sub-issues yourself. + +Your GOAL is to get the issue done as RELIABLY as possible — fewest errors — at \ +reasonable cost. Decompose ONLY when splitting the work genuinely serves that \ +goal; never split for its own sake, and never split one coherent feature across \ +technical layers (interface / logic / stored state / tests) — a lone layer has \ +no standalone value. +{revision_directive} +Follow these steps in order: + +1. **Understand the repository and the issue** + The repo `{repo_url}` is cloned at `{workspace}/{task_id}`. Read the README, \ +the project layout, relevant modules, docs (ROADMAP/ARCHITECTURE/guides), and \ +any existing tests — enough to judge what the issue actually entails HERE. A \ +short issue title may name work that is much larger (or smaller) than it looks \ +until you see the code. Use the repo; do not plan from the title alone. +{prior_repo_digest} + +2. **Decide: one cohesive unit, or a dependency-ordered breakdown?** + Decompose when the issue genuinely contains two or more separable units of \ +work that each stand on their own — a coherent change one agent could implement \ +and a reviewer could judge in isolation, each delivering an identifiable piece \ +of the goal. Keep it as ONE unit when the parts only make sense together, share \ +mutable state, or must change in lockstep. A dependency/build-order between \ +parts is NOT by itself a reason to merge them — ordering is handled for you. + - If the issue is too thin to tell what the separable pieces are and the \ +repository doesn't make them obvious either, say so (set ``decompose: false`` \ +with a ``reasoning`` that asks for more detail) rather than guessing. + - **The ``reasoning`` is shown verbatim to the person who filed the issue. \ +State only what you actually observed — e.g. "the description is empty" or "this \ +is a single one-line change". Do NOT assert specific facts you cannot verify from \ +what you read: never cite commit hashes, PR numbers, dates, or claims like "this \ +is already fixed" / "all known bugs are resolved". If you're declining because \ +there's nothing to act on, say that plainly and ask for the missing detail — \ +don't invent supporting specifics to justify the verdict. + - **Name your assumption when the target is ambiguous.** A thin request \ +("make the dashboard better", "improve the export") often maps to more than one \ +thing in the repo (e.g. an internal ops/monitoring dashboard vs. the product UI \ +users see). If you INFER which subject the issue means in order to plan, say so \ +in the FIRST sentence of the ``reasoning`` — name what you assumed and the \ +alternative you ruled out (e.g. "Assuming you mean the customer-facing analytics \ +page, not the internal CloudWatch ops dashboard — tell me if it's the latter.") \ +so the reviewer can correct a wrong target BEFORE approving, rather than \ +discovering it after work runs. When the target genuinely can't be inferred, \ +decline for more detail instead of guessing. + +3. **If decomposing, draft the breakdown** + - Propose only as many sub-issues as the work honestly has — fewer is \ +better. The project enforces a hard cap and will reject an over-large plan, so \ +keep the breakdown tight (a handful of units, not a long list). Each must be a \ +VERTICAL SLICE an agent can implement on its own. + - Give each a short imperative title and a one-paragraph scope, and a size: \ +"S" (small/isolated), "M" (medium), or "L" (large/involved). + - **Write the title and scope for the PERSON who filed the issue — who may \ +not be an engineer.** Say WHAT each piece delivers and WHY, in plain language, \ +before any implementation detail. Ground your plan in the repo, but do NOT lead \ +with jargon: avoid raw file paths, framework/tool names (e.g. "Vitest", \ +"serverless route"), or internal terms in the title, and keep them out of the \ +first sentence of the scope. A reviewer should understand what they're approving \ +without opening the codebase. It's fine to mention a specific file or tool later \ +in the scope when it genuinely aids a technical reader — just don't make it the \ +headline. + - Express dependencies with ``depends_on``: zero-based indices into your own \ +``sub_issues`` array of the sub-issues that must finish first. Independent \ +sub-issues have ``depends_on: []``. Keep the critical path as short as the work \ +honestly allows — parallelize independent work. Dependencies MUST form a DAG. + +4. **Emit the plan as your FINAL message** + Your final message IS the deliverable — it is captured as the task artifact \ +and consumed by the platform. Output ONLY a single JSON object (no prose, no \ +markdown fences) of this EXACT shape: + ``` + {{ + "decompose": true, + "reasoning": "one or two sentences explaining the verdict", + "sub_issues": [ + {{ "title": "string", "description": "string", "size": "S"|"M"|"L", "depends_on": [int, ...] }} + ], + "repo_digest": "a compact structural summary of what you learned exploring \ +this repo (see below)", + "repo_digest_sha": "{repo_head_sha}" + }} + ``` + When you decide NOT to decompose, output `{{ "decompose": false, "reasoning": "...", "sub_issues": [], "repo_digest": "...", "repo_digest_sha": "{repo_head_sha}" }}`. + Do not include any text before or after the JSON object. + Copy ``repo_digest_sha`` VERBATIM from here: ``{repo_head_sha}`` — it records \ +the exact repository revision your digest describes, so a later run can tell if \ +the repo has moved. + + **The ``repo_digest`` field** — this is a reusable, plain-text structural map \ +of the repository, so a LATER planning run on this same issue (when the reviewer \ +asks for a change) can start from your understanding instead of re-deriving it. \ +Capture, in a few compact lines: the project layout (top-level modules/dirs and \ +what each is for), the conventions a new feature follows here (where API/UI/tests \ +live, the pattern to imitate), any existing pattern this issue resembles, and the \ +concrete files/dirs a breakdown would touch. Write it as durable repo facts, NOT \ +as instructions or commentary about the plan — no second-person, no "you should". \ +Keep it tight (aim for well under ~1500 characters). It is data for the next run, \ +not part of the proposal the human sees. +""" 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..d06b7daac 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 (coding/decompose-v1) clones, reads/greps to plan, 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). The coding/decompose-v1 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..2f0d20800 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,21 @@ 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; decompose emits a plan +# artifact (its "ask for more detail" is `decompose:false` with reasoning); 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", + "coding/decompose-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 +494,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 +517,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 (decompose) — 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 +552,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 +597,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 +679,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..09e1f50c0 100644 --- a/agent/src/server.py +++ b/agent/src/server.py @@ -34,7 +34,7 @@ # doesn't forward container stdout to APPLICATION_LOGS, so a broken writer # is invisible except for this metric. Single counter = single alarm # surface — the trade-off is that the alarm can't distinguish which writer -# is broken (see Chunk 7c review notes). Defined BEFORE any function that +# is broken. Defined BEFORE any function that # references it (including ``_debug_cw`` / ``_warn_cw``) so the ordering is # import-time safe: a daemon thread spawned from a write-blocking function # can never race with module-level globals still being assigned. @@ -50,12 +50,7 @@ def _redact_cached_credentials(text: str) -> str: """Remove cached env secrets from debug text before stdout / CloudWatch.""" out = text - for env_key in ( - "GITHUB_TOKEN", - "LINEAR_API_TOKEN", - "JIRA_API_TOKEN", - "JIRA_APP_ACTOR_SHARED_SECRET", - ): + for env_key in ("GITHUB_TOKEN", "LINEAR_API_TOKEN", "JIRA_API_TOKEN"): secret = os.environ.get(env_key) or "" if len(secret) >= _MIN_REDACTABLE_SECRET_LEN: out = out.replace(secret, f"<{env_key}_REDACTED>") @@ -125,8 +120,8 @@ def _debug_cw_exc( def _warn_cw(msg: str, *, task_id: str | None = None) -> None: """Emit a server-level warning to stdout AND CloudWatch. - Chunk 7c — AgentCore doesn't forward container stdout to - APPLICATION_LOGS (see the ``_debug_cw`` comment block above), so + AgentCore doesn't forward container stdout to APPLICATION_LOGS (see + the ``_debug_cw`` comment block above), so warning ``print`` calls about malformed invocation payloads are effectively invisible in production. Route them through the same daemon-thread CloudWatch writer used by ``_debug_cw`` (writing to @@ -308,8 +303,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 +385,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 +433,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, ) @@ -459,7 +458,7 @@ def _run_task_background( try: # Propagate the correlation envelope into this thread's OTEL context # so spans are correlated with the AgentCore session and the platform - # identity in CloudWatch (#245). Runs whenever any field is present — + # identity in CloudWatch. Runs whenever any field is present — # session_id may be empty while user_id/repo are known. if session_id or user_id or repo_url: propagate_correlation_context(session_id, user_id=user_id, repo=repo_url or None) @@ -476,11 +475,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 +528,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,15 +541,21 @@ 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 + # Cedar human-in-the-loop approvals — per-task approval defaults + seeded + # allowlist. Both are forwarded verbatim to the pipeline; the engine # validates shape at construction time and raises on bad input. approval_timeout_s = inp.get("approval_timeout_s") initial_approvals = inp.get("initial_approvals") or [] - # Chunk 7: TaskTable-persisted ``approval_gate_count`` threaded by - # the orchestrator so a container restart (§13.6) resumes the - # cumulative gate budget instead of resetting to 0. Non-int payloads + # TaskTable-persisted ``approval_gate_count`` threaded by the orchestrator + # so a container restart resumes the cumulative gate budget instead of + # resetting to 0. Non-int payloads # coerce to 0 to keep the invocation path fail-open on a malformed # field; the downstream PolicyEngine rejects negatives loudly. raw_gate_count = inp.get("initial_approval_gate_count", 0) @@ -557,9 +569,9 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: task_id=inp.get("task_id"), ) initial_approval_gate_count = 0 - # Chunk 7b (§4 step 5, decision #13): per-task cap resolved by the - # submit path and persisted on the TaskRecord. Threaded so a - # blueprint-configured cap (or the default-50 frozen at submit) wins + # Per-task cap resolved by the submit path and persisted on the + # TaskRecord. Threaded so a blueprint-configured cap (or the + # default-50 frozen at submit) wins # over the PolicyEngine's compile-time fallback on restarts. A # malformed payload coerces to ``None`` so the engine can still # construct; its own bounds check would reject anything out-of-range. @@ -579,18 +591,17 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: channel_source = inp.get("channel_source", "") or "" channel_metadata = inp.get("channel_metadata") or {} attachments = inp.get("attachments") or [] - # ``trace`` is strictly opt-in (design §10.1). Accept only real - # booleans from the orchestrator — a string "false" would otherwise - # flip the flag on. + # ``trace`` is strictly opt-in. Accept only real booleans from the + # orchestrator — a string "false" would otherwise flip the flag on. trace = inp.get("trace") is True # Platform user_id (Cognito ``sub``). Only consumed when ``trace`` # is true (see ``TaskConfig.user_id``). String check defends against # a non-string payload — the agent writes this into an S3 key, so a # surprise ``None`` or int would blow up later at upload time. # When coercion fires, WARN loudly: a silent empty string combined - # with ``trace=True`` would make Stage 4's upload path skip the S3 - # write with zero observability, and a user-reported "my trace - # vanished" investigation would find nothing. + # with ``trace=True`` would make the upload path skip the S3 write + # with zero observability, and a user-reported "my trace vanished" + # investigation would find nothing. raw_user_id = inp.get("user_id", "") if isinstance(raw_user_id, str): user_id = raw_user_id @@ -605,9 +616,9 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: session_id = request.headers.get("x-amzn-bedrock-agentcore-runtime-session-id", "") - # Cedar HITL: stamp TASK_STARTED_AT so the PreToolUse hook's - # ``_remaining_maxlifetime_s`` (agent/src/hooks.py §6.5) has the - # real per-task clock to compute the maxLifetime ceiling. Without + # Stamp TASK_STARTED_AT so the PreToolUse hook's + # ``_remaining_maxlifetime_s`` (agent/src/hooks.py) has the real + # per-task clock to compute the maxLifetime ceiling. Without # this the hook's ceiling computation silently falls back to # "unknown, don't clip" (fail-open) and the user may be asked for # approval on a gate whose window will expire before they can @@ -636,11 +647,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, @@ -659,10 +674,11 @@ def _validate_required_params(params: dict) -> list[str]: """Check the minimum viable param set for the pipeline. Returns the list of missing field names (empty list = valid). A repo-bound - 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`` + workflow requires ``repo_url``; a repo-less workflow (``requires_repo:false``) + 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 +703,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/shared_constants.py b/agent/src/shared_constants.py index 95a2a81d0..e69de29bb 100644 --- a/agent/src/shared_constants.py +++ b/agent/src/shared_constants.py @@ -1,29 +0,0 @@ -"""Load cross-language constants shared by the agent runtime.""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any - - -def _load_shared_constants() -> dict[str, Any]: - """Read the contract in deployed and local repository layouts.""" - here = Path(__file__).resolve() - candidates = [ - here.parent.parent / "contracts" / "constants.json", - here.parent.parent.parent / "contracts" / "constants.json", - ] - for path in candidates: - if path.is_file(): - value = json.loads(path.read_text()) - if not isinstance(value, dict): - raise ValueError(f"{path} must contain a JSON object") - return value - raise FileNotFoundError( - "contracts/constants.json not found; checked: " - + ", ".join(str(path) for path in candidates), - ) - - -SHARED_CONSTANTS = _load_shared_constants() diff --git a/agent/src/shell.py b/agent/src/shell.py index 79411ed24..e0aa497b2 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,126 @@ def _surface_failure_lines(stdout: str) -> list[str]: return out or tail +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, proc.returncode or 0, "\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 +369,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 +402,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 +417,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 +438,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 +466,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/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..79a85704d 100644 --- a/agent/tests/test_config.py +++ b/agent/tests/test_config.py @@ -1,6 +1,5 @@ """Unit tests for config.py — build_config and constants.""" -import os from datetime import UTC from unittest.mock import MagicMock, patch @@ -9,7 +8,6 @@ from config import ( PR_WORKFLOW_IDS, build_config, - clear_jira_task_credentials, resolve_jira_oauth_token, resolve_linear_api_token, ) @@ -550,48 +548,6 @@ def test_returns_empty_when_secret_arn_missing(self, monkeypatch): assert resolve_jira_oauth_token() == "" mock_boto.assert_not_called() - def test_clear_jira_task_credentials_removes_oauth_and_app_actor_values( - self, - monkeypatch, - ): - for name in ( - "JIRA_API_TOKEN", - "JIRA_APP_ACTOR_CONFIGURED", - "JIRA_APP_ACTOR_PROXY_URL", - "JIRA_APP_ACTOR_SHARED_SECRET", - ): - monkeypatch.setenv(name, "stale") - - clear_jira_task_credentials() - - assert "JIRA_API_TOKEN" not in os.environ - assert "JIRA_APP_ACTOR_CONFIGURED" not in os.environ - assert "JIRA_APP_ACTOR_PROXY_URL" not in os.environ - assert "JIRA_APP_ACTOR_SHARED_SECRET" not in os.environ - - def test_metadata_bound_task_clears_stale_app_actor_when_secret_arn_missing( - self, - monkeypatch, - ): - """A malformed next task cannot inherit another tenant's Forge credentials.""" - monkeypatch.delenv("JIRA_OAUTH_SECRET_ARN", raising=False) - monkeypatch.setenv("JIRA_API_TOKEN", "previous-human-token") - monkeypatch.setenv("JIRA_APP_ACTOR_CONFIGURED", "1") - monkeypatch.setenv( - "JIRA_APP_ACTOR_PROXY_URL", - "https://previous.webtrigger.atlassian.app/public/trigger", - ) - monkeypatch.setenv("JIRA_APP_ACTOR_SHARED_SECRET", "s" * 64) - - with patch("boto3.client") as mock_boto: - assert resolve_jira_oauth_token({"jira_cloud_id": "next-tenant"}) == "" - mock_boto.assert_not_called() - - assert "JIRA_API_TOKEN" not in os.environ - assert "JIRA_APP_ACTOR_CONFIGURED" not in os.environ - assert "JIRA_APP_ACTOR_PROXY_URL" not in os.environ - assert "JIRA_APP_ACTOR_SHARED_SECRET" not in os.environ - def test_returns_empty_when_region_missing(self, monkeypatch): """No region → can't construct boto3 client → empty + WARN, no SDK call.""" monkeypatch.delenv("JIRA_API_TOKEN", raising=False) @@ -636,69 +592,6 @@ def test_resolves_from_secrets_manager_and_caches_in_env(self, monkeypatch): # Reset for other tests. monkeypatch.delenv("JIRA_API_TOKEN", raising=False) - def test_resolves_forge_app_actor_even_when_oauth_token_is_expiring(self, monkeypatch): - """Forge credentials are independent of the human 3LO token lifetime.""" - import json - from datetime import datetime, timedelta - - monkeypatch.delenv("JIRA_API_TOKEN", raising=False) - monkeypatch.setenv("AWS_REGION", "us-east-1") - soon = (datetime.now(UTC) + timedelta(seconds=10)).isoformat().replace("+00:00", "Z") - token_payload = { - "access_token": "expired-human-token", - "refresh_token": "refresh", - "expires_at": soon, - "scope": "read:jira-work write:jira-work", - "client_id": "cid", - "client_secret": "csec", - "cloud_id": "cloud-uuid", - "site_url": "https://acme.atlassian.net", - "installed_at": "2026-05-19T08:00:00Z", - "updated_at": "2026-05-19T08:00:00Z", - "installed_by_platform_user_id": "cog-sub", - "app_actor_proxy_url": ("https://install.webtrigger.atlassian.app/public/trigger"), - "app_actor_shared_secret": "s" * 64, - "app_actor_configured_at": "2026-07-23T00:00:00Z", - } - mock_sm = MagicMock() - mock_sm.get_secret_value.return_value = {"SecretString": json.dumps(token_payload)} - - with patch("boto3.client", return_value=mock_sm): - assert resolve_jira_oauth_token({"jira_oauth_secret_arn": "arn:test"}) == "" - - assert os.environ["JIRA_APP_ACTOR_CONFIGURED"] == "1" - assert os.environ["JIRA_APP_ACTOR_PROXY_URL"].endswith("/public/trigger") - assert os.environ["JIRA_APP_ACTOR_SHARED_SECRET"] == "s" * 64 - monkeypatch.delenv("JIRA_APP_ACTOR_CONFIGURED", raising=False) - monkeypatch.delenv("JIRA_APP_ACTOR_PROXY_URL", raising=False) - monkeypatch.delenv("JIRA_APP_ACTOR_SHARED_SECRET", raising=False) - - def test_metadata_only_app_actor_configuration_fails_closed(self, monkeypatch): - import json - - monkeypatch.setenv("AWS_REGION", "us-east-1") - token_payload = { - "access_token": "human-token", - "refresh_token": "refresh", - "expires_at": "2099-01-01T00:00:00Z", - "app_actor_account_id": "app-account-1", - "app_actor_proxy_url": ["not", "a", "string"], - "app_actor_shared_secret": 123, - } - mock_sm = MagicMock() - mock_sm.get_secret_value.return_value = {"SecretString": json.dumps(token_payload)} - - with patch("boto3.client", return_value=mock_sm): - assert ( - resolve_jira_oauth_token({"jira_oauth_secret_arn": "arn:metadata-only"}) - == "human-token" - ) - - assert os.environ["JIRA_APP_ACTOR_CONFIGURED"] == "1" - assert "JIRA_APP_ACTOR_PROXY_URL" not in os.environ - assert "JIRA_APP_ACTOR_SHARED_SECRET" not in os.environ - monkeypatch.delenv("JIRA_APP_ACTOR_CONFIGURED", raising=False) - def test_returns_empty_on_secrets_manager_access_denied(self, monkeypatch): """ClientError surfaces as empty + ERROR log, never crashes the agent.""" from botocore.exceptions import ClientError @@ -713,21 +606,6 @@ def test_returns_empty_on_secrets_manager_access_denied(self, monkeypatch): with patch("boto3.client", return_value=mock_sm): assert resolve_jira_oauth_token({"jira_oauth_secret_arn": "arn:test"}) == "" - @pytest.mark.parametrize("payload", [[], 5, "scalar", True]) - def test_returns_empty_on_valid_non_object_secret_json(self, monkeypatch, payload): - """Valid JSON scalars/lists must not escape as AttributeError.""" - import json - - monkeypatch.setenv("AWS_REGION", "us-east-1") - mock_sm = MagicMock() - mock_sm.get_secret_value.return_value = {"SecretString": json.dumps(payload)} - - with patch("boto3.client", return_value=mock_sm): - assert resolve_jira_oauth_token({"jira_oauth_secret_arn": "arn:non-object"}) == "" - - assert "JIRA_API_TOKEN" not in os.environ - assert "JIRA_APP_ACTOR_CONFIGURED" not in os.environ - def test_falls_back_to_env_var_when_channel_metadata_omits_arn(self, monkeypatch): """JIRA_OAUTH_SECRET_ARN env var is the back-compat fallback.""" from datetime import datetime, timedelta 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..33c42a59f 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,122 @@ def test_denies_direct_non_dict_tool_input(self): ) +class TestSelfRecloneGuard: + """ABCA-815 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): + # ABCA-856 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_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 diff --git a/agent/tests/test_jira_reactions.py b/agent/tests/test_jira_reactions.py index 65a1eac67..183e87b27 100644 --- a/agent/tests/test_jira_reactions.py +++ b/agent/tests/test_jira_reactions.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os from unittest.mock import MagicMock, patch import pytest @@ -65,12 +64,6 @@ def _reset_circuit(): Autouse so it applies to module-level functions AND methods inside test classes (a module-level ``setup_function`` does not run for class methods). """ - for key in ( - "JIRA_APP_ACTOR_CONFIGURED", - "JIRA_APP_ACTOR_PROXY_URL", - "JIRA_APP_ACTOR_SHARED_SECRET", - ): - os.environ.pop(key, None) jira_reactions._reset_state_for_testing() yield jira_reactions._reset_state_for_testing() @@ -98,64 +91,6 @@ def test_missing_issue_key_is_noop(self, monkeypatch): class TestStartComment: - def test_uses_signed_forge_app_actor_when_configured(self, monkeypatch): - import hashlib - import hmac - import json - - monkeypatch.setenv("JIRA_API_TOKEN", "human-oauth-token") - monkeypatch.setenv("JIRA_APP_ACTOR_CONFIGURED", "1") - monkeypatch.setenv( - "JIRA_APP_ACTOR_PROXY_URL", - "https://install.webtrigger.atlassian.app/public/trigger", - ) - monkeypatch.setenv("JIRA_APP_ACTOR_SHARED_SECRET", "s" * 64) - with patch("jira_reactions.requests.post", return_value=_resp(201)) as post: - comment_task_started("jira", JIRA_META) - - assert post.call_args[0][0].endswith(".webtrigger.atlassian.app/public/trigger") - assert "json" not in post.call_args.kwargs - headers = post.call_args.kwargs["headers"] - assert "Authorization" not in headers - raw_body = post.call_args.kwargs["data"].decode() - payload = json.loads(raw_body) - assert payload["operation"] == "comment" - assert payload["issue_key"] == "KAN-1" - timestamp = headers["X-Bgagent-Timestamp"] - expected = hmac.new( - ("s" * 64).encode(), - f"{timestamp}.{raw_body}".encode(), - hashlib.sha256, - ).hexdigest() - assert headers["X-Bgagent-Signature"] == f"sha256={expected}" - - def test_invalid_configured_app_actor_never_falls_back_to_oauth(self, monkeypatch): - monkeypatch.setenv("JIRA_API_TOKEN", "human-oauth-token") - monkeypatch.setenv("JIRA_APP_ACTOR_CONFIGURED", "1") - monkeypatch.setenv("JIRA_APP_ACTOR_PROXY_URL", "https://attacker.example/proxy") - monkeypatch.setenv("JIRA_APP_ACTOR_SHARED_SECRET", "s" * 64) - with patch("jira_reactions.requests.post") as post: - comment_task_started("jira", JIRA_META) - post.assert_not_called() - - def test_logs_bounded_proxy_error_code_for_permanent_failure(self, monkeypatch, capfd): - monkeypatch.setenv("JIRA_APP_ACTOR_CONFIGURED", "1") - monkeypatch.setenv( - "JIRA_APP_ACTOR_PROXY_URL", - "https://install.webtrigger.atlassian.app/public/trigger", - ) - monkeypatch.setenv("JIRA_APP_ACTOR_SHARED_SECRET", "s" * 64) - with patch( - "jira_reactions.requests.post", - return_value=_resp(401, '{"error":"invalid_signature","detail":"do-not-log"}'), - ): - comment_task_started("jira", JIRA_META) - - output = capfd.readouterr().out - assert "error_id=JIRA_APP_ACTOR_PROXY_REJECTED" in output - assert "proxy_error=invalid_signature" in output - assert "do-not-log" not in output - def test_posts_adf_comment_to_correct_url(self, monkeypatch): monkeypatch.setenv("JIRA_API_TOKEN", "jira_at") with patch("jira_reactions.requests.post", return_value=_resp(201)) as post: @@ -230,23 +165,6 @@ def test_2xx_resets_failure_counter(self, monkeypatch): assert post.call_count == 4 assert jira_reactions._auth_circuit_open is False - def test_open_message_calls_out_forge_secret_drift(self, monkeypatch, capfd): - monkeypatch.setenv("JIRA_APP_ACTOR_CONFIGURED", "1") - monkeypatch.setenv( - "JIRA_APP_ACTOR_PROXY_URL", - "https://install.webtrigger.atlassian.app/public/trigger", - ) - monkeypatch.setenv("JIRA_APP_ACTOR_SHARED_SECRET", "s" * 64) - with patch( - "jira_reactions.requests.post", - return_value=_resp(401, '{"error":"invalid_signature"}'), - ): - for _ in range(jira_reactions._AUTH_FAILURE_THRESHOLD): - comment_task_started("jira", JIRA_META) - - output = capfd.readouterr().out - assert "BGAGENT_PROXY_SECRET matches JIRA_APP_ACTOR_SHARED_SECRET" in output - class TestTransitionChannelGate: def test_non_jira_source_is_noop(self, monkeypatch): @@ -275,32 +193,6 @@ def test_skips_when_token_missing(self, monkeypatch): class TestStartTransitionSelection: - def test_uses_app_actor_for_transition_read_and_write(self, monkeypatch): - import json - - monkeypatch.setenv("JIRA_APP_ACTOR_CONFIGURED", "1") - monkeypatch.setenv( - "JIRA_APP_ACTOR_PROXY_URL", - "https://install.webtrigger.atlassian.app/public/trigger", - ) - monkeypatch.setenv("JIRA_APP_ACTOR_SHARED_SECRET", "s" * 64) - issue = _issue_resp(_transition("21", "In Progress", category="indeterminate")) - with ( - patch("jira_reactions.requests.get") as get, - patch( - "jira_reactions.requests.post", - side_effect=[issue, _resp(204)], - ) as post, - ): - transition_task_started("jira", JIRA_META) - - get.assert_not_called() - assert post.call_count == 2 - operations = [ - json.loads(call.kwargs["data"].decode())["operation"] for call in post.call_args_list - ] - assert operations == ["get_transitions", "transition"] - def test_prefers_in_progress_by_name_over_blocked(self, monkeypatch): """#605: both Blocked and In Progress are `indeterminate`; a name match must land on In Progress regardless of list order.""" diff --git a/agent/tests/test_linear_reactions.py b/agent/tests/test_linear_reactions.py index 9b47f9ce6..0c4124e8e 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,136 @@ 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: + """PM-3: 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..2d7dfc357 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 + # #247 A4: defaults for stacked-child fields. + assert config.base_branch is None + assert config.merge_branches == [] + + def test_a4_stacked_child_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_pipeline.py b/agent/tests/test_pipeline.py index 58644fa18..0cf728dc3 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,76 @@ 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_decompose_workflow_delivers_plan_artifact_and_skips_pr( + self, + _mock_task_state, + mock_task_span, + mock_setup_repo, + _mock_discover, + _mock_build_prompt, + mock_run_agent, + monkeypatch, + ): + # #299 agent-native decompose: coding/decompose-v1 is REPO-FUL (clones for + # context) but its terminal outcome is an ARTIFACT (the plan JSON), not a + # PR. It must take the repo-bound path (clone), then deliver the plan as an + # artifact and SKIP the build/PR post-hooks entirely. + 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, + ) + plan_json = '{"decompose": true, "reasoning": "two features", "sub_issues": []}' + + 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=plan_json + ) + + mock_run_agent.side_effect = fake_run_agent + mock_task_span.return_value = self._mock_span() + + with ( + patch( + "pipeline._deliver_plan_artifact", + return_value="s3://artifacts-bkt/artifacts/decompose-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="decompose-1", + resolved_workflow={"id": "coding/decompose-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/decompose-1/result.md" + class TestChainPriorAgentError: def test_none_agent_result_returns_exception_only(self): @@ -605,6 +676,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): + # User 2026-06-29: 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 +853,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 +876,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 +1053,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"), @@ -921,8 +1137,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 +1205,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 +1272,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 +1340,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", @@ -1197,8 +1413,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 +1483,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 +1554,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 +1619,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 ABCA-815 no-deliverable 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 +1950,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 +2028,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"), @@ -1832,34 +2052,6 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= mock_task_state.write_terminal.assert_called() -class TestJiraCredentialIsolation: - @pytest.mark.parametrize("channel_source", ["", "linear", "jira"]) - def test_run_task_scrubs_prior_jira_credentials_before_building_config( - self, - monkeypatch, - channel_source, - ): - """A warm Jira task cannot expose credentials to the next task.""" - credential_names = ( - "JIRA_API_TOKEN", - "JIRA_APP_ACTOR_CONFIGURED", - "JIRA_APP_ACTOR_PROXY_URL", - "JIRA_APP_ACTOR_SHARED_SECRET", - ) - for name in credential_names: - monkeypatch.setenv(name, "tenant-x-secret") - - def stop_after_scrub(**_kwargs): - assert all(name not in os.environ for name in credential_names) - raise RuntimeError("stop after credential scrub") - - with patch("pipeline.build_config", side_effect=stop_after_scrub): - from pipeline import run_task - - with pytest.raises(RuntimeError, match="stop after credential scrub"): - run_task(channel_source=channel_source) - - class TestEarlyAckOrdering: """#616 review N4 — the early-ACK reorder must hold under future edits. @@ -1919,8 +2111,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..e8bbd90a7 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): + # ABCA-659 #2: 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,97 @@ def test_error_status_preserves_bedrock_entitlement_message(self): assert "not available" in err +class TestDeliveryGate: + """ABCA-815: a create-strategy new-work task that reported success but + shipped NOTHING (no PR AND no commit) must be failed loudly, not COMPLETED. + + 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 ABCA-815 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): + # decompose 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")) 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..a6367791e 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,189 @@ def responder(cmd): assert "Resolves #55" in body assert "**PASS**" in body # build passed assert "**FAIL**" in body # lint failed + + +class TestReconcileAgentBranch: + """ABCA-815 root cause: 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 live ABCA-815 case). + 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). Live-caught on the #247 chain: 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..03c940494 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 decompose 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/decompose-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): + # #247 UX.16: the synthetic orchestration integration node 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): + # A6/#299: 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 @@ -272,3 +351,122 @@ def test_all_vectors_match(self, vectors): for v in vectors: actual = hashlib.sha256(v["input"].encode("utf-8")).hexdigest() assert actual == v["sha256"], f"Hash mismatch for: {v['note']}" + + +class TestDecomposePriorRepoDigest: + """#299 plan-mode T2 — the warm-digest injection into the decompose prompt.""" + + def _setup(self, head_sha: str = "a1b2c3d4e5f6a7b8"): + from models import RepoSetup + + return RepoSetup(repo_dir="/w/repo", branch="feat/x", head_sha_before=head_sha) + + def _decompose_config(self, channel_metadata=None) -> TaskConfig: + return _config( + task_id="t-1", + resolved_workflow={"id": "coding/decompose-v1", "version": "1.0.0"}, + channel_source="linear", + channel_metadata=channel_metadata or {}, + ) + + def test_round0_no_prior_digest_leaves_placeholder_empty(self): + from prompt_builder import build_system_prompt + + prompt = build_system_prompt(self._decompose_config(), self._setup(), None, "") + # The placeholder is always substituted (never leaks a literal {token}). + assert "{prior_repo_digest}" not in prompt + assert "{repo_head_sha}" not in prompt + # Round 0: no "prior exploration" block. + assert "Prior exploration of this repository" not in prompt + + def test_revise_injects_prior_digest_and_current_sha_echo(self): + from prompt_builder import build_system_prompt + + cfg = self._decompose_config( + { + "decompose_repo_digest": "modules: api/, ui/; tests in test/", + "decompose_repo_digest_sha": "a1b2c3d4e5f6a7b8", + } + ) + prompt = build_system_prompt(cfg, self._setup("a1b2c3d4e5f6a7b8"), None, "") + assert "Prior exploration of this repository" in prompt + assert "modules: api/, ui/; tests in test/" in prompt + # Same sha → the "current state" freshness note, not the drift warning. + assert "current state" in prompt + assert "has changed since this digest" not in prompt + # The sha is echoed for the agent to copy into repo_digest_sha. + assert "a1b2c3d4e5f6a7b8" in prompt + + def test_drift_note_when_repo_moved(self): + from prompt_builder import build_system_prompt + + cfg = self._decompose_config( + { + "decompose_repo_digest": "modules: api/, ui/", + "decompose_repo_digest_sha": "0000000aaaaaaaaa", # prior sha + } + ) + # Repo now at a DIFFERENT sha → the agent is warned to re-verify. + prompt = build_system_prompt(cfg, self._setup("ffffffff11111111"), None, "") + assert "has changed since this digest" in prompt + assert "re-verify" in prompt + + +class TestDecomposeRevisionDirective: + """#299 BLOCKER-1 — the revise-in-place directive injected on a REVISION round. + + Without it the decompose prompt reads as "plan from scratch" and the agent + silently reverts edits the reviewer already accepted; with it the agent is + told to EDIT the current plan (apply only the requested change, keep the rest) + and report the diff in ``change_summary``. + """ + + def _setup(self, head_sha: str = "a1b2c3d4e5f6a7b8"): + from models import RepoSetup + + return RepoSetup(repo_dir="/w/repo", branch="feat/x", head_sha_before=head_sha) + + def _decompose_config(self, channel_metadata=None) -> TaskConfig: + return _config( + task_id="t-1", + resolved_workflow={"id": "coding/decompose-v1", "version": "1.0.0"}, + channel_source="linear", + channel_metadata=channel_metadata or {}, + ) + + def test_round0_no_revision_directive(self): + from prompt_builder import build_system_prompt + + prompt = build_system_prompt(self._decompose_config(), self._setup(), None, "") + # The placeholder is always substituted (never leaks a literal {token}). + assert "{revision_directive}" not in prompt + # Round 0: no "this is a REVISION" framing. + assert "This is a REVISION" not in prompt + + def test_revision_round_injects_edit_in_place_directive(self): + from prompt_builder import build_system_prompt + + cfg = self._decompose_config({"decompose_revision_round": "1"}) + prompt = build_system_prompt(cfg, self._setup(), None, "") + assert "This is a REVISION" in prompt + # It tells the agent to preserve untouched sub-issues and not re-derive. + assert "keep every other sub-issue" in prompt + assert "do NOT silently undo edits" in prompt.replace("\n", " ") + + def test_zero_or_garbage_revision_round_is_treated_as_round0(self): + from prompt_builder import build_system_prompt + + for raw in ("0", "", "not-a-number"): + cfg = self._decompose_config({"decompose_revision_round": raw}) + prompt = build_system_prompt(cfg, self._setup(), None, "") + assert "This is a REVISION" not in prompt, f"round={raw!r} should be round-0" + + def test_change_summary_field_retired_from_plan_shape(self): + from prompt_builder import build_system_prompt + + # #299 BLOCKER-1 round 2: the agent-authored change_summary was RETIRED + # (it fabricated a justification for a re-added dropped node). The "what + # changed" line is now computed by the platform from the before→after diff, + # so the emit-step JSON shape must NOT ask for change_summary anymore. + prompt = build_system_prompt(self._decompose_config(), self._setup(), None, "") + assert '"change_summary"' not in prompt diff --git a/agent/tests/test_repo.py b/agent/tests/test_repo.py index a217808ed..69d335a32 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) + # ABCA-815: 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): + # #299 plan-mode T2: a NON-PR workflow (e.g. coding/decompose-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 that decompose 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 TestDiamondBaseBranchF1: + """#247 F1 (DE-stress 2026-07-24): 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: + """#299 ECS_RIGHTSIZED_PLANNING: a read_only workflow (coding/decompose-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: + """ABCA-659 Bug B: 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 661/662 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) + # ABCA-815: 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) + # ABCA-815: 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) + # ABCA-815: 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): + # ABCA-662 root cause: the pre-agent 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) + # ABCA-815: 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) + # ABCA-815: 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: + """ABCA-662 follow-up: `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 #247 A4 stacking: 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) + # ABCA-815: 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) + # ABCA-815: 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) + # ABCA-815: 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: + """ABCA-815: 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..d81e65033 100644 --- a/agent/tests/test_run_task_from_payload.py +++ b/agent/tests/test_run_task_from_payload.py @@ -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" @@ -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..63f4fa15f 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 == [] + # #305 A6: restack 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( { @@ -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 #247 A4 regression + 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_a4_base_branch_and_merge_branches_extracted_and_accepted(self): + # The specific A4 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_a4_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..ac864e29d 100644 --- a/agent/tests/test_shell.py +++ b/agent/tests/test_shell.py @@ -318,3 +318,53 @@ 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 — ABCA-662).""" + + 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) diff --git a/agent/tests/test_verify_commands.py b/agent/tests/test_verify_commands.py new file mode 100644 index 000000000..6b3bdd1cd --- /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): + # ABCA-662 follow-up: 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. (Live-caught: 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 (user 2026-06-29): 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): + # K8: 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): + # ABCA-659 #2: 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): + # ABCA-691 live regression: 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/decompose-v1.yaml b/agent/workflows/coding/decompose-v1.yaml new file mode 100644 index 000000000..33a533e0b --- /dev/null +++ b/agent/workflows/coding/decompose-v1.yaml @@ -0,0 +1,59 @@ +# #299 Mode B agent-native planning (replaces the webhook Lambda's blind +# two-call Bedrock planner). Clone the repo, plan a decomposition with FULL +# repository context, and deliver the plan JSON as an artifact — no code +# changes, no PR. The platform reads the artifact and seeds Linear sub-issues +# from it (idempotent write-back → Mode A), preserving the :decompose approval +# gate. Structurally a `pr-review`-shaped read-only clone task, but its terminal +# outcome is an ARTIFACT (the plan) rather than a PR (mirrors web-research). +# +# Root-causes ABCA-490 (30s Lambda ceiling killed the planner mid-call) and +# ABCA-492 (planner was blind to the repo): planning now runs on the tunable +# agent substrate inside a real clone, with turns instead of a single 30s call. +id: coding/decompose-v1 +version: 1.0.0 +domain: coding +description: >- + Plan how to decompose an issue into dependency-ordered sub-issues, using full + repository context. Deliverable is a decomposition-plan artifact; never + mutates the repo and never opens a PR — the platform seeds the sub-issues. +guidance: >- + Platform-issued by the Linear webhook on a `:decompose` / `:auto` label. Not a + user-facing workflow_ref. +requires_repo: true +read_only: true +prompt: + template: registry://prompt/coding-decompose-workflow + placeholders: + - repo_url + - task_id + - workspace + - branch_name + - default_branch + - max_turns + - setup_notes + - memory_context +hydration: + sources: [issue, memory, task_description] +agent_config: + tier: read-only + # No Write/Edit — read-only tier may not grant mutating tools (validator rule + # 6). The deliverable is the agent's synthesised plan JSON, not a code change. + allowed_tools: [Bash, Read, Glob, Grep, WebFetch] + cedar_policy_modules: [builtin/hard_deny] +repo_config: + provider: github + discover: true +required_inputs: + one_of: [issue_number, task_description] +steps: + - { kind: clone_repo, name: setup } + - { kind: hydrate_context, name: context } + - { kind: run_agent, name: plan } + - { kind: deliver_artifact, name: deliver, target: s3 } +terminal_outcomes: + primary: artifact +limits: + max_turns: 60 +promotion_gate: + requires: [tests:agent/decompose] +status: production 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 From ce47f5de34f7364a13a66d5fa0e1b22c71631592 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Mon, 27 Jul 2026 12:49:43 +0100 Subject: [PATCH 2/7] test: tolerate either frozenset layout when cross-checking the writeable-workflow set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent-vs-CDK cross-check for the writeable-workflow constant matched only a single-line `frozenset((...))`. The formatter renders the same literal across several lines once it grows past the line limit, and the pattern then found nothing — so the check failed on a purely cosmetic reformat instead of on a real drift between the two lists. Allow whitespace between the frozenset call and its inner tuple so the assertion tests the set's contents, which is what it is for. --- cdk/test/handlers/shared/workflows.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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]), From 206a3b6755ae51f49daefeb5934b001d329a2469 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Mon, 27 Jul 2026 13:03:17 +0100 Subject: [PATCH 3/7] fix(carve S3): keep the Jira app-identity work when applying the agent-side changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent-side files in this slice were taken wholesale from the branch that carries the orchestration work. That branch predates the Jira app-identity feature already on the target, so copying its versions over silently dropped that feature from all 13 agent files — the app-actor env plumbing, the per-task credential scrub, and their tests — while the CDK, CLI and Forge halves of the same feature stayed. The result would have merged as a half-reverted feature. Reapply the agent-side changes as a three-way merge against the common ancestor instead of a wholesale copy, so both survive: the app-identity path and this slice's own agent changes. Verified per file that the result is exactly "branch version + app-identity delta" with no other drift, and that a symbol removed on purpose here (the Linear MCP endpoint) stays removed. One docstring conflicted, describing how Jira comments get posted; kept the target's wording since it names the app actor and is now correct. --- agent/AGENTS.md | 2 + agent/README.md | 7 + agent/src/channel_mcp.py | 13 +- agent/src/config.py | 134 +++++++--- agent/src/jira_reactions.py | 360 ++++++++++++++++++------- agent/src/pipeline.py | 406 +++++++++++++++-------------- agent/src/policy.py | 249 ++++++++---------- agent/src/prompt_builder.py | 93 +++---- agent/src/server.py | 62 +++-- agent/src/shared_constants.py | 29 +++ agent/tests/test_config.py | 122 +++++++++ agent/tests/test_jira_reactions.py | 108 ++++++++ agent/tests/test_pipeline.py | 28 ++ 13 files changed, 1062 insertions(+), 551 deletions(-) diff --git a/agent/AGENTS.md b/agent/AGENTS.md index d1e08b785..eec51f81e 100644 --- a/agent/AGENTS.md +++ b/agent/AGENTS.md @@ -67,10 +67,12 @@ def _reset_shared_circuit_breaker_state(): yield _reset_circuit_breakers() + class TestGenerateUlid: def test_length_is_26(self): assert len(_generate_ulid()) == 26 + # ❌ Bad — no isolation, test order dependency def test_a(): _ProgressWriter._circuit_open = True # poisons test_b diff --git a/agent/README.md b/agent/README.md index 5feb0f5ee..f3903a524 100644 --- a/agent/README.md +++ b/agent/README.md @@ -125,6 +125,13 @@ The `run.sh` script overrides the container's default CMD to run `python /app/sr | `DRY_RUN` | No | | Set to `1` to validate config and print the prompt without running the agent | | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | No | `anthropic.claude-haiku-4-5-20251001-v1:0` | Bedrock model ID for the pre-flight safety check (see below) | | `NUDGES_TABLE_NAME` | No | | **Phase 2.** DynamoDB table for mid-task user nudges (`` XML blocks injected between turns). If unset, the agent runs without nudge support — `nudge_reader.read_pending()` returns `[]` and logs a WARN once. Set automatically by the CDK stack on both AgentCore runtimes. | +| `JIRA_APP_ACTOR_PROXY_URL` | No | | Resolved per-task from the Jira tenant secret. Forge v2 web-trigger URL used for app-authored Jira comments and transitions. | +| `JIRA_APP_ACTOR_SHARED_SECRET` | No | | Resolved per-task from the Jira tenant secret. HMAC key for the Forge proxy; redacted from agent diagnostics. | +| `JIRA_API_TOKEN` | No | | Legacy per-task Jira 3LO token. Used for outbound writes only when no app actor is configured. | + +`run_task` removes all Jira credential variables before every invocation, +including non-Jira tasks, so a warm AgentCore process cannot expose one +tenant's OAuth or Forge credential to the next task. **Bedrock model access (main model):** Configuring `ANTHROPIC_MODEL` and IAM credentials is not enough. Your AWS account must be able to **invoke** that model in Amazon Bedrock: follow [Request access to models](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html) (Marketplace permissions on first use, Anthropic first-time use where required, valid payment method for Marketplace-backed models). Use an inference profile ID such as `us.anthropic.claude-sonnet-4-6` when Bedrock requires it. If the CLI stops with a message that the model is not available on your Bedrock deployment, fix model access in the console or switch `ANTHROPIC_MODEL` to an entitled profile, then retry. diff --git a/agent/src/channel_mcp.py b/agent/src/channel_mcp.py index a59585d9b..2b762bb43 100644 --- a/agent/src/channel_mcp.py +++ b/agent/src/channel_mcp.py @@ -56,11 +56,9 @@ #: and will NOT accept the stored REST OAuth token as a Bearer header, so it #: fails to connect in the runtime (``claude mcp list`` → "Failed to connect"). #: -#: The LIVE outbound path is the REST shim in ``agent/src/jira_reactions.py`` -#: (the "Plan B" that became Plan A), which posts comments via the Jira REST -#: v3 API using the same stored OAuth token. See ADR-015 and -#: ``agent/src/prompt_builder.py``. If Atlassian ever ships a token-compatible -#: MCP, this entry can be promoted and the REST shim retired. +#: The LIVE outbound path is ``agent/src/jira_reactions.py``. It uses the +#: signed Forge app proxy when configured and retains direct REST + OAuth only +#: as a migration fallback. See ADR-015 and ``agent/src/prompt_builder.py``. JIRA_MCP_URL = "https://mcp.atlassian.com/v1/sse" #: Key name inside ``mcpServers``. Tools surface as ``mcp__jira-server__*`` @@ -176,11 +174,12 @@ def configure_channel_mcp( # The Jira MCP entry is a non-functional placeholder (see JIRA_MCP_URL # docstring + ADR-015). Log it in-band so a "Failed to connect" line in # the agent logs isn't mistaken for the cause of a missing comment — - # the live outbound path is the REST shim in jira_reactions.py. + # the live outbound path is the Forge/app-auth aware jira_reactions.py. log( "TASK", "jira MCP entry is a placeholder and is EXPECTED to fail to connect; " - "outbound Jira comments use the REST shim (jira_reactions.py), not MCP", + "outbound Jira writes use jira_reactions.py (Forge app actor when configured), " + "not MCP", ) return True diff --git a/agent/src/config.py b/agent/src/config.py index 5e2df2395..14b5454c0 100644 --- a/agent/src/config.py +++ b/agent/src/config.py @@ -13,16 +13,16 @@ # The platform default workflow id used when a payload omits resolved_workflow # (local/batch runs). Mirrors the create-task boundary's coding default. DEFAULT_WORKFLOW_ID = "coding/new-task-v1" -# The repo-less platform default workflow — the one first-party id whose -# ``requires_repo`` is false. Used by the load-failure fallback to decide -# repo-optionality without loading the file. +# The repo-less platform default workflow (#248 Phase 3) — the one first-party +# 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 — they # check out the existing PR branch instead of creating a fresh one. restack-v1 -# re-merges a changed predecessor into an existing stacked-child PR. +# (#305 A6) 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: the exact marker a coding/new-task agent puts on the -# FIRST line of its final message when a request is too ambiguous to +# 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. @@ -69,15 +69,15 @@ def resolve_github_token() -> str: def resolve_linear_api_token(channel_metadata: dict[str, str] | None = None) -> str: """Resolve the Linear OAuth access token from Secrets Manager. - The orchestrator stamps ``linear_oauth_secret_arn`` into the task - record's ``channel_metadata`` at task-creation time. 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 the one - remaining consumer — ``linear_reactions.py``'s direct-GraphQL - Authorization header (reactions + state transitions) — keeps working. - (Per ADR-016 there is no Linear MCP; this token no longer feeds an - ``.mcp.json`` placeholder.) + Phase 2.0b-O2: the orchestrator stamps ``linear_oauth_secret_arn`` + into the task record's ``channel_metadata`` at task-creation time. + 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 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. @@ -86,9 +86,9 @@ def resolve_linear_api_token(channel_metadata: dict[str, str] | None = None) -> then skips its reactions/state calls (best-effort, logged). This function is only called when ``channel_source == 'linear'``. - An earlier approach used AgentCore Identity; this reads Secrets Manager - directly because that Identity federation flow has an open service-side - bug. + Phase 2.0a (parked) used AgentCore Identity. Phase 2.0b-O2 reads + Secrets Manager directly because AgentCore Identity's USER_FEDERATION + flow has an open service-side bug (see memory/project_oauth_2_0b.md). """ cached = os.environ.get("LINEAR_API_TOKEN", "") if cached: @@ -238,7 +238,7 @@ def _try_refresh_once(current: dict) -> tuple[str, dict | None]: "updated_at": now.isoformat().replace("+00:00", "Z"), } - # The agent runtime no longer has + # Phase 2.0b-O2 review item S1: agent runtime no longer has # `secretsmanager:PutSecretValue` on the OAuth secret prefix — # the agent executes untrusted repo code, and writing tokens # back means a compromised agent could overwrite any @@ -343,14 +343,29 @@ def _refresh(current: dict) -> dict | None: return access +_JIRA_TASK_CREDENTIAL_ENV_VARS = ( + "JIRA_API_TOKEN", + "JIRA_APP_ACTOR_CONFIGURED", + "JIRA_APP_ACTOR_PROXY_URL", + "JIRA_APP_ACTOR_SHARED_SECRET", +) + + +def clear_jira_task_credentials() -> None: + """Remove Jira credentials left by an earlier task in this process.""" + for name in _JIRA_TASK_CREDENTIAL_ENV_VARS: + os.environ.pop(name, None) + + def resolve_jira_oauth_token(channel_metadata: dict[str, str] | None = None) -> str: - """Resolve the Jira Cloud OAuth access token from Secrets Manager. + """Resolve Jira outbound credentials from Secrets Manager. The orchestrator stamps ``jira_oauth_secret_arn`` into the task - record's ``channel_metadata`` at task-creation time. We fetch the - per-tenant secret, parse the token JSON, and cache the access_token in - ``JIRA_API_TOKEN`` so the agent-side Jira REST calls - (``jira_reactions``) can authorize. + record's ``channel_metadata`` at task-creation time. A tenant configured + with a Forge app actor gets ``JIRA_APP_ACTOR_*`` environment variables; + older tenants retain ``JIRA_API_TOKEN`` as an explicit migration fallback. + ``jira_reactions`` always prefers the app actor and never falls back to + OAuth when an app configuration is present but broken. **The agent never refreshes the token.** Unlike Linear, Atlassian *rotates the refresh_token on every use* — a successful refresh @@ -372,20 +387,22 @@ def resolve_jira_oauth_token(channel_metadata: dict[str, str] | None = None) -> session, so in practice the agent reads a freshly-written token with a full lifetime ahead of it. - For local development, a pre-set ``JIRA_API_TOKEN`` env var - short-circuits the lookup so the agent can run outside the runtime. - This function is only called when ``channel_source == 'jira'``. """ - cached = os.environ.get("JIRA_API_TOKEN", "") - if cached: - return cached - secret_arn = "" if channel_metadata: secret_arn = channel_metadata.get("jira_oauth_secret_arn", "") if not secret_arn: secret_arn = os.environ.get("JIRA_OAUTH_SECRET_ARN", "") + cached = os.environ.get("JIRA_API_TOKEN", "") + if cached and not secret_arn and channel_metadata is None: + return cached + + # AgentCore can reuse a warm process. Clear the prior task's tenant + # credentials before resolving a metadata-bound task so a missing ARN or + # failed SM read cannot reuse another tenant's token or proxy secret. + if channel_metadata is not None or secret_arn: + clear_jira_task_credentials() if not secret_arn: return "" @@ -402,7 +419,7 @@ def resolve_jira_oauth_token(channel_metadata: dict[str, str] | None = None) -> from botocore.exceptions import BotoCoreError, ClientError except ImportError as e: log("WARN", f"resolve_jira_oauth_token: boto3 unavailable ({e}); skipping") - return "" + return "" # nosemgrep: py-silent-success-masking -- Jira feedback is advisory sm = boto3.client("secretsmanager", region_name=region) @@ -416,7 +433,7 @@ def _fetch_token() -> dict | None: f"resolve_jira_oauth_token: secret '{secret_arn}' is not valid JSON " f"({type(e).__name__}: {e}); tenant requires re-onboarding", ) - return None + return None # nosemgrep: py-silent-success-masking -- bad secret disables writes def _is_expiring(expires_at_iso: str, threshold_seconds: int = 60) -> bool: try: @@ -438,19 +455,52 @@ def _is_expiring(expires_at_iso: str, threshold_seconds: int = 60) -> bool: is_hard_failure = code in ("AccessDeniedException", "ResourceNotFoundException") severity = "ERROR" if is_hard_failure else "WARN" log(severity, f"resolve_jira_oauth_token failed: {type(e).__name__}: {e}") - return "" + return "" # nosemgrep: py-silent-success-masking -- feedback cannot fail the task if token_obj is None: return "" + if not isinstance(token_obj, dict): + log( + "ERROR", + f"resolve_jira_oauth_token: secret '{secret_arn}' contains non-object JSON; " + "tenant requires re-onboarding", + ) + return "" + + app_actor_fields = ( + "app_actor_proxy_url", + "app_actor_shared_secret", + "app_actor_account_id", + "app_actor_display_name", + "app_actor_configured_at", + ) + raw_app_proxy_url = token_obj.get("app_actor_proxy_url", "") + raw_app_shared_secret = token_obj.get("app_actor_shared_secret", "") + app_proxy_url = raw_app_proxy_url if isinstance(raw_app_proxy_url, str) else "" + app_shared_secret = raw_app_shared_secret if isinstance(raw_app_shared_secret, str) else "" + if any(field in token_obj for field in app_actor_fields): + # The marker makes partial/corrupt app setup fail closed in + # jira_reactions. Do not silently post as the OAuth authorizing user. + os.environ["JIRA_APP_ACTOR_CONFIGURED"] = "1" + if app_proxy_url: + os.environ["JIRA_APP_ACTOR_PROXY_URL"] = app_proxy_url + if app_shared_secret: + os.environ["JIRA_APP_ACTOR_SHARED_SECRET"] = app_shared_secret # Fail closed if the stored token is expiring — the agent cannot refresh # without burning Atlassian's rotating refresh_token (see docstring). The - # Lambda path owns refresh; advisory Jira comments simply no-op here. + # Lambda path owns refresh. A configured Forge app actor remains usable + # because its signed proxy credential is independent of 3LO expiry. if _is_expiring(token_obj.get("expires_at", "")): + suffix = ( + " Forge app-actor writes remain enabled." + if os.environ.get("JIRA_APP_ACTOR_CONFIGURED") == "1" + else " Jira OAuth fallback writes will be skipped for this task." + ) log( "WARN", "resolve_jira_oauth_token: stored token is expiring and the agent does not " "refresh (Atlassian rotates refresh_tokens; agent lacks PutSecretValue). " - "Failing closed — Jira comments will be skipped for this task.", + f"Failing closed for OAuth.{suffix}", ) return "" @@ -518,9 +568,9 @@ def build_config( is_pr_workflow = workflow_id in PR_WORKFLOW_IDS # Load the workflow up-front: it drives the Cedar principal, the read_only - # flag, AND whether a repo is required. Fall back to id-based mapping when - # the file can't be loaded (e.g. an id whose workflow file has not shipped - # yet) — a repo-less default is the safe assumption only for non-coding. + # flag, AND whether a repo is required (#248 Phase 3). Fall back to id-based + # mapping when the file can't be loaded (e.g. a registry-only id in a future + # phase) — a repo-less default is the safe assumption only for non-coding. from workflow import WorkflowValidationError, load_workflow, policy_principal_for try: @@ -628,9 +678,9 @@ def get_config() -> TaskConfig: 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"), - # Local-batch ``--trace`` parity. Without these env vars a - # developer running the agent outside AgentCore could never - # exercise the trace path. Both are + # Local-batch ``--trace`` parity (design §10.1). Without + # these env vars a developer running the agent outside + # AgentCore could never exercise the trace path. Both are # opt-in; empty ``USER_ID`` with ``TRACE=1`` logs a skip # warning (see ``pipeline.run_task``) rather than writing # an unreachable ``traces//`` key. diff --git a/agent/src/jira_reactions.py b/agent/src/jira_reactions.py index b292ac805..83a81ae28 100644 --- a/agent/src/jira_reactions.py +++ b/agent/src/jira_reactions.py @@ -5,28 +5,28 @@ REST API has no lightweight reaction primitive, so comments are the right tool). -It also moves the originating issue across its board as the task progresses: -To Do → In Progress on start, → In Review on PR, via the Jira transitions API. -Like comments, transitions are best-effort — logged and swallowed on any -failure, sharing the same auth circuit breaker — so the Jira board never gates -the task. See the "Workflow transitions" section below. - -The *terminal* status comment is NOT posted from here: the deterministic -fan-out plane (``cdk/src/handlers/fanout-task-events.ts`` ``dispatchToJira``) -owns it, so it carries cost/turns/duration and fires even when this agent -crashes before completing. This module only owns the start comment. - -Why a direct REST call instead of MCP: Atlassian's Remote MCP +It also moves the originating issue across its board as the task progresses +(issue #572): To Do → In Progress on start, → In Review on PR, via the Jira +transitions API. Like comments, transitions are best-effort — logged and +swallowed on any failure, sharing the same auth circuit breaker — so the Jira +board never gates the task. See the "Workflow transitions" section below. + +The *terminal* status comment is NOT posted from here: since issue #573 the +deterministic fan-out plane (``cdk/src/handlers/fanout-task-events.ts`` +``dispatchToJira``) owns it, so it carries cost/turns/duration and fires even +when this agent crashes before completing. This module only owns the start +comment. + +Why a signed app proxy instead of MCP: Atlassian's Remote MCP (``mcp.atlassian.com``) requires an interactive, browser-based OAuth 2.1 authorization flow with dynamic client registration — it does NOT accept the stored Jira REST OAuth token as a ``Bearer`` header, and a headless background agent cannot complete the interactive handshake. The MCP path therefore fails -to connect in the runtime (``claude mcp list`` → "Failed to connect"). The -Jira *REST* API, by contrast, accepts the same stored OAuth access token (it -carries ``write:jira-work``), so we post comments via -``POST /rest/api/3/issue/{key}/comment`` on the cross-region -``api.atlassian.com/ex/jira/{cloudId}`` base. This is the "Plan B REST shim" -the ``channel_mcp`` module's comments anticipated. +to connect in the runtime (``claude mcp list`` → "Failed to connect"). +Configured tenants call a narrow HMAC-authenticated Forge web trigger, which +uses ``api.asApp().requestJira`` so Jira attributes writes to the app account. +Tenants without Forge configuration retain the user-delegated OAuth REST path +as an explicit migration fallback. Gating: every function is a no-op unless ``channel_source == 'jira'`` and the issue key + cloud id are present in ``channel_metadata``. All network / auth @@ -39,20 +39,28 @@ from __future__ import annotations +import hashlib +import hmac +import json import os import threading +import time from http import HTTPStatus from typing import Any -from urllib.parse import quote +from urllib.parse import quote, urlparse import requests +from shared_constants import SHARED_CONSTANTS from shell import log #: Atlassian cross-region REST base. The ``{cloudId}`` segment scopes the #: call to the tenant; ``JIRA_API_TOKEN`` (populated by #: ``config.resolve_jira_oauth_token``) authorizes it. JIRA_API_BASE = "https://api.atlassian.com/ex/jira" +_JIRA_APP_ACTOR_CONSTANTS = SHARED_CONSTANTS["jira_app_actor"] +FORGE_WEBTRIGGER_SUFFIX = str(_JIRA_APP_ACTOR_CONSTANTS["forge_webtrigger_suffix"]) +APP_ACTOR_MIN_SECRET_LENGTH = int(_JIRA_APP_ACTOR_CONSTANTS["min_secret_length"]) #: Request timeout — comments are fire-and-forget status UX; never block the #: task pipeline for more than a couple of seconds. @@ -66,6 +74,22 @@ _consecutive_auth_failures: int = 0 _auth_circuit_open: bool = False _auth_state_lock = threading.Lock() +_PROXY_ERROR_CODES = frozenset( + { + "cloud_id_required", + "invalid_comment_request", + "invalid_issue_key", + "invalid_json", + "invalid_payload", + "invalid_signature", + "invalid_transition_request", + "jira_request_failed", + "method_not_allowed", + "payload_too_large", + "proxy_not_configured", + "unsupported_operation", + } +) def _circuit_open() -> bool: @@ -97,8 +121,9 @@ def _note_auth_status(status_code: int) -> None: log( "ERROR", "jira_reactions: auth circuit OPEN after " - f"{failures} consecutive {status_code}s — Jira token likely " - "revoked/expired without a working refresh. Suppressing further " + f"{failures} consecutive {status_code}s — Jira outbound credential " + "or app permission is invalid. For Forge app writes, verify " + "BGAGENT_PROXY_SECRET matches JIRA_APP_ACTOR_SHARED_SECRET. Suppressing further " "Jira calls for this container.", ) else: @@ -141,6 +166,124 @@ def _adf(text: str) -> dict[str, Any]: } +def _app_actor_intended() -> bool: + """True once app setup has written any Forge app-actor field.""" + return os.environ.get("JIRA_APP_ACTOR_CONFIGURED") == "1" or bool( + os.environ.get("JIRA_APP_ACTOR_PROXY_URL") or os.environ.get("JIRA_APP_ACTOR_SHARED_SECRET") + ) + + +def _app_actor_credentials() -> tuple[str, str] | None: + """Return validated Forge proxy credentials, failing closed when malformed.""" + proxy_url = os.environ.get("JIRA_APP_ACTOR_PROXY_URL", "") + shared_secret = os.environ.get("JIRA_APP_ACTOR_SHARED_SECRET", "") + try: + parsed = urlparse(proxy_url) + valid_url = ( + parsed.scheme == "https" + and bool(parsed.hostname) + and parsed.hostname.endswith(FORGE_WEBTRIGGER_SUFFIX) + and parsed.path.startswith("/public/") + and not parsed.username + and not parsed.password + and not parsed.query + and not parsed.fragment + ) + except ValueError: + valid_url = False + if not valid_url or len(shared_secret) < APP_ACTOR_MIN_SECRET_LENGTH: + log( + "ERROR", + "jira_reactions: configured Forge app actor is incomplete or invalid; " + "refusing OAuth fallback", + ) + return None + return proxy_url, shared_secret + + +def _app_actor_request( + cloud_id: str, + operation: str, + *, + issue_key: str | None = None, + body: dict[str, Any] | None = None, + transition_id: str | None = None, +) -> requests.Response | None: + """Call the signed, operation-allowlisted Forge app proxy.""" + credentials = _app_actor_credentials() + if not credentials: + return None + proxy_url, shared_secret = credentials + payload: dict[str, Any] = { + "version": 1, + "operation": operation, + "cloud_id": cloud_id, + } + if issue_key is not None: + payload["issue_key"] = issue_key + if body is not None: + payload["body"] = body + if transition_id is not None: + payload["transition_id"] = transition_id + + raw_body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + timestamp = str(int(time.time())) + signature = hmac.new( + shared_secret.encode(), + f"{timestamp}.{raw_body}".encode(), + hashlib.sha256, + ).hexdigest() + try: + response = requests.post( + proxy_url, + data=raw_body.encode(), + headers={ + "Content-Type": "application/json", + "X-Bgagent-Timestamp": timestamp, + "X-Bgagent-Signature": f"sha256={signature}", + }, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + if not HTTPStatus.OK <= response.status_code < HTTPStatus.MULTIPLE_CHOICES: + proxy_error = _proxy_error_code(response.text) + non_retryable = ( + response.status_code < HTTPStatus.INTERNAL_SERVER_ERROR + or proxy_error == "proxy_not_configured" + ) + level = "ERROR" if non_retryable else "WARN" + error_id = ( + "JIRA_APP_ACTOR_PROXY_REJECTED" + if non_retryable + else "JIRA_APP_ACTOR_PROXY_TRANSIENT" + ) + log( + level, + f"jira_reactions: Forge app proxy returned HTTP {response.status_code} " + f"error_id={error_id} operation={operation} " + f"proxy_error={proxy_error or 'unclassified'}", + ) + return response + except requests.RequestException as e: + log( + "WARN", + f"jira_reactions: Forge app proxy request failed ({type(e).__name__}): {e}", + ) + # nosemgrep: py-silent-success-masking -- Jira writes are advisory on network blips + return None + + +def _proxy_error_code(raw_body: str) -> str | None: + """Return a known proxy error code without logging arbitrary response text.""" + try: + payload = json.loads(raw_body) + except (json.JSONDecodeError, TypeError): + return None + if not isinstance(payload, dict): + return None + value = payload.get("error") + return value if isinstance(value, str) and value in _PROXY_ERROR_CODES else None + + def _post_comment(cloud_id: str, issue_key: str, text: str) -> bool: """POST a comment to the issue. Return True on success, False on any failure. @@ -153,34 +296,40 @@ def _post_comment(cloud_id: str, issue_key: str, text: str) -> bool: log("DEBUG", "jira_reactions: auth circuit still open; short-circuiting call") return False - token = os.environ.get("JIRA_API_TOKEN", "") - if not token: - log("WARN", "jira_reactions: JIRA_API_TOKEN not set; skipping comment") - return False - - # URL-encode both path segments. cloud_id and issue_key originate from the - # verified webhook payload (stamped into channel_metadata by the - # processor), but encoding them keeps an unexpected value from injecting - # extra path segments into the gateway URL. `safe=""` so even "/" encodes. - url = ( - f"{JIRA_API_BASE}/{quote(cloud_id, safe='')}" - f"/rest/api/3/issue/{quote(issue_key, safe='')}/comment" - ) - try: - resp = requests.post( - url, - json={"body": _adf(text)}, - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - "Accept": "application/json", - }, - timeout=REQUEST_TIMEOUT_SECONDS, + if _app_actor_intended(): + resp = _app_actor_request( + cloud_id, + "comment", + issue_key=issue_key, + body=_adf(text), ) - except requests.RequestException as e: - log("WARN", f"jira_reactions: request failed ({type(e).__name__}): {e}") - # nosemgrep: py-silent-success-masking -- Jira comments are best-effort on network blips - return False + if resp is None: + return False + else: + token = os.environ.get("JIRA_API_TOKEN", "") + if not token: + log("WARN", "jira_reactions: JIRA_API_TOKEN not set; skipping comment") + return False + log("WARN", "jira_reactions: app actor not configured; using OAuth fallback") + url = ( + f"{JIRA_API_BASE}/{quote(cloud_id, safe='')}" + f"/rest/api/3/issue/{quote(issue_key, safe='')}/comment" + ) + try: + resp = requests.post( + url, + json={"body": _adf(text)}, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + except requests.RequestException as e: + log("WARN", f"jira_reactions: request failed ({type(e).__name__}): {e}") + # nosemgrep: py-silent-success-masking -- Jira comments are best-effort on network blips + return False if resp.status_code in (401, 403): _note_auth_status(resp.status_code) @@ -213,16 +362,16 @@ def comment_task_started( log("TASK", f"jira_reactions: comment_task_started issue={issue_key} ok={ok}") -# NOTE: there is deliberately no ``comment_task_finished`` here. The -# deterministic fan-out plane (``cdk/src/handlers/fanout-task-events.ts`` -# ``dispatchToJira``) owns the Jira terminal comment — it carries -# cost/turns/duration and, crucially, fires even when the agent crashes before -# completing (max-turns, OOM). The agent only posts the *start* comment -# (``comment_task_started`` above); posting a terminal comment here too would -# double-comment on the issue. +# NOTE: there is deliberately no ``comment_task_finished`` here. Since issue +# #573 the deterministic fan-out plane +# (``cdk/src/handlers/fanout-task-events.ts`` ``dispatchToJira``) owns the Jira +# terminal comment — it carries cost/turns/duration and, crucially, fires even +# when the agent crashes before completing (max-turns, OOM). The agent only +# posts the *start* comment (``comment_task_started`` above); posting a terminal +# comment here too would double-comment on the issue. -# ── Workflow transitions ──────────────────────────────────────────────────────── +# ── Workflow transitions (issue #572) ────────────────────────────────────────── # # Move the originating Jira card across its board as the task progresses so the # board reflects reality: To Do → In Progress on start, → In Review on PR. Jira @@ -248,14 +397,14 @@ def comment_task_started( #: Preferred destination *name* for the start transition (matched #: case-insensitively before the category fallback). Both ``In Progress`` and #: ``Blocked`` share the ``indeterminate`` category, so a name match is what -#: keeps a category-only heuristic from landing on ``Blocked``. +#: keeps a category-only heuristic from landing on ``Blocked`` (#605). _START_STATUS_NAME = "in progress" #: Preferred destination names for the PR-opened transition, tried in order. #: Mirrors Linear's "In Review → In Progress" fallback while also matching #: common review-column names, so a "Code Review" column isn't silently skipped #: and a stock board (no review status) still advances to / stays at In Progress -#: rather than no-opping. Ends with In Progress as the safe fallback. +#: rather than no-opping (#605). Ends with In Progress as the safe fallback. _REVIEW_STATUS_NAMES = ( "in review", "code review", @@ -267,7 +416,7 @@ def comment_task_started( #: Destination names the category fallback must never auto-pick. ``Blocked`` #: shares the ``indeterminate`` category with ``In Progress``; a bare -#: first-indeterminate heuristic could land there, which is never what +#: first-indeterminate heuristic could land there (#605), which is never what #: "move to In Progress" means. A configured override can still target it. _CATEGORY_FALLBACK_DENY = ("blocked",) @@ -278,29 +427,34 @@ def _get_issue_transitions( """GET the issue's current status + the transitions valid from it. Fetches ``?fields=status&expand=transitions`` so a single call yields both - the current ``status`` (to skip moving a card backward) and the + the current ``status`` (to skip moving a card backward, #605) and the ``transitions`` list. Returns ``(current_status, transitions)`` on success, or ``None`` on any failure. An empty transitions list is normal — Jira returns one when the OAuth user lacks the *Transition Issues* permission — and callers treat it as "nothing to do", not an error. """ - url = ( - f"{JIRA_API_BASE}/{quote(cloud_id, safe='')}" - f"/rest/api/3/issue/{quote(issue_key, safe='')}?fields=status&expand=transitions" - ) - try: - resp = requests.get( - url, - headers={ - "Authorization": f"Bearer {token}", - "Accept": "application/json", - }, - timeout=REQUEST_TIMEOUT_SECONDS, + if _app_actor_intended(): + resp = _app_actor_request(cloud_id, "get_transitions", issue_key=issue_key) + if resp is None: + return None + else: + url = ( + f"{JIRA_API_BASE}/{quote(cloud_id, safe='')}" + f"/rest/api/3/issue/{quote(issue_key, safe='')}?fields=status&expand=transitions" ) - except requests.RequestException as e: - log("WARN", f"jira_reactions: issue GET failed ({type(e).__name__}): {e}") - # nosemgrep: py-silent-success-masking -- Jira transitions are best-effort on network blips - return None + try: + resp = requests.get( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + }, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + except requests.RequestException as e: + log("WARN", f"jira_reactions: issue GET failed ({type(e).__name__}): {e}") + # nosemgrep: py-silent-success-masking -- advisory Jira transition read + return None if resp.status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): _note_auth_status(resp.status_code) @@ -319,7 +473,7 @@ def _get_issue_transitions( # A well-formed response is a JSON object. Guard against valid-but-unexpected # JSON (``null``, a bare list, a scalar) — ``.get`` would raise # AttributeError, which is NOT best-effort: it propagates out of the pipeline - # hook and flips the task to FAILED. + # hook and flips the task to FAILED (#605). if not isinstance(payload, dict): log("WARN", "jira_reactions: issue GET returned non-object JSON — skipping") return None @@ -402,25 +556,35 @@ def _select_transition( def _post_transition(cloud_id: str, issue_key: str, token: str, transition_id: str) -> bool: """POST a transition. Return True on success (204), False on any failure.""" - url = ( - f"{JIRA_API_BASE}/{quote(cloud_id, safe='')}" - f"/rest/api/3/issue/{quote(issue_key, safe='')}/transitions" - ) - try: - resp = requests.post( - url, - json={"transition": {"id": transition_id}}, - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - "Accept": "application/json", - }, - timeout=REQUEST_TIMEOUT_SECONDS, + if _app_actor_intended(): + resp = _app_actor_request( + cloud_id, + "transition", + issue_key=issue_key, + transition_id=transition_id, ) - except requests.RequestException as e: - log("WARN", f"jira_reactions: transition POST failed ({type(e).__name__}): {e}") - # nosemgrep: py-silent-success-masking -- Jira transitions are best-effort on network blips - return False + if resp is None: + return False + else: + url = ( + f"{JIRA_API_BASE}/{quote(cloud_id, safe='')}" + f"/rest/api/3/issue/{quote(issue_key, safe='')}/transitions" + ) + try: + resp = requests.post( + url, + json={"transition": {"id": transition_id}}, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + except requests.RequestException as e: + log("WARN", f"jira_reactions: transition POST failed ({type(e).__name__}): {e}") + # nosemgrep: py-silent-success-masking -- advisory Jira transition write + return False if resp.status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): _note_auth_status(resp.status_code) @@ -463,9 +627,11 @@ def _transition( return token = os.environ.get("JIRA_API_TOKEN", "") - if not token: + if not _app_actor_intended() and not token: log("WARN", "jira_reactions: JIRA_API_TOKEN not set; skipping transition") return + if not _app_actor_intended(): + log("WARN", "jira_reactions: app actor not configured; using OAuth fallback") result = _get_issue_transitions(cloud_id, issue_key, token) if result is None: @@ -554,7 +720,7 @@ def transition_pr_opened( Resolution: ``jira_status_on_pr`` override → a destination named ``In Review`` (or a common review-column synonym) → any ``indeterminate``-category destination (Linear's In Review→In Progress - fallback, so a stock board isn't silently skipped). Skips only if the + fallback, so a stock board isn't silently skipped, #605). Skips only if the issue is already Done. Best-effort; no-op for non-Jira tasks. """ override = (channel_metadata or {}).get("jira_status_on_pr") diff --git a/agent/src/pipeline.py b/agent/src/pipeline.py index e697915c8..30f0abe58 100644 --- a/agent/src/pipeline.py +++ b/agent/src/pipeline.py @@ -20,6 +20,7 @@ AGENT_WORKSPACE, NEEDS_INPUT_MARKER, build_config, + clear_jira_task_credentials, get_config, resolve_jira_oauth_token, resolve_linear_api_token, @@ -111,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. + useful (K2 review Finding #1). - Gates: + Gates (K2 Stage 3 review Finding #1): - ``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 @@ -173,7 +174,7 @@ def _deliver_plan_artifact( prompt: str, agent_result, ) -> str | None: - """Deliver a decompose-planning agent's plan as the task artifact. + """Deliver a decompose-planning agent's plan as the task artifact (#299). ``coding/decompose-v1`` is a repo-ful workflow whose primary terminal outcome is an ARTIFACT (the decomposition plan), not a PR. It clones the repo for full @@ -217,12 +218,12 @@ def _execute_agent_step( ): """Run the agentic step through the workflow step runner. - The workflow runner is the sole path: the single ``run_agent`` step is - dispatched through ``workflow.run_workflow`` — exercising the real handler - registry, step milestones, and result threading — while clone, context - assembly, and post-hooks stay inline (moving the full step list onto the - runner is a follow-up). The workflow is loaded from the resolved - ``{id, version}`` pinned at the create-task boundary. + Post-cutover (#248 task 8), the workflow runner is the sole path: the single + ``run_agent`` step is dispatched through ``workflow.run_workflow`` — + exercising the real handler registry, step milestones, and result threading — + while clone, context assembly, and post-hooks stay inline (moving the full + step list onto the runner is a follow-up). The workflow is loaded from the + resolved ``{id, version}`` pinned at the create-task boundary. Returns the ``AgentResult`` so the surrounding pipeline (cancel short-circuit, post-hooks, result assembly) is unchanged. @@ -278,7 +279,7 @@ def _run_repoless_task( memory_id: str, system_prompt_overrides: str, ) -> dict: - """Run a repo-less workflow (knowledge work, no repo) and return the result dict. + """Run a repo-less workflow (#248 Phase 3) and return the result dict. No clone / build / PR: the workflow runner drives the full repo-less step list (``hydrate_context`` → ``run_agent`` → ``deliver_artifact``) inside the @@ -308,7 +309,7 @@ def _run_repoless_task( # Drive the full repo-less step list: hydrate_context → run_agent → # deliver_artifact. The deliverer uploads the agent's result text to # artifacts/{task_id}/ (and/or surfaces it as a comment), so the declared - # terminal outcome is actually produced. + # terminal outcome is actually produced (#248 Phase 3). with task_span("task.agent_execution"): wf_result = run_workflow(wf, ctx) @@ -348,7 +349,7 @@ def _run_repoless_task( # Either way the task produced nothing the user can retrieve, so it is a loud # FAILED rather than a silent "succeeded with no deliverable". Arm 2 closes # the gap where a deliverer that returns without raising (but also without an - # S3 key) would otherwise pass the gate. + # S3 key) would otherwise pass the gate (code-review MEDIUM #1). artifact_uri = ctx.artifacts.get("artifact_uri") primary_outcome = wf.terminal_outcomes.primary if overall_status == "success" and not wf_result.succeeded: @@ -371,8 +372,8 @@ def _run_repoless_task( trace_s3_uri = _maybe_upload_trace(config, trajectory, progress) - # Episodic memory for a repo-less task is keyed on user:{user_id} — the same - # namespace the orchestrator fallback + hydration read. + # Episodic memory for a repo-less task is keyed on user:{user_id} (ADR-014 + # addendum) — the same namespace the orchestrator fallback + hydration read. # Fail-open: a memory write failure must not fail the task. memory_written = False effective_memory_id = memory_id or os.environ.get("MEMORY_ID", "") @@ -437,17 +438,18 @@ def _apply_post_hook_gates( build_before: bool, lint_before: bool, ) -> bool: - """Resolve the coding lane's post-hook verify gates against the workflow. - - The inline post-hook path CONSULTS each declared ``verify_build`` / - ``verify_lint`` step's ``gate`` through the runner's ``gate_status`` — the - single place gate semantics live — rather than routing the post-hooks through - the runner's step handlers. Routing through the runner would also change - failure-path side effects (a gating ``verify_build`` with ``on_failure: fail`` - stops the runner *before* ``ensure_pr``, stranding committed work with no PR), - which is a broader half-migrated-runner unification left for later. Here the - inline ordering (verify → ensure_pr always runs) is preserved; only the task - verdict honors the declared gate. + """Resolve the coding lane's post-hook verify gates against the workflow (#301). + + Decision (issue #301 acceptance criteria): the inline post-hook path + CONSULTS each declared ``verify_build`` / ``verify_lint`` step's ``gate`` + through the runner's ``gate_status`` — the single place gate semantics live — + rather than routing the post-hooks through the runner's step handlers. + Routing through the runner would also change failure-path side effects (a + gating ``verify_build`` with ``on_failure: fail`` stops the runner *before* + ``ensure_pr``, stranding committed work with no PR), which is the broader + half-migrated-runner unification the issue defers. Here the inline ordering + (verify → ensure_pr always runs) is preserved; only the task verdict honors + the declared gate. Per-step semantics: @@ -508,7 +510,7 @@ def _apply_post_hook_gates( 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: the new_task workflow tells the agent to put + 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 @@ -552,24 +554,23 @@ def _resolve_overall_task_status( ``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. Without it, a - build that was ALSO infra-killed BEFORE the agent ran looks "already red → not - a regression", which would wrongly report ✅ success on code we never actually - verified. The ``build_ok=infra`` marker makes the platform surface a retryable + 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 — the ABCA-659 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 - # 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 (e.g. a task that thrashes on a failing `git push` from - # invalid credentials, retries every which way, and hits the cap). 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 the max_turns reason; a task that used its turns - # productively adds nothing. + # 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 + # 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 + # the max_turns reason; a task that used its turns productively adds nothing. if err and "error_max_turns" in err: from hooks import last_stuck_summary @@ -587,11 +588,12 @@ def _resolve_overall_task_status( if agent_status in ("success", "end_turn") and build_ok: return "success", err - # A hook may have detected an environmental blocker mid-run (egress denial, - # policy fail-closed) that the SDK surfaced only as a generic failure or as a - # missing ResultMessage. Promote the canonical ``BLOCKED[]: …`` reason - # so the platform's error classifier attaches a precise remedy. Import locally - # to avoid a module-load cycle (hooks imports pipeline-adjacent modules). + # #251 carry-path: a hook may have detected an environmental blocker mid-run + # (egress denial, policy fail-closed) that the SDK surfaced only as a generic + # failure or as a missing ResultMessage. Promote the canonical + # ``BLOCKED[]: …`` reason so the CDK classifier attaches a precise + # remedy. Import locally to avoid a module-load cycle (hooks imports + # pipeline-adjacent modules). from hooks import last_blocker_reason blocker = last_blocker_reason() @@ -616,7 +618,7 @@ def _resolve_overall_task_status( return "error", merged if not err: - # A latched blocker (e.g. egress denied, naming a host) is the more + # #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: @@ -634,13 +636,13 @@ def _branch_has_new_commits(repo_dir: str, default_branch: str) -> bool: 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 ``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).""" + ``post_hooks._ensure_pr``). Used by the ABCA-815 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 #247 A4 stacked child + ``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"], @@ -666,10 +668,10 @@ def _apply_delivery_gate( 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. + """ABCA-815: fail a new-work coding task that reported success but shipped + nothing (no PR AND no commit on its branch) — the deliverable was lost. - Observed cause: a stacked child edited its files in a NESTED working tree + Live cause: a #247 A4 stacked child 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 — @@ -724,8 +726,8 @@ def _compute_turns_completed( ) -> int | None: """Clamp ``turns_completed`` to ``max_turns`` when the SDK hit the limit. - The Claude Agent SDK reports ``num_turns = max_turns + 1`` on - ``error_max_turns`` because the aborted attempt is counted. Clamping + Rev-5 DATA-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 for debugging. @@ -837,6 +839,11 @@ def run_task( from repo import setup_repo + # AgentCore can reuse this process for another task. Scrub every Jira + # credential before config or repository code runs, including for non-Jira + # tasks, so a prior tenant's long-lived Forge secret cannot cross tasks. + clear_jira_task_credentials() + # Build config config = build_config( repo_url=repo_url, @@ -899,8 +906,8 @@ def run_task( "repo.url": config.repo_url, "issue.number": config.issue_number, "agent.model": config.anthropic_model, - # Correlation envelope: user.id joins agent spans to orchestrator - # logs by the platform identity, not just task/repo. + # Correlation envelope (#245): user.id joins agent spans to + # orchestrator logs by the platform identity, not just task/repo. **({"user.id": config.user_id} if config.user_id else {}), }, ) as root_span: @@ -911,26 +918,27 @@ def run_task( progress = _ProgressWriter( config.task_id, trace=trace, user_id=config.user_id, repo=config.repo_url ) - # Clear any blocker latched by a prior task. The agent container is - # one-task-per-process today, but the FastAPI server thread-pool can in - # principle dispatch a second run_task in the same process — reset here so - # a stale BLOCKED[...] reason can never leak into this task's terminal - # error_message (the latch is a scalar, not task_id-keyed). + # #251: clear any blocker latched by a prior task. The agent container + # is one-task-per-process today, but the FastAPI server thread-pool can + # in principle dispatch a second run_task in the same process — reset + # here so a stale BLOCKED[...] reason can never leak into this task's + # terminal error_message (the latch is a scalar, not task_id-keyed). from hooks import reset_blocker_reason, reset_stuck_summary reset_blocker_reason() - # 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. + # 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. reset_stuck_summary() - # --trace accumulator: when the task opted into trace, - # ``_TrajectoryWriter`` keeps an in-memory copy of each event so the - # pipeline can gzip+upload the full trajectory to S3 on terminal. Owned by - # the pipeline rather than the runner so the accumulator outlives - # ``run_agent``'s scope. + # --trace accumulator (design §10.1): when the task opted into + # trace, ``_TrajectoryWriter`` keeps an in-memory copy of each + # event so the pipeline can gzip+upload the full trajectory to + # 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) - # 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. + # K2 review Finding #3 — 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. if trace: def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: @@ -995,20 +1003,20 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: prompt = assemble_prompt(config) - # Repo-less path: a knowledge workflow has no repo to clone, build, or - # PR. Drive its steps (hydrate_context → run_agent → deliver_artifact) - # through the workflow runner and assemble the terminal result, - # skipping the repo-coupled segment below entirely. + # Repo-less path (#248 Phase 3): a knowledge workflow has no repo to + # clone, build, or PR. Drive its steps (hydrate_context → run_agent → + # deliver_artifact) through the workflow runner and assemble the + # terminal result, skipping the repo-coupled segment below entirely. # # ``requires_repo: false`` means repo-OPTIONAL, not repo-forbidden: - # task creation admits and persists a repo for such a workflow, and - # the orchestrator then assembles a repo-bound prompt (issue/PR fetch). - # Keying the repo-less branch on ``requires_repo`` ALONE would make the - # agent skip the clone while the prompt promised a repo — the two - # halves disagree. So take the repo-less path only when no repo was - # actually supplied; when a repo IS present it is hydrated as context - # exactly like a coding task (clone → build → PR), honoring the - # repo-bound prompt the orchestrator built. + # create-task-core admits and persists a repo for such a workflow, + # and the orchestrator then assembles a repo-bound prompt (issue/PR + # fetch). Keying the repo-less branch on ``requires_repo`` ALONE made + # the agent skip the clone while the prompt promised a repo — the two + # halves disagreed (PR review #296 finding #3). So take the repo-less + # path only when no repo was actually supplied; when a repo IS present + # it is hydrated as context exactly like a coding task (clone → build → + # PR), honoring the repo-bound prompt the orchestrator built. if not config.requires_repo and not config.repo_url: return _run_repoless_task( config=config, @@ -1028,7 +1036,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # by Claude Code and the safety-net commit in post_hooks) WITHOUT # writing to any on-disk config. `--global` would clobber the real # ~/.gitconfig — harmless in the ephemeral container, but destructive - # when this pipeline runs on a developer workstation. + # when this pipeline runs on a developer workstation (#622). os.environ["GIT_AUTHOR_NAME"] = "bgagent" os.environ["GIT_AUTHOR_EMAIL"] = "bgagent@noreply.github.com" os.environ["GIT_COMMITTER_NAME"] = "bgagent" @@ -1042,15 +1050,15 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: if prompt_version: os.environ["PROMPT_VERSION"] = prompt_version - # ── Early ACK ──────────────────────────────────────────────────── + # ── Early ACK (ABCA-707) ───────────────────────────────────────── # Acknowledge the task is picked up BEFORE the (potentially long) # 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 would leave the issue looking dead for the whole - # phase (no reaction, comment, or state change for tens of minutes). - # 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. + # 👀 only *after* it left the issue looking dead for the whole phase + # (the ABCA-707 symptom: 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). @@ -1064,11 +1072,11 @@ 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 (👀 → ✅/❌). - # 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 (decompose-v1 planning, pr-review) - # never transition — the orchestration panel owns the parent's state, - # and a planning run shouldn't advance the issue. + # PM-3: 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 (decompose-v1 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, @@ -1076,19 +1084,20 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: transition_state=linear_transition_state, ) - # "Starting" comment on the Jira issue (REST shim — the Atlassian - # Remote MCP can't be used from a headless agent). No-op for - # non-Jira tasks. Best-effort; failures are logged, never block. + # "Starting" comment on the Jira issue through the Forge app actor + # (or legacy OAuth fallback). No-op for non-Jira tasks. + # Best-effort; failures are logged, never block. comment_task_started( config.channel_source, config.channel_metadata, ) # Move the Jira card To Do → In Progress so the board reflects that - # work has started. 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. + # 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, @@ -1099,9 +1108,9 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # the outer ``except Exception`` at the bottom of this ``try`` writes # the task FAILED, swaps the 👀 (posted above) to ❌, and posts the # failure comment. Before the Early-ACK move the 👀 didn't exist yet - # at this point, so a setup failure left the issue silently stuck (no - # visible signal); posting the 👀 earlier is what makes the outer - # handler's ❌-swap actually visible for setup failures. + # at this point, so a setup failure left the issue silently stuck + # (the ABCA-707 symptom); 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) @@ -1231,8 +1240,8 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: "task_cancelled_acknowledged", "Post-hooks skipped; terminal state already CANCELLED.", ) - # Best-effort trace upload + conditional self-heal on the - # cancel path. ``write_terminal``'s + # L4 item 1c: best-effort trace upload + conditional + # self-heal on the cancel path. ``write_terminal``'s # ConditionExpression rejects CANCELLED, so we cannot # persist ``trace_s3_uri`` atomically with the terminal # write — use ``write_trace_uri_conditional`` instead, @@ -1267,7 +1276,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # Resolve the post-hook gating inputs: read_only, the ensure_pr # strategy (create / push_resolve / resolve), and the verify steps' - # declared gates the workflow declares. + # declared gates (#301) the workflow declares. # # ``read_only`` comes from ``config`` — build_config already computed # it (with its own fail-soft fallback) and it drove Cedar during the @@ -1279,7 +1288,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # has already mutated / committed the tree, so a load failure here # must NOT strand the work as FAILED with no PR — it falls back to # the default "create" strategy + legacy regression-only gating and - # still opens the PR. + # still opens the PR (PR review #296 finding #5). from workflow import WorkflowValidationError, load_workflow workflow_read_only = config.read_only @@ -1300,11 +1309,11 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: ) ensure_pr_strategy = "create" - # Agent-native decompose: a REPO-FUL workflow whose primary terminal - # outcome is an ARTIFACT (coding/decompose-v1) clones the repo for - # context but produces a plan, not a PR. Skip the build/PR post-hooks; - # deliver the agent's result text (the plan JSON) as the artifact so - # the platform can read it and seed the sub-issues. + # #299 agent-native decompose: a REPO-FUL workflow whose primary + # terminal outcome is an ARTIFACT (coding/decompose-v1) clones the + # repo for context but produces a plan, not a PR. Skip the build/PR + # post-hooks; deliver the agent's result text (the plan JSON) as the + # artifact so the platform can read it and seed the sub-issues. # # BOTH conditions matter: a repo-LESS artifact workflow # (default/agent-v1, web-research) never reaches this repo-bound @@ -1319,7 +1328,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: ) artifact_uri: str | None = None # set by the decompose (artifact) branch below - # Clarify-before-spend: a writeable, PR-producing task + # 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 @@ -1367,14 +1376,15 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: ) post_span.set_attribute("artifact.uri", artifact_uri or "") else: - # If the agent switched off the platform branch (it sometimes - # runs `git checkout -b ` and commits/opens its PR - # there), 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. + # ABCA-815 root cause: if the agent switched off the platform + # branch (it sometimes runs `git checkout -b ` and + # commits/opens its PR there — live-caught on backgroundagent-dev), + # 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) @@ -1391,23 +1401,21 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # 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" (a false pass). Threaded into - # the verdict + error_message (build_ok=infra) so the platform - # reports a retryable infra fault, not "build failed" and not a - # bogus success. + # ABCA-659 #2: 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. + # K8: 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( @@ -1416,13 +1424,13 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: "— not gating on it; surfacing as inert, not a failure", ) build_passed = True - # When lint is INERT for this repo (no runnable lint task and + # #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). + # 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", @@ -1446,15 +1454,15 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: 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 — 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 would move the card to In Review - # on a red build, telling the board the work is ready for review - # when it isn't. 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. + # 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, @@ -1488,7 +1496,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # Overall status: do not infer success from PR/build when the SDK never # emitted ResultMessage (agent_status=unknown) — that masks protocol gaps. # Gating honors each verify step's declared ``gate`` via the runner's - # gate_status; an undeclared verify_lint never gates (legacy). + # gate_status (#301); an undeclared verify_lint never gates (legacy). agent_status = agent_result.status build_ok = _apply_post_hook_gates( _workflow, @@ -1507,12 +1515,12 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: 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 dependency graph. 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`. + # ABCA-815 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 #247 stacked DAG. 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 @@ -1557,7 +1565,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: ) # NOTE: the terminal status comment on the Jira issue is NOT posted - # here. The deterministic fan-out plane + # here. Since issue #573 the deterministic fan-out plane # (``cdk/src/handlers/fanout-task-events.ts`` ``dispatchToJira``) # owns the Jira final-status comment — it carries cost/turns/ # duration and, crucially, fires even if this agent crashes before @@ -1565,7 +1573,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # double-comment. The agent still posts the *start* comment # (``comment_task_started`` above) for in-flight progress. - # --trace trajectory S3 upload. Runs AFTER + # --trace trajectory S3 upload (design §10.1). Runs AFTER # post-hooks but BEFORE ``write_terminal`` so the resulting # ``trace_s3_uri`` can be persisted atomically with the # terminal-status transition. Fail-open: an S3 error does @@ -1575,9 +1583,9 @@ 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 + # A6/#299: 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 @@ -1643,9 +1651,9 @@ 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, - # A decompose (artifact) workflow carries the plan artifact URI - # here so the platform can read the plan and seed sub-issues; None - # for a normal PR workflow. + # #299: a decompose (artifact) workflow carries the plan 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 @@ -1697,12 +1705,13 @@ 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. # - # 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 - # design intent. Fully wrapped in its own try/except so a trace upload - # failure cannot mask or replace the real exception (we re-raise ``e`` - # at the end). + # K2 review Finding #1 — 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 + # design intent. Fully wrapped in its own try/except so a + # trace upload failure cannot mask or replace the real + # exception (we re-raise ``e`` at the end). crash_trace_s3_uri: str | None = None try: crash_trace_s3_uri = _maybe_upload_trace(config, trajectory, progress) @@ -1722,7 +1731,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: trace_s3_uri=crash_trace_s3_uri, # Still inside `with task_span()`, so the id is live — capture it # here too or FAILED tasks (the primary post-mortem case for the - # replay bundle) persist otel_trace_id: null. + # replay bundle, #515) persist otel_trace_id: null. otel_trace_id=current_otel_trace_id(), ) task_state.write_terminal(config.task_id, "FAILED", crash_result.model_dump()) @@ -1738,10 +1747,10 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: started_reaction_id=linear_eyes_reaction_id, ) # NOTE: no Jira failure comment here — the fan-out plane's - # ``dispatchToJira`` owns the Jira terminal comment and fires on the - # platform side even when this crash path runs, so posting here would - # double-comment. (Contrast the Linear ❌ reaction above, which the - # fan-out plane does not replicate.) + # ``dispatchToJira`` (issue #573) owns the Jira terminal comment + # and fires on the platform side even when this crash path runs, + # so posting here would double-comment. (Contrast the Linear ❌ + # reaction above, which the fan-out plane does not replicate.) raise @@ -1767,17 +1776,18 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: #: Orchestrator payload keys we KNOW about that ``run_task`` does not (yet) #: 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 we want to catch — so we +#: 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. #: Keys not in this set (genuinely foreign) are dropped quietly as before. #: -#: NB: ``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. +#: 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 (N3): 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( { # AgentCore's server.py exports task_started_at as TASK_STARTED_AT, which @@ -1796,12 +1806,11 @@ def run_task_from_payload(payload: dict) -> dict: """Invoke :func:`run_task` from a full orchestrator payload dict. The ECS compute path (``ecs-strategy.ts``) hands the agent the *entire* - orchestrator payload (via an S3 pointer, since the payload can exceed the - inline size limit). Previously the ECS boot command hand-listed a subset of - ``run_task`` kwargs and silently dropped the rest — most visibly - ``channel_source``/``channel_metadata`` (so ECS runs got no Linear/Jira - reactions or channel MCP), plus ``build_command``, ``cedar_policies``, - ``base_branch``/``merge_branches``, ``attachments``, etc. + 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``, + ``cedar_policies``, ``base_branch``/``merge_branches``, ``attachments``, etc. This maps the payload to ``run_task``'s real signature so no field can be silently dropped again: rename the aliased keys, filter to parameters @@ -1821,7 +1830,8 @@ 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. Foreign keys are dropped quietly. + # through" no-op surfaces (N4 / ABCA-487 class). Foreign keys are + # dropped quietly. if key in _KNOWN_ORCHESTRATOR_KEYS and value is not None: log( "WARN", @@ -1840,7 +1850,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. + # would both silently become a bogus turn count (N4). 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/policy.py b/agent/src/policy.py index 057870db0..3b399a568 100644 --- a/agent/src/policy.py +++ b/agent/src/policy.py @@ -4,7 +4,7 @@ restrictions. See ``docs/design/CEDAR_HITL_GATES.md`` for the full design; short summary below. -**Three outcomes**. Each ``evaluate_tool_use`` call walks up to +**Three outcomes** (§2, §6.2). Each ``evaluate_tool_use`` call walks up to two Cedar evaluations interleaved with in-process caches: 1. Hard-deny Cedar eval (agent/policies/hard_deny.cedar + blueprint hard). @@ -14,27 +14,27 @@ patterns. 2.5 Recent-decision cache: same (tool_name, input_sha256) within 60s of a DENIED/TIMED_OUT outcome auto-denies. Session-scoped, cleared on - container restart. + container restart (§12.8). 3. Soft-deny Cedar eval (agent/policies/soft_deny.cedar + blueprint soft). Match → REQUIRE_APPROVAL with merged annotations; rule-scope allowlist match → ALLOW; no match → fall through to step 4. 4. Default ALLOW. -**Cedar-entity conventions**: user-supplied values (bash commands, file -paths) use sentinel resource IDs (``Agent::File::file``, +**Cedar-entity conventions** preserved from Phase 1: user-supplied values +(bash commands, file paths) use sentinel resource IDs (``Agent::File::file``, ``Agent::BashCommand::command``) with the real value in ``context.command`` / ``context.file_path``, because Cedar entity UIDs cannot contain arbitrary characters. -**Annotations** expected on every rule in hard_deny/soft_deny files: -``@rule_id`` (globally unique, kebab/snake_case), ``@tier`` +**Annotations** expected on every rule in hard_deny/soft_deny files +(§5.2): ``@rule_id`` (globally unique, kebab/snake_case), ``@tier`` ("hard"|"soft"), ``@approval_timeout_s`` (int seconds ≥ 30; soft-deny only), ``@severity`` ("low"|"medium"|"high"; soft-deny only), ``@category`` (free-form; UX grouping). Annotation recovery goes through cedarpy's ``policies_to_json_str()``; the round-trip contract is locked by ``tests/test_cedarpy_annotations_contract.py``. -**Fail-closed posture**: any cedarpy exception during evaluation +**Fail-closed posture** (§13): any cedarpy exception during evaluation returns ``Outcome.DENY`` with reason ``"fail-closed: "``. Invalid blueprint policies raise at ``PolicyEngine.__init__`` (task fails to start rather than running with broken rules). @@ -66,50 +66,27 @@ from pathlib import Path from typing import TYPE_CHECKING +from shared_constants import SHARED_CONSTANTS from shell import log if TYPE_CHECKING: from collections.abc import Callable, Iterable # --------------------------------------------------------------------------- -# Constants +# Constants (§3, §5.2, §12.9) # --------------------------------------------------------------------------- -WARN_TIMEOUT_S: int = 120 # a sub-120s approval timeout emits a WARN on blueprint load +WARN_TIMEOUT_S: int = 120 # IMPL-25: sub-120s emits WARN on blueprint load -def _load_shared_constants() -> dict: - """Read ``contracts/constants.json`` (see ``contracts/constants.md``). - - Two candidate paths cover both the deployed image - (``/app/contracts/constants.json`` — Dockerfile copies ``contracts/`` - to ``/app/contracts``) and the local repo layout - (``/contracts/constants.json`` — for tests + dev). Fail-fast on - missing: a missing contract should crash import, not silently fall - back to literals that would re-introduce the drift the contract is - designed to prevent. - """ - here = Path(__file__).resolve() - candidates = [ - here.parent.parent / "contracts" / "constants.json", # /app/contracts/ - here.parent.parent.parent / "contracts" / "constants.json", # /contracts/ - ] - for path in candidates: - if path.is_file(): - return json.loads(path.read_text()) - raise FileNotFoundError( - "contracts/constants.json not found; checked: " + ", ".join(str(p) for p in candidates), - ) - - -_SHARED_CONSTANTS = _load_shared_constants() +_SHARED_CONSTANTS = SHARED_CONSTANTS _AGC = _SHARED_CONSTANTS["approval_gate_cap"] -DEFAULT_APPROVAL_GATE_CAP: int = int(_AGC["default"]) # default per-task approval-gate cap +DEFAULT_APPROVAL_GATE_CAP: int = int(_AGC["default"]) # decision #13 default APPROVAL_GATE_CAP_MIN: int = int(_AGC["min"]) APPROVAL_GATE_CAP_MAX: int = int(_AGC["max"]) _ATS = _SHARED_CONSTANTS["approval_timeout_s"] -FLOOR_TIMEOUT_S: int = int(_ATS["min"]) # approval timeouts below this are rejected at load -DEFAULT_TASK_TIMEOUT_S: int = int(_ATS["default"]) # default per-task approval timeout +FLOOR_TIMEOUT_S: int = int(_ATS["min"]) # §6 decision #6: rejected below this at load +DEFAULT_TASK_TIMEOUT_S: int = int(_ATS["default"]) # §6 decision #6 default def _validate_constants() -> None: @@ -147,10 +124,10 @@ def _validate_constants() -> None: _validate_constants() -CACHE_MAX_ENTRIES: int = 50 # cache bound, decoupled from approvalGateCap -CACHE_TTL_S: float = 60.0 # sliding-window TTL on DENIED/TIMED_OUT cache entries -POLICIES_MAX_BYTES: int = 64 * 1024 # reject blueprint policies larger than 64 KB -APPROVAL_RATE_LIMIT: int = 20 # per-container per-minute approval writes +CACHE_MAX_ENTRIES: int = 50 # §12.9: decoupled from approvalGateCap +CACHE_TTL_S: float = 60.0 # §12.8 sliding-window TTL on DENIED/TIMED_OUT +POLICIES_MAX_BYTES: int = 64 * 1024 # finding #12: reject blueprints > 64 KB +APPROVAL_RATE_LIMIT: int = 20 # §12.9 per-container per-minute approval writes APPROVAL_RATE_WINDOW_S: float = 60.0 # sliding window paired with APPROVAL_RATE_LIMIT _SEVERITY_ORDER = {"low": 0, "medium": 1, "high": 2} @@ -158,8 +135,8 @@ def _validate_constants() -> None: _VALID_SEVERITIES = frozenset(_SEVERITY_ORDER) _VALID_TIERS = frozenset({"hard", "soft"}) -# Tool group membership. Resolves tool_group:file_write scope to -# {Write, Edit} at runtime. +# Tool group membership (§6.4 decision #21). Resolves tool_group:file_write +# scope to {Write, Edit} at runtime. TOOL_GROUPS: dict[str, frozenset[str]] = { "file_write": frozenset({"Write", "Edit"}), } @@ -170,7 +147,7 @@ def _validate_constants() -> None: # --------------------------------------------------------------------------- -# Outcome + PolicyDecision +# Outcome + PolicyDecision (§6.1) # --------------------------------------------------------------------------- @@ -192,12 +169,12 @@ class PolicyDecision: Fields beyond ``outcome`` + ``reason`` + ``duration_ms`` are populated only on ``REQUIRE_APPROVAL``. ``.allowed`` is the backward-compat shim - for older callers that predate the three-outcome engine and treat this - as a simple allow/deny boolean. + for Phase 1a/1b/2 callers that predate the three-outcome engine and + treat this as a simple allow/deny boolean. Not a dataclass — a custom ``__init__`` supports BOTH the new ``outcome=...``-keyed form and the legacy ``allowed=...``-keyed form - so older tests keep working without caller changes. Instances are + so Phase 1 tests keep working without caller changes. Instances are immutable by convention (no mutator methods); callers should treat them as read-only. """ @@ -217,7 +194,7 @@ def __init__( self, *, outcome: Outcome | None = None, - allowed: bool | None = None, # legacy allow/deny kwarg (predates three-outcome) + allowed: bool | None = None, # Legacy Phase 1 kwarg reason: str = "", timeout_s: int | None = None, severity: str | None = None, @@ -238,31 +215,31 @@ def __init__( self.severity = severity self.matching_rule_ids = matching_rule_ids self.duration_ms = duration_ms - # Populated only when the recent-decision-cache step of - # evaluate_tool_use returns a cache-hit DENY. Contains the payload for - # the `policy_decision` milestone with - # `decision_source="recent_decision_cache"`; the hook forwards it to - # progress_writer.write_policy_decision_cached(). Observability-only — - # NOT part of __eq__/__hash__: two cache-hit decisions with different - # original_decision_ts values still represent the same deny outcome. + # IMPL-23: populated only when Step 2.5 of evaluate_tool_use returns + # a cache-hit DENY. Contains the payload for the `policy_decision` + # milestone with `decision_source="recent_decision_cache"`; the hook + # forwards it to progress_writer.write_policy_decision_cached(). + # Observability-only — NOT part of __eq__/__hash__: two cache-hit + # decisions with different original_decision_ts values still + # represent the same deny outcome. self.cache_hit_metadata = cache_hit_metadata - # Structured discriminator for environmental fail-closed denies (Cedar - # engine errored / unavailable) vs. intentional hard-denies and - # cache-driven denies. The hook branches on THIS flag — not a brittle - # ``reason.startswith("fail-closed:")`` string match — to decide whether - # to emit a ``policy_fail_closed`` blocker event. Set True ONLY at - # engine-error/unavailable deny sites; hard-deny and cache-deny leave it - # False. Like ``cache_hit_metadata``, NOT part of __eq__/__hash__ (it is - # derived from outcome+reason, and legacy equality-based tests predate - # the field). + # #251 (decision E): structured discriminator for environmental + # fail-closed denies (Cedar engine errored / unavailable) vs. + # intentional hard-denies and cache-driven denies. The hook branches + # on THIS flag — not a brittle ``reason.startswith("fail-closed:")`` + # string match — to decide whether to emit a ``policy_fail_closed`` + # blocker event. Set True ONLY at engine-error/unavailable deny sites; + # hard-deny and cache-deny leave it False. Like ``cache_hit_metadata``, + # NOT part of __eq__/__hash__ (it is derived from outcome+reason, and + # legacy equality-based tests predate the field). self.fail_closed = fail_closed @property def allowed(self) -> bool: """True only when outcome == ALLOW. DENY and REQUIRE_APPROVAL both map to False so legacy ``if not decision.allowed: return deny`` callers - keep blocking soft-deny hits (preserving at-rest behavior for callers - that predate the three-outcome PreToolUse path). + keep blocking soft-deny hits (preserving at-rest behavior until the + PreToolUse hook is extended to the three-outcome path in Chunk 3). """ return self.outcome == Outcome.ALLOW @@ -338,7 +315,7 @@ def require_approval( # --------------------------------------------------------------------------- -# Allowlist +# Allowlist (§6.4) # --------------------------------------------------------------------------- @@ -349,8 +326,8 @@ class _CachedDecision: ``inserted_at`` is a monotonic timestamp used for TTL/LRU; it is NOT safe to surface in events (monotonic clocks aren't wall-clock and restart at container boot). ``original_decision_ts`` is the ISO-8601 - wall-clock string captured at record time so cache-hit events can - report when the original decision landed. + wall-clock string captured at record time so IMPL-23 cache-hit + events can report when the original decision landed. """ decision: str # "DENIED" | "TIMED_OUT" @@ -362,10 +339,10 @@ class _CachedDecision: class ApprovalAllowlist: """Runtime scope allowlist, seeded from ``initial_approvals`` at task start. - ``matches`` checks tool-scope fast paths (all_session, tool_type, - tool_group, bash_pattern, write_path); rule-scope matches are checked - POST soft-deny-eval in ``evaluate_tool_use`` because rule_ids are not - known until Cedar reports matching policies. + See §6.4. ``matches`` checks tool-scope fast paths (all_session, + tool_type, tool_group, bash_pattern, write_path); rule-scope matches + are checked POST soft-deny-eval in ``evaluate_tool_use`` because + rule_ids are not known until Cedar reports matching policies. """ def __init__(self, initial_scopes: list[str] | None = None) -> None: @@ -385,11 +362,12 @@ def add(self, scope: str) -> None: Whitespace around both the prefix and the value is stripped so ``"tool_type: Read"`` and ``" tool_type:Read "`` normalize to the same internal state; empty-after-strip values are rejected so - ``"tool_type:"`` fails loud. Case is preserved verbatim — - ``"tool_type:read"`` will not match the ``"Read"`` tool name at - runtime. That's intentional (Cedar `like` is case-sensitive) but - the CLI surfaces a WARN on uppercase ``write_path:`` globs to flag - the dev-vs-prod fnmatch footgun. + ``"tool_type:"`` fails loud (finding #6 from Chunk 2 review). + Case is preserved verbatim — ``"tool_type:read"`` will not match + the ``"Read"`` tool name at runtime. That's intentional (Cedar + `like` is case-sensitive) but the CLI surfaces a WARN on uppercase + ``write_path:`` globs to flag the dev-vs-prod fnmatch footgun + (§5.5 finding #15). """ normalized = scope.strip() if normalized == "all_session": @@ -432,7 +410,7 @@ def matches(self, tool_name: str, tool_input: dict) -> bool: return True if tool_name == "Bash": cmd = tool_input.get("command", "") - # fnmatch semantics are a superset of Cedar's `like`. + # fnmatch semantics documented as Cedar-`like` superset (§5.5). if any(fnmatch(cmd, pat) for pat in self._bash_patterns): return True if tool_name in ("Write", "Edit"): @@ -443,7 +421,7 @@ def matches(self, tool_name: str, tool_input: dict) -> bool: # --------------------------------------------------------------------------- -# Recent-decision cache +# Recent-decision cache (§6.2, §12.8, §12.9) # --------------------------------------------------------------------------- @@ -451,8 +429,8 @@ class RecentDecisionCache: """In-process LRU cache of recent DENIED/TIMED_OUT outcomes. Bounded at 50 entries (``CACHE_MAX_ENTRIES``) INDEPENDENT of the per-task - ``approvalGateCap``: a blueprint that raises the gate cap to 200 does NOT - get a larger cache. Two concerns, two bounds: cap = UX ceiling, + ``approvalGateCap`` (§12.9): a blueprint that raises the gate cap to 200 + does NOT get a larger cache. Two concerns, two bounds: cap = UX ceiling, cache = engine memory bound. TTL is 60s on each entry; ``get`` skips expired entries without eviction @@ -460,8 +438,8 @@ class RecentDecisionCache: DENIED/TIMED_OUT — NEVER on APPROVED (so a just-approved call does not auto-deny on the next identical invocation). - **Session-scoped**: cleared on container restart. This is a known - caveat, not a bug; a persistent cache is possible future work. + **Session-scoped**: cleared on container restart. Documented caveat in + §12.8 — not a bug. Persistent cache is §17.5 future work. """ def __init__( @@ -481,7 +459,7 @@ def __init__( # origin branch-b`` because both resolve to the same # ``force_push_any`` rule. Without this the agent can burn # through its max_turns budget hammering on variations the - # user has already said no to (observed in end-to-end testing). + # user has already said no to (observed in E2E Phase 4). self._rule_entries: OrderedDict[tuple[str, str], _CachedDecision] = OrderedDict() self._max_entries = max_entries self._ttl_s = ttl_s @@ -499,7 +477,7 @@ def record( ``original_decision_ts`` is the ISO-8601 wall-clock time of the original approval decision that seeded this cache entry. Surfaced - on subsequent cache-hit events so operators can correlate + on subsequent cache-hit events (IMPL-23) so operators can correlate cache-driven denies back to the originating gate. Falsy values (``None`` or empty string) fall back to "now" at record time, so legacy test callers and corrupted outcome rows keep working. @@ -591,7 +569,7 @@ def __len__(self) -> int: # --------------------------------------------------------------------------- -# Annotation handling +# Annotation handling (§5.2, §6.3, §12.4) # --------------------------------------------------------------------------- @@ -650,9 +628,9 @@ def _validate_tier(rules: list[_ParsedRule], expected_tier: str, source: str) -> Exception: ``base_permit`` is allowed without @tier ONLY when it's a ``permit`` effect — the neutral catch-all at the top of each tier. A misnamed ``forbid`` annotated ``@rule_id("base_permit")`` would - otherwise bypass validation entirely; restricting the exemption to - ``effect == "permit"`` forces genuine forbid rules through the regular - validation path. + otherwise bypass validation entirely (silent-failure finding #7 from + Chunk 2 review); restricting the exemption to ``effect == "permit"`` + forces genuine forbid rules through the regular validation path. """ seen_rule_ids: set[str] = set() for rule in rules: @@ -684,7 +662,7 @@ def _validate_tier(rules: list[_ParsedRule], expected_tier: str, source: str) -> f"floor {FLOOR_TIMEOUT_S}s" ) if rule.approval_timeout_s < WARN_TIMEOUT_S: - # Advisory WARN, not a strict reject. + # IMPL-25: advisory WARN, not strict reject. log( "WARN", f"{source}: rule {rule.rule_id!r} has " @@ -704,7 +682,7 @@ def _merge_annotations( matching_policy_ids: list[str], task_default_timeout_s: int, ) -> tuple[list[str], int, str]: - """Merge annotations across multiple matching soft-deny policies. + """Merge annotations across multiple matching soft-deny policies (§6.3). Timeout: min across rules (clamped by FLOOR_TIMEOUT_S). Severity: max. rule_ids preserved in order of match. If a matching rule has no @@ -764,7 +742,7 @@ def _sha256_tool_input(tool_input: dict) -> str: # --------------------------------------------------------------------------- -# PolicyEngine — the main three-outcome engine +# PolicyEngine — the main three-outcome engine (§6.2) # --------------------------------------------------------------------------- @@ -776,11 +754,11 @@ class PolicyEngine: disable-list validation), probes a test authorization to catch syntax errors early, and seeds the approval allowlist from ``initial_approvals``. - Legacy callers that pass ``extra_policies=[...]`` (the older shape) are + Legacy callers that pass ``extra_policies=[...]`` (Phase 1 shape) are supported in backward-compat mode: the extra text is appended to the - soft-deny tier WITHOUT strict annotation validation. New callers should - use ``blueprint_hard_policies`` / ``blueprint_soft_policies`` / - ``blueprint_disable`` / ``initial_approvals`` / ``approval_gate_cap``. + soft-deny tier WITHOUT strict annotation validation. New callers in + Chunks 3+ should use ``blueprint_hard_policies`` / ``blueprint_soft_policies`` + / ``blueprint_disable`` / ``initial_approvals`` / ``approval_gate_cap``. """ def __init__( @@ -804,7 +782,7 @@ def __init__( self._disabled = False self._task_default_timeout_s = task_default_timeout_s - # Bounds check on approval_gate_cap. + # Bounds check on approval_gate_cap (decision #13). if not APPROVAL_GATE_CAP_MIN <= approval_gate_cap <= APPROVAL_GATE_CAP_MAX: raise ValueError( f"approval_gate_cap must be in " @@ -819,26 +797,27 @@ def __init__( f"initial_approval_gate_count must be >= 0, got {initial_approval_gate_count}" ) - # Per-task gate counter + per-container sliding-window rate limit. + # §12.9 per-task gate counter + per-container sliding-window rate limit. # The counter is session-scoped within a container but seeded from the - # persisted TaskTable value so container restarts resume the cumulative - # gate budget instead of resetting to 0. The rate-limit window stays - # per-container by design. + # persisted TaskTable value (§13.6) so container restarts resume the + # cumulative gate budget instead of resetting to 0. The rate-limit + # window stays per-container by design (§13.6 finding #10 scenario). self._approval_gate_count: int = initial_approval_gate_count self._approvals_last_minute: deque[float] = deque() - # Queue consumed by ``_denial_between_turns_hook``. Each entry is - # ``{"request_id", "reason", "decided_at"}``; reason is already - # sanitized upstream by the deny path. + # §6.5 queue consumed by ``_denial_between_turns_hook``. Each entry + # is ``{"request_id", "reason", "decided_at"}``; reason is already + # sanitized by DenyTaskFn (§12.6). self._denial_injection_queue: list[dict] = [] - # ``approval_ceiling_shrinking`` is emit-once per task. + # IMPL-26: ``approval_ceiling_shrinking`` is emit-once per task. self._emitted_ceiling_shrinking: bool = False # ``task_type`` here is the workflow-derived Cedar principal identity # (config.policy_principal): new_task / pr_iteration / pr_review. - # Read-only enforcement no longer keys off this principal literal — it - # keys off the ``read_only`` context attribute (threaded below into - # every Cedar request), so the hard-deny Write/Edit rules fire for *any* - # read-only workflow, not just coding/pr-review. See ADR-014. + # Read-only enforcement no longer keys off this principal literal — + # since #248 Phase 2a it keys off the ``read_only`` context attribute + # (threaded below into every Cedar request), so the hard-deny Write/Edit + # rules fire for *any* read-only workflow, not just coding/pr-review. + # See ADR-014 addendum 2026-06-08. # Import cedarpy lazily so the module still loads in environments # without the native extension (tests can monkey-patch). @@ -879,9 +858,10 @@ def __init__( # annotation semantics on duplicate keys within a single policy # are implementation-defined (parse error in most versions), so # we REJECT legacy text that already declares @tier or @rule_id - # instead of silently picking one interpretation. Callers should - # migrate to blueprint_soft_policies / blueprint_hard_policies with - # fully annotated rules. + # (finding #2 from Chunk 2 review) instead of silently picking + # one interpretation. Callers should migrate to + # blueprint_soft_policies / blueprint_hard_policies with fully + # annotated rules. legacy_extra: list[str] = [] if extra_policies: for idx, policy_text in enumerate(extra_policies): @@ -899,8 +879,8 @@ def __init__( if legacy_extra: soft_text = soft_text + "\n" + "\n".join(legacy_extra) - # 64 KB cap on combined blueprint text. Built-ins do not count - # against the cap — they are trusted platform content. + # 64 KB cap on combined blueprint text (finding #12). Built-ins do + # not count against the cap — they are trusted platform content. blueprint_text = "".join(filter(None, [blueprint_hard_policies, blueprint_soft_policies])) if len(blueprint_text.encode("utf-8")) > POLICIES_MAX_BYTES: raise ValueError( @@ -925,8 +905,8 @@ def __init__( raise ValueError(f"soft-deny policy validation failed: {exc}") from exc # blueprint_disable: reject any entry that names a built-in hard-deny - # rule. Built-in hard rule IDs come from the original builtin_hard - # text, NOT the concatenated hard_text. + # rule (finding #9, §5.1). Built-in hard rule IDs come from the + # original builtin_hard text, NOT the concatenated hard_text. builtin_hard_rule_ids = { r.rule_id for r in _parse_policy_annotations(self._cedarpy, builtin_hard) @@ -936,7 +916,7 @@ def __init__( if disable_id in builtin_hard_rule_ids: raise ValueError( f"blueprint disable[{disable_id!r}]: cannot disable built-in " - f"hard-deny rule; hard-deny is absolute" + f"hard-deny rule; hard-deny is absolute (§5.1, §12.5)" ) # Disabled soft rules are filtered at evaluate_tool_use time: if a # soft-deny eval's matching rule_ids are ALL in the disable set, @@ -1005,11 +985,11 @@ def allowlist(self) -> ApprovalAllowlist: def recent_decisions(self) -> RecentDecisionCache: return self._cache - # ---- Approval-gate counters + denial queue ---------------------------- + # ---- Approval-gate counters + denial queue (§6.5, §12.9) -------------- @property def approval_gate_count(self) -> int: - """Count of REQUIRE_APPROVAL gates emitted this task (session-scoped).""" + """Session-scoped count of REQUIRE_APPROVAL gates emitted this task.""" return self._approval_gate_count def increment_approval_gate_count(self) -> None: @@ -1042,8 +1022,8 @@ def queue_denial_injection( ) -> None: """Append a denial-injection payload for ``_denial_between_turns_hook``. - Reason is expected to be pre-sanitized upstream by the deny path. The - hook is responsible for XML-escaping at injection time. + Reason is expected to be pre-sanitized upstream (by ``DenyTaskFn``, + §12.6). The hook is responsible for XML-escaping at injection time. """ self._denial_injection_queue.append( {"request_id": request_id, "reason": reason, "decided_at": decided_at} @@ -1056,7 +1036,7 @@ def drain_denial_injections(self) -> list[dict]: return out def mark_ceiling_shrinking_emitted(self) -> bool: - """Idempotency latch for ``approval_ceiling_shrinking``. + """Idempotency latch for ``approval_ceiling_shrinking`` (IMPL-26). Returns ``True`` the first time it is called (caller should emit the milestone) and ``False`` on every subsequent call. @@ -1180,7 +1160,7 @@ def evaluate_tool_use(self, tool_name: str, tool_input: dict) -> PolicyDecision: # Compute input_sha separately so a TypeError from json.dumps # surfaces with a distinct fail-closed reason instead of being - # mis-attributed to Cedar evaluation. + # mis-attributed to Cedar evaluation (finding #5 from review). try: input_sha = _sha256_tool_input(tool_input) except (TypeError, ValueError) as exc: @@ -1222,7 +1202,7 @@ def evaluate_tool_use(self, tool_name: str, tool_input: dict) -> PolicyDecision: # STEP 2.5 — Recent-decision cache. cached = self._cache.get(tool_name, input_sha) if cached is not None: - # Attach cache-hit metadata so the hook can emit a + # IMPL-23: attach cache-hit metadata so the hook can emit a # `policy_decision` milestone to TaskEventsTable. Keeps the # engine pure — policy.py never calls the progress writer. return PolicyDecision( @@ -1250,7 +1230,7 @@ def evaluate_tool_use(self, tool_name: str, tool_input: dict) -> PolicyDecision: all_matching_ids = _matching_rule_ids( self._soft_rules, soft_decision[1], tier_name="soft" ) - # Filter out blueprint-disabled rules (the `disable:` list). + # Filter out blueprint-disabled rules (§5.1 `disable:` list). # If ALL matches are disabled, the soft-deny hit is neutralized # and we fall through to default ALLOW. If some are disabled # but others remain, the surviving rules drive REQUIRE_APPROVAL. @@ -1269,7 +1249,7 @@ def evaluate_tool_use(self, tool_name: str, tool_input: dict) -> PolicyDecision: duration_ms=(time.monotonic() - start) * 1000, ) - # Rule-level recent-deny cache. + # Rule-level recent-deny cache (§12.8 extension). # If the user recently denied any of these rule_ids on # this tool, fast-deny without a new approval gate. # Catches semantic retries the input-hash cache misses @@ -1341,15 +1321,16 @@ def _eval_for_tool( """Run the appropriate Cedar eval(s) for a given tool + input. Returns the first deny decision + matching policy IDs, or None if - no eval at this tier matched anything. The routing (invoke_tool - sentinel for tool-type, write_file for Write/Edit, execute_bash for - Bash) keeps existing tests working. + no eval at this tier matched anything. Mirrors the Phase 1 routing + so existing tests (invoke_tool sentinel for tool-type, write_file + for Write/Edit, execute_bash for Bash) keep working. ``no_decision`` responses are logged at WARN — Cedar should always reach a definite allow/deny given the base_permit catch-all, so - no_decision means the catch-all is missing or malformed. Fall-through - to subsequent action evals continues either way; the log gives - operators signal without changing behavior. + no_decision means the catch-all is missing or malformed (finding + #9 from Chunk 2 review). Fall-through to subsequent action evals + continues either way; the log gives operators signal without + changing behavior. """ def _run(action: str, resource_type: str, resource_id: str, ctx: dict): @@ -1412,7 +1393,7 @@ def _matching_rule_ids( Logs WARN on any policy ID that doesn't resolve to a parsed rule — the condition indicates a state inconsistency (e.g. ``_hard_policies`` mutated without re-parsing) and was silently ignored in earlier - revisions. + revisions (finding #3 from Chunk 2 review). """ by_id = {r.policy_id: r for r in rules} resolved: list[str] = [] diff --git a/agent/src/prompt_builder.py b/agent/src/prompt_builder.py index d0f11df9d..1b901f8d3 100644 --- a/agent/src/prompt_builder.py +++ b/agent/src/prompt_builder.py @@ -30,8 +30,8 @@ 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: the new_task workflow references this marker in its - # "ask instead of guess" branch. Harmless no-op for prompts that don't + # 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 = ( @@ -41,41 +41,42 @@ def build_system_prompt( ) system_prompt = system_prompt.replace("{setup_notes}", setup_notes) - # A revise-round decompose task carries the PRIOR run's repo_digest in - # channel_metadata (a NON-guardrail-screened channel — task_description is - # screened, this isn't). Inject it so the agent starts from the cached - # structural understanding instead of re-deriving it. Cache-key discipline: - # the prior run recorded the sha it cloned to (decompose_repo_digest_sha); if - # the repo has since moved, the agent is told the digest may be stale for - # changed areas and to re-verify there (drift handling is agent-side — the - # platform has no GitHub token to pre-check, by least-privilege design). - # Harmless no-op for a prompt without the placeholder or a first-round task - # with no prior digest. + # #299 plan-mode T2 (warm digest): a revise-round decompose task carries the + # PRIOR run's repo_digest in channel_metadata (a NON-guardrail-screened + # channel — task_description is screened, this isn't; see create-task-core). + # Inject it so the agent starts from the cached structural understanding + # instead of re-deriving it. Cache-key discipline: the prior run recorded the + # sha it cloned to (decompose_repo_digest_sha); if the repo has since moved, + # the agent is told the digest may be stale for changed areas and to re-verify + # there (drift handling, agent-side — the platform has no GitHub token to + # pre-check, by P5 least-privilege design). Harmless no-op for a prompt + # without the placeholder or a round-0 task with no prior digest. system_prompt = system_prompt.replace( "{prior_repo_digest}", _render_prior_repo_digest(config, setup), ) - # On a REVISION round the task carries the CURRENT breakdown (in the - # guardrail-screened task_description, as "Earlier proposed breakdown") plus - # the reviewer's requested change. Without explicit framing the decompose - # prompt reads as "plan this issue from scratch", so the agent re-derives from - # the issue text and silently reverts edits the reviewer had already accepted - # (a dropped node reappears, a reworded title snaps back). This directive — - # injected ONLY on a revision — reframes the task as EDIT-the-current-plan: - # apply only the requested change, keep everything else verbatim. It lives in - # the trusted system prompt (NOT the screened task_description, which can't - # carry imperatives without tripping the prompt-injection filter). Empty on - # the first round. NOTE: only the ESCALATION path reaches this agent now — - # most revises are applied deterministically in the webhook (interpret → edit - # the stored plan in code, no clone, no re-derive). + # #299 BLOCKER-1 (revise-forgets-edits): on a REVISION round the task carries + # the CURRENT breakdown (in the guardrail-screened task_description, as + # "Earlier proposed breakdown") plus the reviewer's requested change. Without + # explicit framing the decompose prompt reads as "plan this issue from + # scratch", so the agent re-derives from the issue text and silently reverts + # edits the reviewer had already accepted (a dropped node reappears, a reworded + # title snaps back). This directive — injected ONLY on a revision — reframes + # the task as EDIT-the-current-plan: apply only the requested change, keep + # everything else verbatim. It lives in the trusted system prompt (NOT the + # screened task_description, which can't carry imperatives without tripping + # PROMPT_ATTACK — Bug #1a). Empty on round 0. NOTE: only the ESCALATION path + # reaches this agent now — most revises are applied deterministically in the + # webhook (interpret → edit the stored plan in code, no clone, no re-derive). system_prompt = system_prompt.replace( "{revision_directive}", _render_revision_directive(config), ) - # The sha the repo was cloned to, echoed into the plan JSON's - # ``repo_digest_sha`` so a later revise run can drift-check the cached digest. - # Empty when unknown (best-effort — the platform's sha-shape guard then just - # treats the digest as un-versioned). Harmless no-op without the placeholder. + # #299 plan-mode T2: the sha the repo was cloned to, echoed into the plan + # JSON's ``repo_digest_sha`` so a later revise run can drift-check the cached + # digest. Empty when unknown (best-effort — the platform's sha-shape guard + # then just treats the digest as un-versioned). Harmless no-op without the + # placeholder. system_prompt = system_prompt.replace("{repo_head_sha}", setup.head_sha_before or "") # Inject memory context from orchestrator hydration @@ -125,7 +126,7 @@ def build_repoless_system_prompt( hydrated_context: HydratedContext | None, overrides: str, ) -> str: - """Assemble the system prompt for a repo-less workflow. + """Assemble the system prompt for a repo-less workflow (#248 Phase 3). The repo-bound :func:`build_system_prompt` requires a ``RepoSetup`` (branch, default_branch, setup notes); a repo-less task has none. This builds the @@ -154,8 +155,8 @@ def build_repoless_system_prompt( def _render_prior_repo_digest(config: TaskConfig, setup: RepoSetup) -> str: - """Render the cached prior repo digest into the decompose prompt, or empty - string when there is none (first-round plan / non-decompose). + """#299 plan-mode T2 — render the cached prior repo digest into the decompose + prompt, or empty string when there is none (round-0 plan / non-decompose). A revise-round ``coding/decompose-v1`` task carries the previous run's ``repo_digest`` + the sha it was built at in ``channel_metadata`` (keys @@ -169,14 +170,14 @@ def _render_prior_repo_digest(config: TaskConfig, setup: RepoSetup) -> str: Drift: the prior run recorded the sha it cloned to. If the repo has since moved (``head_sha_before`` differs), the digest may be stale for changed areas, so we say so and tell the agent to re-verify there. The platform can't pre-check the - sha (no GitHub token, by least-privilege), so this agent-side compare IS the + sha (no GitHub token — P5 least-privilege), so this agent-side compare IS the drift handling. A blank prior sha (older task) is treated as "unknown → trust but re-verify if anything looks off". """ cm = config.channel_metadata or {} digest = (cm.get("decompose_repo_digest") or "").strip() if not digest: - return "" # first round or no cached digest → the agent explores fresh + return "" # round-0 or no cached digest → the agent explores fresh prior_sha = (cm.get("decompose_repo_digest_sha") or "").strip() current_sha = (setup.head_sha_before or "").strip() if prior_sha and current_sha and prior_sha != current_sha: @@ -203,22 +204,22 @@ def _render_prior_repo_digest(config: TaskConfig, setup: RepoSetup) -> str: def _render_revision_directive(config: TaskConfig) -> str: - """Render the revise-in-place directive for a REVISION round, or empty string - for a first-time plan. + """#299 BLOCKER-1 — render the revise-in-place directive for a REVISION round, + or empty string for a first-time plan. A revise-round ``coding/decompose-v1`` task carries the CURRENT breakdown in its (guardrail-screened) task_description as reference data, plus the reviewer's requested change. Without explicit framing the decompose prompt reads as "plan this issue from scratch" and the agent re-derives the whole breakdown, silently reverting edits the reviewer had already accepted (dropped - nodes reappear, reworded titles snap back). + nodes reappear, reworded titles snap back — the customer-caught BLOCKER 1). This directive reframes the task as an EDIT of the current plan: start FROM it, apply ONLY the requested change, keep every other sub-issue verbatim. It must live in the trusted system prompt — the screened task_description can't carry imperatives ("start from this plan and change only X") without tripping the - prompt-injection filter. Gated on ``decompose_revision_round`` (set by the - webhook only on a revise dispatch); a blank/zero/absent value → first round → + PROMPT_ATTACK filter (Bug #1a). Gated on ``decompose_revision_round`` (set by + the webhook only on a revise dispatch); a blank/zero/absent value → round 0 → empty (no-op). NOTE: most revises never reach this agent — the webhook interprets the change @@ -290,15 +291,17 @@ def _channel_prompt_addendum(config: TaskConfig) -> str: Jira-origin tasks intentionally get NO addendum: Atlassian's Remote MCP requires an interactive OAuth flow a headless agent can't complete, so the - MCP tools never load. Jira progress comments are posted out-of-band by - ``jira_reactions`` (a REST shim wired into the pipeline), not by the agent. + MCP tools never load. Instructing the agent to use them just wastes turns. + Jira progress comments are posted out-of-band by ``jira_reactions`` through + the configured Forge app actor (or the legacy OAuth migration fallback), + not by the coding agent. """ if config.channel_source != "linear": return "" - # A synthetic orchestration integration node 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. + # #247 UX.16: a synthetic orchestration integration node 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 "" diff --git a/agent/src/server.py b/agent/src/server.py index 09e1f50c0..7a8ed3f7c 100644 --- a/agent/src/server.py +++ b/agent/src/server.py @@ -34,7 +34,7 @@ # doesn't forward container stdout to APPLICATION_LOGS, so a broken writer # is invisible except for this metric. Single counter = single alarm # surface — the trade-off is that the alarm can't distinguish which writer -# is broken. Defined BEFORE any function that +# is broken (see Chunk 7c review notes). Defined BEFORE any function that # references it (including ``_debug_cw`` / ``_warn_cw``) so the ordering is # import-time safe: a daemon thread spawned from a write-blocking function # can never race with module-level globals still being assigned. @@ -50,7 +50,12 @@ def _redact_cached_credentials(text: str) -> str: """Remove cached env secrets from debug text before stdout / CloudWatch.""" out = text - for env_key in ("GITHUB_TOKEN", "LINEAR_API_TOKEN", "JIRA_API_TOKEN"): + for env_key in ( + "GITHUB_TOKEN", + "LINEAR_API_TOKEN", + "JIRA_API_TOKEN", + "JIRA_APP_ACTOR_SHARED_SECRET", + ): secret = os.environ.get(env_key) or "" if len(secret) >= _MIN_REDACTABLE_SECRET_LEN: out = out.replace(secret, f"<{env_key}_REDACTED>") @@ -120,8 +125,8 @@ def _debug_cw_exc( def _warn_cw(msg: str, *, task_id: str | None = None) -> None: """Emit a server-level warning to stdout AND CloudWatch. - AgentCore doesn't forward container stdout to APPLICATION_LOGS (see - the ``_debug_cw`` comment block above), so + Chunk 7c — AgentCore doesn't forward container stdout to + APPLICATION_LOGS (see the ``_debug_cw`` comment block above), so warning ``print`` calls about malformed invocation payloads are effectively invisible in production. Route them through the same daemon-thread CloudWatch writer used by ``_debug_cw`` (writing to @@ -303,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 on a single request via - diagnostic logging): + one of two header spellings (both observed 2026-05-18 on a single + request via diagnostic logging in us-east-1): 1. ``WorkloadAccessToken`` — the SDK's documented header in ``bedrock_agentcore.runtime.models::ACCESS_TOKEN_HEADER``. 2. ``x-amzn-bedrock-agentcore-runtime-workload-accesstoken`` — @@ -458,7 +463,7 @@ def _run_task_background( try: # Propagate the correlation envelope into this thread's OTEL context # so spans are correlated with the AgentCore session and the platform - # identity in CloudWatch. Runs whenever any field is present — + # identity in CloudWatch (#245). Runs whenever any field is present — # session_id may be empty while user_id/repo are known. if session_id or user_id or repo_url: propagate_correlation_context(session_id, user_id=user_id, repo=repo_url or None) @@ -541,21 +546,21 @@ 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). + # #247 A4: 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 human-in-the-loop approvals — per-task approval defaults + seeded - # allowlist. Both are forwarded verbatim to the pipeline; the engine + # Cedar HITL (§7.3) — per-task approval defaults + seeded allowlist. + # Both are forwarded verbatim to the pipeline; the engine # validates shape at construction time and raises on bad input. approval_timeout_s = inp.get("approval_timeout_s") initial_approvals = inp.get("initial_approvals") or [] - # TaskTable-persisted ``approval_gate_count`` threaded by the orchestrator - # so a container restart resumes the cumulative gate budget instead of - # resetting to 0. Non-int payloads + # Chunk 7: TaskTable-persisted ``approval_gate_count`` threaded by + # the orchestrator so a container restart (§13.6) resumes the + # cumulative gate budget instead of resetting to 0. Non-int payloads # coerce to 0 to keep the invocation path fail-open on a malformed # field; the downstream PolicyEngine rejects negatives loudly. raw_gate_count = inp.get("initial_approval_gate_count", 0) @@ -569,9 +574,9 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: task_id=inp.get("task_id"), ) initial_approval_gate_count = 0 - # Per-task cap resolved by the submit path and persisted on the - # TaskRecord. Threaded so a blueprint-configured cap (or the - # default-50 frozen at submit) wins + # Chunk 7b (§4 step 5, decision #13): per-task cap resolved by the + # submit path and persisted on the TaskRecord. Threaded so a + # blueprint-configured cap (or the default-50 frozen at submit) wins # over the PolicyEngine's compile-time fallback on restarts. A # malformed payload coerces to ``None`` so the engine can still # construct; its own bounds check would reject anything out-of-range. @@ -591,17 +596,18 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: channel_source = inp.get("channel_source", "") or "" channel_metadata = inp.get("channel_metadata") or {} attachments = inp.get("attachments") or [] - # ``trace`` is strictly opt-in. Accept only real booleans from the - # orchestrator — a string "false" would otherwise flip the flag on. + # ``trace`` is strictly opt-in (design §10.1). Accept only real + # booleans from the orchestrator — a string "false" would otherwise + # flip the flag on. trace = inp.get("trace") is True # Platform user_id (Cognito ``sub``). Only consumed when ``trace`` # is true (see ``TaskConfig.user_id``). String check defends against # a non-string payload — the agent writes this into an S3 key, so a # surprise ``None`` or int would blow up later at upload time. # When coercion fires, WARN loudly: a silent empty string combined - # with ``trace=True`` would make the upload path skip the S3 write - # with zero observability, and a user-reported "my trace vanished" - # investigation would find nothing. + # with ``trace=True`` would make Stage 4's upload path skip the S3 + # write with zero observability, and a user-reported "my trace + # vanished" investigation would find nothing. raw_user_id = inp.get("user_id", "") if isinstance(raw_user_id, str): user_id = raw_user_id @@ -616,9 +622,9 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: session_id = request.headers.get("x-amzn-bedrock-agentcore-runtime-session-id", "") - # Stamp TASK_STARTED_AT so the PreToolUse hook's - # ``_remaining_maxlifetime_s`` (agent/src/hooks.py) has the real - # per-task clock to compute the maxLifetime ceiling. Without + # Cedar HITL: stamp TASK_STARTED_AT so the PreToolUse hook's + # ``_remaining_maxlifetime_s`` (agent/src/hooks.py §6.5) has the + # real per-task clock to compute the maxLifetime ceiling. Without # this the hook's ceiling computation silently falls back to # "unknown, don't clip" (fail-open) and the user may be asked for # approval on a gate whose window will expire before they can @@ -674,8 +680,8 @@ def _validate_required_params(params: dict) -> list[str]: """Check the minimum viable param set for the pipeline. Returns the list of missing field names (empty list = valid). A repo-bound - workflow requires ``repo_url``; a repo-less workflow (``requires_repo:false``) - does not. All non-PR workflows need either an ``issue_number`` + 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`` / ``coding/restack-v1``) require ``pr_number`` instead and carry no description. diff --git a/agent/src/shared_constants.py b/agent/src/shared_constants.py index e69de29bb..95a2a81d0 100644 --- a/agent/src/shared_constants.py +++ b/agent/src/shared_constants.py @@ -0,0 +1,29 @@ +"""Load cross-language constants shared by the agent runtime.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def _load_shared_constants() -> dict[str, Any]: + """Read the contract in deployed and local repository layouts.""" + here = Path(__file__).resolve() + candidates = [ + here.parent.parent / "contracts" / "constants.json", + here.parent.parent.parent / "contracts" / "constants.json", + ] + for path in candidates: + if path.is_file(): + value = json.loads(path.read_text()) + if not isinstance(value, dict): + raise ValueError(f"{path} must contain a JSON object") + return value + raise FileNotFoundError( + "contracts/constants.json not found; checked: " + + ", ".join(str(path) for path in candidates), + ) + + +SHARED_CONSTANTS = _load_shared_constants() diff --git a/agent/tests/test_config.py b/agent/tests/test_config.py index 79a85704d..f1b630c54 100644 --- a/agent/tests/test_config.py +++ b/agent/tests/test_config.py @@ -1,5 +1,6 @@ """Unit tests for config.py — build_config and constants.""" +import os from datetime import UTC from unittest.mock import MagicMock, patch @@ -8,6 +9,7 @@ from config import ( PR_WORKFLOW_IDS, build_config, + clear_jira_task_credentials, resolve_jira_oauth_token, resolve_linear_api_token, ) @@ -548,6 +550,48 @@ def test_returns_empty_when_secret_arn_missing(self, monkeypatch): assert resolve_jira_oauth_token() == "" mock_boto.assert_not_called() + def test_clear_jira_task_credentials_removes_oauth_and_app_actor_values( + self, + monkeypatch, + ): + for name in ( + "JIRA_API_TOKEN", + "JIRA_APP_ACTOR_CONFIGURED", + "JIRA_APP_ACTOR_PROXY_URL", + "JIRA_APP_ACTOR_SHARED_SECRET", + ): + monkeypatch.setenv(name, "stale") + + clear_jira_task_credentials() + + assert "JIRA_API_TOKEN" not in os.environ + assert "JIRA_APP_ACTOR_CONFIGURED" not in os.environ + assert "JIRA_APP_ACTOR_PROXY_URL" not in os.environ + assert "JIRA_APP_ACTOR_SHARED_SECRET" not in os.environ + + def test_metadata_bound_task_clears_stale_app_actor_when_secret_arn_missing( + self, + monkeypatch, + ): + """A malformed next task cannot inherit another tenant's Forge credentials.""" + monkeypatch.delenv("JIRA_OAUTH_SECRET_ARN", raising=False) + monkeypatch.setenv("JIRA_API_TOKEN", "previous-human-token") + monkeypatch.setenv("JIRA_APP_ACTOR_CONFIGURED", "1") + monkeypatch.setenv( + "JIRA_APP_ACTOR_PROXY_URL", + "https://previous.webtrigger.atlassian.app/public/trigger", + ) + monkeypatch.setenv("JIRA_APP_ACTOR_SHARED_SECRET", "s" * 64) + + with patch("boto3.client") as mock_boto: + assert resolve_jira_oauth_token({"jira_cloud_id": "next-tenant"}) == "" + mock_boto.assert_not_called() + + assert "JIRA_API_TOKEN" not in os.environ + assert "JIRA_APP_ACTOR_CONFIGURED" not in os.environ + assert "JIRA_APP_ACTOR_PROXY_URL" not in os.environ + assert "JIRA_APP_ACTOR_SHARED_SECRET" not in os.environ + def test_returns_empty_when_region_missing(self, monkeypatch): """No region → can't construct boto3 client → empty + WARN, no SDK call.""" monkeypatch.delenv("JIRA_API_TOKEN", raising=False) @@ -592,6 +636,69 @@ def test_resolves_from_secrets_manager_and_caches_in_env(self, monkeypatch): # Reset for other tests. monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + def test_resolves_forge_app_actor_even_when_oauth_token_is_expiring(self, monkeypatch): + """Forge credentials are independent of the human 3LO token lifetime.""" + import json + from datetime import datetime, timedelta + + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + monkeypatch.setenv("AWS_REGION", "us-east-1") + soon = (datetime.now(UTC) + timedelta(seconds=10)).isoformat().replace("+00:00", "Z") + token_payload = { + "access_token": "expired-human-token", + "refresh_token": "refresh", + "expires_at": soon, + "scope": "read:jira-work write:jira-work", + "client_id": "cid", + "client_secret": "csec", + "cloud_id": "cloud-uuid", + "site_url": "https://acme.atlassian.net", + "installed_at": "2026-05-19T08:00:00Z", + "updated_at": "2026-05-19T08:00:00Z", + "installed_by_platform_user_id": "cog-sub", + "app_actor_proxy_url": ("https://install.webtrigger.atlassian.app/public/trigger"), + "app_actor_shared_secret": "s" * 64, + "app_actor_configured_at": "2026-07-23T00:00:00Z", + } + mock_sm = MagicMock() + mock_sm.get_secret_value.return_value = {"SecretString": json.dumps(token_payload)} + + with patch("boto3.client", return_value=mock_sm): + assert resolve_jira_oauth_token({"jira_oauth_secret_arn": "arn:test"}) == "" + + assert os.environ["JIRA_APP_ACTOR_CONFIGURED"] == "1" + assert os.environ["JIRA_APP_ACTOR_PROXY_URL"].endswith("/public/trigger") + assert os.environ["JIRA_APP_ACTOR_SHARED_SECRET"] == "s" * 64 + monkeypatch.delenv("JIRA_APP_ACTOR_CONFIGURED", raising=False) + monkeypatch.delenv("JIRA_APP_ACTOR_PROXY_URL", raising=False) + monkeypatch.delenv("JIRA_APP_ACTOR_SHARED_SECRET", raising=False) + + def test_metadata_only_app_actor_configuration_fails_closed(self, monkeypatch): + import json + + monkeypatch.setenv("AWS_REGION", "us-east-1") + token_payload = { + "access_token": "human-token", + "refresh_token": "refresh", + "expires_at": "2099-01-01T00:00:00Z", + "app_actor_account_id": "app-account-1", + "app_actor_proxy_url": ["not", "a", "string"], + "app_actor_shared_secret": 123, + } + mock_sm = MagicMock() + mock_sm.get_secret_value.return_value = {"SecretString": json.dumps(token_payload)} + + with patch("boto3.client", return_value=mock_sm): + assert ( + resolve_jira_oauth_token({"jira_oauth_secret_arn": "arn:metadata-only"}) + == "human-token" + ) + + assert os.environ["JIRA_APP_ACTOR_CONFIGURED"] == "1" + assert "JIRA_APP_ACTOR_PROXY_URL" not in os.environ + assert "JIRA_APP_ACTOR_SHARED_SECRET" not in os.environ + monkeypatch.delenv("JIRA_APP_ACTOR_CONFIGURED", raising=False) + def test_returns_empty_on_secrets_manager_access_denied(self, monkeypatch): """ClientError surfaces as empty + ERROR log, never crashes the agent.""" from botocore.exceptions import ClientError @@ -606,6 +713,21 @@ def test_returns_empty_on_secrets_manager_access_denied(self, monkeypatch): with patch("boto3.client", return_value=mock_sm): assert resolve_jira_oauth_token({"jira_oauth_secret_arn": "arn:test"}) == "" + @pytest.mark.parametrize("payload", [[], 5, "scalar", True]) + def test_returns_empty_on_valid_non_object_secret_json(self, monkeypatch, payload): + """Valid JSON scalars/lists must not escape as AttributeError.""" + import json + + monkeypatch.setenv("AWS_REGION", "us-east-1") + mock_sm = MagicMock() + mock_sm.get_secret_value.return_value = {"SecretString": json.dumps(payload)} + + with patch("boto3.client", return_value=mock_sm): + assert resolve_jira_oauth_token({"jira_oauth_secret_arn": "arn:non-object"}) == "" + + assert "JIRA_API_TOKEN" not in os.environ + assert "JIRA_APP_ACTOR_CONFIGURED" not in os.environ + def test_falls_back_to_env_var_when_channel_metadata_omits_arn(self, monkeypatch): """JIRA_OAUTH_SECRET_ARN env var is the back-compat fallback.""" from datetime import datetime, timedelta diff --git a/agent/tests/test_jira_reactions.py b/agent/tests/test_jira_reactions.py index 183e87b27..65a1eac67 100644 --- a/agent/tests/test_jira_reactions.py +++ b/agent/tests/test_jira_reactions.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from unittest.mock import MagicMock, patch import pytest @@ -64,6 +65,12 @@ def _reset_circuit(): Autouse so it applies to module-level functions AND methods inside test classes (a module-level ``setup_function`` does not run for class methods). """ + for key in ( + "JIRA_APP_ACTOR_CONFIGURED", + "JIRA_APP_ACTOR_PROXY_URL", + "JIRA_APP_ACTOR_SHARED_SECRET", + ): + os.environ.pop(key, None) jira_reactions._reset_state_for_testing() yield jira_reactions._reset_state_for_testing() @@ -91,6 +98,64 @@ def test_missing_issue_key_is_noop(self, monkeypatch): class TestStartComment: + def test_uses_signed_forge_app_actor_when_configured(self, monkeypatch): + import hashlib + import hmac + import json + + monkeypatch.setenv("JIRA_API_TOKEN", "human-oauth-token") + monkeypatch.setenv("JIRA_APP_ACTOR_CONFIGURED", "1") + monkeypatch.setenv( + "JIRA_APP_ACTOR_PROXY_URL", + "https://install.webtrigger.atlassian.app/public/trigger", + ) + monkeypatch.setenv("JIRA_APP_ACTOR_SHARED_SECRET", "s" * 64) + with patch("jira_reactions.requests.post", return_value=_resp(201)) as post: + comment_task_started("jira", JIRA_META) + + assert post.call_args[0][0].endswith(".webtrigger.atlassian.app/public/trigger") + assert "json" not in post.call_args.kwargs + headers = post.call_args.kwargs["headers"] + assert "Authorization" not in headers + raw_body = post.call_args.kwargs["data"].decode() + payload = json.loads(raw_body) + assert payload["operation"] == "comment" + assert payload["issue_key"] == "KAN-1" + timestamp = headers["X-Bgagent-Timestamp"] + expected = hmac.new( + ("s" * 64).encode(), + f"{timestamp}.{raw_body}".encode(), + hashlib.sha256, + ).hexdigest() + assert headers["X-Bgagent-Signature"] == f"sha256={expected}" + + def test_invalid_configured_app_actor_never_falls_back_to_oauth(self, monkeypatch): + monkeypatch.setenv("JIRA_API_TOKEN", "human-oauth-token") + monkeypatch.setenv("JIRA_APP_ACTOR_CONFIGURED", "1") + monkeypatch.setenv("JIRA_APP_ACTOR_PROXY_URL", "https://attacker.example/proxy") + monkeypatch.setenv("JIRA_APP_ACTOR_SHARED_SECRET", "s" * 64) + with patch("jira_reactions.requests.post") as post: + comment_task_started("jira", JIRA_META) + post.assert_not_called() + + def test_logs_bounded_proxy_error_code_for_permanent_failure(self, monkeypatch, capfd): + monkeypatch.setenv("JIRA_APP_ACTOR_CONFIGURED", "1") + monkeypatch.setenv( + "JIRA_APP_ACTOR_PROXY_URL", + "https://install.webtrigger.atlassian.app/public/trigger", + ) + monkeypatch.setenv("JIRA_APP_ACTOR_SHARED_SECRET", "s" * 64) + with patch( + "jira_reactions.requests.post", + return_value=_resp(401, '{"error":"invalid_signature","detail":"do-not-log"}'), + ): + comment_task_started("jira", JIRA_META) + + output = capfd.readouterr().out + assert "error_id=JIRA_APP_ACTOR_PROXY_REJECTED" in output + assert "proxy_error=invalid_signature" in output + assert "do-not-log" not in output + def test_posts_adf_comment_to_correct_url(self, monkeypatch): monkeypatch.setenv("JIRA_API_TOKEN", "jira_at") with patch("jira_reactions.requests.post", return_value=_resp(201)) as post: @@ -165,6 +230,23 @@ def test_2xx_resets_failure_counter(self, monkeypatch): assert post.call_count == 4 assert jira_reactions._auth_circuit_open is False + def test_open_message_calls_out_forge_secret_drift(self, monkeypatch, capfd): + monkeypatch.setenv("JIRA_APP_ACTOR_CONFIGURED", "1") + monkeypatch.setenv( + "JIRA_APP_ACTOR_PROXY_URL", + "https://install.webtrigger.atlassian.app/public/trigger", + ) + monkeypatch.setenv("JIRA_APP_ACTOR_SHARED_SECRET", "s" * 64) + with patch( + "jira_reactions.requests.post", + return_value=_resp(401, '{"error":"invalid_signature"}'), + ): + for _ in range(jira_reactions._AUTH_FAILURE_THRESHOLD): + comment_task_started("jira", JIRA_META) + + output = capfd.readouterr().out + assert "BGAGENT_PROXY_SECRET matches JIRA_APP_ACTOR_SHARED_SECRET" in output + class TestTransitionChannelGate: def test_non_jira_source_is_noop(self, monkeypatch): @@ -193,6 +275,32 @@ def test_skips_when_token_missing(self, monkeypatch): class TestStartTransitionSelection: + def test_uses_app_actor_for_transition_read_and_write(self, monkeypatch): + import json + + monkeypatch.setenv("JIRA_APP_ACTOR_CONFIGURED", "1") + monkeypatch.setenv( + "JIRA_APP_ACTOR_PROXY_URL", + "https://install.webtrigger.atlassian.app/public/trigger", + ) + monkeypatch.setenv("JIRA_APP_ACTOR_SHARED_SECRET", "s" * 64) + issue = _issue_resp(_transition("21", "In Progress", category="indeterminate")) + with ( + patch("jira_reactions.requests.get") as get, + patch( + "jira_reactions.requests.post", + side_effect=[issue, _resp(204)], + ) as post, + ): + transition_task_started("jira", JIRA_META) + + get.assert_not_called() + assert post.call_count == 2 + operations = [ + json.loads(call.kwargs["data"].decode())["operation"] for call in post.call_args_list + ] + assert operations == ["get_transitions", "transition"] + def test_prefers_in_progress_by_name_over_blocked(self, monkeypatch): """#605: both Blocked and In Progress are `indeterminate`; a name match must land on In Progress regardless of list order.""" diff --git a/agent/tests/test_pipeline.py b/agent/tests/test_pipeline.py index 0cf728dc3..c7de2d3c2 100644 --- a/agent/tests/test_pipeline.py +++ b/agent/tests/test_pipeline.py @@ -2052,6 +2052,34 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= mock_task_state.write_terminal.assert_called() +class TestJiraCredentialIsolation: + @pytest.mark.parametrize("channel_source", ["", "linear", "jira"]) + def test_run_task_scrubs_prior_jira_credentials_before_building_config( + self, + monkeypatch, + channel_source, + ): + """A warm Jira task cannot expose credentials to the next task.""" + credential_names = ( + "JIRA_API_TOKEN", + "JIRA_APP_ACTOR_CONFIGURED", + "JIRA_APP_ACTOR_PROXY_URL", + "JIRA_APP_ACTOR_SHARED_SECRET", + ) + for name in credential_names: + monkeypatch.setenv(name, "tenant-x-secret") + + def stop_after_scrub(**_kwargs): + assert all(name not in os.environ for name in credential_names) + raise RuntimeError("stop after credential scrub") + + with patch("pipeline.build_config", side_effect=stop_after_scrub): + from pipeline import run_task + + with pytest.raises(RuntimeError, match="stop after credential scrub"): + run_task(channel_source=channel_source) + + class TestEarlyAckOrdering: """#616 review N4 — the early-ACK reorder must hold under future edits. From 566dc3e80e667adaf798477e23328b1867c0140d Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Mon, 27 Jul 2026 18:11:11 +0100 Subject: [PATCH 4/7] =?UTF-8?q?fix(carve=20S3):=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20close=20a=20clone-guard=20bypass,=20grant=20the=20m?= =?UTF-8?q?odel=20the=20agent=20defaults=20to?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from the automated review of this slice, plus its comment cleanup. **The self-reclone guard let `git clone -b` through.** `-b` is `gh`'s `--body` short form, so it sits in the free-text-argument list, but it is ALSO `git clone`'s own `--branch`. The guard truncated the command at the first free-text flag BEFORE scanning for the clone verb, so the cut landed ahead of the very verb it was looking for and an ordinary `git clone -b main ` was allowed — reopening the stranded-work failure the guard exists to prevent. Two more shapes slipped through for the same reason: `gh repo clone -b main `, and a clone chained after an unrelated `-m` (`git commit -m wip && gh repo clone `), where the `-m` belongs to the commit, not the clone. Now each shell segment is checked on its own, and within a segment the verb is located first — a verb only counts as prose if a free-text argument opens before it in that same segment. Prose in a `--body` value is still ignored. **The agent's fallback model was not in the Bedrock grant list.** This slice flips the fallback to Opus 4.8 for a repo that pins no model, but the IAM grant is derived from `DEFAULT_BEDROCK_MODEL_IDS`, and that entry was landing several changes later. Merging this slice on its own would have produced AccessDenied at turn 0 on every task with no per-repo model. The grant now travels with the default, and a drift guard reads the agent's own fallback and asserts the grant covers it, so the two cannot diverge again. **Two workflow descriptors arrived here rather than in the base slice**, matching where their `agent/workflows/**` definitions live. `DESCRIPTORS` is the live admission table, so an entry without its file means a submission is accepted with a 201 and then dies when the agent cannot load it. Also removes private tracker ids and work-item shorthand used as the load-bearing explanation in comments, docstrings, log messages and test names. The reasoning is preserved in plain language — a comment that recorded a real defect still records it, just without an id only this team can resolve. --- agent/src/config.py | 2 +- agent/src/hooks.py | 59 +++++++++++---- agent/src/linear_reactions.py | 13 ++-- agent/src/pipeline.py | 85 ++++++++++++---------- agent/src/post_hooks.py | 2 +- agent/src/prompt_builder.py | 2 +- agent/src/server.py | 6 +- agent/tests/conftest.py | 6 +- agent/tests/test_aws_session.py | 4 +- agent/tests/test_cedar_parity.py | 6 +- agent/tests/test_config.py | 2 +- agent/tests/test_conftest_env_scrub.py | 4 +- agent/tests/test_hooks.py | 43 ++++++++++- agent/tests/test_linear_reactions.py | 3 +- agent/tests/test_models.py | 4 +- agent/tests/test_nudge_hook.py | 2 +- agent/tests/test_pipeline.py | 8 +- agent/tests/test_pipeline_outcomes.py | 11 +-- agent/tests/test_post_hooks.py | 11 +-- agent/tests/test_prompts.py | 4 +- agent/tests/test_repo.py | 38 +++++----- agent/tests/test_run_task_from_payload.py | 10 +-- agent/tests/test_server.py | 14 ++-- agent/tests/test_shell.py | 10 +-- agent/tests/test_stuck_guard.py | 10 +-- agent/tests/test_task_state.py | 6 +- agent/tests/test_telemetry_redaction.py | 2 +- agent/tests/test_trace_upload.py | 8 +- agent/tests/test_verify_commands.py | 12 +-- cdk/src/constructs/bedrock-models.ts | 7 ++ cdk/src/handlers/shared/workflows.ts | 23 ++++++ cdk/test/constructs/bedrock-models.test.ts | 27 +++++++ 32 files changed, 290 insertions(+), 154 deletions(-) diff --git a/agent/src/config.py b/agent/src/config.py index 14b5454c0..50d8969ee 100644 --- a/agent/src/config.py +++ b/agent/src/config.py @@ -19,7 +19,7 @@ REPO_LESS_DEFAULT_WORKFLOW_ID = "default/agent-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 A6) re-merges a changed predecessor into an existing stacked-child PR. +# (#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 diff --git a/agent/src/hooks.py b/agent/src/hooks.py index 88e0ac031..37cf6e1ce 100644 --- a/agent/src/hooks.py +++ b/agent/src/hooks.py @@ -272,8 +272,27 @@ def _allow_response(reason: str = "permitted") -> dict: # 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. We stop scanning for the clone verb at the first of these so a body -# quoting the clone command can't trip the guard. +# 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. +_SHELL_SEPARATOR_RE = re.compile(r"(?:&&|\|\||[;&|\n])") + +# The clone verb WITHOUT the leading-separator anchor, for matching inside a +# segment that has already been split on separators (so the verb is at the +# segment start rather than after one). +_CLONE_VERB_RE = re.compile( + r"^\s*(?:gh\s+repo\s+clone|git\s+(?:-C\s+\S+\s+)?clone)\b", + re.IGNORECASE, +) + _FREE_TEXT_ARG_RE = re.compile( r"(?:^|\s)(?:-m|--message|--body|--body-file|-b|--title|-t|-F|--field|--raw-field)\b", re.IGNORECASE, @@ -307,21 +326,33 @@ def _is_self_reclone(command: str, repo_url: str) -> bool: only a re-clone of the TASK repo threatens the workspace invariant.""" if not command or not repo_url: return False - # Only consider the segment BEFORE any free-text argument — a --body/-m value - # that quotes the clone command is prose, not an executed clone. - m = _FREE_TEXT_ARG_RE.search(command) - scan = command[: m.start()] if m else command - if not _CLONE_CMD_RE.search(scan): - 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 - # Normalize the command's URL punctuation to the bare slug space: drop the - # scheme/host and the scp-style ``git@host:`` prefix so ``.../owner/repo.git`` - # and ``git@github.com:owner/repo`` both reduce to a substring carrying - # ``owner/repo``. Search the SAME pre-free-text segment. - haystack = scan.lower().replace("git@github.com:", "github.com/") - return any(v in haystack for v in variants) + for segment in _SHELL_SEPARATOR_RE.split(command): + clone = _CLONE_CMD_RE.search(segment) or _CLONE_VERB_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( diff --git a/agent/src/linear_reactions.py b/agent/src/linear_reactions.py index 3c469293f..797d49d6f 100644 --- a/agent/src/linear_reactions.py +++ b/agent/src/linear_reactions.py @@ -86,7 +86,8 @@ query Viewer { viewer { id } } """.strip() -#: PM-3: fetch the issue's current state + its team's full workflow-state list, +#: 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 = """ @@ -98,7 +99,7 @@ } """.strip() -#: PM-3: set an issue's workflow state by id. +#: Set an issue's workflow state by id. _SET_STATE_MUTATION = """ mutation SetIssueState($id: String!, $stateId: String!) { issueUpdate(id: $id, input: { stateId: $stateId }) { success } @@ -254,7 +255,7 @@ def _get_viewer_id() -> str | None: def _transition_issue_state(issue_id: str, target_type: str, preferred_names: list[str]) -> None: - """PM-3: move the issue to a workflow state of ``target_type``, forward-only. + """Move the issue to a workflow state of ``target_type``, forward-only. A plain single task (a direct ``abca`` label, or an ``:auto``/``:decompose`` the planner declined to a single unit) previously left the issue in Backlog @@ -447,7 +448,8 @@ def react_task_started( name="linear-reactions-sweep", ).start() - # PM-3: a writeable single task moves the issue Backlog → In Progress, so it + # 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 # (decompose-v1, pr-review) — the orchestration panel owns the parent state. if transition_state: @@ -478,7 +480,8 @@ def react_task_finished( _CREATE_MUTATION, {"issueId": issue_id, "emoji": EMOJI_SUCCESS if success else EMOJI_FAILURE}, ) - # PM-3: on a clean finish, a writeable single task moves the issue to + # 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 diff --git a/agent/src/pipeline.py b/agent/src/pipeline.py index 30f0abe58..4f867fed6 100644 --- a/agent/src/pipeline.py +++ b/agent/src/pipeline.py @@ -112,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 @@ -556,17 +556,17 @@ def _resolve_overall_task_status( 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 — the ABCA-659 false + 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 @@ -636,10 +636,11 @@ def _branch_has_new_commits(repo_dir: str, default_branch: str) -> bool: 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 ABCA-815 delivery gate to tell + ``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 #247 A4 stacked child - ``default_branch`` IS its predecessor branch (``config.base_branch``), so + 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).""" @@ -668,10 +669,14 @@ def _apply_delivery_gate( pr_url: str | None, commit_landed: bool, ) -> tuple[str, str | None]: - """ABCA-815: fail a new-work coding task that reported success but shipped - nothing (no PR AND no commit on its branch) — the deliverable was lost. + """Fail a new-work coding task that reported success but shipped nothing + (no PR AND no commit on its branch) — the deliverable was lost. - Live cause: a #247 A4 stacked child edited its files in a NESTED working tree + 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 — @@ -715,7 +720,7 @@ def _apply_delivery_gate( "PR was opened — the agent's changes did not land in the task's " "repository." ) - log("WARN", f"ABCA-815 delivery gate: {reason}") + log("WARN", f"Delivery gate: {reason}") return "error", reason @@ -726,7 +731,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 @@ -926,8 +931,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 @@ -935,7 +940,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. @@ -1050,12 +1055,12 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: if prompt_version: os.environ["PROMPT_VERSION"] = prompt_version - # ── Early ACK (ABCA-707) ───────────────────────────────────────── + # ── Early ACK ──────────────────────────────────────────────────── # Acknowledge the task is picked up BEFORE the (potentially long) # 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 - # (the ABCA-707 symptom: no reaction, comment, or state change for + # (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. @@ -1072,7 +1077,8 @@ 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 (👀 → ✅/❌). - # PM-3: a writeable coding task (new-task / pr-iteration) also moves + # 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 (decompose-v1 planning, # pr-review) never transition — the orchestration panel owns the @@ -1109,7 +1115,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # the task FAILED, swaps the 👀 (posted above) to ❌, and posts the # failure comment. Before the Early-ACK move the 👀 didn't exist yet # at this point, so a setup failure left the issue silently stuck - # (the ABCA-707 symptom); posting the 👀 earlier is what makes the + # 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) @@ -1376,9 +1382,10 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: ) post_span.set_attribute("artifact.uri", artifact_uri or "") else: - # ABCA-815 root cause: if the agent switched off the platform - # branch (it sometimes runs `git checkout -b ` and - # commits/opens its PR there — live-caught on backgroundagent-dev), + # 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 @@ -1401,7 +1408,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # 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 - # ABCA-659 #2: the build was KILLED by an environment fault (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 @@ -1410,7 +1417,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # + 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 - # K8: an INERT build gate (exit 127 / no-such-task — the command + # 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 @@ -1515,10 +1522,10 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: build_timed_out=build_timed_out, build_infra_failed=build_infra_failed, ) - # ABCA-815 delivery gate: a create-strategy new-work task that + # 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 #247 stacked DAG. The branch-diff is only run + # 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 = ( @@ -1583,7 +1590,7 @@ 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) - # A6/#299: did this PR-iteration actually advance the branch HEAD? + # 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 @@ -1705,7 +1712,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 @@ -1776,15 +1783,16 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: #: Orchestrator payload keys we KNOW about that ``run_task`` does not (yet) #: 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 (N3): it is ALWAYS present and ALWAYS resolved via the +#: 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. @@ -1808,8 +1816,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 @@ -1830,8 +1838,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", @@ -1850,7 +1857,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 1f3546d66..e6e7f0de2 100644 --- a/agent/src/post_hooks.py +++ b/agent/src/post_hooks.py @@ -378,7 +378,7 @@ def reconcile_agent_branch(repo_dir: str, branch: str) -> bool: "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 (ABCA-815).", + "the work is delivered on the tracked branch.", ) try: # Re-point the platform branch at the agent's HEAD (force: the platform diff --git a/agent/src/prompt_builder.py b/agent/src/prompt_builder.py index 1b901f8d3..79d942e77 100644 --- a/agent/src/prompt_builder.py +++ b/agent/src/prompt_builder.py @@ -298,7 +298,7 @@ def _channel_prompt_addendum(config: TaskConfig) -> str: """ if config.channel_source != "linear": return "" - # #247 UX.16: a synthetic orchestration integration node has NO real Linear + # 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. diff --git a/agent/src/server.py b/agent/src/server.py index 7a8ed3f7c..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`` — @@ -546,7 +546,7 @@ 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", "")) - # #247 A4: stacked-child base branch + (diamond) predecessor branches + # 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 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_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_hooks.py b/agent/tests/test_hooks.py index 33c42a59f..5d359c608 100644 --- a/agent/tests/test_hooks.py +++ b/agent/tests/test_hooks.py @@ -196,7 +196,8 @@ def test_denies_direct_non_dict_tool_input(self): class TestSelfRecloneGuard: - """ABCA-815 layer 1: block a Bash re-clone of the task's OWN repo (the agent + """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.""" @@ -231,7 +232,7 @@ 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): - # ABCA-856 false positive: the clone command quoted inside a --body value + # 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" ' @@ -253,6 +254,40 @@ def test_still_blocks_clone_before_a_body_arg(self): 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_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. @@ -1820,7 +1855,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" @@ -1842,7 +1877,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 0c4124e8e..2f545e6d2 100644 --- a/agent/tests/test_linear_reactions.py +++ b/agent/tests/test_linear_reactions.py @@ -524,7 +524,8 @@ def test_403_treated_same_as_401(self, monkeypatch): class TestTransitionIssueState: - """PM-3: a writeable single task moves the issue Backlog → In Progress → + """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).""" diff --git a/agent/tests/test_models.py b/agent/tests/test_models.py index 2d7dfc357..fd05818fe 100644 --- a/agent/tests/test_models.py +++ b/agent/tests/test_models.py @@ -282,11 +282,11 @@ def test_required_fields(self): assert config.is_pr_workflow is False assert config.cedar_policies == [] assert config.issue is None - # #247 A4: defaults for stacked-child fields. + # Defaults for stacked-child fields (#247). assert config.base_branch is None assert config.merge_branches == [] - def test_a4_stacked_child_fields(self): + def test_stacked_child_base_branch_fields(self): # Diamond child: base off main + predecessor branches to merge in. config = TaskConfig( repo_url="owner/repo", 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 c7de2d3c2..a4c9e9e68 100644 --- a/agent/tests/test_pipeline.py +++ b/agent/tests/test_pipeline.py @@ -677,7 +677,7 @@ def test_success_with_build_failed(self): assert "build_ok=False" in err def test_success_with_build_TIMED_OUT_marks_timeout_distinctly(self): - # User 2026-06-29: a build that exceeded the time limit must read as a + # 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") @@ -1085,7 +1085,7 @@ def _running_span() -> MagicMock: # --------------------------------------------------------------------------- -# Chunk K1 — trace threading into TaskConfig (design §10.1) +# Trace threading into TaskConfig (design §10.1) # --------------------------------------------------------------------------- @@ -1365,7 +1365,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.""" @@ -1622,7 +1622,7 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= 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 ABCA-815 no-deliverable gate. Returning a PR keeps + # 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"), diff --git a/agent/tests/test_pipeline_outcomes.py b/agent/tests/test_pipeline_outcomes.py index e8bbd90a7..fbd7fdb11 100644 --- a/agent/tests/test_pipeline_outcomes.py +++ b/agent/tests/test_pipeline_outcomes.py @@ -60,7 +60,7 @@ def test_success_end_turn_with_build_ok(self): assert err is None def test_infra_failed_build_forces_error_even_when_gate_would_pass(self): - # ABCA-659 #2: the build was killed by ENOSPC/OOM (build_infra_failed). + # 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 @@ -162,8 +162,9 @@ def test_error_status_preserves_bedrock_entitlement_message(self): class TestDeliveryGate: - """ABCA-815: a create-strategy new-work task that reported success but - shipped NOTHING (no PR AND no commit) must be failed loudly, not COMPLETED. + """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.""" @@ -193,7 +194,7 @@ def _gate( ) def test_success_no_pr_no_commit_becomes_lost(self): - # The ABCA-815 case: agent-success, create strategy, nothing shipped. + # The lost-deliverable case: agent-success, create strategy, nothing shipped. overall, err = self._gate() assert overall == "error" assert err is not None @@ -314,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_post_hooks.py b/agent/tests/test_post_hooks.py index a6367791e..b52b755e2 100644 --- a/agent/tests/test_post_hooks.py +++ b/agent/tests/test_post_hooks.py @@ -268,8 +268,9 @@ def responder(cmd): class TestReconcileAgentBranch: - """ABCA-815 root cause: reconcile the platform branch when the agent - committed on its OWN branch instead of the pre-checked-out platform branch. + """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 @@ -307,7 +308,7 @@ def test_reconciles_when_agent_on_own_branch(self, 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 live ABCA-815 case). + # 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") @@ -355,8 +356,8 @@ def test_current_branch_reports_none_when_detached(self, tmp_path): 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). Live-caught on the #247 chain: a stacked - child + a root both opened against a wrong 'main'.""" + 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 diff --git a/agent/tests/test_prompts.py b/agent/tests/test_prompts.py index 03c940494..854bc9274 100644 --- a/agent/tests/test_prompts.py +++ b/agent/tests/test_prompts.py @@ -65,7 +65,7 @@ def test_linear_addendum_same_regardless_of_workflow(self): assert "Linear context discovery" not in addendum def test_linear_integration_node_gets_no_addendum(self): - # #247 UX.16: the synthetic orchestration integration node is a Linear + # 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). @@ -125,7 +125,7 @@ def test_pr_iteration_returns_prompt_with_update_pr(self): assert "{workflow}" not in prompt def test_pr_iteration_distinguishes_question_from_change(self): - # A6/#299: a question-only comment ("where is the login page?") must be + # 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") diff --git a/agent/tests/test_repo.py b/agent/tests/test_repo.py index 69d335a32..3bd877cee 100644 --- a/agent/tests/test_repo.py +++ b/agent/tests/test_repo.py @@ -33,7 +33,7 @@ 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) - # ABCA-815: the clone is faked here, so the real workspace never gets a + # 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. @@ -124,8 +124,8 @@ def test_pr_workflow_does_not_double_capture_head_sha(self, monkeypatch): assert "head-sha-after-setup" not in fake.labels() -class TestDiamondBaseBranchF1: - """#247 F1 (DE-stress 2026-07-24): a diamond child (base_branch + merge_branches) +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 @@ -236,11 +236,11 @@ def test_non_read_only_still_runs_build_and_lint_baseline(self, monkeypatch): class TestBaselineBuildTimeout: - """ABCA-659 Bug B: 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 + """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 661/662 symptom: no PR, issue + the whole task before the agent ever runs (the observed symptom: no PR, issue stuck in Backlog, indistinguishable from a real failure).""" class _RecordingFake: @@ -279,7 +279,7 @@ def test_baseline_build_uses_the_generous_verify_ceiling_not_600s(self, monkeypa monkeypatch.setattr(repo, "run_cmd", fake) monkeypatch.setattr(repo, "run_cmd_with_backoff", fake) monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) - # ABCA-815: the clone is faked here, so the real workspace never gets a + # 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. @@ -301,7 +301,7 @@ def test_baseline_build_timeout_is_guarded_not_a_task_crash(self, monkeypatch): monkeypatch.setattr(repo, "run_cmd", fake) monkeypatch.setattr(repo, "run_cmd_with_backoff", fake) monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) - # ABCA-815: the clone is faked here, so the real workspace never gets a + # 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. @@ -320,7 +320,7 @@ def test_baseline_lint_timeout_is_guarded(self, monkeypatch): monkeypatch.setattr(repo, "run_cmd", fake) monkeypatch.setattr(repo, "run_cmd_with_backoff", fake) monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) - # ABCA-815: the clone is faked here, so the real workspace never gets a + # 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. @@ -335,7 +335,7 @@ def test_baseline_lint_timeout_is_guarded(self, monkeypatch): 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): - # ABCA-662 root cause: the pre-agent baseline build was OOM-KILLED (exit + # 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"). @@ -346,7 +346,7 @@ def test_baseline_build_OOM_kill_is_not_a_regression(self, monkeypatch): monkeypatch.setattr(repo, "run_cmd", fake) monkeypatch.setattr(repo, "run_cmd_with_backoff", fake) monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) - # ABCA-815: the clone is faked here, so the real workspace never gets a + # 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. @@ -369,7 +369,7 @@ def test_baseline_build_genuine_failure_still_marks_regression_baseline(self, mo monkeypatch.setattr(repo, "run_cmd", fake) monkeypatch.setattr(repo, "run_cmd_with_backoff", fake) monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) - # ABCA-815: the clone is faked here, so the real workspace never gets a + # 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. @@ -384,7 +384,7 @@ def test_baseline_build_genuine_failure_still_marks_regression_baseline(self, mo class TestFindMiseConfigs: - """ABCA-662 follow-up: `mise trust ` trusts only the ROOT config; + """`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.""" @@ -482,7 +482,7 @@ class TestPlatformBranchNameVerbatim: 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 #247 A4 stacking: a stacked child fetches the + 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).""" @@ -549,7 +549,7 @@ 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) - # ABCA-815: the clone is faked here, so the real workspace never gets a + # 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. @@ -592,7 +592,7 @@ 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) - # ABCA-815: the clone is faked here, so the real workspace never gets a + # 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. @@ -719,7 +719,7 @@ 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) - # ABCA-815: the clone is faked here, so the real workspace never gets a + # 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. @@ -771,7 +771,7 @@ def test_remediation_does_not_widen_creds_or_egress(self, monkeypatch): class TestCloneWorkspaceGuards: - """ABCA-815: the clone-time slate-clean + git-root backstop that stop a + """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).""" diff --git a/agent/tests/test_run_task_from_payload.py b/agent/tests/test_run_task_from_payload.py index d81e65033..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"} @@ -91,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. @@ -99,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): @@ -144,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. diff --git a/agent/tests/test_server.py b/agent/tests/test_server.py index 63f4fa15f..32383703d 100644 --- a/agent/tests/test_server.py +++ b/agent/tests/test_server.py @@ -343,7 +343,7 @@ def test_validate_required_params_pr_workflows_require_pr_number(): ) assert missing == [] - # #305 A6: restack is a PR workflow — pr_number suffices, NO description + # 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( @@ -649,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 @@ -840,8 +840,8 @@ class TestInvocationParamContract: 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 #247 A4 regression - where ``base_branch`` was passed to ``run_task`` but never extracted + 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. """ @@ -876,8 +876,8 @@ def test_extracted_params_unpack_into_background_signature(self): # Should not raise. sig.bind(**params) - def test_a4_base_branch_and_merge_branches_extracted_and_accepted(self): - # The specific A4 fields whose omission caused the regression. + 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( @@ -890,7 +890,7 @@ def test_a4_base_branch_and_merge_branches_extracted_and_accepted(self): bg = set(inspect.signature(server._run_task_background).parameters) assert {"base_branch", "merge_branches"} <= bg - def test_a4_fields_default_safely_when_absent(self): + 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"] == [] diff --git a/agent/tests/test_shell.py b/agent/tests/test_shell.py index ac864e29d..cc2823384 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. @@ -324,7 +324,7 @@ 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 — ABCA-662).""" + Popen + drain-thread path (the buffered summary hid build failures).""" def _run(self, argv, check=False): logs = [] 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 index 6b3bdd1cd..02b7a5162 100644 --- a/agent/tests/test_verify_commands.py +++ b/agent/tests/test_verify_commands.py @@ -56,10 +56,10 @@ def test_plain_command_still_direct_argv(self): assert resolve_verify_argv("npm run build", "") == ["npm", "run", "build"] def test_env_assignment_prefix_wraps_in_shell(self): - # ABCA-662 follow-up: a leading VAR=value env-prefix is shell syntax. Exec'd + # 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. (Live-caught: a + # 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", @@ -125,7 +125,7 @@ def test_nonzero_returncode_is_failure_not_timeout(self, monkeypatch): 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 (user 2026-06-29): a timeout must read as "timed + # 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): @@ -137,7 +137,7 @@ def boom(argv, **kw): assert outcome.timed_out is True def test_exit_127_is_INERT_not_a_build_failure(self, monkeypatch): - # K8: command-not-found (e.g. yarn missing) means the gate couldn't run — + # 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( @@ -172,7 +172,7 @@ def test_genuine_nonzero_is_a_failure_NOT_inert(self, monkeypatch): assert outcome.inert is False def test_ENOSPC_is_INFRA_failure_not_a_build_failure(self, monkeypatch): - # ABCA-659 #2: disk-full mid-build means the build couldn't COMPLETE on + # 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". @@ -198,7 +198,7 @@ def test_OOM_sigkill_137_is_INFRA_failure(self, monkeypatch): assert outcome.infra_failed is True def test_bare_sigkill_137_no_stderr_signature_is_INFRA_not_inert(self, monkeypatch): - # ABCA-691 live regression: the container/cgroup OOM-killer delivers + # 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 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..b150f4beb 100644 --- a/cdk/src/handlers/shared/workflows.ts +++ b/cdk/src/handlers/shared/workflows.ts @@ -136,6 +136,29 @@ 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'] }, + }, + // Decomposition planning: clone the repo, decide whether the request splits, + // and draft the breakdown with full repo context, emitting the plan as the + // task's artifact. The platform seeds sub-issues from that plan (an idempotent + // write-back), after which the run proceeds like any human-authored graph. + // Repo-bound but read-only — it opens no PR, it only reads in order to plan. + // Platform-issued when a decompose/auto trigger label is applied. + 'coding/decompose-v1': { + id: 'coding/decompose-v1', + version: '1.0.0', + requiresRepo: true, + readOnly: true, + requiredInputs: { oneOf: ['issue_number', 'task_description'] }, + }, '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); + }); +}); From 88f27efea14ed9a0c8dd32c299da961f1dbc2a21 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 28 Jul 2026 02:16:44 +0100 Subject: [PATCH 5/7] fix(carve S3): the clone-guard rewrite broke two things it was meant to protect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second review pass on my own fix found that it closed the `-b` bypass while opening a new one and introducing a false positive. Both are worse than the original bug in a security guard, so both are fixed and pinned. **New bypass: a wrapped clone walked straight through.** Splitting on a bare newline separated the verb from the repo, so `git clone \` + newline + url never had the slug in the verb's segment. That command was BLOCKED before my rewrite and ALLOWED after it — including via the `-b` form the rewrite existed to catch. Backslash-continuations are now joined before scanning, which also covers a continuation splitting the verb itself (`git \` + newline + `clone`) — the case that genuinely needs the joining, since no segment handling reunites those. **New false positive: a multi-line PR body was denied.** A `--body` or `-m` value spanning lines is how an agent normally writes a PR or commit body, and that text routinely documents a clone command. Treating `\n` as a command separator put the quoted line in its own segment with no preceding free-text flag, so a legitimate `gh pr create` was refused — exactly the false positive the free-text list exists to prevent. A bare newline is no longer a separator; the real separators still are, so a clone chained after an unrelated `-m` is still caught. Also removes `_CLONE_VERB_RE`, which was never load-bearing: across every segment shape it was the sole matcher zero times, and it actually MISSES a subshell (`( git clone …`) that the anchored pattern catches. Dead alternatives in a security path are worse than none. Both regressions are mutation-verified: restoring `\n` as a separator fails the multi-line-body test, and dropping the continuation join fails the wrapped-clone test. --- agent/src/hooks.py | 27 +++++++++++++++++---------- agent/tests/test_hooks.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/agent/src/hooks.py b/agent/src/hooks.py index 37cf6e1ce..a17f04e82 100644 --- a/agent/src/hooks.py +++ b/agent/src/hooks.py @@ -283,15 +283,17 @@ def _allow_response(reason: str = "permitted") -> dict: # 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. -_SHELL_SEPARATOR_RE = re.compile(r"(?:&&|\|\||[;&|\n])") +# +# 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]*") -# The clone verb WITHOUT the leading-separator anchor, for matching inside a -# segment that has already been split on separators (so the verb is at the -# segment start rather than after one). -_CLONE_VERB_RE = re.compile( - r"^\s*(?:gh\s+repo\s+clone|git\s+(?:-C\s+\S+\s+)?clone)\b", - re.IGNORECASE, -) +_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", @@ -338,8 +340,13 @@ def _is_self_reclone(command: str, repo_url: str) -> bool: variants = _repo_slug_variants(repo_url) if not variants: return False - for segment in _SHELL_SEPARATOR_RE.split(command): - clone = _CLONE_CMD_RE.search(segment) or _CLONE_VERB_RE.search(segment) + # 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) diff --git a/agent/tests/test_hooks.py b/agent/tests/test_hooks.py index 5d359c608..f78f0588e 100644 --- a/agent/tests/test_hooks.py +++ b/agent/tests/test_hooks.py @@ -288,6 +288,42 @@ def test_still_ignores_prose_after_a_body_flag_in_the_same_segment(self): "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. From 75687431adb35e436f2bf7bb7e8bea0452ae83b9 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 28 Jul 2026 21:08:51 +0100 Subject: [PATCH 6/7] fix(carve S3): report a signal-killed build as infra, not as broken code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the streamed path returning Popen's raw return code, which for a signal death is the NEGATIVE signal number (SIGKILL -> -9). The OOM classifier keys on the shell's 128+signal convention (137), so the two never met. This is reachable, not theoretical. A verify command only goes through a shell when it contains shell operators, and `mise run build` has none — so it is exec'd directly and its signal deaths arrive as -9. Reproduced: is_infra_failure returns False for -9 and True for 137, on identical input otherwise. The consequence is the exact mislabel that function exists to prevent. The container/cgroup OOM-killer writes "Killed process" to the KERNEL log, not the build's stderr, so an OOM'd build frequently has no stderr signature at all and the exit status is the only evidence. Left raw, that build is reported as a genuine failure — telling the user their code is broken when the box ran out of memory. Given the build tier's default was just lowered, this is the wrong moment to have OOM detection silently not fire. Normalized at the boundary where the signal is still visible rather than teaching each consumer both encodings, so SIGTERM (-15 -> 143) is covered too. Also replaces `proc.returncode or 0`, which mapped an unreaped None to 0. That cannot happen after wait(), but success is the one unsafe default for an unknown outcome, since it would let a build that never ran report as passing. Now -1. Tests assert 137 reaches is_infra_failure as infra, and that None maps to non-zero. Verified by restoring the old expression and watching the first fail. --- agent/src/shell.py | 32 +++++++++++++++++++++++++++++++- agent/tests/test_shell.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/agent/src/shell.py b/agent/src/shell.py index e0aa497b2..5d91ed4f8 100644 --- a/agent/src/shell.py +++ b/agent/src/shell.py @@ -238,6 +238,36 @@ 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: @@ -299,7 +329,7 @@ def _drain(pipe, buf: list[str]) -> None: t_out.join(timeout=10) t_err.join(timeout=10) return subprocess.CompletedProcess( - cmd, proc.returncode or 0, "\n".join(out_lines), "\n".join(err_lines) + cmd, _exit_status(proc.returncode), "\n".join(out_lines), "\n".join(err_lines) ) diff --git a/agent/tests/test_shell.py b/agent/tests/test_shell.py index cc2823384..225e2df83 100644 --- a/agent/tests/test_shell.py +++ b/agent/tests/test_shell.py @@ -368,3 +368,33 @@ def test_stream_raises_on_check_true_failure(self): 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 From b34b07fab6bf07c06da955a03ca95e75c86c7d68 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 29 Jul 2026 00:51:28 +0100 Subject: [PATCH 7/7] refactor(carve S3): drop the decomposition workflow from the main-bound slices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decomposition is being kept on the development branch as experimental rather than landed on main. It presupposes one way of working — an issue split into a sub-issue graph with a human approval gate on the proposed plan — and main should not presuppose that. The rest of this arc is workstyle-neutral: iterating on a PR, heartbeats, clarify-resume, and the compute substrate work regardless. Removes from this slice the workflow YAML, its prompt, its registration in the prompt map, its DESCRIPTORS admission entry, and the two prompt-injection blocks that only a planning task used (the warm repo digest and the revise-round edit-in-place directive, both keyed on channel_metadata nothing else writes). Two things deliberately KEPT, because they are contracts rather than features: - The repo-ful artifact branch in the pipeline. It is selected by the workflow contract (terminal_outcomes.primary == artifact AND requires_repo), not by a workflow id, and no shipped workflow currently takes it. Deleting it would remove a declared capability and the failure mode is bad — an empty PR opened for a document task, or a build run that was never wanted. Its test now synthesizes the workflow by copying the real coding workflow and flipping only those two fields, so the test cannot drift from the production contract the way a hand-built stub would. Verified by forcing the gate false and watching it fail. - The trigger-label and :help helpers. Those are label plumbing, not decomposition. Tests that used the planning workflow purely as a convenient read-only or non-PR example now use coding/pr-review-v1, which is the surviving read-only workflow — the properties they assert (a read-only task inherits the repo's compute substrate; a non-PR workflow still takes the default clone path) are real and still worth pinning. Comments across the stack, cluster, orchestrator, and fan-out handlers described this branch in terms of the planning workflow; they now describe artifact workflows generally, since that is what the code actually keys on. No decompose reference survives in this slice's source or tests. --- agent/src/linear_reactions.py | 7 +- agent/src/pipeline.py | 46 ++++---- agent/src/prompt_builder.py | 137 ----------------------- agent/src/prompts/__init__.py | 6 - agent/src/prompts/decompose.py | 126 --------------------- agent/src/repo.py | 4 +- agent/src/runner.py | 10 +- agent/tests/test_pipeline.py | 44 ++++++-- agent/tests/test_pipeline_outcomes.py | 2 +- agent/tests/test_prompts.py | 123 +------------------- agent/tests/test_repo.py | 6 +- agent/workflows/coding/decompose-v1.yaml | 59 ---------- cdk/src/handlers/shared/workflows.ts | 13 --- 13 files changed, 73 insertions(+), 510 deletions(-) delete mode 100644 agent/src/prompts/decompose.py delete mode 100644 agent/workflows/coding/decompose-v1.yaml diff --git a/agent/src/linear_reactions.py b/agent/src/linear_reactions.py index 797d49d6f..1a8a4b6a8 100644 --- a/agent/src/linear_reactions.py +++ b/agent/src/linear_reactions.py @@ -257,9 +257,8 @@ def _get_viewer_id() -> str | 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, or an ``:auto``/``:decompose`` - the planner declined to a single unit) previously left the issue in Backlog - for its whole run — only orchestration parents got a state change (via the + 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. @@ -451,7 +450,7 @@ def react_task_started( # 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 - # (decompose-v1, pr-review) — the orchestration panel owns the parent state. + # (pr-review) — the orchestration panel owns the parent state. if transition_state: _transition_issue_state(issue_id, "started", ["In Progress"]) diff --git a/agent/src/pipeline.py b/agent/src/pipeline.py index 4f867fed6..32bf80e80 100644 --- a/agent/src/pipeline.py +++ b/agent/src/pipeline.py @@ -174,16 +174,15 @@ def _deliver_plan_artifact( prompt: str, agent_result, ) -> str | None: - """Deliver a decompose-planning agent's plan as the task artifact (#299). - - ``coding/decompose-v1`` is a repo-ful workflow whose primary terminal outcome - is an ARTIFACT (the decomposition plan), not a PR. It clones the repo for full - planning context but produces no code change — so the build/PR post-hooks do - not apply. This uploads the agent's final result text (the plan JSON) 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 "planned nothing". + """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 @@ -201,7 +200,7 @@ def _deliver_plan_artifact( deliver_ctx.agent_result = agent_result result = deliver_artifact("s3", deliver_ctx) artifact_uri = result.artifact_uri - log("POST", f"decompose plan delivered as artifact: {artifact_uri}") + log("POST", f"artifact delivered: {artifact_uri}") if artifact_uri: progress.write_agent_milestone("artifact_delivered", artifact_uri) return artifact_uri @@ -686,7 +685,7 @@ def _apply_delivery_gate( 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 (decompose) ship no PR by design; + * 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; @@ -1080,7 +1079,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # 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 (decompose-v1 planning, + # 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 @@ -1315,11 +1314,16 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: ) ensure_pr_strategy = "create" - # #299 agent-native decompose: a REPO-FUL workflow whose primary - # terminal outcome is an ARTIFACT (coding/decompose-v1) clones the - # repo for context but produces a plan, not a PR. Skip the build/PR - # post-hooks; deliver the agent's result text (the plan JSON) as the - # artifact so the platform can read it and seed the sub-issues. + # 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 @@ -1332,7 +1336,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: and getattr(_workflow.terminal_outcomes, "primary", None) == "artifact" and getattr(_workflow, "requires_repo", False) ) - artifact_uri: str | None = None # set by the decompose (artifact) branch below + 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 @@ -1340,7 +1344,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # 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 (decompose emits JSON, not 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). @@ -1658,7 +1662,7 @@ 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, - # #299: a decompose (artifact) workflow carries the plan artifact + # 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, diff --git a/agent/src/prompt_builder.py b/agent/src/prompt_builder.py index 79d942e77..3582575d6 100644 --- a/agent/src/prompt_builder.py +++ b/agent/src/prompt_builder.py @@ -41,44 +41,6 @@ def build_system_prompt( ) system_prompt = system_prompt.replace("{setup_notes}", setup_notes) - # #299 plan-mode T2 (warm digest): a revise-round decompose task carries the - # PRIOR run's repo_digest in channel_metadata (a NON-guardrail-screened - # channel — task_description is screened, this isn't; see create-task-core). - # Inject it so the agent starts from the cached structural understanding - # instead of re-deriving it. Cache-key discipline: the prior run recorded the - # sha it cloned to (decompose_repo_digest_sha); if the repo has since moved, - # the agent is told the digest may be stale for changed areas and to re-verify - # there (drift handling, agent-side — the platform has no GitHub token to - # pre-check, by P5 least-privilege design). Harmless no-op for a prompt - # without the placeholder or a round-0 task with no prior digest. - system_prompt = system_prompt.replace( - "{prior_repo_digest}", - _render_prior_repo_digest(config, setup), - ) - # #299 BLOCKER-1 (revise-forgets-edits): on a REVISION round the task carries - # the CURRENT breakdown (in the guardrail-screened task_description, as - # "Earlier proposed breakdown") plus the reviewer's requested change. Without - # explicit framing the decompose prompt reads as "plan this issue from - # scratch", so the agent re-derives from the issue text and silently reverts - # edits the reviewer had already accepted (a dropped node reappears, a reworded - # title snaps back). This directive — injected ONLY on a revision — reframes - # the task as EDIT-the-current-plan: apply only the requested change, keep - # everything else verbatim. It lives in the trusted system prompt (NOT the - # screened task_description, which can't carry imperatives without tripping - # PROMPT_ATTACK — Bug #1a). Empty on round 0. NOTE: only the ESCALATION path - # reaches this agent now — most revises are applied deterministically in the - # webhook (interpret → edit the stored plan in code, no clone, no re-derive). - system_prompt = system_prompt.replace( - "{revision_directive}", - _render_revision_directive(config), - ) - # #299 plan-mode T2: the sha the repo was cloned to, echoed into the plan - # JSON's ``repo_digest_sha`` so a later revise run can drift-check the cached - # digest. Empty when unknown (best-effort — the platform's sha-shape guard - # then just treats the digest as un-versioned). Harmless no-op without the - # placeholder. - system_prompt = system_prompt.replace("{repo_head_sha}", setup.head_sha_before or "") - # Inject memory context from orchestrator hydration memory_context_text = "(No previous knowledge available for this repository.)" if hydrated_context and hydrated_context.memory_context: @@ -154,105 +116,6 @@ def build_repoless_system_prompt( return system_prompt -def _render_prior_repo_digest(config: TaskConfig, setup: RepoSetup) -> str: - """#299 plan-mode T2 — render the cached prior repo digest into the decompose - prompt, or empty string when there is none (round-0 plan / non-decompose). - - A revise-round ``coding/decompose-v1`` task carries the previous run's - ``repo_digest`` + the sha it was built at in ``channel_metadata`` (keys - ``decompose_repo_digest`` / ``decompose_repo_digest_sha``). channel_metadata is - NOT guardrail-screened (unlike task_description), so a large structural blob - rides here safely. We inject it as reference DATA so the agent starts from the - prior structural understanding rather than re-deriving it — the exploration is - the expensive part of a revise round, and structural facts rarely change - between rounds. - - Drift: the prior run recorded the sha it cloned to. If the repo has since moved - (``head_sha_before`` differs), the digest may be stale for changed areas, so we - say so and tell the agent to re-verify there. The platform can't pre-check the - sha (no GitHub token — P5 least-privilege), so this agent-side compare IS the - drift handling. A blank prior sha (older task) is treated as "unknown → trust - but re-verify if anything looks off". - """ - cm = config.channel_metadata or {} - digest = (cm.get("decompose_repo_digest") or "").strip() - if not digest: - return "" # round-0 or no cached digest → the agent explores fresh - prior_sha = (cm.get("decompose_repo_digest_sha") or "").strip() - current_sha = (setup.head_sha_before or "").strip() - if prior_sha and current_sha and prior_sha != current_sha: - freshness = ( - "NOTE: the repository has changed since this digest was captured " - f"(digest @ {prior_sha[:8]}, repo now @ {current_sha[:8]}). Treat it as " - "a starting map, and re-verify any area your plan touches that may have " - "moved." - ) - else: - freshness = ( - "This reflects the repository at its current state; use it as your " - "starting map and only re-read files where this revision's feedback " - "requires deeper detail." - ) - return ( - "\n **Prior exploration of this repository (reuse this — don't re-derive " - "from scratch):**\n" - f" {freshness}\n" - " ```\n" - f" {digest}\n" - " ```" - ) - - -def _render_revision_directive(config: TaskConfig) -> str: - """#299 BLOCKER-1 — render the revise-in-place directive for a REVISION round, - or empty string for a first-time plan. - - A revise-round ``coding/decompose-v1`` task carries the CURRENT breakdown in - its (guardrail-screened) task_description as reference data, plus the - reviewer's requested change. Without explicit framing the decompose prompt - reads as "plan this issue from scratch" and the agent re-derives the whole - breakdown, silently reverting edits the reviewer had already accepted (dropped - nodes reappear, reworded titles snap back — the customer-caught BLOCKER 1). - - This directive reframes the task as an EDIT of the current plan: start FROM it, - apply ONLY the requested change, keep every other sub-issue verbatim. It must - live in the trusted system prompt — the screened task_description can't carry - imperatives ("start from this plan and change only X") without tripping the - PROMPT_ATTACK filter (Bug #1a). Gated on ``decompose_revision_round`` (set by - the webhook only on a revise dispatch); a blank/zero/absent value → round 0 → - empty (no-op). - - NOTE: most revises never reach this agent — the webhook interprets the change - into structured edits and applies them to the stored plan DETERMINISTICALLY - (no clone, no re-derive). This directive only governs the ESCALATION path, - where a change genuinely needs the repo (feasibility / new scope). The - reviewer-facing "what changed" line is computed by the platform from the - before→after diff — the agent does NOT self-report it (an earlier cut had the - agent describe its own changes and it fabricated a justification for a - silently re-added dropped node). - """ - cm = config.channel_metadata or {} - raw_round = (cm.get("decompose_revision_round") or "").strip() - try: - revision_round = int(raw_round) - except ValueError: - revision_round = 0 - if revision_round <= 0: - return "" - return ( - "\n**This is a REVISION of an existing breakdown, not a fresh plan.** The " - "current breakdown and the reviewer's requested change are given below " - '(under "Earlier proposed breakdown" and "Requested changes"). Treat ' - "the current breakdown as your starting point: apply ONLY the change the " - "reviewer asked for and keep every other sub-issue EXACTLY as it is — same " - "titles, scopes, sizes, and dependencies — unless their change requires " - "touching it. Do NOT re-derive the whole breakdown from the issue text and " - "do NOT silently undo edits already reflected in the current breakdown " - "(e.g. a sub-issue that was dropped stays dropped; a reworded title stays " - "reworded).\n" - ) - - def _render_memory_context(hydrated_context: HydratedContext | None) -> str: """Render the memory-context block shared by repo-bound and repo-less prompts.""" if not (hydrated_context and hydrated_context.memory_context): diff --git a/agent/src/prompts/__init__.py b/agent/src/prompts/__init__.py index aef184031..c4b6e2621 100644 --- a/agent/src/prompts/__init__.py +++ b/agent/src/prompts/__init__.py @@ -9,7 +9,6 @@ from shell import log from .base import BASE_PROMPT -from .decompose import DECOMPOSE_WORKFLOW from .default_agent import DEFAULT_AGENT_PROMPT from .new_task import NEW_TASK_WORKFLOW from .pr_iteration import PR_ITERATION_WORKFLOW @@ -31,11 +30,6 @@ # 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), - # Agent-native decomposition planning: clone the repo, decide whether to - # split the issue, draft a decomposition plan, and emit it as the artifact. - # Repo-ful (uses BASE_PROMPT's clone env) but opens no PR — the platform - # seeds sub-issues from the plan. - "coding/decompose-v1": BASE_PROMPT.replace("{workflow}", DECOMPOSE_WORKFLOW), # Repo-less knowledge workflow — no git/branch/PR placeholders. "default/agent-v1": DEFAULT_AGENT_PROMPT, # Repo-less reference knowledge workflow — a research-specialized prompt so it diff --git a/agent/src/prompts/decompose.py b/agent/src/prompts/decompose.py deleted file mode 100644 index 59c3dd046..000000000 --- a/agent/src/prompts/decompose.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Workflow section for ``coding/decompose-v1`` — decomposition planning run as -a real agent task, rather than as a blind two-call model invocation inside the -webhook Lambda. - -Unlike the other coding workflows this one does NOT change code or open a PR: it -clones the repo (so it plans with FULL repository context, which a planner -running inside the webhook Lambda never had — it lacked the repo, so it timed out -and planned blind), decides whether the issue should be decomposed, and if so -emits a structured decomposition-plan JSON as its final message. The platform's -existing write-back + seed machinery (idempotent issueCreate/issueRelationCreate) -consumes that plan and turns it into sub-issues; the agent does NOT create -sub-issues itself. - -Slots into BASE_PROMPT's {workflow} placeholder like the other coding variants, -so it inherits the clone/{repo_url}/{branch_name} environment — but its steps -override the PR-creation flow with a plan-emit deliverable (mirrors the -web_research "your final message IS the artifact" contract, for a repo-ful task). -""" - -DECOMPOSE_WORKFLOW = """\ -## Workflow — decomposition planning (no code changes, no PR) - -You are PLANNING how a fleet of autonomous coding agents should tackle this \ -issue in THIS repository. You will NOT write code, commit, or open a pull \ -request. Your only deliverable is a decomposition plan (see below). A separate \ -system turns your plan into Linear sub-issues and runs them — you do not create \ -sub-issues yourself. - -Your GOAL is to get the issue done as RELIABLY as possible — fewest errors — at \ -reasonable cost. Decompose ONLY when splitting the work genuinely serves that \ -goal; never split for its own sake, and never split one coherent feature across \ -technical layers (interface / logic / stored state / tests) — a lone layer has \ -no standalone value. -{revision_directive} -Follow these steps in order: - -1. **Understand the repository and the issue** - The repo `{repo_url}` is cloned at `{workspace}/{task_id}`. Read the README, \ -the project layout, relevant modules, docs (ROADMAP/ARCHITECTURE/guides), and \ -any existing tests — enough to judge what the issue actually entails HERE. A \ -short issue title may name work that is much larger (or smaller) than it looks \ -until you see the code. Use the repo; do not plan from the title alone. -{prior_repo_digest} - -2. **Decide: one cohesive unit, or a dependency-ordered breakdown?** - Decompose when the issue genuinely contains two or more separable units of \ -work that each stand on their own — a coherent change one agent could implement \ -and a reviewer could judge in isolation, each delivering an identifiable piece \ -of the goal. Keep it as ONE unit when the parts only make sense together, share \ -mutable state, or must change in lockstep. A dependency/build-order between \ -parts is NOT by itself a reason to merge them — ordering is handled for you. - - If the issue is too thin to tell what the separable pieces are and the \ -repository doesn't make them obvious either, say so (set ``decompose: false`` \ -with a ``reasoning`` that asks for more detail) rather than guessing. - - **The ``reasoning`` is shown verbatim to the person who filed the issue. \ -State only what you actually observed — e.g. "the description is empty" or "this \ -is a single one-line change". Do NOT assert specific facts you cannot verify from \ -what you read: never cite commit hashes, PR numbers, dates, or claims like "this \ -is already fixed" / "all known bugs are resolved". If you're declining because \ -there's nothing to act on, say that plainly and ask for the missing detail — \ -don't invent supporting specifics to justify the verdict. - - **Name your assumption when the target is ambiguous.** A thin request \ -("make the dashboard better", "improve the export") often maps to more than one \ -thing in the repo (e.g. an internal ops/monitoring dashboard vs. the product UI \ -users see). If you INFER which subject the issue means in order to plan, say so \ -in the FIRST sentence of the ``reasoning`` — name what you assumed and the \ -alternative you ruled out (e.g. "Assuming you mean the customer-facing analytics \ -page, not the internal CloudWatch ops dashboard — tell me if it's the latter.") \ -so the reviewer can correct a wrong target BEFORE approving, rather than \ -discovering it after work runs. When the target genuinely can't be inferred, \ -decline for more detail instead of guessing. - -3. **If decomposing, draft the breakdown** - - Propose only as many sub-issues as the work honestly has — fewer is \ -better. The project enforces a hard cap and will reject an over-large plan, so \ -keep the breakdown tight (a handful of units, not a long list). Each must be a \ -VERTICAL SLICE an agent can implement on its own. - - Give each a short imperative title and a one-paragraph scope, and a size: \ -"S" (small/isolated), "M" (medium), or "L" (large/involved). - - **Write the title and scope for the PERSON who filed the issue — who may \ -not be an engineer.** Say WHAT each piece delivers and WHY, in plain language, \ -before any implementation detail. Ground your plan in the repo, but do NOT lead \ -with jargon: avoid raw file paths, framework/tool names (e.g. "Vitest", \ -"serverless route"), or internal terms in the title, and keep them out of the \ -first sentence of the scope. A reviewer should understand what they're approving \ -without opening the codebase. It's fine to mention a specific file or tool later \ -in the scope when it genuinely aids a technical reader — just don't make it the \ -headline. - - Express dependencies with ``depends_on``: zero-based indices into your own \ -``sub_issues`` array of the sub-issues that must finish first. Independent \ -sub-issues have ``depends_on: []``. Keep the critical path as short as the work \ -honestly allows — parallelize independent work. Dependencies MUST form a DAG. - -4. **Emit the plan as your FINAL message** - Your final message IS the deliverable — it is captured as the task artifact \ -and consumed by the platform. Output ONLY a single JSON object (no prose, no \ -markdown fences) of this EXACT shape: - ``` - {{ - "decompose": true, - "reasoning": "one or two sentences explaining the verdict", - "sub_issues": [ - {{ "title": "string", "description": "string", "size": "S"|"M"|"L", "depends_on": [int, ...] }} - ], - "repo_digest": "a compact structural summary of what you learned exploring \ -this repo (see below)", - "repo_digest_sha": "{repo_head_sha}" - }} - ``` - When you decide NOT to decompose, output `{{ "decompose": false, "reasoning": "...", "sub_issues": [], "repo_digest": "...", "repo_digest_sha": "{repo_head_sha}" }}`. - Do not include any text before or after the JSON object. - Copy ``repo_digest_sha`` VERBATIM from here: ``{repo_head_sha}`` — it records \ -the exact repository revision your digest describes, so a later run can tell if \ -the repo has moved. - - **The ``repo_digest`` field** — this is a reusable, plain-text structural map \ -of the repository, so a LATER planning run on this same issue (when the reviewer \ -asks for a change) can start from your understanding instead of re-deriving it. \ -Capture, in a few compact lines: the project layout (top-level modules/dirs and \ -what each is for), the conventions a new feature follows here (where API/UI/tests \ -live, the pattern to imitate), any existing pattern this issue resembles, and the \ -concrete files/dirs a breakdown would touch. Write it as durable repo facts, NOT \ -as instructions or commentary about the plan — no second-person, no "you should". \ -Keep it tight (aim for well under ~1500 characters). It is data for the next run, \ -not part of the proposal the human sees. -""" diff --git a/agent/src/repo.py b/agent/src/repo.py index d06b7daac..19f2aa7ec 100644 --- a/agent/src/repo.py +++ b/agent/src/repo.py @@ -422,7 +422,7 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: resolve_verify_argv, ) - # A read_only workflow (coding/decompose-v1) clones, reads/greps to plan, and + # 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 @@ -600,7 +600,7 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: _install_commit_hook(repo_dir) # Ensure the cloned HEAD sha is captured for NON-PR workflows too (the PR - # branch above already set it). The coding/decompose-v1 planner echoes this + # 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 diff --git a/agent/src/runner.py b/agent/src/runner.py index 2f0d20800..c88f1194f 100644 --- a/agent/src/runner.py +++ b/agent/src/runner.py @@ -341,16 +341,14 @@ def _initialize_policy_engine_and_hooks( # 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; decompose emits a plan -# artifact (its "ask for more detail" is `decompose:false` with reasoning); web/ -# default artifact tasks don't open PRs. Only the plain PR-producing new_task path -# benefits from an ask-instead-of-guess signal. +# 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", - "coding/decompose-v1", "default/agent-v1", "web/research-v1", ) @@ -521,7 +519,7 @@ def _on_stderr(line: str) -> None: # 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 (decompose) — they have their own terminal shapes 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] = {} diff --git a/agent/tests/test_pipeline.py b/agent/tests/test_pipeline.py index a4c9e9e68..93cc435fe 100644 --- a/agent/tests/test_pipeline.py +++ b/agent/tests/test_pipeline.py @@ -562,7 +562,7 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N @patch("repo.setup_repo") @patch("pipeline.task_span") @patch("pipeline.task_state") - def test_decompose_workflow_delivers_plan_artifact_and_skips_pr( + def test_repoful_artifact_workflow_delivers_artifact_and_skips_pr( self, _mock_task_state, mock_task_span, @@ -572,10 +572,15 @@ def test_decompose_workflow_delivers_plan_artifact_and_skips_pr( mock_run_agent, monkeypatch, ): - # #299 agent-native decompose: coding/decompose-v1 is REPO-FUL (clones for - # context) but its terminal outcome is an ARTIFACT (the plan JSON), not a - # PR. It must take the repo-bound path (clone), then deliver the plan as an - # artifact and SKIP the build/PR post-hooks entirely. + # 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( @@ -583,20 +588,37 @@ def test_decompose_workflow_delivers_plan_artifact_and_skips_pr( branch="bgagent/test/branch", build_before=True, ) - plan_json = '{"decompose": true, "reasoning": "two features", "sub_issues": []}' + 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=plan_json + 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/decompose-1/result.md", + 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, @@ -612,8 +634,8 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N task_description="Add auth + billing + admin", github_token="ghp_test", aws_region="us-east-1", - task_id="decompose-1", - resolved_workflow={"id": "coding/decompose-v1", "version": "1.0.0"}, + 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. @@ -624,7 +646,7 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N mock_ensure_committed.assert_not_called() assert result["status"] == "success" assert result["pr_url"] is None - assert result["artifact_uri"] == "s3://artifacts-bkt/artifacts/decompose-1/result.md" + assert result["artifact_uri"] == "s3://artifacts-bkt/artifacts/artifact-1/result.md" class TestChainPriorAgentError: diff --git a/agent/tests/test_pipeline_outcomes.py b/agent/tests/test_pipeline_outcomes.py index fbd7fdb11..f3fb0001d 100644 --- a/agent/tests/test_pipeline_outcomes.py +++ b/agent/tests/test_pipeline_outcomes.py @@ -220,7 +220,7 @@ def test_read_only_success_no_pr_stays_success(self): assert err is None def test_artifact_workflow_success_no_pr_stays_success(self): - # decompose delivers an artifact_uri, not a PR. + # An artifact workflow delivers an artifact_uri, not a PR. overall, err = self._gate(artifact_workflow=True) assert overall == "success" assert err is None diff --git a/agent/tests/test_prompts.py b/agent/tests/test_prompts.py index 854bc9274..08e42a422 100644 --- a/agent/tests/test_prompts.py +++ b/agent/tests/test_prompts.py @@ -52,10 +52,10 @@ def test_linear_addendum_is_deterministic_no_mcp(self): def test_linear_addendum_same_regardless_of_workflow(self): # There is no longer per-workflow MCP choreography — new-task, iteration, - # and decompose all get the same deterministic "platform manages Linear" + # 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/decompose-v1", "coding/new-task-v1"): + 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"} @@ -351,122 +351,3 @@ def test_all_vectors_match(self, vectors): for v in vectors: actual = hashlib.sha256(v["input"].encode("utf-8")).hexdigest() assert actual == v["sha256"], f"Hash mismatch for: {v['note']}" - - -class TestDecomposePriorRepoDigest: - """#299 plan-mode T2 — the warm-digest injection into the decompose prompt.""" - - def _setup(self, head_sha: str = "a1b2c3d4e5f6a7b8"): - from models import RepoSetup - - return RepoSetup(repo_dir="/w/repo", branch="feat/x", head_sha_before=head_sha) - - def _decompose_config(self, channel_metadata=None) -> TaskConfig: - return _config( - task_id="t-1", - resolved_workflow={"id": "coding/decompose-v1", "version": "1.0.0"}, - channel_source="linear", - channel_metadata=channel_metadata or {}, - ) - - def test_round0_no_prior_digest_leaves_placeholder_empty(self): - from prompt_builder import build_system_prompt - - prompt = build_system_prompt(self._decompose_config(), self._setup(), None, "") - # The placeholder is always substituted (never leaks a literal {token}). - assert "{prior_repo_digest}" not in prompt - assert "{repo_head_sha}" not in prompt - # Round 0: no "prior exploration" block. - assert "Prior exploration of this repository" not in prompt - - def test_revise_injects_prior_digest_and_current_sha_echo(self): - from prompt_builder import build_system_prompt - - cfg = self._decompose_config( - { - "decompose_repo_digest": "modules: api/, ui/; tests in test/", - "decompose_repo_digest_sha": "a1b2c3d4e5f6a7b8", - } - ) - prompt = build_system_prompt(cfg, self._setup("a1b2c3d4e5f6a7b8"), None, "") - assert "Prior exploration of this repository" in prompt - assert "modules: api/, ui/; tests in test/" in prompt - # Same sha → the "current state" freshness note, not the drift warning. - assert "current state" in prompt - assert "has changed since this digest" not in prompt - # The sha is echoed for the agent to copy into repo_digest_sha. - assert "a1b2c3d4e5f6a7b8" in prompt - - def test_drift_note_when_repo_moved(self): - from prompt_builder import build_system_prompt - - cfg = self._decompose_config( - { - "decompose_repo_digest": "modules: api/, ui/", - "decompose_repo_digest_sha": "0000000aaaaaaaaa", # prior sha - } - ) - # Repo now at a DIFFERENT sha → the agent is warned to re-verify. - prompt = build_system_prompt(cfg, self._setup("ffffffff11111111"), None, "") - assert "has changed since this digest" in prompt - assert "re-verify" in prompt - - -class TestDecomposeRevisionDirective: - """#299 BLOCKER-1 — the revise-in-place directive injected on a REVISION round. - - Without it the decompose prompt reads as "plan from scratch" and the agent - silently reverts edits the reviewer already accepted; with it the agent is - told to EDIT the current plan (apply only the requested change, keep the rest) - and report the diff in ``change_summary``. - """ - - def _setup(self, head_sha: str = "a1b2c3d4e5f6a7b8"): - from models import RepoSetup - - return RepoSetup(repo_dir="/w/repo", branch="feat/x", head_sha_before=head_sha) - - def _decompose_config(self, channel_metadata=None) -> TaskConfig: - return _config( - task_id="t-1", - resolved_workflow={"id": "coding/decompose-v1", "version": "1.0.0"}, - channel_source="linear", - channel_metadata=channel_metadata or {}, - ) - - def test_round0_no_revision_directive(self): - from prompt_builder import build_system_prompt - - prompt = build_system_prompt(self._decompose_config(), self._setup(), None, "") - # The placeholder is always substituted (never leaks a literal {token}). - assert "{revision_directive}" not in prompt - # Round 0: no "this is a REVISION" framing. - assert "This is a REVISION" not in prompt - - def test_revision_round_injects_edit_in_place_directive(self): - from prompt_builder import build_system_prompt - - cfg = self._decompose_config({"decompose_revision_round": "1"}) - prompt = build_system_prompt(cfg, self._setup(), None, "") - assert "This is a REVISION" in prompt - # It tells the agent to preserve untouched sub-issues and not re-derive. - assert "keep every other sub-issue" in prompt - assert "do NOT silently undo edits" in prompt.replace("\n", " ") - - def test_zero_or_garbage_revision_round_is_treated_as_round0(self): - from prompt_builder import build_system_prompt - - for raw in ("0", "", "not-a-number"): - cfg = self._decompose_config({"decompose_revision_round": raw}) - prompt = build_system_prompt(cfg, self._setup(), None, "") - assert "This is a REVISION" not in prompt, f"round={raw!r} should be round-0" - - def test_change_summary_field_retired_from_plan_shape(self): - from prompt_builder import build_system_prompt - - # #299 BLOCKER-1 round 2: the agent-authored change_summary was RETIRED - # (it fabricated a justification for a re-added dropped node). The "what - # changed" line is now computed by the platform from the before→after diff, - # so the emit-step JSON shape must NOT ask for change_summary anymore. - prompt = build_system_prompt(self._decompose_config(), self._setup(), None, "") - assert '"change_summary"' not in prompt diff --git a/agent/tests/test_repo.py b/agent/tests/test_repo.py index 3bd877cee..0091fe555 100644 --- a/agent/tests/test_repo.py +++ b/agent/tests/test_repo.py @@ -97,10 +97,10 @@ def test_pr_branch_checkout_path(self, monkeypatch): assert setup.default_branch == "develop" def test_non_pr_task_captures_head_sha_for_digest(self, monkeypatch): - # #299 plan-mode T2: a NON-PR workflow (e.g. coding/decompose-v1) must also + # 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 that decompose uses. + # 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") @@ -191,7 +191,7 @@ def test_linear_child_base_branch_is_left_untouched(self, monkeypatch): class TestReadOnlyBaselineSkip: - """#299 ECS_RIGHTSIZED_PLANNING: a read_only workflow (coding/decompose-v1) + """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 diff --git a/agent/workflows/coding/decompose-v1.yaml b/agent/workflows/coding/decompose-v1.yaml deleted file mode 100644 index 33a533e0b..000000000 --- a/agent/workflows/coding/decompose-v1.yaml +++ /dev/null @@ -1,59 +0,0 @@ -# #299 Mode B agent-native planning (replaces the webhook Lambda's blind -# two-call Bedrock planner). Clone the repo, plan a decomposition with FULL -# repository context, and deliver the plan JSON as an artifact — no code -# changes, no PR. The platform reads the artifact and seeds Linear sub-issues -# from it (idempotent write-back → Mode A), preserving the :decompose approval -# gate. Structurally a `pr-review`-shaped read-only clone task, but its terminal -# outcome is an ARTIFACT (the plan) rather than a PR (mirrors web-research). -# -# Root-causes ABCA-490 (30s Lambda ceiling killed the planner mid-call) and -# ABCA-492 (planner was blind to the repo): planning now runs on the tunable -# agent substrate inside a real clone, with turns instead of a single 30s call. -id: coding/decompose-v1 -version: 1.0.0 -domain: coding -description: >- - Plan how to decompose an issue into dependency-ordered sub-issues, using full - repository context. Deliverable is a decomposition-plan artifact; never - mutates the repo and never opens a PR — the platform seeds the sub-issues. -guidance: >- - Platform-issued by the Linear webhook on a `:decompose` / `:auto` label. Not a - user-facing workflow_ref. -requires_repo: true -read_only: true -prompt: - template: registry://prompt/coding-decompose-workflow - placeholders: - - repo_url - - task_id - - workspace - - branch_name - - default_branch - - max_turns - - setup_notes - - memory_context -hydration: - sources: [issue, memory, task_description] -agent_config: - tier: read-only - # No Write/Edit — read-only tier may not grant mutating tools (validator rule - # 6). The deliverable is the agent's synthesised plan JSON, not a code change. - allowed_tools: [Bash, Read, Glob, Grep, WebFetch] - cedar_policy_modules: [builtin/hard_deny] -repo_config: - provider: github - discover: true -required_inputs: - one_of: [issue_number, task_description] -steps: - - { kind: clone_repo, name: setup } - - { kind: hydrate_context, name: context } - - { kind: run_agent, name: plan } - - { kind: deliver_artifact, name: deliver, target: s3 } -terminal_outcomes: - primary: artifact -limits: - max_turns: 60 -promotion_gate: - requires: [tests:agent/decompose] -status: production diff --git a/cdk/src/handlers/shared/workflows.ts b/cdk/src/handlers/shared/workflows.ts index b150f4beb..84524d059 100644 --- a/cdk/src/handlers/shared/workflows.ts +++ b/cdk/src/handlers/shared/workflows.ts @@ -146,19 +146,6 @@ const DESCRIPTORS: Record = { readOnly: false, requiredInputs: { allOf: ['pr_number'] }, }, - // Decomposition planning: clone the repo, decide whether the request splits, - // and draft the breakdown with full repo context, emitting the plan as the - // task's artifact. The platform seeds sub-issues from that plan (an idempotent - // write-back), after which the run proceeds like any human-authored graph. - // Repo-bound but read-only — it opens no PR, it only reads in order to plan. - // Platform-issued when a decompose/auto trigger label is applied. - 'coding/decompose-v1': { - id: 'coding/decompose-v1', - version: '1.0.0', - requiresRepo: true, - readOnly: true, - requiredInputs: { oneOf: ['issue_number', 'task_description'] }, - }, 'default/agent-v1': { id: 'default/agent-v1', version: '1.0.0',