diff --git a/agent/pyproject.toml b/agent/pyproject.toml index 17ae8cb64..fa86e5ea4 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -131,6 +131,16 @@ ignore = [ [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] +# Non-TTY output ergonomics. In a captured/piped context (the ECS build gate, CI, +# an agent's Bash tool) pytest's default is a wall of featureless progress dots +# with the "N passed" summary easy to lose in a tail — live-caught on ABCA-691, +# where an agent burned ~6 turns re-running the full suite with different flags +# because it couldn't confirm the result from the dots. ``count`` renders progress +# as an explicit "N/M passed" so a captured log always shows how far it got and how +# many passed; ``-ra`` appends a short summary of every non-passing outcome (with +# reasons) at the end. Both are display-only — they never change test results. +addopts = "-ra" +console_output_style = "count" # pytest-timeout: hard wall-clock cap PER TEST. A unit test that blocks (an # unmocked network/subprocess/Bedrock call with no timeout of its own) otherwise # hangs the whole `mise run build` until the 3600s build-verify ceiling kills it, @@ -143,6 +153,17 @@ pythonpath = ["src"] # but the build kept hanging ~55 min until the 3600s ceiling). signal is the real fix. timeout = 120 timeout_method = "signal" +# faulthandler backstop for hangs signal-timeout CANNOT interrupt. SIGALRM only +# fires in the MAIN thread during a test's call phase, so a hang in a WORKER +# thread (a deadlocked Barrier/join), a fixture, or a C-level socket read the +# main thread never returns from is invisible to it — exactly the ECS-only stall +# chased across ABCA-684/686/688 and again on the warm-cache run (53 min of +# silence, signal never fired). This arms faulthandler's dedicated C watchdog +# thread (immune to the GIL and blocked syscalls) to dump EVERY thread's Python +# stack after 300s in a test, so the next hang self-reports the exact file:line +# instead of stalling blind to the 3600s build ceiling. A session-level +# hard-exit watchdog for hangs OUTSIDE any test lives in tests/conftest.py. +faulthandler_timeout = 300 [tool.coverage.run] branch = true diff --git a/agent/src/channel_mcp.py b/agent/src/channel_mcp.py index 7407f9e28..2b762bb43 100644 --- a/agent/src/channel_mcp.py +++ b/agent/src/channel_mcp.py @@ -6,17 +6,33 @@ start and exposes the server's tools. Currently wired channels: -- ``linear`` → Linear hosted MCP (``mcp__linear-server__*`` tools) — functional. - ``jira`` → Atlassian Remote MCP entry — a NON-FUNCTIONAL placeholder. It is written for forward-compatibility but cannot connect from a headless agent (interactive OAuth 2.1 only); live outbound Jira comments go through the REST shim in ``jira_reactions.py``. See ``JIRA_MCP_URL`` below + ADR-015. -For all other channel sources this is a no-op: no MCP is written, and the -SDK sees no channel-specific tools. +Linear is NOT written here: ABCA runs Linear 100% deterministically (ADR-016 +"Linear is fully deterministic"). There is no Linear MCP — issue text, recent +comments, and attachments are pre-hydrated at the Lambda tier (the webhook +processor + ``linear-attachments.ts`` / ``linear-feedback.fetchRecentComments``), +and outbound reactions / state transitions go through direct GraphQL in +``linear_reactions.py`` (which reads ``LINEAR_API_TOKEN`` set by config.py — +independent of this module). The Linear MCP was removed after it proved +non-functional against a single OAuth app (actor=user data reads error; +actor=app can't re-consent an installed app). + +Beyond WRITING channel entries, this module also ENFORCES the Linear-no-MCP +guarantee: {@link strip_linear_mcp_servers} removes any Linear MCP server a repo +may have COMMITTED to its own ``.mcp.json`` before the SDK loads it (the prompt +prohibition is not a security boundary — the SDK loads ``project`` settings and +we export ``LINEAR_API_TOKEN``). The pipeline calls it after +``configure_channel_mcp`` on every task with a repo. + +For all other channel sources ``configure_channel_mcp`` is a no-op: no MCP is +written (though the scrub above still runs). See: cdk/src/handlers/{linear,jira}-webhook-processor.ts (inbound), -runner.py (SDK invocation). +runner.py (SDK invocation), ADR-016 (Linear determinism). """ from __future__ import annotations @@ -30,31 +46,6 @@ if TYPE_CHECKING: from collections.abc import Callable -# ─── Linear ────────────────────────────────────────────────────────────────── - -#: Linear MCP endpoint — hosted by Linear, Streamable HTTP transport. -LINEAR_MCP_URL = "https://mcp.linear.app/mcp" - -#: Key name inside ``mcpServers``. Tools surface as -#: ``mcp__linear-server__*`` in the Agent SDK. -LINEAR_MCP_SERVER_KEY = "linear-server" - -#: Env var name the MCP server entry reads via ``${LINEAR_API_TOKEN}`` -#: placeholder expansion. Populated from the OAuth secret by config.py. -LINEAR_API_TOKEN_ENV = "LINEAR_API_TOKEN" # noqa: S105 — env var *name*, not a secret value - - -def _linear_server_entry() -> dict[str, Any]: - """Build the `mcpServers` entry for Linear's hosted MCP.""" - return { - "type": "http", - "url": LINEAR_MCP_URL, - "headers": { - "Authorization": f"Bearer ${{{LINEAR_API_TOKEN_ENV}}}", - }, - } - - # ─── Jira (Atlassian Remote MCP — NON-FUNCTIONAL PLACEHOLDER) ──────────────── #: Atlassian Remote MCP endpoint — Streamable HTTP transport. @@ -99,7 +90,6 @@ def _jira_server_entry() -> dict[str, Any]: #: have a hosted MCP (api, webhook, slack) intentionally have no entry here — #: the gate in ``configure_channel_mcp`` short-circuits on missing keys. CHANNEL_MCP_BUILDERS: dict[str, tuple[str, Callable[[], dict[str, Any]]]] = { - "linear": (LINEAR_MCP_SERVER_KEY, _linear_server_entry), "jira": (JIRA_MCP_SERVER_KEY, _jira_server_entry), } @@ -124,7 +114,11 @@ def _read_existing_mcp_config(path: str) -> dict[str, Any]: return {} -def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool: +def configure_channel_mcp( + repo_dir: str, + channel_source: str, + channel_metadata: dict[str, str] | None = None, +) -> bool: """Write or merge a channel-specific ``.mcp.json`` into ``repo_dir``. Looks up ``channel_source`` in :data:`CHANNEL_MCP_BUILDERS`: @@ -136,11 +130,15 @@ def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool: Args: repo_dir: the cloned-repo working directory the SDK will use as ``cwd``. channel_source: inbound channel (``TaskConfig.channel_source``). + channel_metadata: per-task channel metadata (``TaskConfig.channel_metadata``). + Currently unused by the wired channels (Jira's entry is static); retained + for call-site compatibility and future channel builders that need it. Returns: True if a channel MCP entry was (re)written, False otherwise (channel unmapped, missing repo_dir, or write failure). """ + _ = channel_metadata # reserved for future channel builders (see docstring) builder_entry = CHANNEL_MCP_BUILDERS.get(channel_source) if builder_entry is None: return False @@ -184,3 +182,77 @@ def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool: "not MCP", ) return True + + +#: Substrings that mark an ``mcpServers`` entry as a Linear MCP server. Matched +#: (case-insensitively) against the server KEY and, defensively, the JSON of its +#: value (url / command / args / headers) so an entry named innocuously +#: (``"specs": {url:"https://mcp.linear.app/sse"}``) or reading the token +#: (``${LINEAR_API_TOKEN}``) is caught regardless of its key. +_LINEAR_MCP_MARKERS = ("linear", "mcp.linear.app", "linear_api_token") + + +def strip_linear_mcp_servers(repo_dir: str) -> int: + """Remove any Linear MCP server entry from the repo's ``.mcp.json``. + + ADR-016 ENFORCEMENT (not just convention): Linear is 100% deterministic and + the agent must have NO Linear MCP tools. The prompt says so, but a prompt is + not a security boundary — a repo could COMMIT a ``.mcp.json`` with a + ``linear-server`` entry using ``${LINEAR_API_TOKEN}``; the SDK loads + ``project`` settings + we export ``LINEAR_API_TOKEN``, so under + ``bypassPermissions`` those tools would authenticate and run. This scrubs any + such entry from the on-disk config BEFORE the SDK reads it, on every task + with a repo, regardless of channel_source. Jira's own entry (written by + ``configure_channel_mcp`` for jira tasks) never matches these markers. + + Returns the number of server entries removed (0 when none / no file). + Best-effort: a read/write failure logs and returns 0 (the prompt prohibition + + the absence of any platform-written Linear entry still hold). + """ + if not repo_dir or not os.path.isdir(repo_dir): + return 0 + mcp_path = os.path.join(repo_dir, ".mcp.json") + config = _read_existing_mcp_config(mcp_path) + servers = config.get("mcpServers") + if not isinstance(servers, dict) or not servers: + return 0 + + def _is_linear(key: str, value: object) -> bool: + hay = (key + " " + json.dumps(value, default=str)).lower() + return any(m in hay for m in _LINEAR_MCP_MARKERS) + + offending = [k for k, v in servers.items() if _is_linear(k, v)] + if not offending: + return 0 + + for k in offending: + del servers[k] + config["mcpServers"] = servers + + # If the Linear server(s) were the ONLY content, drop the whole file rather + # than leave an inert ``{"mcpServers": {}}`` behind — a repo that shipped a + # Linear-only .mcp.json ends up with no .mcp.json at all, which is the correct + # end state (the SDK then has nothing to load). Keep the file only when other + # MCP servers OR other top-level keys survive (a legit non-Linear server, or a + # Jira entry the platform just wrote for a jira task). + other_top_level_keys = [k for k in config if k != "mcpServers"] + try: + if not servers and not other_top_level_keys: + os.remove(mcp_path) + removed_file = True + else: + with open(mcp_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + f.write("\n") + removed_file = False + except OSError as e: + log("ERROR", f"strip_linear_mcp_servers: failed to rewrite/remove {mcp_path}: {e}") + return 0 + log( + "WARN", + f"Removed {len(offending)} Linear MCP server entr(ies) from {mcp_path} " + f"(ADR-016: Linear is deterministic; the agent has no Linear MCP). " + f"Keys: {', '.join(offending)}" + + ("; deleted the now-empty .mcp.json" if removed_file else ""), + ) + return len(offending) diff --git a/agent/src/clarification_tool.py b/agent/src/clarification_tool.py new file mode 100644 index 000000000..d69bda31f --- /dev/null +++ b/agent/src/clarification_tool.py @@ -0,0 +1,74 @@ +"""In-process ``request_clarification`` SDK tool (clarify-before-spend, UX #4). + +The customer-caught problem: a vague request like "make it faster" was answered +by GUESSING (shipping a plausible PR and charging for it) instead of asking what +was meant. The fix asks the agent to STOP and pose a question when a request is +too underspecified to implement without guessing. + +An earlier cut used a text sentinel the agent had to reproduce verbatim on its +final line. That proved unreliable live — the model either shipped a guess or +finished silently without emitting the exact string. A **tool call** is a +discrete, deterministic event: the model either invokes ``request_clarification`` +or it doesn't, and the runner sees the ``ToolUseBlock`` in the message stream (no +string-matching, no reproduction). This module defines that tool as an in-process +SDK MCP server; the runner registers it and captures the question, and the +pipeline treats a captured question as a hold-and-ask (no build, no PR). + +Kept tiny and side-effect-free: the tool just acknowledges the call. The +authoritative signal is the runner observing the call + its ``question`` arg. +""" + +from __future__ import annotations + +from typing import Any + +#: The in-process MCP server name. The SDK exposes each tool as +#: ``mcp____``, so the fully-qualified tool name the runner matches +#: on is :data:`CLARIFICATION_TOOL_NAME`. +CLARIFICATION_SERVER_NAME = "abca" +CLARIFICATION_TOOL_NAME = f"mcp__{CLARIFICATION_SERVER_NAME}__request_clarification" + + +def build_clarification_server() -> Any: + """Build the in-process SDK MCP server exposing ``request_clarification``. + + Returns the SDK server config dict for ``ClaudeAgentOptions(mcp_servers=...)``, + or ``None`` if the SDK is unavailable (defensive — the runner then simply + doesn't register it and the marker-based fallback still works). + """ + try: + from claude_agent_sdk import create_sdk_mcp_server, tool + except ImportError: # pragma: no cover - SDK always present in the container + return None + + @tool( + "request_clarification", + ( + "Ask the requester ONE clarifying question and STOP, instead of guessing, " + "when the task is too underspecified to implement without picking among " + "materially different interpretations (e.g. 'make it faster' with no target " + "or slow path named). Calling this opens NO pull request and charges nothing " + "for a guess — the platform surfaces your question to the requester. Only " + "call it for genuinely ambiguous goal-without-substance requests; a task that " + "names what to change is actionable, so just do it." + ), + {"question": str}, + ) + async def request_clarification(args: dict[str, Any]) -> dict[str, Any]: + question = str(args.get("question", "")).strip() + # The tool's own return is only feedback to the agent; the runner captures + # the question from the ToolUseBlock. Tell the agent to stop here. + return { + "content": [ + { + "type": "text", + "text": ( + "Clarifying question recorded and will be posted to the requester. " + "Do not make code changes or open a PR — end your turn now." + + (f" (question: {question})" if question else "") + ), + } + ] + } + + return create_sdk_mcp_server(name=CLARIFICATION_SERVER_NAME, tools=[request_clarification]) diff --git a/agent/src/config.py b/agent/src/config.py index 523f3174d..50d8969ee 100644 --- a/agent/src/config.py +++ b/agent/src/config.py @@ -17,14 +17,28 @@ # id whose ``requires_repo`` is false. Used by the load-failure fallback to # decide repo-optionality without loading the file. REPO_LESS_DEFAULT_WORKFLOW_ID = "default/agent-v1" -# First-party workflow ids that operate on an existing pull request. -PR_WORKFLOW_IDS = frozenset(("coding/pr-iteration-v1", "coding/pr-review-v1")) +# First-party workflow ids that operate on an existing pull request — they +# check out the existing PR branch instead of creating a fresh one. restack-v1 +# (#305) re-merges a changed predecessor into an existing stacked-child PR. +PR_WORKFLOW_IDS = frozenset(("coding/pr-iteration-v1", "coding/pr-review-v1", "coding/restack-v1")) +# Clarify-before-spend (customer UX #4): the exact marker a coding/new-task agent +# puts on the FIRST line of its final message when a request is too ambiguous to +# implement without guessing. Its presence tells the pipeline to hold — post the +# question, open NO PR, and surface it as "needs input" rather than a finished +# task. Kept as an unusual sentinel so it can't collide with ordinary prose. +NEEDS_INPUT_MARKER = "[[ABCA_NEEDS_INPUT]]" # First-party workflow ids that are writeable (NOT read-only). Used only by the # load-failure fallback to bias an unrecognised id toward read-only (fail closed # on the write-deny invariant). pr-review-v1 is intentionally excluded (it is # read-only); default/agent-v1 is excluded because its conservative posture # should fail closed too. -_KNOWN_WRITEABLE_WORKFLOW_IDS = frozenset(("coding/new-task-v1", "coding/pr-iteration-v1")) +_KNOWN_WRITEABLE_WORKFLOW_IDS = frozenset( + ( + "coding/new-task-v1", + "coding/pr-iteration-v1", + "coding/restack-v1", + ) +) def resolve_github_token() -> str: @@ -60,18 +74,17 @@ def resolve_linear_api_token(channel_metadata: dict[str, str] | None = None) -> Pass that dict in via ``channel_metadata`` (the pipeline does this automatically). We fetch the per-workspace secret, parse the token JSON, refresh if expiring, and cache the access_token in - ``LINEAR_API_TOKEN`` so downstream consumers (the Linear MCP's - ``${LINEAR_API_TOKEN}`` placeholder in ``.mcp.json`` and - ``linear_reactions.py``'s GraphQL Authorization header) keep working - unchanged. + ``LINEAR_API_TOKEN`` so the one remaining consumer — + ``linear_reactions.py``'s direct-GraphQL Authorization header (reactions + + state transitions) — keeps working. (ADR-016: there is no Linear MCP; this + token no longer feeds an ``.mcp.json`` placeholder.) For local development, a pre-set ``LINEAR_API_TOKEN`` env var short-circuits the lookup so the agent can run outside the runtime. - Returns an empty string when the credential is absent — the agent-side - MCP config then renders with an unresolved ``${LINEAR_API_TOKEN}`` - placeholder and the Linear MCP fails closed. This function is only - called when ``channel_source == 'linear'``. + Returns an empty string when the credential is absent — ``linear_reactions`` + then skips its reactions/state calls (best-effort, logged). This function is + only called when ``channel_source == 'linear'``. Phase 2.0a (parked) used AgentCore Identity. Phase 2.0b-O2 reads Secrets Manager directly because AgentCore Identity's USER_FEDERATION @@ -105,7 +118,7 @@ def resolve_linear_api_token(channel_metadata: dict[str, str] | None = None) -> from botocore.exceptions import BotoCoreError, ClientError except ImportError as e: log("WARN", f"resolve_linear_api_token: boto3 unavailable ({e}); skipping") - # nosemgrep: py-silent-success-masking -- optional Linear MCP; boto3 unavailable + # nosemgrep: py-silent-success-masking -- optional Linear reactions token; boto3 unavailable return "" sm = boto3.client("secretsmanager", region_name=region) @@ -116,8 +129,8 @@ def _fetch_token() -> dict | None: Returns the parsed dict, or None if the SM payload can't be decoded as JSON (corrupted byte, missing SecretString key, etc.). The caller treats None like a missing secret — agent - proceeds without Linear MCP rather than crashing the task - pipeline thread on a raw traceback. + proceeds without the Linear reactions token rather than crashing + the task pipeline thread on a raw traceback. """ resp = sm.get_secret_value(SecretId=secret_arn) try: @@ -316,7 +329,7 @@ def _refresh(current: dict) -> dict | None: return "" if token_obj is None: # Corrupted secret JSON; already logged inside _fetch_token. - # Fail closed — Linear MCP renders with unresolved placeholder. + # Corrupted secret JSON → no token; linear_reactions skips (best-effort). return "" if _is_expiring(token_obj.get("expires_at", "")): @@ -510,9 +523,13 @@ def build_config( dry_run: bool = False, task_id: str = "", system_prompt_overrides: str = "", + build_command: str = "", + lint_command: str = "", resolved_workflow: dict | None = None, branch_name: str = "", pr_number: str = "", + base_branch: str | None = None, + merge_branches: list[str] | None = None, channel_source: str = "", channel_metadata: dict[str, str] | None = None, trace: bool = False, @@ -533,7 +550,7 @@ def build_config( resolved_github_token = github_token or resolve_github_token() resolved_aws_region = aws_region or os.environ.get("AWS_REGION", "") resolved_anthropic_model = anthropic_model or os.environ.get( - "ANTHROPIC_MODEL", "us.anthropic.claude-sonnet-4-6" + "ANTHROPIC_MODEL", "us.anthropic.claude-opus-4-8" ) # Small/fast auxiliary model (WebFetch summarization etc.). Falls back to the # deployed ANTHROPIC_DEFAULT_HAIKU_MODEL env, then the platform default. Must @@ -623,6 +640,8 @@ def build_config( max_turns=max_turns, max_budget_usd=max_budget_usd, system_prompt_overrides=system_prompt_overrides, + build_command=build_command, + lint_command=lint_command, resolved_workflow=workflow, policy_principal=policy_principal, read_only=workflow_read_only, @@ -631,6 +650,8 @@ def build_config( is_pr_workflow=is_pr_workflow, branch_name=branch_name, pr_number=pr_number, + base_branch=base_branch, + merge_branches=merge_branches or [], task_id=task_id or uuid.uuid4().hex[:12], channel_source=channel_source, channel_metadata=channel_metadata or {}, @@ -653,7 +674,7 @@ def get_config() -> TaskConfig: issue_number=os.environ.get("ISSUE_NUMBER", ""), github_token=os.environ.get("GITHUB_TOKEN", ""), anthropic_model=os.environ.get("ANTHROPIC_MODEL", ""), - max_turns=int(os.environ.get("MAX_TURNS", "100")), + max_turns=int(os.environ.get("MAX_TURNS", "200")), max_budget_usd=float(os.environ.get("MAX_BUDGET_USD", "0")) or None, aws_region=os.environ.get("AWS_REGION", ""), dry_run=os.environ.get("DRY_RUN", "").lower() in ("1", "true", "yes"), diff --git a/agent/src/hooks.py b/agent/src/hooks.py index 19f3bafab..a17f04e82 100644 --- a/agent/src/hooks.py +++ b/agent/src/hooks.py @@ -4,15 +4,15 @@ REQUIRE_APPROVAL). The REQUIRE_APPROVAL path writes a pending approval row + transitions the task to AWAITING_APPROVAL atomically, polls for a human decision, then resumes / denies per the user's input. See - ``docs/design/CEDAR_HITL_GATES.md`` §6.5. + ``docs/design/CEDAR_HITL_GATES.md``. - PostToolUse: output scanner for secrets/PII. - Stop: between-turns hook dispatcher. Cancel → nudge → denial injection - in that order (cancel-wins semantics, finding #2). Each producer - appends synthetic user-message strings that get reinjected via the - SDK's ``decision: "block"`` mechanism. + in that order (cancel-wins semantics). Each producer appends synthetic + user-message strings that get reinjected via the SDK's + ``decision: "block"`` mechanism. -A module-level registry ``between_turns_hooks`` lets phases (Phase 2 -nudges, Phase 3 denial injections) append additional synthetic-message +A module-level registry ``between_turns_hooks`` lets other producers +(nudges, denial injections) append additional synthetic-message producers without touching the Stop hook callback itself. """ @@ -41,31 +41,31 @@ from telemetry import _TrajectoryWriter # --------------------------------------------------------------------------- -# Chunk 3 constants (§6.5 pseudocode) +# Approval-gate constants # --------------------------------------------------------------------------- -FLOOR_30S: int = FLOOR_TIMEOUT_S # §6 decision #6: sourced from contracts/constants.json -CLEANUP_MARGIN_120S: int = 120 # §6.5 lifetime-margin reserve for cleanup -# Poll cadence per §3 decision #3 and IMPL-12: 2s for the first 30s, 5s -# thereafter. Exact counts vary with ``timeout_s``; these pin the -# user-observable "fast for a bit, then slack off" behavior. +FLOOR_30S: int = FLOOR_TIMEOUT_S # minimum approval window; sourced from contracts/constants.json +CLEANUP_MARGIN_120S: int = 120 # lifetime-margin reserve for cleanup +# Poll cadence: 2s for the first 30s, 5s thereafter. Exact counts vary with +# ``timeout_s``; these pin the user-observable "fast for a bit, then slack off" +# behavior. POLL_FAST_INTERVAL_S: float = 2.0 POLL_FAST_DURATION_S: float = 30.0 POLL_SLOW_INTERVAL_S: float = 5.0 -POLL_DEGRADED_FAILS: int = 3 # emit approval_poll_degraded at this count (§13.2) -POLL_MAX_CONSECUTIVE_FAILS: int = 10 # treat as TIMED_OUT at this count (§13.2) -TOOL_INPUT_PREVIEW_MAX: int = 256 # §6.5: strip-ANSI, truncate +POLL_DEGRADED_FAILS: int = 3 # emit approval_poll_degraded at this many consecutive failures +POLL_MAX_CONSECUTIVE_FAILS: int = 10 # treat as TIMED_OUT at this many consecutive failures +TOOL_INPUT_PREVIEW_MAX: int = 256 # cap for the strip-ANSI, truncated input preview ELLIPSIS_LEN: int = 3 # chars reserved for the "..." truncation marker # ANSI CSI / OSC escape sequence stripper for ``tool_input_preview`` + -# ``permissionDecisionReason`` fields (§12.7). Re-derives the pattern from -# the canonical definition; kept local to avoid adding a cross-module -# dependency for one regex. +# ``permissionDecisionReason`` fields, so persisted/logged reasons can't carry +# terminal-escape injection. Kept local to avoid a cross-module dependency for +# one regex. _ANSI_ESCAPE_RE = re.compile(r"\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1B]*(?:\x07|\x1B\\)|[@-Z\\-_])") def _strip_ansi(text: str) -> str: - """Remove ANSI CSI / OSC sequences to prevent terminal injection (§12.7).""" + """Remove ANSI CSI / OSC sequences to prevent terminal injection.""" return _ANSI_ESCAPE_RE.sub("", text) @@ -101,7 +101,7 @@ def _tool_input_preview(tool_input: Any, max_len: int = TOOL_INPUT_PREVIEW_MAX) # --------------------------------------------------------------------------- -# Egress-denial detection (#251, Phase 1) +# Egress-denial detection # --------------------------------------------------------------------------- # # Best-effort, heuristic scan of a tool's output for the signatures a blocked @@ -133,15 +133,15 @@ def _tool_input_preview(tool_input: Any, max_len: int = TOOL_INPUT_PREVIEW_MAX) ) -# #251 carry-path: the most recent agent-detected blocker, as a canonical -# ``BLOCKED[]: …`` reason string (see ``format_blocker_reason``). Blockers -# are detected mid-turn in the hooks (egress in PostToolUse, fail-closed in -# PreToolUse) but the terminal ``error_message`` is assembled later in the -# pipeline. This latch bridges the two: the pipeline promotes it into -# ``TaskResult.error`` ONLY when the task errored without a more specific -# reason, so the CDK classifier surfaces a precise remedy. Process-lifetime -# (== one task) like ``_INJECTED_NUDGES``; last-writer-wins is fine — the most -# recent blocker is the most relevant to a terminal failure. +# The most recent agent-detected blocker, as a canonical ``BLOCKED[]: …`` +# reason string (see ``format_blocker_reason``). Blockers are detected mid-turn +# in the hooks (egress in PostToolUse, fail-closed in PreToolUse) but the +# terminal ``error_message`` is assembled later in the pipeline. This latch +# bridges the two: the pipeline promotes it into ``TaskResult.error`` ONLY when +# the task errored without a more specific reason, so the error classifier +# surfaces a precise remedy. Process-lifetime (== one task) like +# ``_INJECTED_NUDGES``; last-writer-wins is fine — the most recent blocker is +# the most relevant to a terminal failure. _LAST_BLOCKER_REASON: str | None = None @@ -154,7 +154,7 @@ def _record_blocker_reason(kind: str, detail: str, resource: str | None = None) def last_blocker_reason() -> str | None: - """Return the latched canonical blocker reason for terminal promotion (#251).""" + """Return the latched canonical blocker reason for terminal promotion.""" return _LAST_BLOCKER_REASON @@ -173,8 +173,8 @@ def reset_blocker_reason() -> None: _reset_blocker_reason_for_tests = reset_blocker_reason -# ABCA-662: latch of the stuck-guard's "why it's spinning" summary, refreshed by -# the between-turns hook. Read by the pipeline's terminal path so a max_turns +# Latch of the stuck-guard's "why it's spinning" summary, refreshed by the +# between-turns hook. Read by the pipeline's terminal path so a max_turns # failure can say WHY it capped ("Exceeded max turns — spinning on failing tool # calls: git push → invalid credentials") vs. a task that genuinely used its # turns. Process-lifetime (one task), last-writer-wins (the most recent window @@ -195,7 +195,7 @@ def reset_stuck_summary() -> None: def detect_egress_denial(text: str) -> tuple[bool, str | None]: - """Scan tool output for an egress-denial signature (#251). + """Scan tool output for an egress-denial signature. Returns ``(detected, host_or_None)``: ``(True, host)`` on the first matching signature (``host`` populated when a pattern captured one so the emitted @@ -222,7 +222,7 @@ def _deny_response(reason: str) -> dict: Guaranteed surface: ``permissionDecisionReason`` is ANSI-stripped and truncated to 500 chars so it can never carry terminal-escape injection - or overflow a log line (§6.5, §12.7). + or overflow a log line. """ return { "hookSpecificOutput": { @@ -244,6 +244,124 @@ def _allow_response(reason: str = "permitted") -> dict: } +# Workspace-integrity guard. The repo is ALREADY cloned and checked out at the +# agent's cwd (``repo_dir``); the platform tracks that dir for +# commit/branch/PR/push. In practice the agent sometimes ran its OWN +# ``gh repo clone``/``git clone`` of the SAME repo into a sibling dir +# (``/workspace/``) and did all its work there, leaving repo_dir empty +# → the platform saw no commits (delivery gate: ``deliverable=lost``) and, for a +# stacked child, the successor had no branch to stack on. The industry norm is a +# filesystem sandbox that makes a divergent clone impossible; lacking that (raw +# Bash + bypassPermissions + open GitHub egress), a harness-enforced deny of the +# re-clone is the closest robust analog — and Claude Code hook denies fire even +# under bypassPermissions and survive prompt-injection. We match ONLY a re-clone +# of the TASK's OWN repo (not legitimate dependency/submodule clones), keyed off +# the ``owner/repo`` slug in any of the URL forms gh/git accept. +# +# The clone verb must appear as an actual COMMAND — at the start of the string or +# right after a shell separator (``&&``, ``||``, ``|``, ``;``, newline, ``(``) — +# NOT as text buried inside a quoted argument. Without that anchoring, a +# ``gh pr create --body "…do not run gh repo clone…"`` was wrongly blocked because +# the phrase appeared inside the PR body. Anchoring to command position (and, in +# ``_is_self_reclone``, truncating at the first argument that carries free text) +# removes that false positive while still catching every real re-clone. +_CLONE_CMD_RE = re.compile( + r"(?:^|[;&|\n(]|&&|\|\|)\s*(?:gh\s+repo\s+clone|git\s+(?:-C\s+\S+\s+)?clone)\b", + re.IGNORECASE, +) + +# Arguments after which the rest of the command is free-form TEXT (a PR/issue/ +# commit body or title), where a mention of "gh repo clone" is prose, not a +# command. Used to ignore text that merely QUOTES a clone command. +# +# NOTE ``-b`` is deliberately here (``gh pr create -b`` is ``--body``) AND is +# ``git clone``'s own ``--branch`` short form. So this list can only be applied +# to the text FOLLOWING a clone verb, never to the text preceding it: truncating +# the command at the first match before looking for the verb let +# ``git clone -b main `` through entirely, because the truncation cut +# the command off ahead of the verb it was meant to find. +# Shell separators that start a fresh command. Splitting on these means a +# free-text argument can only swallow the rest of ITS OWN segment, so a clone +# chained after an unrelated ``-m`` is still seen. +# +# A bare newline is deliberately NOT a separator here. A multi-line ``--body`` / +# ``-m`` value is the normal way an agent writes a PR or commit body, and its +# quoted text routinely contains a clone command as documentation. Splitting on +# ``\n`` put that quoted line in its own segment with no preceding free-text +# flag, so the guard blocked a legitimate ``gh pr create`` — the exact +# false-positive the free-text list exists to prevent. +# A backslash at end-of-line continues the SAME command onto the next line. +_LINE_CONTINUATION_RE = re.compile(r"\\[ \t]*\r?\n[ \t]*") + +_SHELL_SEPARATOR_RE = re.compile(r"(?:&&|\|\||[;&|])") + +_FREE_TEXT_ARG_RE = re.compile( + r"(?:^|\s)(?:-m|--message|--body|--body-file|-b|--title|-t|-F|--field|--raw-field)\b", + re.IGNORECASE, +) + + +def _repo_slug_variants(repo_url: str) -> list[str]: + """Return the ``owner/repo`` slug forms a clone command could name. + + ``config.repo_url`` is the canonical ``owner/repo``. A clone command may + reference it as ``owner/repo``, ``https://github.com/owner/repo(.git)``, or + ``git@github.com:owner/repo(.git)`` — all normalize to the same slug, so we + just need the bare ``owner/repo`` (with and without ``.git``) to substring- + match against the command text after normalization.""" + slug = (repo_url or "").strip().removesuffix(".git").strip("/").lower() + if not slug: + return [] + return [slug, f"{slug}.git"] + + +def _is_self_reclone(command: str, repo_url: str) -> bool: + """True when *command* clones the task's OWN repo (any URL form). + + Deliberately narrow on two axes so legitimate commands pass: + * the clone verb must be in COMMAND position (start / after a shell + separator), not inside a quoted argument — see ``_CLONE_CMD_RE``; and + * the scan stops at the first free-text argument (``--body``/``-m``/…), so a + PR/commit body that merely QUOTES "gh repo clone" is not treated as a + clone; and + * a clone of a DIFFERENT repo (dependency, fixture, example) is allowed — + only a re-clone of the TASK repo threatens the workspace invariant.""" + if not command or not repo_url: + return False + # Check each shell segment on its own. A free-text argument only swallows the + # REST OF ITS OWN segment, so `git commit -m wip && gh repo clone ` + # must still be caught: the `-m` belongs to the commit, not to the clone. + # + # Within a segment, find the clone verb FIRST and only then ask whether it + # sits inside free text. Truncating at the first free-text flag *before* + # searching is what let `git clone -b main ` through: `-b` is both + # gh's `--body` and git clone's `--branch`, so the cut landed ahead of the + # very verb it was meant to find. + variants = _repo_slug_variants(repo_url) + if not variants: + return False + # Join backslash-continuations first: ``git clone \`` + newline + url is ONE + # command, and leaving the break in place separated the verb from the repo so + # the slug was never found in the verb's segment — a wrapped self re-clone + # walked straight through. + normalized = _LINE_CONTINUATION_RE.sub(" ", command) + for segment in _SHELL_SEPARATOR_RE.split(normalized): + clone = _CLONE_CMD_RE.search(segment) + if clone is None: + continue + free_text = _FREE_TEXT_ARG_RE.search(segment) + if free_text is not None and free_text.start() < clone.start(): + # The verb appears inside a --body/-m/--title value: prose, not a run. + continue + # The repo of a real clone always follows its verb. Normalize the URL + # punctuation to the bare slug space so `.../owner/repo.git` and + # `git@github.com:owner/repo` both reduce to text carrying `owner/repo`. + haystack = segment[clone.start() :].lower().replace("git@github.com:", "github.com/") + if any(v in haystack for v in variants): + return True + return False + + async def pre_tool_use_hook( hook_input: Any, tool_use_id: str | None, @@ -255,27 +373,27 @@ async def pre_tool_use_hook( user_id: str | None = None, progress: Any = None, task_state_module: Any = None, + repo_url: str | None = None, ) -> dict: - """PreToolUse hook: three-outcome Cedar policy enforcement (§6.5). + """PreToolUse hook: three-outcome Cedar policy enforcement. Returns a dict with hookSpecificOutput containing: - permissionDecision: "allow" or "deny" - permissionDecisionReason: explanation string - The REQUIRE_APPROVAL path (Chunk 3, §6.5) pauses here: writes a - pending approval row + transitions the task to AWAITING_APPROVAL - atomically, polls for a human decision with 2s→5s backoff, then - returns allow / deny based on the decision. On TIMED_OUT a - ConditionCheckFailed from the best-effort status write triggers the - IMPL-24 re-read — if the user's decision landed between our poll - and write, we honor it instead of falsely denying. - - ``task_id`` / ``user_id`` / ``progress`` are optional to preserve - the Phase 1 test call shape. Without them the REQUIRE_APPROVAL path - falls through to fail-closed DENY (state-write infrastructure is - missing), so legacy callers still see coherent behaviour. - ``task_state_module`` is a test seam for injecting a mocked - ``task_state`` namespace; production callers rely on the default. + The REQUIRE_APPROVAL path pauses here: writes a pending approval row + + transitions the task to AWAITING_APPROVAL atomically, polls for a + human decision with 2s→5s backoff, then returns allow / deny based on + the decision. On TIMED_OUT a ConditionCheckFailed from the best-effort + status write triggers a re-read — if the user's decision landed between + our poll and write, we honor it instead of falsely denying. + + ``task_id`` / ``user_id`` / ``progress`` are optional to preserve the + older test call shape. Without them the REQUIRE_APPROVAL path falls + through to fail-closed DENY (state-write infrastructure is missing), so + legacy callers still see coherent behaviour. ``task_state_module`` is a + test seam for injecting a mocked ``task_state`` namespace; production + callers rely on the default. """ ts_module = task_state_module if task_state_module is not None else task_state @@ -303,13 +421,34 @@ async def pre_tool_use_hook( log("WARN", f"PreToolUse hook received non-dict tool_input — denying {tool_name}") return _deny_response("tool input is not an object") + # Block a Bash re-clone of the task's OWN repo before Cedar eval. The repo is + # already checked out at the agent's cwd; a self-reclone into a sibling dir + # strands the work off the platform-tracked branch (the delivery gate then + # fails it as ``deliverable=lost``). Deny it here with a redirect so the agent + # works in place instead. Scoped to the task repo only (dependency/fixture + # clones pass). Fires even under bypassPermissions. + if tool_name == "Bash" and repo_url: + command = tool_input.get("command", "") + if isinstance(command, str) and _is_self_reclone(command, repo_url): + reason = ( + f"The repository '{repo_url}' is ALREADY cloned and checked out in " + "your working directory — do NOT clone it again or work in another " + "directory. Your commits must land in the current working directory " + "so the platform can open the pull request on the correct branch. " + "Remove the clone command and work in place (edit, commit, and push " + "from where you already are)." + ) + log("POLICY", f"DENIED self-reclone of {repo_url}: {command[:160]}") + if trajectory: + trajectory.write_policy_decision("Bash", False, "self_reclone_blocked", 0) + return _deny_response(reason) + decision = engine.evaluate_tool_use(tool_name, tool_input) # Telemetry: ALLOW "permitted" is the quiet happy path; everything else # is worth a trajectory event. Treat REQUIRE_APPROVAL as "not allowed" - # for the legacy ``allowed=False`` field so the Phase 2 trajectory - # schema stays coherent — the specific outcome is already on the - # ``reason`` string. + # for the legacy ``allowed=False`` field so the trajectory schema stays + # coherent — the specific outcome is already on the ``reason`` string. if trajectory and decision.reason != "permitted": trajectory.write_policy_decision( tool_name, decision.allowed, decision.reason, decision.duration_ms @@ -319,27 +458,26 @@ async def pre_tool_use_hook( return _allow_response(decision.reason or "permitted") if decision.outcome == Outcome.DENY: - # IMPL-23: when the DENY arrived from the recent-decision cache - # (evaluate_tool_use Step 2.5), emit a ``policy_decision`` - # milestone with ``decision_source="recent_decision_cache"`` to - # TaskEventsTable so cache-driven denies are visible in the live - # stream + 90d audit record (§12.8). No new approval row is - # written and the gate counter is NOT bumped — the original - # gate already accounted for the decision. + # When the DENY arrived from the recent-decision cache, emit a + # ``policy_decision`` milestone with + # ``decision_source="recent_decision_cache"`` to TaskEventsTable so + # cache-driven denies are visible in the live stream + retained audit + # record. No new approval row is written and the gate counter is NOT + # bumped — the original gate already accounted for the decision. if progress is not None and decision.cache_hit_metadata is not None: _try_progress( progress, "write_policy_decision_cached", **decision.cache_hit_metadata, ) - # #251 (decision E): a fail-closed deny means the Cedar engine errored - # or was unavailable — an ENVIRONMENTAL fault (misconfiguration), NOT - # an intentional hard-deny. Emit a ``policy_fail_closed`` blocker event - # so it renders distinctly and carries a remediation hint. We branch on - # the structured ``decision.fail_closed`` flag, never a reason-string - # prefix. Intentional hard-denies / cache-denies (fail_closed=False) - # never emit. Emitted strictly BEFORE the deny; the deny itself is - # unchanged, so this adds observability without altering the outcome. + # A fail-closed deny means the Cedar engine errored or was unavailable — + # an ENVIRONMENTAL fault (misconfiguration), NOT an intentional hard-deny. + # Emit a ``policy_fail_closed`` blocker event so it renders distinctly and + # carries a remediation hint. We branch on the structured + # ``decision.fail_closed`` flag, never a reason-string prefix. Intentional + # hard-denies / cache-denies (fail_closed=False) never emit. Emitted + # strictly BEFORE the deny; the deny itself is unchanged, so this adds + # observability without altering the outcome. if getattr(decision, "fail_closed", False): _record_blocker_reason("policy_fail_closed", decision.reason) if progress is not None: @@ -359,7 +497,7 @@ async def pre_tool_use_hook( log("POLICY", f"DENIED: {tool_name} — {decision.reason}") return _deny_response(decision.reason) - # -- REQUIRE_APPROVAL path (§6.5) --------------------------------------- + # -- REQUIRE_APPROVAL path ---------------------------------------------- return await _handle_require_approval( decision=decision, tool_name=tool_name, @@ -385,19 +523,18 @@ async def _handle_require_approval( ) -> dict: """REQUIRE_APPROVAL branch of ``pre_tool_use_hook``. - Split out of the main hook for readability — the control-flow in - §6.5 is long enough that inlining it obscures the top-level three-way - branch. + Split out of the main hook for readability — the control flow is long + enough that inlining it obscures the top-level three-way branch. """ - # Missing task infrastructure → fail closed with a clear reason. This - # lines up with §13.15: every exceptional branch ends in DENY. + # Missing task infrastructure → fail closed with a clear reason. Every + # exceptional branch here ends in DENY. if not task_id: log("WARN", "REQUIRE_APPROVAL hit without task_id — fail-closed deny") return _deny_response("approval system unavailable (no task_id)") request_id = _generate_ulid() - # Step 1 — per-task cap. §12.9: cap exceeded fails closed and the + # Step 1 — per-task cap. Cap exceeded fails closed, and the # cap_exceeded milestone carries the configured cap so dashboards # reflect the blueprint override. if engine.approval_gate_count >= engine.approval_gate_cap: @@ -425,9 +562,9 @@ async def _handle_require_approval( ) return _deny_response(f"approval-gate rate limit exceeded ({APPROVAL_RATE_LIMIT}/min)") - # Step 3 — effective timeout with floor/ceiling math (§6.5). Emit + # Step 3 — effective timeout with floor/ceiling math. Emit # ``approval_timeout_capped`` when the caller's ask is clipped so the - # user can see why (IMPL-26). + # user can see why. remaining_lifetime = _remaining_maxlifetime_s() effective_timeout, clip_reason, requested_timeout = _compute_effective_timeout( decision_timeout_s=decision.timeout_s, @@ -446,9 +583,9 @@ async def _handle_require_approval( list(decision.matching_rule_ids) if clip_reason == "rule_annotation" else None ), ) - # IMPL-26: once per task, surface the "gates will have small windows - # from here on" ceiling-shrinking milestone when the remaining - # lifetime is approaching the 2x-task-default threshold. + # Once per task, surface the "gates will have small windows from here + # on" ceiling-shrinking milestone when the remaining lifetime is + # approaching the 2x-task-default threshold. if ( remaining_lifetime is not None and remaining_lifetime - CLEANUP_MARGIN_120S < 2 * engine.task_default_timeout_s @@ -464,15 +601,15 @@ async def _handle_require_approval( task_default_timeout_s=engine.task_default_timeout_s, ) - # Step 4 — insufficient lifetime remaining for a valid approval - # (§13.7). Below the floor we DENY immediately without writing the - # approval row; no point waking the user to a guaranteed-dead gate. + # Step 4 — insufficient lifetime remaining for a valid approval. + # Below the floor we DENY immediately without writing the approval + # row; no point waking the user to a guaranteed-dead gate. if remaining_lifetime is not None and remaining_lifetime - CLEANUP_MARGIN_120S < FLOOR_30S: return _deny_response( f"insufficient maxLifetime remaining ({remaining_lifetime}s) for approval" ) - # Step 5 — build the approval row per §10.1 schema. + # Step 5 — build the approval row. tool_input_sha256 = _sha256_tool_input_for_row(tool_input) row = { "task_id": task_id, @@ -499,14 +636,13 @@ async def _handle_require_approval( engine.increment_approval_gate_count() engine.record_approval_gate_timestamp() - # Chunk 7 (§13.6): best-effort atomic increment of the persisted - # ``approval_gate_count`` on TaskTable. The session counter - # enforces the cap within THIS container; the persisted counter - # exists so a restarted container re-seeds from a non-zero value - # instead of re-exposing the user to another ``approval_gate_cap`` - # worth of gates. Failure is best-effort per §13.6 — "counter is - # a safety bound, not a correctness bound" — so we keep going on - # error and accept the (bounded) restart-retry amplification. + # Best-effort atomic increment of the persisted ``approval_gate_count`` + # on TaskTable. The session counter enforces the cap within THIS + # container; the persisted counter exists so a restarted container + # re-seeds from a non-zero value instead of re-exposing the user to + # another ``approval_gate_cap`` worth of gates. Failure is best-effort — + # the counter is a safety bound, not a correctness bound — so we keep + # going on error and accept the (bounded) restart-retry amplification. if task_id: await asyncio.to_thread(ts.increment_approval_gate_count_in_ddb, task_id) @@ -569,9 +705,8 @@ async def _handle_require_approval( ts=ts, ) - # Step 10 — IMPL-24 VM-throttle + late-approval race. Best-effort - # flip to TIMED_OUT; if ConditionCheckFailed, the user beat us — read - # and honor. + # Step 10 — VM-throttle + late-approval race. Best-effort flip to + # TIMED_OUT; if ConditionCheckFailed, the user beat us — read and honor. if outcome["status"] == "TIMED_OUT": try: wrote = await asyncio.to_thread( @@ -583,14 +718,14 @@ async def _handle_require_approval( ) except Exception as exc: log("WARN", f"approval TIMED_OUT write raised: {type(exc).__name__}: {exc}") - # Fall into the IMPL-24 re-read path. A transient DDB write - # error MUST NOT bypass the late-approval check — the user's - # APPROVED decision may already be on the row, and skipping - # the re-read would falsely deny their tool call. Setting - # ``wrote = False`` triggers the ConsistentRead below; if - # the row still says PENDING the re-read is a no-op and we - # keep the TIMED_OUT outcome. If it says APPROVED/DENIED, - # ``_reconcile_late_decision`` honors the user's choice. + # Fall into the re-read path. A transient DDB write error MUST + # NOT bypass the late-approval check — the user's APPROVED + # decision may already be on the row, and skipping the re-read + # would falsely deny their tool call. Setting ``wrote = False`` + # triggers the ConsistentRead below; if the row still says + # PENDING the re-read is a no-op and we keep the TIMED_OUT + # outcome. If it says APPROVED/DENIED, ``_reconcile_late_decision`` + # honors the user's choice. wrote = False if not wrote: # User's decision beat our timer; re-read with ConsistentRead. @@ -649,7 +784,7 @@ async def _handle_require_approval( request_id=request_id, scope=scope, decided_at=outcome.get("decided_at"), - # Chunk 8a: propagate the row's ``created_at`` so the + # Propagate the row's ``created_at`` so the # ApprovalMetricsPublisher can compute decision latency. created_at=row.get("created_at"), ) @@ -657,12 +792,12 @@ async def _handle_require_approval( # DENIED or TIMED_OUT — cache + queue injection. cache_decision = "DENIED" if status == "DENIED" else "TIMED_OUT" - # IMPL-23: thread the user's ``decided_at`` into the cache entry so - # subsequent cache-hit events surface the ORIGINAL decision timestamp, - # not the wall-clock time the cache was populated (which is ~the same - # but technically wrong). ``decided_at`` for TIMED_OUT is the - # agent-side clock moment the timeout fired; for DENIED it's the - # user's deny timestamp from the Lambda audit row. + # Thread the user's ``decided_at`` into the cache entry so subsequent + # cache-hit events surface the ORIGINAL decision timestamp, not the + # wall-clock time the cache was populated (which is ~the same but + # technically wrong). ``decided_at`` for TIMED_OUT is the agent-side + # clock moment the timeout fired; for DENIED it's the user's deny + # timestamp from the Lambda audit row. engine.recent_decisions.record( tool_name, tool_input_sha256, @@ -671,12 +806,11 @@ async def _handle_require_approval( original_decision_ts=outcome.get("decided_at"), ) - # Rule-level cache (§12.8 extension): on DENIED, record an entry - # per matching_rule_id so semantic retries — same rule, different - # input — get fast-denied without a new approval round-trip. Only - # populate on DENIED because TIMED_OUT is ambiguous (user was - # away, not actively refusing); TIMED_OUT cache entries stay - # input-hash-scoped. + # Rule-level cache: on DENIED, record an entry per matching_rule_id + # so semantic retries — same rule, different input — get fast-denied + # without a new approval round-trip. Only populate on DENIED because + # TIMED_OUT is ambiguous (user was away, not actively refusing); + # TIMED_OUT cache entries stay input-hash-scoped. if status == "DENIED": for rule_id in decision.matching_rule_ids: engine.recent_decisions.record_rule_decision( @@ -700,7 +834,7 @@ async def _handle_require_approval( request_id=request_id, reason=outcome.get("reason", ""), decided_at=outcome.get("decided_at"), - # Chunk 8a: propagate the row's ``created_at`` so the + # Propagate the row's ``created_at`` so the # ApprovalMetricsPublisher can compute decision latency. created_at=row.get("created_at"), ) @@ -710,24 +844,24 @@ async def _handle_require_approval( "write_approval_timed_out", request_id=request_id, timeout_s=effective_timeout, - # Chunk 8a: propagate the row's ``created_at`` + - # ``matching_rule_ids`` + the post-clip effective timeout - # so the ApprovalMetricsPublisher can emit the decision - # latency + the ``ApprovalTimeoutBreakdown`` histogram - # with a normalized ``rule_id`` dimension. + # Propagate the row's ``created_at`` + ``matching_rule_ids`` + + # the post-clip effective timeout so the + # ApprovalMetricsPublisher can emit the decision latency + the + # ``ApprovalTimeoutBreakdown`` histogram with a normalized + # ``rule_id`` dimension. created_at=row.get("created_at"), effective_timeout_s=effective_timeout, matching_rule_ids=list(decision.matching_rule_ids), ) - # Guaranteed surface (§6.5): truncated reason even when denial - # injection is pre-empted by a concurrent cancel. Wrap the user's - # reason in authoritative stop-language — E2E Phase 4 observed - # the agent treating bare "User denied" as "try a different - # approach" and burning through max_turns retrying the same rule - # with trivial variations. The explicit AUTHORITATIVE-prefixed - # wording, combined with the rule-level recent-deny cache (§12.8), - # makes retries fail fast with clear feedback. + # Guaranteed surface: truncated reason even when denial injection is + # pre-empted by a concurrent cancel. Wrap the user's reason in + # authoritative stop-language — end-to-end testing showed the agent + # treating a bare "User denied" as "try a different approach" and + # burning through max_turns retrying the same rule with trivial + # variations. The explicit AUTHORITATIVE-prefixed wording, combined + # with the rule-level recent-deny cache, makes retries fail fast with + # clear feedback. raw_reason = outcome.get("reason") or f"User {status.lower() if status else 'denied'}" if status == "DENIED": rule_hint = ( @@ -754,12 +888,12 @@ def _reconcile_late_decision( progress: Any, request_id: str, ) -> dict: - """IMPL-24: rebuild outcome from a re-read row after a TIMED_OUT race. + """Rebuild outcome from a re-read row after a TIMED_OUT race. - ``row["status"] == "APPROVED"`` → rebuild as APPROVED (allow flow). - ``row["status"] == "DENIED"`` → rebuild as DENIED (deny flow). - Anything else (row gone, still PENDING) → fall through with the - original TIMED_OUT (§13.12 fail-closed branch). + original TIMED_OUT (the fail-closed branch). Emits ``approval_late_win`` for APPROVED or DENIED races so operator telemetry can count them. @@ -810,10 +944,10 @@ async def _poll_for_decision( """Poll the approval row until terminal or timeout. Cadence: ``POLL_FAST_INTERVAL_S`` for ``POLL_FAST_DURATION_S``, then - ``POLL_SLOW_INTERVAL_S`` (IMPL-12). Each iteration uses - ConsistentRead; after ``POLL_DEGRADED_FAILS`` consecutive failures we - emit ``approval_poll_degraded``; at ``POLL_MAX_CONSECUTIVE_FAILS`` we - fall through as TIMED_OUT with a distinct reason (§13.2). + ``POLL_SLOW_INTERVAL_S``. Each iteration uses ConsistentRead; after + ``POLL_DEGRADED_FAILS`` consecutive failures we emit + ``approval_poll_degraded``; at ``POLL_MAX_CONSECUTIVE_FAILS`` we + fall through as TIMED_OUT with a distinct reason. Returns an outcome dict mirroring the approval row's terminal fields. """ @@ -890,7 +1024,7 @@ def _compute_effective_timeout( task_default_timeout_s: int, remaining_lifetime_s: int | None, ) -> tuple[int, str | None, int]: - """Compute the effective timeout per §6.5. + """Compute the effective approval timeout. ``min(rule-annotation timeout, task default, remaining lifetime - cleanup margin)``, floored at FLOOR_30S. The engine's @@ -947,8 +1081,8 @@ def _remaining_maxlifetime_s() -> int | None: (ISO 8601, optional). Returns ``None`` if the start timestamp is unavailable; the hook treats this as "unknown, don't clip" so the gate still fires with the task default (fail-open on the optional - signal rather than pre-DENY when unknown). A future Chunk wires - these from the task launch path; for now they are optional hints. + signal rather than pre-DENY when unknown). These are wired from the + task launch path when available; otherwise they are optional hints. """ try: max_lifetime = int(os.environ.get("AGENTCORE_MAX_LIFETIME_S", "28800")) @@ -983,7 +1117,8 @@ def _sha256_tool_input_for_row(tool_input: Any) -> str: Re-derives hashing here (rather than importing ``policy._sha256_tool_input``) so the hook's failure-mode is independent of the engine's internals and to keep import graphs shallow. The engine's own cache uses the same - algorithm; §6.5 row + ``RecentDecisionCache`` need the same key shape. + algorithm; the approval row and ``RecentDecisionCache`` need the same + key shape. """ import hashlib @@ -1001,7 +1136,7 @@ def _iso_now() -> str: return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) -# S10: per-method consecutive-failure counters for ``_try_progress``. +# Per-method consecutive-failure counters for ``_try_progress``. # Progress writes are best-effort, but a hard infrastructure failure # (DDB throttle, IAM regression, missing table) would otherwise be # masked as a stream of WARN lines with no escalation. After @@ -1020,12 +1155,12 @@ def _try_progress(progress: Any, method_name: str, /, **kwargs: Any) -> None: Progress is best-effort observability; a throttled DDB write must not break the approval flow. ``_emit_nudge_milestone`` uses a similar - pattern for the Phase 2 nudges. + pattern for nudges. - S10 escalation: repeated consecutive failures of the same - ``method_name`` are tracked per-process and escalated to ERROR - after ``_TRY_PROGRESS_ESCALATE_AFTER`` so a silent live-stream - outage becomes operator-visible. + Escalation: repeated consecutive failures of the same ``method_name`` + are tracked per-process and escalated to ERROR after + ``_TRY_PROGRESS_ESCALATE_AFTER`` so a silent live-stream outage + becomes operator-visible. """ if getattr(progress, "_disabled", False) is True: log("WARN", f"progress {method_name!r} skipped: circuit breaker open") @@ -1060,8 +1195,8 @@ async def post_tool_use_hook( hook_context: Any, *, trajectory: _TrajectoryWriter | None = None, - progress: Any = None, stuck_guard: StuckGuard | None = None, + progress: Any = None, ) -> dict: """PostToolUse hook: screen tool output for secrets/PII. @@ -1070,14 +1205,14 @@ async def post_tool_use_hook( redacted version (steered enforcement — content is sanitized, not blocked). - ``progress`` is optional (preserves the Phase 1 test call shape). When + ``progress`` is optional (preserves the older test call shape). When present, an egress-denial signature in the tool output emits an - ``egress_denied`` blocker event (#251) — best-effort observability, - never alters the screening decision. + ``egress_denied`` blocker event — best-effort observability, never + alters the screening decision. - K7: when a ``stuck_guard`` is supplied, every tool result is recorded so a - between-turns hook can detect a repeating failing command (the ABCA-483 - spin loop) and steer / bail. Recording is best-effort and never alters the + When a ``stuck_guard`` is supplied, every tool result is recorded so a + between-turns hook can detect a repeating failing command (a spin + loop) and steer / bail. Recording is best-effort and never alters the screening outcome. """ _PASS_THROUGH: dict = {"hookSpecificOutput": {"hookEventName": "PostToolUse"}} @@ -1104,7 +1239,7 @@ async def post_tool_use_hook( if not isinstance(tool_response, str): tool_response = str(tool_response) - # #251: best-effort egress-denial detection. A blocked outbound connection + # Best-effort egress-denial detection. A blocked outbound connection # (non-allowlisted host hitting the DNS Firewall, refused connection, name # resolution failure) surfaces in the tool's stderr here. Emit an # ``egress_denied`` blocker naming the host to allowlist. Observability @@ -1141,7 +1276,7 @@ async def post_tool_use_hook( resource=host, ) - # K7: feed the stuck-guard (best-effort — a tracking error must never block + # Feed the stuck-guard (best-effort — a tracking error must never block # the screening path that follows). if stuck_guard is not None: try: @@ -1176,7 +1311,7 @@ async def post_tool_use_hook( # --------------------------------------------------------------------------- -# Between-turns hook registry (Phase 2 nudges, extensible for Phase 3) +# Between-turns hook registry (nudges, extensible for more producers) # --------------------------------------------------------------------------- # A hook takes a context dict (currently ``{"task_id": str}``) and returns a @@ -1255,10 +1390,10 @@ def _nudge_between_turns_hook(ctx: dict) -> list[str]: ``mark_consumed`` repeatedly fails. Emits a ``nudge_acknowledged`` ``agent_milestone`` event **before** - returning the injected user-message list (combined-turn ack, see - ``INTERACTIVE_AGENTS.md`` §AD-5) so the durable event stream records - the ack in the same turn the nudge is consumed. Emission is - best-effort: if the progress writer's circuit breaker has tripped + returning the injected user-message list (combined-turn ack) so the + durable event stream records the ack in the same turn the nudge is + consumed. Emission is best-effort: if the progress writer's circuit + breaker has tripped (repeated DDB write failures) or no ``progress`` ref is stamped on ``ctx``, the ack is logged but skipped and the injection still proceeds — better to steer the agent than block on a flaky event @@ -1273,8 +1408,8 @@ def _nudge_between_turns_hook(ctx: dict) -> list[str]: # break in :func:`stop_hook` which short-circuits the dispatcher as soon as # any earlier hook sets ``_cancel_requested``. That assumes # ``_cancel_between_turns_hook`` runs BEFORE this hook — true for the - # module-level ``between_turns_hooks`` registry today (line 340), but a - # future reorder (or a test that rebinds the list without preserving + # module-level ``between_turns_hooks`` registry today, but a future + # reorder (or a test that rebinds the list without preserving # order) would silently reintroduce the bug: ``read_pending`` + # ``mark_consumed`` would flip the DDB rows to consumed and stamp # ``_INJECTED_NUDGES`` for a dying agent that will never see the text. @@ -1331,7 +1466,7 @@ def _nudge_between_turns_hook(ctx: dict) -> list[str]: details = f"{count} nudge(s) acknowledged (ids=…{ids}): {first_msg}" + ( "…" if count > 1 or len(first_msg) == _NUDGE_PREVIEW_LEN else "" ) - # AD-5: emit the ack BEFORE returning the injection list. + # Emit the ack BEFORE returning the injection list. _emit_nudge_milestone(ctx, "nudge_acknowledged", details) return [formatted] if formatted else [] @@ -1341,16 +1476,15 @@ def _denial_between_turns_hook(ctx: dict) -> list[str]: """Drain queued denial injections into ```` XML blocks. Registered AFTER ``_cancel_between_turns_hook`` and - ``_nudge_between_turns_hook`` (cancel-wins semantics, finding #2). - If cancel flagged this turn we early-return — denial injection is - explicitly best-effort on cancelled tasks, and the guaranteed - surface is the ``permissionDecisionReason`` that - ``pre_tool_use_hook`` already returned on the deny response - (§6.5 line 922). + ``_nudge_between_turns_hook`` (cancel-wins semantics). If cancel + flagged this turn we early-return — denial injection is explicitly + best-effort on cancelled tasks, and the guaranteed surface is the + ``permissionDecisionReason`` that ``pre_tool_use_hook`` already + returned on the deny response. ``ctx["engine"]`` is expected when the pipeline wires the approval - engine through; absent it this hook is a no-op (Phase 1 call sites - that don't thread an engine ref through still work). + engine through; absent it this hook is a no-op (call sites that don't + thread an engine ref through still work). """ if ctx.get("_cancel_requested"): return [] @@ -1385,8 +1519,8 @@ def _denial_between_turns_hook(ctx: dict) -> list[str]: log("POLICY", f"Injecting {count} denial(s) for task {ctx.get('task_id', '')}") # Emit a single ``user_denial_injected`` milestone per Stop seam so # the durable event stream records the ack. Mirrors - # ``nudge_acknowledged`` (§AD-5); this is an additive milestone not - # in the §11.1 enumerated list, so keep the name distinct from the + # ``nudge_acknowledged``; this is an additive milestone not in the + # enumerated milestone list, so keep the name distinct from the # enumerated ``approval_*`` prefix. _emit_nudge_milestone( ctx, @@ -1432,7 +1566,7 @@ def _cancel_between_turns_hook(ctx: dict) -> list[str]: def _stuck_guard_between_turns_hook(ctx: dict) -> list[str]: - """K7: nudge the agent when it repeats the SAME failing command (ABCA-483). + """Nudge the agent when it repeats the SAME failing command. Reads the per-task :class:`StuckGuard` stamped on ``ctx`` (by :func:`stop_hook`). When the same command has failed with identical output @@ -1457,10 +1591,10 @@ def _stuck_guard_between_turns_hook(ctx: dict) -> list[str]: return [] try: action = guard.evaluate() - # ABCA-662: refresh the "why it's spinning" latch every turn. When the - # trailing window is failure-dominated this returns a one-liner; otherwise - # None (which clears the latch — a task that recovered isn't "stuck"). Read - # by the terminal path so a later max_turns cap explains itself. + # Refresh the "why it's spinning" latch every turn. When the trailing + # window is failure-dominated this returns a one-liner; otherwise None + # (which clears the latch — a task that recovered isn't "stuck"). Read by + # the terminal path so a later max_turns cap explains itself. global _LAST_STUCK_SUMMARY _LAST_STUCK_SUMMARY = guard.recent_failure_summary() except Exception as exc: @@ -1481,19 +1615,19 @@ def _stuck_guard_between_turns_hook(ctx: dict) -> list[str]: # dispatcher breaks out of the loop as soon as ``_cancel_requested`` is set, # and :func:`_nudge_between_turns_hook` early-returns when the flag is already # present — belt-and-braces in case a future ``append`` reorders this list. -# Phase 3 (approval gates) should ``append`` additional hooks AFTER the +# Additional producers (e.g. approval gates) should ``append`` hooks AFTER the # nudge reader to preserve cancel-wins semantics. between_turns_hooks: list[BetweenTurnsHook] = [ _cancel_between_turns_hook, - # K7 stuck-guard (advisory): injects a one-time "stop retrying X" nudge when + # Stuck-guard (advisory): injects a one-time "stop retrying X" nudge when # the same command keeps failing identically. Runs after cancel (never steer # a dying agent); order vs nudge/denial is cosmetic since it never bails. _stuck_guard_between_turns_hook, _nudge_between_turns_hook, - # Chunk 3 (finding #2): denial injection runs LAST so both cancel and - # nudge short-circuits pre-empt it. The hook explicitly re-checks - # ``_cancel_requested`` so re-ordering doesn't silently break cancel - # semantics (belt-and-braces, matching ``_nudge_between_turns_hook``). + # Denial injection runs LAST so both cancel and nudge short-circuits + # pre-empt it. The hook explicitly re-checks ``_cancel_requested`` so + # re-ordering doesn't silently break cancel semantics (belt-and-braces, + # matching ``_nudge_between_turns_hook``). _denial_between_turns_hook, ] @@ -1523,7 +1657,8 @@ async def stop_hook( so hooks can emit their own milestone / progress events without holding a module-global reference to it. ``engine`` is threaded through for ``_denial_between_turns_hook`` which needs to drain queued denials; - absent it the denial hook is a no-op (Phase 1 / Phase 2 call paths). + absent it the denial hook is a no-op (call paths that don't wire an + engine through). """ ctx = { "task_id": task_id, @@ -1541,7 +1676,7 @@ async def stop_hook( # returned ``continue_=False`` below. Breaking out of the loop as soon # as any hook sets ``_cancel_requested`` guarantees subsequent hooks # (notably the nudge reader) never run, so DDB state is never mutated - # for work the agent will never do. The registry at line 340 keeps + # for work the agent will never do. The registry keeps # ``_cancel_between_turns_hook`` first so this break fires before the # nudge hook gets a chance. ``_nudge_between_turns_hook`` also carries # an internal cancel-check as belt-and-braces in case a future refactor @@ -1588,6 +1723,7 @@ def build_hook_matchers( task_id: str = "", progress: Any = None, user_id: str = "", + repo_url: str = "", ) -> dict: """Build hook matchers dict for ClaudeAgentOptions. @@ -1600,7 +1736,7 @@ def build_hook_matchers( ``progress`` is forwarded to both the PreToolUse hook (approval gate milestones) and the Stop hook (nudge/denial acks). ``user_id`` is written onto the approval row so ownership checks on the REST side - can enforce §12.2 (user can only approve their own gates). + can enforce that a user can only approve their own gates. """ from claude_agent_sdk.types import ( HookContext, @@ -1611,7 +1747,7 @@ def build_hook_matchers( SyncHookJSONOutput, ) - # K7: one stuck-guard per task (== per build_hook_matchers call). The + # One stuck-guard per task (== per build_hook_matchers call). The # PostToolUse closure feeds it every tool result; the Stop closure reads it # between turns to steer / bail on a repeating failing command. _stuck_guard = StuckGuard() @@ -1638,6 +1774,7 @@ async def _pre( task_id=task_id or None, user_id=user_id or None, progress=progress, + repo_url=repo_url or None, ) except Exception as exc: log( diff --git a/agent/src/linear_reactions.py b/agent/src/linear_reactions.py index 95a3074b2..1a8a4b6a8 100644 --- a/agent/src/linear_reactions.py +++ b/agent/src/linear_reactions.py @@ -14,13 +14,14 @@ errors are logged and swallowed — a transient Linear API failure must never fail the task itself (reactions are advisory UX, not load-bearing). -Why a direct GraphQL call instead of MCP: Linear's MCP v1 does not expose -a reactions tool (confirmed 2026-05-06). Once an MCP ``create_reaction`` -tool ships, this module should be retired in favour of a prompt addendum -that has the agent call it directly. - -See: ``agent/src/channel_mcp.py`` for the parallel MCP gate, and -``~/.claude/plans/linear-mcp-findings.md`` for the locked spec. +Why a direct GraphQL call: under ADR-016 Linear is 100% deterministic — +the agent has NO Linear tools and never calls Linear itself. Reactions +and state transitions are posted by the platform on its own credential +(here, on the agent tier; the Lambda tier owns the rest). This is the +permanent design, not a stopgap: there is no future in which the agent +drives Linear through an MCP tool, so this module is not "awaiting" one. + +See: ``agent/src/channel_mcp.py`` for why there is no Linear MCP. """ from __future__ import annotations @@ -35,7 +36,9 @@ from shell import log -#: Linear GraphQL endpoint. The same auth flow the MCP server uses. +#: Linear GraphQL endpoint. Authenticated with the per-workspace +#: ``actor=app`` OAuth token (``LINEAR_API_TOKEN``, set by config.py from the +#: workspace's OAuth bundle) sent as the ``Authorization`` header. LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql" #: Request timeout — reactions are fire-and-forget status UX; never block @@ -83,6 +86,41 @@ query Viewer { viewer { id } } """.strip() +#: Workflow-state mirroring: fetch the issue's current state + its team's full +#: workflow-state list, +#: so we can pick the right target state (by type, with a name preference) and +#: never move the issue BACKWARD along the lifecycle. +_ISSUE_STATES_QUERY = """ +query IssueStates($id: String!) { + issue(id: $id) { + state { id name type position } + team { states(first: 50) { nodes { id name type position } } } + } +} +""".strip() + +#: Set an issue's workflow state by id. +_SET_STATE_MUTATION = """ +mutation SetIssueState($id: String!, $stateId: String!) { + issueUpdate(id: $id, input: { stateId: $stateId }) { success } +} +""".strip() + +#: Lifecycle rank by Linear state TYPE (backlog → unstarted → started → +#: completed/canceled). A move to a strictly higher rank — or a higher +#: position within the same type — is FORWARD; anything else is a no-op so a +#: human who already advanced/closed the issue is never demoted. Mirrors the +#: platform's ``transitionIssueState`` guard so the single-task and +#: orchestration paths agree. +_STATE_TYPE_RANK = { + "backlog": 0, + "triage": 0, + "unstarted": 1, + "started": 2, + "completed": 3, + "canceled": 3, +} + #: Reactions we own and want to clear before a fresh run. _BGAGENT_EMOJIS = frozenset({EMOJI_STARTED, EMOJI_SUCCESS, EMOJI_FAILURE}) @@ -216,6 +254,62 @@ def _get_viewer_id() -> str | None: return None +def _transition_issue_state(issue_id: str, target_type: str, preferred_names: list[str]) -> None: + """Move the issue to a workflow state of ``target_type``, forward-only. + + A plain single task (a direct ``abca`` label) previously left the issue in + Backlog for its whole run — only orchestration parents got a state change (via the + epic panel). This is the missing single-task equivalent: on start move to + 'started' (preferring "In Progress"), on clean finish to 'started' (preferring + "In Review") — the same targets the orchestration panel uses. + + Forward-only via ``_STATE_TYPE_RANK`` (+ position within a type): never demote + an issue a human already advanced or closed. Best-effort — every failure is a + logged no-op (state mirroring is advisory UX, like the reaction). + """ + data = _graphql(_ISSUE_STATES_QUERY, {"id": issue_id}) + if not data: + return + issue = data.get("issue") or {} + states = ((issue.get("team") or {}).get("states") or {}).get("nodes") or [] + if not states: + log("WARN", f"linear_reactions: no team states for issue {issue_id}; skipping transition") + return + + of_type = [s for s in states if s.get("type") == target_type] + if not of_type: + log("DEBUG", f"linear_reactions: no '{target_type}' state on the team; skipping transition") + return + target = None + lowered = [n.lower() for n in preferred_names] + for name in lowered: + target = next((s for s in of_type if (s.get("name") or "").lower() == name), None) + if target: + break + if target is None: + target = sorted(of_type, key=lambda s: s.get("position", 0))[0] + + current = issue.get("state") or {} + if current.get("id") == target.get("id"): + return # already there — idempotent no-op + cur_rank = _STATE_TYPE_RANK.get(current.get("type", ""), 0) + tgt_rank = _STATE_TYPE_RANK.get(target.get("type", ""), 0) + backward = cur_rank > tgt_rank or ( + cur_rank == tgt_rank and current.get("position", 0) >= target.get("position", 0) + ) + if backward: + log( + "TASK", + f"linear_reactions: skipping backward state move " + f"{current.get('name')!r} → {target.get('name')!r}", + ) + return + + result = _graphql(_SET_STATE_MUTATION, {"id": issue_id, "stateId": target["id"]}) + if result is not None: + log("TASK", f"linear_reactions: issue {issue_id} → {target.get('name')!r}") + + def _sweep_stale_reactions_safe(issue_id: str, exclude_id: str | None = None) -> None: """Top-level wrapper for the sweep daemon thread. @@ -300,6 +394,7 @@ def _sweep_stale_reactions(issue_id: str, exclude_id: str | None = None) -> None def react_task_started( channel_source: str, channel_metadata: dict[str, str] | None, + transition_state: bool = False, ) -> str | None: """Post 👀 on the Linear issue. Return the reaction id (or None on failure/no-op). @@ -352,6 +447,13 @@ def react_task_started( name="linear-reactions-sweep", ).start() + # Workflow-state transition: a writeable single task moves the issue + # Backlog → In Progress, so it + # doesn't sit in Backlog for the whole run. Off for read-only/planning tasks + # (pr-review) — the orchestration panel owns the parent state. + if transition_state: + _transition_issue_state(issue_id, "started", ["In Progress"]) + log( "TASK", f"linear_reactions: react_task_started EXIT (sweep dispatched) " @@ -365,6 +467,7 @@ def react_task_finished( channel_metadata: dict[str, str] | None, success: bool, started_reaction_id: str | None = None, + transition_state: bool = False, ) -> None: """Delete the 👀 (if we have its id) and post ✅/❌ as a replacement.""" issue_id = _enabled(channel_source, channel_metadata) @@ -376,3 +479,11 @@ def react_task_finished( _CREATE_MUTATION, {"issueId": issue_id, "emoji": EMOJI_SUCCESS if success else EMOJI_FAILURE}, ) + # Workflow-state transition: on a clean finish, a writeable single task + # moves the issue to + # In Review (work done, awaiting human merge) — the same target the + # orchestration panel uses on clean completion. On failure, leave the + # state as-is (In Progress); the ❌ reaction conveys the outcome, and the + # user can reply to retry. Off for read-only/planning tasks. + if transition_state and success: + _transition_issue_state(issue_id, "started", ["In Review"]) diff --git a/agent/src/models.py b/agent/src/models.py index d7687c4e7..0a3c4d0c3 100644 --- a/agent/src/models.py +++ b/agent/src/models.py @@ -143,17 +143,18 @@ def version_supported(self) -> Self: class TaskConfig(BaseModel): model_config = ConfigDict(validate_assignment=True) - # repo_url / github_token default to "" so a repo-less TaskConfig (#248 - # Phase 3) is constructible. The _validate_requires_repo_has_repo validator - # below enforces that a repo-BOUND config (requires_repo=True, the default) - # still carries a repo_url — so dropping the field-level requirement does not - # weaken the coding-path invariant. + # repo_url / github_token default to "" so a repo-less TaskConfig (a + # knowledge workflow with no repo) is constructible. The + # _validate_requires_repo_has_repo validator below enforces that a repo-BOUND + # config (requires_repo=True, the default) still carries a repo_url — so + # dropping the field-level requirement does not weaken the coding-path + # invariant. repo_url: str = "" issue_number: str = "" task_description: str = "" github_token: str = "" aws_region: str - anthropic_model: str = "us.anthropic.claude-sonnet-4-6" + anthropic_model: str = "us.anthropic.claude-opus-4-8" # The "small/fast" model Claude Code uses for auxiliary work (e.g. WebFetch # page summarization). Must be a cross-region INFERENCE-PROFILE id (``us.`` # prefix), not a bare foundation-model id — Claude 4.x cannot be invoked @@ -163,23 +164,30 @@ class TaskConfig(BaseModel): max_turns: int = 10 max_budget_usd: float | None = None system_prompt_overrides: str = "" + # Per-repo build/lint verification commands. When set (from the blueprint, + # via the payload), the agent runs these instead of the hardcoded + # ``mise run build`` / ``mise run lint`` to gate build/lint regressions. + # Empty → default to mise. Set for non-mise repos (e.g. ``npm run build``) so + # gating actually runs the repo's real command. + build_command: str = "" + lint_command: str = "" # The pinned workflow this task runs ({"id", "version"}), resolved at the - # create-task boundary and threaded through the payload (#248). None on - # local/batch runs, where the pipeline defaults to coding/new-task-v1. + # create-task boundary and threaded through the payload. None on local/batch + # runs, where the pipeline defaults to coding/new-task-v1. resolved_workflow: dict | None = None # The Cedar principal identity derived from the resolved workflow # (id→legacy map, else "new_task"). The Agent::TaskAgent::"" principal - # scheme is unchanged; since #248 Phase 2a, read-only enforcement no longer - # keys off this principal — it keys off ``read_only`` below. + # scheme is unchanged; read-only enforcement no longer keys off this + # principal — it keys off ``read_only`` below. policy_principal: str = "new_task" # Whether the resolved workflow is read-only (may not mutate the working # tree). Threaded into the Cedar request ``context.read_only`` so the - # hard-deny Write/Edit rules fire for *any* read-only workflow (#248 - # Phase 2a), and drives the runner's allowed_tools tightening. + # hard-deny Write/Edit rules fire for *any* read-only workflow, and drives the + # runner's allowed_tools tightening. read_only: bool = False # The SDK tool surface for this task, from the resolved workflow's - # ``agent_config.allowed_tools`` (#248). This is the second enforcement layer - # the design promises alongside ``read_only``: ``run_agent`` passes it to + # ``agent_config.allowed_tools``. This is the second enforcement layer + # alongside ``read_only``: ``run_agent`` passes it to # ``ClaudeAgentOptions.allowed_tools`` verbatim, and drops ``Write``/``Edit`` # when ``read_only`` is true. Empty list means "fall back to the built-in # full surface" so legacy/batch callers that never resolved a workflow keep @@ -187,10 +195,9 @@ class TaskConfig(BaseModel): # non-empty list (every shipped workflow does). allowed_tools: list[str] = Field(default_factory=list) # Whether the resolved workflow requires a repo. False for repo-less - # knowledge workflows (#248 Phase 3): the pipeline skips clone/build/PR and - # drives the agent + deliver_artifact steps through the workflow runner. - # Defaults True so coding tasks (and any caller that omits it) keep the - # repo-bound path. + # knowledge workflows: the pipeline skips clone/build/PR and drives the agent + # + deliver_artifact steps through the workflow runner. Defaults True so + # coding tasks (and any caller that omits it) keep the repo-bound path. requires_repo: bool = True # True when the resolved workflow operates on an existing PR (pr_* coding # workflows) — gates the "resume existing branch / resolve PR" behavior that @@ -207,42 +214,47 @@ class TaskConfig(BaseModel): channel_metadata: dict[str, str] = Field(default_factory=dict) # Platform user_id (Cognito ``sub``) threaded from the orchestrator # payload. Required ONLY when ``trace`` is true — the agent writes - # the trajectory dump to ``traces//.jsonl.gz`` - # (design §10.1), and the ``get-trace-url`` handler's per-caller- - # prefix guard refuses to presign keys outside the caller's own - # ``traces//`` prefix. Empty-string default for local - # batch runs (no orchestrator in the loop; no trace upload). + # the trajectory dump to ``traces//.jsonl.gz``, + # and the ``get-trace-url`` handler's per-caller-prefix guard refuses + # to presign keys outside the caller's own ``traces//`` + # prefix. Empty-string default for local batch runs (no orchestrator + # in the loop; no trace upload). user_id: str = "" - # Opt-in debug preview cap (design §10.1). Threaded to BOTH the - # pipeline.py milestone writer AND the runner.py turn/tool writer — - # the runner's writer is where thinking/tool_input/tool_result - # previews live, so dropping ``trace`` here silently no-ops the - # feature for the fields that matter. + # Opt-in debug trajectory capture. Threaded to BOTH the pipeline.py + # milestone writer AND the runner.py turn/tool writer — the runner's + # writer is where thinking/tool_input/tool_result previews live, so + # dropping ``trace`` here silently no-ops the feature for the fields + # that matter. trace: bool = False # Enriched mid-flight by pipeline.py: cedar_policies: list[str] = [] - # Cedar HITL (§7.3, §10.2). Per-task approval defaults threaded + # Cedar human-in-the-loop approvals. Per-task approval defaults threaded # from the orchestrator payload; consumed by PolicyEngine at # construction so the engine seeds ApprovalAllowlist and adopts # the per-task timeout default. approval_timeout_s: int | None = None initial_approvals: list[str] = [] - # Chunk 7: TaskTable-persisted ``approval_gate_count`` seeded into - # the session counter so container restarts (§13.6) resume the - # cumulative gate budget without resetting to 0. Threaded from the - # orchestrator payload; zero default preserves legacy callers. + # TaskTable-persisted ``approval_gate_count`` seeded into the session + # counter so container restarts resume the cumulative gate budget + # without resetting to 0. Threaded from the orchestrator payload; zero + # default preserves legacy callers. initial_approval_gate_count: int = 0 - # Chunk 7b (§4 step 5, decision #13): per-task approval-gate cap - # resolved at task submit-time from ``Blueprint.security.approvalGateCap`` - # (or the platform default of 50). Persisted on the TaskRecord so - # it survives container restarts and mid-task blueprint edits do - # not shift the cap beneath a running task. ``None`` when the - # orchestrator payload did not include the field (legacy tasks); - # PolicyEngine falls back to its own default of 50 in that case. + # Per-task approval-gate cap resolved at task submit-time from + # ``Blueprint.security.approvalGateCap`` (or the platform default of 50). + # Persisted on the TaskRecord so it survives container restarts and + # mid-task blueprint edits do not shift the cap beneath a running task. + # ``None`` when the orchestrator payload did not include the field + # (legacy tasks); PolicyEngine falls back to its own default of 50 in + # that case. approval_gate_cap: int | None = None issue: GitHubIssue | None = None base_branch: str | None = None - # Attachments from the orchestrator payload (Phase 3). Validated as + # Predecessor branches to merge into this child's branch before work, + # for a diamond child (2+ predecessors) that branches off the default + # branch but must see all predecessors' code. Empty for root + linear + # children (linear children stack via ``base_branch`` instead). + merge_branches: list[str] = Field(default_factory=list) + # Attachments from the orchestrator payload. Validated as # AttachmentConfig models. Empty list for tasks without attachments. attachments: list[AttachmentConfig] = Field(default_factory=list) @@ -251,8 +263,8 @@ def _validate_trace_requires_user_id(self) -> Self: """Fail at construction when trace=True without a user_id. The trace trajectory is uploaded to - ``traces//.jsonl.gz`` (design §10.1). An empty - ``user_id`` produces ``traces//.jsonl.gz``, which the + ``traces//.jsonl.gz``. An empty ``user_id`` + produces ``traces//.jsonl.gz``, which the ``get-trace-url`` handler's per-caller-prefix guard refuses. Catching this at construction time surfaces the misconfiguration locally / in CI instead of deferring to runtime S3 upload. @@ -270,7 +282,7 @@ def _validate_trace_requires_user_id(self) -> Self: @model_validator(mode="after") def _validate_requires_repo_has_repo(self) -> Self: - """Fail at construction when a repo-bound config has no repo (#248 Phase 3). + """Fail at construction when a repo-bound config has no repo. ``requires_repo`` defaults True, so a config that requires a repo but carries an empty ``repo_url`` is an illegal state the repo-bound pipeline @@ -299,6 +311,30 @@ class RepoSetup(BaseModel): build_before: bool = True lint_before: bool = True default_branch: str = "main" + # True when the build verification command is INERT — it could not run + # at all (no build task / command not found) AND no explicit build_command + # was configured. In that state build-regression gating is effectively OFF + # (a change that breaks the build still reports success), so the agent + # surfaces a one-time warning on the PR. Distinct from a genuinely red build + # (command ran, exited non-zero), which IS meaningful gating signal. + build_gate_inert: bool = False + # Same notion for lint. True when the lint verification command is INERT + # — could not run at all (no lint task / command not found) AND no explicit + # lint_command was configured. In that state lint verification is meaningless + # (the default ``mise run lint`` fails for "no such task", not a real lint + # error), so lint_passed is treated as inert rather than a genuine FAIL. + # Mirrors build_gate_inert. Lint never gates the task verdict regardless + # (only a workflow declaring a gating verify_lint step opts in), so this + # affects reporting + the persisted lint_passed signal, not pass/fail gating. + lint_gate_inert: bool = False + # The branch HEAD sha captured right after checkout, BEFORE the agent runs. + # On a PR-iteration the post-hooks compare the final HEAD to this to decide + # whether the iteration actually committed anything — a question-only comment + # ("where is the login page?") makes no commit, and the platform must report + # "answered / no change" rather than a misleading "✅ Updated — PR #N". Empty + # when the sha couldn't be read (treated as "unknown" → defaults to the + # change-made path, the safe-for-back-compat side). + head_sha_before: str = "" class TokenUsage(BaseModel): @@ -322,24 +358,31 @@ class AgentResult(BaseModel): usage: TokenUsage | None = None # The agent's final result text (ResultMessage.result on success). For a # repo-less knowledge task this IS the deliverable that deliver_artifact - # uploads/posts (#248 Phase 3). Empty for coding tasks (their product is the - # PR, not the text). + # uploads/posts. Empty for coding tasks (their product is the PR, not the + # text). result_text: str = "" + # Clarify-before-spend: the question text captured when the agent + # called the ``request_clarification`` tool instead of doing the work. A + # non-empty value is the deterministic hold-and-ask signal — the pipeline + # skips build/PR and surfaces this question to the requester (no charge for a + # guess). Empty when the agent proceeded normally. Preferred over the older + # NEEDS_INPUT_MARKER text sentinel (a tool call can't be mis-reproduced). + clarification_question: str = "" class TaskResult(BaseModel): status: str agent_status: str = "unknown" pr_url: str | None = None - # Tri-state (#515): True/False once the post-run gate runs; None when it did - # not (repo-less workflow has no build/lint; a crash before post-hooks). The + # Tri-state: True/False once the post-run gate runs; None when it did not + # (repo-less workflow has no build/lint; a crash before post-hooks). The # None case is persisted as "absent" by write_terminal's `is not None` guard, # so the replay bundle reports verification:null rather than a fictional # build_passed:false for a gate that never executed. build_passed: bool | None = None lint_passed: bool | None = None cost_usd: float | None = None - # Rev-5 DATA-1: historically the `turns` field was set to the SDK's + # Historically the `turns` field was set to the SDK's # `ResultMessage.num_turns`, which INCLUDES the attempted turn that # tripped a cap (so `max_turns=6` yields `turns=7` under # `agent_status='error_max_turns'`). That confused operators. We @@ -369,13 +412,31 @@ class TaskResult(BaseModel): # the task did not run with ``--trace`` / the upload was skipped or # failed. Threaded into ``task_state.write_terminal`` so the # TaskRecord's ``trace_s3_uri`` field is set atomically with the - # terminal-status transition (design §10.1). + # terminal-status transition. trace_s3_uri: str | None = None - # S3 URI of a repo-less workflow's delivered artifact (deliver_artifact, #248 - # Phase 3), or ``None`` for coding tasks / when no artifact was delivered. + # S3 URI of a repo-less workflow's delivered artifact (deliver_artifact), + # or ``None`` for coding tasks / when no artifact was delivered. # Surfaced on TaskDetail so the user can retrieve the knowledge-task output. artifact_uri: str | None = None + # True when this run advanced the PR branch HEAD (a real commit landed), + # False when it ran but the branch is unchanged (a question-only iteration), + # None when not a PR-iteration / unknown (no baseline sha). The Linear/Slack + # settle reply reads this: False → "💬 answered, no change", True/None → the + # existing "✅ Updated — PR #N". None defaults to the change-made side for + # back-compat with pre-fix tasks. + code_changed: bool | None = None + # The agent's final answer text, surfaced verbatim on a no-change iteration + # reply so a question gets an actual answer (not an empty "✅ Updated"). + # Distinct from result_text's repo-less-artifact role; populated only for + # the no-op-iteration reply path. Empty otherwise. + answer_text: str = "" + # The branch HEAD sha AFTER this run pushed (PR workflows). The screenshot + # webhook matches a deploy's commit sha → the iteration task that pushed it, + # so the preview thumbnail lands on the RIGHT iteration's reply when two + # iterations on one PR overlap (else "newest task" mis-attributes it). Empty + # when unknown (rev-parse failed / non-PR run) → webhook falls back to newest. + head_sha: str = "" # OTEL trace id (32-char hex) of the task's root span, captured at terminal - # write so the replay bundle (#515) can correlate the task to its - # CloudWatch/X-Ray trace. ``None`` when tracing is unavailable (local/dev). + # write so the replay bundle can correlate the task to its CloudWatch/X-Ray + # trace. ``None`` when tracing is unavailable (local/dev). otel_trace_id: str | None = None diff --git a/agent/src/pipeline.py b/agent/src/pipeline.py index 78b2972ac..32bf80e80 100644 --- a/agent/src/pipeline.py +++ b/agent/src/pipeline.py @@ -6,6 +6,7 @@ import hashlib import inspect import os +import subprocess import sys import time from typing import TYPE_CHECKING @@ -14,9 +15,10 @@ import memory as agent_memory import task_state -from channel_mcp import configure_channel_mcp +from channel_mcp import configure_channel_mcp, strip_linear_mcp_servers from config import ( AGENT_WORKSPACE, + NEEDS_INPUT_MARKER, build_config, clear_jira_task_credentials, get_config, @@ -36,6 +38,7 @@ _extract_agent_notes, ensure_committed, ensure_pr, + reconcile_agent_branch, verify_build, verify_lint, ) @@ -109,9 +112,9 @@ def _maybe_upload_trace( both the happy path (post-hooks complete) and the crash path (top-level ``except``) so a crashing task still produces a debuggable artifact — which is exactly when ``--trace`` is most - useful (K2 review Finding #1). + useful. - Gates (K2 Stage 3 review Finding #1): + Gates: - ``config.trace`` must be true. - ``config.user_id`` must be non-empty, else we would write to ``traces//.jsonl.gz`` — an unreachable key that no @@ -161,6 +164,48 @@ def _maybe_upload_trace( return trace_s3_uri +def _deliver_plan_artifact( + workflow, + config, + hydrated, + progress, + trajectory, + setup, + prompt: str, + agent_result, +) -> str | None: + """Deliver a repo-ful artifact workflow's result as the task artifact. + + Such a workflow is one whose primary terminal outcome is an ARTIFACT — a + document — rather than a PR. It clones the repo for context but produces no code + change, so the build/PR post-hooks do not apply. This uploads the agent's final + result text via the SAME ``deliver_artifact`` uploader web-research uses + (``artifacts/{task_id}/``), returning the ``s3://`` URI. Raises on delivery + failure — delivery is the terminal side effect, so a failure must surface as a + FAILED task (caught by the pipeline's outer handler), not a silent success. + """ + from workflow import StepContext + from workflow.deliverers import deliver as deliver_artifact + + deliver_ctx = StepContext( + workflow=workflow, + config=config, + hydrated=hydrated, + progress=progress, + trajectory=trajectory, + setup=setup, + system_prompt="", + user_prompt=prompt, + ) + deliver_ctx.agent_result = agent_result + result = deliver_artifact("s3", deliver_ctx) + artifact_uri = result.artifact_uri + log("POST", f"artifact delivered: {artifact_uri}") + if artifact_uri: + progress.write_agent_milestone("artifact_delivered", artifact_uri) + return artifact_uri + + def _execute_agent_step( prompt: str, system_prompt: str, @@ -461,20 +506,66 @@ def _apply_post_hook_gates( return gates_ok +def _starts_with_needs_input_marker(result_text: str | None) -> bool: + """True when the agent's final message opens with the clarify-and-hold marker. + + Clarify-before-spend (UX #4): the new_task workflow tells the agent to put + :data:`NEEDS_INPUT_MARKER` on the FIRST line of its final message when it + needs to ask instead of guess. We match the FIRST non-empty line only (a + marker buried mid-answer is not a hold signal — it prevents a stray mention + of the token in prose from tripping the hold). + """ + if not result_text: + return False + for line in result_text.splitlines(): + stripped = line.strip() + if not stripped: + continue + return stripped.startswith(NEEDS_INPUT_MARKER) + return False + + +def _strip_needs_input_marker(result_text: str) -> str: + """Remove the leading NEEDS_INPUT_MARKER line/token so the reviewer sees only + the clarifying question, never our internal sentinel.""" + text = result_text.strip() + if text.startswith(NEEDS_INPUT_MARKER): + text = text[len(NEEDS_INPUT_MARKER) :] + return text.strip() + + def _resolve_overall_task_status( agent_result: AgentResult, *, build_ok: bool, pr_url: str | None, + build_timed_out: bool = False, + build_infra_failed: bool = False, ) -> tuple[str, str | None]: - """Map agent outcome + build gate to (overall_status, error_for_task_result).""" + """Map agent outcome + build gate to (overall_status, error_for_task_result). + + ``build_timed_out`` distinguishes a build-gate failure that was actually a + TIMEOUT (the verify command exceeded its wall-clock ceiling and was killed) + from a genuine red build. When the agent itself finished cleanly but the + build gate failed ONLY because it timed out, the error_message carries a + ``build_ok=timeout`` marker so the platform surfaces "build timed out" + rather than the misleading "build/tests failed". + + ``build_infra_failed`` marks a build KILLED by an environment fault (out of + disk / OOM) — we could not VERIFY the code on this host. This forces an error + verdict EVEN IF the regression-only gate would otherwise pass (a build that + was also infra-killed BEFORE the agent looks "already red → not a regression", + which would wrongly report ✅ success on unverified code — an observed false + ✅). The ``build_ok=infra`` marker makes the platform surface a retryable + infrastructure fault, not "build/tests failed" or a bogus success. + """ agent_status = agent_result.status err = agent_result.error - # ABCA-662: a max_turns cap is a CORRECT classification, but on its own it - # doesn't say WHETHER the task genuinely needed the turns or SPUN on a failing - # operation until it ran out (662 thrashed on a failing `git push` → invalid - # credentials, retried every which way, and capped). When the stuck-guard's + # A max_turns cap is a CORRECT classification, but on its own it doesn't say + # WHETHER the task genuinely needed the turns or SPUN on a failing operation + # until it ran out (one observed run thrashed on a failing `git push` → + # invalid credentials, retried every which way, and capped). When the stuck-guard's # trailing window was failure-dominated, append its one-line summary so the # reason distinguishes "ran long" from "looped on an error" — the classifier # still buckets it as max_turns, but a human sees the real cause. Only enriches @@ -486,6 +577,13 @@ def _resolve_overall_task_status( if stuck and stuck not in err: err = f"{err} — {stuck}" + # Infra-killed build (ENOSPC/OOM) → we have NO valid build verdict. Surface a + # retryable infra fault regardless of the regression gate, so it neither reads + # as a false ✅ (regression-only saw red-before+red-after) nor as "your build + # failed". Checked before the success short-circuit for exactly that reason. + if build_infra_failed and agent_status in ("success", "end_turn"): + return "error", (f"Task did not succeed (agent_status={agent_status!r}, build_ok=infra)") + if agent_status in ("success", "end_turn") and build_ok: return "success", err @@ -519,12 +617,112 @@ def _resolve_overall_task_status( return "error", merged if not err: + # #251: a latched blocker (e.g. egress_denied naming a host) is the more + # specific, authoritative terminal reason — prefer it over the generic + # build-gate copy so the classifier attaches the precise remedy. if blocker: return "error", blocker - err = f"Task did not succeed (agent_status={agent_status!r}, build_ok={build_ok})" + # The agent finished cleanly but the build gate failed. If that failure + # was a TIMEOUT, mark it distinctly (``build_ok=timeout``) so the + # platform's failure copy reads "timed out", not "build/tests failed". + build_marker = "timeout" if build_timed_out else build_ok + err = f"Task did not succeed (agent_status={agent_status!r}, build_ok={build_marker})" return "error", err +def _branch_has_new_commits(repo_dir: str, default_branch: str) -> bool: + """True if HEAD carries commits beyond ``origin/``. + + The same ``origin/..HEAD`` diff ``ensure_pr`` consults to + decide whether there is anything to open a PR from (see + ``post_hooks._ensure_pr``). Used by the delivery gate to tell + "a commit landed but the PR failed to open" (recoverable) from "no commit + ever reached the branch — the work was lost". For a stacked child of an + orchestrated issue graph (#247) ``default_branch`` IS its predecessor + branch (``config.base_branch``), so + this asks the right question: did this child add anything on top of its + base? Best-effort — a git failure returns ``False`` (assume nothing landed, + the conservative read for a gate whose job is to catch a lost deliverable).""" + try: + res = subprocess.run( + ["git", "log", f"origin/{default_branch}..HEAD", "--oneline"], + cwd=repo_dir, + check=False, + capture_output=True, + text=True, + timeout=60, + ) + except (OSError, subprocess.SubprocessError): + return False + return res.returncode == 0 and bool(res.stdout.strip()) + + +def _apply_delivery_gate( + overall_status: str, + result_error: str | None, + *, + workflow_read_only: bool, + artifact_workflow: bool, + needs_input: bool, + ensure_pr_strategy: str, + pr_url: str | None, + commit_landed: bool, +) -> tuple[str, str | None]: + """Fail a new-work coding task that reported success but shipped nothing + (no PR AND no commit on its branch) — the deliverable was lost. + + The invariant: a task that reports success but ships nothing must FAIL, or + the platform reports success for work that no longer exists anywhere. + + Observed cause: a stacked child of an orchestrated issue graph (#247) edited + its files in a NESTED working tree + (persistent-storage residue left the clone one level deep inside repo_dir), + so every pipeline git op ran against the outer clean tree — + ``ensure_committed`` saw nothing, ``ensure_pr`` found no commits and skipped — + yet the task reported COMPLETED/build_passed. That silent success poisoned the + integration: the child's feature never reached the branch its successor + stacked on. This gate turns that into a loud, retryable FAILED. + + Mirrors the repo-LESS artifact gate (``run_task`` arm 2): a sanctioned no-op + is EXPECTED to ship nothing, so this fires ONLY for create-strategy new work — + * read-only (pr_review) / artifact workflows ship no PR by design; + * push_resolve / resolve (pr_iteration) legitimately add no NEW PR, and a + question-only iteration is handled via the ``code_changed=False`` + "answered" path — none MUST open a PR; + * clarify-and-hold (needs_input) is the one intentional new_task no-op, and + the caller forces it to success right after this gate. + + ``commit_landed`` (from :func:`_branch_has_new_commits`) distinguishes + "a commit reached the branch but the PR failed to open" (``deliverable=no_pr`` + — recoverable, the work is safe on the branch) from "no commit ever landed" + (``deliverable=lost`` — the work is gone) so the failure copy is honest. + Returns the (possibly unchanged) ``(overall_status, result_error)``. + """ + delivery_expected = ( + not workflow_read_only + and not artifact_workflow + and not needs_input + and ensure_pr_strategy == "create" + ) + if not (overall_status == "success" and delivery_expected and not pr_url): + return overall_status, result_error + if commit_landed: + reason = ( + "Task did not succeed (agent_status=success, deliverable=no_pr): a " + "commit reached the branch but no PR was opened — the change is on the " + "branch but was not delivered." + ) + else: + reason = ( + "Task did not succeed (agent_status=success, deliverable=lost): the " + "coding task reported success but no commit reached the branch and no " + "PR was opened — the agent's changes did not land in the task's " + "repository." + ) + log("WARN", f"Delivery gate: {reason}") + return "error", reason + + def _compute_turns_completed( agent_status: str, turns_attempted: int | None, @@ -532,7 +730,7 @@ def _compute_turns_completed( ) -> int | None: """Clamp ``turns_completed`` to ``max_turns`` when the SDK hit the limit. - Rev-5 DATA-1 — the Claude Agent SDK reports ``num_turns = max_turns + 1`` + The Claude Agent SDK reports ``num_turns = max_turns + 1`` on ``error_max_turns`` because the aborted attempt is counted. Clamping at the final write keeps ``turns_completed`` truthful ("how many turns actually executed") while ``turns_attempted`` keeps the raw SDK value @@ -611,11 +809,15 @@ def run_task( task_id: str = "", hydrated_context: dict | None = None, system_prompt_overrides: str = "", + build_command: str = "", + lint_command: str = "", prompt_version: str = "", memory_id: str = "", resolved_workflow: dict | None = None, branch_name: str = "", pr_number: str = "", + base_branch: str | None = None, + merge_branches: list[str] | None = None, cedar_policies: list[str] | None = None, approval_timeout_s: int | None = None, initial_approvals: list[str] | None = None, @@ -658,9 +860,13 @@ def run_task( aws_region=aws_region, task_id=task_id, system_prompt_overrides=system_prompt_overrides, + build_command=build_command, + lint_command=lint_command, resolved_workflow=resolved_workflow, branch_name=branch_name, pr_number=pr_number, + base_branch=base_branch, + merge_branches=merge_branches, channel_source=channel_source, channel_metadata=channel_metadata, trace=trace, @@ -724,8 +930,8 @@ def run_task( from hooks import reset_blocker_reason, reset_stuck_summary reset_blocker_reason() - # ABCA-662: same per-task reset for the stuck-guard recent-failure latch, - # so a prior task's observation can't leak into this task's max_turns copy. + # Same per-task reset for the stuck-guard recent-failure latch, so a + # prior task's observation can't leak into this task's max_turns copy. reset_stuck_summary() # --trace accumulator (design §10.1): when the task opted into # trace, ``_TrajectoryWriter`` keeps an in-memory copy of each @@ -733,7 +939,7 @@ def run_task( # S3 on terminal. Owned by the pipeline rather than the runner # so the accumulator outlives ``run_agent``'s scope. trajectory = _TrajectoryWriter(config.task_id, accumulate=trace) - # K2 review Finding #3 — surface accumulator truncation to the + # Surface accumulator truncation to the # user via a ``trace_truncated`` milestone on TaskEventsTable # (visible in ``bgagent watch``). Fire-once by design: the # downloaded artifact's header reports the final drop count. @@ -853,9 +1059,10 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # pre-agent baseline build in setup_repo(). On a large repo that # baseline is minutes (up to the build-verify ceiling); posting the # 👀 only *after* it left the issue looking dead for the whole phase - # (no reaction, comment, or state change). None of these calls needs - # the cloned repo — they act on the channel issue via its API token + - # issue id from channel metadata — so they belong before the build. + # (observed in practice as no reaction, comment, or state change for + # 30+ min). None of these calls needs the cloned repo — they act on + # the channel issue via its API token + issue id from channel + # metadata — so they belong before the clone/build. # # Resolve the per-channel access token from Secrets Manager first # (react_task_started/comment_task_started read the env var it sets). @@ -869,9 +1076,17 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # No-op for non-Linear tasks. Best-effort; failures are logged # but do not block the pipeline. Capture the reaction id so we # can delete it at terminal status (👀 → ✅/❌). + # Workflow-state transition: a writeable coding task (new-task / + # pr-iteration) also moves + # the Linear issue Backlog → In Progress so it doesn't sit in Backlog + # for the whole run. read_only tasks (planning, + # pr-review) never transition — the orchestration panel owns the + # parent's state, and a planning run shouldn't advance the issue. + linear_transition_state = not config.read_only linear_eyes_reaction_id = react_task_started( config.channel_source, config.channel_metadata, + transition_state=linear_transition_state, ) # "Starting" comment on the Jira issue through the Forge app actor @@ -883,10 +1098,11 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: ) # Move the Jira card To Do → In Progress so the board reflects that - # work has started (issue #572). No-op for non-Jira tasks. Part of - # the early ACK (before setup_repo) so the board reflects "started" - # during the baseline build, not only after it. Best-effort; failures - # are logged and never block the pipeline. + # work has started (issue #572). No-op for non-Jira tasks. + # Best-effort; failures are logged and never block the pipeline. + # Part of the Early-ACK block (moved before setup_repo with the 👀 + # and start comment) so board state updates immediately, not after + # the multi-minute baseline build. transition_task_started( config.channel_source, config.channel_metadata, @@ -896,8 +1112,10 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # pre-agent baseline build raises here; it needs no local handler — # the outer ``except Exception`` at the bottom of this ``try`` writes # the task FAILED, swaps the 👀 (posted above) to ❌, and posts the - # failure comment. Posting the 👀 earlier is what makes the outer - # handler's ❌-swap actually visible for setup failures. + # failure comment. Before the Early-ACK move the 👀 didn't exist yet + # at this point, so a setup failure left the issue silently stuck + # with no visible signal; posting the 👀 earlier is what makes the + # outer handler's ❌-swap actually visible for setup failures. with task_span("task.repo_setup") as setup_span: setup = setup_repo(config, progress=progress) setup_span.set_attribute("build.before", setup.build_before) @@ -914,6 +1132,13 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # repo dir. (Token resolution + the 👀/start ACK moved earlier so # the user gets immediate feedback; see the Early ACK block above.) configure_channel_mcp(setup.repo_dir, config.channel_source) + # ADR-016 ENFORCEMENT: strip any Linear MCP server a repo may have + # COMMITTED to its own .mcp.json before the SDK reads it — the prompt + # prohibition ("you have no Linear tools") is not a security boundary, + # and we export LINEAR_API_TOKEN + load project settings under + # bypassPermissions. Runs for every channel (defense-in-depth); never + # matches Jira's own entry. + strip_linear_mcp_servers(setup.repo_dir) # Download attachments from S3 (version-pinned, integrity-verified) prepared_attachments: list = [] @@ -1089,35 +1314,177 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: ) ensure_pr_strategy = "create" + # A REPO-FUL workflow whose primary terminal outcome is an ARTIFACT + # clones the repo for context but produces a document, not a PR. Skip + # the build/PR post-hooks and deliver the agent's result text as the + # artifact. + # + # No workflow currently shipped takes this branch — the repo-ful + # artifact workflows live downstream — but the generic capability stays + # because it is declared by the workflow contract + # (terminal_outcomes.primary + requires_repo), not by a workflow id, and + # the alternative is silently opening a PR for a document task. + # + # BOTH conditions matter: a repo-LESS artifact workflow + # (default/agent-v1, web-research) never reaches this repo-bound + # branch, but default/agent-v1 is repo-OPTIONAL — run WITH a repo it + # takes THIS path yet still expects a PR (primary: artifact but + # requires_repo: false). So gate on requires_repo too, or a + # repo-optional default-agent run would wrongly skip its PR. + artifact_workflow = bool( + _workflow + and getattr(_workflow.terminal_outcomes, "primary", None) == "artifact" + and getattr(_workflow, "requires_repo", False) + ) + artifact_uri: str | None = None # set by the artifact branch below + + # Clarify-before-spend (UX #4): a writeable, PR-producing task + # (new_task) whose agent judged the request too ambiguous to + # implement emits NEEDS_INPUT_MARKER on the first line of its final + # message. Treat that as a HOLD: no build, no commit, no PR — the + # deliverable is the clarifying question, surfaced by the platform as + # "needs input" rather than a finished task, so we don't charge for a + # guess. Scoped OFF for artifact workflows (they emit a document, not a + # question) and PR workflows (pr_iteration already has its own + # answer-only path). Fail-safe: if the marker is somehow present on a + # read-only task we still just hold (nothing to lose). + # Primary signal: the agent CALLED the request_clarification tool + # (deterministic — a tool call, captured by the runner). Fallback: + # the legacy first-line text sentinel (kept so a model that types the + # marker instead of calling the tool still holds). Either → hold. + clarification_q = (agent_result.clarification_question or "").strip() + needs_input = bool( + not artifact_workflow + and not config.is_pr_workflow + and (clarification_q or _starts_with_needs_input_marker(agent_result.result_text)) + ) + # Post-hooks (agent_result is guaranteed set by the try/except above) with task_span("task.post_hooks") as post_span: - # Safety net: commit any uncommitted tracked changes (skip for read-only tasks) - safety_committed = False if workflow_read_only else ensure_committed(setup.repo_dir) - post_span.set_attribute("safety_net.committed", safety_committed) - - build_passed = verify_build(setup.repo_dir) - lint_passed = verify_lint(setup.repo_dir) - pr_url = ensure_pr( - config, - setup, - build_passed, - lint_passed, - agent_result=agent_result, - strategy=ensure_pr_strategy, - ) - post_span.set_attribute("build.passed", build_passed) - post_span.set_attribute("lint.passed", lint_passed) - post_span.set_attribute("pr.url", pr_url or "") + if needs_input: + # Hold-and-ask: skip build/lint/PR entirely. The agent asked a + # question and made no changes; there is nothing to verify or ship. + build_passed = True + lint_passed = True + build_timed_out = False + build_inert = False + build_infra_failed = False + safety_committed = False + pr_url = None + log("POST", "Clarify-before-spend: agent asked for input — holding (no PR)") + elif artifact_workflow: + # Plan-only task: no build/lint/PR gate — the plan IS the deliverable. + build_passed = True + lint_passed = True + build_timed_out = False + build_inert = False + build_infra_failed = False + safety_committed = False + pr_url = None + artifact_uri = _deliver_plan_artifact( + _workflow, config, hc, progress, trajectory, setup, prompt, agent_result + ) + post_span.set_attribute("artifact.uri", artifact_uri or "") + else: + # A leading cause of lost deliverables: if the agent switched + # off the platform branch (it sometimes runs + # `git checkout -b ` and commits/opens its PR + # there — observed in practice), + # re-point the platform branch at the agent's HEAD so the + # safety-net commit, build verify, PR, and push below all run on + # the branch the platform tracks. No-op on the healthy case + # (agent stayed on the platform branch). Skip for read-only + # (no commit/PR to deliver). The delivery gate stays as the + # backstop if reconcile can't recover the work. + if not workflow_read_only: + reconcile_agent_branch(setup.repo_dir, setup.branch) + # Safety net: commit any uncommitted tracked changes (skip read-only tasks) + safety_committed = ( + False if workflow_read_only else ensure_committed(setup.repo_dir) + ) + post_span.set_attribute("safety_net.committed", safety_committed) + + build_outcome = verify_build(setup.repo_dir, config.build_command) + build_passed = build_outcome.passed + # Distinct diagnosis: a build that exceeded BUILD_VERIFY_TIMEOUT_S + # was KILLED, not failed — surface "timed out" rather than the + # misleading "build/tests failed" (a build that never finished is + # a different problem than a broken build). Threaded into the task + # error_message below so the platform's failure copy reflects it. + build_timed_out = build_outcome.timed_out + # The build was KILLED by an environment fault (out + # of disk / OOM) — we could NOT verify the code. Unlike inert, do + # NOT treat this as passing: an infra-killed build gives no + # verdict, and if the pre-agent baseline was ALSO infra-killed the + # regression-only gate would wrongly conclude "already red → not a + # regression → success" (the false ✅). Threaded into the verdict + # + error_message (build_ok=infra) so the platform reports a + # retryable infra fault, not "build failed" and not a bogus ✅. + build_infra_failed = build_outcome.infra_failed + # An INERT build gate (exit 127 / no-such-task — the command + # couldn't run, e.g. yarn missing) verified NOTHING. Treat it like + # the lint-inert path: do NOT gate on it (it's a config problem, + # not the agent's code), and treat build as passing for the gate + # so we don't emit a false "build failed". The honest signal is + # carried in error_message (build_ok=inert) for the platform copy. + build_inert = build_outcome.inert + if build_inert: + log( + "POST", + "Post-agent build gate is INERT (command couldn't run) " + "— not gating on it; surfacing as inert, not a failure", + ) + build_passed = True + # #72: when lint is INERT for this repo (no runnable lint task and + # no configured lint_command — see repo.py setup), running the + # default `mise run lint` would just fail "no such task" and + # record a misleading lint_passed=False. Skip the post-agent lint + # run entirely in that case and treat lint as passing (it never + # gates the verdict regardless; this keeps the persisted signal + # honest rather than a false red). + if getattr(setup, "lint_gate_inert", False): + log( + "POST", + "Skipping post-agent lint verification " + "(lint gating is INERT for this repo)", + ) + lint_passed = True + else: + lint_passed = verify_lint(setup.repo_dir, config.lint_command).passed + pr_url = ensure_pr( + config, + setup, + build_passed, + lint_passed, + agent_result=agent_result, + strategy=ensure_pr_strategy, + ) + post_span.set_attribute("build.passed", build_passed) + post_span.set_attribute("lint.passed", lint_passed) + post_span.set_attribute("pr.url", pr_url or "") if pr_url: progress.write_agent_milestone("pr_created", pr_url) # Move the Jira card In Progress → In Review now that a PR is - # open (issue #572). Only fires when a PR was actually opened — - # failed / no-PR tasks leave the card where humans can see the - # failure comment. No-op for non-Jira tasks; best-effort. - transition_pr_opened( - config.channel_source, - config.channel_metadata, - ) + # open (issue #572) — but ONLY when the build passed. ensure_pr + # deliberately opens a PR even on a FAILED build (so the human + # sees the broken diff), so gating on pr_url alone moved the card + # to In Review on a red build, telling the board the work is ready + # for review when it isn't (review blocker #9a). Gate on build_ok + # to mirror the Linear twin, which only transitions on success + # (react_task_finished: `if transition_state and success`). A + # build-failed PR leaves the card In Progress with the failure + # comment. No-op for non-Jira tasks; best-effort. + if build_passed: + transition_pr_opened( + config.channel_source, + config.channel_metadata, + ) + else: + log( + "TASK", + "Jira card NOT moved to In Review — PR opened with a FAILED build " + "(build_ok=False); leaving it In Progress with the failure comment (#9a).", + ) # Memory write — capture task episode and repo learnings memory_written = False @@ -1156,7 +1523,47 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: agent_result, build_ok=build_ok, pr_url=pr_url, + build_timed_out=build_timed_out, + build_infra_failed=build_infra_failed, + ) + # Delivery gate: a create-strategy new-work task that + # reported success but opened no PR AND landed no commit shipped + # nothing — fail it loudly (retryable) instead of a false COMPLETED + # that would poison a stacked orchestration DAG (#247). The branch-diff is only run + # when the gate is actually in play (success + no pr_url) so a normal + # PR-producing run pays nothing. See :func:`_apply_delivery_gate`. + gate_in_play = ( + overall_status == "success" + and not pr_url + and not workflow_read_only + and not artifact_workflow + and not needs_input + and ensure_pr_strategy == "create" + ) + commit_landed = ( + _branch_has_new_commits(setup.repo_dir, setup.default_branch) + if gate_in_play + else False ) + overall_status, result_error = _apply_delivery_gate( + overall_status, + result_error, + workflow_read_only=workflow_read_only, + artifact_workflow=artifact_workflow, + needs_input=needs_input, + ensure_pr_strategy=ensure_pr_strategy, + pr_url=pr_url, + commit_landed=commit_landed, + ) + # Clarify-before-spend: a hold-for-input run is a SUCCESSFUL outcome + # (the agent did the right thing by asking), not a failure — the + # deliverable is the question. Force success + clear any error so the + # platform surfaces "needs input", not ❌. (The agent emitted a normal + # ResultMessage, so overall_status is already 'success' in the common + # case; this guards the edge where a gate/marker interaction differs.) + if needs_input: + overall_status = "success" + result_error = None # ✅/❌ on the Linear issue (removes the 👀 first so the final # status stands alone). No-op for non-Linear tasks. @@ -1165,6 +1572,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: config.channel_metadata, success=(overall_status == "success"), started_reaction_id=linear_eyes_reaction_id, + transition_state=linear_transition_state, ) # NOTE: the terminal status comment on the Jira issue is NOT posted @@ -1186,6 +1594,41 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # still produces a usable debug artifact. trace_s3_uri = _maybe_upload_trace(config, trajectory, progress) + # Did this PR-iteration actually advance the branch HEAD? + # Compare the final HEAD to the sha captured at checkout. Unchanged + # ⇒ a question-only iteration (no commit) ⇒ the settle reply reports + # "answered / no change" instead of a false "✅ Updated". Only + # meaningful for a PR workflow with a baseline sha; otherwise None + # (the change-made / back-compat side). Best-effort — a rev-parse + # failure leaves it None, never flips the verdict. + code_changed: bool | None = None + head_sha_after = "" + if config.is_pr_workflow and setup.head_sha_before: + head_after_res = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=setup.repo_dir, + check=False, + capture_output=True, + text=True, + timeout=60, + ) + if head_after_res.returncode == 0: + head_sha_after = head_after_res.stdout.strip() + code_changed = head_sha_after != setup.head_sha_before + # The agent's final text — surfaced as the answer on a no-change + # iteration so a question gets an actual reply. + answer_text = (agent_result.result_text or "").strip() + # Clarify-before-spend: reuse the SAME "no change → 💬 answered" surface + # the pr-iteration answer path uses (code_changed=False + answer_text). + # A new_task hold makes no commit, so code_changed is naturally False; + # set it explicitly and strip the marker line so the reviewer sees only + # the question, not our internal sentinel. + if needs_input: + code_changed = False + # Prefer the tool's ``question`` arg (clean, no marker); fall back + # to the final message with the legacy sentinel stripped. + answer_text = clarification_q or _strip_needs_input_marker(answer_text) + # Build TaskResult usage = agent_result.usage turns_attempted = agent_result.num_turns or agent_result.turns @@ -1219,6 +1662,15 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: cache_read_input_tokens=usage.cache_read_input_tokens if usage else None, cache_creation_input_tokens=usage.cache_creation_input_tokens if usage else None, trace_s3_uri=trace_s3_uri, + # An artifact workflow carries its artifact + # URI here so the platform can read the plan and seed sub-issues; + # None for a normal PR workflow. + artifact_uri=artifact_uri, + code_changed=code_changed, + # Only carry the answer text on a no-change iteration (where it + # becomes the reply); a normal edit's reply is the PR link. + answer_text=answer_text if code_changed is False else "", + head_sha=head_sha_after, otel_trace_id=current_otel_trace_id(), ) @@ -1264,7 +1716,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # Ensure the task is marked FAILED in DynamoDB even if the pipeline # crashes before reaching the normal terminal-state write. # - # K2 review Finding #1 — crash-path trace upload. The + # Crash-path trace upload. The # trajectory accumulator is exactly the artifact the user # enabled ``--trace`` to capture the failure with; dropping # it on the crash path is a silent regression against the @@ -1333,30 +1785,23 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: _RUN_TASK_PARAMS = frozenset(inspect.signature(run_task).parameters) #: Orchestrator payload keys we KNOW about that ``run_task`` does not (yet) -#: accept as a parameter. Dropping one of these is expected today (e.g. -#: ``base_branch`` is consumed via ``hydrated_context``, not a run_task kwarg; -#: ``github_token_secret_arn`` is resolved before this call), but a key that +#: accept as a parameter. Dropping one of these is expected today, but a key that #: shows up here AND is silently dropped is exactly the "wired one side of an -#: orchestrator→agent field, forgot the other" no-op that ABCA-487 was — so we -#: WARN when we drop one, making a future contract gap visible instead of silent. +#: orchestrator→agent field, forgot the other" no-op we have already hit once — +#: so we WARN when we drop one, making a future contract gap visible instead of +#: silent. #: Keys not in this set (genuinely foreign) are dropped quietly as before. +#: +#: NB (merge note): on this branch ``run_task`` DOES accept ``build_command``, +#: ``lint_command``, ``base_branch`` and ``merge_branches`` (see its signature), +#: so those are forwarded — NOT dropped — and must NOT be listed here (they would +#: never hit the drop path). ``github_token_secret_arn`` is deliberately omitted +#: too: it is ALWAYS present and ALWAYS resolved via the +#: ``GITHUB_TOKEN_SECRET_ARN`` env in build_config, so listing it would fire the +#: WARN on 100% of ECS boots — pure noise. It falls through as a quiet +#: foreign-key drop instead. _KNOWN_ORCHESTRATOR_KEYS = frozenset( { - "build_command", - # ``lint_command``'s sibling: neither is a run_task param today (the build/ - # lint commands are consumed via repo config, not passed through here), but - # listing both makes a future "wired build_command, forgot lint_command" - # contract gap WARN instead of drop silently (N4). - "lint_command", - "merge_branches", - "base_branch", - # NB: ``github_token_secret_arn`` is deliberately NOT listed (N3). It is - # ALWAYS in the payload and ALWAYS dropped here (resolved via the - # ``GITHUB_TOKEN_SECRET_ARN`` env in build_config, never a run_task - # param), so listing it would fire the known-key WARN on 100% of ECS - # boots — pure noise that dilutes the channel meant to surface genuine - # future "wired one side, forgot the other" gaps. It falls through as a - # quiet foreign-key drop instead. # AgentCore's server.py exports task_started_at as TASK_STARTED_AT, which # hooks._remaining_maxlifetime_s() uses to clip the Cedar HITL approval-gate # maxLifetime. The ECS boot path bypasses server.py and does not (yet) set @@ -1375,8 +1820,8 @@ def run_task_from_payload(payload: dict) -> dict: The ECS compute path (``ecs-strategy.ts``) hands the agent the *entire* orchestrator payload (via the #502 S3 pointer). Previously the ECS boot command hand-listed a subset of ``run_task`` kwargs and silently dropped the - rest — most visibly ``channel_source``/``channel_metadata`` (no Linear/Jira - reactions or channel MCP on ECS — ABCA-487), plus ``build_command``, + rest — most visibly ``channel_source``/``channel_metadata``, whose absence + meant no Linear/Jira reactions and no channel MCP on ECS, plus ``build_command``, ``cedar_policies``, ``base_branch``/``merge_branches``, ``attachments``, etc. This maps the payload to ``run_task``'s real signature so no field can be @@ -1397,8 +1842,7 @@ def run_task_from_payload(payload: dict) -> dict: # Not a run_task parameter — ignore. A KNOWN orchestrator key being # dropped is expected today but worth a breadcrumb: if run_task ever # grows a matching param, this WARN is where a "forgot to wire it - # through" no-op surfaces (N4 / ABCA-487 class). Foreign keys are - # dropped quietly. + # through" no-op surfaces. Foreign keys are dropped quietly. if key in _KNOWN_ORCHESTRATOR_KEYS and value is not None: log( "WARN", @@ -1417,7 +1861,7 @@ def run_task_from_payload(payload: dict) -> dict: # non-str coercion, so it also guards the surprising int() cases the # orchestrator never emits but a hand-edited payload might: a bool # (``int(True) == 1``) and a non-integral float (``int(3.9) == 3``) - # would both silently become a bogus turn count (N4). + # would both silently become a bogus turn count. if isinstance(value, bool) or not isinstance(value, (int, float, str)): log("WARN", f"run_task_from_payload: ignoring non-integer max_turns {value!r}") continue diff --git a/agent/src/post_hooks.py b/agent/src/post_hooks.py index 058a1e4c8..e6e7f0de2 100644 --- a/agent/src/post_hooks.py +++ b/agent/src/post_hooks.py @@ -2,8 +2,11 @@ from __future__ import annotations +import os import re +import shlex import subprocess +from dataclasses import dataclass from typing import TYPE_CHECKING from shell import log, run_cmd @@ -11,45 +14,240 @@ if TYPE_CHECKING: from models import AgentResult, RepoSetup, TaskConfig +# Default verification commands. A repo that uses mise gets these for free; a +# non-mise repo sets ``pipeline.buildCommand`` / ``lintCommand`` in its +# blueprint (threaded to the agent as build_command / lint_command) so gating +# runs the repo's real command. +DEFAULT_BUILD_COMMAND = "mise run build" +DEFAULT_LINT_COMMAND = "mise run lint" + +# Wall-clock ceiling for a single build/lint verification subprocess. The old +# hardcoded 600s (run_cmd's default) was too low for a real CI-parity build +# (install + compile + full test suite + synth) — a heavy repo's legitimate +# build exceeded it and was reported as a build FAILURE, which is the wrong +# diagnosis (the build didn't fail, it didn't finish in time). Raised to 30min +# and made env-overridable; well under the orchestrator's 9h durable ceiling. +# When the ceiling IS hit we now surface a distinct "timed out" reason (see +# VerifyOutcome.timed_out → pipeline error_message → platform failure copy) +# rather than a generic "build failed". +BUILD_VERIFY_TIMEOUT_S = int(os.environ.get("BUILD_VERIFY_TIMEOUT_S") or 1800) + + +@dataclass +class VerifyOutcome: + """Result of a build/lint verification run. + + ``passed`` drives gating exactly as the old bare-bool return did. The two + other flags distinguish WHY a not-passed result happened, so the platform + can report an honest, actionable reason instead of a blanket "build failed": + + - ``timed_out`` — the command exceeded ``BUILD_VERIFY_TIMEOUT_S`` and was + killed (a build that never finished, not a build that failed). + - ``inert`` — the command could not RUN at all: exit 127 (command not + found, e.g. ``yarn`` missing) or mise "no such task". This is a CONFIG + problem (the gate isn't actually verifying anything), NOT the agent's + code being broken. Without this, an inert exit-127 gate was silently + reported as ``build_passed=False`` — a false "your code is broken" for a + repo we never managed to build. ``is_verify_command_inert`` already + existed but was only consulted at repo SETUP; now the post-agent gate + consults it too. + + - ``infra_failed`` — the command was KILLED by an environment fault (the + build box ran out of disk/ENOSPC or memory/OOM), so the build could not + complete on this host. Like ``inert`` this is NOT the agent's code being + broken — it's an infrastructure fault that a retry (fresh host) or more + capacity clears. This was a real failure mode: concurrent builds filled + the Fargate root fs → ENOSPC mid-build → bogus ``build_passed=False``. + + A timeout / inert / infra_failed result still counts as not-passed for + gating, but the pipeline surfaces each as its own reason. + """ -def verify_build(repo_dir: str) -> bool: - """Run mise run build after agent completion to verify the build.""" - log("POST", "Running post-agent build verification (mise run build)...") - try: - result = run_cmd( - ["mise", "run", "build"], - label="mise-run-build-post", - cwd=repo_dir, - check=False, - ) - except subprocess.TimeoutExpired: - log("WARN", "Post-agent build timed out — treating as failed") - return False - if result.returncode != 0: - log("POST", "Post-agent build FAILED") - return False - log("POST", "Post-agent build: OK") - return True + passed: bool + timed_out: bool = False + inert: bool = False + infra_failed: bool = False + + +# POSIX shell exit code for "command not found" — an inert build signal (the +# configured verify command isn't installed), not a genuine build failure. +SHELL_COMMAND_NOT_FOUND = 127 + + +def is_verify_command_inert(returncode: int, stderr: str) -> bool: + """True when a verify command did not actually RUN (vs ran-and-failed). + + Distinguishes the inert-gate state — the build/lint command isn't + runnable in this repo, so gating is effectively OFF — from a genuine red + build (command executed, exited non-zero), which IS meaningful signal. + + Heuristics (conservative — only the unambiguous "couldn't run" signals): + - exit 127: shell "command not found" (e.g. ``gradle`` not installed). + - mise "no tasks defined" / "no task named" / "not found": the configured + (or default ``mise run build``) task does not exist in the repo. + A repo that genuinely fails its build returns some other non-zero code with + real compiler/test output, which this does NOT flag. + """ + if returncode == SHELL_COMMAND_NOT_FOUND: + return True + s = (stderr or "").lower() + return ( + "no tasks defined" in s + or "no task named" in s + or ("mise" in s and "not found" in s) + or "command not found" in s + ) + + +# Exit code for a process killed by SIGKILL (128 + 9) — how the OOM-killer and +# some disk-full kills surface. Paired with the ENOSPC/OOM stderr signatures. +SIGKILL_EXIT = 137 + + +def is_infra_failure(returncode: int, stderr: str) -> bool: + """True when a verify command was killed by an ENVIRONMENT fault, not a real + build failure — the build box ran out of disk or memory. + + Distinct from :func:`is_verify_command_inert` (the command isn't runnable — + a CONFIG problem) and from a genuine red build (command ran, tests failed). + An out-of-disk / OOM kill means the build *couldn't complete on this host*, + so reporting ``build_passed=False`` is a false "your code is broken" — it's + an infrastructure fault a retry (on a fresh host) or more capacity clears. + This was a real failure mode: concurrent builds filled the Fargate root fs → + ``ENOSPC: no space left on device`` mid-build → bogus build-fail. + + A bare SIGKILL (137) with no accompanying signature is ALSO treated as infra: + the container-runtime / cgroup OOM-killer delivers SIGKILL and writes its + "Killed process …" line to the KERNEL log, not the build process's own stderr, + so an OOM'd `mise run build` frequently exits 137 with NO "killed"/"out of + memory" string captured (observed: a post-agent build OOM at 137 fell through + to the inert heuristic and was mislabeled "command not found"; a 137 with + plain build output would fall through to a GENUINE build FAILURE → a false + gate on healthy code). SIGKILL is never something a healthy + `mise run build` does to itself — a real test failure exits with the runner's + own non-zero code (1/2), not 137. So 137 ⇒ resource kill ⇒ infra, and this is + checked BEFORE the inert/genuine-failure paths in ``_run_verify``. + """ + s = (stderr or "").lower() + disk_full = "no space left on device" in s or "enospc" in s or "errno 28" in s + oom = "out of memory" in s or "oomkilled" in s or "cannot allocate memory" in s + # A SIGKILL (137) is a resource/OOM kill by the runtime, not a build result — + # infra regardless of what (if anything) reached the captured stderr. + return disk_full or oom or returncode == SIGKILL_EXIT + + +# Shell metacharacters that mean the command can't be a single argv exec and +# must run through a shell to behave as written. Without this, a configured +# ``npm ci && npm run lint && npm test`` was shlex-split into one ``npm`` call +# with ``&&``/``npm``/… as bogus args — ``npm ci`` ran, ignored the rest, exited +# 0, and the chain's lint/test NEVER ran, so a broken build reported "OK". +_SHELL_OPERATORS = ("&&", "||", "|", ";", ">", "<", "$(", "`") + +# A leading ``VAR=value`` env-assignment prefix (one or more) is shell syntax: +# ``MISE_EXPERIMENTAL=1 mise //cdk:eslint`` only sets the env when run through a +# shell. Exec'd directly (shlex-split), the FIRST token ``MISE_EXPERIMENTAL=1`` +# is treated as the program name → ``FileNotFoundError``. Detect it so such a +# command is routed through ``bash -lc`` like the operator case. NAME must be a +# valid POSIX env identifier so a plain arg like ``a=b`` in a real program's +# args (unusual as a leading token, but be precise) is matched only when it truly +# leads. Without this, a configured ``lint_command`` of ``MISE_EXPERIMENTAL=1 mise +# //cdk:eslint`` crashed the whole task at exit 1 before the build ran. +_ENV_ASSIGN_PREFIX = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") + + +def resolve_verify_argv(command: str | None, default: str) -> list[str]: + """Resolve a configured verify command into an argv for :func:`run_cmd`. + + Empty/whitespace/None ``command`` → the default (mise). A plain command with + args (``npm run build``) is ``shlex``-split and exec'd directly. A command + that needs a shell to behave as written — it contains shell operators (``&&``, + ``|``, ``;``, redirects, command substitution) OR begins with a ``VAR=value`` + env-assignment prefix — is wrapped as ``bash -lc ''``; otherwise the + operators/assignment are passed as literal args to (or AS) the first program + and mis-run (chained build commands would silently no-op; an env-prefixed + lint command would exec ``VAR=value`` as the binary and crash). + """ + cmd = (command or "").strip() or default + needs_shell = any(op in cmd for op in _SHELL_OPERATORS) or bool(_ENV_ASSIGN_PREFIX.match(cmd)) + if needs_shell: + return ["bash", "-lc", cmd] + return shlex.split(cmd) -def verify_lint(repo_dir: str) -> bool: - """Run mise run lint after agent completion to verify lint passes.""" - log("POST", "Running post-agent lint verification (mise run lint)...") +def _run_verify(repo_dir: str, command: str, default: str, label: str) -> VerifyOutcome: + """Run a configured verify command and classify the outcome. + + Returns a :class:`VerifyOutcome` so callers can distinguish a TIMEOUT (the + command exceeded ``BUILD_VERIFY_TIMEOUT_S`` and was killed — the build did + not *fail*, it did not *finish*) from a genuine non-zero exit. Both are + not-passed for gating, but the pipeline surfaces them as different reasons. + """ + argv = resolve_verify_argv(command, default) + log("POST", f"Running post-agent {label} ({' '.join(argv)})...") try: result = run_cmd( - ["mise", "run", "lint"], - label="mise-run-lint-post", + argv, + label=label, cwd=repo_dir, check=False, + timeout=BUILD_VERIFY_TIMEOUT_S, + # Stream the build/lint output live → full log reaches CloudWatch + # verbatim (a buffered summary would hide which sub-task failed). + stream=True, ) except subprocess.TimeoutExpired: - log("WARN", "Post-agent lint timed out — treating as failed") - return False + log( + "WARN", + f"Post-agent {label} TIMED OUT after {BUILD_VERIFY_TIMEOUT_S}s " + "— reporting as timed out (not a build failure)", + ) + return VerifyOutcome(passed=False, timed_out=True) if result.returncode != 0: - log("POST", "Post-agent lint FAILED") - return False - log("POST", "Post-agent lint: OK") - return True + stderr = getattr(result, "stderr", "") or "" + # An ENVIRONMENT fault (out of disk / OOM) means the build couldn't + # complete on this host — NOT that the code is broken. Check this BEFORE + # the inert/genuine-failure paths: it's the most specific signal, and a + # disk-full mid-build otherwise looks like a random non-zero exit and gets + # mis-reported as "build/tests failed" (concurrent builds filling the + # Fargate root fs → ENOSPC → bogus build-fail). Surface as infra so the + # platform reports "retry / needs more capacity", not the agent's code. + if is_infra_failure(result.returncode, stderr): + log( + "WARN", + f"Post-agent {label} was KILLED by an environment fault (exit " + f"{result.returncode}: out of disk/memory) — infrastructure issue, " + "not a build failure", + ) + return VerifyOutcome(passed=False, infra_failed=True) + # Distinguish "couldn't RUN" (exit 127 / no-such-task → the gate is + # inert, a config problem) from "ran and failed" (real red build). An + # inert gate verified nothing, so reporting it as a build FAILURE is a + # false "your code is broken" — surface it as inert instead. + if is_verify_command_inert(result.returncode, stderr): + log( + "WARN", + f"Post-agent {label} could not RUN (exit {result.returncode}) " + "— gate is INERT (command not found / no such task), not a build failure", + ) + return VerifyOutcome(passed=False, inert=True) + log("POST", f"Post-agent {label} FAILED (exit {result.returncode})") + return VerifyOutcome(passed=False) + log("POST", f"Post-agent {label}: OK") + return VerifyOutcome(passed=True) + + +def verify_build(repo_dir: str, command: str = "") -> VerifyOutcome: + """Run the configured build command (default ``mise run build``) to verify the build. + + Returns a :class:`VerifyOutcome` (``.passed`` for gating, ``.timed_out`` to + distinguish "exceeded the time limit" from "ran and failed"). + """ + return _run_verify(repo_dir, command, DEFAULT_BUILD_COMMAND, "verify-build-post") + + +def verify_lint(repo_dir: str, command: str = "") -> VerifyOutcome: + """Run the configured lint command (default ``mise run lint``) to verify lint passes.""" + return _run_verify(repo_dir, command, DEFAULT_LINT_COMMAND, "verify-lint-post") def ensure_committed(repo_dir: str) -> bool: @@ -126,6 +324,97 @@ def ensure_committed(repo_dir: str) -> bool: return False +def _current_branch(repo_dir: str) -> str | None: + """Return the checked-out branch name, or None if detached / git fails.""" + try: + res = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=repo_dir, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + return None + if res.returncode != 0: + return None + name = res.stdout.strip() + # "HEAD" is git's sentinel for a detached HEAD — not a branch name. + return name or None if name != "HEAD" else None + + +def reconcile_agent_branch(repo_dir: str, branch: str) -> bool: + """Make delivery tolerant of the agent switching off the platform-provided + branch. + + The platform checks out ``branch`` (``config.branch_name``, e.g. + ``bgagent//``) BEFORE the agent runs, and every delivery git op + (ensure_committed / ensure_pr's commit-diff / ensure_pushed) is keyed to it. + But the agent doesn't always stay there: it sometimes ran + ``git checkout -b ``, committed + opened its own PR + there, and left the platform branch empty. ensure_pr then saw no commits on + ``branch`` → skipped → the task looked delivered-nothing, and for a stacked + child the successor had no branch to stack on. It is NON-deterministic (the + same task succeeds on a retry when the agent happens to stay put), so a + prompt tweak alone can't be relied on. + + This reconciles deterministically: if HEAD is on a DIFFERENT branch than the + platform ``branch`` and that HEAD carries commits, fast-forward the platform + branch to the agent's HEAD (``git branch -f`` + checkout) so all downstream + delivery runs against the branch the platform tracks. The agent's commits are + preserved verbatim — we only re-point the platform branch label at them. + + Returns True if it moved the platform branch, False otherwise (already on it, + detached HEAD with no branch to adopt, or a git failure — all handled by the + existing ensure_committed/ensure_pr/delivery-gate chain). Best-effort: never + raises, so a reconcile failure degrades to the pre-existing behavior.""" + head_branch = _current_branch(repo_dir) + if head_branch is None or head_branch == branch: + # On the platform branch already (the common, healthy case) or detached + # with nothing to adopt — nothing to reconcile. + return False + log( + "POST", + f"Agent left the platform branch: HEAD is on '{head_branch}', expected " + f"'{branch}'. Reconciling the platform branch to the agent's commits so " + "the work is delivered on the tracked branch.", + ) + try: + # Re-point the platform branch at the agent's HEAD (force: the platform + # branch was created empty at setup, so this only ever fast-forwards it + # to the work the agent actually did). Then check it out so ensure_committed + # / ensure_pr / ensure_pushed all operate on the tracked branch. + force_res = run_cmd( + ["git", "branch", "-f", branch, "HEAD"], + label="reconcile-branch-force", + cwd=repo_dir, + check=False, + ) + if force_res.returncode != 0: + stderr = (force_res.stderr or "").strip()[:200] + log("WARN", f"reconcile: git branch -f failed (exit {force_res.returncode}): {stderr}") + return False + checkout_res = run_cmd( + ["git", "checkout", branch], + label="reconcile-branch-checkout", + cwd=repo_dir, + check=False, + ) + if checkout_res.returncode != 0: + stderr = (checkout_res.stderr or "").strip()[:200] + log( + "WARN", + f"reconcile: checkout '{branch}' failed (exit {checkout_res.returncode}): {stderr}", + ) + return False + except (OSError, subprocess.SubprocessError) as e: + log("WARN", f"reconcile: git op raised {type(e).__name__}: {e}") + return False + log("POST", f"Reconciled: platform branch '{branch}' now points at the agent's work") + return True + + def ensure_pushed(repo_dir: str, branch: str) -> bool: """Push the branch if there are unpushed commits.""" result = subprocess.run( @@ -200,6 +489,79 @@ def _note_unpushed_commits(repo_dir: str, branch: str, config: TaskConfig) -> No log("WARN", f"Failed to post un-pushed-commits note: {type(e).__name__}: {e}") +def _reconcile_pr_base(repo_dir: str, branch: str, config: TaskConfig, expected_base: str) -> None: + """Deterministically retarget an existing PR onto ``expected_base``. + + The PR is created by the AGENT (its own ``gh pr create`` in the prompt + workflow), so the ``--base`` it chose is a model judgment call, not the + orchestrator's. Observed on a stacked-chain stress test: a stacked child + that branched off its predecessor's branch STILL opened its PR against + ``main`` — the agent reasoned "this was based off the predecessor branch, + let me open the PR against main" — and even a root whose + ``detect_default_branch`` correctly returned a non-``main`` default was + pointed at ``main``. Wrong base ⇒ the PR shows the whole default-branch + divergence (100s of files) instead of the child's real delta, and a stacked + child's PR merges onto the wrong branch. + + ``expected_base`` is ``setup.default_branch`` — which is the orchestrator's + ``base_branch`` for a stacked child (the predecessor's branch, or ``main`` + for a diamond) and ``detect_default_branch`` for a root. This post-hook + reads the PR's current base and, if it disagrees, retargets it via + ``gh pr edit --base`` — removing the agent's discretion without forbidding + it from opening the PR (which keeps the agent-authored title/body). + + Best-effort: any failure is logged, never fatal — a mis-based PR is a + presentation/merge-target defect, not a reason to fail the whole task. + """ + try: + view = subprocess.run( + [ + "gh", + "pr", + "view", + branch, + "--repo", + config.repo_url, + "--json", + "baseRefName", + "-q", + ".baseRefName", + ], + cwd=repo_dir, + capture_output=True, + text=True, + timeout=60, + ) + except (OSError, subprocess.SubprocessError) as exc: + log("WARN", f"Could not read PR base to reconcile ({type(exc).__name__}) — leaving as-is") + return + if view.returncode != 0 or not view.stdout.strip(): + # No PR / unreadable base — nothing to reconcile (creation path handles + # the no-PR case; a transient read error just leaves the base as-is). + return + current_base = view.stdout.strip() + if current_base == expected_base: + return + log( + "POST", + f"Retargeting PR base '{current_base}' → '{expected_base}' " + f"(deterministic; agent chose the wrong base)", + ) + result = run_cmd( + ["gh", "pr", "edit", branch, "--repo", config.repo_url, "--base", expected_base], + label="reconcile-pr-base", + cwd=repo_dir, + check=False, + ) + if result.returncode != 0: + stderr_msg = result.stderr.strip()[:200] if result.stderr else "(no stderr)" + log( + "WARN", + f"Failed to retarget PR base to '{expected_base}' (gh exit {result.returncode}): " + f"{stderr_msg} — PR remains based on '{current_base}'", + ) + + def ensure_pr( config: TaskConfig, setup: RepoSetup, @@ -211,7 +573,7 @@ def ensure_pr( """Realize the PR per the workflow's ``ensure_pr`` strategy. Strategy (provider-neutral, from the workflow step — replaces the former - ``task_type`` self-inspection, #248): + ``task_type`` self-inspection): - ``create``: create a new PR if one doesn't exist (the new_task path). - ``push_resolve``: push follow-up commits, then resolve the existing PR URL @@ -293,6 +655,10 @@ def ensure_pr( if result.returncode == 0 and result.stdout.strip(): pr_url = result.stdout.strip() log("POST", f"PR already exists: {pr_url}") + # The agent opened this PR and picked its own --base; correct it to the + # orchestrator-supplied / detected base if it disagrees (stacked-child + # + root wrong-base fix). + _reconcile_pr_base(repo_dir, branch, config, default_branch) return pr_url # Check if there are any commits on this branch beyond the default branch @@ -343,19 +709,35 @@ def ensure_pr( build_status = "PASS" if build_passed else "FAIL" lint_status = "PASS" if lint_passed else "FAIL" + # Show the actual commands run (default mise), not a hardcoded label. + build_label = (config.build_command or DEFAULT_BUILD_COMMAND).strip() + lint_label = (config.lint_command or DEFAULT_LINT_COMMAND).strip() cost_line = "" if agent_result and agent_result.cost_usd is not None: cost_line = f"- Agent cost: **${agent_result.cost_usd:.4f}**\n" + # When build-regression gating is inert (no runnable build command, none + # configured), say so plainly — otherwise a green "build: PASS" misleads: + # nothing was actually verified. + gate_warning = "" + if getattr(setup, "build_gate_inert", False): + gate_warning = ( + "> ⚠️ **Build-regression gating is OFF for this repo.** No runnable " + f"`{DEFAULT_BUILD_COMMAND}` task was found and no build command is configured, " + "so a change that breaks the build still reports success. To enable gating, set " + "`pipeline.buildCommand` in this repo's ABCA blueprint (e.g. `npm run build`).\n\n" + ) + pr_body = ( f"## Summary\n\n" f"{task_source}" f"### Commits\n\n" f"```\n{commits}\n```\n\n" f"## Verification\n\n" - f"- `mise run build` (post-agent): **{build_status}**\n" - f"- `mise run lint` (post-agent): **{lint_status}**\n" + f"{gate_warning}" + f"- `{build_label}` (post-agent): **{build_status}**\n" + f"- `{lint_label}` (post-agent): **{lint_status}**\n" f"{cost_line}\n" f"---\n\n" f"By submitting this pull request, I confirm that you can use, modify, copy, " diff --git a/agent/src/prompt_builder.py b/agent/src/prompt_builder.py index 6a262cbba..3582575d6 100644 --- a/agent/src/prompt_builder.py +++ b/agent/src/prompt_builder.py @@ -6,7 +6,7 @@ import os from typing import TYPE_CHECKING -from config import AGENT_WORKSPACE +from config import AGENT_WORKSPACE, NEEDS_INPUT_MARKER from prompts import get_system_prompt from sanitization import sanitize_external_content as sanitize_memory_content from shell import log @@ -30,6 +30,10 @@ def build_system_prompt( system_prompt = system_prompt.replace("{branch_name}", setup.branch) system_prompt = system_prompt.replace("{default_branch}", setup.default_branch) system_prompt = system_prompt.replace("{max_turns}", str(config.max_turns)) + # Clarify-before-spend (UX #4): the new_task workflow references this marker + # in its "ask instead of guess" branch. Harmless no-op for prompts that don't + # contain the placeholder. + system_prompt = system_prompt.replace("{needs_input_marker}", NEEDS_INPUT_MARKER) setup_notes = ( "\n".join(f"- {n}" for n in setup.notes) if setup.notes @@ -132,9 +136,21 @@ def _render_memory_context(hydrated_context: HydratedContext | None) -> str: def _channel_prompt_addendum(config: TaskConfig) -> str: """Return channel-specific prompt guidance, or empty string. - For Linear-origin tasks, instruct the agent to post progress comments and - transition state using the already-loaded Linear MCP tools. The tool names - are stated explicitly so the agent doesn't grope for them. + Linear-origin tasks (ADR-016 "Linear is fully deterministic"): the agent has + NO Linear MCP and NO Linear write access. All Linear I/O is handled by the + platform, not the agent: + * inbound context — the issue title/description, recent human comments, + project wiki-document CONTENT, and attachments are ALREADY pre-hydrated + into the task description + ``attachments`` at admission time + (linear-webhook-processor + linear-attachments.ts + + linear-feedback.fetchRecentComments + linear-issue-context-probe's doc + fetch). There is nothing to fetch at runtime. + * outbound status — 👀/✅/❌ reactions and Backlog→In Progress→In Review + state transitions are posted deterministically by ``linear_reactions.py``; + the "🤖 Starting" and PR-opened comments are posted at the Lambda tier; + the terminal ✅/⚠️/❌ summary (cost/turns/PR link) is posted by the + fan-out plane. So the addendum's whole job now is to tell the agent to + do the code work and NOT attempt any Linear calls. Jira-origin tasks intentionally get NO addendum: Atlassian's Remote MCP requires an interactive OAuth flow a headless agent can't complete, so the @@ -145,36 +161,39 @@ def _channel_prompt_addendum(config: TaskConfig) -> str: """ if config.channel_source != "linear": return "" + # A synthetic orchestration integration node (#247) has NO real Linear + # sub-issue — `linear_issue_id` is intentionally omitted from its + # channel_metadata (see orchestration-release.ts). Without a target issue + # there is nothing issue-specific to say; the parent panel is the surface. + if not config.channel_metadata.get("linear_issue_id"): + return "" issue_identifier = config.channel_metadata.get("linear_issue_identifier") or "" issue_ref = f" (`{issue_identifier}`)" if issue_identifier else "" + return ( - "\n\n## Linear issue progress updates (REQUIRED)\n\n" - f"This task was submitted from Linear issue{issue_ref}. The Linear MCP " - "server is loaded. You MUST perform these updates; they are part of " - "the task contract, not optional:\n\n" - "1. **At start** — call `mcp__linear-server__save_comment` with a short " - '"🤖 Starting on this issue…" message, then call ' - "`mcp__linear-server__save_issue` to transition the issue state. Use " - "`mcp__linear-server__list_issue_statuses` first if you don't already " - "know the state ids; pick the one named `In Progress` (fall back to " - "`Todo` if that state doesn't exist). If the issue is already in " - "`In Progress` or any later state (`In Review`, `Done`), skip the " - "transition. If neither exists, skip — the comment alone is enough. " - "Do not invent state names or loop on `list_issue_statuses`.\n" - "2. **When you open the PR** — call `mcp__linear-server__save_comment` " - "with the PR URL, then call `mcp__linear-server__save_issue` to " - "transition the issue state to `In Review` (fall back to `In Progress` " - "if that state doesn't exist). If neither exists, skip the state " - "transition — the PR comment alone is enough. Do not invent state " - "names or loop on `list_issue_statuses`.\n\n" - "**Do NOT post a final 'task completed' or 'task failed' comment.** " - "The platform fan-out plane (issue #239) posts a structured " - "✅/⚠️/❌ summary on terminal events with cost / turns / duration / " - "PR-link metrics that you don't have visibility into. A redundant " - "agent-side completion comment would just stack two near-identical " - "comments on the issue.\n\n" - "Keep the start + PR-opened comments concise. Do not mirror the full " - "agent transcript back to Linear." + "\n\n## Linear issue\n\n" + f"This task was submitted from Linear issue{issue_ref}. The platform " + "manages ALL Linear interaction for you — you have no Linear tools and " + "must not try to call any:\n\n" + "- **Context is already here.** The issue title, description, recent " + "human comments, the reporter's uploaded files (inline images and " + "paperclip attachments), and the content of any project wiki documents " + "have been pre-fetched and included in your task description (see the " + "`## Project documents` and `## Recent comments` sections if present) + " + "attachments. You have no way to fetch more from Linear, so work from " + "what you've been given. If the task clearly references material that " + "ISN'T in your context (an external link, a file you can't see, or a doc " + "noted as present-but-not-included), don't guess — say so in the PR and " + "proceed with best effort on what you have.\n" + "- **Status is automatic.** The platform posts the issue reactions " + "(👀 on start, ✅/❌ on finish), moves the issue through its workflow " + "states (In Progress → In Review), posts the start + PR-opened comments, " + "and posts the final ✅/⚠️/❌ summary with cost/PR-link metrics. Do NOT " + "post Linear comments or change the issue state yourself — you'd only " + "duplicate the platform's messages.\n\n" + "Just do the code work: make the change, open the PR, and let the " + "platform narrate it. Reference issues/PRs in your GitHub PR description " + "as usual.\n" ) diff --git a/agent/src/prompts/__init__.py b/agent/src/prompts/__init__.py index 60c5b2c03..c4b6e2621 100644 --- a/agent/src/prompts/__init__.py +++ b/agent/src/prompts/__init__.py @@ -1,9 +1,9 @@ """Prompt module — selects the system prompt template by resolved workflow id. -In Phases 1-3 the runner uses these built-in prompt modules (the workflow file's -``prompt.template: registry://...`` is resolved by the registry only in Phase 4, -per WORKFLOWS.md). The lookup is keyed by workflow id; ``DEFAULT_WORKFLOW_ID`` is -the fallback for an unknown/absent id. +Today the runner uses these built-in prompt modules. A planned later stage will +instead resolve a workflow file's ``prompt.template: registry://...`` through a +prompt registry (see WORKFLOWS.md). The lookup is keyed by workflow id; +``DEFAULT_WORKFLOW_ID`` is the fallback for an unknown/absent id. """ from shell import log @@ -13,23 +13,27 @@ from .new_task import NEW_TASK_WORKFLOW from .pr_iteration import PR_ITERATION_WORKFLOW from .pr_review import PR_REVIEW_WORKFLOW +from .restack import RESTACK_WORKFLOW from .web_research import WEB_RESEARCH_PROMPT DEFAULT_WORKFLOW_ID = "coding/new-task-v1" -# The fallback template for a repo-less id without its own registered prompt -# (e.g. knowledge/web-research-v1 until its prompt ships in the #246 registry). -# Falling back to the *coding* default would leak {repo_url}/{branch_name} -# placeholders the repo-less prompt builder cannot substitute. +# The fallback template for a repo-less workflow id that has no registered prompt +# of its own. Falling back to the *coding* default would leak +# {repo_url}/{branch_name} placeholders the repo-less prompt builder cannot +# substitute, so repo-less ids fall back to a repo-less prompt instead. REPO_LESS_DEFAULT_WORKFLOW_ID = "default/agent-v1" _PROMPTS = { "coding/new-task-v1": BASE_PROMPT.replace("{workflow}", NEW_TASK_WORKFLOW), "coding/pr-iteration-v1": BASE_PROMPT.replace("{workflow}", PR_ITERATION_WORKFLOW), "coding/pr-review-v1": BASE_PROMPT.replace("{workflow}", PR_REVIEW_WORKFLOW), - # Repo-less knowledge workflow (#248 Phase 3) — no git/branch/PR placeholders. + # Re-stack: re-merge a changed predecessor branch into an existing + # stacked-child branch. Pushes to the existing PR; not new work. + "coding/restack-v1": BASE_PROMPT.replace("{workflow}", RESTACK_WORKFLOW), + # Repo-less knowledge workflow — no git/branch/PR placeholders. "default/agent-v1": DEFAULT_AGENT_PROMPT, - # Repo-less reference knowledge workflow (#248) — research-specialized prompt - # so it no longer silently degrades to the generic default-agent prompt. + # Repo-less reference knowledge workflow — a research-specialized prompt so it + # no longer silently degrades to the generic default-agent prompt. "knowledge/web-research-v1": WEB_RESEARCH_PROMPT, } @@ -37,17 +41,16 @@ def get_system_prompt(workflow_id: str = DEFAULT_WORKFLOW_ID, *, repo_less: bool = False) -> str: """Return the system prompt template for the given resolved workflow id. - Falls back to a built-in template for an id without its own (e.g. a - registry-only workflow in Phase 4). The fallback is the **repo-less** - default-agent prompt when ``repo_less`` is set (so a knowledge workflow never - inherits the coding prompt's git/branch/PR placeholders), else the coding - default. + Falls back to a built-in template for an id that has no registered prompt of + its own. The fallback is the **repo-less** default-agent prompt when + ``repo_less`` is set (so a knowledge workflow never inherits the coding + prompt's git/branch/PR placeholders), else the coding default. """ fallback = REPO_LESS_DEFAULT_WORKFLOW_ID if repo_less else DEFAULT_WORKFLOW_ID if workflow_id not in _PROMPTS: - # No registered prompt for this id (typo, or a registry-only workflow - # whose prompt ships in Phase 4). Surface it: silently substituting a - # generic prompt degrades agent behavior with zero signal otherwise. + # No registered prompt for this id (a typo, or a workflow whose prompt + # has not shipped yet). Surface it: silently substituting a generic + # prompt degrades agent behavior with zero signal otherwise. log( "WARN", f"no registered system prompt for workflow {workflow_id!r}; " diff --git a/agent/src/prompts/base.py b/agent/src/prompts/base.py index c831d00af..be6c81a58 100644 --- a/agent/src/prompts/base.py +++ b/agent/src/prompts/base.py @@ -19,7 +19,13 @@ ## Environment - You are running inside an isolated container with shell access. -- The repository `{repo_url}` is already cloned at `{workspace}/{task_id}`. +- The repository `{repo_url}` is already cloned and checked out at \ +`{workspace}/{task_id}`, which is your current working directory. **Work there \ +— do NOT run `git clone` or `gh repo clone` to fetch it again, and do NOT `cd` \ +to a different copy.** The platform tracks THIS directory to open your pull \ +request; commits made in any other clone are invisible to it and will be \ +discarded as lost work. If you ever find yourself outside `{workspace}/{task_id}`, \ +`cd` back to it before committing. - You are on branch `{branch_name}`. - The repository default branch is `{default_branch}`. - Git is configured and authenticated — `git push` works without extra setup. diff --git a/agent/src/prompts/new_task.py b/agent/src/prompts/new_task.py index 2496fd844..ac4bc6675 100644 --- a/agent/src/prompts/new_task.py +++ b/agent/src/prompts/new_task.py @@ -9,11 +9,50 @@ Read relevant files, check the project structure, look at existing tests, \ build scripts, and CI configuration. Understand the project before changing it. -2. **Work on the task** - Make the necessary code changes. Be thorough but focused — only change what \ -the task requires. Do not refactor unrelated code. +2. **Decide: can you act on this safely, or do you need to ask first?** + Before writing any code, judge whether the request tells you WHAT to change \ +and WHAT "done" looks like. Most tasks do — proceed. But some requests name a \ +GOAL without saying what to actually do, so any PR would be a guess at the \ +requester's intent. You MUST ask instead of guessing when the request is a bare \ +quality/direction adjective with no concrete target, metric, scope, or named \ +problem, e.g.: + - "make it faster" / "improve performance" — no page/flow named, no metric \ +or target (which part is slow? by how much? what's the budget?) + - "make it better" / "improve the UI" / "clean it up" — no direction + - "make the site nicer" / "it feels a bit plain" / "make it pop" / "more \ +modern" — a whole-site or whole-page aesthetic verdict is NOT a concrete target: \ +no page or element is named and "nicer"/"plain" doesn't say what to change. An \ +adjective describing how something FEELS is a direction-without-substance, not a \ +named problem — ask which page/section and what "nicer" means to them (colours? \ +spacing? imagery? animation?) rather than picking a redesign and shipping it. + - "fix the bug" — no reproduction, no error, and none findable in the code + In these cases do NOT pick a plausible interpretation and ship it (even a \ +"safe, universally-good" change is still a guess at what they wanted, and they \ +get charged for it). Instead, **call the `request_clarification` tool** with ONE \ +short, specific question that names exactly what you need and offers concrete \ +options (e.g. "Which feels slow — initial page load, navigation, or images? And \ +is there a target, like under 1s?"). Calling that tool opens NO pull request and \ +charges nothing for a guess — the platform posts your question to the requester \ +and ends the task. After calling it, STOP: do not commit, do not run the build, \ +do not open a PR. (If the `request_clarification` tool is not available, instead \ +make your FINAL message the question, prefixed on its own first line with the \ +exact marker `{needs_input_marker}`.) + - This is ONLY for goal-without-substance requests. A request that names \ +what to change (even loosely) is actionable — make the reasonable call on \ +low-stakes details and note it in the PR (step 5). When you can name a specific, \ +concrete, low-risk deliverable that unambiguously satisfies the request, do it; \ +when you'd be picking among materially different interpretations, ask. -3. **Test your changes** +3. **Work on the task** + Make the necessary code changes. Be thorough but focused — implement exactly \ +what the task asks for. Do NOT add features, endpoints, buttons, or behavior \ +that weren't requested, and do NOT refactor unrelated code. If, while working, \ +you find the task implies something surprising or much larger than it first \ +appeared (e.g. a one-word request that would touch many files), do the \ +smallest faithful interpretation and call out the surprising scope in the PR \ +description rather than silently building it all. + +4. **Test your changes** This step is MANDATORY — do NOT skip it. - Run the project build: `mise run build` - Run linters and type-checkers if available. @@ -22,7 +61,7 @@ check, dry-run) and note this in the PR. - Report test and build results in the PR description — both passes and failures. -4. **Commit and push frequently** +5. **Commit and push frequently** After each logical unit of work, commit and push: ``` git add @@ -35,16 +74,23 @@ Do NOT accumulate large uncommitted changes — pushing frequently is your \ durability mechanism. -5. **Create a Pull Request** +6. **Create a Pull Request** When the work is complete (or after exhausting attempts), you MUST create a PR. \ -Do NOT skip this step or tell the user to do it manually. +Do NOT skip this step or tell the user to do it manually. (The one exception is \ +the clarify-and-hold case in step 2 — if you asked a clarifying question and \ +made no changes, do NOT open a PR.) The PR body must include a section titled "## Agent notes" with: - What went well and what was difficult - Any patterns or conventions you discovered about this repo - Suggestions for future tasks on this repo - Run: + Run this EXACTLY — the `--base` value is chosen for you (for a stacked \ +sub-issue it is the predecessor's branch so the PR shows only your delta; for \ +a normal task it is the repo default). Do NOT substitute a different base such \ +as `main`/`master` even if this branch was cut from another branch — targeting \ +the wrong base makes the PR show the whole branch divergence instead of your \ +change: ``` gh pr create --repo {repo_url} --head {branch_name} --base {default_branch} --title "(): " --body "" ``` diff --git a/agent/src/prompts/pr_iteration.py b/agent/src/prompts/pr_iteration.py index 8289b2c5f..83ae04c07 100644 --- a/agent/src/prompts/pr_iteration.py +++ b/agent/src/prompts/pr_iteration.py @@ -3,10 +3,29 @@ PR_ITERATION_WORKFLOW = """\ ## Workflow -You are iterating on an existing pull request (PR #{pr_number}). Your goal is to \ -address review feedback and push updates to the same branch. +You are responding to a comment on an existing pull request (PR #{pr_number}). -Follow these steps in order: +**First, decide what the comment is asking for:** + +- **A QUESTION or request for information** (e.g. "where is the login page?", \ +"why did you use JWT?", "does this handle logout?"). ANSWER it directly and \ +concisely from the code/PR. Do NOT invent a code change to justify a commit — \ +if no change is actually needed, make none. Post your answer as a PR comment \ +(`gh pr comment {pr_number} --repo {repo_url} --body ""`) and STOP. Your \ +final message should BE the answer (it is surfaced back to the requester). Do not \ +push an empty or cosmetic commit just to have "done something". + +- **A CHANGE REQUEST** (e.g. "rename this", "add validation", "fix the bug", \ +"make the header blue"). Address it by editing code and pushing to the branch, \ +following the steps below. + +If genuinely ambiguous whether it's a question or a change, treat it as a \ +question and ask for clarification rather than guessing at a change. + +--- + +For a CHANGE REQUEST, your goal is to address the feedback and push updates to \ +the same branch. Follow these steps in order: 1. **Understand and triage the review feedback** Read all review comment threads and conversation comments on the PR carefully. \ diff --git a/agent/src/prompts/restack.py b/agent/src/prompts/restack.py new file mode 100644 index 000000000..0f44e64b7 --- /dev/null +++ b/agent/src/prompts/restack.py @@ -0,0 +1,59 @@ +"""Workflow section for restack — re-merge a changed predecessor. + +A stacked child's predecessor PR was edited after the child already merged the +predecessor's code in, so the child is stale. The platform re-runs the child on +its EXISTING branch with the updated predecessor branch(es) merged into the +working tree before the agent starts (the same merge mechanism used when a child +is first built on top of its predecessors). The agent's job is narrow: +reconcile, verify, push to the same branch — NOT new feature work. +""" + +RESTACK_WORKFLOW = """\ +## Workflow + +You are RE-STACKING an existing pull request branch (`{branch_name}`). A +predecessor branch this work was built on has changed, and its updated code has +already been merged into your working tree before you started. Your only job is +to reconcile that update — do NOT add features or change scope. + +Follow these steps in order: + +1. **Assess the merged-in predecessor changes** + The setup notes above record which predecessor branch(es) were merged in and + whether the merge was clean or left conflicts. Read them first. + - If a merge was aborted due to conflicts, the predecessor branch is fetched + as `origin/`; merge it now and resolve the conflicts so your + branch contains both your original work AND the updated predecessor code. + - If the merge was clean, just verify your original changes still apply on top + of the updated predecessor code (the predecessor may have moved code you + depended on). + +2. **Reconcile — keep BOTH sides** + The goal is a branch that has your sub-issue's changes correctly layered on + the predecessor's NEW code. Do not drop your work, and do not revert the + predecessor's update. Resolve conflicts by integrating both intents. + +3. **Test your changes (MANDATORY)** + - Run the project build: `mise run build` + - Run linters/type-checkers if available. + - Run tests if the project has them (`npm test`, `pytest`, `make test`). + - The combined result must build — a re-stack that doesn't build is worse + than the stale state it replaced. + +4. **Commit and push to `{branch_name}` (the SAME branch — do not create a new one)** + ``` + git add + git commit -m "chore(restack): re-merge updated predecessor into {branch_name}" + git push origin {branch_name} + ``` + Pushing to the existing branch updates the existing PR in place — the + platform does NOT open a new PR for a re-stack. + +5. **Post a brief summary comment on the PR** + ``` + gh pr comment {pr_number} --repo {repo_url} --body "" + ``` + Note which predecessor change was absorbed, any conflicts resolved, and the + build/test result. Keep it concise — this is a maintenance update, not a new + review.\ +""" diff --git a/agent/src/repo.py b/agent/src/repo.py index 59929556d..19f2aa7ec 100644 --- a/agent/src/repo.py +++ b/agent/src/repo.py @@ -8,11 +8,31 @@ from models import RepoSetup, TaskConfig from shell import log, run_cmd, run_cmd_with_backoff, slugify +# Directories never worth scanning for nested mise configs (huge, and any +# ``mise.toml`` inside a dependency tree is not ours to trust). Bounds the walk +# on a large clone so the trust step stays fast. +_MISE_CONFIG_SKIP_DIRS = frozenset({".git", "node_modules", ".venv", "cdk.out", "dist", "build"}) + + +def _find_mise_configs(repo_dir: str) -> list[str]: + """Return every ``mise.toml`` under *repo_dir* EXCEPT the root one (already + trusted by ``mise trust ``), skipping vendored/build dirs. + + A monorepo has per-package config roots (``cdk/mise.toml`` etc.); each must + be trusted or ``mise run `` fanning into it fails at the trust gate. + """ + configs: list[str] = [] + for dirpath, dirnames, filenames in os.walk(repo_dir): + dirnames[:] = [d for d in dirnames if d not in _MISE_CONFIG_SKIP_DIRS] + if "mise.toml" in filenames and os.path.abspath(dirpath) != os.path.abspath(repo_dir): + configs.append(os.path.join(dirpath, "mise.toml")) + return configs + def _clone_backoff_reporter(progress: Any, label: str): """Build an ``on_retry`` callback that emits a ``dependency_unreachable`` - blocker event per transient retry (#251, Phase 2) — auditable in the live - stream + 90d record. Returns ``None`` when no progress writer is wired so + blocker event per transient retry — auditable in the live stream and the + retained event record. Returns ``None`` when no progress writer is wired so ``run_cmd_with_backoff`` simply logs to CMD.""" if progress is None: return None @@ -35,10 +55,11 @@ def _on_retry(attempt: int, max_attempts: int, stderr: str) -> None: class DependencyUnreachableError(RuntimeError): - """Raised when repo setup cannot reach a dependency after bounded retries - (#251, Phase 2). Its message is the canonical ``BLOCKED[dependency_unreachable]`` - reason so the crash path carries it into the terminal ``error`` verbatim and - the CDK classifier attaches a precise remedy.""" + """Raised when repo setup cannot reach a dependency after bounded retries. + + Its message is the canonical ``BLOCKED[dependency_unreachable]`` reason so the + crash path carries it into the terminal ``error`` verbatim and the platform's + error classifier attaches a precise remedy.""" def _fail_setup_command(label: str, resource: str, stderr: str, progress: Any) -> None: @@ -56,7 +77,7 @@ def _fail_setup_command(label: str, resource: str, stderr: str, progress: Any) - 2. **Transient** — a DNS/registry blip that survived the retries with no nameable host: report a retryable ``dependency_unreachable`` blocker. 3. **Permanent** — repo not found, auth denied: re-raise a plain - ``RuntimeError`` carrying the redacted git stderr, preserving the pre-#251 + ``RuntimeError`` carrying the redacted git stderr, preserving the original ``check=True`` behavior so the classifier routes it to the right (auth / not-found) remedy rather than mislabeling it retryable. @@ -92,7 +113,7 @@ def _fail_setup_command(label: str, resource: str, stderr: str, progress: Any) - if not is_transient_cmd_failure(stderr): # (3) Permanent. Redact before raising — this message is persisted to - # TaskResult.error (DynamoDB, `bgagent status`). The pre-#251 check=True + # TaskResult.error (DynamoDB, `bgagent status`). The original check=True # path redacted here (shell.py run_cmd); preserve that so a credential in # git stderr never lands in cleartext. snippet = redact_secrets((stderr or "").strip()[:500]) @@ -121,6 +142,49 @@ def _fail_setup_command(label: str, resource: str, stderr: str, progress: Any) - ) +def _prepare_clone_dir(repo_dir: str, notes: list[str]) -> None: + """Guarantee a clean slate at *repo_dir* before cloning. + + On persistent session storage the workspace path (``/workspace/``) + can carry residue from a prior run/task sharing the id or mount. If repo_dir + already exists and is NON-EMPTY, ``gh repo clone `` exits 128 + ("directory not empty") WITHOUT nesting — but the agent, whose cwd IS + repo_dir, then works against whatever stale tree is there (or re-clones into a + subdir), while every pipeline git op (ensure_committed / ensure_pr / + ensure_pushed) runs against repo_dir's ROOT. Those trees diverge silently: + the agent's edits/commits land in the inner tree, the outer tree stays clean, + ensure_committed sees nothing to commit, ensure_pr finds no commits, and the + task reports a false COMPLETED with the work lost. Removing the residue so the + clone lands DIRECTLY in an empty repo_dir closes that divergence at the + source; :func:`_assert_clone_root` is the post-clone backstop.""" + if os.path.exists(repo_dir) and os.listdir(repo_dir): + log( + "SETUP", + f"Workspace {repo_dir} is non-empty before clone (persistent-storage " + "residue) — clearing it so the clone lands at the root, not nested", + ) + notes.append("cleared pre-existing workspace residue before clone") + import shutil + + shutil.rmtree(repo_dir, ignore_errors=True) + + +def _assert_clone_root(repo_dir: str) -> None: + """Backstop: assert the clone produced a git root AT *repo_dir*. + + If ``.git`` is missing here the working tree the agent will edit (cwd=repo_dir) + is NOT a checkout the pipeline's git ops can see — every commit/PR step would + silently no-op and the task would report a false success with the work lost. + Fail loudly at setup (a plain RuntimeError → terminal FAILED with a clear + reason) instead of after the agent has burned a run.""" + if not os.path.isdir(os.path.join(repo_dir, ".git")): + raise RuntimeError( + "clone did not produce a git repository at the workspace root " + f"({repo_dir}/.git missing after clone) — the agent's working tree " + "would not be the tracked checkout; refusing to run so no work is lost" + ) + + def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: """Clone repo, create branch, configure git auth, run mise install. @@ -128,16 +192,28 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: lint_before, and default_branch. ``progress`` is optional (preserves legacy/test call shape). When present, - transient clone/fetch retries emit ``dependency_unreachable`` blocker - events (#251, Phase 2). + transient clone/fetch retries emit ``dependency_unreachable`` blocker events. """ repo_dir = f"{AGENT_WORKSPACE}/{config.task_id}" notes: list[str] = [] - if config.is_pr_workflow and config.branch_name: + # Always use the platform-provided branch name verbatim when present. + # The platform computes branch_name (gateway.ts generateBranchName/slugify) + # and persists it on the TaskRecord AND, for stacked children, as the + # predecessor's child_branch_name that the reconciler hands to the next + # child as its base. If the agent re-derives the slug here it produces a + # DIFFERENT string (shell.py slugify strips dots vs gateway's dash, and + # truncates at 40 vs 50) — e.g. ``...guide.html`` → agent ``guidehtml`` vs + # platform ``guide-html``. That divergence means a stacked child's + # ``git fetch origin `` 404s and it silently falls back + # to branching off main (breaking the stack). Use config.branch_name as-is. + if config.branch_name: branch = config.branch_name else: - # Derive branch slug from issue title or task description + # Fallback only when the platform supplied no branch (older callers / + # direct invocations). Derive a slug from the issue title or task + # description. NOTE: this path's slug may differ from the platform's; + # it exists for resilience, not for the orchestrated/standard flow. title = "" if config.issue: title = config.issue.title @@ -146,6 +222,9 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: slug = slugify(title) branch = f"bgagent/{config.task_id}/{slug}" + # Guarantee a clean slate at repo_dir BEFORE cloning. + _prepare_clone_dir(repo_dir, notes) + # Mark the repo directory as safe for git. On persistent session storage # the mount may be owned by a different UID than the container user, # triggering git's "dubious ownership" check on clone/resume. @@ -154,7 +233,7 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: label="safe-directory", ) - # Clone — bounded retry on transient network/registry failures (#251). + # Clone — bounded retry on transient network/registry failures. log("SETUP", f"Cloning {config.repo_url}...") clone_result = run_cmd_with_backoff( ["gh", "repo", "clone", config.repo_url, repo_dir], @@ -164,6 +243,9 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: if clone_result.returncode != 0: _fail_setup_command("clone", config.repo_url, clone_result.stderr, progress) + # Backstop: assert the clone produced a git root AT repo_dir. + _assert_clone_root(repo_dir) + # Pin the remote to the plain https URL (no embedded credentials) and # authenticate git push via gh's credential helper. Embedding the token # in the remote URL would persist it in .git/config inside the workspace @@ -190,6 +272,19 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: ) # Branch setup + head_sha_before = "" + # For a DIAMOND child (2+ predecessors → base_branch set AND merge_branches + # non-empty), the orchestrator hardcodes the literal 'main' as base_branch + # (it has no server-side record of the repo's real default branch). On a repo + # whose real default is NOT main, branching a diamond off 'main' makes its PR + # target main and show the whole default↔main divergence (tens of thousands + # of lines) instead of the child's small delta. Resolve the REAL default here + # (the same detect_default_branch the root path already uses reliably) and use + # it for BOTH the checkout base and the PR base (setup.default_branch below), + # so the diamond stacks on the real default and its small delta is reviewable. + # Only a diamond overrides; a LINEAR child's base_branch is its predecessor + # branch (correct — the PR stacks on it) and must be left untouched. + resolved_diamond_base: str | None = None if config.is_pr_workflow and config.branch_name: log("SETUP", f"Checking out existing PR branch: {branch}") fetch_result = run_cmd_with_backoff( @@ -205,17 +300,102 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: label="checkout-pr-branch", cwd=repo_dir, ) + # Snapshot the branch HEAD BEFORE the agent runs. The post-hooks compare + # the final HEAD to this to tell a real edit (HEAD advanced) from a + # question-only iteration (HEAD unchanged → no commit), so the platform + # reports "answered / no change" rather than a false "✅ Updated". Capture + # AFTER any predecessor merges would advance HEAD — but pr_iteration / + # pr_review pass no merge_branches, so the checkout HEAD is the baseline. + # (Restack DOES merge predecessors and isn't a comment-iteration, so its + # HEAD-advance is expected and never reaches the no-op reply path.) + sha_res = run_cmd( + ["git", "rev-parse", "HEAD"], + label="head-sha-before", + cwd=repo_dir, + check=False, + ) + if sha_res.returncode == 0: + head_sha_before = sha_res.stdout.strip() + # Re-stack: a predecessor branch changed; merge its UPDATED code into + # this existing PR branch so the child is no longer stale. + # (pr_iteration / pr_review pass no merge_branches, so this is a no-op + # for them — only the restack path threads predecessors here.) + for pred_branch in config.merge_branches: + _merge_predecessor_branch(repo_dir, pred_branch, notes) + elif config.base_branch: + # Stacked child. Branch from the predecessor's branch (linear) or from + # the repo default (diamond) so the child sees predecessor code without + # waiting for a human merge. fetch the base first — it is an unmerged + # sibling branch that the fresh clone may not have locally. + base_branch = config.base_branch + # A diamond rewrites the orchestrator's 'main' literal to the real + # default before fetch/checkout (see the block comment above); a linear + # child (no merge_branches) keeps its predecessor base untouched. + if config.merge_branches: + detected = detect_default_branch(config.repo_url, repo_dir) + if detected != base_branch: + log( + "SETUP", + f"Diamond base: server passed '{base_branch}', using detected " + f"repo default '{detected}' instead (F1 wrong-base guard)", + ) + base_branch = detected + resolved_diamond_base = base_branch + log("SETUP", f"Creating branch {branch} from base {base_branch}") + fetch_res = run_cmd( + ["git", "fetch", "origin", base_branch], + label="fetch-base-branch", + cwd=repo_dir, + check=False, + ) + if fetch_res.returncode == 0: + run_cmd( + ["git", "checkout", "-b", branch, f"origin/{base_branch}"], + label="create-branch-from-base", + cwd=repo_dir, + ) + else: + # Base branch not found on origin (e.g. predecessor PR already + # merged + branch deleted, or a transient fetch error). Fall + # back to a normal branch off the current HEAD so the child + # still runs rather than failing setup; the predecessor's code + # is likely in the default branch by now anyway. + notes.append(f"base branch '{base_branch}' not fetchable; branched off default instead") + log("SETUP", f"Base branch not found; creating {branch} off HEAD") + run_cmd(["git", "checkout", "-b", branch], label="create-branch", cwd=repo_dir) + + # Diamond: merge each predecessor branch into this child's branch + # so it sees ALL predecessors' code (the base only gave it one). + for pred_branch in config.merge_branches: + _merge_predecessor_branch(repo_dir, pred_branch, notes) else: log("SETUP", f"Creating branch: {branch}") run_cmd(["git", "checkout", "-b", branch], label="create-branch", cwd=repo_dir) - # Trust mise config files in the cloned repo (required before mise install) + # Trust mise config files in the cloned repo (required before mise install + # AND before every `mise run `). ``mise trust `` trusts only the + # ROOT ``mise.toml`` — but a monorepo has per-package config ROOTS + # (``cdk/mise.toml``, ``cli/mise.toml``, ``agent/mise.toml``, ``docs/mise.toml`` + # here). When ``mise run build`` fans out into ``//cdk:eslint`` etc. it loads + # the nested config, which is UNtrusted → ``mise ERROR Config files … are not + # trusted`` → exit 1, and the whole build/lint gate dies in seconds BEFORE + # anything compiles. (`mise trust --all` only covers cwd + PARENTS, not + # children, so it doesn't help.) Trust every ``mise.toml`` in the clone. + # Without this, fresh-clone builds failed the baseline at the trust gate, + # indistinguishable in the log from a red build. run_cmd( ["mise", "trust", repo_dir], label="mise-trust", cwd=repo_dir, check=False, ) + for cfg in _find_mise_configs(repo_dir): + run_cmd( + ["mise", "trust", cfg], + label="mise-trust-nested", + cwd=repo_dir, + check=False, + ) # mise install (deterministic — not left to the LLM) log("SETUP", "Running mise install...") @@ -231,48 +411,210 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: else: notes.append("mise install: OK") - # Initial build (record whether the project builds before agent changes) - log("SETUP", "Running initial build (mise run build)...") - result = run_cmd( - ["mise", "run", "build"], - label="mise-run-build-pre", - cwd=repo_dir, - check=False, + # Initial build (record whether the project builds before agent changes). + # Use the repo's configured build command (default mise run build). + from post_hooks import ( + BUILD_VERIFY_TIMEOUT_S, + DEFAULT_BUILD_COMMAND, + DEFAULT_LINT_COMMAND, + is_infra_failure, + is_verify_command_inert, + resolve_verify_argv, ) - if result.returncode != 0: - note = "Initial build (mise run build) FAILED before agent changes" - notes.append(note) - build_before = False - else: - notes.append("Initial build (mise run build): OK") - build_before = True - # Initial lint baseline (record whether lint passes before agent changes) - log("SETUP", "Running initial lint (mise run lint)...") - result = run_cmd( - ["mise", "run", "lint"], - label="mise-run-lint-pre", - cwd=repo_dir, - check=False, - ) - if result.returncode != 0: - note = "Initial lint (mise run lint) FAILED before agent changes" - notes.append(note) - lint_before = False - else: - notes.append("Initial lint (mise run lint): OK") + # A read_only workflow clones, reads/greps, and + # emits an artifact — it NEVER edits code, runs the post-agent build/lint + # gate, or opens a PR. Running the full pre-agent `mise run build` + lint + # baseline for it is pure waste: on a big repo that baseline is the + # multi-minute CI-parity build, and it will not fit the smaller memory budget + # provisioned for a read-only planning task (it would stall or OOM the planner + # before it ever reads a file). Skip both baselines for read_only and record + # neutral "OK" values (there is no regression to gate against — nothing gets + # committed). No baseline is ever compared for a read_only run, so these + # values are informational only. + build_gate_inert = False + lint_gate_inert = False + if config.read_only: + log("SETUP", "Skipping build/lint baseline for read-only workflow (no build, no PR)") + notes.append("Read-only workflow: skipped pre-agent build/lint baseline (planning only)") + build_before = True lint_before = True - - # Detect default branch - # For PR tasks (pr_iteration, pr_review): use base_branch from orchestrator if available - if config.is_pr_workflow and config.base_branch: - default_branch = config.base_branch else: - default_branch = detect_default_branch(config.repo_url, repo_dir) + build_argv = resolve_verify_argv(config.build_command, DEFAULT_BUILD_COMMAND) + build_cmd_str = " ".join(build_argv) + log("SETUP", f"Running initial build ({build_cmd_str})...") + # Use the same generous wall-clock ceiling as the POST-agent gate + # (BUILD_VERIFY_TIMEOUT_S, 30min) — NOT run_cmd's 600s default — and GUARD + # the timeout. A heavy CI-parity baseline build (install + compile + full + # test suite + synth) legitimately runs longer than 10min; at 600s it + # would raise TimeoutExpired here (this call had no try/except) and crash + # the whole task BEFORE the agent ever ran, so the issue got no PR and sat + # in Backlog — indistinguishable from a real failure. The baseline is only + # informational (it seeds regression gating); a timeout means "no usable + # baseline", NOT "the agent broke it", so we treat it as no-known-regression + # and let the run proceed. The post-agent gate re-runs the build with the + # same ceiling and surfaces an honest "timed out" if it's genuinely too slow. + try: + result = run_cmd( + build_argv, + label="verify-build-pre", + cwd=repo_dir, + check=False, + timeout=BUILD_VERIFY_TIMEOUT_S, + # Stream live → the full baseline-build log reaches CloudWatch + # verbatim (buffered capture would hide the failing sub-task). + stream=True, + ) + except subprocess.TimeoutExpired: + log( + "WARN", + f"Initial build ({build_cmd_str}) did not finish within " + f"{BUILD_VERIFY_TIMEOUT_S}s — skipping baseline (not a regression)", + ) + notes.append( + f"Initial build ({build_cmd_str}) did not finish within " + f"{BUILD_VERIFY_TIMEOUT_S}s — baseline skipped (not treated as a regression)" + ) + build_before = True + else: + if result.returncode != 0 and is_infra_failure(result.returncode, result.stderr): + # An ENVIRONMENT fault (OOM / exit 137 / out of disk) means the + # baseline build was KILLED, not that the code is broken. Treat it + # exactly like the timeout case above: there is NO usable baseline, + # so record no-known-regression (build_before=True) rather than the + # false "the project was already broken" (build_before=False). + # + # This was a real failure mode: several heavy CI-parity builds + # shared one host, the baseline was OOM-killed (exit 137), and the + # generic non-zero branch below mislabeled it build_before=False. + # That false "already red" flag then told the regression gate + # "red-before → red-after isn't the agent's fault → ✅" AND flowed + # into the orchestration gate as a node failure — for a task that + # GitHub built green. The post-agent gate already had this OOM + # check (is_infra_failure); the pre-agent baseline was missing it. + log( + "WARN", + f"Initial build ({build_cmd_str}) was KILLED by an environment " + f"fault (exit {result.returncode}: out of memory/disk) — no usable " + "baseline, treating as no-known-regression (not 'already broken')", + ) + notes.append( + f"Initial build ({build_cmd_str}) hit an environment fault " + f"(exit {result.returncode}: out of memory/disk) before agent " + "changes — baseline skipped (not treated as a regression)" + ) + build_before = True + elif result.returncode != 0: + note = f"Initial build ({build_cmd_str}) FAILED before agent changes" + notes.append(note) + build_before = False + # If the build command could not RUN (no task / not found) AND no + # explicit build_command was configured, build-regression gating is + # INERT — flag it so the agent warns on the PR rather than silently + # passing every task. A configured command that fails to run is the + # operator's typo, not the silent-default trap, so only flag the + # unconfigured (mise-default) case. + if not config.build_command and is_verify_command_inert( + result.returncode, result.stderr + ): + build_gate_inert = True + notes.append( + "⚠️ Build-regression gating is INERT: no runnable `mise run build` task " + "in this repo and no build command configured. A change that breaks the " + "build will still report success. Set pipeline.buildCommand in the repo's " + "blueprint (e.g. 'npm run build') to enable gating." + ) + else: + notes.append(f"Initial build ({build_cmd_str}): OK") + build_before = True + + # Initial lint baseline (record whether lint passes before agent changes) + lint_argv = resolve_verify_argv(config.lint_command, DEFAULT_LINT_COMMAND) + lint_cmd_str = " ".join(lint_argv) + log("SETUP", f"Running initial lint ({lint_cmd_str})...") + # Same generous ceiling + timeout guard as the build baseline above (a + # slow lint must not crash the task before the agent runs). A timeout → + # no usable lint baseline → treat as not-a-regression. + try: + result = run_cmd( + lint_argv, + label="verify-lint-pre", + cwd=repo_dir, + check=False, + timeout=BUILD_VERIFY_TIMEOUT_S, + stream=True, # full lint output → CloudWatch verbatim + ) + except subprocess.TimeoutExpired: + log( + "WARN", + f"Initial lint ({lint_cmd_str}) did not finish within " + f"{BUILD_VERIFY_TIMEOUT_S}s — skipping baseline (not a regression)", + ) + notes.append( + f"Initial lint ({lint_cmd_str}) did not finish within " + f"{BUILD_VERIFY_TIMEOUT_S}s — baseline skipped (not treated as a regression)" + ) + lint_before = True + result = None + if result is not None and result.returncode != 0: + # Distinguish "lint couldn't RUN" (no `mise run lint` task and no + # configured lint_command — the default fired and the task doesn't exist) + # from a genuine lint failure. The former is INERT: recording it as a + # red lint baseline is misleading (e.g. a Node repo with no mise lint + # task perpetually shows lint FAIL). Only flag inert for the + # unconfigured-default case, mirroring build_gate_inert. + if not config.lint_command and is_verify_command_inert( + result.returncode, result.stderr + ): + lint_gate_inert = True + lint_before = True # no real lint baseline → don't treat as a regression source + notes.append( + f"Initial lint ({lint_cmd_str}) did not run (no runnable lint task); " + "lint verification is INERT for this repo. Set pipeline.lintCommand in the " + "repo's blueprint (e.g. 'npm run lint') to enable lint reporting." + ) + else: + note = f"Initial lint ({lint_cmd_str}) FAILED before agent changes" + notes.append(note) + lint_before = False + elif result is not None: + # Ran and passed (the timeout path already noted + set lint_before). + notes.append(f"Initial lint ({lint_cmd_str}): OK") + lint_before = True + + # Detect default branch (used as the PR base + the commit-diff range). + # - PR tasks: base_branch from the orchestrator (the PR's real base). + # - Linear stacked children: base_branch is the predecessor's branch — the + # child's PR targets it. + # - Diamond children: base_branch was the orchestrator's 'main' literal; we + # resolved the REAL repo default above (resolved_diamond_base) so the PR + # targets it, not stale main. + # - Otherwise: detect the repo default (main/master). + default_branch = ( + resolved_diamond_base + or config.base_branch + or detect_default_branch(config.repo_url, repo_dir) + ) # Install prepare-commit-msg hook for code attribution _install_commit_hook(repo_dir) + # Ensure the cloned HEAD sha is captured for NON-PR workflows too (the PR + # branch above already set it). A read-only planner echoes this + # into its plan's ``repo_digest_sha`` so a later revise run can tell if the + # repo moved since the digest was built. Best-effort: a read failure leaves it + # '' (the platform's sha-shape guard then just treats the digest as + # un-versioned — trust-but-reverify). + if not head_sha_before: + head_res = run_cmd( + ["git", "rev-parse", "HEAD"], + label="head-sha-after-setup", + cwd=repo_dir, + check=False, + ) + if head_res.returncode == 0: + head_sha_before = head_res.stdout.strip() + return RepoSetup( repo_dir=repo_dir, branch=branch, @@ -280,7 +622,54 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup: build_before=build_before, lint_before=lint_before, default_branch=default_branch, + build_gate_inert=build_gate_inert, + lint_gate_inert=lint_gate_inert, + head_sha_before=head_sha_before, + ) + + +def _merge_predecessor_branch(repo_dir: str, pred_branch: str, notes: list[str]) -> None: + """Merge a predecessor branch into the current child branch (diamond stack). + + Fetches the predecessor branch and merges it so the child sees its + code. On a clean merge: done. On a CONFLICT: abort the merge (leaving + the working tree clean) and record a note. We deliberately do NOT leave + the repo in a conflicted state — the agent runs AFTER setup and a + half-merged tree would break its build/lint baseline. Instead the + predecessor branch remains fetched (``origin/``) and the + note tells the agent to integrate it as part of its task. This keeps + conflict resolution agent-driven without corrupting the deterministic + setup phase. + """ + fetch_res = run_cmd( + ["git", "fetch", "origin", pred_branch], + label="fetch-predecessor", + cwd=repo_dir, + check=False, + ) + if fetch_res.returncode != 0: + notes.append(f"predecessor branch '{pred_branch}' not fetchable; skipped merge") + log("SETUP", f"Predecessor branch not found, skipping merge: {pred_branch}") + return + + merge_res = run_cmd( + ["git", "merge", "--no-edit", f"origin/{pred_branch}"], + label="merge-predecessor", + cwd=repo_dir, + check=False, + ) + if merge_res.returncode == 0: + log("SETUP", f"Merged predecessor branch: {pred_branch}") + notes.append(f"merged predecessor branch '{pred_branch}'") + return + + # Conflict (or other merge failure): abort to keep the tree clean. + run_cmd(["git", "merge", "--abort"], label="merge-abort", cwd=repo_dir, check=False) + notes.append( + f"predecessor branch '{pred_branch}' conflicts with this branch; " + f"merge aborted — integrate origin/{pred_branch} as part of the task" ) + log("SETUP", f"Predecessor merge conflicted, aborted: {pred_branch}") def _install_commit_hook(repo_dir: str) -> None: diff --git a/agent/src/runner.py b/agent/src/runner.py index 949021976..c88f1194f 100644 --- a/agent/src/runner.py +++ b/agent/src/runner.py @@ -1,7 +1,7 @@ """Agent invocation: environment setup and Claude Agent SDK execution. -Between-turns injection seam (Phase 2 Nudges) ---------------------------------------------- +Between-turns injection seam (nudges) +------------------------------------- User nudges and other synthetic mid-task steering messages are injected via the Claude Agent SDK's ``Stop`` hook (registered in ``hooks.build_hook_matchers``), NOT the message-receive loop below. @@ -15,8 +15,8 @@ ``{"decision": "block", "reason": ""}`` causes the SDK to continue the conversation with ```` as the next user message, which is exactly the semantics we need for nudge injection. - * A module-level registry ``hooks.between_turns_hooks`` lets Phase 3 - approval gates add additional hooks without touching this file. + * A module-level registry ``hooks.between_turns_hooks`` lets other + producers (e.g. approval gates) add hooks without touching this file. Turn counting (``result.turns``) is incremented on ``AssistantMessage`` only and is NOT affected by the Stop hook's block/continue decision — nudge @@ -30,6 +30,11 @@ from typing import Any, Literal from urllib.parse import quote +from clarification_tool import ( + CLARIFICATION_SERVER_NAME, + CLARIFICATION_TOOL_NAME, + build_clarification_server, +) from config import AGENT_WORKSPACE from models import AgentResult, TaskConfig, TokenUsage from progress_writer import _ProgressWriter @@ -60,7 +65,7 @@ def _parse_token_usage(raw_usage: Any) -> TokenUsage: def _setup_bedrock_cost_attribution(config: TaskConfig) -> None: - """Wire Bedrock cost attribution for the Claude Code subprocess (#215). + """Wire Bedrock cost attribution for the Claude Code subprocess. Claude Code makes the ``InvokeModel`` calls, so attribution is configured through *its* credential + header channels, not the agent's boto3: @@ -248,11 +253,10 @@ def _initialize_policy_engine_and_hooks( Handles: * Threading per-task approval params (``initial_approvals``, - ``approval_timeout_s``, and Chunk 7's ``initial_approval_gate_count``) + ``approval_timeout_s``, and ``initial_approval_gate_count``) through to ``PolicyEngine.__init__``. - * Emitting the ``pre_approvals_loaded`` milestone (§4 step 7, §11.1) - unconditionally so "no pre-approvals seeded" is explicit rather than - inferred from silence. + * Emitting the ``pre_approvals_loaded`` milestone unconditionally so + "no pre-approvals seeded" is explicit rather than inferred from silence. * Building the SDK hook matchers that route PreToolUse / PostToolUse / Stop invocations through the engine. """ @@ -260,25 +264,22 @@ def _initialize_policy_engine_and_hooks( from policy import PolicyEngine cedar_policies = config.cedar_policies - # Cedar HITL (§7.3, §10.2) — per-task approval defaults threaded - # from the orchestrator payload. Engine clamps invalid values - # at construction. + # Per-task approval defaults threaded from the orchestrator payload. + # Engine clamps invalid values at construction. engine_kwargs: dict = {} if config.initial_approvals: engine_kwargs["initial_approvals"] = list(config.initial_approvals) if config.approval_timeout_s is not None: engine_kwargs["task_default_timeout_s"] = config.approval_timeout_s - # Chunk 7 (§13.6): seed the session counter from the TaskTable - # persisted value so a container restart mid-task resumes the - # cumulative gate budget and the ``approval_gate_cap`` remains - # the terminal bound across restarts. + # Seed the session counter from the TaskTable persisted value so a + # container restart mid-task resumes the cumulative gate budget and the + # ``approval_gate_cap`` remains the terminal bound across restarts. if config.initial_approval_gate_count: engine_kwargs["initial_approval_gate_count"] = config.initial_approval_gate_count - # Chunk 7b (§4 step 5, decision #13): adopt the per-task cap - # resolved at submit-time (blueprint override or platform default, - # frozen on the TaskRecord). When absent (legacy task predating - # Chunk 7b), ``PolicyEngine`` falls back to DEFAULT_APPROVAL_GATE_CAP - # so the behavior matches pre-Chunk-7b deploys. + # Adopt the per-task cap resolved at submit-time (blueprint override or + # platform default, frozen on the TaskRecord). When absent (a legacy task + # that predates the persisted cap), ``PolicyEngine`` falls back to + # DEFAULT_APPROVAL_GATE_CAP. if config.approval_gate_cap is not None: engine_kwargs["approval_gate_cap"] = config.approval_gate_cap policy_engine = PolicyEngine( @@ -288,19 +289,18 @@ def _initialize_policy_engine_and_hooks( extra_policies=cedar_policies if cedar_policies else None, **engine_kwargs, ) - # Chunk 7c: surface the resolved cap + its source so operators can - # distinguish a blueprint-threaded value from the engine's compile-time - # default on a container restart. Mirrors the ``approval_gate_cap_source`` - # field on the handler's "Task created" log so both ends of the cascade - # carry the same key name — CloudWatch Insights queries can - # filter/group by ``approval_gate_cap_source`` across handler + - # agent events. Value domains differ intentionally: the handler - # distinguishes ``blueprint`` vs ``platform_default``, but the - # agent only sees the threaded number (blueprint-set or default-50 - # frozen on the TaskRecord both look the same from here), so it - # emits ``threaded`` vs ``engine_default`` (the latter only fires - # for legacy tasks that predate Chunk 7b and have no cap on the - # TaskRecord at all). Cross-reference handler log at + # Surface the resolved cap + its source so operators can distinguish a + # blueprint-threaded value from the engine's compile-time default on a + # container restart. Mirrors the ``approval_gate_cap_source`` field on the + # handler's "Task created" log so both ends of the cascade carry the same + # key name — CloudWatch Insights queries can filter/group by + # ``approval_gate_cap_source`` across handler + agent events. Value domains + # differ intentionally: the handler distinguishes ``blueprint`` vs + # ``platform_default``, but the agent only sees the threaded number + # (blueprint-set or default-50 frozen on the TaskRecord both look the same + # from here), so it emits ``threaded`` vs ``engine_default`` (the latter + # only fires for legacy tasks that have no cap on the TaskRecord at all). + # Cross-reference the handler log at # ``create-task-core.ts::logger.info('Task created', ...)`` for the # ground-truth blueprint-vs-default distinction. if config.approval_gate_cap is not None: @@ -314,11 +314,10 @@ def _initialize_policy_engine_and_hooks( + cap_log, ) - # §4 step 7, §11.1: surface the starting pre-approval posture to the - # live SSE stream + 90d DDB record so operators can see exactly which - # scopes were seeded at task start. Emit unconditionally (count=0, - # scopes=[]) so "no pre-approvals seeded" is explicit rather than - # inferred from silence. + # Surface the starting pre-approval posture to the live SSE stream + + # retained DDB record so operators can see exactly which scopes were + # seeded at task start. Emit unconditionally (count=0, scopes=[]) so "no + # pre-approvals seeded" is explicit rather than inferred from silence. progress.write_approval_pre_approvals_loaded( count=len(config.initial_approvals), scopes=list(config.initial_approvals), @@ -330,6 +329,7 @@ def _initialize_policy_engine_and_hooks( task_id=config.task_id or "", progress=progress, user_id=config.user_id or "", + repo_url=config.repo_url or "", ) return policy_engine, hooks @@ -340,6 +340,19 @@ def _initialize_policy_engine_and_hooks( # Tools that mutate the working tree — dropped from the SDK surface for any # read-only workflow. _WRITE_TOOLS = frozenset(("Write", "Edit")) +# Clarify-before-spend (UX #4): workflows that do NOT get the request_clarification +# tool. pr-iteration already has its own answer-only path; web/default artifact +# tasks don't open PRs. Only the plain PR-producing new_task path benefits from an +# ask-instead-of-guess signal. +_NO_CLARIFICATION_WORKFLOW_IDS = frozenset( + ( + "coding/pr-iteration-v1", + "coding/pr-review-v1", + "coding/restack-v1", + "default/agent-v1", + "web/research-v1", + ) +) # Tools that DEFER work off-session and are hard-blocked for every task. These # launch detached / cross-session orchestration that a one-shot headless agent @@ -479,7 +492,7 @@ def _on_stderr(line: str) -> None: # When the caller (pipeline.py) injects a pre-built ``trajectory`` we # use it as-is so the pipeline can retain access to the accumulator # after ``run_agent`` returns (the --trace S3 upload runs in - # pipeline.py on terminal state — see design §10.1). For standalone + # pipeline.py on terminal state). For standalone # invocations we fall back to a fresh writer with no accumulator. if trajectory is None: trajectory = _TrajectoryWriter(config.task_id or "unknown") @@ -502,6 +515,25 @@ def _on_stderr(line: str) -> None: progress=progress, ) + # Clarify-before-spend (UX #4): register the in-process request_clarification + # tool for writeable PR-producing workflows (new_task). It lets the agent STOP + # and ask a question instead of guessing on a vague request; the runner + # captures the call below. Gated OFF for read-only workflows (pr-review) and + # artifact planners — they have their own terminal shapes and + # shouldn't grow an ask-instead path. Best-effort: a null server (SDK missing) + # just means the tool isn't offered. + mcp_servers: dict[str, Any] = {} + workflow_id = (config.resolved_workflow or {}).get("id", "") + offer_clarification = not config.read_only and workflow_id not in _NO_CLARIFICATION_WORKFLOW_IDS + if offer_clarification: + clar_server = build_clarification_server() + if clar_server is not None: + mcp_servers[CLARIFICATION_SERVER_NAME] = clar_server + # Under bypassPermissions MCP tools surface without being in + # allowed_tools, but list it explicitly so intent is clear + robust + # to a future permission-mode change. + allowed_tools = [*allowed_tools, CLARIFICATION_TOOL_NAME] + options = ClaudeAgentOptions( model=config.anthropic_model, system_prompt=system_prompt, @@ -518,6 +550,7 @@ def _on_stderr(line: str) -> None: hooks=hooks, max_budget_usd=config.max_budget_usd, stderr=_on_stderr, + **({"mcp_servers": mcp_servers} if mcp_servers else {}), ) result = AgentResult() @@ -562,7 +595,22 @@ def _on_stderr(line: str) -> None: turn_text += block.text + "\n" elif isinstance(block, ToolUseBlock): tool_input = block.input - if block.name == "Bash": + # Clarify-before-spend (UX #4): the agent called the + # request_clarification tool → capture its question. This + # is the deterministic hold signal (a tool call, not a + # reproduced sentinel). Last call wins if it somehow asks + # twice; the pipeline treats any non-empty value as a hold. + if block.name == CLARIFICATION_TOOL_NAME: + q = "" + if isinstance(tool_input, dict): + q = str(tool_input.get("question", "")).strip() + # Any non-empty value flags the hold; " " if the arg + # was blank so the signal still fires. + result.clarification_question = ( + q or result.clarification_question or " " + ) + log("TOOL", f"request_clarification: {truncate(q, 300)}") + elif block.name == "Bash": cmd = tool_input.get("command", "") log("TOOL", f"Bash: {truncate(cmd, 300)}") elif block.name in ("Read", "Glob", "Grep"): @@ -629,8 +677,8 @@ def _on_stderr(line: str) -> None: err_payload = getattr(message, "result", None) is_terminal_error = bool(getattr(message, "is_error", False)) # On a non-error result, ``message.result`` is the agent's final - # text — the deliverable for a repo-less knowledge task (#248 - # Phase 3). Capture it so deliver_artifact can upload/post it. + # text — the deliverable for a repo-less knowledge task. Capture + # it so deliver_artifact can upload/post it. if not is_terminal_error and err_payload: result.result_text = str(err_payload) # The Claude Code CLI may emit ResultMessage with subtype "success" diff --git a/agent/src/server.py b/agent/src/server.py index 9045716a4..452c984a5 100644 --- a/agent/src/server.py +++ b/agent/src/server.py @@ -308,8 +308,8 @@ def _extract_workload_access_token(request: Request) -> str: """Read AgentCore's workload access token off the inbound request. AgentCore Runtime delivers the token on `/invocations` requests under - one of two header spellings (both observed 2026-05-18 on a single - request via diagnostic logging in us-east-1): + one of two header spellings (both observed on a single request via + diagnostic logging): 1. ``WorkloadAccessToken`` — the SDK's documented header in ``bedrock_agentcore.runtime.models::ACCESS_TOKEN_HEADER``. 2. ``x-amzn-bedrock-agentcore-runtime-workload-accesstoken`` — @@ -390,11 +390,15 @@ def _run_task_background( session_id: str = "", hydrated_context: dict | None = None, system_prompt_overrides: str = "", + build_command: str = "", + lint_command: str = "", prompt_version: str = "", memory_id: str = "", resolved_workflow: dict | None = None, branch_name: str = "", pr_number: str = "", + base_branch: str | None = None, + merge_branches: list[str] | None = None, cedar_policies: list[str] | None = None, approval_timeout_s: int | None = None, initial_approvals: list[str] | None = None, @@ -434,8 +438,8 @@ def _run_task_background( except (ImportError, AttributeError) as e: _warn_cw( f"bedrock_agentcore workload-token bridge unavailable " - f"({type(e).__name__}: {e}); Linear MCP will resolve via " - "Secrets Manager fallback", + f"({type(e).__name__}: {e}); the Linear reactions token will " + "resolve via Secrets Manager fallback", task_id=task_id, ) @@ -476,11 +480,15 @@ def _run_task_background( task_id=task_id, hydrated_context=hydrated_context, system_prompt_overrides=system_prompt_overrides, + build_command=build_command, + lint_command=lint_command, prompt_version=prompt_version, memory_id=memory_id, resolved_workflow=resolved_workflow, branch_name=branch_name, pr_number=pr_number, + base_branch=base_branch, + merge_branches=merge_branches, cedar_policies=cedar_policies, approval_timeout_s=approval_timeout_s, initial_approvals=initial_approvals, @@ -525,7 +533,10 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: inp.get("model_id") or inp.get("anthropic_model") or os.environ.get("ANTHROPIC_MODEL", "") ) system_prompt_overrides = inp.get("system_prompt_overrides", "") - max_turns = int(inp.get("max_turns", 0)) or int(os.environ.get("MAX_TURNS", "100")) + # #1: per-repo build/lint verification commands. Empty → agent defaults to mise. + build_command = inp.get("build_command", "") + lint_command = inp.get("lint_command", "") + max_turns = int(inp.get("max_turns", 0)) or int(os.environ.get("MAX_TURNS", "200")) max_budget_usd = float(inp.get("max_budget_usd", 0)) or None aws_region = inp.get("aws_region") or os.environ.get("AWS_REGION", "") task_id = inp.get("task_id", "") @@ -535,6 +546,12 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: resolved_workflow = inp.get("resolved_workflow") branch_name = inp.get("branch_name", "") pr_number = str(inp.get("pr_number", "")) + # Stacked-child base branch + (diamond) predecessor branches + # to merge in. The orchestrator sets these from the orchestration row; + # absent for ordinary tasks (agent branches off main as today). + base_branch = inp.get("base_branch") or None + merge_branches_raw = inp.get("merge_branches") or [] + merge_branches = [b for b in merge_branches_raw if isinstance(b, str)] cedar_policies = inp.get("cedar_policies") or [] # Cedar HITL (§7.3) — per-task approval defaults + seeded allowlist. # Both are forwarded verbatim to the pipeline; the engine @@ -636,11 +653,15 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: "session_id": session_id, "hydrated_context": hydrated_context, "system_prompt_overrides": system_prompt_overrides, + "build_command": build_command, + "lint_command": lint_command, "prompt_version": prompt_version, "memory_id": memory_id, "resolved_workflow": resolved_workflow, "branch_name": branch_name, "pr_number": pr_number, + "base_branch": base_branch, + "merge_branches": merge_branches, "cedar_policies": cedar_policies, "approval_timeout_s": approval_timeout_s, "initial_approvals": initial_approvals, @@ -662,7 +683,8 @@ def _validate_required_params(params: dict) -> list[str]: workflow requires ``repo_url``; a repo-less workflow (``requires_repo:false``, #248 Phase 3) does not. All non-PR workflows need either an ``issue_number`` or ``task_description``; PR workflows (``coding/pr-iteration-v1`` / - ``coding/pr-review-v1``) additionally require ``pr_number``. + ``coding/pr-review-v1`` / ``coding/restack-v1``) require ``pr_number`` + instead and carry no description. """ missing: list[str] = [] workflow_id = (params.get("resolved_workflow") or {}).get("id", "coding/new-task-v1") @@ -687,7 +709,7 @@ def _validate_required_params(params: dict) -> list[str]: if requires_repo and not params.get("repo_url"): missing.append("repo_url") - if workflow_id in ("coding/pr-iteration-v1", "coding/pr-review-v1"): + if workflow_id in ("coding/pr-iteration-v1", "coding/pr-review-v1", "coding/restack-v1"): if not params.get("pr_number"): missing.append("pr_number") else: diff --git a/agent/src/shell.py b/agent/src/shell.py index 79411ed24..5d91ed4f8 100644 --- a/agent/src/shell.py +++ b/agent/src/shell.py @@ -31,14 +31,14 @@ def log(prefix: str, text: str): def log_error_cw(message: str, *, task_id: str | None = None) -> None: """Emit an ERROR line to stdout AND the APPLICATION_LOGS CloudWatch group. - Chunk 10 observability gap: ``log("ERROR", ...)`` writes to container - stdout, which AgentCore routes to + Observability gap: ``log("ERROR", ...)`` writes to container stdout, + which AgentCore routes to ``/aws/bedrock-agentcore/runtimes/-DEFAULT`` rather than the APPLICATION_LOGS group that ``TaskDashboard`` LogQueryWidgets and ``bgagent status`` read. Agent-fatal errors were therefore invisible in the two places operators normally look — discovered - during E2E 2026-05-11 T2.2 when a ``missing built-in hard-deny - policies`` crash surfaced only as a cryptic "unknown" on the CLI. + in testing when a ``missing built-in hard-deny policies`` crash + surfaced only as a cryptic "unknown" on the CLI. This helper mirrors the ERROR line to APPLICATION_LOGS via a fire-and-forget daemon thread (so it cannot block the failing @@ -209,7 +209,8 @@ def _surface_failure_lines(stdout: str) -> list[str]: tail — a parallel task DAG interleaves output so the red line is often in the middle) followed by a trailing-context tail. Deduped, order-preserving, capped. This is the fix for build-gate failures that a plain tail couldn't - explain (ABCA-662: the tail was a passing package's coverage table).""" + explain (the tail was often a passing package's coverage table, not the + error).""" lines = stdout.strip().splitlines() matched: list[str] = [] for ln in lines: @@ -237,46 +238,156 @@ def _surface_failure_lines(stdout: str) -> list[str]: return out or tail +# A process killed by a signal surfaces on ``Popen.returncode`` as the NEGATIVE +# signal number (SIGKILL -> -9), whereas a shell reports the same death as +# ``128 + signal`` (-> 137). Downstream classification keys on the shell +# convention, so normalize here — the one place the distinction is still visible +# — rather than teaching every consumer both encodings. +# +# This matters for diagnosis, not tidiness: the container/cgroup OOM-killer writes +# its "Killed process" line to the KERNEL log, not the build's stderr, so an OOM'd +# build often has NO stderr signature and the exit status is the only evidence. +# Left raw, a directly-exec'd ``mise run build`` OOM-killed at -9 misses the 137 +# check and is reported as a GENUINE build failure — telling the user their code is +# broken when the box merely ran out of memory. +_SIGNAL_EXIT_BASE = 128 + + +def _exit_status(returncode: int | None) -> int: + """Normalize a ``Popen`` return code to the shell's ``128 + signal`` form. + + ``None`` means the process was not yet reaped, which cannot happen after + ``wait()``. Map it to a non-zero failure rather than ``0``: treating an unknown + outcome as success is the one unsafe answer, since it would let a build that + was never verified report as passing. + """ + if returncode is None: + return -1 + if returncode < 0: + return _SIGNAL_EXIT_BASE - returncode + return returncode + + +def _run_cmd_streaming( + cmd: list[str], label: str, cwd: str | None, timeout: int +) -> subprocess.CompletedProcess: + """Run *cmd* streaming BOTH pipes live to the log while capturing them. + + The buffered ``subprocess.run(capture_output=True)`` path holds the ENTIRE + output in memory and never writes it to container stdout — so awslogs (→ + CloudWatch) never sees the raw stream, and the only record is whatever the + caller's post-hoc summary chooses to emit. For a heavy, long, opaque command + (``mise run build``: 4 parallel packages, 3000+ tests, ~30 min) that meant a + build failure was diagnosable ONLY if the summary happened to capture the + right lines — a failing sub-task's error would be buffered away and never + shipped, making such failures very hard to investigate. + + Streaming fixes that at the source: every line is written to the log (→ + CloudWatch verbatim, redacted) AS IT HAPPENS, so the full build log always + exists — no curated slice, no guessing which lines matter, plus live progress + instead of a silent multi-minute gap. Two drain threads (one per pipe) avoid + the classic single-thread PIPE deadlock and keep stdout/stderr SEPARATE so the + returned ``CompletedProcess`` matches ``subprocess.run``'s contract exactly + (callers still read ``.stdout`` / ``.stderr`` / ``.returncode`` unchanged). + """ + proc = subprocess.Popen( + cmd, + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, # line-buffered so each line streams promptly + env=_clean_env(), + ) + out_lines: list[str] = [] + err_lines: list[str] = [] + + def _drain(pipe, buf: list[str]) -> None: + # log() redacts each line; the buffer keeps the raw text for the caller + # (the failure classifier redacts again before it re-emits anything). + try: + for line in pipe: + line = line.rstrip("\n") + buf.append(line) + log("CMD", f" {line}") + finally: + pipe.close() + + t_out = threading.Thread(target=_drain, args=(proc.stdout, out_lines), daemon=True) + t_err = threading.Thread(target=_drain, args=(proc.stderr, err_lines), daemon=True) + t_out.start() + t_err.start() + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + # Let the drain threads finish flushing the pipes the kill closes, so + # nothing is lost, then re-raise for the caller's timeout handling. + t_out.join(timeout=10) + t_err.join(timeout=10) + raise + t_out.join(timeout=10) + t_err.join(timeout=10) + return subprocess.CompletedProcess( + cmd, _exit_status(proc.returncode), "\n".join(out_lines), "\n".join(err_lines) + ) + + def run_cmd( cmd: list[str], label: str, cwd: str | None = None, timeout: int = 600, check: bool = True, + stream: bool = False, ) -> subprocess.CompletedProcess: - """Run a command with logging.""" + """Run a command with logging. + + ``stream=True`` tees the command's output to the log line-by-line as it runs + (so the FULL output reaches CloudWatch verbatim + gives live progress) — + used for the long/opaque build & lint verify commands where a buffered, + post-hoc summary hid the real failure. Default (buffered) is unchanged for + the many short commands (git/gh/mise-install) where a curated summary is + plenty and streaming would just add noise. + """ log("CMD", redact_secrets(f"{label}: {' '.join(cmd)}")) - result = subprocess.run( - cmd, - cwd=cwd, - capture_output=True, - text=True, - timeout=timeout, - env=_clean_env(), - ) + if stream: + result = _run_cmd_streaming(cmd, label, cwd, timeout) + else: + result = subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + text=True, + timeout=timeout, + env=_clean_env(), + ) if result.returncode != 0: log("CMD", f"{label}: FAILED (exit {result.returncode})") - if result.stderr: - for line in result.stderr.strip().splitlines()[:20]: - log("CMD", f" {line}") - # ALSO surface stdout on failure. Build/test tooling (jest, tsc, the mise - # task DAG) writes the ACTUAL failing-task error to STDOUT, not stderr — - # stderr often carries only the runner's plan echo. Logging stderr alone - # made build-gate failures undebuggable: a red ``mise run build`` showed - # every task STARTING but never WHICH one failed or why (ABCA-662). - # - # A plain tail is NOT enough for a PARALLEL task DAG: `mise run build` - # runs 4 packages concurrently and interleaves their output, so the - # failing task's error scrolls into the MIDDLE while the tail captures - # whatever finished LAST (e.g. a passing package's coverage table) — - # ABCA-662 follow-up: the tail showed a coverage table, not the red task. - # So FIRST scan the whole output for failure-signature lines and surface - # those (this is what names the failing sub-task), THEN a larger tail for - # trailing context. Redact — repo build output is untrusted. - if result.stdout: - surfaced = _surface_failure_lines(result.stdout) - for line in surfaced: - log("CMD", f" {redact_secrets(line)}") + # When streaming, the FULL output already reached the log live — don't + # re-dump it. Just point at the surfaced failure lines for a quick jump. + if stream: + surfaced = _surface_failure_lines(result.stdout or "") + if surfaced: + log("CMD", f"{label}: failing lines (full output streamed above):") + for line in surfaced: + log("CMD", f" {redact_secrets(line)}") + else: + if result.stderr: + for line in result.stderr.strip().splitlines()[:20]: + log("CMD", f" {line}") + # ALSO surface stdout on failure. Build/test tooling (jest, tsc, the + # mise task DAG) writes the ACTUAL failing-task error to STDOUT, not + # stderr. A plain tail is NOT enough for a PARALLEL task DAG (the + # failing line scrolls into the MIDDLE while the tail is a passing + # package's coverage table), so scan the whole output for + # failure-signature lines FIRST, then a tail for context. Redact — + # repo build output is untrusted. (Buffered path only; the streaming + # path above already emitted everything verbatim.) + if result.stdout: + surfaced = _surface_failure_lines(result.stdout) + for line in surfaced: + log("CMD", f" {redact_secrets(line)}") if check: stderr_snippet = redact_secrets(result.stderr.strip()[:500]) if result.stderr else "" raise RuntimeError(f"{label} failed (exit {result.returncode}): {stderr_snippet}") @@ -288,7 +399,7 @@ def run_cmd( # Signatures a transient (retryable) dependency/registry failure leaves in a # command's stderr — network blips, DNS hiccups, registry 5xx / rate limits. # NOT a permanent auth/not-found error: those are re-run-won't-help and would -# just waste backoff time. Deliberately conservative (#251 dependency_unreachable). +# just waste backoff time. Deliberately conservative. _TRANSIENT_CMD_SIGNATURES: tuple[str, ...] = ( "could not resolve host", "temporary failure in name resolution", @@ -321,7 +432,7 @@ def is_transient_cmd_failure(stderr: str) -> bool: # DNS name-resolution failures that NAME a host — a firewalled / non-existent # endpoint whose name cannot be resolved. Retrying never helps, so backoff bails -# immediately (#251 review). Deliberately NARROWER than hooks.detect_egress_denial: +# immediately. Deliberately NARROWER than hooks.detect_egress_denial: # a TCP-connect failure ("Failed to connect to ... Connection timed out") # to an ALLOWLISTED host is genuinely transient and must stay retryable — so # ``Failed to connect``/``Connection refused`` are excluded here. A persistent @@ -336,7 +447,7 @@ def is_transient_cmd_failure(stderr: str) -> bool: def _names_unresolvable_host(stderr: str) -> bool: - """True when *stderr* is a DNS name-resolution failure naming a host (#251). + """True when *stderr* is a DNS name-resolution failure naming a host. Such a host cannot be reached no matter how often we retry (non-existent or firewalled at DNS), so backoff bails immediately rather than burn its budget @@ -357,7 +468,7 @@ def run_cmd_with_backoff( on_retry=None, sleep=time.sleep, ) -> subprocess.CompletedProcess: - """Run ``cmd`` with bounded retries on *transient* failures (#251, Phase 2). + """Run ``cmd`` with bounded retries on *transient* failures. Retries up to ``max_attempts`` times with exponential backoff (``base_delay_s * 2**(attempt-1)``) ONLY when the failure looks transient @@ -385,9 +496,9 @@ def run_cmd_with_backoff( return result stderr = result.stderr or "" # A named-host failure is a firewalled/non-existent endpoint — retrying - # never helps and would emit misleading dependency_unreachable events - # (#251 review). Bail immediately so _fail_setup_command reclassifies it - # to the non-retryable egress_denied remedy. + # never helps and would emit misleading dependency_unreachable events. + # Bail immediately so _fail_setup_command reclassifies it to the + # non-retryable egress_denied remedy. exhausted = attempt >= max_attempts if exhausted or not is_transient_cmd_failure(stderr) or _names_unresolvable_host(stderr): break diff --git a/agent/src/task_state.py b/agent/src/task_state.py index fbb95c4dd..bf40d712f 100644 --- a/agent/src/task_state.py +++ b/agent/src/task_state.py @@ -7,7 +7,7 @@ import os import time -from typing import Any, TypedDict +from typing import TypedDict from shell import log, log_error_cw @@ -15,13 +15,12 @@ class ApprovalRow(TypedDict): """Schema for the approval row written by ``transact_write_approval_request``. - Mirrors the DDB column layout described in design §10.1 and the - TypeScript ``ApprovalRecord`` discriminated union in - ``cdk/src/handlers/shared/types.ts``. Used as the typed contract - between the PreToolUse hook (which builds the row) and the - transactional writer (which serializes it to DDB attributes). - Pre-S7 the function accepted a bare ``dict`` so missing or - misspelled fields would fail at runtime, not at the call site. + Mirrors the DDB column layout and the TypeScript ``ApprovalRecord`` + discriminated union in ``cdk/src/handlers/shared/types.ts``. Used as + the typed contract between the PreToolUse hook (which builds the row) + and the transactional writer (which serializes it to DDB attributes). + Earlier the function accepted a bare ``dict`` so missing or misspelled + fields would fail at runtime, not at the call site. """ task_id: str @@ -246,7 +245,9 @@ def write_terminal(task_id: str, status: str, result: dict | None = None) -> Non return now = _now_iso() expr_names = {"#s": "status"} - expr_values: dict[str, Any] = { + # Mixed value types: most are strings, but build_passed/lint_passed are + # persisted as native booleans (the reconciler reads them via .BOOL). + expr_values: dict[str, object] = { ":s": status, ":t": now, ":sca": f"{status}#{now}", @@ -257,9 +258,8 @@ def write_terminal(task_id: str, status: str, result: dict | None = None) -> Non # mid-gate can still record the terminal transition. Without # it, a crash while the user is deciding leaves the task # stuck until the stranded-task reconciler catches it (~2h). - # Cedar HITL state machine (design §9): RUNNING ↔ - # AWAITING_APPROVAL, both can transition straight to a - # terminal state. + # Approval state machine: RUNNING ↔ AWAITING_APPROVAL, both can + # transition straight to a terminal state. ":awaiting_approval": "AWAITING_APPROVAL", } update_parts = ["#s = :s", "completed_at = :t", "status_created_at = :sca"] @@ -280,8 +280,8 @@ def write_terminal(task_id: str, status: str, result: dict | None = None) -> Non if result.get("turns") is not None: update_parts.append("turns = :turns") expr_values[":turns"] = str(result["turns"]) - # Rev-5 DATA-1: dual counters so operators can distinguish - # SDK-attempted vs pipeline-completed turn counts. + # Dual counters so operators can distinguish SDK-attempted vs + # pipeline-completed turn counts. if result.get("turns_attempted") is not None: update_parts.append("turns_attempted = :ta") expr_values[":ta"] = str(result["turns_attempted"]) @@ -294,30 +294,51 @@ def write_terminal(task_id: str, status: str, result: dict | None = None) -> Non if result.get("memory_written") is not None: update_parts.append("memory_written = :mw") expr_values[":mw"] = result["memory_written"] - # Verification verdict (#515 replay bundle). build_passed/lint_passed - # were historically dropped here (present on TaskResult but never - # written), so TaskDetail.build_passed was always null. Persist both - # so the replay bundle carries a structured verification signal. + # Persist the post-hook verify outcomes so they're observable on the + # task record (orchestration reconciler / dashboards / replay + # bundle), not just consumed in-process by the gate. build_passed/ + # lint_passed were historically dropped here (present on TaskResult but + # never written) — persist both so a consumer sees WHY a task passed/ + # failed verification, as a structured signal. if result.get("build_passed") is not None: update_parts.append("build_passed = :bp") expr_values[":bp"] = bool(result["build_passed"]) if result.get("lint_passed") is not None: update_parts.append("lint_passed = :lp") expr_values[":lp"] = bool(result["lint_passed"]) - # OTEL trace id (#515) for cross-plane correlation. Absent on tasks + # Whether a PR-iteration advanced the branch HEAD (a real commit + # landed) vs. ran with no change (a question-only comment). + # The Linear/Slack settle reply reads this to avoid a false + # "✅ Updated" on a no-op iteration. None ⇒ not persisted (the + # consumer defaults to the change-made side, back-compat). + if result.get("code_changed") is not None: + update_parts.append("code_changed = :cc") + expr_values[":cc"] = bool(result["code_changed"]) + # The pushed HEAD sha — lets the screenshot webhook match a deploy's + # commit to the iteration task that pushed it (correct preview-reply + # attribution when two iterations overlap on one PR). Skip empties. + if result.get("head_sha"): + update_parts.append("head_sha = :hsha") + expr_values[":hsha"] = str(result["head_sha"]) + if result.get("answer_text"): + update_parts.append("answer_text = :ans") + # Bound the persisted answer so a verbose agent can't bloat the + # row; the reply renderer truncates again for display. + expr_values[":ans"] = str(result["answer_text"])[:2000] + # OTEL trace id for cross-plane correlation. Absent on tasks # that predate this field and when tracing is unavailable. if result.get("otel_trace_id"): update_parts.append("otel_trace_id = :otid") expr_values[":otid"] = result["otel_trace_id"] - # --trace artifact URI (design §10.1). Written atomically - # with the terminal-status transition so a consumer that - # reads TaskRecord.trace_s3_uri immediately after - # status becomes terminal sees a consistent view. + # --trace artifact URI. Written atomically with the + # terminal-status transition so a consumer that reads + # TaskRecord.trace_s3_uri immediately after status becomes + # terminal sees a consistent view. if result.get("trace_s3_uri"): update_parts.append("trace_s3_uri = :ts3") expr_values[":ts3"] = result["trace_s3_uri"] - # Repo-less delivered artifact URI (#248 Phase 3). Persisted with the - # terminal write so TaskDetail.artifact_uri surfaces the deliverable + # Repo-less delivered artifact URI. Persisted with the terminal + # write so TaskDetail.artifact_uri surfaces the deliverable # — otherwise the S3 object exists but its URI is undiscoverable. if result.get("artifact_uri"): update_parts.append("artifact_uri = :au") @@ -342,9 +363,9 @@ def write_terminal(task_id: str, status: str, result: dict | None = None) -> Non "[task_state] write_terminal skipped: " "status precondition not met (task may have been cancelled)", ) - # K2 final review SIG-1: ConditionalCheckFailed on the - # happy path after a successful S3 trace upload orphans - # the S3 object — the URI never lands on the TaskRecord, + # A ConditionalCheckFailed on the happy path after a + # successful S3 trace upload orphans the S3 object — the URI + # never lands on the TaskRecord, # so ``get-trace-url`` will 404 ``TRACE_NOT_AVAILABLE`` # indefinitely. Without this dedicated log the orphan # is invisible; the generic skip message above doesn't @@ -479,35 +500,35 @@ def get_task(task_id: str) -> dict | None: # --------------------------------------------------------------------------- -# Cedar HITL approval primitives (§6.5, §9.1, IMPL-24) +# Approval primitives (Cedar human-in-the-loop gates) # --------------------------------------------------------------------------- # -# ``TaskApprovalsTable`` and the AWAITING_APPROVAL status transitions land -# physically in Chunk 4 (CDK). The agent-side helpers below are written to -# that contract and exposed so Chunk 3's ``pre_tool_use_hook`` can be -# implemented + unit-tested now (via mocked boto3 clients); Chunk 4 sets -# ``TASK_APPROVALS_TABLE_NAME`` + grants IAM and the same helpers start -# making real DDB calls with no further code change on the agent side. +# ``TaskApprovalsTable`` and the AWAITING_APPROVAL status transitions are +# provisioned by the CDK stack. The agent-side helpers below are written to +# that contract and exposed so the ``pre_tool_use_hook`` can be implemented + +# unit-tested (via mocked boto3 clients); once the stack sets +# ``TASK_APPROVALS_TABLE_NAME`` + grants IAM, the same helpers start making +# real DDB calls with no further code change on the agent side. # # Primitives exposed: # - ``transact_write_approval_request`` — atomic Put(TaskApprovals) + # Update(TaskTable: RUNNING → AWAITING_APPROVAL). Raises # ``ApprovalWriteError`` on ``TransactionCanceledException`` so the -# hook can return DENY + ``approval_write_failed`` (§13.1). +# hook can return DENY + ``approval_write_failed``. # - ``transact_resume_from_approval`` — atomic Update(TaskTable: # AWAITING_APPROVAL → RUNNING) gated on # ``awaiting_approval_request_id = request_id``. Raises -# ``ApprovalResumeError`` on cancellation (§13.9). +# ``ApprovalResumeError`` on cancellation. # - ``best_effort_update_approval_status`` — conditional Update on the # approval row (``status = :pending`` guard). Returns ``False`` on -# ``ConditionCheckFailed`` so IMPL-24's re-read re-read path fires. +# ``ConditionCheckFailed`` so the late-approval re-read path fires. # - ``get_approval_row`` — strongly-consistent GetItem; default -# ``consistent_read=True`` because IMPL-24's race fix relies on it. +# ``consistent_read=True`` because the race fix relies on it. # # Errors beyond the structural conditions (unreachable DDB, IAM drift, # missing env var) raise ``ApprovalTablesUnavailable`` so the hook can -# fail CLOSED without guessing. The hook maps that to DENY so a -# pre-Chunk-4 deploy cannot silently bypass gates. +# fail CLOSED without guessing. The hook maps that to DENY so a deploy +# without the approvals table cannot silently bypass gates. TASK_APPROVALS_TABLE_ENV = "TASK_APPROVALS_TABLE_NAME" TASK_TABLE_ENV = "TASK_TABLE_NAME" @@ -521,9 +542,8 @@ def get_task(task_id: str) -> dict | None: class ApprovalTablesUnavailable(RuntimeError): """Either ``TASK_APPROVALS_TABLE_NAME`` or ``TASK_TABLE_NAME`` is unset. - Hook maps to DENY (fail-closed); see §13.15. Distinct from - ``TaskFetchError`` so callers do not collapse a config problem with a - transient read failure. + Hook maps to DENY (fail-closed). Distinct from ``TaskFetchError`` so + callers do not collapse a config problem with a transient read failure. """ @@ -533,7 +553,7 @@ class ApprovalWriteError(RuntimeError): Fired when the cross-table atomic write is cancelled — either the TaskTable precondition fails (task already cancelled / advanced past RUNNING) or the approval row already exists. Hook maps to DENY + - ``approval_write_failed`` (§13.1). The underlying cancellation reasons + ``approval_write_failed``. The underlying cancellation reasons are stashed on ``.cancellation_reasons`` for triage. """ @@ -546,7 +566,7 @@ class ApprovalResumeError(RuntimeError): """``transact_resume_from_approval`` TransactionCanceledException. Fired when the resume transition fails — typically because the user - cancelled the task mid-approval (§13.9). Hook maps to DENY + + cancelled the task mid-approval. Hook maps to DENY + ``approval_resume_failed``. """ @@ -589,8 +609,8 @@ def _py_to_ddb_attr(value): Handles the subset we actually write: ``str``, ``int``, ``bool``, ``None``, lists-of-str. More exotic types would need marshalling - support; ``approval_row`` values are constrained to the §10.1 schema - which falls entirely inside this subset. + support; ``approval_row`` values are constrained to the approval-row + schema, which falls entirely inside this subset. """ if value is None: return {"NULL": True} @@ -722,7 +742,7 @@ def transact_resume_from_approval( The condition ``status = AWAITING_APPROVAL AND awaiting_approval_request_id = :rid`` prevents: - - resuming a task that's been cancelled mid-approval (§13.9); + - resuming a task that's been cancelled mid-approval; - resuming with a stale request_id after a race with the reconciler / a concurrent approval. @@ -776,10 +796,10 @@ def best_effort_update_approval_status( ) -> bool: """Conditionally flip ``status`` on an approval row. - The condition ``status = :pending`` is the design-doc guard from §6.5. - Used on the TIMED_OUT write path: if the row has already transitioned - to APPROVED or DENIED, the update fails and the caller (the hook) must - re-read the row with ConsistentRead (IMPL-24). + The condition ``status = :pending`` guards the write. Used on the + TIMED_OUT write path: if the row has already transitioned to APPROVED + or DENIED, the update fails and the caller (the hook) must re-read the + row with ConsistentRead. Returns ``True`` on successful write, ``False`` on ``ConditionalCheckFailedException``. All other errors propagate. @@ -820,11 +840,11 @@ def get_approval_row( consistent_read: bool = True, client=None, ) -> dict | None: - """Fetch an approval row. Defaults to strongly-consistent read (IMPL-24). + """Fetch an approval row. Defaults to a strongly-consistent read. Returns a Python dict with unmarshalled attribute values, or ``None`` if the row does not exist (TTL reaped, wrong IDs, etc.). Callers use the - ``None`` return to detect the row-gone branch in §13.12. + ``None`` return to detect the row-gone branch of the late-approval race. """ _, approvals_table = _require_tables() ddb = _get_ddb_client(client=client) @@ -846,19 +866,19 @@ def increment_approval_gate_count_in_ddb( ) -> bool: """Best-effort atomic increment of ``approval_gate_count`` on TaskTable. - Chunk 7 persistence layer for decision #13's per-task gate counter. The - session counter (``PolicyEngine._approval_gate_count``) stays - authoritative WITHIN a container — this write exists so that a container - restart (§13.6) can seed the new container's counter from the persisted - value instead of resetting to 0 and re-exposing the user to another - ``approval_gate_cap`` worth of gates. + Persistence layer for the per-task gate counter. The session counter + (``PolicyEngine._approval_gate_count``) stays authoritative WITHIN a + container — this write exists so that a container restart can seed the + new container's counter from the persisted value instead of resetting to + 0 and re-exposing the user to another ``approval_gate_cap`` worth of + gates. - **Best-effort semantics (§13.6):** the counter is a safety bound, not a + **Best-effort semantics:** the counter is a safety bound, not a correctness bound. A DDB write failure here MUST NOT block the gate — the session counter still enforces the cap within this container, and - the §13.6 analysis accepts at most one lost increment per restart as - acceptable damage. Returns ``True`` on success, ``False`` on any - failure (config missing, IAM drift, throttling). Never raises. + losing at most one increment per restart is acceptable damage. Returns + ``True`` on success, ``False`` on any failure (config missing, IAM + drift, throttling). Never raises. Uses a pure ADD UpdateExpression without a ConditionExpression — the counter is monotonic and concurrent writes from different hooks on the @@ -867,9 +887,9 @@ def increment_approval_gate_count_in_ddb( applying the ADD, matching the CreateTaskFn seed of ``approval_gate_count: 0`` (see cdk/src/handlers/shared/create-task-core.ts). - Deliberately kept separate from the resume TransactWriteItems (§6.5): the + Deliberately kept separate from the resume TransactWriteItems: the joint-update invariant on ``status`` + ``awaiting_approval_request_id`` - (§10.2) must not be burdened with a non-safety-critical counter bump. + must not be burdened with a non-safety-critical counter bump. """ try: task_table, _ = _require_tables() diff --git a/agent/src/workflow/deliverers.py b/agent/src/workflow/deliverers.py index 0245da566..50b8529a2 100644 --- a/agent/src/workflow/deliverers.py +++ b/agent/src/workflow/deliverers.py @@ -1,4 +1,4 @@ -"""Registry of ``deliver_artifact`` deliverers (#248, ADR-014 addendum 2026-06-08). +"""Registry of ``deliver_artifact`` deliverers (see ADR-014). A workflow's ``deliver_artifact`` step names a *deliverer* in its ``target`` field; that name resolves here. This mirrors the step-handler registry pattern @@ -7,15 +7,15 @@ closed set of valid names lives in ``DELIVERERS`` rather than a JSON-Schema enum. Each deliverer declares the terminal outcomes it ``produces`` so the cross-field -validator (rule 11) can check a workflow's declared ``terminal_outcomes.primary`` -is actually produced by some step — the single source of truth for the old -``_DELIVER_TARGET_OUTCOMES`` map, now registry-driven. - -The shared *plumbing* contract every deliverer builds on is frozen in the -ADR-014 addendum: artifacts upload to a task-scoped key ``artifacts/{task_id}/`` -in the platform artifacts bucket (``ARTIFACTS_BUCKET_NAME``), the agent -SessionRole carries a prefix-scoped IAM grant, a per-artifact size limit -applies, and the delivered URL surfaces on ``TaskDetail`` (``artifact_uri``). +validator can check a workflow's declared ``terminal_outcomes.primary`` is +actually produced by some step. This registry is the single source of truth for +that mapping. + +The shared *plumbing* contract every deliverer builds on: artifacts upload to a +task-scoped key ``artifacts/{task_id}/`` in the platform artifacts bucket +(``ARTIFACTS_BUCKET_NAME``), the agent SessionRole carries a prefix-scoped IAM +grant, a per-artifact size limit applies, and the delivered URL surfaces on +``TaskDetail`` (``artifact_uri``). """ from __future__ import annotations @@ -85,13 +85,13 @@ def _strip_code_and_urls(text: str) -> str: def _reject_if_deferral(text: str) -> None: """Fail the deliver step if the final message defers instead of delivering. - Guards the exact silently-wrong-artifact failure observed on a repo-less - research task: the agent launched a background workflow and its final text - promised results elsewhere, which would have uploaded as the artifact. - Raising here routes to a terminal FAILED (see the pipeline delivery gate) so - the placeholder is never presented as the result. Matches full deferral - phrases against prose with code/URLs stripped, to avoid false-FAILing a - genuine answer that merely quotes such a phrase in a link or snippet. + Guards against a silently-wrong artifact: on a repo-less research task the + agent can launch a background workflow and end with text that promises + results elsewhere, which would then be uploaded as the artifact. Raising here + routes to a terminal FAILED (see the pipeline delivery gate) so the + placeholder is never presented as the result. Matches full deferral phrases + against prose with code/URLs stripped, to avoid false-FAILing a genuine + answer that merely quotes such a phrase in a link or snippet. """ lowered = _strip_code_and_urls(text).lower() hit = next((m for m in _DEFERRAL_MARKERS if m in lowered), None) @@ -117,18 +117,18 @@ class Deliverer: ``produces`` is the set of ``terminal_outcomes`` values this deliverer can satisfy (e.g. an S3 upload produces ``artifact``; a comment post produces - ``comment``). Used by validator rule 11 and by the runtime ``deliver`` - dispatcher. + ``comment``). Used by the terminal-outcome validator and by the runtime + ``deliver`` dispatcher. """ name: str produces: frozenset[str] = field(default_factory=frozenset) -# First-party deliverers. The three names preserve the exact produced-outcome -# sets of the pre-addendum ``_DELIVER_TARGET_OUTCOMES`` enum, so no existing -# workflow / fixture / golden vector changes behavior — the closed enum is -# widened to an open string + this registry, not redefined. +# First-party deliverers. These three names and their produced-outcome sets match +# the earlier hardcoded target→outcomes mapping this registry replaced, so no +# existing workflow / fixture / golden vector changes behavior — the closed set +# is widened to an open string + this registry, not redefined. DELIVERERS: dict[str, Deliverer] = { "s3": Deliverer("s3", frozenset({"artifact"})), "comment": Deliverer("comment", frozenset({"comment"})), @@ -138,13 +138,12 @@ class Deliverer: # The target a ``deliver_artifact`` step uses when it omits ``target``. This is # the SINGLE source of truth for that default — both the runtime (runner.py's # ``_handle_deliver_artifact``) and the validator (``produced_outcomes(None)``) -# key off it, so the two can never disagree about what an unset target delivers -# (PR review #296 finding #7). +# key off it, so the two can never disagree about what an unset target delivers. DEFAULT_DELIVER_TARGET = "s3" def _artifact_body(ctx: StepContext) -> bytes: - """The deliverable bytes: the agent's final result text (#248 Phase 3).""" + """The deliverable bytes: the agent's final result text.""" text = ctx.agent_result.result_text if ctx.agent_result else "" if not text: raise ValueError("deliver_artifact: agent produced no result text to deliver") @@ -154,10 +153,9 @@ def _artifact_body(ctx: StepContext) -> bytes: # Bound memory BEFORE encoding. UTF-8 uses ≥1 byte per character, so a string # whose character count already exceeds the byte cap cannot possibly fit — # reject it without materializing a second full copy as bytes. This is what - # makes the cap actually cap memory on the constrained MicroVM: previously the - # bytes were encoded first and the check ran after, so a multi-hundred-MB - # result had both the str and its bytes resident before the cap fired - # (PR review #296 finding #9). + # makes the cap actually cap memory in the agent's memory-constrained sandbox: + # encoding first and checking after would leave a multi-hundred-MB result with + # both the str and its bytes resident before the cap fired. if len(text) > MAX_ARTIFACT_BYTES: raise ValueError( f"deliver_artifact: artifact text is {len(text)} characters, exceeds the " @@ -204,10 +202,11 @@ def _upload_to_s3(ctx: StepContext) -> str: def _post_comment(ctx: StepContext) -> bool: """Record the deliverable as a ``delivered_comment`` progress milestone. - The agent has no direct comment channel for a repo-less task (no GitHub repo; - Linear MCP is channel-gated). This records the result text as a - ``delivered_comment`` milestone on TaskEventsTable — visible in the live event - stream (``bgagent watch``) and to any consumer of the task's events. + The agent has no direct comment channel for a repo-less task (there is no + GitHub repo, and the agent posts nothing to the issue tracker directly). This + records the result text as a ``delivered_comment`` milestone on + TaskEventsTable — visible in the live event stream (``bgagent watch``) and to + any consumer of the task's events. NOTE: rendering this milestone to an external channel (Slack/email/GitHub) is NOT yet wired — ``delivered_comment`` is not in the fan-out's @@ -225,8 +224,9 @@ def _post_comment(ctx: StepContext) -> bool: def deliver(target: str, ctx: StepContext) -> DeliveryResult: """Run the named deliverer against the step context. - Raises ``ValueError`` for an unknown target (the validator's rule-8 should - prevent this reaching runtime, but fail loud rather than silently no-op). + Raises ``ValueError`` for an unknown target (the validator's handler-coverage + check should prevent this reaching runtime, but fail loud rather than + silently no-op). """ if target not in DELIVERERS: raise ValueError( @@ -242,7 +242,7 @@ def deliver(target: str, ctx: StepContext) -> DeliveryResult: # Terminal outcomes that any deliver_artifact deliverer can produce (union over -# the registry) — the set rule 11 treats as "deliver_artifact-backed". +# the registry) — the set the validator treats as "deliver_artifact-backed". DELIVER_OUTCOMES: frozenset[str] = frozenset().union(*(d.produces for d in DELIVERERS.values())) @@ -250,12 +250,11 @@ def produced_outcomes(target: str | None) -> frozenset[str]: """Terminal outcomes a deliver_artifact ``target`` produces. An unset target resolves to {@link DEFAULT_DELIVER_TARGET} — the SAME default - the runtime applies — so the validator models exactly what will run. (It was - previously lenient, returning the full set, which let a ``primary: comment`` + the runtime applies — so the validator models exactly what will run. (Being + lenient here and returning the full set would let a ``primary: comment`` workflow with no ``target`` pass validation while the runtime silently - delivered only to ``s3`` and never posted the comment — PR review #296 - finding #7.) An unknown name returns the empty set (it produces nothing the - validator can vouch for). + delivered only to ``s3`` and never posted the comment.) An unknown name + returns the empty set (it produces nothing the validator can vouch for). """ resolved = DEFAULT_DELIVER_TARGET if target is None else target deliverer = DELIVERERS.get(resolved) diff --git a/agent/src/workflow/runner.py b/agent/src/workflow/runner.py index b7bde5b25..ba7a7bc31 100644 --- a/agent/src/workflow/runner.py +++ b/agent/src/workflow/runner.py @@ -1,4 +1,4 @@ -"""The agent-side workflow step runner (#248). +"""The agent-side workflow step runner. Per `ADR-014 <../../../docs/decisions/ADR-014-workflow-driven-tasks.md>`_ the runner lives *in the container* and interprets ``workflow.steps`` — it drives @@ -388,8 +388,8 @@ def _handle_clone_repo(step: Step, ctx: StepContext) -> StepOutcome: reused = ctx.setup is not None if not reused: - # Thread progress so bounded-retry blocker events (#251, dependency_ - # unreachable / egress_denied during clone/fetch backoff) reach the live + # Thread progress so bounded-retry blocker events (e.g. dependency + # unreachable / egress denied during clone/fetch backoff) reach the live # stream — matching the inline pipeline path. Terminal reason still # propagates via the raised exception even when progress is None. ctx.setup = setup_repo(ctx.config, progress=ctx.progress) @@ -414,7 +414,7 @@ def _handle_hydrate_context(step: Step, ctx: StepContext) -> StepOutcome: the workflow path produces the same system prompt as ``pipeline.run_task`` (repo_url/branch/workspace/max_turns/setup_notes/memory_context + overrides + channel guidance). Without this the agent loop would run with an empty - system prompt (code-review finding). Requires a prior ``clone_repo`` for + system prompt. Requires a prior ``clone_repo`` for the ``RepoSetup``; when absent (repo-less workflows) the system prompt is left to the caller, since ``build_system_prompt`` is repo-shaped today. """ @@ -493,11 +493,10 @@ def gate_status( """Map a verify result + the step's ``gate`` to a step status. Single place the verify-gate semantics live, shared by ``verify_build`` and - ``verify_lint`` (the two were near-identical twins that drifted on the - ``read_only`` rule — see the code-review finding). Since #301 it is also the - implementation behind the coding lane's inline post-hook gating - (``pipeline._apply_post_hook_gates``), so both lanes honor a step's - declared ``gate`` through this one function: + ``verify_lint`` (the two were near-identical twins that had drifted on the + ``read_only`` rule). It is also the implementation behind the coding lane's + inline post-hook gating (``pipeline._apply_post_hook_gates``), so both lanes + honor a step's declared ``gate`` through this one function: - ``informational`` (or a ``read_only`` workflow) — never gates. - ``strict`` — any failure gates. @@ -519,11 +518,12 @@ def gate_status( def _handle_verify_build(step: Step, ctx: StepContext) -> StepOutcome: - """Run ``mise run build``. Gating vs informational is the step's ``gate``.""" + """Run the repo's build command (default ``mise run build``); gating is the step's ``gate``.""" from post_hooks import verify_build repo_dir = ctx.setup.repo_dir if ctx.setup else "" - passed = verify_build(repo_dir) + outcome = verify_build(repo_dir, ctx.config.build_command) + passed = outcome.passed # was_passing_before defaults True (assume green-before, so a post-agent # failure IS a regression) — the same conservative default pipeline.py uses. was_passing_before = ctx.setup.build_before if ctx.setup else True @@ -533,21 +533,28 @@ def _handle_verify_build(step: Step, ctx: StepContext) -> StepOutcome: read_only=ctx.workflow.read_only, was_passing_before=was_passing_before, ) + # Distinguish a timeout from a genuine red build in the step error too. + fail_reason = ( + "post-agent build timed out" + if outcome.timed_out + else "post-agent build failed (regression)" + ) return StepOutcome( kind=step.kind, name=_step_key(step), status=status, - error=None if status == "succeeded" else "post-agent build failed (regression)", + error=None if status == "succeeded" else fail_reason, data={"build_passed": passed}, ) def _handle_verify_lint(step: Step, ctx: StepContext) -> StepOutcome: - """Run ``mise run lint`` (typically an advisory ``on_failure: continue`` gate).""" + """Run the repo's lint command (default ``mise run lint``; usually an advisory gate).""" from post_hooks import verify_lint repo_dir = ctx.setup.repo_dir if ctx.setup else "" - passed = verify_lint(repo_dir) + outcome = verify_lint(repo_dir, ctx.config.lint_command) + passed = outcome.passed was_passing_before = ctx.setup.lint_before if ctx.setup else True status = gate_status( passed=passed, @@ -555,11 +562,14 @@ def _handle_verify_lint(step: Step, ctx: StepContext) -> StepOutcome: read_only=ctx.workflow.read_only, was_passing_before=was_passing_before, ) + fail_reason = ( + "post-agent lint timed out" if outcome.timed_out else "post-agent lint failed (regression)" + ) return StepOutcome( kind=step.kind, name=_step_key(step), status=status, - error=None if status == "succeeded" else "post-agent lint failed (regression)", + error=None if status == "succeeded" else fail_reason, data={"lint_passed": passed}, ) @@ -568,10 +578,9 @@ def _handle_ensure_pr(step: Step, ctx: StepContext) -> StepOutcome: """Create / push+resolve / resolve a PR per the step's ``strategy``. The provider-neutral intent dispatches through the existing GitHub - realization. ``ensure_pr`` now takes the strategy explicitly (``create`` | - ``push_resolve`` | ``resolve``) instead of self-inspecting the removed - ``task_type`` (#248 task 8), so the workflow's declared strategy drives the - behavior. + realization. ``ensure_pr`` takes the strategy explicitly (``create`` | + ``push_resolve`` | ``resolve``) instead of inferring it from the now-removed + ``task_type``, so the workflow's declared strategy drives the behavior. """ from post_hooks import ensure_pr @@ -606,9 +615,9 @@ def _handle_post_review(step: Step, ctx: StepContext) -> StepOutcome: No first-party workflow declares a ``post_review`` step — ``coding/pr-review-v1`` resolves its PR via ``ensure_pr(strategy: resolve)`` instead. The handler is - registered so the handler-coverage check (validator rule 8) stays honest and - fails loudly rather than silently no-opping; it is implemented when a workflow - that posts a GitHub Reviews-API review (vs an issue comment) ships. + registered so the validator's handler-coverage check stays honest and fails + loudly rather than silently no-opping; it is implemented when a workflow that + posts a GitHub Reviews-API review (vs an issue comment) ships. """ raise NotImplementedError( "post_review has no shipped workflow yet — coding/pr-review-v1 uses " @@ -617,7 +626,7 @@ def _handle_post_review(step: Step, ctx: StepContext) -> StepOutcome: def _handle_deliver_artifact(step: Step, ctx: StepContext) -> StepOutcome: - """Deliver a produced artifact (repo-less knowledge work, #248 Phase 3). + """Deliver a produced artifact (repo-less knowledge work). Routes through the named deliverer (``step.target`` → ``workflow.deliverers``): an ``s3``-producing target uploads the agent's result text to diff --git a/agent/tests/conftest.py b/agent/tests/conftest.py index 8d304f178..0ea21a8bb 100644 --- a/agent/tests/conftest.py +++ b/agent/tests/conftest.py @@ -14,7 +14,7 @@ # in the MAIN thread during a test's *call* phase, so a deadlock in a WORKER # thread, a fixture, collection, or a C-level socket read the main thread never # returns from stalls the whole `mise run build` silently — up to the platform's -# 3600s build-verify ceiling (the ECS-only stall on ABCA-684/686/688, and the +# 3600s build-verify ceiling (the ECS-only stall we chased for weeks, and the # scoped-session S3 hang the _clean_env reset below guards against: 40+ min of # dead air, container never reaped). # @@ -35,7 +35,7 @@ # # ``pytest_sessionfinish`` cancels the timer on a clean finish (below), so a # legitimately slow-but-passing run that lands near 600s — e.g. still in teardown -# / coverage write — is NOT hard-exited into a bewildering red (#616 review N2). +# / coverage write — is NOT hard-exited into a bewildering red. # ``os._exit`` skips atexit + buffer flush, so it must only fire on a TRUE hang. _HANG_REAP_DEADLINE_S = 600 @@ -59,7 +59,7 @@ def _reap_on_hang() -> None: def pytest_sessionfinish(session, exitstatus): - """Cancel the hang watchdog on a clean session finish (#616 review N2). + """Cancel the hang watchdog on a clean session finish. Without this, a legitimately slow-but-passing suite that finishes just after the 600s deadline (e.g. during teardown / coverage write) would be hard-exited diff --git a/agent/tests/test_aws_session.py b/agent/tests/test_aws_session.py index c57b1e23d..3a7a67d5d 100644 --- a/agent/tests/test_aws_session.py +++ b/agent/tests/test_aws_session.py @@ -84,7 +84,7 @@ def test_blank_role_arn_treated_as_unset(self, monkeypatch): # autouse fixture. This file's `_reset` fixture (above) itself does # `monkeypatch.delenv(SESSION_ROLE_ARN_ENV)`, which would MASK whether conftest's # `_clean_env` performs the scrub — a guard placed here passes even if the -# conftest line is deleted (#616 review B1). Only a fixture-free module truly +# conftest line is deleted. Only a fixture-free module truly # guards the fix. @@ -145,7 +145,7 @@ def _worker() -> None: # container memory pressure, or thread creation throttled) — every # survivor then hangs here and the main thread hangs in join() below, # stalling the whole `mise run build` until the 3600s ceiling. This is - # the ECS-only flaky hang chased across ABCA-684/686/688 (pytest-timeout + # the ECS-only flaky hang we chased for weeks (pytest-timeout # only fixed the SYMPTOM; this Barrier is the ROOT cause). A timeout # makes the barrier raise BrokenBarrierError so the test fails fast. start.wait(timeout=30) diff --git a/agent/tests/test_cedar_parity.py b/agent/tests/test_cedar_parity.py index b166adada..e9eccbfc3 100644 --- a/agent/tests/test_cedar_parity.py +++ b/agent/tests/test_cedar_parity.py @@ -23,7 +23,7 @@ # Hard import (not importorskip): the parity contract REQUIRES cedarpy. # A dependency regression that drops cedarpy must fail loudly, not be # silently skipped — skipping would let divergence reach production. -# See silent-failure audit finding #8 (Chunk 1 review, 2026-05-07). +# Surfaced by a silent-failure audit of this suite. import cedarpy import pytest @@ -115,8 +115,8 @@ def _recover_rule_ids(policies: str, matching_policy_ids: list[str]) -> list[str Dropping unannotated matches would silently hide genuine cross-engine disagreement (e.g. one engine matching the base ``permit`` alongside a ``forbid``) — the whole point of this test is to fail such disagreement, - not bury it. See silent-failure audit finding #1 (Chunk 1 review, - 2026-05-07). Fixture policies are expected to annotate every rule + not bury it (surfaced by a silent-failure audit of this suite). + Fixture policies are expected to annotate every rule including the base permit (``@rule_id("base_permit")``); a missing annotation raises rather than silently coerces to empty. """ diff --git a/agent/tests/test_channel_mcp.py b/agent/tests/test_channel_mcp.py index d7d3321aa..31d3f4dbb 100644 --- a/agent/tests/test_channel_mcp.py +++ b/agent/tests/test_channel_mcp.py @@ -1,4 +1,9 @@ -"""Unit tests for channel_mcp.configure_channel_mcp — Linear/Jira MCP gating + merge.""" +"""Unit tests for channel_mcp.configure_channel_mcp — Jira MCP gating + merge. + +Linear is NOT tested here: ABCA runs Linear 100% deterministically (ADR-016), +so there is no Linear MCP entry. The gate below asserts that channel_source=='linear' +is now a no-op (no .mcp.json written). +""" from __future__ import annotations @@ -9,10 +14,8 @@ JIRA_API_TOKEN_ENV, JIRA_MCP_SERVER_KEY, JIRA_MCP_URL, - LINEAR_API_TOKEN_ENV, - LINEAR_MCP_SERVER_KEY, - LINEAR_MCP_URL, configure_channel_mcp, + strip_linear_mcp_servers, ) @@ -23,7 +26,23 @@ def _read_mcp(repo_dir: str) -> dict: class TestChannelGate: - """Only channel_source=='linear' writes anything — everything else is a no-op.""" + """Only channel_source with a wired MCP writes anything — everything else is a no-op.""" + + def test_no_op_for_linear_channel(self, tmp_path): + # ADR-016: Linear is fully deterministic — no Linear MCP is written. + wrote = configure_channel_mcp(str(tmp_path), "linear") + assert wrote is False + assert not (tmp_path / ".mcp.json").exists() + + def test_no_op_for_linear_channel_ignores_gateway_url(self, tmp_path): + # A stale gateway_url in metadata must not resurrect a Linear MCP entry. + wrote = configure_channel_mcp( + str(tmp_path), + "linear", + {"gateway_url": "https://gw.example/mcp"}, + ) + assert wrote is False + assert not (tmp_path / ".mcp.json").exists() def test_no_op_for_slack_channel(self, tmp_path): wrote = configure_channel_mcp(str(tmp_path), "slack") @@ -46,101 +65,16 @@ def test_no_op_for_empty_channel(self, tmp_path): assert not (tmp_path / ".mcp.json").exists() -class TestLinearWrite: - """channel_source=='linear' writes .mcp.json with the linear-server entry.""" - - def test_creates_mcp_json_with_linear_server_key(self, tmp_path): - wrote = configure_channel_mcp(str(tmp_path), "linear") - assert wrote is True - config = _read_mcp(str(tmp_path)) - assert LINEAR_MCP_SERVER_KEY in config["mcpServers"] - - def test_renders_linear_url_and_token_placeholder(self, tmp_path): - configure_channel_mcp(str(tmp_path), "linear") - entry = _read_mcp(str(tmp_path))["mcpServers"][LINEAR_MCP_SERVER_KEY] - assert entry["type"] == "http" - assert entry["url"] == LINEAR_MCP_URL - assert entry["headers"]["Authorization"] == f"Bearer ${{{LINEAR_API_TOKEN_ENV}}}" - - def test_server_key_is_linear_server(self): - # If this ever changes, tools surface under a different mcp__ prefix and - # the agent prompt (prompt_builder._channel_prompt_addendum) must be - # updated in lockstep. - assert LINEAR_MCP_SERVER_KEY == "linear-server" - - -class TestMerge: - """Existing .mcp.json must not be clobbered.""" - - def test_adds_linear_to_existing_empty_mcp_json(self, tmp_path): - (tmp_path / ".mcp.json").write_text("{}") - wrote = configure_channel_mcp(str(tmp_path), "linear") - assert wrote is True - assert LINEAR_MCP_SERVER_KEY in _read_mcp(str(tmp_path))["mcpServers"] - - def test_preserves_existing_mcp_servers(self, tmp_path): - existing = { - "mcpServers": { - "other-server": {"type": "stdio", "command": "/usr/bin/my-mcp"}, - }, - } - (tmp_path / ".mcp.json").write_text(json.dumps(existing)) - - configure_channel_mcp(str(tmp_path), "linear") - merged = _read_mcp(str(tmp_path)) - assert "other-server" in merged["mcpServers"] - assert merged["mcpServers"]["other-server"]["command"] == "/usr/bin/my-mcp" - assert LINEAR_MCP_SERVER_KEY in merged["mcpServers"] - - def test_overwrites_existing_linear_server_entry(self, tmp_path): - # If someone committed a stale Linear entry with a wrong token var, we - # want the fresh ABCA-written entry to win — otherwise the MCP would - # fail to auth. - existing = { - "mcpServers": { - LINEAR_MCP_SERVER_KEY: { - "type": "http", - "url": "https://stale.example", - "headers": {"Authorization": "Bearer stale"}, - }, - }, - } - (tmp_path / ".mcp.json").write_text(json.dumps(existing)) - - configure_channel_mcp(str(tmp_path), "linear") - entry = _read_mcp(str(tmp_path))["mcpServers"][LINEAR_MCP_SERVER_KEY] - assert entry["url"] == LINEAR_MCP_URL - assert "stale" not in entry["headers"]["Authorization"] - - def test_tolerates_mcp_json_without_mcpservers_key(self, tmp_path): - # A .mcp.json that only has unrelated top-level keys should still - # gain an mcpServers map. - (tmp_path / ".mcp.json").write_text(json.dumps({"version": 1})) - configure_channel_mcp(str(tmp_path), "linear") - merged = _read_mcp(str(tmp_path)) - assert merged["version"] == 1 - assert LINEAR_MCP_SERVER_KEY in merged["mcpServers"] - - def test_malformed_mcp_json_is_replaced(self, tmp_path): - # Malformed JSON is treated as absent (logged as a warning in shell.log) - # rather than crashing the pipeline. - (tmp_path / ".mcp.json").write_text("{not json") - wrote = configure_channel_mcp(str(tmp_path), "linear") - assert wrote is True - merged = _read_mcp(str(tmp_path)) - assert LINEAR_MCP_SERVER_KEY in merged["mcpServers"] - - class TestRepoDirGuard: """Missing repo_dir must not raise — the pipeline should keep going.""" def test_missing_repo_dir(self, tmp_path): missing = tmp_path / "does-not-exist" - wrote = configure_channel_mcp(str(missing), "linear") + wrote = configure_channel_mcp(str(missing), "jira") assert wrote is False def test_empty_repo_dir_string(self): - wrote = configure_channel_mcp("", "linear") + wrote = configure_channel_mcp("", "jira") assert wrote is False @@ -169,6 +103,12 @@ def test_server_key_is_jira_server(self): class TestJiraMerge: """Jira entry must coexist with other servers and overwrite stale jira entries.""" + def test_adds_jira_to_existing_empty_mcp_json(self, tmp_path): + (tmp_path / ".mcp.json").write_text("{}") + wrote = configure_channel_mcp(str(tmp_path), "jira") + assert wrote is True + assert JIRA_MCP_SERVER_KEY in _read_mcp(str(tmp_path))["mcpServers"] + def test_preserves_existing_mcp_servers(self, tmp_path): existing = { "mcpServers": { @@ -200,13 +140,111 @@ def test_overwrites_existing_jira_server_entry(self, tmp_path): assert entry["url"] == JIRA_MCP_URL assert "stale" not in entry["headers"]["Authorization"] - def test_linear_and_jira_can_coexist(self, tmp_path): - # Belt-and-braces: a repo that committed a Linear entry and then - # gets onboarded to Jira (or vice-versa) must keep both. The current - # code path only writes one channel per run, but this test guards - # against a future refactor that writes the wrong key. - configure_channel_mcp(str(tmp_path), "linear") + def test_tolerates_mcp_json_without_mcpservers_key(self, tmp_path): + (tmp_path / ".mcp.json").write_text(json.dumps({"version": 1})) configure_channel_mcp(str(tmp_path), "jira") merged = _read_mcp(str(tmp_path)) - assert LINEAR_MCP_SERVER_KEY in merged["mcpServers"] + assert merged["version"] == 1 + assert JIRA_MCP_SERVER_KEY in merged["mcpServers"] + + def test_malformed_mcp_json_is_replaced(self, tmp_path): + # Malformed JSON is treated as absent (logged as a warning in shell.log) + # rather than crashing the pipeline. + (tmp_path / ".mcp.json").write_text("{not json") + wrote = configure_channel_mcp(str(tmp_path), "jira") + assert wrote is True + merged = _read_mcp(str(tmp_path)) assert JIRA_MCP_SERVER_KEY in merged["mcpServers"] + + +class TestStripLinearMcpServers: + """ADR-016 ENFORCEMENT (review finding #1): a repo can't smuggle a Linear MCP + server in via a committed .mcp.json — it's stripped before the SDK reads it.""" + + def test_removes_linear_server_by_key(self, tmp_path): + (tmp_path / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "linear-server": {"type": "http", "url": "https://mcp.linear.app/sse"}, + "other": {"command": "some-tool"}, + } + } + ) + ) + removed = strip_linear_mcp_servers(str(tmp_path)) + assert removed == 1 + servers = _read_mcp(str(tmp_path))["mcpServers"] + assert "linear-server" not in servers + assert "other" in servers # unrelated servers survive + + def test_removes_entry_named_innocuously_but_referencing_linear_url(self, tmp_path): + # A non-obvious key can't hide a Linear MCP — the value is scanned too. + # Linear was the ONLY server → the now-empty .mcp.json is deleted entirely. + (tmp_path / ".mcp.json").write_text( + json.dumps( + {"mcpServers": {"specs": {"type": "http", "url": "https://mcp.linear.app/sse"}}} + ) + ) + removed = strip_linear_mcp_servers(str(tmp_path)) + assert removed == 1 + assert not (tmp_path / ".mcp.json").exists() + + def test_keeps_file_when_other_servers_survive(self, tmp_path): + # A repo's legit non-Linear server means the file stays (Linear entry gone). + (tmp_path / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "linear-server": {"url": "https://mcp.linear.app/sse"}, + "my-tool": {"command": "/usr/bin/my-mcp"}, + } + } + ) + ) + removed = strip_linear_mcp_servers(str(tmp_path)) + assert removed == 1 + assert (tmp_path / ".mcp.json").exists() + assert _read_mcp(str(tmp_path))["mcpServers"] == {"my-tool": {"command": "/usr/bin/my-mcp"}} + + def test_keeps_file_when_other_top_level_keys_survive(self, tmp_path): + # Linear was the only server but the file carries other config → keep it, + # just with an empty mcpServers. + (tmp_path / ".mcp.json").write_text( + json.dumps( + { + "version": 3, + "mcpServers": {"linear-server": {"url": "https://mcp.linear.app/sse"}}, + } + ) + ) + removed = strip_linear_mcp_servers(str(tmp_path)) + assert removed == 1 + assert (tmp_path / ".mcp.json").exists() + merged = _read_mcp(str(tmp_path)) + assert merged["version"] == 3 + assert merged["mcpServers"] == {} + + def test_removes_entry_reading_linear_api_token(self, tmp_path): + (tmp_path / ".mcp.json").write_text( + json.dumps( + {"mcpServers": {"lin": {"command": "mcp", "env": {"TOKEN": "${LINEAR_API_TOKEN}"}}}} + ) + ) + removed = strip_linear_mcp_servers(str(tmp_path)) + assert removed == 1 + + def test_leaves_jira_and_other_servers_untouched(self, tmp_path): + configure_channel_mcp(str(tmp_path), "jira") # writes jira-server + removed = strip_linear_mcp_servers(str(tmp_path)) + assert removed == 0 + assert JIRA_MCP_SERVER_KEY in _read_mcp(str(tmp_path))["mcpServers"] + + def test_noop_when_no_file(self, tmp_path): + assert strip_linear_mcp_servers(str(tmp_path)) == 0 + + def test_noop_when_no_linear_entry(self, tmp_path): + (tmp_path / ".mcp.json").write_text( + json.dumps({"mcpServers": {"jira-server": {"type": "http", "url": JIRA_MCP_URL}}}) + ) + assert strip_linear_mcp_servers(str(tmp_path)) == 0 diff --git a/agent/tests/test_clarification_tool.py b/agent/tests/test_clarification_tool.py new file mode 100644 index 000000000..0f2c817d3 --- /dev/null +++ b/agent/tests/test_clarification_tool.py @@ -0,0 +1,33 @@ +"""Tests for the request_clarification in-process SDK tool (clarify-before-spend).""" + +from clarification_tool import ( + CLARIFICATION_SERVER_NAME, + CLARIFICATION_TOOL_NAME, + build_clarification_server, +) + + +class TestClarificationTool: + def test_tool_name_is_the_mcp_qualified_form(self): + # The runner matches on the fully-qualified mcp____ name. + assert f"mcp__{CLARIFICATION_SERVER_NAME}__request_clarification" == CLARIFICATION_TOOL_NAME + + def test_build_server_returns_sdk_config(self): + server = build_clarification_server() + # SDK present in the venv → a dict server config with the sdk type + name. + assert server is not None + assert server["type"] == "sdk" + assert server["name"] == CLARIFICATION_SERVER_NAME + assert "instance" in server + + def test_registered_tool_exposes_the_question_param(self): + # The registered tool must accept a ``question`` arg — that's what the + # runner reads off the ToolUseBlock as the clarifying question. + from claude_agent_sdk import tool + + @tool("request_clarification", "ask", {"question": str}) + async def rc(args): # pragma: no cover - handler body not exercised here + return {"content": [{"type": "text", "text": "ok"}]} + + assert rc.name == "request_clarification" + assert "question" in rc.input_schema diff --git a/agent/tests/test_config.py b/agent/tests/test_config.py index f1b630c54..a848e3a56 100644 --- a/agent/tests/test_config.py +++ b/agent/tests/test_config.py @@ -508,7 +508,7 @@ def test_network_failure_during_refresh_returns_stale_token(self, monkeypatch): monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) def test_corrupted_secret_json_returns_empty_with_error_log(self, monkeypatch): - """B3: corrupted SM payload → empty string return, no traceback.""" + """A corrupted SM payload → empty string return, no traceback.""" monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) monkeypatch.setenv("AWS_REGION", "us-east-1") diff --git a/agent/tests/test_conftest_env_scrub.py b/agent/tests/test_conftest_env_scrub.py index 9c515b989..7391e4b6a 100644 --- a/agent/tests/test_conftest_env_scrub.py +++ b/agent/tests/test_conftest_env_scrub.py @@ -1,4 +1,4 @@ -"""Fixture-free regression guard for the ECS test-hang scrub (#615 / #616 B1). +"""Fixture-free regression guard for the ECS test-hang scrub (#615 / #616). This module deliberately has NO local autouse fixture. It exists to prove that conftest's ``_clean_env`` autouse fixture strips ``AGENT_SESSION_ROLE_ARN`` from @@ -8,7 +8,7 @@ Why a separate module: ``test_aws_session.py`` has its OWN autouse ``_reset`` fixture that does ``monkeypatch.delenv(SESSION_ROLE_ARN_ENV)``, so a guard placed there passes even if the conftest scrub is deleted (it asserts what the local -fixture guarantees, not what the fix does — #616 review B1). Here, only conftest's +fixture guarantees, not what the fix does). Here, only conftest's ``_clean_env`` is in play. Why set the var at MODULE IMPORT time (not in the test body): pytest autouse diff --git a/agent/tests/test_entrypoint.py b/agent/tests/test_entrypoint.py index c25740006..96afdb3b2 100644 --- a/agent/tests/test_entrypoint.py +++ b/agent/tests/test_entrypoint.py @@ -500,3 +500,94 @@ def test_selects_pr_review_prompt(self): assert "READ-ONLY" in prompt assert "must NOT modify" in prompt assert "55" in prompt + + +# --------------------------------------------------------------------------- +# _build_system_prompt — Linear channel addendum +# --------------------------------------------------------------------------- + + +class TestBuildSystemPromptLinearChannel: + """The Linear-channel addendum is appended only for channel_source=='linear'.""" + + def _setup(self) -> RepoSetup: + return RepoSetup( + repo_dir="/workspace/t1", + branch="b", + default_branch="main", + notes=[], + ) + + def test_no_addendum_when_channel_is_blank(self): + config = TaskConfig( + repo_url="o/r", + task_id="t1", + max_turns=10, + github_token="ghp_test", + aws_region="us-east-1", + ) + prompt = _build_system_prompt(config, self._setup(), None, "") + assert "## Linear issue" not in prompt + + def test_no_addendum_for_slack_channel(self): + config = TaskConfig( + repo_url="o/r", + task_id="t1", + max_turns=10, + github_token="ghp_test", + aws_region="us-east-1", + channel_source="slack", + ) + prompt = _build_system_prompt(config, self._setup(), None, "") + assert "## Linear issue" not in prompt + + def test_addendum_present_for_linear_channel(self): + config = TaskConfig( + repo_url="o/r", + task_id="t1", + max_turns=10, + github_token="ghp_test", + aws_region="us-east-1", + channel_source="linear", + channel_metadata={ + "linear_issue_id": "issue-uuid-1", + "linear_issue_identifier": "ABC-42", + "linear_project_id": "project-uuid-1", + }, + ) + prompt = _build_system_prompt(config, self._setup(), None, "") + assert "## Linear issue" in prompt + assert "ABC-42" in prompt + + def test_linear_addendum_references_no_mcp_tools(self): + # ADR-016: Linear is fully deterministic — the agent has no Linear MCP. + # The addendum must NOT name any mcp__linear-server__* tool (a leftover + # reference would send the agent groping for a tool that doesn't exist). + config = TaskConfig( + repo_url="o/r", + task_id="t1", + max_turns=10, + github_token="ghp_test", + aws_region="us-east-1", + channel_source="linear", + channel_metadata={"linear_issue_id": "issue-uuid-1"}, + ) + prompt = _build_system_prompt(config, self._setup(), None, "") + assert "mcp__linear-server" not in prompt + + def test_linear_addendum_states_context_prehydrated_and_status_automatic(self): + # The agent must be told (a) inbound context is already provided (nothing + # to fetch) and (b) not to post Linear comments or change state. + config = TaskConfig( + repo_url="o/r", + task_id="t1", + max_turns=10, + github_token="ghp_test", + aws_region="us-east-1", + channel_source="linear", + channel_metadata={"linear_issue_id": "i"}, + ) + prompt = _build_system_prompt(config, self._setup(), None, "") + assert "Context is already here" in prompt + assert "Status is automatic" in prompt + assert "Do NOT post Linear comments" in prompt diff --git a/agent/tests/test_hooks.py b/agent/tests/test_hooks.py index 075fd5f23..f78f0588e 100644 --- a/agent/tests/test_hooks.py +++ b/agent/tests/test_hooks.py @@ -8,6 +8,7 @@ cedarpy = pytest.importorskip("cedarpy") from hooks import ( + _is_self_reclone, _reset_blocker_reason_for_tests, _stuck_guard_between_turns_hook, build_hook_matchers, @@ -194,6 +195,193 @@ def test_denies_direct_non_dict_tool_input(self): ) +class TestSelfRecloneGuard: + """Lost-deliverable defense, layer 1: block a Bash re-clone of the task's + OWN repo (the agent + sometimes cloned into a sibling dir and stranded its work off the tracked + branch). Scoped to the task repo — dependency/fixture clones must pass.""" + + def test_matches_gh_repo_clone_bare_slug(self): + assert _is_self_reclone("cd /workspace && gh repo clone owner/repo", "owner/repo") + + def test_matches_git_clone_https_url(self): + assert _is_self_reclone("git clone https://github.com/owner/repo.git /tmp/x", "owner/repo") + + def test_matches_git_clone_scp_form(self): + assert _is_self_reclone("git clone git@github.com:owner/repo.git", "owner/repo") + + def test_matches_git_dash_c_clone(self): + assert _is_self_reclone("git -C /workspace clone owner/repo", "owner/repo") + + def test_case_insensitive_and_dotgit_config(self): + # config.repo_url may carry a trailing .git or mixed case. + assert _is_self_reclone("gh repo clone Owner/Repo", "owner/repo.git") + + def test_ignores_clone_of_a_different_repo(self): + # A dependency/fixture clone is legitimate and must NOT be blocked. + assert not _is_self_reclone("git clone https://github.com/other/dep.git", "owner/repo") + + def test_ignores_non_clone_git_command(self): + assert not _is_self_reclone("git status && git commit -am wip", "owner/repo") + + def test_ignores_mention_of_repo_without_clone_verb(self): + # Naming the repo in a non-clone command (e.g. a gh pr create) is fine. + assert not _is_self_reclone("gh pr create --repo owner/repo --title x", "owner/repo") + + def test_no_repo_url_is_noop(self): + assert not _is_self_reclone("gh repo clone owner/repo", "") + + def test_ignores_clone_phrase_inside_pr_body(self): + # Observed false positive: the clone command quoted inside a --body value + # is PROSE, not an executed clone. Must NOT be blocked. + cmd = ( + 'gh pr create --repo owner/repo --base main --title "add marker" ' + '--body "I deliberately did NOT run gh repo clone owner/repo; I worked in place."' + ) + assert not _is_self_reclone(cmd, "owner/repo") + + def test_ignores_clone_phrase_in_body_file_and_commit_message(self): + assert not _is_self_reclone( + "gh pr create --repo owner/repo --body-file /tmp/b.md", "owner/repo" + ) + assert not _is_self_reclone( + 'git commit -m "note: do not gh repo clone owner/repo again"', "owner/repo" + ) + + def test_still_blocks_clone_before_a_body_arg(self): + # A REAL clone chained before a body-carrying command must still be caught + # (the free-text truncation only drops what's AFTER the first body arg). + cmd = 'gh repo clone owner/repo && gh pr create --repo owner/repo --body "x"' + assert _is_self_reclone(cmd, "owner/repo") + + def test_blocks_clone_using_the_branch_short_flag(self): + # ``-b`` is git clone's own ``--branch`` short form AND gh's ``--body``. + # The guard used to cut the command at the first free-text flag BEFORE + # looking for the clone verb, so this ordinary command slipped through + # entirely — reopening the stranded-work failure the guard exists to stop. + assert _is_self_reclone( + "git clone -b main https://github.com/owner/repo /w/repo", "owner/repo" + ) + assert _is_self_reclone("gh repo clone -b main owner/repo", "owner/repo") + assert _is_self_reclone( + "git clone -b feature/x git@github.com:owner/repo.git /w/repo", "owner/repo" + ) + assert _is_self_reclone( + "cd /tmp && git clone -b main --depth 1 https://github.com/owner/repo x", + "owner/repo", + ) + + def test_blocks_clone_chained_after_an_unrelated_message_flag(self): + # The ``-m`` belongs to the commit, not to the clone that follows it. A + # free-text argument must only swallow the rest of ITS OWN shell segment. + assert _is_self_reclone( + "git commit -m wip && gh repo clone owner/repo /w/repo", "owner/repo" + ) + + def test_still_ignores_prose_after_a_body_flag_in_the_same_segment(self): + # The counterpart: within one segment, a verb appearing after the body + # value opens is prose. These must stay allowed. + assert not _is_self_reclone( + 'gh pr create --body "do not run gh repo clone owner/repo"', "owner/repo" + ) + assert not _is_self_reclone( + "gh issue comment -m 'see gh repo clone owner/repo for context'", "owner/repo" + ) + + def test_blocks_a_clone_wrapped_across_lines_with_a_backslash(self): + """A trailing backslash continues the SAME command onto the next line. + + Splitting on a bare newline separated the verb from the repo, so the slug + was never found in the verb's segment and a wrapped self re-clone walked + through — including the ``-b`` form this guard was hardened to catch.""" + assert _is_self_reclone( + "git clone \\\n https://github.com/owner/repo /w/repo", "owner/repo" + ) + assert _is_self_reclone( + "git clone -b main \\\n https://github.com/owner/repo /w/repo", "owner/repo" + ) + assert _is_self_reclone( + "git clone --depth 1 \\\n https://github.com/owner/repo x", "owner/repo" + ) + # The continuation can split the VERB itself, not just its arguments — + # this is the case that needs the joining, since no amount of segment + # handling reunites ``git`` with ``clone`` across the break. + assert _is_self_reclone("git \\\n clone https://github.com/owner/repo x", "owner/repo") + + def test_ignores_a_clone_quoted_inside_a_MULTI_LINE_body(self): + """A multi-line --body/-m value is the normal way an agent writes a PR body. + + Its quoted text routinely documents a clone command. Treating a bare + newline as a command separator put that line in its own segment with no + preceding free-text flag, so a legitimate ``gh pr create`` was denied.""" + body = "## Setup\ngit clone https://github.com/owner/repo\ncd repo\nmise run setup" + assert not _is_self_reclone( + f'gh pr create --title "docs: onboarding" --body "{body}"', + "owner/repo", + ) + assert not _is_self_reclone( + 'git commit -m "steps:\n git clone https://github.com/owner/repo"', + "owner/repo", + ) + + def test_requires_command_position_not_substring(self): + # The verb must be in command position (start / after a separator), not an + # arbitrary substring like a path or flag value. + assert _is_self_reclone("echo hi; gh repo clone owner/repo", "owner/repo") + assert not _is_self_reclone("ls /tmp/gh-repo-clone-notes/owner/repo", "owner/repo") + + def test_hook_denies_self_reclone_with_redirect(self): + engine = PolicyEngine(task_type="new_task", repo="owner/repo") + hook_input = { + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "cd /workspace && gh repo clone owner/repo"}, + "tool_use_id": "test-reclone", + "session_id": "sess-1", + "transcript_path": "/tmp/t", + "cwd": "/workspace", + } + result = _run( + pre_tool_use_hook(hook_input, "test-reclone", {}, engine=engine, repo_url="owner/repo") + ) + assert result["hookSpecificOutput"]["permissionDecision"] == "deny" + reason = result["hookSpecificOutput"]["permissionDecisionReason"] + assert "already cloned" in reason.lower() + assert "work in place" in reason.lower() + + def test_hook_allows_dependency_clone(self): + engine = PolicyEngine(task_type="new_task", repo="owner/repo") + hook_input = { + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "git clone https://github.com/other/dep.git vendor/dep"}, + "tool_use_id": "test-dep", + "session_id": "sess-1", + "transcript_path": "/tmp/t", + "cwd": "/workspace", + } + result = _run( + pre_tool_use_hook(hook_input, "test-dep", {}, engine=engine, repo_url="owner/repo") + ) + # Not blocked by the reclone guard — falls through to Cedar (which permits). + assert result["hookSpecificOutput"]["permissionDecision"] == "allow" + + def test_hook_without_repo_url_does_not_block(self): + # Legacy call shape (no repo_url threaded) must not crash or over-block. + engine = PolicyEngine(task_type="new_task", repo="owner/repo") + hook_input = { + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "gh repo clone owner/repo"}, + "tool_use_id": "test-legacy", + "session_id": "sess-1", + "transcript_path": "/tmp/t", + "cwd": "/workspace", + } + result = _run(pre_tool_use_hook(hook_input, "test-legacy", {}, engine=engine)) + assert result["hookSpecificOutput"]["permissionDecision"] == "allow" + + class TestTruncate: def test_returns_text_when_under_max(self): from hooks import _truncate @@ -1703,7 +1891,7 @@ def test_unparseable_started_at_returns_none(self, monkeypatch): class TestStuckGuardHookIntegration: - """K7: PostToolUse feeds the guard; the between-turns hook steers (advisory).""" + """PostToolUse feeds the guard; the between-turns hook steers (advisory).""" def _oom(self): return "[//cdk:test] FAILED (exit 134)\nJavaScript heap out of memory" @@ -1725,7 +1913,7 @@ def test_post_tool_use_records_failures_into_the_guard(self): assert guard.evaluate().kind == "steer" def test_between_turns_hook_latches_stuck_summary_from_the_guard(self): - # #599 N2: pin the PRODUCTION write path for the _LAST_STUCK_SUMMARY latch + # Pin the PRODUCTION write path for the _LAST_STUCK_SUMMARY latch # (hooks.py:1464-1465). The enrichment tests monkeypatch the getter, so # without this test deleting those two lines would leave the latch # permanently None and every test would still pass. Drive the real hook diff --git a/agent/tests/test_linear_reactions.py b/agent/tests/test_linear_reactions.py index 9b47f9ce6..2f545e6d2 100644 --- a/agent/tests/test_linear_reactions.py +++ b/agent/tests/test_linear_reactions.py @@ -3,6 +3,7 @@ from __future__ import annotations import threading +from typing import ClassVar from unittest.mock import MagicMock, patch import pytest @@ -520,3 +521,137 @@ def test_403_treated_same_as_401(self, monkeypatch): for _ in range(3): linear_reactions._graphql("query Q { x }", {}) assert linear_reactions._auth_circuit_open is True + + +class TestTransitionIssueState: + """Workflow-state transition: a writeable single task moves the issue + Backlog → In Progress → + In Review, forward-only. Mocks ``_graphql`` directly so we assert on the + decision logic, not the wire format (covered elsewhere).""" + + _STATES: ClassVar[list[dict]] = [ + {"id": "s-backlog", "name": "Backlog", "type": "backlog", "position": 0}, + {"id": "s-todo", "name": "Todo", "type": "unstarted", "position": 1}, + {"id": "s-prog", "name": "In Progress", "type": "started", "position": 2}, + {"id": "s-review", "name": "In Review", "type": "started", "position": 1002}, + {"id": "s-done", "name": "Done", "type": "completed", "position": 3}, + ] + + def _issue(self, current_state: dict) -> dict: + return {"issue": {"state": current_state, "team": {"states": {"nodes": self._STATES}}}} + + def _cur(self, name: str) -> dict: + return next(s for s in self._STATES if s["name"] == name) + + def test_backlog_to_in_progress_moves_forward(self): + set_calls = [] + + def fake_graphql(query, variables): + if "IssueStates" in query: + return self._issue(self._cur("Backlog")) + set_calls.append(variables) + return {"issueUpdate": {"success": True}} + + with patch("linear_reactions._graphql", side_effect=fake_graphql): + linear_reactions._transition_issue_state("issue-1", "started", ["In Progress"]) + assert len(set_calls) == 1 + assert set_calls[0]["stateId"] == "s-prog" # preferred name wins + + def test_in_progress_to_in_review_on_finish(self): + set_calls = [] + + def fake_graphql(query, variables): + if "IssueStates" in query: + return self._issue(self._cur("In Progress")) + set_calls.append(variables) + return {"issueUpdate": {"success": True}} + + with patch("linear_reactions._graphql", side_effect=fake_graphql): + linear_reactions._transition_issue_state("issue-1", "started", ["In Review"]) + assert set_calls[0]["stateId"] == "s-review" + + def test_never_demotes_a_completed_issue(self): + """A human already marked it Done — a start transition must NOT reopen it.""" + set_calls = [] + + def fake_graphql(query, variables): + if "IssueStates" in query: + return self._issue(self._cur("Done")) + set_calls.append(variables) + return {"issueUpdate": {"success": True}} + + with patch("linear_reactions._graphql", side_effect=fake_graphql): + linear_reactions._transition_issue_state("issue-1", "started", ["In Progress"]) + assert set_calls == [] # backward move (completed → started) skipped + + def test_no_op_when_already_in_target_state(self): + set_calls = [] + + def fake_graphql(query, variables): + if "IssueStates" in query: + return self._issue(self._cur("In Progress")) + set_calls.append(variables) + return {"issueUpdate": {"success": True}} + + with patch("linear_reactions._graphql", side_effect=fake_graphql): + linear_reactions._transition_issue_state("issue-1", "started", ["In Progress"]) + assert set_calls == [] + + def test_gate_off_means_started_does_not_transition(self, monkeypatch): + """react_task_started with transition_state=False posts 👀 but never + touches issue state (the read_only/planning path).""" + monkeypatch.setenv("LINEAR_API_TOKEN", "lin_api_test") + with ( + patch("linear_reactions._transition_issue_state") as trans, + patch("linear_reactions.requests.post", side_effect=_clean_start_calls("r-x")), + ): + react_task_started("linear", {"linear_issue_id": "issue-1"}, transition_state=False) + _join_sweep_thread() + trans.assert_not_called() + + def test_gate_on_transitions_started_to_in_progress(self, monkeypatch): + monkeypatch.setenv("LINEAR_API_TOKEN", "lin_api_test") + with ( + patch("linear_reactions._transition_issue_state") as trans, + patch("linear_reactions.requests.post", side_effect=_clean_start_calls("r-x")), + ): + react_task_started("linear", {"linear_issue_id": "issue-1"}, transition_state=True) + _join_sweep_thread() + trans.assert_called_once_with("issue-1", "started", ["In Progress"]) + + def test_finish_success_transitions_to_in_review_when_gated(self, monkeypatch): + monkeypatch.setenv("LINEAR_API_TOKEN", "lin_api_test") + with ( + patch("linear_reactions._transition_issue_state") as trans, + patch( + "linear_reactions.requests.post", + side_effect=[_ok_delete_response(), _ok_response("r-term")], + ), + ): + react_task_finished( + "linear", + {"linear_issue_id": "issue-1"}, + success=True, + started_reaction_id="r-eyes", + transition_state=True, + ) + trans.assert_called_once_with("issue-1", "started", ["In Review"]) + + def test_finish_failure_does_not_transition(self, monkeypatch): + """A failed run leaves the state (In Progress) — the ❌ conveys it.""" + monkeypatch.setenv("LINEAR_API_TOKEN", "lin_api_test") + with ( + patch("linear_reactions._transition_issue_state") as trans, + patch( + "linear_reactions.requests.post", + side_effect=[_ok_delete_response(), _ok_response("r-term")], + ), + ): + react_task_finished( + "linear", + {"linear_issue_id": "issue-1"}, + success=False, + started_reaction_id="r-eyes", + transition_state=True, + ) + trans.assert_not_called() diff --git a/agent/tests/test_models.py b/agent/tests/test_models.py index 49cbd93a1..fd05818fe 100644 --- a/agent/tests/test_models.py +++ b/agent/tests/test_models.py @@ -282,6 +282,21 @@ def test_required_fields(self): assert config.is_pr_workflow is False assert config.cedar_policies == [] assert config.issue is None + # Defaults for stacked-child fields (#247). + assert config.base_branch is None + assert config.merge_branches == [] + + def test_stacked_child_base_branch_fields(self): + # Diamond child: base off main + predecessor branches to merge in. + config = TaskConfig( + repo_url="owner/repo", + github_token="ghp_test", + aws_region="us-east-1", + base_branch="main", + merge_branches=["bgagent/taskB/b", "bgagent/taskC/c"], + ) + assert config.base_branch == "main" + assert config.merge_branches == ["bgagent/taskB/b", "bgagent/taskC/c"] def test_mutable_assignment(self): config = TaskConfig( diff --git a/agent/tests/test_nudge_hook.py b/agent/tests/test_nudge_hook.py index b116563fd..96aafd52e 100644 --- a/agent/tests/test_nudge_hook.py +++ b/agent/tests/test_nudge_hook.py @@ -321,7 +321,7 @@ def test_multiple_hooks_joined(self): def test_registry_default_contains_cancel_then_nudge(self): # Freshly-imported registry: cancel runs FIRST so it short-circuits # nudge injection on cancelled tasks; nudge runs AFTER it for running - # tasks. The K7 stuck-guard is inserted between them (it also wants to + # tasks. The stuck-guard is inserted between them (it also wants to # short-circuit before the nudge reader mutates DDB on a bail), so the # invariant we assert is the relative ORDER (cancel < stuck-guard < # nudge), not exact adjacency. diff --git a/agent/tests/test_pipeline.py b/agent/tests/test_pipeline.py index 58644fa18..93cc435fe 100644 --- a/agent/tests/test_pipeline.py +++ b/agent/tests/test_pipeline.py @@ -8,6 +8,7 @@ from models import AgentResult, RepoSetup, TaskConfig from pipeline import _chain_prior_agent_error, _resolve_overall_task_status +from post_hooks import VerifyOutcome # Minimal Linear channel metadata for the early-ACK ordering tests. _LINEAR_META = {"issue_id": "ABCA-1", "workspace_id": "ws-1"} @@ -56,8 +57,8 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch( "pipeline.ensure_pr", return_value="https://github.com/org/repo/pull/1", @@ -124,8 +125,8 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch( "pipeline.ensure_pr", return_value="https://github.com/org/repo/pull/1", @@ -194,8 +195,8 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch( "pipeline.ensure_pr", return_value="https://github.com/org/repo/pull/1", @@ -532,8 +533,8 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", return_value="https://github.com/org/repo/pull/1"), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), @@ -555,6 +556,98 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N assert result["status"] == "success" assert result["pr_url"] == "https://github.com/org/repo/pull/1" + @patch("runner.run_agent") + @patch("pipeline.build_system_prompt") + @patch("pipeline.discover_project_config") + @patch("repo.setup_repo") + @patch("pipeline.task_span") + @patch("pipeline.task_state") + def test_repoful_artifact_workflow_delivers_artifact_and_skips_pr( + self, + _mock_task_state, + mock_task_span, + mock_setup_repo, + _mock_discover, + _mock_build_prompt, + mock_run_agent, + monkeypatch, + ): + # A REPO-FUL artifact workflow clones for context but its terminal outcome + # is a DOCUMENT, not a PR. It must take the repo-bound path (clone), then + # deliver the result as an artifact and SKIP the build/PR post-hooks. + # + # No such workflow ships today, so this uses a synthetic one: the branch is + # selected by the workflow CONTRACT (terminal_outcomes.primary == artifact + # AND requires_repo), not by a workflow id, and without a test the branch + # could be deleted or inverted silently. Getting it wrong means opening an + # empty PR for a document task, or running a build that was never wanted. + monkeypatch.setenv("GITHUB_TOKEN", "ghp_test") + monkeypatch.setenv("AWS_REGION", "us-east-1") + mock_setup_repo.return_value = RepoSetup( + repo_dir="/workspace/repo", + branch="bgagent/test/branch", + build_before=True, + ) + artifact_text = '{"summary": "two features", "items": []}' + + async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=None): + return AgentResult( + status="success", turns=3, cost_usd=0.05, num_turns=3, result_text=artifact_text + ) + + mock_run_agent.side_effect = fake_run_agent + mock_task_span.return_value = self._mock_span() + + # Synthesize the workflow by copying the real coding workflow and flipping + # ONLY the two fields that select this branch, so the test cannot drift from + # the production contract the way a hand-built stub would. + from workflow import load_workflow as real_load_workflow + + base_wf = real_load_workflow("coding/new-task-v1") + artifact_wf = base_wf.model_copy( + update={ + "id": "synthetic/artifact-repoful-v1", + "requires_repo": True, + "terminal_outcomes": base_wf.terminal_outcomes.model_copy( + update={"primary": "artifact"} + ), + } + ) + + with ( + patch("workflow.load_workflow", return_value=artifact_wf), + patch( + "pipeline._deliver_plan_artifact", + return_value="s3://artifacts-bkt/artifacts/artifact-1/result.md", + ) as mock_deliver, + patch("pipeline.ensure_pr") as mock_ensure_pr, + patch("pipeline.verify_build") as mock_verify_build, + patch("pipeline.ensure_committed") as mock_ensure_committed, + patch("pipeline.get_disk_usage", return_value=0), + patch("pipeline.print_metrics"), + patch("pipeline._maybe_upload_trace", return_value=None), + ): + from pipeline import run_task + + result = run_task( + repo_url="owner/repo", + task_description="Add auth + billing + admin", + github_token="ghp_test", + aws_region="us-east-1", + task_id="artifact-1", + resolved_workflow={"id": "synthetic/artifact-repoful-v1", "version": "1.0.0"}, + ) + + # Repo-bound path ran (clone), plan delivered as artifact, PR/build skipped. + mock_setup_repo.assert_called_once() + mock_deliver.assert_called_once() + mock_ensure_pr.assert_not_called() + mock_verify_build.assert_not_called() + mock_ensure_committed.assert_not_called() + assert result["status"] == "success" + assert result["pr_url"] is None + assert result["artifact_uri"] == "s3://artifacts-bkt/artifacts/artifact-1/result.md" + class TestChainPriorAgentError: def test_none_agent_result_returns_exception_only(self): @@ -605,6 +698,28 @@ def test_success_with_build_failed(self): assert "agent_status='success'" in err assert "build_ok=False" in err + def test_success_with_build_TIMED_OUT_marks_timeout_distinctly(self): + # A build that exceeded the time limit must read as a + # TIMEOUT, not a generic build failure. The error_message carries + # ``build_ok=timeout`` so the platform's failure copy says "timed out". + ar = AgentResult(status="success") + status, err = _resolve_overall_task_status( + ar, build_ok=False, pr_url="https://pr", build_timed_out=True + ) + assert status == "error" + assert err is not None + assert "build_ok=timeout" in err + assert "build_ok=False" not in err # not the generic-failure marker + + def test_build_failed_but_not_timeout_keeps_false_marker(self): + ar = AgentResult(status="success") + _, err = _resolve_overall_task_status( + ar, build_ok=False, pr_url="https://pr", build_timed_out=False + ) + assert err is not None + assert "build_ok=False" in err + assert "timeout" not in err + def test_unknown_always_error_even_with_pr_and_build(self): """agent_status=unknown must always fail — never infer success from PR/build.""" ar = AgentResult(status="unknown") @@ -760,8 +875,8 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", mock_ensure_pr), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), @@ -783,6 +898,129 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= mock_ensure_pr.assert_called_once() + @patch("runner.run_agent") + @patch("pipeline.build_system_prompt") + @patch("pipeline.discover_project_config") + @patch("repo.setup_repo") + @patch("pipeline.task_span") + def test_jira_card_not_moved_to_in_review_on_build_failure( + self, + mock_task_span, + mock_setup_repo, + _mock_discover, + _mock_build_prompt, + mock_run_agent, + monkeypatch, + ): + """review blocker #9a: ensure_pr opens a PR even on a FAILED build (so the + human sees the broken diff), so the Jira In Progress → In Review transition + must gate on build_passed — not merely on pr_url — or the board lies that + the work is ready for review. Mirrors the Linear success-only twin.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_test") + monkeypatch.setenv("AWS_REGION", "us-east-1") + mock_setup_repo.return_value = RepoSetup( + repo_dir="/workspace/repo", + branch="bgagent/test/branch", + build_before=True, + ) + + async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory=None): + return AgentResult(status="success", turns=2, cost_usd=0.01, num_turns=2) + + mock_run_agent.side_effect = fake_run_agent + + mock_span = MagicMock() + mock_span.__enter__ = MagicMock(return_value=mock_span) + mock_span.__exit__ = MagicMock(return_value=False) + mock_task_span.return_value = mock_span + + mock_transition = MagicMock() + with ( + patch("pipeline.ensure_committed", return_value=False), + # FAILED build — ensure_pr still opens the PR below. + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=False)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), + patch("pipeline.ensure_pr", return_value="https://github.com/o/r/pull/9"), + patch("pipeline.transition_pr_opened", mock_transition), + patch("pipeline.get_disk_usage", return_value=0), + patch("pipeline.print_metrics"), + patch("pipeline.task_state") as mock_task_state_mod, + ): + mock_task_state_mod.get_task = MagicMock(return_value={"status": "RUNNING"}) + mock_task_state_mod.TaskFetchError = Exception # type: ignore[attr-defined] + from pipeline import run_task + + run_task( + repo_url="o/r", + task_description="x", + github_token="ghp_test", + aws_region="us-east-1", + task_id="t-jira-failbuild", + channel_source="jira", + channel_metadata={"jira_issue_key": "ABC-1"}, + ) + # PR opened, but the Jira card was NOT advanced to In Review on the red build. + mock_transition.assert_not_called() + + @patch("runner.run_agent") + @patch("pipeline.build_system_prompt") + @patch("pipeline.discover_project_config") + @patch("repo.setup_repo") + @patch("pipeline.task_span") + def test_jira_card_moved_to_in_review_on_build_success( + self, + mock_task_span, + mock_setup_repo, + _mock_discover, + _mock_build_prompt, + mock_run_agent, + monkeypatch, + ): + """The positive twin: a PASSING build DOES move the Jira card to In Review.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_test") + monkeypatch.setenv("AWS_REGION", "us-east-1") + mock_setup_repo.return_value = RepoSetup( + repo_dir="/workspace/repo", + branch="bgagent/test/branch", + build_before=True, + ) + + async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory=None): + return AgentResult(status="success", turns=2, cost_usd=0.01, num_turns=2) + + mock_run_agent.side_effect = fake_run_agent + + mock_span = MagicMock() + mock_span.__enter__ = MagicMock(return_value=mock_span) + mock_span.__exit__ = MagicMock(return_value=False) + mock_task_span.return_value = mock_span + + mock_transition = MagicMock() + with ( + patch("pipeline.ensure_committed", return_value=False), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), + patch("pipeline.ensure_pr", return_value="https://github.com/o/r/pull/9"), + patch("pipeline.transition_pr_opened", mock_transition), + patch("pipeline.get_disk_usage", return_value=0), + patch("pipeline.print_metrics"), + patch("pipeline.task_state") as mock_task_state_mod, + ): + mock_task_state_mod.get_task = MagicMock(return_value={"status": "RUNNING"}) + mock_task_state_mod.TaskFetchError = Exception # type: ignore[attr-defined] + from pipeline import run_task + + run_task( + repo_url="o/r", + task_description="x", + github_token="ghp_test", + aws_region="us-east-1", + task_id="t-jira-okbuild", + channel_source="jira", + channel_metadata={"jira_issue_key": "ABC-1"}, + ) + mock_transition.assert_called_once() + @patch("runner.run_agent") @patch("pipeline.build_system_prompt") @patch("pipeline.discover_project_config") @@ -837,8 +1075,8 @@ def flaky_load(workflow_id): with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", mock_ensure_pr), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), @@ -869,7 +1107,7 @@ def _running_span() -> MagicMock: # --------------------------------------------------------------------------- -# Chunk K1 — trace threading into TaskConfig (design §10.1) +# Trace threading into TaskConfig (design §10.1) # --------------------------------------------------------------------------- @@ -921,8 +1159,8 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch( "pipeline.ensure_pr", return_value="https://github.com/org/repo/pull/1", @@ -989,8 +1227,8 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch( "pipeline.ensure_pr", return_value="https://github.com/org/repo/pull/1", @@ -1056,8 +1294,8 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch( "pipeline.ensure_pr", return_value="https://github.com/org/repo/pull/1", @@ -1124,8 +1362,8 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch( "pipeline.ensure_pr", return_value="https://github.com/org/repo/pull/1", @@ -1149,7 +1387,7 @@ async def fake_run_agent(_prompt, _system_prompt, config, cwd=None, trajectory=N class TestTraceS3Upload: - """K2 Stage 4 — pipeline triggers the S3 trace upload only when + """Pipeline triggers the S3 trace upload only when ``trace=True`` AND ``user_id`` is non-empty; threads the resulting ``trace_s3_uri`` into ``task_state.write_terminal`` so the TaskRecord update is atomic with terminal-status.""" @@ -1197,8 +1435,8 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", return_value=None), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), @@ -1267,8 +1505,8 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", return_value=None), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), @@ -1338,8 +1576,8 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", return_value=None), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), @@ -1403,9 +1641,13 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= with ( patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), - patch("pipeline.ensure_pr", return_value=None), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), + # A DELIVERED task (a PR was opened) — this test is about trace-upload + # fail-open, not the no-deliverable delivery gate. Returning a PR keeps + # the delivery gate out of the picture so the assertion isolates the + # trace behavior (a real no-PR task is covered in TestDeliveryGate). + patch("pipeline.ensure_pr", return_value="https://github.com/owner/repo/pull/1"), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), ): @@ -1730,7 +1972,7 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= with ( patch("pipeline.ensure_committed", return_value=False), patch("pipeline.verify_build", side_effect=RuntimeError("build verify boom")), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", return_value=None), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), @@ -1808,7 +2050,7 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= with ( patch("pipeline.ensure_committed", return_value=False), patch("pipeline.verify_build", side_effect=ValueError("original pipeline error")), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", return_value=None), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), @@ -1919,8 +2161,8 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory= patch("pipeline.transition_task_started") as m_transition, patch("pipeline.configure_channel_mcp"), patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=True), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", return_value="https://github.com/o/r/pull/1"), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), diff --git a/agent/tests/test_pipeline_outcomes.py b/agent/tests/test_pipeline_outcomes.py index d2b89a6a8..f3fb0001d 100644 --- a/agent/tests/test_pipeline_outcomes.py +++ b/agent/tests/test_pipeline_outcomes.py @@ -2,15 +2,47 @@ import pytest +from config import NEEDS_INPUT_MARKER from hooks import _record_blocker_reason, _reset_blocker_reason_for_tests from models import AgentResult from pipeline import ( + _apply_delivery_gate, _chain_prior_agent_error, _compute_turns_completed, _resolve_overall_task_status, + _starts_with_needs_input_marker, + _strip_needs_input_marker, ) +class TestNeedsInputMarker: + """Clarify-before-spend (UX #4): detect + strip the hold-and-ask marker.""" + + def test_detects_marker_on_first_line(self): + text = f"{NEEDS_INPUT_MARKER}\nWhich page feels slow — the dashboard or the list?" + assert _starts_with_needs_input_marker(text) is True + + def test_detects_marker_after_leading_blank_lines(self): + text = f"\n\n{NEEDS_INPUT_MARKER} What target latency are you aiming for?" + assert _starts_with_needs_input_marker(text) is True + + def test_ignores_marker_buried_mid_message(self): + # A stray mention deep in prose is NOT a hold signal — only the first line. + text = f"I made the change.\nBy the way {NEEDS_INPUT_MARKER} is our sentinel." + assert _starts_with_needs_input_marker(text) is False + + def test_none_or_empty_is_not_a_hold(self): + assert _starts_with_needs_input_marker(None) is False + assert _starts_with_needs_input_marker("") is False + assert _starts_with_needs_input_marker("Just a normal answer.") is False + + def test_strip_removes_leading_marker_only(self): + text = f"{NEEDS_INPUT_MARKER}\nWhich part is slow?" + assert _strip_needs_input_marker(text) == "Which part is slow?" + # Idempotent-ish: no marker → unchanged (trimmed). + assert _strip_needs_input_marker(" plain question? ") == "plain question?" + + @pytest.fixture(autouse=True) def _reset_blocker_latch(): """#251 carry-path latch is module-level; reset around every test so a @@ -27,6 +59,33 @@ def test_success_end_turn_with_build_ok(self): assert overall == "success" assert err is None + def test_infra_failed_build_forces_error_even_when_gate_would_pass(self): + # The build was killed by ENOSPC/OOM (build_infra_failed). + # Even if the regression-only gate would pass (build_ok=True — e.g. the + # pre-agent baseline was ALSO infra-killed, so "already red → not a + # regression"), we must NOT report a false ✅ on unverified code. Forces + # an error with a build_ok=infra marker for the platform's honest copy. + ar = AgentResult(status="success", error=None) + overall, err = _resolve_overall_task_status( + ar, + build_ok=True, + pr_url="https://pr", + build_infra_failed=True, + ) + assert overall == "error" + assert "build_ok=infra" in (err or "") + + def test_infra_failed_marker_present_when_gate_also_fails(self): + ar = AgentResult(status="end_turn", error=None) + overall, err = _resolve_overall_task_status( + ar, + build_ok=False, + pr_url=None, + build_infra_failed=True, + ) + assert overall == "error" + assert "build_ok=infra" in (err or "") + def test_unknown_is_always_error_even_with_pr(self): ar = AgentResult(status="unknown", error=None) overall, err = _resolve_overall_task_status( @@ -102,6 +161,98 @@ def test_error_status_preserves_bedrock_entitlement_message(self): assert "not available" in err +class TestDeliveryGate: + """A create-strategy new-work task that reported success but shipped + NOTHING (no PR AND no commit) must be failed loudly, not COMPLETED — + otherwise the platform reports success for work that was lost. + + Common args factory keeps each case to just the axis under test.""" + + @staticmethod + def _gate( + overall_status: str = "success", + result_error: str | None = None, + *, + workflow_read_only: bool = False, + artifact_workflow: bool = False, + needs_input: bool = False, + ensure_pr_strategy: str = "create", + pr_url: str | None = None, + commit_landed: bool = False, + ) -> tuple[str, str | None]: + # Explicit typed kwargs (not **overrides) so `ty` can check each arg + # against _apply_delivery_gate's signature. + return _apply_delivery_gate( + overall_status, + result_error, + workflow_read_only=workflow_read_only, + artifact_workflow=artifact_workflow, + needs_input=needs_input, + ensure_pr_strategy=ensure_pr_strategy, + pr_url=pr_url, + commit_landed=commit_landed, + ) + + def test_success_no_pr_no_commit_becomes_lost(self): + # The lost-deliverable case: agent-success, create strategy, nothing shipped. + overall, err = self._gate() + assert overall == "error" + assert err is not None + assert "deliverable=lost" in err + + def test_success_no_pr_but_commit_landed_is_no_pr(self): + # A commit reached the branch but the PR failed to open — recoverable, + # and the copy must say the work is NOT gone. + overall, err = self._gate(commit_landed=True) + assert overall == "error" + assert err is not None + assert "deliverable=no_pr" in err + + def test_success_with_pr_is_untouched(self): + overall, err = self._gate(pr_url="https://github.com/o/r/pull/1") + assert overall == "success" + assert err is None + + def test_read_only_success_no_pr_stays_success(self): + # pr_review ships no PR by design — never gate it. + overall, err = self._gate(workflow_read_only=True) + assert overall == "success" + assert err is None + + def test_artifact_workflow_success_no_pr_stays_success(self): + # An artifact workflow delivers an artifact_uri, not a PR. + overall, err = self._gate(artifact_workflow=True) + assert overall == "success" + assert err is None + + def test_needs_input_success_no_pr_stays_success(self): + # Clarify-and-hold is the one sanctioned new_task no-op. + overall, err = self._gate(needs_input=True) + assert overall == "success" + assert err is None + + def test_non_create_strategy_success_no_pr_stays_success(self): + # pr_iteration (push_resolve/resolve) legitimately opens no NEW PR. + overall, err = self._gate(ensure_pr_strategy="push_resolve") + assert overall == "success" + assert err is None + + def test_already_error_is_left_error(self): + # A task that already failed for another reason keeps that reason — + # the gate only promotes a FALSE success, never rewrites a real error. + overall, err = self._gate(overall_status="error", result_error="build failed") + assert overall == "error" + assert err == "build failed" + + def test_lost_reason_matches_classifier_pattern(self): + # Contract with the CDK error-classifier: the reason must carry the + # ``deliverable=lost`` token the classifier keys on. + _, err = self._gate() + assert err is not None + assert "agent_status=success" in err + assert "deliverable=lost" in err + + class TestChainPriorAgentError: def test_no_agent_result(self): msg = _chain_prior_agent_error(None, ValueError("post-hook failed")) @@ -164,7 +315,7 @@ def test_zero_turns_attempted_round_trips(self): class TestMaxTurnsStuckEnrichment: - """N3 wiring seam (#600): _resolve_overall_task_status enriches a max_turns + """Wiring seam (#600): _resolve_overall_task_status enriches a max_turns reason with the stuck-guard summary (hooks.last_stuck_summary). Previously tested only at its two pure endpoints; this drives the append itself.""" diff --git a/agent/tests/test_pipeline_post_hook_gates.py b/agent/tests/test_pipeline_post_hook_gates.py index 32ed74cc2..8428fd8bd 100644 --- a/agent/tests/test_pipeline_post_hook_gates.py +++ b/agent/tests/test_pipeline_post_hook_gates.py @@ -21,6 +21,7 @@ from models import AgentResult, RepoSetup from pipeline import _apply_post_hook_gates +from post_hooks import VerifyOutcome from workflow import Workflow, gate_status, load_workflow @@ -324,8 +325,8 @@ async def fake_run_agent(_p, _s, _c, cwd=None, trajectory=None): patch("pipeline.task_span", return_value=self._span()), patch("pipeline.task_state"), patch("pipeline.ensure_committed", return_value=False), - patch("pipeline.verify_build", return_value=build_passed), - patch("pipeline.verify_lint", return_value=True), + patch("pipeline.verify_build", return_value=VerifyOutcome(passed=build_passed)), + patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)), patch("pipeline.ensure_pr", mock_ensure_pr), patch("pipeline.get_disk_usage", return_value=0), patch("pipeline.print_metrics"), diff --git a/agent/tests/test_post_hooks.py b/agent/tests/test_post_hooks.py index c39c21770..b52b755e2 100644 --- a/agent/tests/test_post_hooks.py +++ b/agent/tests/test_post_hooks.py @@ -6,6 +6,7 @@ ``shell.run_cmd`` (mutating git/gh commands) — both faked with recorders. """ +import subprocess from types import SimpleNamespace import post_hooks @@ -45,10 +46,18 @@ def __call__(self, cmd, **kwargs): return _cp() -def _pr_view(url: str) -> _SubprocessRunRecorder: - """Recorder whose ``gh pr view`` returns *url* (other calls rc=0, empty).""" +def _pr_view(url: str, base: str = "main") -> _SubprocessRunRecorder: + """Recorder for the two ``gh pr view`` shapes ensure_pr issues. + + The URL query (``--json url``) returns *url*; the base query + (``--json baseRefName``, used by ``_reconcile_pr_base``) returns *base*. + Defaulting *base* to ``main`` matches ``_setup``'s default_branch so the + reconcile is a no-op unless a test opts into a mismatch. + """ def responder(cmd): + if "view" in cmd and "baseRefName" in cmd: + return _cp(returncode=0, stdout=base + "\n") if "view" in cmd: return _cp(returncode=0, stdout=url + "\n") return _cp() @@ -182,17 +191,24 @@ def _ensure_pushed(d, b): class TestEnsurePrCreate: def test_returns_existing_pr_when_already_open(self, monkeypatch): - # First `gh pr view` returns a URL -> short-circuit, no creation. - sub = _pr_view("https://github.com/o/r/pull/1") + # First `gh pr view` returns a URL -> short-circuit, no creation. The + # existing PR's base ("main") already matches default_branch, so the + # base reconcile is a no-op (no `gh pr edit`). + sub = _pr_view("https://github.com/o/r/pull/1", base="main") run_cmd = _RunCmdRecorder() monkeypatch.setattr(post_hooks.subprocess, "run", sub) monkeypatch.setattr(post_hooks, "run_cmd", run_cmd) url = post_hooks.ensure_pr( - _config(), _setup(), build_passed=True, lint_passed=True, strategy="create" + _config(), + _setup(default_branch="main"), + build_passed=True, + lint_passed=True, + strategy="create", ) assert url == "https://github.com/o/r/pull/1" assert "create-pr" not in run_cmd.labels() + assert "reconcile-pr-base" not in run_cmd.labels() def test_no_commits_means_no_pr(self, monkeypatch): # pr view -> empty (no existing PR); git log diff -> empty (no commits). @@ -249,3 +265,190 @@ def responder(cmd): assert "Resolves #55" in body assert "**PASS**" in body # build passed assert "**FAIL**" in body # lint failed + + +class TestReconcileAgentBranch: + """A leading cause of lost deliverables: reconcile the platform branch when + the agent committed on its OWN branch instead of the pre-checked-out + platform branch. + + Real git (tmp_path) — this is pure git plumbing, so a real repo gives far + higher confidence than faking subprocess. The two seams (subprocess.run for + the branch read, run_cmd for the mutating ops) both hit the tmp repo.""" + + @staticmethod + def _git(repo, *args): + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True) + + def _make_repo(self, tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + self._git(repo, "init", "-q") + self._git(repo, "config", "user.email", "t@t") + self._git(repo, "config", "user.name", "t") + (repo / "f.txt").write_text("base\n") + self._git(repo, "add", "-A") + self._git(repo, "commit", "-qm", "base") + # Rename default branch to a stable name for the test. + self._git(repo, "branch", "-M", "main") + return str(repo) + + def _head_sha(self, repo): + return subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo, check=True, capture_output=True, text=True + ).stdout.strip() + + def _sha_of(self, repo, ref): + return subprocess.run( + ["git", "rev-parse", ref], cwd=repo, check=True, capture_output=True, text=True + ).stdout.strip() + + def test_reconciles_when_agent_on_own_branch(self, tmp_path): + repo = self._make_repo(tmp_path) + platform = "bgagent/task-1/fix" + # Platform creates its (empty) branch, as setup_repo does. + self._git(repo, "checkout", "-qb", platform) + # Agent goes rogue: its own branch + a commit (the case observed in practice). + self._git(repo, "checkout", "-qb", "agent-own-branch") + (tmp_path / "repo" / "f.txt").write_text("base\nagent change\n") + self._git(repo, "commit", "-qam", "agent work") + agent_head = self._head_sha(repo) + + moved = post_hooks.reconcile_agent_branch(repo, platform) + + assert moved is True + # Platform branch now points at the agent's commit … + assert self._sha_of(repo, platform) == agent_head + # … and it is the checked-out branch, so downstream delivery uses it. + assert post_hooks._current_branch(repo) == platform + + def test_noop_when_already_on_platform_branch(self, tmp_path): + repo = self._make_repo(tmp_path) + platform = "bgagent/task-1/fix" + self._git(repo, "checkout", "-qb", platform) + (tmp_path / "repo" / "f.txt").write_text("base\non platform\n") + self._git(repo, "commit", "-qam", "work on platform") + before = self._head_sha(repo) + + moved = post_hooks.reconcile_agent_branch(repo, platform) + + assert moved is False + assert self._sha_of(repo, platform) == before + assert post_hooks._current_branch(repo) == platform + + def test_noop_on_detached_head(self, tmp_path): + repo = self._make_repo(tmp_path) + platform = "bgagent/task-1/fix" + self._git(repo, "checkout", "-qb", platform) + # Detach HEAD at the current commit. + self._git(repo, "checkout", "-q", "--detach") + + moved = post_hooks.reconcile_agent_branch(repo, platform) + + assert moved is False # nothing to adopt + + def test_current_branch_reports_none_when_detached(self, tmp_path): + repo = self._make_repo(tmp_path) + self._git(repo, "checkout", "-q", "--detach") + assert post_hooks._current_branch(repo) is None + + +class TestReconcilePrBase: + """The agent picks its own PR --base; ensure_pr corrects it deterministically + to setup.default_branch (the orchestrator's base for a stacked child / the + detected repo default for a root). Observed in practice on an orchestrated + chain (#247): a stacked child + a root both opened against a wrong 'main'.""" + + def test_retargets_when_base_mismatches(self, monkeypatch): + # Existing PR is based on 'main' but the stacked child's real base is + # the predecessor branch -> ensure_pr issues `gh pr edit --base `. + pred = "bgagent/task-x/abca-1-predecessor" + sub = _pr_view("https://github.com/o/r/pull/7", base="main") + run_cmd = _RunCmdRecorder() + monkeypatch.setattr(post_hooks.subprocess, "run", sub) + monkeypatch.setattr(post_hooks, "run_cmd", run_cmd) + + url = post_hooks.ensure_pr( + _config(), + _setup(default_branch=pred), + build_passed=True, + lint_passed=True, + strategy="create", + ) + assert url == "https://github.com/o/r/pull/7" + assert "reconcile-pr-base" in run_cmd.labels() + edit_cmd = run_cmd.cmd_for("reconcile-pr-base") + assert "edit" in edit_cmd + assert edit_cmd[edit_cmd.index("--base") + 1] == pred + + def test_noop_when_base_matches(self, monkeypatch): + # PR base already == default_branch -> no `gh pr edit`. + sub = _pr_view("https://github.com/o/r/pull/7", base="develop") + run_cmd = _RunCmdRecorder() + monkeypatch.setattr(post_hooks.subprocess, "run", sub) + monkeypatch.setattr(post_hooks, "run_cmd", run_cmd) + + url = post_hooks.ensure_pr( + _config(), + _setup(default_branch="develop"), + build_passed=True, + lint_passed=True, + strategy="create", + ) + assert url == "https://github.com/o/r/pull/7" + assert "reconcile-pr-base" not in run_cmd.labels() + + def test_retarget_failure_warns_and_is_not_fatal(self, monkeypatch): + # `gh pr edit` fails -> WARN naming the consequence, URL still returned. + pred = "bgagent/task-x/abca-1-predecessor" + sub = _pr_view("https://github.com/o/r/pull/7", base="main") + run_cmd = _RunCmdRecorder(returncodes={"reconcile-pr-base": 1}) + monkeypatch.setattr(post_hooks.subprocess, "run", sub) + monkeypatch.setattr(post_hooks, "run_cmd", run_cmd) + warns: list[str] = [] + monkeypatch.setattr( + post_hooks, "log", lambda lvl, msg: warns.append(msg) if lvl == "WARN" else None + ) + + url = post_hooks.ensure_pr( + _config(), + _setup(default_branch=pred), + build_passed=True, + lint_passed=True, + strategy="create", + ) + assert url == "https://github.com/o/r/pull/7" + assert any("PR remains based on 'main'" in w for w in warns) + + def test_reconcile_skipped_for_freshly_created_pr_path(self, monkeypatch): + # When the agent did NOT pre-create the PR, ensure_pr creates it with the + # correct --base directly; no separate reconcile needed (create path + # already uses default_branch). Guards against double-work. + def responder(cmd): + if "view" in cmd: + return _cp(returncode=1, stderr="no pr") + if "log" in cmd and "--reverse" in cmd: + return _cp(returncode=0, stdout="feat: x\n") + if "log" in cmd: + return _cp(returncode=0, stdout="feat: x\n\n---") + return _cp() + + sub = _SubprocessRunRecorder(responder=responder) + run_cmd = _RunCmdRecorder(stdouts={"create-pr": "https://github.com/o/r/pull/8\n"}) + monkeypatch.setattr(post_hooks, "ensure_pushed", lambda d, b: True) + monkeypatch.setattr(post_hooks.subprocess, "run", sub) + monkeypatch.setattr(post_hooks, "run_cmd", run_cmd) + + pred = "bgagent/task-x/abca-1-predecessor" + url = post_hooks.ensure_pr( + _config(), + _setup(default_branch=pred), + build_passed=True, + lint_passed=True, + strategy="create", + ) + assert url == "https://github.com/o/r/pull/8" + # create path used the right base; no post-creation reconcile fired. + create_cmd = run_cmd.cmd_for("create-pr") + assert create_cmd[create_cmd.index("--base") + 1] == pred + assert "reconcile-pr-base" not in run_cmd.labels() diff --git a/agent/tests/test_prompts.py b/agent/tests/test_prompts.py index b26e13aac..08e42a422 100644 --- a/agent/tests/test_prompts.py +++ b/agent/tests/test_prompts.py @@ -31,16 +31,55 @@ def test_no_channel_returns_empty(self): def test_api_channel_returns_empty(self): assert _channel_prompt_addendum(_config(channel_source="api")) == "" - def test_linear_channel_includes_linear_tools(self): + def test_linear_addendum_is_deterministic_no_mcp(self): + # ADR-016: Linear is fully deterministic. The agent has no Linear tools, + # so the addendum must NOT reference any mcp__linear-server__* tool and + # must tell the agent context is pre-hydrated + status is automatic. addendum = _channel_prompt_addendum( _config( channel_source="linear", - channel_metadata={"linear_issue_identifier": "ABC-42"}, + channel_metadata={ + "linear_issue_id": "issue-uuid-1", + "linear_issue_identifier": "ABC-42", + }, ) ) - assert "Linear issue progress updates" in addendum - assert "mcp__linear-server__save_comment" in addendum assert "ABC-42" in addendum + assert "mcp__linear-server" not in addendum + # Inbound context is pre-hydrated; outbound status is platform-managed. + assert "already" in addendum.lower() + assert "Do NOT post Linear comments" in addendum + + def test_linear_addendum_same_regardless_of_workflow(self): + # There is no longer per-workflow MCP choreography — new-task, iteration, + # and review all get the same deterministic "platform manages Linear" + # guidance, and none of them mention MCP tools. + base_meta = {"linear_issue_id": "issue-uuid-1", "linear_issue_identifier": "ABC-42"} + for wf in (None, "coding/pr-iteration-v1", "coding/pr-review-v1", "coding/new-task-v1"): + overrides: dict[str, Any] = {"channel_source": "linear", "channel_metadata": base_meta} + if wf is not None: + overrides["resolved_workflow"] = {"id": wf, "version": "1.0.0"} + addendum = _channel_prompt_addendum(_config(**overrides)) + assert "mcp__linear-server" not in addendum + assert "🤖 Starting on this issue" not in addendum + assert "Linear context discovery" not in addendum + + def test_linear_integration_node_gets_no_addendum(self): + # The synthetic orchestration integration node (#247) is a Linear + # task but has NO real sub-issue — channel_metadata omits + # linear_issue_id. No issue id → no addendum (the parent panel is the + # surface). + addendum = _channel_prompt_addendum( + _config( + channel_source="linear", + channel_metadata={ + "orchestration_id": "orch_abc", + "orchestration_sub_issue_id": "orch_abc__integration", + "parent_linear_issue_id": "parent-uuid", + }, + ) + ) + assert addendum == "" def test_jira_channel_gets_no_addendum(self): # Jira comments are posted out-of-band by jira_reactions (REST shim); @@ -63,6 +102,17 @@ def test_new_task_returns_prompt_with_create_pr(self): assert "{branch_name}" in prompt assert "{workflow}" not in prompt + def test_new_task_has_clarify_before_spend_branch(self): + # Clarify-before-spend (UX #4): the new_task workflow must tell the agent + # to ASK via the request_clarification tool instead of guessing on a + # genuinely vague request, and to not build unrequested scope. + prompt = get_system_prompt("coding/new-task-v1") + assert "request_clarification" in prompt # the deterministic tool signal + assert "{needs_input_marker}" in prompt # marker fallback, substituted at build time + assert "clarifying question" in prompt or "clarification" in prompt + # Scope discipline (the typo->button case). + assert "weren't requested" in prompt or "not requested" in prompt + def test_pr_iteration_returns_prompt_with_update_pr(self): prompt = get_system_prompt("coding/pr-iteration-v1") assert "Post a summary comment on the PR" in prompt @@ -74,6 +124,16 @@ def test_pr_iteration_returns_prompt_with_update_pr(self): assert "{branch_name}" in prompt assert "{workflow}" not in prompt + def test_pr_iteration_distinguishes_question_from_change(self): + # A question-only comment ("where is the login page?") must be + # answered without forcing a code change, or the platform reports a + # false "✅ Updated". The prompt must carry the triage. + prompt = get_system_prompt("coding/pr-iteration-v1") + assert "QUESTION" in prompt + assert "CHANGE REQUEST" in prompt + # It must explicitly forbid inventing a commit to justify "doing something". + assert "empty or cosmetic commit" in prompt or "Do NOT invent a code change" in prompt + def test_pr_review_returns_prompt_with_review_workflow(self): prompt = get_system_prompt("coding/pr-review-v1") assert "READ-ONLY" in prompt @@ -84,8 +144,27 @@ def test_pr_review_returns_prompt_with_review_workflow(self): assert "Write and Edit are not available" in prompt assert "{workflow}" not in prompt + def test_restack_returns_prompt_with_remerge_workflow(self): + prompt = get_system_prompt("coding/restack-v1") + assert "RE-STACKING" in prompt + assert "predecessor" in prompt + assert ( + "do NOT add features" in prompt + or "NOT new feature work" in prompt + or "not new feature" in prompt.lower() + ) + assert "{branch_name}" in prompt # pushes to the SAME existing branch + assert "{pr_number}" in prompt + assert "{repo_url}" in prompt + assert "{workflow}" not in prompt + def test_all_workflows_contain_shared_base_sections(self): - for workflow_id in ("coding/new-task-v1", "coding/pr-iteration-v1", "coding/pr-review-v1"): + for workflow_id in ( + "coding/new-task-v1", + "coding/pr-iteration-v1", + "coding/pr-review-v1", + "coding/restack-v1", + ): prompt = get_system_prompt(workflow_id) assert "## Environment" in prompt, f"Missing Environment in {workflow_id}" has_rules = "## Rules" in prompt or "## Rules override" in prompt diff --git a/agent/tests/test_repo.py b/agent/tests/test_repo.py index a217808ed..0091fe555 100644 --- a/agent/tests/test_repo.py +++ b/agent/tests/test_repo.py @@ -33,6 +33,12 @@ def _patch_common(monkeypatch, fake: FakeRunCmd): # _install_commit_hook touches the filesystem; stub it out (it's its own # best-effort path and not under test here). monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) + # The clone is faked here, so the real workspace never gets a + # `.git`. Stub the pre-clean + git-root backstop (tested directly in + # TestCloneWorkspaceGuards) so the hermetic clone tests don't trip the + # assertion on a non-existent/empty workspace path. + monkeypatch.setattr(repo, "_prepare_clone_dir", lambda repo_dir, notes: None) + monkeypatch.setattr(repo, "_assert_clone_root", lambda repo_dir: None) class TestSetupRepoHappyPath: @@ -90,6 +96,334 @@ def test_pr_branch_checkout_path(self, monkeypatch): # base_branch from orchestrator wins for PR workflows — no detection call. assert setup.default_branch == "develop" + def test_non_pr_task_captures_head_sha_for_digest(self, monkeypatch): + # A NON-PR workflow (e.g. coding/pr-review-v1) must also + # capture the cloned HEAD sha (via the post-setup rev-parse) so the planner + # can echo it into repo_digest_sha. The PR path captures its own sha; this + # covers the else/default clone path such a workflow uses. + fake = _fake_run_cmd(stdouts={"head-sha-after-setup": "deadbeefcafe1234\n"}) + _patch_common(monkeypatch, fake) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + + setup = repo.setup_repo(_config()) + + assert "head-sha-after-setup" in fake.labels() + assert setup.head_sha_before == "deadbeefcafe1234" + + def test_pr_workflow_does_not_double_capture_head_sha(self, monkeypatch): + # The PR path already set head_sha_before, so the post-setup fallback must + # NOT run (guarded on `if not head_sha_before`). + fake = _fake_run_cmd(stdouts={"head-sha-before": "aaaa1111bbbb2222\n"}) + _patch_common(monkeypatch, fake) + + setup = repo.setup_repo( + _config(is_pr_workflow=True, branch_name="feature/x", base_branch="develop") + ) + + assert setup.head_sha_before == "aaaa1111bbbb2222" + assert "head-sha-after-setup" not in fake.labels() + + +class TestDiamondBaseBranch: + """A diamond child (base_branch + merge_branches) + is handed the server's 'main' literal as its base, which is WRONG on a fork + whose real default isn't main. repo.py must resolve the real default and use + it for BOTH the checkout base and the PR base. A linear child (base_branch, no + merge_branches) must be left untouched.""" + + def test_diamond_resolves_real_default_over_server_main_literal(self, monkeypatch): + fake = _fake_run_cmd() + _patch_common(monkeypatch, fake) + # Fork's real default is linear-vercel, but the server passed 'main'. + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "linear-vercel") + + setup = repo.setup_repo( + _config( + base_branch="main", + merge_branches=["bgagent/task-x/feat-a", "bgagent/task-y/feat-b"], + ) + ) + + # Checkout base was rewritten to the detected default, not 'main'. + fetch_cmd = fake.cmd_for("fetch-base-branch") + assert fetch_cmd is not None + assert fetch_cmd[-1] == "linear-vercel" + create_cmd = fake.cmd_for("create-branch-from-base") + assert create_cmd is not None + assert create_cmd[-1] == "origin/linear-vercel" + # PR base (setup.default_branch) follows — so the diamond PR targets the + # real default, not stale main (the whole point of F1). + assert setup.default_branch == "linear-vercel" + # The predecessor branches are still merged in (diamond sees all preds). + assert "merge-predecessor" in " ".join(fake.labels()) or any( + "merge" in lbl for lbl in fake.labels() + ) + + def test_diamond_no_op_when_server_base_already_matches_detected_default(self, monkeypatch): + fake = _fake_run_cmd() + _patch_common(monkeypatch, fake) + # Server 'main' already IS the real default (upstream/main case) → no rewrite. + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + + setup = repo.setup_repo( + _config(base_branch="main", merge_branches=["bgagent/task-x/feat-a"]) + ) + assert fake.cmd_for("fetch-base-branch")[-1] == "main" + assert setup.default_branch == "main" + + def test_linear_child_base_branch_is_left_untouched(self, monkeypatch): + # A LINEAR child (single predecessor → base_branch set, NO merge_branches) + # stacks on its predecessor's branch; that base must NOT be rewritten to + # the repo default (it's intentionally the predecessor branch). + fake = _fake_run_cmd() + _patch_common(monkeypatch, fake) + # If detect_default_branch were (wrongly) consulted, it'd return this — the + # test proves it is NOT used for a linear child. + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "linear-vercel") + + setup = repo.setup_repo( + _config(base_branch="bgagent/task-pred/feat-pred", merge_branches=[]) + ) + assert fake.cmd_for("fetch-base-branch")[-1] == "bgagent/task-pred/feat-pred" + assert fake.cmd_for("create-branch-from-base")[-1] == "origin/bgagent/task-pred/feat-pred" + # PR base is the predecessor branch (the stack target), unchanged. + assert setup.default_branch == "bgagent/task-pred/feat-pred" + + +class TestReadOnlyBaselineSkip: + """ECS rightsized planning: a read_only workflow (coding/pr-review-v1) + never edits code, runs the post-agent gate, or opens a PR, so the pre-agent + build + lint baseline is pure waste — and on a big repo the full CI-parity + `mise run build` won't fit the 8 GB read-only planning task def (it would + stall/OOM before the planner reads a file). setup_repo must skip both + baselines for read_only and still return neutral OK values.""" + + def test_read_only_skips_build_and_lint_baseline(self, monkeypatch): + fake = _fake_run_cmd() + _patch_common(monkeypatch, fake) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + + setup = repo.setup_repo(_config(read_only=True)) + + labels = fake.labels() + # The heavy build + lint baselines must NOT run. + assert "verify-build-pre" not in labels + assert "verify-lint-pre" not in labels + # But the clone/branch/mise-install setup still happens. + assert "clone" in labels + assert "mise-install" in labels + # Neutral OK baselines (nothing gets committed, so nothing to gate). + assert setup.build_before is True + assert setup.lint_before is True + assert setup.build_gate_inert is False + assert setup.lint_gate_inert is False + assert any("Read-only workflow" in n for n in setup.notes) + + def test_non_read_only_still_runs_build_and_lint_baseline(self, monkeypatch): + # Regression guard: the default (write) workflow must still run both + # baselines — the skip is gated strictly on read_only. + fake = _fake_run_cmd() + _patch_common(monkeypatch, fake) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + + setup = repo.setup_repo(_config(read_only=False)) + + labels = fake.labels() + assert "verify-build-pre" in labels + assert "verify-lint-pre" in labels + assert setup.build_before is True # fake run_cmd returns rc 0 + assert setup.lint_before is True + + +class TestBaselineBuildTimeout: + """The pre-agent baseline build/lint must run with the SAME generous + wall-clock ceiling as the post-agent gate (BUILD_VERIFY_TIMEOUT_S, 30min) + — NOT run_cmd's 600s default — and a TIMEOUT must be GUARDED so a + slow-but-valid CI-parity build no longer raises out of setup_repo and crashes + the whole task before the agent ever runs (the observed symptom: no PR, issue + stuck in Backlog, indistinguishable from a real failure).""" + + class _RecordingFake: + """run_cmd fake that records the ``timeout`` kwarg and can raise + TimeoutExpired for a label substring.""" + + def __init__( + self, + timeout_on: str | None = None, + rc_on: tuple[str, int, str] | None = None, + ): + self.calls: list[dict] = [] + self._timeout_on = timeout_on + # (label_substring, returncode, stderr) to force a non-zero exit on a + # specific labelled command (e.g. an OOM-killed baseline build). + self._rc_on = rc_on + + def __call__(self, cmd, label, cwd=None, timeout=600, check=True, **kwargs): + self.calls.append({"label": label, "timeout": timeout}) + if self._timeout_on and self._timeout_on in label: + raise subprocess.TimeoutExpired(cmd=cmd, timeout=timeout) + if self._rc_on and self._rc_on[0] in label: + return SimpleNamespace(returncode=self._rc_on[1], stdout="", stderr=self._rc_on[2]) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + def timeout_for(self, label: str) -> int | None: + for c in self.calls: + if label in c["label"]: + return c["timeout"] + return None + + def test_baseline_build_uses_the_generous_verify_ceiling_not_600s(self, monkeypatch): + from post_hooks import BUILD_VERIFY_TIMEOUT_S + + fake = self._RecordingFake() + monkeypatch.setattr(repo, "run_cmd", fake) + monkeypatch.setattr(repo, "run_cmd_with_backoff", fake) + monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) + # The clone is faked here, so the real workspace never gets a + # `.git`. Stub the pre-clean + git-root backstop (tested directly in + # TestCloneWorkspaceGuards) so the hermetic clone tests don't trip the + # assertion on a non-existent/empty workspace path. + monkeypatch.setattr(repo, "_prepare_clone_dir", lambda repo_dir, notes: None) + monkeypatch.setattr(repo, "_assert_clone_root", lambda repo_dir: None) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + + repo.setup_repo(_config(read_only=False)) + + assert fake.timeout_for("verify-build-pre") == BUILD_VERIFY_TIMEOUT_S + assert fake.timeout_for("verify-lint-pre") == BUILD_VERIFY_TIMEOUT_S + assert BUILD_VERIFY_TIMEOUT_S > 600 # the whole point: not the old default + + def test_baseline_build_timeout_is_guarded_not_a_task_crash(self, monkeypatch): + # The heavy build times out. setup_repo must NOT propagate TimeoutExpired + # (which crashed the task pre-agent); it degrades to "no baseline" and the + # run proceeds with build_before=True (a timeout is not a regression). + fake = self._RecordingFake(timeout_on="verify-build-pre") + monkeypatch.setattr(repo, "run_cmd", fake) + monkeypatch.setattr(repo, "run_cmd_with_backoff", fake) + monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) + # The clone is faked here, so the real workspace never gets a + # `.git`. Stub the pre-clean + git-root backstop (tested directly in + # TestCloneWorkspaceGuards) so the hermetic clone tests don't trip the + # assertion on a non-existent/empty workspace path. + monkeypatch.setattr(repo, "_prepare_clone_dir", lambda repo_dir, notes: None) + monkeypatch.setattr(repo, "_assert_clone_root", lambda repo_dir: None) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + + setup = repo.setup_repo(_config(read_only=False)) # must not raise + + assert setup.build_before is True # timeout → not treated as a regression + assert setup.build_gate_inert is False + assert any("did not finish within" in n for n in setup.notes) + + def test_baseline_lint_timeout_is_guarded(self, monkeypatch): + fake = self._RecordingFake(timeout_on="verify-lint-pre") + monkeypatch.setattr(repo, "run_cmd", fake) + monkeypatch.setattr(repo, "run_cmd_with_backoff", fake) + monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) + # The clone is faked here, so the real workspace never gets a + # `.git`. Stub the pre-clean + git-root backstop (tested directly in + # TestCloneWorkspaceGuards) so the hermetic clone tests don't trip the + # assertion on a non-existent/empty workspace path. + monkeypatch.setattr(repo, "_prepare_clone_dir", lambda repo_dir, notes: None) + monkeypatch.setattr(repo, "_assert_clone_root", lambda repo_dir: None) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + + setup = repo.setup_repo(_config(read_only=False)) # must not raise + + assert setup.lint_before is True + assert setup.lint_gate_inert is False + assert any("Initial lint" in n and "did not finish within" in n for n in setup.notes) + + def test_baseline_build_OOM_kill_is_not_a_regression(self, monkeypatch): + # Root cause of an observed false ✅: the baseline build was OOM-KILLED (exit + # 137) because several heavy CI-parity builds shared one ECS box. Exit 137 + # is an ENVIRONMENT fault, NOT broken code — so build_before must be True + # (no usable baseline, no known regression), NOT False ("already broken"). + # A False here poisons the whole verdict: the regression gate reads + # "red-before → red-after isn't the agent's fault → ✅" while the absolute + # orchestration gate fails the node — a task GitHub built green. + fake = self._RecordingFake(rc_on=("verify-build-pre", 137, "Killed")) + monkeypatch.setattr(repo, "run_cmd", fake) + monkeypatch.setattr(repo, "run_cmd_with_backoff", fake) + monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) + # The clone is faked here, so the real workspace never gets a + # `.git`. Stub the pre-clean + git-root backstop (tested directly in + # TestCloneWorkspaceGuards) so the hermetic clone tests don't trip the + # assertion on a non-existent/empty workspace path. + monkeypatch.setattr(repo, "_prepare_clone_dir", lambda repo_dir, notes: None) + monkeypatch.setattr(repo, "_assert_clone_root", lambda repo_dir: None) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + + setup = repo.setup_repo(_config(read_only=False)) + + assert setup.build_before is True # OOM → no baseline, NOT "already broken" + assert setup.build_gate_inert is False # an OOM kill is not an inert gate + assert any("environment fault" in n for n in setup.notes) + # And it must NOT be recorded as a pre-existing build failure. + assert not any("FAILED before agent changes" in n for n in setup.notes) + + def test_baseline_build_genuine_failure_still_marks_regression_baseline(self, monkeypatch): + # Guard the other side: a REAL red build (exit 1, not an infra signal) must + # still record build_before=False so genuine regressions are gated. + fake = self._RecordingFake(rc_on=("verify-build-pre", 1, "TS2345: type error")) + monkeypatch.setattr(repo, "run_cmd", fake) + monkeypatch.setattr(repo, "run_cmd_with_backoff", fake) + monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) + # The clone is faked here, so the real workspace never gets a + # `.git`. Stub the pre-clean + git-root backstop (tested directly in + # TestCloneWorkspaceGuards) so the hermetic clone tests don't trip the + # assertion on a non-existent/empty workspace path. + monkeypatch.setattr(repo, "_prepare_clone_dir", lambda repo_dir, notes: None) + monkeypatch.setattr(repo, "_assert_clone_root", lambda repo_dir: None) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + + setup = repo.setup_repo(_config(read_only=False)) + + assert setup.build_before is False # a real red build IS the baseline + assert any("FAILED before agent changes" in n for n in setup.notes) + + +class TestFindMiseConfigs: + """`mise trust ` trusts only the ROOT config; + a monorepo's per-package `mise.toml` roots must ALSO be trusted or + `mise run build` fanning into `//cdk:*` etc. dies at the trust gate.""" + + def _mk(self, root, rels): + import os + + for r in rels: + d = os.path.dirname(r) + if d: + os.makedirs(os.path.join(root, d), exist_ok=True) + open(os.path.join(root, r), "w").close() + + def _rel(self, root, configs): + import os + + return sorted(os.path.relpath(c, root) for c in configs) + + def test_returns_nested_configs_excluding_root(self, tmp_path): + root = str(tmp_path) + self._mk(root, ["mise.toml", "cdk/mise.toml", "cli/mise.toml", "agent/mise.toml"]) + got = self._rel(root, repo._find_mise_configs(root)) + # root already trusted by `mise trust `, so it's excluded + assert "mise.toml" not in got + assert got == ["agent/mise.toml", "cdk/mise.toml", "cli/mise.toml"] + + def test_skips_vendored_and_build_dirs(self, tmp_path): + root = str(tmp_path) + self._mk( + root, + ["mise.toml", "cdk/mise.toml", "node_modules/pkg/mise.toml", "cdk/cdk.out/a/mise.toml"], + ) + got = self._rel(root, repo._find_mise_configs(root)) + assert got == ["cdk/mise.toml"] # node_modules + cdk.out pruned + + def test_no_nested_configs_returns_empty(self, tmp_path): + root = str(tmp_path) + self._mk(root, ["mise.toml"]) # only the root + assert repo._find_mise_configs(root) == [] + class TestDetectDefaultBranch: def test_returns_detected_branch(self, monkeypatch): @@ -143,6 +477,56 @@ def fake_run(*args, **kwargs): assert repo.detect_default_branch("owner/repo", "/tmp/x") == "main" +class TestPlatformBranchNameVerbatim: + """The agent MUST use the platform-provided ``config.branch_name`` verbatim + when present, for EVERY workflow — never re-deriving its own slug. A + re-derived slug diverges from the platform's (shell.py slugify strips + dots / truncates at 40; gateway.ts uses dashes / truncates at 50), which + silently breaks stacked-PR chaining (#247): a stacked child fetches the + predecessor's platform-named branch, the agent pushed a differently-named + one, the fetch 404s, and the child falls back to main (#14).""" + + def test_uses_platform_branch_name_verbatim_for_new_task(self, monkeypatch): + # new_task (is_pr_workflow=False) with a platform branch_name carrying a + # dotted/dashed slug. The agent must NOT re-slugify it. + fake = _fake_run_cmd() + _patch_common(monkeypatch, fake) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + setup = repo.setup_repo( + _config( + is_pr_workflow=False, + branch_name="bgagent/01TESTTASKID/abca-166-add-seville-guide-html", + task_description="ABCA-166: Add seville-guide.html", + ) + ) + assert setup.branch == "bgagent/01TESTTASKID/abca-166-add-seville-guide-html" + + def test_uses_platform_branch_name_verbatim_for_pr_workflow(self, monkeypatch): + fake = _fake_run_cmd() + _patch_common(monkeypatch, fake) + setup = repo.setup_repo( + _config( + is_pr_workflow=True, + branch_name="bgagent/01TESTTASKID/abca-167-stacked-child", + base_branch="bgagent/01PREDTASK/abca-166-predecessor", + ) + ) + assert setup.branch == "bgagent/01TESTTASKID/abca-167-stacked-child" + + def test_falls_back_to_derived_slug_only_when_no_branch_name(self, monkeypatch): + # No platform branch_name → the agent derives its own slug (legacy path). + fake = _fake_run_cmd() + _patch_common(monkeypatch, fake) + monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "main") + setup = repo.setup_repo( + _config( + is_pr_workflow=False, + task_description="ABCA-168: derive me", + ) + ) + assert setup.branch.startswith("bgagent/") + + class _RecordingProgress: """Progress double recording write_agent_blocked calls (mirrors test_hooks).""" @@ -165,6 +549,12 @@ def _patch_backoff_to_fail(self, monkeypatch, fake, *, transient_stderr): report/raise path fires, while run_cmd (non-clone commands) stays faked.""" monkeypatch.setattr(repo, "run_cmd", fake) monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) + # The clone is faked here, so the real workspace never gets a + # `.git`. Stub the pre-clean + git-root backstop (tested directly in + # TestCloneWorkspaceGuards) so the hermetic clone tests don't trip the + # assertion on a non-existent/empty workspace path. + monkeypatch.setattr(repo, "_prepare_clone_dir", lambda repo_dir, notes: None) + monkeypatch.setattr(repo, "_assert_clone_root", lambda repo_dir: None) def fake_backoff(cmd, label, *, on_retry=None, **kwargs): # Mirror the real helper: on_retry only fires for transient failures. @@ -202,6 +592,12 @@ def fake_run_cmd(cmd, label, cwd=None, timeout=600, check=True, **kwargs): monkeypatch.setattr("shell.run_cmd", fake_run_cmd) monkeypatch.setattr(repo, "run_cmd", fake_run_cmd) monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) + # The clone is faked here, so the real workspace never gets a + # `.git`. Stub the pre-clean + git-root backstop (tested directly in + # TestCloneWorkspaceGuards) so the hermetic clone tests don't trip the + # assertion on a non-existent/empty workspace path. + monkeypatch.setattr(repo, "_prepare_clone_dir", lambda repo_dir, notes: None) + monkeypatch.setattr(repo, "_assert_clone_root", lambda repo_dir: None) progress = _RecordingProgress() import pytest @@ -323,6 +719,12 @@ def test_exhausted_pr_branch_fetch_reports_branch_as_resource(self, monkeypatch) fake = _fake_run_cmd() monkeypatch.setattr(repo, "run_cmd", fake) monkeypatch.setattr(repo, "_install_commit_hook", lambda repo_dir: None) + # The clone is faked here, so the real workspace never gets a + # `.git`. Stub the pre-clean + git-root backstop (tested directly in + # TestCloneWorkspaceGuards) so the hermetic clone tests don't trip the + # assertion on a non-existent/empty workspace path. + monkeypatch.setattr(repo, "_prepare_clone_dir", lambda repo_dir, notes: None) + monkeypatch.setattr(repo, "_assert_clone_root", lambda repo_dir: None) def fake_backoff(cmd, label, *, on_retry=None, **kwargs): # Clone succeeds; only the PR-branch fetch fails transiently. @@ -366,3 +768,58 @@ def test_remediation_does_not_widen_creds_or_egress(self, monkeypatch): assert "set-remote-url" not in labels assert "configure-git-credential-helper" not in labels assert "safe-directory" in labels # only the pre-clone step ran + + +class TestCloneWorkspaceGuards: + """The clone-time slate-clean + git-root backstop that stop a + stacked child from silently editing a NESTED working tree the pipeline's + git ops never see (which reported a false COMPLETED with the work lost).""" + + def test_prepare_clears_non_empty_workspace_and_notes(self, tmp_path): + repo_dir = tmp_path / "task-abc" + repo_dir.mkdir() + (repo_dir / "stale.txt").write_text("residue from a prior run") + (repo_dir / "nested").mkdir() + notes: list[str] = [] + + repo._prepare_clone_dir(str(repo_dir), notes) + + # The whole dir is removed so the clone lands directly at the root. + assert not repo_dir.exists() + assert any("residue" in n for n in notes) + + def test_prepare_leaves_empty_workspace_untouched_no_note(self, tmp_path): + repo_dir = tmp_path / "task-abc" + repo_dir.mkdir() # exists but empty — a normal fresh workspace + notes: list[str] = [] + + repo._prepare_clone_dir(str(repo_dir), notes) + + assert repo_dir.exists() # empty dir is fine; clone lands into it + assert notes == [] + + def test_prepare_absent_workspace_is_noop(self, tmp_path): + repo_dir = tmp_path / "does-not-exist" + notes: list[str] = [] + + repo._prepare_clone_dir(str(repo_dir), notes) # must not raise + + assert not repo_dir.exists() + assert notes == [] + + def test_assert_root_passes_when_git_dir_present(self, tmp_path): + repo_dir = tmp_path / "task-abc" + (repo_dir / ".git").mkdir(parents=True) + + repo._assert_clone_root(str(repo_dir)) # must not raise + + def test_assert_root_raises_when_git_missing(self, tmp_path): + import pytest + + repo_dir = tmp_path / "task-abc" + repo_dir.mkdir() + # A nested clone: .git lives one level deep, NOT at repo_dir root. + (repo_dir / "inner" / ".git").mkdir(parents=True) + + with pytest.raises(RuntimeError, match="git repository at the workspace root"): + repo._assert_clone_root(str(repo_dir)) diff --git a/agent/tests/test_run_task_from_payload.py b/agent/tests/test_run_task_from_payload.py index a65107a61..9784ca9f1 100644 --- a/agent/tests/test_run_task_from_payload.py +++ b/agent/tests/test_run_task_from_payload.py @@ -1,6 +1,6 @@ """Unit tests for pipeline.run_task_from_payload — the ECS payload→run_task map. -Regression cover for ABCA-487: the ECS boot command used to hand-list a subset +Regression cover for a silent contract gap: the ECS boot command used to hand-list a subset of run_task kwargs and silently dropped channel_source/channel_metadata (no Linear/Jira reactions on ECS), build_command, cedar_policies, base_branch, etc. run_task_from_payload maps the WHOLE payload so nothing is dropped again. @@ -35,7 +35,7 @@ def test_renames_prompt_and_model_id(self): assert "prompt" not in seen assert "model_id" not in seen - def test_forwards_channel_fields_ABCA_487(self): + def test_forwards_every_channel_field_to_the_agent(self): # THE regression: channel_source/channel_metadata must reach run_task so # the Linear/Jira reaction + channel MCP fire on ECS. cm = {"linear_issue_id": "iss-1", "linear_oauth_secret_arn": "arn:sm:...:lin"} @@ -43,44 +43,32 @@ def test_forwards_channel_fields_ABCA_487(self): assert seen["channel_source"] == "linear" assert seen["channel_metadata"] == cm - def test_forwards_cedar_attachments_trace_user_fields(self): - # HITL guardrails (cedar_policies, approval_*), attachments, trace, and - # user_id are real run_task params here — they must reach run_task on ECS + def test_forwards_build_and_lint_and_cedar_and_branch_fields(self): + # On this branch build_command/lint_command/base_branch/merge_branches ARE + # real run_task params (configurable verify #1 + #247 stacking), alongside + # cedar_policies/attachments/trace/user_id — all must reach run_task on ECS # (the hand-listed boot command used to drop them). seen = _capture( { + "build_command": "npm ci && npm test", + "lint_command": "npm run lint", "cedar_policies": ["p1", "p2"], + "base_branch": "epic-tip", + "merge_branches": ["a", "b"], "attachments": [{"filename": "x.png"}], "trace": True, "user_id": "user-9", } ) + assert seen["build_command"] == "npm ci && npm test" + assert seen["lint_command"] == "npm run lint" assert seen["cedar_policies"] == ["p1", "p2"] + assert seen["base_branch"] == "epic-tip" + assert seen["merge_branches"] == ["a", "b"] assert seen["attachments"] == [{"filename": "x.png"}] assert seen["trace"] is True assert seen["user_id"] == "user-9" - def test_drops_payload_keys_that_are_not_yet_run_task_params(self): - # build_command/lint_command (configurable verify, #1) and base_branch/ - # merge_branches (orchestration stacking, #247) are emitted by the - # orchestrator but are NOT run_task parameters on this branch. The mapper - # filters against run_task's REAL signature, so they are dropped rather - # than smuggled through as an invalid kwarg. When those params land, - # they forward automatically with no change here — that's the point of - # keying off inspect.signature instead of a hand-list. - seen = _capture( - { - "repo_url": "org/repo", - "build_command": "npm ci && npm test", - "lint_command": "npm run lint", - "base_branch": "epic-tip", - "merge_branches": ["a", "b"], - } - ) - assert seen["repo_url"] == "org/repo" - for not_yet in ("build_command", "lint_command", "base_branch", "merge_branches"): - assert not_yet not in seen - def test_coerces_issue_and_pr_number_to_str_and_max_turns_to_int(self): seen = _capture({"issue_number": 42, "pr_number": 7, "max_turns": "50"}) assert seen["issue_number"] == "42" @@ -103,7 +91,7 @@ def test_ignores_unknown_payload_keys(self): assert "sources" not in seen def test_github_token_secret_arn_dropped_quietly(self): - # N3: github_token_secret_arn is ALWAYS present and ALWAYS resolved via + # github_token_secret_arn is ALWAYS present and ALWAYS resolved via # the GITHUB_TOKEN_SECRET_ARN env (never a run_task param), so its drop is # 100% expected and must NOT fire the known-key WARN — that channel is for # genuine future contract gaps, not this always-dropped key. @@ -111,7 +99,7 @@ def test_github_token_secret_arn_dropped_quietly(self): with patch("pipeline.log", side_effect=lambda level, msg, **kw: logs.append((level, msg))): _capture({"github_token_secret_arn": "arn:aws:secretsmanager:...", "repo_url": "r"}) assert not [m for level, m in logs if "github_token_secret_arn" in m], ( - "github_token_secret_arn must drop quietly (N3) — it is always resolved via env" + "github_token_secret_arn must drop quietly — it is always resolved via env" ) def test_drops_none_values_so_run_task_defaults_apply(self): @@ -156,7 +144,7 @@ def test_every_forwarded_key_is_a_real_run_task_param(self): assert set(seen).issubset(accepted) def test_max_turns_rejects_surprising_inputs(self): - # N4: int() accepts a bool (int(True)==1) and truncates a float + # int() accepts a bool (int(True)==1) and truncates a float # (int(3.9)==3). The orchestrator always emits a real int, but a corrupt # / hand-edited payload must not silently become a bogus turn count — # drop with a breadcrumb and let run_task's default apply. @@ -167,17 +155,6 @@ def test_max_turns_rejects_surprising_inputs(self): assert _capture({"repo_url": "r", "max_turns": "50"})["max_turns"] == 50 assert _capture({"repo_url": "r", "max_turns": 50.0})["max_turns"] == 50 - def test_warns_when_dropping_a_known_orchestrator_key(self): - # N4: a KNOWN orchestrator key that run_task doesn't accept is dropped - # (expected today) but logged, so a future "wired one side, forgot the - # other" contract gap (the ABCA-487 class) is visible, not silent. - logs: list[tuple[str, str]] = [] - with patch("pipeline.log", side_effect=lambda level, msg, **kw: logs.append((level, msg))): - _capture({"build_command": "mise run build", "repo_url": "r"}) - assert [m for level, m in logs if level == "WARN" and "build_command" in m], ( - "expected a WARN when dropping the known orchestrator key build_command" - ) - def test_does_NOT_warn_when_dropping_a_foreign_key(self): # A genuinely-foreign key (not a known orchestrator field) is dropped # quietly — no log noise for keys we never expected to forward. @@ -189,7 +166,9 @@ def test_does_NOT_warn_when_dropping_a_foreign_key(self): def test_warns_when_dropping_task_started_at_HITL_parity(self): # task_started_at drives the AgentCore HITL maxLifetime clip via # TASK_STARTED_AT; the ECS path doesn't set it yet, so its drop must WARN - # (surface the AgentCore↔ECS parity gap, not silently fail-open). + # (surface the AgentCore↔ECS parity gap, not silently fail-open). It is the + # one _KNOWN_ORCHESTRATOR_KEYS entry on this branch (build_command etc. are + # real run_task params here and forward, so they never hit the drop path). logs: list[tuple[str, str]] = [] with patch("pipeline.log", side_effect=lambda level, msg, **kw: logs.append((level, msg))): _capture({"task_started_at": "2026-07-14T00:00:00Z", "repo_url": "r"}) diff --git a/agent/tests/test_server.py b/agent/tests/test_server.py index a839cd0dd..32383703d 100644 --- a/agent/tests/test_server.py +++ b/agent/tests/test_server.py @@ -343,6 +343,26 @@ def test_validate_required_params_pr_workflows_require_pr_number(): ) assert missing == [] + # Restack (#305) is a PR workflow — pr_number suffices, NO description + # required (regression: it previously fell into the non-PR branch and + # 400'd on missing issue_number_or_task_description). + missing = server._validate_required_params( + { + "repo_url": "o/r", + "resolved_workflow": {"id": "coding/restack-v1", "version": "1.0.0"}, + "pr_number": "113", + } + ) + assert missing == [] + missing = server._validate_required_params( + { + "repo_url": "o/r", + "resolved_workflow": {"id": "coding/restack-v1", "version": "1.0.0"}, + "pr_number": "", + } + ) + assert missing == ["pr_number"] + # A non-PR workflow needs issue OR description. missing = server._validate_required_params( { @@ -629,7 +649,7 @@ def test_trace_1_does_NOT_enable_trace(self): class TestExtractUserId: - """K2 Stage 3: ``user_id`` is the platform Cognito ``sub`` threaded + """``user_id`` is the platform Cognito ``sub`` threaded from the orchestrator. The agent uses it to construct the trace S3 key ``traces//.jsonl.gz``. A non-string value must be coerced to empty so a surprise ``None`` / int doesn't flow @@ -809,3 +829,75 @@ def test_none_stays_none(self): self._fake_req(), ) assert params["approval_gate_cap"] is None + + +class TestInvocationParamContract: + """The invocation boundary is wired as: + + params = _extract_invocation_params(inp, request) # a dict + _run_task_background(**params) # kwargs unpack + + The ONLY thing keeping these in sync is that every dict key is a valid + parameter name of ``_run_task_background`` (and vice-versa for required + fields). A mismatch is invisible until runtime and crashes EVERY task + with a ``NameError`` / ``TypeError`` — exactly the stacked-child regression + (#247) where ``base_branch`` was passed to ``run_task`` but never extracted + into the params dict. These tests lock that contract structurally so + the next field added on one side but not the other fails in CI. + """ + + def _fake_req(self) -> Any: + return _FakeRequest() + + def _payload(self, **extra): + return {"repo_url": "org/repo", "task_description": "x", "task_id": "t-1", **extra} + + def test_every_extracted_key_is_a_valid_background_param(self): + import inspect + + params = server._extract_invocation_params(self._payload(), self._fake_req()) + sig = inspect.signature(server._run_task_background) + bg_param_names = set(sig.parameters) + + unknown = set(params) - bg_param_names + assert not unknown, ( + f"_extract_invocation_params returns keys that _run_task_background " + f"does not accept (would crash on **kwargs unpack): {sorted(unknown)}" + ) + + def test_extracted_params_unpack_into_background_signature(self): + # Binding the extracted dict against the real signature is exactly + # what `_run_task_background(**params)` does — this raises TypeError + # if a key is unknown OR a required (no-default) param is missing. + import inspect + + params = server._extract_invocation_params(self._payload(), self._fake_req()) + sig = inspect.signature(server._run_task_background) + # Should not raise. + sig.bind(**params) + + def test_base_branch_and_merge_branches_extracted_and_accepted(self): + # The specific stacked-child fields whose omission caused the regression. + import inspect + + params = server._extract_invocation_params( + self._payload(base_branch="bgagent/taskA/a", merge_branches=["b1", "b2"]), + self._fake_req(), + ) + assert params["base_branch"] == "bgagent/taskA/a" + assert params["merge_branches"] == ["b1", "b2"] + # And they are real parameters of the background runner. + bg = set(inspect.signature(server._run_task_background).parameters) + assert {"base_branch", "merge_branches"} <= bg + + def test_stacking_fields_default_safely_when_absent(self): + params = server._extract_invocation_params(self._payload(), self._fake_req()) + assert params["base_branch"] is None + assert params["merge_branches"] == [] + + def test_merge_branches_non_string_entries_filtered(self): + params = server._extract_invocation_params( + self._payload(merge_branches=["ok", 123, None, "ok2"]), + self._fake_req(), + ) + assert params["merge_branches"] == ["ok", "ok2"] diff --git a/agent/tests/test_shell.py b/agent/tests/test_shell.py index 51968735e..225e2df83 100644 --- a/agent/tests/test_shell.py +++ b/agent/tests/test_shell.py @@ -191,7 +191,7 @@ def test_backoff_delays_are_exponential(self): class TestRunCmdFailureLogging: """A failing command must surface its ACTUAL error. Build/test tooling (jest, tsc, the mise task DAG) writes the failing-task error to STDOUT, not stderr — - so logging stderr alone made build-gate failures undebuggable (ABCA-662: a red + so logging stderr alone made build-gate failures undebuggable (a red ``mise run build`` showed every task starting but never WHICH one failed).""" def _completed(self, rc, stdout="", stderr=""): @@ -231,7 +231,7 @@ def test_no_markers_falls_back_to_tail(self): assert "line 0" not in blob # earliest lines dropped def test_failure_line_in_the_MIDDLE_is_surfaced(self): - # ABCA-662 root cause of the tooling gap: a PARALLEL mise DAG interleaves + # Root cause of the tooling gap: a PARALLEL mise DAG interleaves # output, so the failing task's line is in the MIDDLE while the tail is a # passing package's coverage table. The failing line MUST be surfaced. mid = "[//cdk:test] FAIL test/handlers/foo.test.ts — expected 1 got 2" @@ -269,7 +269,7 @@ def test_benign_zero_errors_line_not_surfaced_as_failure(self): assert "ok 19" in blob def test_marker_line_with_noise_term_is_filtered_by_allowlist(self): - # N3: the LOAD-BEARING allowlist case. A line that hits a real marker + # The LOAD-BEARING allowlist case. A line that hits a real marker # (`error:`) AND a noise term (`0 errors`) must be filtered OUT of the # surfaced set. Placed in the MIDDLE (before the tail window) so it is # only reachable via the marker scan — if _FAILURE_LINE_NOISE were @@ -287,7 +287,7 @@ def test_marker_line_with_noise_term_is_filtered_by_allowlist(self): assert "0 errors after autofix retry" not in blob # noisy marker filtered by allowlist def test_surfaced_failure_lines_are_capped_and_truncation_marked(self): - # N4: a genuinely huge red run (more than _MAX_SURFACED_FAILURE_LINES + # A genuinely huge red run (more than _MAX_SURFACED_FAILURE_LINES # marker lines) must be capped, with an explicit truncation breadcrumb — # so it can't flood CloudWatch. Exercises the cap branch the other tests # never reach. @@ -318,3 +318,83 @@ def test_success_does_not_dump_stdout(self): logs = self._run_capturing_logs(proc) blob = "\n".join(text for _, text in logs) assert "lots of build output" not in blob + + +class TestRunCmdStreaming: + """stream=True tees the command's output to the log LINE-BY-LINE as it runs + (so the full log reaches CloudWatch verbatim) AND returns a CompletedProcess + matching subprocess.run's contract. Uses real `sh -c` — exercises the actual + Popen + drain-thread path (the buffered summary hid build failures).""" + + def _run(self, argv, check=False): + logs = [] + with patch("shell.log", side_effect=lambda prefix, text: logs.append((prefix, text))): + result = run_cmd(argv, "verify-build-post", check=check, stream=True) + blob = "\n".join(text for _, text in logs) + return result, blob + + def test_streams_stdout_lines_live_and_returns_captured(self): + result, blob = self._run(["sh", "-c", "echo out-line-A; echo out-line-B"]) + assert result.returncode == 0 + # every line reached the log (verbatim, live) + assert "out-line-A" in blob and "out-line-B" in blob + # and the CompletedProcess still carries stdout for callers + assert "out-line-A" in result.stdout and "out-line-B" in result.stdout + + def test_keeps_stdout_and_stderr_separate(self): + result, _ = self._run(["sh", "-c", "echo to-out; echo to-err 1>&2"]) + assert "to-out" in result.stdout + assert "to-err" in result.stderr + assert "to-err" not in result.stdout # streams not merged + + def test_nonzero_exit_surfaces_failing_line(self): + # A mid-stream failure line is streamed AND flagged in the failing-lines + # pointer — the whole reason streaming exists. + result, blob = self._run(["sh", "-c", "echo passing; echo 'FAIL test/x.test.ts'; exit 1"]) + assert result.returncode == 1 + assert "FAIL test/x.test.ts" in blob + assert "failing lines" in blob # the streamed-path pointer + + def test_stream_redacts_secrets_in_live_output(self): + # Redaction happens inside the real log(); assert redact_secrets covers the + # streamed line (the test patches log(), so check the redactor directly on + # what the drain thread hands it — that's the line that reaches CloudWatch). + from shell import redact_secrets + + assert "ghp_streamedsecretABC123" not in redact_secrets(" token=ghp_streamedsecretABC123") + + def test_stream_raises_on_check_true_failure(self): + import pytest + + with pytest.raises(RuntimeError): + self._run(["sh", "-c", "exit 3"], check=True) + + def test_stream_normalizes_a_signal_kill_to_the_shell_convention(self): + # A signal death arrives from Popen as a NEGATIVE signal number, but the + # OOM classifier keys on the shell's 128+signal form. Without normalizing, + # an OOM-killed build reports as a genuine build failure — "your code is + # broken" when the box ran out of memory. + from post_hooks import is_infra_failure + + # 137 = 128 + SIGKILL(9). `kill -9 $$` makes the shell kill itself, so the + # child really dies by signal rather than exiting with a code. + result, _ = self._run(["sh", "-c", "kill -9 $$"]) + assert result.returncode == 137, "SIGKILL must surface as 137, not -9" + # The point of the normalization: the classifier now sees infra, not a + # genuine red build, with no stderr signature to help it. + assert is_infra_failure(result.returncode, "") is True + + def test_stream_maps_an_unreaped_process_to_failure_not_success(self): + # `returncode is None` cannot happen after wait(), but if it ever did, + # mapping it to 0 would let an unverified build report as passing. The one + # unsafe answer, so it is pinned. + from shell import _exit_status + + assert _exit_status(None) != 0 + assert _exit_status(None) == -1 + # Ordinary exits pass through untouched. + assert _exit_status(0) == 0 + assert _exit_status(1) == 1 + assert _exit_status(137) == 137 + # SIGTERM (-15) also normalizes. + assert _exit_status(-15) == 143 diff --git a/agent/tests/test_stuck_guard.py b/agent/tests/test_stuck_guard.py index 942246e2a..d3ca649bf 100644 --- a/agent/tests/test_stuck_guard.py +++ b/agent/tests/test_stuck_guard.py @@ -1,4 +1,4 @@ -"""Tests for the stuck/runaway guard (K7, live-caught ABCA-483).""" +"""Tests for the stuck/runaway guard.""" from __future__ import annotations @@ -120,7 +120,7 @@ def test_healthy_varied_work_never_trips(self): assert g.evaluate().kind == "none" def test_iterating_agent_same_command_DIFFERENT_failures_never_steers(self): - # K10 false-positive guard: the agent re-runs the SAME test command as + # False-positive guard: the agent re-runs the SAME test command as # it fixes failures one by one — each run fails on a DIFFERENT test. # That's progress, not a loop. The streak resets on each new output, so # it never even reaches the (advisory) steer threshold. @@ -171,7 +171,7 @@ def test_interleaved_success_on_OTHER_sig_does_not_clear_the_loop(self): class TestWindowSpin: - """ABCA-662: the loop-of-VARIATIONS the per-signature streak can't see — the + """The loop-of-VARIATIONS the per-signature streak can't see — the agent tries a different command each turn toward the same failing goal (a git push that keeps failing on 'invalid credentials'). No single signature reaches STEER_THRESHOLD, but the trailing window is failure-dominated.""" @@ -226,7 +226,7 @@ def test_healthy_iteration_below_window_threshold_no_steer(self): assert g.recent_failure_summary() is None def test_window_steers_at_exactly_the_threshold(self): - # N4 boundary: exactly WINDOW_FAIL_THRESHOLD (5) same-fingerprint failures + # Boundary: exactly WINDOW_FAIL_THRESHOLD (5) same-fingerprint failures # in a FULL window of WINDOW (6) — the `>=` edge where an off-by-one would # hide. One OK dilutes the window to 5/6 fails, still == the threshold. assert WINDOW == 6 and WINDOW_FAIL_THRESHOLD == 5 # pin the constants @@ -240,7 +240,7 @@ def test_window_steers_at_exactly_the_threshold(self): assert g.recent_failure_summary() is not None def test_no_steer_when_window_not_yet_full(self): - # N4 boundary: WINDOW_FAIL_THRESHOLD failures but the window has fewer than + # Boundary: WINDOW_FAIL_THRESHOLD failures but the window has fewer than # WINDOW entries — _dominant_window_failure requires a FULL window, so 5 # identical failures in a length-5 history must NOT steer yet. g = StuckGuard() diff --git a/agent/tests/test_task_state.py b/agent/tests/test_task_state.py index bf565260a..b0eef03d9 100644 --- a/agent/tests/test_task_state.py +++ b/agent/tests/test_task_state.py @@ -252,7 +252,7 @@ def update_item(self, **kwargs): class TestWriteTerminalTraceS3Uri: - """K2 Stage 4 — ``write_terminal`` persists ``trace_s3_uri`` from + """``write_terminal`` persists ``trace_s3_uri`` from the result dict so the ``get-trace-url`` handler (which reads the field off the TaskRecord) sees a consistent view the moment the task reaches terminal.""" @@ -411,13 +411,13 @@ def test_conditional_check_failed_with_trace_uri_logs_orphan_diagnostic( monkeypatch, capfd, ): - """K2 final review SIG-1: when ``write_terminal``'s precondition + """When ``write_terminal``'s precondition fails (typically: concurrent cancel) and a ``trace_s3_uri`` was already uploaded, the orphaned S3 object needs a dedicated log line — otherwise the generic ``skipped: precondition not met`` message hides silently-lost trace URIs. - L4 extension: after the orphan log prints, the self-heal + Extension: after the orphan log prints, the self-heal ``write_trace_uri_conditional`` fires; when the second UpdateItem succeeds, the self-heal log also prints.""" from botocore.exceptions import ClientError diff --git a/agent/tests/test_telemetry_redaction.py b/agent/tests/test_telemetry_redaction.py index 3fc9bb6e1..adb50a5e8 100644 --- a/agent/tests/test_telemetry_redaction.py +++ b/agent/tests/test_telemetry_redaction.py @@ -4,7 +4,7 @@ under ``_METRICS_REDACT_KEYS`` (just ``error``). That swallowed legitimate structural-error strings like ``missing built-in hard-deny policies: /app/policies/hard_deny.cedar`` whose diagnostic value is -high and secret-risk is zero — see E2E 2026-05-11 T2.2. +high and secret-risk is zero (observed during end-to-end testing). The new implementation routes ``error`` values through ``output_scanner.scan_tool_output`` and only substitutes diff --git a/agent/tests/test_trace_upload.py b/agent/tests/test_trace_upload.py index f9f54a497..bbb685942 100644 --- a/agent/tests/test_trace_upload.py +++ b/agent/tests/test_trace_upload.py @@ -1,13 +1,13 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: MIT-0 -"""Tests for the K2 Stage 4 --trace upload path (design §10.1). +"""Tests for the --trace upload path (design §10.1). Covers: * ``_TrajectoryWriter`` accumulator behavior (enabled/disabled, bounded, JSONL header shape) * ``upload_trace_to_s3`` fail-open semantics and contract enforcement - from the K2 Stage 3 review (empty user_id -> skip + warn, never + (empty user_id -> skip + warn, never write ``traces//`` keys) """ @@ -404,7 +404,7 @@ def test_non_accumulating_writer_retains_no_state(self): class TestAccumulatorWhenCloudWatchDisabled: - """K2 review Finding #9: accumulator must capture events even when + """The accumulator must capture events even when the CloudWatch path is disabled (no log group env, or circuit breaker open). The S3 artifact is independent of CW health by design.""" @@ -423,7 +423,7 @@ def test_captures_when_circuit_breaker_open(self): class TestTruncationCallback: - """K2 review Finding #3: accumulator cap trips fire a one-shot + """Accumulator cap trips fire a one-shot callback so the pipeline can surface ``trace_truncated`` in ``bgagent watch``.""" diff --git a/agent/tests/test_verify_commands.py b/agent/tests/test_verify_commands.py new file mode 100644 index 000000000..02b7a5162 --- /dev/null +++ b/agent/tests/test_verify_commands.py @@ -0,0 +1,256 @@ +"""Tests for the configurable build/lint verification command (#1 build-gate fix).""" + +from __future__ import annotations + +import subprocess +from types import SimpleNamespace + +import post_hooks +from post_hooks import ( + DEFAULT_BUILD_COMMAND, + DEFAULT_LINT_COMMAND, + is_verify_command_inert, + resolve_verify_argv, + verify_build, + verify_lint, +) + + +class TestResolveVerifyArgv: + def test_empty_falls_back_to_default(self): + assert resolve_verify_argv("", DEFAULT_BUILD_COMMAND) == ["mise", "run", "build"] + assert resolve_verify_argv(" ", DEFAULT_LINT_COMMAND) == ["mise", "run", "lint"] + + def test_none_falls_back_to_default(self): + assert resolve_verify_argv(None, DEFAULT_BUILD_COMMAND) == ["mise", "run", "build"] + + def test_configured_command_splits_to_argv(self): + assert resolve_verify_argv("npm run build", "") == ["npm", "run", "build"] + assert resolve_verify_argv("gradle build", "") == ["gradle", "build"] + + def test_quoted_args_preserved(self): + assert resolve_verify_argv('make "target with spaces"', DEFAULT_BUILD_COMMAND) == [ + "make", + "target with spaces", + ] + + def test_chained_command_runs_through_a_shell(self): + # #72: a && / | / ; chain must run via `bash -lc` so the WHOLE chain + # executes. Previously shlex-split into one `npm` call with `&&`/`npm`/… + # as bogus args — `npm ci` ran, ignored the rest, exited 0, and a broken + # lint/test in the chain NEVER ran (false "build OK"). + assert resolve_verify_argv("npm ci && npm run lint && npm test", "") == [ + "bash", + "-lc", + "npm ci && npm run lint && npm test", + ] + + def test_other_shell_operators_also_wrap(self): + for cmd in ("eslint . | tee out.txt", "make build; make test", "tsc > /dev/null"): + argv = resolve_verify_argv(cmd, "") + assert argv[:2] == ["bash", "-lc"], cmd + assert argv[2] == cmd + + def test_plain_command_still_direct_argv(self): + # No operators → still a direct exec (no shell wrapper). + assert resolve_verify_argv("npm run build", "") == ["npm", "run", "build"] + + def test_env_assignment_prefix_wraps_in_shell(self): + # A leading VAR=value env-prefix is shell syntax. Exec'd + # directly, shlex-split makes the FIRST token the "program" (VAR=value) → + # FileNotFoundError, crashing the task before the build runs. Must route + # through bash -lc so the assignment takes effect. (Observed in practice: a + # lint_command of `MISE_EXPERIMENTAL=1 mise //cdk:eslint` crashed at exit 1.) + assert resolve_verify_argv("MISE_EXPERIMENTAL=1 mise //cdk:eslint", "") == [ + "bash", + "-lc", + "MISE_EXPERIMENTAL=1 mise //cdk:eslint", + ] + + def test_multiple_env_assignments_wrap(self): + cmd = "FOO=1 BAR=2 make build" + assert resolve_verify_argv(cmd, "") == ["bash", "-lc", cmd] + + def test_equals_not_at_start_is_not_an_env_prefix(self): + # An `=` inside a later arg (not a leading VAR= token) is NOT an env prefix + # — a plain command with such an arg still execs directly. + assert resolve_verify_argv("npm run build --define=X=1", "") == [ + "npm", + "run", + "build", + "--define=X=1", + ] + + +class TestVerifyBuildHonorsCommand: + def _capture_argv(self, monkeypatch): + seen = {} + + def fake_run_cmd(argv, **kw): + seen["argv"] = argv + seen["kw"] = kw + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(post_hooks, "run_cmd", fake_run_cmd) + return seen + + def test_build_defaults_to_mise(self, monkeypatch): + seen = self._capture_argv(monkeypatch) + outcome = verify_build("/repo") + assert outcome.passed is True + assert outcome.timed_out is False + assert seen["argv"] == ["mise", "run", "build"] + + def test_build_uses_configured_command(self, monkeypatch): + seen = self._capture_argv(monkeypatch) + assert verify_build("/repo", "npm run build").passed is True + assert seen["argv"] == ["npm", "run", "build"] + + def test_verify_passes_the_build_timeout(self, monkeypatch): + # The verify subprocess must run under BUILD_VERIFY_TIMEOUT_S (not + # run_cmd's 600s default) so a real CI-parity build can finish. + seen = self._capture_argv(monkeypatch) + verify_build("/repo", "mise run build") + assert seen["kw"].get("timeout") == post_hooks.BUILD_VERIFY_TIMEOUT_S + + def test_lint_uses_configured_command(self, monkeypatch): + seen = self._capture_argv(monkeypatch) + assert verify_lint("/repo", "ruff check .").passed is True + assert seen["argv"] == ["ruff", "check", "."] + + def test_nonzero_returncode_is_failure_not_timeout(self, monkeypatch): + monkeypatch.setattr(post_hooks, "run_cmd", lambda argv, **kw: SimpleNamespace(returncode=1)) + outcome = verify_build("/repo", "npm run build") + assert outcome.passed is False + assert outcome.timed_out is False # ran-and-failed, not a timeout + + def test_timeout_is_not_passed_AND_flagged_timed_out(self, monkeypatch): + # The key distinction: a timeout must read as "timed + # out", not a generic build failure. passed=False (a build that never + # finished isn't green) but timed_out=True so the reason differs. + def boom(argv, **kw): + raise subprocess.TimeoutExpired(cmd=argv, timeout=1) + + monkeypatch.setattr(post_hooks, "run_cmd", boom) + outcome = verify_build("/repo", "npm run build") + assert outcome.passed is False + assert outcome.timed_out is True + + def test_exit_127_is_INERT_not_a_build_failure(self, monkeypatch): + # Command-not-found (e.g. yarn missing) means the gate couldn't run — + # a CONFIG problem, not the agent's code. Must flag inert, not a failure, + # so the platform doesn't emit a false "build failed". + monkeypatch.setattr( + post_hooks, + "run_cmd", + lambda argv, **kw: SimpleNamespace(returncode=127, stderr="yarn: command not found"), + ) + outcome = verify_build("/repo", "yarn install && yarn build") + assert outcome.passed is False + assert outcome.inert is True + assert outcome.timed_out is False + + def test_no_such_mise_task_is_INERT(self, monkeypatch): + no_task = "mise ERROR no task named 'build'" + monkeypatch.setattr( + post_hooks, + "run_cmd", + lambda argv, **kw: SimpleNamespace(returncode=1, stderr=no_task), + ) + assert verify_build("/repo", "mise run build").inert is True + + def test_genuine_nonzero_is_a_failure_NOT_inert(self, monkeypatch): + # A real compiler/test failure (exit 1/2 with real output) must NOT be + # mislabeled inert — that would hide a genuine red build. + monkeypatch.setattr( + post_hooks, + "run_cmd", + lambda argv, **kw: SimpleNamespace(returncode=2, stderr="tsc: 3 type errors"), + ) + outcome = verify_build("/repo", "mise //cdk:compile") + assert outcome.passed is False + assert outcome.inert is False + + def test_ENOSPC_is_INFRA_failure_not_a_build_failure(self, monkeypatch): + # Disk-full mid-build means the build couldn't COMPLETE on + # this host — an infra fault, not broken code. Must flag infra_failed + # (not a plain failure, not inert) so the platform reports "retry / needs + # capacity", not "build/tests failed". + enospc = "yarn error ENOSPC: no space left on device, write" + monkeypatch.setattr( + post_hooks, + "run_cmd", + lambda argv, **kw: SimpleNamespace(returncode=1, stderr=enospc), + ) + outcome = verify_build("/repo", "mise run build") + assert outcome.passed is False + assert outcome.infra_failed is True + assert outcome.inert is False # NOT a config problem + + def test_OOM_sigkill_137_is_INFRA_failure(self, monkeypatch): + monkeypatch.setattr( + post_hooks, + "run_cmd", + lambda argv, **kw: SimpleNamespace(returncode=137, stderr="Killed"), + ) + outcome = verify_build("/repo", "mise run build") + assert outcome.passed is False + assert outcome.infra_failed is True + + def test_bare_sigkill_137_no_stderr_signature_is_INFRA_not_inert(self, monkeypatch): + # Regression observed in practice: the container/cgroup OOM-killer delivers + # SIGKILL and writes "Killed process …" to the KERNEL log, not the build + # process's own stderr — so an OOM'd `mise run build` exits 137 with NO + # "killed"/"out of memory" string captured. Such a 137 was mislabeled + # INERT (the greedy `mise`+`not found` heuristic matched an unrelated + # webhook-test fixture line in the big streamed log). A bare 137 must be + # INFRA (retry/capacity), never inert (config) and never a build failure. + mise_and_notfound = ( + "[//agent:test] tests/test_attachments.py ...\n" + "[//cdk:test] Linear project is not found — skipping\n" + "ERROR task failed" + ) + monkeypatch.setattr( + post_hooks, + "run_cmd", + lambda argv, **kw: SimpleNamespace(returncode=137, stderr=mise_and_notfound), + ) + outcome = verify_build("/repo", "mise run build") + assert outcome.passed is False + assert outcome.infra_failed is True + assert outcome.inert is False # infra checked BEFORE inert + + def test_bare_sigkill_137_plain_output_is_INFRA_not_a_build_failure(self, monkeypatch): + # The dangerous fall-through: a 137 whose captured output trips NEITHER the + # inert nor OOM string heuristics would have been reported as a GENUINE + # build FAILURE → a false gate blocking healthy code (and, in an epic, + # poisoning the dependent cascade). SIGKILL is a resource kill, not a + # test result, so it must be infra. + monkeypatch.setattr( + post_hooks, + "run_cmd", + lambda argv, **kw: SimpleNamespace(returncode=137, stderr="compiling...\naborting"), + ) + outcome = verify_build("/repo", "mise run build") + assert outcome.passed is False + assert outcome.infra_failed is True + assert outcome.inert is False + + +class TestIsVerifyCommandInert: + def test_mise_no_tasks_defined_is_inert(self): + assert is_verify_command_inert(1, "mise ERROR no tasks defined in /repo") is True + + def test_command_not_found_exit_127_is_inert(self): + assert is_verify_command_inert(127, "gradle: command not found") is True + + def test_no_task_named_is_inert(self): + assert is_verify_command_inert(1, "mise ERROR: no task named 'build'") is True + + def test_genuine_build_failure_is_NOT_inert(self): + # Real compiler/test output, exited non-zero → meaningful gating signal. + real_failure = "TypeError: cannot read property 'x'\n1 test failed" + assert is_verify_command_inert(2, real_failure) is False + + def test_clean_exit_is_not_inert(self): + assert is_verify_command_inert(0, "") is False diff --git a/agent/tests/test_workflow_runner.py b/agent/tests/test_workflow_runner.py index a23c5d68a..d71494dde 100644 --- a/agent/tests/test_workflow_runner.py +++ b/agent/tests/test_workflow_runner.py @@ -13,6 +13,7 @@ import pytest +from post_hooks import VerifyOutcome from workflow import Step, StepContext, StepOutcome, Workflow, run_workflow from workflow.runner import StepHandler, WorkflowCheckpoint, _step_key @@ -492,7 +493,8 @@ def test_verify_build_regression_only_passes_when_broken_before(self, monkeypatc from workflow.runner import _handle_verify_build # build red after, but it was already red before → not a regression. - monkeypatch.setattr("post_hooks.verify_build", lambda _d: False) + red = VerifyOutcome(passed=False) + monkeypatch.setattr("post_hooks.verify_build", lambda _d, _c="": red) wf = _workflow( [ {"kind": "verify_build", "name": "build", "gate": "regression_only"}, @@ -509,7 +511,8 @@ def test_verify_build_regression_only_fails_on_regression(self, monkeypatch): from models import RepoSetup from workflow.runner import _handle_verify_build - monkeypatch.setattr("post_hooks.verify_build", lambda _d: False) + red = VerifyOutcome(passed=False) + monkeypatch.setattr("post_hooks.verify_build", lambda _d, _c="": red) wf = _workflow( [ {"kind": "verify_build", "name": "build", "gate": "regression_only"}, @@ -525,7 +528,8 @@ def test_verify_lint_read_only_is_informational(self, monkeypatch): from workflow.runner import _handle_verify_lint # read_only workflow: a lint failure must not gate (symmetry with build). - monkeypatch.setattr("post_hooks.verify_lint", lambda _d: False) + red = VerifyOutcome(passed=False) + monkeypatch.setattr("post_hooks.verify_lint", lambda _d, _c="": red) wf = _workflow( [ {"kind": "clone_repo"}, diff --git a/agent/workflows/coding/restack-v1.yaml b/agent/workflows/coding/restack-v1.yaml new file mode 100644 index 000000000..f0115598f --- /dev/null +++ b/agent/workflows/coding/restack-v1.yaml @@ -0,0 +1,53 @@ +# A6 re-stack (#305): re-merge a CHANGED predecessor branch into an existing +# stacked-child PR so the child is no longer stale. Like pr-iteration it +# operates on an existing PR branch (push_resolve — no new PR), but it also +# receives the updated predecessor branch(es) as merge_branches, which repo.py +# merges into the working tree before the agent runs. The agent reconciles +# conflicts, verifies the build, and pushes the same branch. +# +# Triggered by the platform (the A6 re-stack handler off a pull_request +# webhook), not by a user. Writeable; Cedar principal "new_task" (the +# id→legacy map has no restack entry, so it falls to new_task — a writeable +# coding identity, correct here). +id: coding/restack-v1 +version: 1.0.0 +domain: coding +description: Re-merge a changed predecessor branch into an existing stacked-child PR (#305 A6). +requires_repo: true +read_only: false +prompt: + template: registry://prompt/coding-restack-workflow + placeholders: + - repo_url + - task_id + - workspace + - branch_name + - default_branch + - max_turns + - setup_notes + - memory_context + - pr_number +hydration: + sources: [pull_request, memory, task_description] +agent_config: + tier: standard + allowed_tools: [Bash, Read, Write, Edit, Glob, Grep, WebFetch] + cedar_policy_modules: [builtin/hard_deny, builtin/soft_deny] +repo_config: + provider: github + discover: true +required_inputs: + all_of: [pr_number] +steps: + - { kind: clone_repo, name: setup } + - { kind: hydrate_context, name: context } + - { kind: run_agent, name: restack } + - { kind: verify_build, name: build, gate: regression_only } + - { kind: ensure_pr, name: resolve_pr, strategy: push_resolve } +terminal_outcomes: + primary: pr_url +limits: + max_turns: 100 +promotion_gate: + requires: [tests:agent/restack] +status: production diff --git a/cdk/eslint.config.mjs b/cdk/eslint.config.mjs index fbd92691e..a7a58ffd6 100644 --- a/cdk/eslint.config.mjs +++ b/cdk/eslint.config.mjs @@ -259,7 +259,17 @@ export default [ { files: ['test/**/*.ts'], rules: { + // Literal fixtures and expected values are the point of a test; naming every + // one defeats readability. '@typescript-eslint/no-magic-numbers': 'off', + // Table-driven assertions and long expected strings read better on one line + // than wrapped. + '@stylistic/max-len': 'off', + 'max-len': 'off', + // NOTE: `no-shadow` is deliberately NOT relaxed here. It is + // correctness-adjacent in test code — a shadowed `row` or `mock` inside a + // nested describe is a common way to assert against the wrong fixture and + // still pass. Rename the inner binding instead. }, }, ]; diff --git a/cdk/package.json b/cdk/package.json index 38dc196be..acbb53b4d 100644 --- a/cdk/package.json +++ b/cdk/package.json @@ -36,7 +36,7 @@ "cdk-nag": "^2.38.2", "constructs": "^10.6.0", "js-yaml": "^4.1.1", - "pdf-parse": "^2.4.5", + "pdf-parse": "2.4.5", "ulid": "^3.0.2", "ws": "^8.21.0" }, @@ -49,7 +49,6 @@ "@types/jest": "^30.0.0", "@types/js-yaml": "^4.0.9", "@types/node": "^26", - "@types/pdf-parse": "^1.1.5", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", 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/constructs/ecs-agent-cluster.ts b/cdk/src/constructs/ecs-agent-cluster.ts index f21a3f163..ebf475a0c 100644 --- a/cdk/src/constructs/ecs-agent-cluster.ts +++ b/cdk/src/constructs/ecs-agent-cluster.ts @@ -27,7 +27,7 @@ import * as logs from 'aws-cdk-lib/aws-logs'; import * as s3 from 'aws-cdk-lib/aws-s3'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import { NagSuppressions } from 'cdk-nag'; -import { Construct } from 'constructs'; +import { Construct, type Node } from 'constructs'; import { AgentMemory } from './agent-memory'; import { AgentSessionRole } from './agent-session-role'; import { resolveBedrockModelIds } from './bedrock-models'; @@ -42,7 +42,14 @@ export interface EcsAgentClusterProps { readonly memoryId?: string; /** - * S3 bucket holding per-task ECS payloads (#502). The orchestrator writes the + * Optional Fargate task sizing overrides. Any unset field uses the generous + * default; a consumer with a lighter repo should shrink the build task to cut + * cost. See {@link EcsTaskSizing}. + */ + readonly taskSizing?: EcsTaskSizing; + + /** + * S3 bucket holding per-task ECS payloads. The orchestrator writes the * payload (incl. the large hydrated_context, which can't fit in the 8 KB * RunTask containerOverrides limit) here and passes only an * `AGENT_PAYLOAD_S3_URI` pointer; the container fetches it on boot. The task @@ -54,46 +61,45 @@ export interface EcsAgentClusterProps { readonly payloadBucket?: s3.IBucket; /** - * Artifacts bucket for repo-bound artifact workflows (#299 coding/decompose-v1 - * emits its plan JSON here via ``deliver_artifact``). The AgentCore runtime - * gets ``ARTIFACTS_BUCKET_NAME`` in its env; the ECS task needs the SAME env - * (but NO bucket grant) or an artifact workflow fails at delivery with - * "ARTIFACTS_BUCKET_NAME is not configured" (live-caught: a :decompose on an - * ecs-configured repo). The delivery WRITE goes through the assumed per-task - * SessionRole (scoped to ``artifacts/${aws:PrincipalTag/task_id}/*``), so the - * task role gets only the env var — parity with the AgentCore runtime role, - * which likewise has no direct artifacts grant (see the grant block below for - * the rationale). + * Artifacts bucket for repo-bound artifact workflows (a planning + * workflow emits its plan JSON here via ``deliver_artifact``). The AgentCore + * runtime gets ``ARTIFACTS_BUCKET_NAME`` in its env; the ECS task needs the + * SAME env (but NO bucket grant) or an artifact workflow fails at delivery with + * "ARTIFACTS_BUCKET_NAME is not configured". The delivery WRITE goes through + * the assumed per-task SessionRole (scoped to + * ``artifacts/${aws:PrincipalTag/task_id}/*``), so the task role gets only the + * env var — parity with the AgentCore runtime role, which likewise has no + * direct artifacts grant (see the grant block below for the rationale). * * NOTE: this wires only ``ARTIFACTS_BUCKET_NAME`` (artifact delivery). It does * NOT set ``TRACE_ARTIFACTS_BUCKET_NAME`` (telemetry.py reads that for the * ``--trace`` upload), so ``--trace`` silently skips on ECS today — a separate - * ECS-parity gap, not wired here. + * ECS-vs-AgentCore parity gap, not wired here. * Omitted in isolated construct tests → no env/grant. */ readonly artifactsBucket?: s3.IBucket; /** - * Per-task SessionRole (#209). When provided, tenant-data DynamoDB access + * Per-task SessionRole. When provided, tenant-data DynamoDB access * (task/events tables) is NOT granted to the Fargate task role; instead the * agent assumes this SessionRole with session tags and the role's * tag-scoped policy governs that access. The task role is admitted to the * SessionRole's trust and `AGENT_SESSION_ROLE_ARN` is injected into the * container. When omitted (e.g. isolated construct tests), the task role - * retains the legacy direct grants. + * retains the direct grants. */ readonly agentSessionRole?: AgentSessionRole; /** - * AgentCore Memory for cross-task learning (F-2 / ABCA-488-class parity). When - * provided, the ECS task role is granted read+write on it so the agent's - * memory writes (write_task_episode / write_repo_learnings → - * ``bedrock-agentcore:CreateEvent``) succeed on the ECS substrate. The - * AgentCore runtime role already gets this via ``agentMemory.grantReadWrite`` - * in agent.ts; without the same grant here, memory writes hit AccessDenied and - * no-op on ECS (logged, non-fatal — memory.py treats an AccessDenied as an - * infra failure), so learning never persists on an ECS-only deployment. - * Omitted in isolated construct tests / memory-less deployments. + * AgentCore Memory for cross-task learning. When provided, the ECS task role + * is granted read+write on it so the agent's memory writes (write_task_episode + * / write_repo_learnings → ``bedrock-agentcore:CreateEvent``) succeed on the + * ECS substrate. The AgentCore runtime role already gets this via + * ``agentMemory.grantReadWrite`` in agent.ts; without the same grant here, + * memory writes hit AccessDenied and no-op on ECS (logged, non-fatal — + * memory.py treats an AccessDenied as an infra failure), so learning never + * persists on an ECS-only deployment. Omitted in isolated construct tests / + * memory-less deployments. */ readonly agentMemory?: AgentMemory; } @@ -101,9 +107,188 @@ export interface EcsAgentClusterProps { /** HTTPS port — the only egress allowed from the agent task ENIs. */ const HTTPS_PORT = 443; +/** + * Default Fargate task sizes (vCPU units / MiB / GiB). These defaults are + * deliberately MODEST, because a default is what an adopter who changes nothing + * pays for and Fargate bills per requested vCPU-second and RAM-second. Both task + * sizes are overridable via {@link EcsAgentClusterProps.taskSizing}, reachable at + * deploy time through context (see {@link resolveEcsTaskSizing}). + * + * - BUILD task: 4 vCPU / 16 GB / 50 GiB disk. + * + * CPU and memory are modest on measured evidence: a full parallel build of a + * large TypeScript + Python monorepo (agent + CDK + CLI + docs) peaked at + * ~3.1 GB of the 16 GB, because ``MISE_JOBS=1`` serialises the packages so peak + * is max-single-package rather than sum-of-all. Nearly 5x headroom. + * + * Disk is the tighter constraint and is sized less aggressively for that + * reason: the same build peaked at ~14.7 GiB, so Fargate's 21 GiB floor leaves + * only ~1.4x — a heavier dependency cache or a second build sharing the task + * would run it out of space and surface as a spurious build failure. 50 GiB + * restores real margin, and ephemeral storage is a small fraction of the + * per-task cost next to vCPU and RAM. + * + * A repo that genuinely needs more can go to Fargate's ceiling of + * 16 vCPU / 120 GB (and up to 200 GiB of disk) through {@link EcsTaskSizing}. + * Note a memory-heavy build is helped only by more per-task RAM or fewer + * parallel build steps — a build task runs in its own isolated microVM, so + * capping how many tasks run at once does not help. + * - PLANNING task: 2 vCPU / 8 GB / default disk. For read-only workflows that + * clone and read the repo to produce a plan but never build. "Read-only" + * describes the WORKFLOW's behaviour, not a reduced IAM role: both task defs + * are built by one factory and deliberately share a single task role and + * execution role, because splitting them is how a grant silently lands on one + * def and not the other. Do not read the name as a privilege boundary. + */ +// A MODEST default: 4 vCPU / 16 GB, and Fargate's own 20 GiB disk. +// +// Deliberately not the Fargate ceiling. A default is what an adopter who changes +// nothing gets, and at 16 vCPU / 120 GB that is roughly 5x the per-build cost of +// this size in us-east-1 on-demand. Under-provisioning surfaces as a slow or +// OOM-ing build, which is diagnosable and fixable with one prop; over-provisioning +// surfaces as a bill, which is not. A large TypeScript + Python monorepo genuinely +// needs more — raise it through {@link EcsTaskSizing}, up to Fargate's 16 vCPU / +// 120 GB maximum, and raise ephemeral storage with it if concurrent builds run the +// disk out of space. +const DEFAULT_BUILD_TASK_CPU = 4096; +const DEFAULT_BUILD_TASK_MEMORY_MIB = 16384; +const DEFAULT_BUILD_TASK_EPHEMERAL_STORAGE_GIB = 50; +const DEFAULT_PLANNING_TASK_CPU = 2048; +const DEFAULT_PLANNING_TASK_MEMORY_MIB = 8192; + +/** + * Per-task Fargate sizing overrides. Every field is optional; anything left + * unset uses the default above. A consumer with a lighter repo should shrink the + * build task (for example 4 vCPU / 16 GB) to cut cost; a heavy monorepo can keep + * or raise it up to the Fargate ceiling of 16 vCPU / 120 GB. Values are passed + * straight to the Fargate task definition, so they must be a valid Fargate + * cpu/memory combination (see the AWS Fargate docs) — an invalid pair fails at + * synth/deploy, not silently. + */ +export interface EcsTaskSizing { + /** Build task vCPU units (1024 = 1 vCPU). Defaults to 4096 (4 vCPU). */ + readonly buildTaskCpu?: number; + /** Build task memory in MiB. Defaults to 16384 (16 GB). */ + readonly buildTaskMemoryMiB?: number; + /** Build task root-filesystem storage in GiB (21–200). Defaults to 50. */ + readonly buildTaskEphemeralStorageGiB?: number; + /** Planning (read-only) task vCPU units. Defaults to 2048 (2 vCPU). */ + readonly planningTaskCpu?: number; + /** Planning task memory in MiB. Defaults to 8192 (8 GB). */ + readonly planningTaskMemoryMiB?: number; + /** + * Extra environment variables for the BUILD task's container, merged over the + * defaults (a key given here wins). + * + * The platform sets a few build-tool variables that are only meaningful for the + * toolchain a given repo actually uses — a verify timeout, and parallelism caps + * for a mise/jest-shaped build. Those values were measured against one + * monorepo, so they are this deployment's opinion rather than a universal + * default: a repo that uses none of those tools ignores them, and a repo that + * uses mise with plenty of memory will want the serialisation cap raised. Set + * them here rather than editing the construct. + */ + readonly extraBuildEnvironment?: Record; +} + +/** + * Read {@link EcsTaskSizing} out of deploy context, so the sizing and build-tool + * knobs are reachable without editing this file. Returns undefined when nothing + * is set, so the construct's own defaults apply untouched. + * + * Numeric keys are parsed strictly: a malformed value throws at synth rather + * than silently falling back to the default, because "I set the flag and the + * build still OOM'd" is a much worse afternoon than a failed synth. + * + * Reserved platform env keys are REJECTED rather than merged. The container's + * base environment carries load-bearing wiring — table names, the artifacts + * bucket, and ``AGENT_SESSION_ROLE_ARN``, whose absence turns tenant scoping off + * without failing the task. A caller reaching for a build-tool override must not + * be able to unset those by a typo. + */ +/** + * Container env keys the platform owns. A build-tool override must not be able to + * reach these: they carry table names, bucket names and the agent-session role. + * ``AGENT_SESSION_ROLE_ARN`` is the sharp one — when it is absent the agent falls + * back to ambient credentials and per-tenant scoping is silently OFF, which the + * agent's own session module calls its most dangerous failure mode. A typo in an + * override key should fail synth, not disable an isolation control. + */ +const RESERVED_BUILD_ENV_KEYS = new Set([ + 'TASK_TABLE_NAME', + 'TASK_EVENTS_TABLE_NAME', + 'USER_CONCURRENCY_TABLE_NAME', + 'LOG_GROUP_NAME', + 'GITHUB_TOKEN_SECRET_ARN', + 'MEMORY_ID', + 'ECS_PAYLOAD_BUCKET', + 'ARTIFACTS_BUCKET_NAME', + 'AGENT_SESSION_ROLE_ARN', + 'CLAUDE_CODE_USE_BEDROCK', + 'AWS_REGION', +]); + +export function resolveEcsTaskSizing(node: Node): EcsTaskSizing | undefined { + const num = (key: string): number | undefined => { + const raw = node.tryGetContext(key); + if (raw === undefined || raw === null || raw === '') return undefined; + const parsed = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`Context '${key}' must be a positive integer; got ${JSON.stringify(raw)}.`); + } + return parsed; + }; + + const rawEnv = node.tryGetContext('ecsExtraBuildEnv'); + let extra: Record | undefined; + if (rawEnv !== undefined && rawEnv !== null && rawEnv !== '') { + const parsed = typeof rawEnv === 'string' ? JSON.parse(rawEnv) : rawEnv; + if (typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error("Context 'ecsExtraBuildEnv' must be a JSON object of string values."); + } + for (const [k, v] of Object.entries(parsed as Record)) { + if (typeof v !== 'string') { + throw new Error(`Context 'ecsExtraBuildEnv' value for '${k}' must be a string.`); + } + if (RESERVED_BUILD_ENV_KEYS.has(k)) { + throw new Error( + `Context 'ecsExtraBuildEnv' cannot set '${k}' — it is platform wiring, not a build-tool ` + + 'knob. Overriding it can break the task or silently disable tenant scoping.', + ); + } + } + extra = parsed as Record; + } + + const sizing: EcsTaskSizing = { + ...(num('ecsBuildTaskCpu') !== undefined && { buildTaskCpu: num('ecsBuildTaskCpu') }), + ...(num('ecsBuildTaskMemoryMiB') !== undefined && { buildTaskMemoryMiB: num('ecsBuildTaskMemoryMiB') }), + ...(num('ecsBuildTaskEphemeralStorageGiB') !== undefined && { + buildTaskEphemeralStorageGiB: num('ecsBuildTaskEphemeralStorageGiB'), + }), + ...(num('ecsPlanningTaskCpu') !== undefined && { planningTaskCpu: num('ecsPlanningTaskCpu') }), + ...(num('ecsPlanningTaskMemoryMiB') !== undefined && { planningTaskMemoryMiB: num('ecsPlanningTaskMemoryMiB') }), + ...(extra !== undefined && { extraBuildEnvironment: extra }), + }; + return Object.keys(sizing).length > 0 ? sizing : undefined; +} + export class EcsAgentCluster extends Construct { public readonly cluster: ecs.Cluster; + /** The BUILD task def (default 4 vCPU / 16 GB, raisable to the Fargate ceiling + * of 16 vCPU / 120 GB via {@link EcsTaskSizing}) — for coding workflows that + * run a full CI-parity build. Selected for non-read-only workflows. */ public readonly taskDefinition: ecs.FargateTaskDefinition; + /** + * The smaller read-only PLANNING task def (8 GB / 2 vCPU) — for any read-only + * workflow that clones + reads + emits an artifact but never builds. Same + * image/role/env/grants as the build def (shared task+execution role + a shared + * container spec, so a grant present on one def but missing on the other can't + * silently diverge); the ONLY difference is cpu/mem. The orchestrator selects + * this for read-only workflows on an ECS repo, so planning doesn't + * over-allocate the large build task. + */ + public readonly planningTaskDefinition: ecs.FargateTaskDefinition; public readonly securityGroup: ec2.SecurityGroup; public readonly containerName: string; public readonly taskRoleArn: string; @@ -139,83 +324,157 @@ export class EcsAgentCluster extends Construct { removalPolicy: RemovalPolicy.DESTROY, }); - // Task execution role (used by ECS agent to pull images, write logs) - // CDK creates this automatically via taskDefinition, but we need to - // grant additional permissions to the task role. - - // Fargate task definition. Sized for heavy CI-parity builds (e.g. ABCA's - // own ~2800-test `mise run build` + cdk synth). Sizing history (all - // live-caught dogfooding ABCA-on-ABCA, 2026-06-29): - // - 4 GB / 2 vCPU → OOM-killed even the AgentCore microVM. - // - 32 GB / 8 vCPU → ran ~50 min then OOM-killed (exit 137) at the cap; - // peak working set ~31.6 GB when the root build fans out 4 heavy jobs - // in PARALLEL (agent:quality ‖ cdk:build ‖ cli:build ‖ docs:build), - // each spawning its own worker fleet (jest maxWorkers, pytest, esbuild - // Lambda bundling). 32 GB had no headroom for that concurrent peak. - // - 64 GB / 16 vCPU → still OOM-killed (exit 137) on ABCA-662's baseline - // build: the parallel storm's peak exceeded 64 GB too. The false - // "build_before=broken" that followed is fixed in repo.py, but the build - // itself genuinely needs more RAM. - // - 120 GB / 16 vCPU (current) → the MAX Fargate admits at 16 vCPU (32–120 - // GB in 8 GB steps). If a build OOMs even here, the fix is to cut the - // build's peak parallelism (serialize the mise DAG / cap jest workers), - // not more RAM — there is none. Paired with BUILD_VERIFY_TIMEOUT_S=3600. - this.taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDef', { - cpu: 16384, - memoryLimitMiB: 122880, - runtimePlatform: { - cpuArchitecture: ecs.CpuArchitecture.ARM64, - operatingSystemFamily: ecs.OperatingSystemFamily.LINUX, - }, + // SHARED task + execution roles for BOTH task defs. The build def and the + // planning def MUST have identical IAM + env, or a token/grant present on one + // def and missing on the other causes a bug that only shows up on whichever + // task type is missing it. Rather than grant twice, we create the roles ONCE + // here and pass the SAME roles to both task defs, and build the container from + // a single shared spec. So there is exactly one place grants/env can be + // edited, and both defs stay in lockstep by construction. + const taskRole = new iam.Role(this, 'TaskRole', { + assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'), + }); + const executionRole = new iam.Role(this, 'ExecutionRole', { + assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonECSTaskExecutionRolePolicy'), + ], }); - // Container - this.taskDefinition.addContainer(this.containerName, { - image: ecs.ContainerImage.fromDockerImageAsset(props.agentImageAsset), - logging: ecs.LogDrivers.awsLogs({ - logGroup, - streamPrefix: 'agent', + // The container spec shared by both task defs — image, logging, env are + // IDENTICAL; only the enclosing task def's cpu/mem differ. BUILD_VERIFY_TIMEOUT_S + // is a build-tier concern (a read-only planner never runs the post-agent build + // verify), so it's set per-def below, not here. + const baseEnvironment: Record = { + CLAUDE_CODE_USE_BEDROCK: '1', + TASK_TABLE_NAME: props.taskTable.tableName, + TASK_EVENTS_TABLE_NAME: props.taskEventsTable.tableName, + USER_CONCURRENCY_TABLE_NAME: props.userConcurrencyTable.tableName, + LOG_GROUP_NAME: logGroup.logGroupName, + GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecret.secretArn, + ...(props.memoryId && { MEMORY_ID: props.memoryId }), + // The payload bucket name so the orchestrator-issued AGENT_PAYLOAD_S3_URI + // can be fetched. (The orchestrator sets the URI per-task via container + // override; this is set here for parity with the runtime env.) + ...(props.payloadBucket && { ECS_PAYLOAD_BUCKET: props.payloadBucket.bucketName }), + // Artifact workflows (planning/analysis) deliver their document to + // this bucket. The AgentCore runtime has ARTIFACTS_BUCKET_NAME; the ECS task + // needs it too or deliver_artifact raises "ARTIFACTS_BUCKET_NAME is not + // configured". + ...(props.artifactsBucket && { ARTIFACTS_BUCKET_NAME: props.artifactsBucket.bucketName }), + // Per-session IAM scoping: when a SessionRole is wired, the agent assumes + // it for tenant-data access (see aws_session.py). + ...(props.agentSessionRole && { + AGENT_SESSION_ROLE_ARN: props.agentSessionRole.role.roleArn, }), - environment: { - CLAUDE_CODE_USE_BEDROCK: '1', - TASK_TABLE_NAME: props.taskTable.tableName, - TASK_EVENTS_TABLE_NAME: props.taskEventsTable.tableName, - USER_CONCURRENCY_TABLE_NAME: props.userConcurrencyTable.tableName, - LOG_GROUP_NAME: logGroup.logGroupName, - GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecret.secretArn, - // Heavy CI-parity builds on this big-box substrate legitimately run - // long (ABCA's own `mise run build` is ~50 min cold), so set a generous - // post-agent build-verify cap here for the ECS-only path. NOTE: the - // consuming side (verify_build/verify_lint reading BUILD_VERIFY_TIMEOUT_S - // and passing it as the subprocess timeout) ships with the ECS-substrate - // work; on `main` today the verify subprocess still uses run_cmd's - // default cap, so this env is provisioned ahead of the wiring rather - // than currently effective. ECS-only: AgentCore repos don't set it. - BUILD_VERIFY_TIMEOUT_S: '3600', - ...(props.memoryId && { MEMORY_ID: props.memoryId }), - // #502: the payload bucket name so the orchestrator-issued - // AGENT_PAYLOAD_S3_URI can be fetched. (The orchestrator sets the URI - // per-task via container override; this is informational parity.) - ...(props.payloadBucket && { ECS_PAYLOAD_BUCKET: props.payloadBucket.bucketName }), - // #299 ECS-parity: artifact workflows (coding/decompose-v1) deliver their - // plan JSON to this bucket. The AgentCore runtime has ARTIFACTS_BUCKET_NAME; - // the ECS task needs it too or deliver_artifact raises "ARTIFACTS_BUCKET_NAME - // is not configured" (live-caught on an ecs-repo :decompose). - ...(props.artifactsBucket && { ARTIFACTS_BUCKET_NAME: props.artifactsBucket.bucketName }), - // Per-session IAM scoping (#209): when a SessionRole is wired, the - // agent assumes it for tenant-data access (see aws_session.py). - ...(props.agentSessionRole && { - AGENT_SESSION_ROLE_ARN: props.agentSessionRole.role.roleArn, - }), - }, - }); + }; + const image = ecs.ContainerImage.fromDockerImageAsset(props.agentImageAsset); + const makeTaskDef = ( + taskDefId: string, + cpu: number, + memoryLimitMiB: number, + extraEnv: Record, + ephemeralStorageGiB?: number, + ) => { + const def = new ecs.FargateTaskDefinition(this, taskDefId, { + cpu, + memoryLimitMiB, + taskRole, + executionRole, + // Raise root-fs storage past Fargate's 20 GiB default for build tasks so + // a large clone plus build caches don't run the disk out of space + // mid-build (ENOSPC); omitted → the 20 GiB default. + ...(ephemeralStorageGiB !== undefined && { ephemeralStorageGiB }), + runtimePlatform: { + cpuArchitecture: ecs.CpuArchitecture.ARM64, + operatingSystemFamily: ecs.OperatingSystemFamily.LINUX, + }, + }); + def.addContainer(this.containerName, { + image, + logging: ecs.LogDrivers.awsLogs({ logGroup, streamPrefix: 'agent' }), + environment: { ...baseEnvironment, ...extraEnv }, + }); + return def; + }; - // Task role permissions - const taskRole = this.taskDefinition.taskRole; + // Resolve task sizing: each field falls back to its default when the + // consumer didn't override it. See DEFAULT_BUILD_TASK_* above for why the + // build default is large, and EcsTaskSizing for how to shrink it. + const sizing = props.taskSizing ?? {}; + const buildCpu = sizing.buildTaskCpu ?? DEFAULT_BUILD_TASK_CPU; + const buildMemory = sizing.buildTaskMemoryMiB ?? DEFAULT_BUILD_TASK_MEMORY_MIB; + const buildDisk = sizing.buildTaskEphemeralStorageGiB ?? DEFAULT_BUILD_TASK_EPHEMERAL_STORAGE_GIB; + const planningCpu = sizing.planningTaskCpu ?? DEFAULT_PLANNING_TASK_CPU; + const planningMemory = sizing.planningTaskMemoryMiB ?? DEFAULT_PLANNING_TASK_MEMORY_MIB; - // DynamoDB: when a SessionRole (#209) is wired, tenant-data access lives on - // that tag-scoped role and the task role only needs to assume it. Without - // one (isolated construct tests / legacy), grant the task role directly. + this.taskDefinition = makeTaskDef('TaskDef', buildCpu, buildMemory, { + // Heavy CI-parity builds legitimately run longer than the 1800s default. + BUILD_VERIFY_TIMEOUT_S: '3600', + // Pin the jest test fleet to an ABSOLUTE worker count on ECS. jest's + // `maxWorkers: 25%` is CORE-relative → 4 workers on this 16-vCPU box. + // Measured, the test suite at 4 workers peaks at only ~2.2 GB (whole process + // tree) — not tens of GB. Container OOMs were not driven by this test + // suite's worker count; they were driven by TOTAL concurrency — a + // full-parallel `mise run build` running every package's test/build legs + // plus the resident coding agent all at once. So the real memory driver is + // cross-package build parallelism, not jest's internal workers. 4 is + // comfortably safe on the 120 GB box even alongside the other packages + + // agent. Kept as an explicit env (not core-relative) so a future bigger box + // can't silently over-spawn. The test script reads JEST_MAX_WORKERS (default + // 25%), so this only pins the shared ECS box — CI (2–4 cores) and dev + // machines keep 25%, unaffected. + JEST_MAX_WORKERS: '4', + // Serialize the mise task graph so the build steps' peak memory doesn't sum + // and OOM the task. `mise run build` fans out its `depends` (the per-package + // build/quality legs) up to MISE_JOBS in parallel (default 4); each package + // then spawns its OWN worker fleet (jest, pytest, esbuild, cdk synth). The + // measured memory driver of the OOMs was this CROSS-PACKAGE storm summing on + // top of the resident coding agent — not any single package. At 120 GB + // (Fargate's max at 16 vCPU) there is no more RAM to add, so the remedy is to + // cut peak parallelism. MISE_JOBS=1 runs the packages SEQUENTIALLY → peak ≈ + // max(single package) instead of sum(all packages), while still building + // every package and keeping BOTH gates (baseline + post-agent). + // Within-package parallelism (JEST_MAX_WORKERS=4, pytest) is untouched, so a + // single package still uses the box's cores. Cost is wall-clock (~serial + // sum, still minutes) — trivial against BUILD_VERIFY_TIMEOUT_S=3600. Without + // this, the post-agent build OOM'd (exit 137) stacking on the still-resident + // agent; a gate that OOMs verified NOTHING — serializing lets it actually + // COMPLETE and gate. Only affects `mise run ` (the build legs); the + // agent's direct `uv run pytest` calls are unaffected. + MISE_JOBS: '1', + // Skip the target repo's pre-push TEST hook inside the agent container. + // `mise run install` installs prek git hooks, incl. a pre-push hook that + // re-runs the FULL cdk+cli+agent test suite on every `git push`. In this + // container that suite already ran TWICE (baseline + post-agent build gate) + // and GitHub CI runs it again — so the pre-push run is pure redundancy, AND + // it runs UNcapped (no JEST_MAX_WORKERS), stacking on the resident agent → + // OOM. The agent's only escape was `git push --no-verify`, which silently + // bypassed ALL hooks (incl. the security scan) and trained a + // skip-verification habit. SKIP is the pre-commit/prek standard env var + // (comma-separated hook ids); scoping it to the tests hook lets the push + // succeed WITHOUT --no-verify while KEEPING the pre-push security scan. + // Propagates to both the platform push (post_hooks.py) and the agent's own + // git-tool pushes via shell.py::_clean_env (blacklist — passes SKIP through). + SKIP: 'monorepo-tests-pre-push', + // Caller overrides win: the values above are tuned for one monorepo's + // toolchain, so a deployment with a different build shape replaces them + // through `taskSizing.extraBuildEnvironment` rather than editing this file. + ...(sizing.extraBuildEnvironment ?? {}), + }, buildDisk); + + // PLANNING task def — for read-only workflows that clone + read + emit a plan + // artifact but NEVER build. 8 GB / 2 vCPU: a clone + a bounded set of file + // reads into the model context, no parallel build storm. Same image/roles/env + // as the build def (so channel OAuth, artifact delivery, payload fetch all + // work identically); NO BUILD_VERIFY_TIMEOUT_S (a read-only planner runs no + // build verify). If 8 GB proves tight on a very large clone, 16 GB / 4 vCPU is + // the next step — size up on Container Insights evidence. + this.planningTaskDefinition = makeTaskDef('PlanningTaskDef', planningCpu, planningMemory, {}); + + // DynamoDB: when a SessionRole is wired, tenant-data access lives on that + // tag-scoped role and the task role only needs to assume it. Without one + // (isolated construct tests / no SessionRole), grant the task role directly. if (props.agentSessionRole) { props.agentSessionRole.admitComputeRole(taskRole); } else { @@ -230,18 +489,18 @@ export class EcsAgentCluster extends Construct { // agent assumes the SessionRole — stays on the task role). props.githubTokenSecret.grantRead(taskRole); - // #502: read-only on the ECS payload bucket so the container can fetch its - // payload (AGENT_PAYLOAD_S3_URI) at boot. READ only — the container runs - // untrusted repo code, so it must not be able to write or delete payloads - // (the trusted orchestrator owns write + delete). Stays on the task role - // (read once at startup, before the agent assumes any SessionRole). + // Read-only on the ECS payload bucket so the container can fetch its payload + // (AGENT_PAYLOAD_S3_URI) at boot. READ only — the container runs untrusted + // repo code, so it must not be able to write or delete payloads (the trusted + // orchestrator owns write + delete). Stays on the task role (read once at + // startup, before the agent assumes any SessionRole). if (props.payloadBucket) { props.payloadBucket.grantRead(taskRole); } - // #299 ECS-parity: coding/decompose-v1 delivers its plan to the artifacts - // bucket via deliver_artifact — but the write goes through the assumed - // SessionRole (deliverers.py -> tenant_client), scoped to + // Artifact workflows (planning/analysis) deliver their document to the + // artifacts bucket via deliver_artifact — but the write goes through the + // assumed SessionRole (deliverers.py -> tenant_client), scoped to // artifacts/${task_id}/*, exactly like the AgentCore runtime (whose task // role likewise has NO direct artifacts grant). So the task role needs only // the ARTIFACTS_BUCKET_NAME env (set above), not a bucket grant. Granting @@ -250,27 +509,25 @@ export class EcsAgentCluster extends Construct { // artifacts//, traces/, attachments/ on the same bucket). // (no props.artifactsBucket grant — intentional; see comment) - // F-2 (ABCA-488-class parity): grant the task role read+write on the - // AgentCore Memory so the agent's cross-task learning writes - // (write_task_episode / write_repo_learnings → bedrock-agentcore:CreateEvent) - // succeed on ECS. The AgentCore runtime role gets this via - // agentMemory.grantReadWrite(runtime) in agent.ts; without the same grant - // here the writes hit AccessDenied and no-op on the ECS substrate (logged, - // non-fatal), so learning never persists on an ECS-only deployment. + // Grant the task role read+write on the AgentCore Memory so the agent's + // cross-task learning writes (write_task_episode / write_repo_learnings → + // bedrock-agentcore:CreateEvent) succeed on ECS. The AgentCore runtime role + // gets this via agentMemory.grantReadWrite(runtime) in agent.ts; without the + // same grant here the writes hit AccessDenied and no-op on the ECS substrate + // (logged, non-fatal), so learning never persists on an ECS-only deployment. if (props.agentMemory) { props.agentMemory.grantReadWrite(taskRole); } - // ABCA-488: per-workspace Linear/Jira OAuth tokens live in Secrets Manager - // under `bgagent-linear-oauth-*` (written by the CLI at setup). For a + // Per-workspace Linear/Jira OAuth tokens live in Secrets Manager under + // `bgagent-linear-oauth-*` (written by the CLI at setup). For a // Linear/Jira-channel task the agent resolves that token at startup // (config.resolve_linear_api_token / resolve_jira_oauth_token) to fire the // 👀→✅ reaction and drive the channel MCP. The AgentCore runtime role + // orchestrator/fanout/screenshot roles all have this prefix grant; the ECS // task role did NOT, so on ECS the token fetch hit AccessDenied and // reactions/MCP no-op'd — logged by config.py's token resolver, not silent, - // but the channel effect (no 👀→✅, no MCP) is invisible to the user - // (ECS-parity gap, live-caught on ABCA-488). + // but the channel effect (no 👀→✅, no MCP) is invisible to the user. // GetSecretValue only — the container reads the token; the orchestrator owns // refresh/PutSecretValue. taskRole.addToPrincipalPolicy(new iam.PolicyStatement({ @@ -324,17 +581,16 @@ export class EcsAgentCluster extends Construct { resources: bedrockResources, })); - // ECS-parity: a CDK-based target repo's build gate runs `cdk synth`, and a - // stack wired to a concrete env ({account, region}) does a synth-time - // availability-zone context lookup (ec2:DescribeAvailabilityZones). On a - // developer box the gitignored cdk.context.json caches the answer so synth - // is hermetic; the agent clones fresh, so there's no cache and synth fires - // the live lookup. Without this grant the ECS task role hit AccessDenied → - // "Synthesis finished with errors" → a FALSE build-gate failure on code that - // builds fine everywhere else (live-caught on the ABCA fork; same class as - // the ABCA-488 GetSecretValue and F-2 CreateEvent ECS-parity gaps). This is a - // read-only describe with no resource-level scoping in IAM, so Resource:* is - // required (suppressed below); it grants no mutation and no data access. + // A CDK-based target repo's build gate runs `cdk synth`, and a stack wired to + // a concrete env ({account, region}) does a synth-time availability-zone + // context lookup (ec2:DescribeAvailabilityZones). On a developer box the + // gitignored cdk.context.json caches the answer so synth is hermetic; the + // agent clones fresh, so there's no cache and synth fires the live lookup. + // Without this grant the ECS task role hit AccessDenied → "Synthesis finished + // with errors" → a FALSE build-gate failure on code that builds fine + // everywhere else. This is a read-only describe with no resource-level scoping + // in IAM, so Resource:* is required (suppressed below); it grants no mutation + // and no data access. taskRole.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ['ec2:DescribeAvailabilityZones'], resources: ['*'], @@ -343,11 +599,18 @@ export class EcsAgentCluster extends Construct { // CloudWatch Logs write logGroup.grantWrite(taskRole); - // Expose role ARNs for scoped iam:PassRole in the orchestrator + // Expose role ARNs for scoped iam:PassRole in the orchestrator. Both task + // defs share these roles, so one ARN pair covers both defs' PassRole grants. this.taskRoleArn = taskRole.roleArn; - this.executionRoleArn = this.taskDefinition.executionRole!.roleArn; + this.executionRoleArn = executionRole.roleArn; - NagSuppressions.addResourceSuppressions(this.taskDefinition, [ + // cdk-nag suppressions. The task role + execution role are SHARED standalone + // constructs rather than roles auto-created under a single task def, so the + // IAM suppressions must target the ROLES directly — a def-level + // `applyToChildren` suppression no longer reaches them (they're siblings of + // the task defs, not children). ECS2 (container env-vars-not-secrets) still + // belongs on each task def. + NagSuppressions.addResourceSuppressions(taskRole, [ { id: 'AwsSolutions-IAM5', reason: 'DynamoDB index/* wildcards from CDK grantReadWriteData (UserConcurrencyTable, and task tables only when no SessionRole is wired); Secrets Manager wildcards from CDK grantRead (GitHub token) and the bgagent-linear-oauth-*/bgagent-jira-oauth-* prefix grant (ABCA-488 — per-workspace channel OAuth tokens are created by the CLI at setup, name unknown at synth, GetSecretValue only); CloudWatch Logs wildcards from CDK grantWrite; S3 object/* wildcard from CDK grantRead on the ECS payload bucket (read-only, scoped to that bucket — #502). Bedrock InvokeModel is scoped to explicit model/inference-profile ARNs (no wildcard resource). ec2:DescribeAvailabilityZones requires Resource:* (EC2 describe actions have no resource-level scoping) — read-only, no mutation/data access; needed so a CDK target repo\'s `cdk synth` build gate can resolve AZ context on a fresh clone (ECS-parity, no cdk.context.json cache in the container).', @@ -357,6 +620,25 @@ export class EcsAgentCluster extends Construct { reason: 'Environment variables contain table names and configuration, not secrets — GitHub token is fetched from Secrets Manager at runtime', }, ], true); + NagSuppressions.addResourceSuppressions(executionRole, [ + { + id: 'AwsSolutions-IAM4', + reason: 'AmazonECSTaskExecutionRolePolicy is the AWS-recommended managed policy for ECS Fargate task execution (ECR image pull + CloudWatch Logs); shared by both the build and planning task defs.', + }, + { + id: 'AwsSolutions-IAM5', + reason: 'ecr:GetAuthorizationToken requires Resource:* (CDK grantPull for the agent image asset); the remaining ECR pull + CloudWatch Logs wildcards are CDK-generated grants scoped to the image repo and the task log group.', + }, + ], true); + // Same ECS2 posture on BOTH task defs (they share the container spec). + for (const def of [this.taskDefinition, this.planningTaskDefinition]) { + NagSuppressions.addResourceSuppressions(def, [ + { + id: 'AwsSolutions-ECS2', + reason: 'Environment variables contain table names and configuration, not secrets — GitHub token is fetched from Secrets Manager at runtime', + }, + ], true); + } NagSuppressions.addResourceSuppressions(this.cluster, [ { diff --git a/cdk/src/constructs/github-screenshot-integration.ts b/cdk/src/constructs/github-screenshot-integration.ts index b48c70864..6005afe38 100644 --- a/cdk/src/constructs/github-screenshot-integration.ts +++ b/cdk/src/constructs/github-screenshot-integration.ts @@ -61,12 +61,21 @@ export interface GitHubScreenshotIntegrationProps { /** * Optional — when provided, the processor also tries to post the * screenshot to a linked Linear issue. Resolved from the GitHub PR - * title/body via a Linear-identifier regex (e.g. `ABCA-42`), then + * title/body via a Linear-identifier regex (e.g. `ENG-42`), then * looked up across all `status='active'` workspaces in the registry * via Linear's `issueVcsBranchSearch` GraphQL. */ readonly linearWorkspaceRegistryTable?: dynamodb.ITable; + /** + * Optional — when provided, the processor persists the captured + * screenshot's public URL onto the deploy task's TaskRecord (keyed by the + * taskId in the deploy branch), so the orchestration reconciler can + * embed the integration node's combined preview in the parent epic panel. + * Unset → persistence is skipped (the PR + Linear comments still post). + */ + readonly taskTable?: dynamodb.ITable; + /** * Removal policy for the dedup table + screenshot bucket. Defaults * to DESTROY so dev stacks don't accumulate orphans on `cdk destroy`. @@ -192,6 +201,9 @@ export class GitHubScreenshotIntegration extends Construct { ...(props.linearWorkspaceRegistryTable && { LINEAR_WORKSPACE_REGISTRY_TABLE_NAME: props.linearWorkspaceRegistryTable.tableName, }), + ...(props.taskTable && { + TASK_TABLE_NAME: props.taskTable.tableName, + }), }, bundling: commonBundling, }); @@ -247,6 +259,49 @@ export class GitHubScreenshotIntegration extends Construct { })); } + // Write access so the processor can persist screenshot_url onto the + // deploy task's TaskRecord (conditional UpdateItem). grantWriteData covers + // the UpdateItem; the handler's update is guarded by attribute_exists. + if (props.taskTable) { + props.taskTable.grantWriteData(this.webhookProcessorFn); + // iteration-UX: on an iteration re-deploy the processor resolves the + // issue's most-recent maturing-reply id via a Query on LinearIssueIndex + // (to append the `· [preview]` link to that reply). grantWriteData does + // NOT include dynamodb:Query nor the index ARN, so grant it narrowly — + // Query on just that one GSI, not blanket grantReadData on the table. + // + // findIterationReplyId then GetItems each candidate's `head_sha` on the + // BASE table to attribute the deploy to the right iteration when several + // iterations overlap. That GetItem read needs dynamodb:GetItem on the + // base-table ARN — the Query GSI grant does NOT cover it. Without this, + // the GetItem throws AccessDenied, is swallowed non-fatally, and the + // preview is captured + posted to the PR but never appended to the Linear + // iteration reply (observed in practice — the head_sha refinement was + // added without extending its IAM grant). + this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['dynamodb:Query'], + resources: [ + Stack.of(this).formatArn({ + service: 'dynamodb', + resource: 'table', + resourceName: `${props.taskTable.tableName}/index/LinearIssueIndex`, + arnFormat: ArnFormat.SLASH_RESOURCE_NAME, + }), + ], + })); + this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['dynamodb:GetItem'], + resources: [ + Stack.of(this).formatArn({ + service: 'dynamodb', + resource: 'table', + resourceName: props.taskTable.tableName, + arnFormat: ArnFormat.SLASH_RESOURCE_NAME, + }), + ], + })); + } + // AgentCore Browser session lifecycle + automation-stream connect. // Action set scoped to the three calls the handler actually makes; // resource is `*` because Browser sessions are ephemeral and the diff --git a/cdk/src/constructs/linear-project-mapping-table.ts b/cdk/src/constructs/linear-project-mapping-table.ts index 4a0d8b072..9825795ac 100644 --- a/cdk/src/constructs/linear-project-mapping-table.ts +++ b/cdk/src/constructs/linear-project-mapping-table.ts @@ -55,6 +55,9 @@ export interface LinearProjectMappingTableProps { * - label_filter — Linear issue label that triggers a task (default `bgagent`) * - status — 'active' | 'removed' * - onboarded_at, updated_at — ISO timestamps + * + * The table is schemaless apart from the partition key, so added fields are + * additive and read with defaults — an existing row needs no migration. */ export class LinearProjectMappingTable extends Construct { /** diff --git a/cdk/src/constructs/orchestration-table.ts b/cdk/src/constructs/orchestration-table.ts new file mode 100644 index 000000000..ba20a07ad --- /dev/null +++ b/cdk/src/constructs/orchestration-table.ts @@ -0,0 +1,143 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { Construct } from 'constructs'; + +/** + * Properties for OrchestrationTable construct. + */ +export interface OrchestrationTableProps { + /** + * Optional table name override. + * @default - auto-generated by CloudFormation + */ + readonly tableName?: string; + + /** + * Removal policy for the table. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to enable point-in-time recovery. + * @default true + */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * DynamoDB table holding the parent/sub-issue dependency graph (DAG) + * for sub-issue orchestration. + * + * One orchestration = one labeled Linear parent issue with sub-issues. + * Each child sub-issue is a row; the reconciler walks the rows to find + * children whose predecessors are all terminal-success and releases them + * via ``createTaskCore``. + * + * Schema: orchestration_id (PK), sub_issue_id (SK). + * + * Per-child row fields (written when the graph is discovered): + * - linear_sub_issue_id — the Linear sub-issue UUID this row tracks + * - child_task_id — the platform task_id created for this child (absent + * until the child is released by the reconciler) + * - depends_on — list of ``sub_issue_id``s that must reach + * terminal-success before this child may start + * - child_status — orchestration-local lifecycle marker (e.g. + * ``blocked`` | ``released`` | ``succeeded`` | ``failed`` | ``skipped``) + * - base_branch — the predecessor branch this child stacks on (ADR-001 + * stacked PRs); ``main`` for root children + * - parent_linear_issue_id, linear_workspace_id, repo — provenance + * + * GSI: + * - ChildTaskIndex (PK: child_task_id) — the reconciler receives a + * child terminal-state event keyed by ``task_id`` and must resolve + * which orchestration + child row it belongs to. Sparse: only rows + * whose child has been released carry ``child_task_id``. + * - ChildBranchIndex (PK: child_branch_name) — the re-stack path receives + * a GitHub ``pull_request`` event keyed by head branch and must resolve + * which orchestration child opened that branch, so it can re-stack the + * child's dependents when its branch changes. Sparse: only released + * children carry ``child_branch_name``. + * + * NOTE: this construct is introduced but not yet instantiated in any + * stack — graph discovery and the reconciler wire it in. Synth-only for + * now keeps this foundational change deploy-safe. + */ +export class OrchestrationTable extends Construct { + /** + * GSI name for resolving a child ``task_id`` back to its + * orchestration + sub-issue row. + * PK: child_task_id. Sparse — only released children are projected. + */ + public static readonly CHILD_TASK_INDEX = 'ChildTaskIndex'; + + /** + * GSI name for resolving a child's head branch back to its + * orchestration + sub-issue row (used by the re-stack path). + * PK: child_branch_name. Sparse — only released children are projected. + */ + public static readonly CHILD_BRANCH_INDEX = 'ChildBranchIndex'; + + /** + * The underlying DynamoDB table. Use this to grant access or read the table name. + */ + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: OrchestrationTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'orchestration_id', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'sub_issue_id', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + timeToLiveAttribute: 'ttl', + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + + // GSI: resolve a released child's task_id back to its orchestration row. + // Sparse — rows without child_task_id (not yet released) are not projected. + this.table.addGlobalSecondaryIndex({ + indexName: OrchestrationTable.CHILD_TASK_INDEX, + partitionKey: { name: 'child_task_id', type: dynamodb.AttributeType.STRING }, + projectionType: dynamodb.ProjectionType.ALL, + }); + + // GSI: resolve a released child's head branch back to its orchestration + // row, so the re-stack path can find it. Sparse — rows without + // child_branch_name (not yet released) are not projected. + this.table.addGlobalSecondaryIndex({ + indexName: OrchestrationTable.CHILD_BRANCH_INDEX, + partitionKey: { name: 'child_branch_name', type: dynamodb.AttributeType.STRING }, + projectionType: dynamodb.ProjectionType.ALL, + }); + } +} diff --git a/cdk/src/constructs/task-orchestrator.ts b/cdk/src/constructs/task-orchestrator.ts index a638b369f..c0f47a559 100644 --- a/cdk/src/constructs/task-orchestrator.ts +++ b/cdk/src/constructs/task-orchestrator.ts @@ -157,6 +157,21 @@ export interface TaskOrchestratorProps { readonly containerName: string; readonly taskRoleArn: string; readonly executionRoleArn: string; + /** + * The smaller read-only PLANNING task def (see + * docs/design/ECS_RIGHTSIZED_PLANNING.md). The ECS strategy selects it for + * read-only workflows so planning doesn't run on the larger build box. + * + * Required, like its siblings, so the all-or-nothing constraint stays visible + * at the type level: the strategy reads this ARN from an env var, and an + * ecsConfig that omitted it would compile but leave the planning def defined + * and permanently unreachable. + * + * Needs no extra IAM — it shares the build def's task and execution roles, + * and the `ecs:RunTask` grant below is scoped by `ecs:cluster` rather than by + * task-definition ARN, so it already covers every def in the cluster. + */ + readonly planningTaskDefinitionArn: string; }; /** @@ -272,6 +287,11 @@ export class TaskOrchestrator extends Construct { ECS_SUBNETS: props.ecsConfig.subnets, ECS_SECURITY_GROUP: props.ecsConfig.securityGroup, ECS_CONTAINER_NAME: props.ecsConfig.containerName, + // Read-only workflows route here instead of the build def. Without this + // var the strategy's `readOnly && ECS_PLANNING_TASK_DEFINITION_ARN` + // guard is always falsy, so the planning def would be synthesized and + // never used. + ECS_PLANNING_TASK_DEFINITION_ARN: props.ecsConfig.planningTaskDefinitionArn, }), // #502: bucket the orchestrator writes the ECS payload to (and deletes // from at finalize); the ECS strategy reads this to build the S3 URI. diff --git a/cdk/src/handlers/fanout-task-events.ts b/cdk/src/handlers/fanout-task-events.ts index bda4c35e5..9bf759f41 100644 --- a/cdk/src/handlers/fanout-task-events.ts +++ b/cdk/src/handlers/fanout-task-events.ts @@ -961,7 +961,7 @@ export function renderLinearFinalStatusComment(args: { // on ⚠️, on the assumption that "on ✅ the agent's own step-2 'PR opened' comment // reliably carries the link, so duplicating it is noise." That assumption FAILS // when the agent skips its PR-opened comment — live-caught on ABCA-584, where a - // decompose→single task opened PR #395 (pr_url on the record) but posted no + // A task opened a PR (pr_url on the record) but posted no // PR-opened comment, so the ✅ completion comment omitted it and the link was // LOST entirely. The completion comment is the terminal, platform-owned surface; // rendering pr_url here guarantees the link is never lost, and a duplicate with @@ -1172,7 +1172,7 @@ async function dispatchToLinear(event: FanOutEvent): Promise { * * The PR URL is rendered on the ✅ success path too — not just the ⚠️ path — * because the agent's own "PR opened" comment is not guaranteed to have fired - * (a decompose→single task, or an agent that skipped that step), so the + * (an agent that skipped that step), so the * platform comment must always carry the link or it can be lost entirely * (ABCA-584). The same fix lands for Linear in #601. * diff --git a/cdk/src/handlers/orchestrate-task.ts b/cdk/src/handlers/orchestrate-task.ts index d19142827..56adafa5b 100644 --- a/cdk/src/handlers/orchestrate-task.ts +++ b/cdk/src/handlers/orchestrate-task.ts @@ -61,10 +61,10 @@ const durableHandler: DurableExecutionHandler = asyn return loadTask(taskId); }); - // Correlation envelope (#245): stamp {task_id, user_id, repo} on every - // lifecycle log line so admission→terminal transitions join to TaskEvents - // and the agent trace without hand-adding fields per call. `repo` is - // undefined for repo-less workflows (#248 Phase 3). + // Correlation envelope: stamp {task_id, user_id, repo} on every lifecycle log + // line so admission→terminal transitions join to TaskEvents and the agent + // trace without hand-adding fields per call. `repo` is undefined for repo-less + // workflows (those with no target repository). const { log, correlation } = envelopeFor(task); // Step 1b: Load blueprint config (per-repo overrides) @@ -169,13 +169,16 @@ const durableHandler: DurableExecutionHandler = asyn userId: task.user_id, payload, blueprintConfig, + // A read-only workflow (e.g. planning that only clones and reads the + // repo) runs on the smaller ECS planning task def. Ignored by AgentCore. + readOnly: workflowIsReadOnly(task.resolved_workflow?.id ?? 'coding/new-task-v1'), }; // Transient-error AUTO-RETRY (once), extracted to startSessionWithRetry so - // the four branches are unit-tested (#599 B2). The retry-event emit is - // best-effort and guarded there (#599 B1): a TaskEvents PutItem fault can't - // abort or mis-attribute the retry. The correlation envelope is threaded so - // the session_start_retry event carries the same trace context as - // session_started below (#245). + // its branches are unit-tested. The retry-event emit is best-effort and + // guarded there: a TaskEvents PutItem fault can't abort or mis-attribute + // the retry. The correlation envelope is threaded so the + // session_start_retry event carries the same trace context as + // session_started below. const { handle, autoRetried: retried } = await startSessionWithRetry( strategy, startInput, @@ -213,16 +216,14 @@ const durableHandler: DurableExecutionHandler = asyn return handle; } catch (err) { // Carry the auto-retry fact into error_message: the `[auto-retried]` suffix - // is persisted verbatim (the classifier ignores it — it does not affect - // classification). It is a breadcrumb for a FORTHCOMING failure renderer to - // detect and surface "I already tried again" to the channel — no consumer - // renders it yet on this branch (retryGuidance() in error-classifier.ts is - // the intended copy source; it ships ahead of its consumer). + // is persisted verbatim (the error classifier ignores it — it does not + // affect classification). It is a breadcrumb for a failure renderer to + // detect and surface "I already tried again" to the channel. // - // Stamp on BOTH paths a retry ran (#599 N1): branch 3 sets `autoRetried` - // above then a LATER step throws; branch 4 (transient-then-transient) throws - // FROM startSessionWithRetry before `autoRetried` is assigned, so the local - // is still false — read the fact off the thrown error via isAutoRetried(). + // Stamp on BOTH paths where a retry ran: one path sets `autoRetried` above + // then a LATER step throws; the transient-then-transient path throws FROM + // startSessionWithRetry before `autoRetried` is assigned, so the local is + // still false — read the fact off the thrown error via isAutoRetried(). // Without this, a double-transient failure was told "reply to retry" instead // of "I already retried" — the exact confusion the marker exists to prevent. const retriedNote = (autoRetried || isAutoRetried(err)) ? ' [auto-retried]' : ''; @@ -330,11 +331,11 @@ const durableHandler: DurableExecutionHandler = asyn // Step 6: Finalize — update terminal status, emit events, release concurrency await context.step('finalize', async () => { await finalizeTask(taskId, finalPollState, task.user_id); - // #502: the task is terminal — the container has long since read its - // payload, so delete the ephemeral S3 payload object now. Best-effort - // (deleteEcsPayload swallows errors) and a no-op for AgentCore tasks / - // deployments without a payload bucket; the bucket's 1-day lifecycle rule - // is the backstop if this delete or the whole step never runs. + // The task is terminal — the container has long since read its payload, so + // delete the ephemeral S3 payload object now. Best-effort (deleteEcsPayload + // swallows errors) and a no-op for AgentCore tasks / deployments without a + // payload bucket; the bucket's 1-day lifecycle rule is the backstop if this + // delete or the whole step never runs. if (blueprintConfig.compute_type === 'ecs') { await deleteEcsPayload(taskId); } diff --git a/cdk/src/handlers/shared/attachment-screening.ts b/cdk/src/handlers/shared/attachment-screening.ts index f8e38cb66..c31dba7af 100644 --- a/cdk/src/handlers/shared/attachment-screening.ts +++ b/cdk/src/handlers/shared/attachment-screening.ts @@ -248,14 +248,42 @@ const PDF_MAX_PAGES = 50; const PDF_MAX_TEXT_BYTES = 1024 * 1024; // 1 MB extracted text cap const PDF_EXTRACT_TIMEOUT_MS = 15_000; -async function extractPdfText(content: Buffer, filename: string): Promise { - // Dynamic import — pdf-parse is only used for PDF attachments. +/** + * pdf-parse v2 is built on pdfjs, which references browser DOM globals + * (`DOMMatrix`/`ImageData`/`Path2D`) that don't exist in the Node Lambda runtime. + * For TEXT extraction (our only use) these are never actually invoked — pdfjs only + * touches them on its optional canvas RENDER path. But if they're merely *undefined*, + * pdfjs tries to load the native `@napi-rs/canvas` binding to supply them, which + * fails on Lambda (the cross-platform native binary isn't bundled) and cascades to + * `DOMMatrix is not defined` → PDF screening unavailable (observed in practice). + * + * Defining them as inert no-op stubs makes pdfjs skip the native-canvas load path + * entirely and extract text headless — no native binary, host-independent. Verified: + * `getText` returns the full text with canvas absent + these three stubs present. + * Idempotent + non-clobbering (only fills genuinely-missing globals). + */ +function ensurePdfDomGlobals(): void { + const g = globalThis as Record; + if (typeof g.DOMMatrix === 'undefined') g.DOMMatrix = class { /* inert stub — text extraction never calls it */ }; + if (typeof g.ImageData === 'undefined') g.ImageData = class { /* inert stub */ }; + if (typeof g.Path2D === 'undefined') g.Path2D = class { /* inert stub */ }; +} - let pdfParseFn: (data: Buffer, options?: { max?: number }) => Promise<{ text: string }>; +async function extractPdfText(content: Buffer, filename: string): Promise { + // pdf-parse v2 (^2.4.5) exposes a `PDFParse` CLASS — `new PDFParse({ data }).getText()` — + // NOT the v1 callable default export. Three things made this fail before: + // (1) the code called the v1 `pdfParseFn(buf)` shape (undefined on v2); (2) the + // webhook processors esbuild-bundled pdf-parse instead of shipping it via `nodeModules`, + // mangling its pdfjs/native deps; and (3) pdfjs tried to load the native + // `@napi-rs/canvas` binding for its DOM globals — absent on Lambda — instead of just + // extracting text. `ensurePdfDomGlobals` fixes (3); the bundling change fixes (2). + ensurePdfDomGlobals(); + let PDFParse; try { - // pdf-parse uses a default export; handle both CJS and ESM module shapes. - const mod = await import(/* webpackIgnore: true */ 'pdf-parse'); - pdfParseFn = (mod as any).default ?? mod; + // Destructure the class from the dynamic import and let TS infer its type from + // the value — a cross-mode `typeof import('pdf-parse').PDFParse` annotation trips + // the ESM-vs-CJS dual-`.d.ts` hazard under moduleResolution:nodenext. + ({ PDFParse } = await import(/* webpackIgnore: true */ 'pdf-parse')); } catch (importErr) { logger.error('pdf-parse module could not be imported — PDF screening unavailable', { error: importErr instanceof Error ? importErr.message : String(importErr), @@ -268,22 +296,73 @@ async function extractPdfText(content: Buffer, filename: string): Promise; + // Hand pdf-parse a COPY, not a view. It transfers ownership of whatever it is + // given to its worker, which DETACHES the underlying ArrayBuffer — and a + // `new Uint8Array(content.buffer, …)` view shares that buffer with the caller's + // Buffer. The caller stores the original bytes in S3 after screening returns, so + // sharing meant the upload read a detached buffer and threw "Cannot perform + // Construct on a detached ArrayBuffer". Fail-closed screening then rejected the + // task: every PDF attachment was refused with "could not be stored". + // + // The copy costs one extra allocation of an already size-capped payload, which is + // the right trade against silently breaking every PDF upload. + const parser = new PDFParse({ data: Uint8Array.from(content) }); try { const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error('PDF extraction timed out')), PDF_EXTRACT_TIMEOUT_MS); }); + // `first: N` parses only pages 1..N (the v2 page-cap knob). We cap pages + + // extracted-text bytes to bound cost/DoS — BUT the caller stores the WHOLE PDF + // and feeds it to the agent, so screening only a prefix while delivering the + // rest is a bypass (review #1 HIGH: injection on page 51 of a 51-page PDF). + // Fail CLOSED when the document exceeds what we can screen: reject rather than + // deliver unscreened pages. `result.total` is the PDF's full page count. const result = await Promise.race([ - pdfParseFn(content, { max: PDF_MAX_PAGES }), + parser.getText({ first: PDF_MAX_PAGES }), timeoutPromise, ]); - let text: string = result.text ?? ''; + const totalPages = typeof result.total === 'number' ? result.total : undefined; + if (totalPages !== undefined && totalPages > PDF_MAX_PAGES) { + throw new AttachmentScreeningError( + `PDF "${filename}" has ${totalPages} pages, over the ${PDF_MAX_PAGES}-page limit ABCA can fully ` + + 'screen. Split it or attach only the relevant pages so the whole document can be checked.', + ); + } + const text: string = result.text ?? ''; if (Buffer.byteLength(text, 'utf-8') > PDF_MAX_TEXT_BYTES) { - text = text.slice(0, PDF_MAX_TEXT_BYTES); + // The screened pages produced more text than we screen — we'd be delivering + // bytes we didn't fully check. Fail closed rather than truncate-and-pass. + throw new AttachmentScreeningError( + `PDF "${filename}" contains more text than ABCA can fully screen (over ${PDF_MAX_TEXT_BYTES} bytes). ` + + 'Attach a smaller document so its full contents can be checked.', + ); } return text; } catch (err) { + // Our own over-limit / no-text rejections are already user-facing — don't + // re-wrap them as "corrupt PDF". + if (err instanceof AttachmentScreeningError) throw err; + // A DEPLOYMENT bug and a genuinely-bad PDF both land here, and they look + // nothing alike to an operator. pdf-parse mangled by esbuild (a Lambda that + // reaches this path but lacks `nodeModules: ['pdf-parse']`) throws with a + // pdfjs/DOM signature — `DOMMatrix is not defined`, `Cannot find native + // binding`, `@napi-rs/canvas`. Detect that and log a LOUD, actionable + // diagnostic (with the fix) so it's not misread as "user's PDF is corrupt" — + // that misdiagnosis has cost a full debug loop before. The user-facing + // message stays generic. + const msg = err instanceof Error ? err.message : String(err); + const looksLikeBundlingBug = /DOMMatrix|ImageData|Path2D|napi-rs\/canvas|Cannot find native binding|pdfjs/i.test(msg); + if (looksLikeBundlingBug) { + logger.error( + 'PDF extraction hit a pdfjs/native-binding error — this is almost certainly a BUNDLING bug, ' + + 'not a bad PDF: the Lambda screens PDFs but was esbuild-bundled without `nodeModules: [\'pdf-parse\']`. ' + + 'Add the attachment-screening bundling carve-out to this function\'s construct (see the ' + + '//:check:pdf-parse-bundling guard).', + { error: msg, filename, metric_type: 'pdf_parse_bundling_error' }, + ); + } throw new AttachmentScreeningError( `PDF "${filename}" could not be processed. It may be corrupt or use unsupported features. ` + 'Try exporting to a simpler PDF format.', @@ -291,6 +370,8 @@ async function extractPdfText(content: Buffer, filename: string): Promise { /* best-effort teardown */ }); } } diff --git a/cdk/src/handlers/shared/compute-strategy.ts b/cdk/src/handlers/shared/compute-strategy.ts index c3de58869..48feb838d 100644 --- a/cdk/src/handlers/shared/compute-strategy.ts +++ b/cdk/src/handlers/shared/compute-strategy.ts @@ -48,6 +48,15 @@ export interface ComputeStrategy { userId: string; payload: Record; blueprintConfig: BlueprintConfig; + /** + * #299 ECS_RIGHTSIZED_PLANNING: true for a read-only workflow (e.g. + * coding/pr-review-v1) that clones + reads but never + * builds. The ECS strategy uses it to pick the smaller planning task def + * instead of the larger build def. AgentCore ignores it (its microVM is a + * single fixed size). Optional so callers/tests that omit it default to the + * build def (never worse than today). + */ + readOnly?: boolean; }): Promise; pollSession(handle: SessionHandle): Promise; stopSession(handle: SessionHandle): Promise; diff --git a/cdk/src/handlers/shared/error-classifier.ts b/cdk/src/handlers/shared/error-classifier.ts index f68e15f46..8364a5b69 100644 --- a/cdk/src/handlers/shared/error-classifier.ts +++ b/cdk/src/handlers/shared/error-classifier.ts @@ -29,7 +29,7 @@ export const ErrorCategory = { GUARDRAIL: 'guardrail', CONFIG: 'config', TIMEOUT: 'timeout', - // Environmental blocker (#251): agent could not progress for a missing + // Environmental blocker: agent could not progress for a missing // secret, egress denial, unreachable dependency, or fail-closed policy // engine error. Distinct from AUTH/CONFIG so operators can spot the // typed, self-diagnosed faults the platform names precisely. @@ -175,10 +175,10 @@ const PATTERNS: readonly ErrorPattern[] = [ // --- Compute --- { // A task dispatched against a task-def revision that was deregistered by a - // deploy (ABCA-660/663). Transient + self-clearing on retry; the family-based - // RunTask fix prevents it going forward, but keep a precise classification so - // any historical/edge occurrence reads as "temporary, just retry", not a - // scary compute-health alarm. + // concurrent deploy. Transient + self-clearing on retry; dispatching against + // the task-definition FAMILY (rather than a pinned revision) prevents it going + // forward, but keep a precise classification so any historical/edge occurrence + // reads as "temporary, just retry", not a scary compute-health alarm. pattern: /TaskDefinition is inactive/i, classification: { category: ErrorCategory.COMPUTE, @@ -260,8 +260,8 @@ const PATTERNS: readonly ErrorPattern[] = [ // refused the binary (`OSError: [Errno 8] Exec format error: 'claude'`) or // the claude-code shim reports its platform-native binary was never placed // ("claude native binary not installed" — its postinstall silently fell - // back at image-build time). Live-caught on ABCA-659's retry: all 3 ECS runs - // died at the run_agent step this way on a freshly rebuilt image, while the + // back at image-build time). Observed in practice: three consecutive ECS runs + // all died at the run_agent step this way on a freshly rebuilt image, while the // native binary was present but unwired. This is an IMAGE/infra fault, NOT a // problem with the user's request — a fresh attempt usually lands on a host // that materializes the image cleanly; a persistent one is a bad build an @@ -303,7 +303,7 @@ const PATTERNS: readonly ErrorPattern[] = [ // ``agent/src/runner.py:515`` (the terminal-error path). // Keying on only ``agent_status=`` missed the ``subtype=`` wrapper, so a // real max-turns failure fell through to UNKNOWN → "Unexpected error" - // (live-caught on ABCA-483: a task hit the 100-turn cap but the reply + // (observed in practice: a task hit the 100-turn cap but the reply // said "Unexpected error"). Match either ``agent_status=``/``subtype=``. { // A max-turns cap is a correct, self-explanatory classification. When the @@ -347,6 +347,57 @@ const PATTERNS: readonly ErrorPattern[] = [ errorClass: ErrorClass.TRANSIENT, }, }, + { + // The build gate was KILLED by an environment fault (out of + // disk / OOM) — the code was never verified. The agent tags the verdict + // ``build_ok=infra`` so this reads as a retryable INFRA fault, not "your + // build failed" and not a bogus ✅ (a build also killed before the agent + // would otherwise look "already red → not a regression → success"). Matched + // before the generic ``Task did not succeed.*agent_status=`` catch-all. + pattern: /Task did not succeed.*build_ok=infra/i, + classification: { + category: ErrorCategory.COMPUTE, + title: 'Build couldn\'t finish — the build machine ran out of resources', + description: 'The build/verify step was stopped because the build environment ran out of disk or memory, so your changes were never actually verified — this is an infrastructure limit, not a problem with your code.', + remedy: 'Reply here to try again — a fresh run usually clears a transient resource crunch (e.g. several builds sharing a box at once). If it keeps happening on this repo, its build needs more capacity: contact your ABCA admin to raise the build task\'s disk/memory.', + retryable: true, + errorClass: ErrorClass.TRANSIENT, + }, + }, + { + // A new-work coding task reported agent-success but no commit + // reached the branch and no PR was opened — the agent's changes were LOST + // (observed cause: a stacked child edited a nested working tree that was never + // the branch's tree, so nothing committed). This is an environment/infra + // fault, not the user's code, and a fresh run usually lands the work — so + // it reads as retryable, NOT the non-retryable "your task didn't succeed". + // Ordered before the generic ``agent_status=`` catch-all so it wins. + pattern: /deliverable=lost/i, + classification: { + category: ErrorCategory.AGENT, + title: 'The change was not saved', + description: 'The agent finished, but none of its changes were committed to the branch and no pull request was opened — the work did not land in the repository. This is usually a transient workspace fault (e.g. the clone ended up in an unexpected directory), not a problem with your request.', + remedy: 'Reply here to try again — a fresh run normally saves the work correctly. If it keeps happening on this repo, share the task ID with your ABCA admin to check the agent workspace setup.', + retryable: true, + errorClass: ErrorClass.TRANSIENT, + }, + }, + { + // The sibling of the work-lost case: a commit DID land on the branch but + // the PR never opened (e.g. ``gh pr create`` failed after the push). The + // code is safe on the branch; the only missing step is opening the PR — so + // a retry (or an operator opening it by hand) recovers it. Distinct copy + // from the work-lost case so the reader knows the change is NOT gone. + pattern: /deliverable=no_pr/i, + classification: { + category: ErrorCategory.AGENT, + title: 'Change saved, but the pull request did not open', + description: 'The agent committed its changes to the branch, but the pull request could not be created (the push succeeded; opening the PR did not). Your work is safe on the branch.', + remedy: 'Reply here to try again — it will find the existing commit and open the PR. If it persists, an admin can open a PR from the task branch manually, or check the GitHub token\'s Pull requests (Read and write) scope.', + retryable: true, + errorClass: ErrorClass.TRANSIENT, + }, + }, { pattern: /Task did not succeed.*agent_status=/i, classification: { @@ -473,8 +524,8 @@ const PATTERNS: readonly ErrorPattern[] = [ ]; /** - * Canonical blocker-reason contract (#251, decision D). Matches - * ``BLOCKED[]`` and, separately, the optional `` (resource: )`` + * Canonical blocker-reason contract. Matches ``BLOCKED[]`` and, + * separately, the optional `` (resource: )`` * segment ``format_blocker_reason`` appends. This is the SINGLE source of truth * on the CDK side and must stay in lockstep with ``format_blocker_reason`` in * ``agent/src/progress_writer.py`` and the taxonomy table in @@ -500,8 +551,8 @@ export type BlockerKind = | 'unknown_environmental'; /** - * Build the canonical terminal-reason string for an orchestration-side blocker - * (#251, decision D) — the TypeScript twin of ``format_blocker_reason`` in + * Build the canonical terminal-reason string for an orchestration-side blocker — + * the TypeScript twin of ``format_blocker_reason`` in * ``agent/src/progress_writer.py``. Produces ``BLOCKED[]: `` with * `` (resource: )`` appended when ``resource`` is set, so the same * ``classifyError`` regex that handles agent-carried reasons also classifies @@ -566,7 +617,7 @@ function blockerClassification(kind: string, resource: string | undefined): Erro case 'auth_failure': { // A Secrets Manager ARN resource means the secret exists but couldn't be // read (AccessDenied / throttling) — the fix is IAM on the task role or - // blueprint wiring, NOT PAT scopes (#251 review). A non-ARN resource is a + // blueprint wiring, NOT PAT scopes. A non-ARN resource is a // runtime credential rejection where scope/validity is the right advice. const isSecretArn = res?.startsWith('arn:aws:secretsmanager:') ?? false; return { @@ -617,7 +668,7 @@ export function classifyError(errorMessage: string | undefined | null): ErrorCla return null; } - // Environmental blockers (#251) carry a canonical ``BLOCKED[]`` prefix + // Environmental blockers carry a canonical ``BLOCKED[]`` prefix // and an extractable resource — check them first so the remedy can name the // exact secret / host rather than falling through to a generic pattern. const kindMatch = BLOCKED_KIND.exec(errorMessage); diff --git a/cdk/src/handlers/shared/iteration-reply.ts b/cdk/src/handlers/shared/iteration-reply.ts new file mode 100644 index 000000000..96c9bebcc --- /dev/null +++ b/cdk/src/handlers/shared/iteration-reply.ts @@ -0,0 +1,343 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Pure renderer for the success reply to an ``@bgagent`` comment-iteration. + * Its job is to tell an EDIT apart from a QUESTION so the reply doesn't claim an + * update that never happened. + * + * Background: every ``@bgagent`` comment on a PR-bearing issue spawns a + * ``coding/pr-iteration-v1`` task. When that task completes + builds, the + * platform replied "✅ Updated — PR #N" UNCONDITIONALLY — even when the comment + * was a QUESTION ("where is the login page?") and the agent made no commit. That + * read as a false success: a "✅ Updated" with nothing updated and the question + * unanswered. + * + * The agent now persists ``code_changed`` (did the branch HEAD advance?) and, + * on a no-change run, ``answer_text`` (its reply to the question). This renderer + * branches on that: + * - code changed (or unknown — back-compat) → "✅ Updated — PR #N." (as before) + * - no code changed → "💬 " (no false ✅) + * + * Pure + no I/O so both settle paths (the standalone fanout reply and the + * orchestration reconciler) render identically and it is unit-testable. + */ + +/** + * Max chars of the agent's answer surfaced inline before truncation. Matches the + * agent's own persist cap (``task_state.py`` stores ``answer_text[:2000]``) so the + * renderer never silently drops chars the agent already bounded — the agent is the + * single truncator. (``failureReason`` shares this cap for a long sanitized error.) + */ +const MAX_ANSWER_CHARS = 2000; + +/** + * Below this elapsed floor the ``working`` reply shows no "(N elapsed)" + * clause — a freshly-acked task reads as a clean "🔄 Working…" and only grows + * the liveness suffix once a run is genuinely long enough to look silent. + */ +const HEARTBEAT_ELAPSED_FLOOR_S = 90; + +/** Max chars of the agent's latest-progress hint shown on the working line. */ +const PROGRESS_NOTE_MAX = 80; + +/** + * Liveness suffix for the ``working`` state: a short italic line carrying + * elapsed time (+ an optional sanitized progress note) so a long-running task + * isn't a silent black box. Without it a slow run showed nothing at all between + * the 👀 ack and the final ❌ — 22 minutes of silence in one observed run. + * Returns '' for a just-started task (< {@link HEARTBEAT_ELAPSED_FLOOR_S}) + * so the first ack stays clean. Pure. + */ +function workingLivenessSuffix( + elapsedS: number | null | undefined, + progressNote: string | undefined, +): string { + const e = dur(elapsedS); + // Only show the clause once the run is long enough to read as "is it alive?". + const showElapsed = + typeof elapsedS === 'number' && Number.isFinite(elapsedS) && elapsedS >= HEARTBEAT_ELAPSED_FLOOR_S; + const note = (progressNote ?? '').replace(/\s+/g, ' ').trim(); + const parts: string[] = []; + if (showElapsed && e) parts.push(`${e} elapsed`); + if (note) parts.push(truncate(note, PROGRESS_NOTE_MAX)); + return parts.length ? `_${parts.join(' · ')}_` : ''; +} + +/** + * The maturing iteration reply. One threaded reply per + * ``@bgagent`` comment that EDITS IN PLACE through these states instead of + * posting ~5 separate top-level comments per round. Same idea as the live status + * panel on a parent epic. + * + * - ``on_it`` — posted synchronously at trigger time (kills the silence). + * - ``working`` — the agent opened/updated the PR (pr_created milestone). + * - ``updated`` — terminal success WITH a commit → the ✅ + cost + total. + * - ``answered`` — terminal success, NO commit (a question) → 💬 + the answer. + * - ``failed`` — terminal failure. + */ +export type IterationState = 'on_it' | 'working' | 'updated' | 'answered' | 'failed'; + +export interface MaturingReplyInput { + readonly state: IterationState; + readonly prNumber?: number | null; + /** Full PR URL — makes the "PR #N" reference a clickable link when present. */ + readonly prUrl?: string | null; + /** Agent's answer (answered state). */ + readonly answerText?: string; + /** This iteration's cost (USD) — shown on terminal states. */ + readonly costUsd?: number | null; + /** Wall-clock seconds for this iteration — shown on terminal states. */ + readonly durationS?: number | null; + /** Cumulative cost across ALL iterations on this PR/issue (incl. this one). */ + readonly runningTotalUsd?: number | null; + /** + * Captured deploy-preview screenshot PNG (our CloudFront URL). Embedded as a + * clickable image thumbnail in the reply when present, NOT a standalone comment. + */ + readonly screenshotUrl?: string | null; + /** + * Live deploy URL (the Vercel/preview site). When present, the embedded + * screenshot links to it ({@link renderPreviewBlock}). MUST be markdown-escaped + * by the caller (payload-derived). + */ + readonly deployUrl?: string | null; + /** Sanitized failure reason (failed state). */ + readonly failureReason?: string; + /** + * Liveness heartbeat: seconds elapsed since the task started, shown on the + * ``working`` state so a long run isn't a silent black box ("🔄 Working — 8m + * elapsed…"). Only rendered when > a small floor (a just-started task shows + * the plain "Working…" line). Distinct from ``durationS`` (a TERMINAL total). + */ + readonly elapsedS?: number | null; + /** + * Short, sanitized latest-progress hint from the agent's most recent + * milestone (e.g. "running build verification"). Optional; appended to the + * working line when present. Caller MUST pre-sanitize (it's agent-derived). + */ + readonly progressNote?: string; +} + +/** + * The deploy-preview block folded into a maturing reply: the captured screenshot + * PNG embedded as an image, made CLICKABLE to the live deploy when the deploy URL + * is known (the user picked the clickable-thumbnail UX over a bare text link). + * - both urls → ``[![preview](screenshot.png)](deploy)`` (image links to deploy) + * - screenshot only → ``![preview](screenshot.png)`` (plain embed, no link target) + * - no screenshot → '' (nothing to show) + * ``screenshotUrl`` is our own CloudFront key (no parens) so it's safe as-is; + * ``deployUrl`` is payload-derived, so callers MUST pass it already + * markdown-escaped (see ``encodeMarkdownUrl``) to avoid a link-breakout. Pure. + */ +export function renderPreviewBlock( + screenshotUrl: string | null | undefined, + deployUrl?: string | null, +): string { + if (!screenshotUrl) return ''; + return deployUrl + ? `[![preview](${screenshotUrl})](${deployUrl})` + : `![preview](${screenshotUrl})`; +} + +/** Format a USD cost as "$X.XX", or "" when unknown. */ +function usd(n: number | null | undefined): string { + return typeof n === 'number' && Number.isFinite(n) ? `$${n.toFixed(2)}` : ''; +} + +/** Compact "Ns"/"Nm Ns" duration, or "" when unknown. */ +function dur(s: number | null | undefined): string { + if (typeof s !== 'number' || !Number.isFinite(s) || s < 0) return ''; + if (s < 60) return `${Math.round(s)}s`; + const m = Math.floor(s / 60); + const rem = Math.round(s % 60); + return rem ? `${m}m ${rem}s` : `${m}m`; +} + +/** + * Render the maturing iteration reply for a given {@link IterationState}. Pure. + * The metadata line (cost · duration · running total) appears only on terminal + * states and only for the fields that are known. The screenshot is a link, not + * an embed, so the reply stays compact across many rounds. + */ +export function renderMaturingReply(input: MaturingReplyInput): string { + const meta = terminalMetaLine(input); + // Clickable image thumbnail (screenshot PNG → live deploy), on its own block. + const previewBlock = renderPreviewBlock(input.screenshotUrl, input.deployUrl); + + const prRef = prReference(input.prNumber, input.prUrl); + switch (input.state) { + case 'on_it': + return '👀 On it — reading the PR…'; + case 'working': { + // Liveness: base "Working" line + an optional "(Nm elapsed[ · note])" + // suffix so a long run shows it's alive, not stuck. The elapsed clause is + // omitted for a freshly-started task (< HEARTBEAT_ELAPSED_FLOOR_S) so the + // first ack reads clean. + const base = prRef ? `🔄 Working — updating ${prRef}…` : '🔄 Working…'; + const live = workingLivenessSuffix(input.elapsedS, input.progressNote); + return live ? `${base}\n${live}` : base; + } + case 'updated': { + const head = prRef ? `✅ Updated — ${prRef}.` : '✅ Updated.'; + // headline + metadata, then the embedded preview thumbnail on its own line. + const lines = [meta ? `${head}\n${meta}` : head]; + if (previewBlock) lines.push(previewBlock); + return lines.join('\n\n'); + } + case 'answered': { + const answer = (input.answerText ?? '').trim(); + const head = answer + ? `💬 ${truncate(answer, MAX_ANSWER_CHARS)}` + : '💬 No code change was needed — nothing to update on this PR.'; + return meta ? `${head}\n${meta}` : head; + } + case 'failed': { + const reason = (input.failureReason ?? '').trim(); + const head = reason ? `❌ ${truncate(reason, MAX_ANSWER_CHARS)}` : '❌ The iteration failed.'; + return meta ? `${head}\n${meta}` : head; + } + } +} + +/** A clickable "[PR #N](url)" when the url is known, else plain "PR #N", else "". */ +function prReference(prNumber: number | null | undefined, prUrl: string | null | undefined): string { + if (prNumber == null) return ''; + return prUrl ? `[PR #${prNumber}](${prUrl})` : `PR #${prNumber}`; +} + +/** "cost: $X · 2m 3s · total this PR: $Y" — only the known parts. */ +function terminalMetaLine(input: MaturingReplyInput): string { + const parts: string[] = []; + const c = usd(input.costUsd); + if (c) parts.push(c); + const d = dur(input.durationS); + if (d) parts.push(d); + const t = usd(input.runningTotalUsd); + if (t) parts.push(`total this PR: ${t}`); + return parts.length ? `_${parts.join(' · ')}_` : ''; +} + +export interface IterationReplyInput { + /** + * Did the iteration advance the PR branch (a real commit landed)? + * - ``true`` → a normal edit; render the "✅ Updated" success. + * - ``false`` → a no-op iteration (a question / nothing to change). + * - ``undefined`` → unknown (pre-fix task, non-PR workflow, or the agent + * couldn't read the baseline). Treated as ``true`` so + * behaviour is unchanged for anything that doesn't opt in. + */ + readonly codeChanged?: boolean; + /** The PR number, when resolvable (only used on the changed path). */ + readonly prNumber?: number | null; + /** The agent's final answer text, surfaced on the no-change path. */ + readonly answerText?: string; +} + +/** + * Render the reply for a SUCCESSFUL (completed + build-passing) iteration. + * (Failures are rendered by a separate failure-reply renderer that arrives with + * the activation slice — this is only the success branch, which is where the + * false-"✅ Updated" lived.) + */ +export function renderIterationSuccessReply(input: IterationReplyInput): string { + const noChange = input.codeChanged === false; + + if (noChange) { + const answer = (input.answerText ?? '').trim(); + if (answer) { + return `💬 ${truncate(answer, MAX_ANSWER_CHARS)}`; + } + // No commit AND no captured answer — be honest that nothing changed rather + // than claim an update. (Rare: the agent settled without a result text.) + return '💬 No code change was needed — nothing to update on this PR.'; + } + + // Changed (or unknown → back-compat): the existing success ack. + return typeof input.prNumber === 'number' + ? `✅ Updated — PR #${input.prNumber}.` + : '✅ Updated.'; +} + +/** True when this is a no-change iteration (drives the 👀→💬 reaction choice). */ +export function isNoChangeIteration(codeChanged?: boolean): boolean { + return codeChanged === false; +} + +/** + * Matches a preview block folded onto a matured reply, in either shape + * {@link renderPreviewBlock} emits: a clickable thumbnail + * ``[![preview](png)](deploy)`` or a plain embed ``![preview](png)``. Captures + * the whole block so convergence re-attaches it verbatim (image + deploy link). + */ +const PREVIEW_BLOCK_RE = /\[?!\[preview\]\([^)\s]+\)(?:\]\([^)\s]+\))?/; + +/** + * Converge two independent writers of the same reply body: the deploy-preview + * block and the terminal-settle are written by two INDEPENDENT async paths (the + * screenshot webhook appends the ``![preview]`` block; the fanout/reconciler + * terminal-settle re-renders the whole reply body) with no ordering guarantee. + * Whichever runs last wins, so a terminal re-render would silently drop a preview + * the webhook already appended — observed live, with the preview wiped ~14 seconds + * after it landed. This makes the edit path CONVERGE rather than + * overwrite: if ``currentBody`` already carries a ``[preview]`` block and the + * freshly-rendered ``newBody`` does not, carry that exact block onto the new body + * on its own line. Pure; idempotent (a no-op when newBody already has its own + * preview or currentBody has none). + */ +export function preservePreviewSuffix(newBody: string, currentBody: string | null | undefined): string { + if (typeof currentBody !== 'string') return newBody; + if (newBody.includes('[preview]')) return newBody; // new render already carries one + const block = currentBody.match(PREVIEW_BLOCK_RE)?.[0]; + if (!block) return newBody; + return `${newBody}\n\n${block}`; +} + +/** + * Lead markers of a TERMINAL maturing-reply render — the states that mean "this + * iteration is finished": ✅ updated, 💬 answered, ❌ failed. Progress states + * (👀 on_it, 🔄 working) are deliberately absent. + * + * Kept beside {@link renderMaturingReply} so the two stay in step: if a terminal + * state's lead marker changes there, it must change here. + */ +const TERMINAL_REPLY_MARKERS: readonly string[] = ['✅', '💬', '❌']; + +/** + * Does this reply body already show a terminal outcome? + * + * Used to stop a LATE progress edit from overwriting a settled reply. Two writers + * edit the same comment — the terminal settle and the (stream-driven, so possibly + * delayed) progress milestone — and the body is the ground truth about which has + * landed, unlike a task-record marker, which is written slightly before the render + * it announces. Checking the body therefore closes the window between the two. + * + * Conservative by construction: only a known terminal lead marker counts, so an + * unrecognised body is treated as NOT settled and progress still renders (a + * missed progress edit is cosmetic; a reply frozen at "On it" is not). + */ +export function isTerminalMaturingReply(body: string | null | undefined): boolean { + if (typeof body !== 'string') return false; + const lead = body.trimStart(); + return TERMINAL_REPLY_MARKERS.some((marker) => lead.startsWith(marker)); +} + +function truncate(s: string, max: number): string { + return s.length <= max ? s : `${s.slice(0, max - 1)}…`; +} diff --git a/cdk/src/handlers/shared/linear-attachments.ts b/cdk/src/handlers/shared/linear-attachments.ts new file mode 100644 index 000000000..d1188ce9f --- /dev/null +++ b/cdk/src/handlers/shared/linear-attachments.ts @@ -0,0 +1,743 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Authenticated Linear attachment enrichment at task-admission time (ADR-016). + * + * ABCA runs Linear 100% deterministically — there is NO Linear MCP (see + * ADR-016 "Linear is fully deterministic"). The agent therefore cannot fetch + * `uploads.linear.app`-hosted files at runtime (that used to be + * `mcp__linear-server__extract_images`). Instead, the webhook processor fetches + * them here at admission time, AUTHENTICATED with the workspace `@bgagent` + * OAuth token, screens each through the same Bedrock Guardrail pipeline as + * every other attachment, uploads the cleaned bytes to S3, and returns `passed` + * AttachmentRecords for `createTaskCore` to persist verbatim. + * + * All platform-supported attachment types come through here, not just images: + * images (PNG/JPEG) are screened visually, files (PDF/text/csv/markdown/json/ + * log) as text — same set the inline/URL paths and `jira-attachments.ts` allow. + * An unsupported type (docx, zip, …) FAILS the task closed with a message naming + * the supported types (user deletes it + re-triggers) — not silently skipped, so + * a user who attached a spec isn't left wondering why it was ignored. + * + * This is the Linear analog of `jira-attachments.ts` — same + * select → fetch → magic-bytes → screen → upload → record shape, same + * fail-closed contract ({@link LinearAttachmentError}), same batch cleanup. The + * one Linear-specific difference is the FETCH primitive: Linear embeds uploaded + * images inline as `![alt](https://uploads.linear.app/…)` and attaches uploaded + * files as plain `[label](https://uploads.linear.app/…)` links in the issue + * description, and those signed URLs require the workspace OAuth bearer (the + * unauthenticated URL-resolver in `resolve-url-attachments.ts` deliberately + * SKIPS `uploads.linear.app` for exactly this reason — see + * `linear-webhook-processor.extractImageUrlAttachments`). Non-Linear-hosted + * markdown images (public CDNs) stay on that unauthenticated URL path; only the + * `uploads.linear.app` ones come through here. + * + * Tests: cdk/test/handlers/shared/linear-attachments.test.ts + */ + +import { createHash } from 'crypto'; +import * as dns from 'dns/promises'; +import * as net from 'net'; +import { PutObjectCommand, DeleteObjectsCommand, type S3Client } from '@aws-sdk/client-s3'; +import { screenImage, screenTextFile, AttachmentScreeningError, type ScreeningConfig } from './attachment-screening'; +import { estimateImageTokensFromBuffer } from './image-tokens'; +import { logger } from './logger'; +import { createAttachmentRecord, type PassedAttachmentRecord } from './types'; +import { EXTENSION_TO_MIME, isAllowedMimeType, isValidFilename, validateMagicBytes, MAX_ATTACHMENT_SIZE_BYTES, MAX_TOTAL_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS_PER_TASK, SUPPORTED_ATTACHMENT_EXTENSIONS_LABEL } from './validation'; +import { ATTACHMENT_OBJECT_KEY_PREFIX } from '../../constructs/attachments-bucket'; + +/** Per-request timeout for a single attachment download. */ +const ATTACHMENT_FETCH_TIMEOUT_MS = 10_000; + +/** Cap on `uploads.linear.app` files pulled from one issue description. */ +const MAX_LINEAR_UPLOADS_PER_ISSUE = 10; + +/** + * Upper bound on how many markdown links we'll even enumerate while scanning a + * description, so a pathological body with thousands of links can't spin. Set + * well above any real cap — overflow past the real slot budget is handled with a + * clear error by the caller, not by this scan limit. + */ +const SCAN_HARD_CAP = 100; + +/** Max length of the derived, path-safe attachment id (S3 key segment). */ +const MAX_ATTACHMENT_ID_LENGTH = 128; + +/** Chars of pathname digest appended to an upload id to keep distinct paths distinct. */ +const UPLOAD_ID_DIGEST_CHARS = 10; + +/* eslint-disable @typescript-eslint/no-magic-numbers -- file-format magic-byte signatures */ +/** Magic-byte signatures used to sniff a body when the content-type is generic. */ +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47] as const; +const JPEG_MAGIC = [0xff, 0xd8, 0xff] as const; +const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46, 0x2d] as const; // %PDF- +/* eslint-enable @typescript-eslint/no-magic-numbers */ + +/** + * Markdown reference to a `uploads.linear.app` file. Matches BOTH the image form + * `![alt](url)` AND the plain link form `[label](url)` — Linear embeds uploaded + * images inline (`!`) but attaches uploaded files (PDFs, logs, specs) as plain + * links. The leading `!` is optional so both are captured. + * + * Capture groups: **1 = label** (the `[…]` text — for a file link this IS the + * original filename, e.g. `spec.docx`, surfaced in user-facing messages), **2 = + * URL**. The URL may be wrapped in angle brackets — `[label]()` — + * which is the CommonMark autolink form Linear NORMALIZES uploaded-file links + * into (observed in practice: a plain `[f](https://…)` link round-tripped + * through Linear comes back as `()`, and the un-bracketed pattern + * silently dropped it). The `<`/`>` are optional and excluded from the captured + * URL, and `>` is excluded from the URL body so the closing bracket can't leak in. + */ +// +// The label quantifier is BOUNDED (``{0,MAX}``) rather than open-ended. Making the +// leading ``!`` optional means the engine can no longer anchor each attempt on a +// literal ``!`` — it retries the label scan from every ``[`` — so an unbounded +// ``[^\]]*`` is quadratic in the description length. Measured on a description of +// unmatched ``[``: 4 KB ≈ 7 ms, 12 KB ≈ 56 ms, 50 KB ≈ 940 ms, and ~150 KB blows +// the webhook processor's timeout. No crafting is needed — a large pasted table +// does it. A bound caps the work per start position; a real markdown label is far +// shorter than the limit, so nothing legitimate stops matching. +// A GENEROUS label bound. This is not what stops the pathological case (see +// SCAN_MAX_DESCRIPTION_CHARS below) — it only keeps a single start position from +// scanning arbitrarily far. It must sit well above any real markdown label, +// because a label longer than the bound makes the whole link stop matching and +// the attachment is then silently MISSED. A tight bound traded a slow scan for +// lost user data, which is the worse failure: 300 dropped a 350-char descriptive +// alt text with nothing logged. +const MARKDOWN_LABEL_MAX_CHARS = 4096; + +/** + * Hard cap on the description length we scan for attachment links. + * + * This is the real bound on the work. Making the leading ``!`` optional stopped + * the engine anchoring each attempt on a literal ``!``, and BOTH variable parts + * of the pattern then backtrack per start position — the label AND the URL. So + * bounding the label alone does not help: measured on `[](https://a` repeated, + * a 100 KB description takes ~820 ms with or without a label bound, and larger + * inputs exceed the webhook processor's timeout. No crafting is needed; a + * mangled paste does it. + * + * A length cap fixes it for every hostile shape at once and is honest about the + * trade: a description longer than this has its tail unscanned, which we LOG + * rather than pass over in silence. Linear descriptions are prose written by + * humans; 64 KB is far past any real one, and the attachment count is separately + * capped by {@link SCAN_HARD_CAP}. + */ +const SCAN_MAX_DESCRIPTION_CHARS = 65_536; +const MARKDOWN_LINK_OR_IMAGE_PATTERN = new RegExp( + `!?\\[([^\\]]{0,${MARKDOWN_LABEL_MAX_CHARS}})\\]\\(]+)>?\\)`, + 'g', +); + +/** + * Thrown when a Linear attachment that was SELECTED for inclusion cannot be + * safely fetched, validated, or screened. The caller treats this as a + * fail-closed signal: reject the whole task rather than let the agent run with + * missing or unscreened context. (Attachments filtered out *before* download — + * non-`uploads.linear.app`, over-cap — are silently skipped and never raise.) + */ +export class LinearAttachmentError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'LinearAttachmentError'; + } +} + +/** S3 + screening dependencies for the attachment download path. */ +export interface LinearAttachmentStorage { + readonly s3Client: S3Client; + readonly bucketName: string; + readonly screeningConfig: ScreeningConfig; + /** Platform user the task is attributed to — part of the S3 key. */ + readonly userId: string; + /** Task ID minted by the caller — part of the S3 key. */ + readonly taskId: string; + /** Workspace `@bgagent` OAuth access token (already resolved by the caller). */ + readonly accessToken: string; + /** For log correlation only. */ + readonly linearWorkspaceId: string; +} + +/** A `uploads.linear.app` file selected from the description for download. */ +interface SelectedUpload { + readonly url: string; + /** Path-traversal-safe, unique filename for the S3 key + on-disk name. */ + readonly filename: string; + /** Stable id derived from the upload path (S3 key segment + on-disk dir). */ + readonly id: string; + /** + * Human-friendly name for USER-FACING messages/logs only (the markdown + * `[label]` — the original filename the user attached, e.g. `spec.docx`). + * Never used in an S3 key or on disk (that's {@link filename}, which is + * path-safe). Falls back to `filename` when the label is empty. + */ + readonly displayName: string; +} + +/** Is this a Linear-hosted upload URL (needs the OAuth bearer to fetch)? */ +export function isLinearUploadsUrl(url: string): boolean { + try { + const host = new URL(url).hostname.toLowerCase(); + return host === 'uploads.linear.app' || host.endsWith('.uploads.linear.app'); + } catch { + return false; + } +} + +// Private/reserved IPv4 first-octet (and second-octet range) constants, named to +// document the RFC each blocks and to satisfy no-magic-numbers. +const OCTET_10_PRIVATE = 10; // 10.0.0.0/8 (RFC 1918) +const OCTET_127_LOOPBACK = 127; // 127.0.0.0/8 +const OCTET_0_ANY = 0; // 0.0.0.0/8 +const OCTET_172_PRIVATE = 172; // 172.16.0.0/12 (RFC 1918) +const OCTET_172_LO = 16; +const OCTET_172_HI = 31; +const OCTET_192_PRIVATE = 192; // 192.168.0.0/16 (RFC 1918) +const OCTET_192_SECOND = 168; +const OCTET_169_LINKLOCAL = 169; // 169.254.0.0/16 (link-local) +const OCTET_169_SECOND = 254; +const OCTET_100_CGN = 100; // 100.64.0.0/10 (carrier-grade NAT) +const OCTET_100_LO = 64; +const OCTET_100_HI = 127; +// IPv6 link-local fe80::/10 = first hextet in [0xfe80, 0xfebf]. +const IPV6_LINKLOCAL_LO = 0xfe80; +const IPV6_LINKLOCAL_HI = 0xfebf; + +/** Reject private / internal IPs (basic SSRF guard for the resolved host). */ +function isPrivateIp(ip: string): boolean { + if (net.isIPv4(ip)) { + const [a, b] = ip.split('.').map(Number); + if (a === OCTET_10_PRIVATE || a === OCTET_127_LOOPBACK || a === OCTET_0_ANY) return true; + if (a === OCTET_172_PRIVATE && b >= OCTET_172_LO && b <= OCTET_172_HI) return true; + if (a === OCTET_192_PRIVATE && b === OCTET_192_SECOND) return true; + if (a === OCTET_169_LINKLOCAL && b === OCTET_169_SECOND) return true; + if (a === OCTET_100_CGN && b >= OCTET_100_LO && b <= OCTET_100_HI) return true; + return false; + } + const lower = ip.toLowerCase(); + // IPv4-mapped / -compatible IPv6 (::ffff:169.254.169.254, ::ffff:10.0.0.1, …): + // extract the trailing dotted-quad and re-check it as IPv4, so an attacker + // can't reach the metadata endpoint or an RFC-1918 host via the v6 wrapper + // (review #8). Matches `::ffff:1.2.3.4` and `::1.2.3.4` forms. + const mapped = lower.match(/(?:::ffff:|::)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + if (mapped && net.isIPv4(mapped[1])) return isPrivateIp(mapped[1]); + // IPv6 loopback + all-zeros. + if (lower === '::1' || lower === '::') return true; + // Unique-local fc00::/7 (fc.. / fd..). + if (lower.startsWith('fc') || lower.startsWith('fd')) return true; + // Link-local is the FULL fe80::/10 range = fe80..febf (not just the fe80 + // prefix). The first hextet's high 10 bits are fixed: 0xfe80–0xfebf. + const firstHextet = parseInt(lower.split(':')[0] || '0', 16); + if (firstHextet >= IPV6_LINKLOCAL_LO && firstHextet <= IPV6_LINKLOCAL_HI) return true; + return false; +} + +/** + * Derive a stable, path-traversal-safe id + filename from a `uploads.linear.app` + * URL. The id becomes an S3 key segment AND the agent-side on-disk directory + * name, so it must never traverse. Linear upload URLs look like + * `https://uploads.linear.app///?signature=…`; we key on the + * path (minus query) and sanitize the trailing name. + */ +function deriveUploadIdentity(url: string, index: number): { id: string; filename: string } { + let pathname = ''; + let lastSegment = ''; + try { + const u = new URL(url); + pathname = u.pathname; + lastSegment = decodeURIComponent(pathname.split('/').filter(Boolean).pop() ?? ''); + } catch { + pathname = url; + } + // Stable id: the path with unsafe chars collapsed to '-' (query dropped so a + // re-signed URL for the same object maps to the same id), plus a short digest + // of the FULL pathname. + // + // The digest is load-bearing, not decoration. Collapsing every unsafe char to + // '-' and then squashing runs maps '.', '-' and '/' onto the same character, so + // ``design.v1.png`` and ``design-v1.png`` produced an identical id — and both + // de-dupe sites resolve a collision by DISCARDING the second file. A user who + // attached both got one, silently, with nothing logged and no warning. The + // digest restores injectivity for practical purposes while keeping the readable + // stem for logs. + const slug = pathname.replace(/[^A-Za-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + const digest = createHash('sha256').update(pathname).digest('hex').slice(0, UPLOAD_ID_DIGEST_CHARS); + const stem = (slug || `upload-${index}`).slice(0, MAX_ATTACHMENT_ID_LENGTH - UPLOAD_ID_DIGEST_CHARS - 1); + const id = `${stem}-${digest}`; + const sanitized = lastSegment.replace(/[^a-zA-Z0-9._-]/g, '_'); + // No .png default: a link-form upload may be a PDF/log. A generic fallback name + // keeps the extension out of the type decision (content-type/magic-bytes win). + const filename = isValidFilename(sanitized) ? sanitized : `linear-upload-${index}`; + return { id, filename }; +} + +/** Lowercased file extension (no dot) of a filename, or '' if none. */ +function extensionOf(filename: string): string { + const dot = filename.lastIndexOf('.'); + return dot > 0 ? filename.slice(dot + 1).toLowerCase() : ''; +} + +/** + * Collect the UNIQUE `uploads.linear.app` markdown files from a description (both + * image `![](url)` and link `[](url)` forms). Non-Linear-hosted URLs are ignored + * here (the unauthenticated URL path in `resolve-url-attachments.ts` handles + * public images). De-dupes by id. Does NOT apply the slot cap — the caller + * decides whether the count fits (so overflow is a loud error, not a silent + * truncation; review finding #2). Scanning is bounded by {@link SCAN_HARD_CAP} + * so a pathological description with thousands of links can't run unbounded. + */ +function collectLinearUploads(description: string | undefined): SelectedUpload[] { + if (!description) return []; + // Bound the scan input, not just the pattern. Truncation is announced: an + // unscanned tail could hide an attachment, and a silent miss is worse than a + // slow scan. + let scanned = description; + if (scanned.length > SCAN_MAX_DESCRIPTION_CHARS) { + logger.warn('Issue description longer than the attachment-scan cap — tail not scanned', { + description_chars: scanned.length, scan_cap: SCAN_MAX_DESCRIPTION_CHARS, + }); + scanned = scanned.slice(0, SCAN_MAX_DESCRIPTION_CHARS); + } + const selected: SelectedUpload[] = []; + const seenIds = new Set(); + let index = 0; + let match: RegExpExecArray | null; + MARKDOWN_LINK_OR_IMAGE_PATTERN.lastIndex = 0; + while ((match = MARKDOWN_LINK_OR_IMAGE_PATTERN.exec(scanned)) !== null) { + if (selected.length >= SCAN_HARD_CAP) break; + const label = (match[1] ?? '').trim(); + const url = match[2]; + if (!isLinearUploadsUrl(url)) continue; // public CDN URLs go via the URL path + const { id, filename } = deriveUploadIdentity(url, index++); + if (seenIds.has(id)) continue; + seenIds.add(id); + // The markdown label is the original filename for a file link (Linear sets + // it to the upload's name). Use it for user-facing messages only; fall back + // to the path-safe filename when the link had no/empty label. + selected.push({ url, filename, id, displayName: label || filename }); + } + return selected; +} + +/** + * GET the raw bytes of one `uploads.linear.app` URL with the OAuth bearer, + * enforcing the size cap while reading. SSRF-guarded: HTTPS-only, host must be a + * Linear uploads host, and the resolved IP must be public. Returns an outcome + * kind so the caller can force a token refresh on a 401/403. + */ +async function fetchUploadBytes( + accessToken: string, + url: string, +): Promise< + | { readonly kind: 'ok'; readonly content: Buffer; readonly contentType: string } + | { readonly kind: 'auth' } + | { readonly kind: 'error'; readonly message: string } +> { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { kind: 'error', message: 'invalid URL' }; + } + if (parsed.protocol !== 'https:') { + return { kind: 'error', message: 'non-HTTPS URL' }; + } + if (!isLinearUploadsUrl(url)) { + return { kind: 'error', message: 'not a Linear uploads host' }; + } + // SSRF: resolve the host and reject private/internal targets before connecting. + try { + const addrs = await dns.lookup(parsed.hostname, { all: true }); + if (addrs.length === 0 || addrs.some((a) => isPrivateIp(a.address))) { + return { kind: 'error', message: 'host resolves to a private/reserved address' }; + } + } catch (err) { + return { kind: 'error', message: `DNS resolution failed: ${err instanceof Error ? err.message : String(err)}` }; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ATTACHMENT_FETCH_TIMEOUT_MS); + try { + const resp = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: '*/*', + }, + signal: controller.signal, + // SSRF hardening (review): the pre-fetch DNS check validates the ALLOWLISTED + // uploads.linear.app host, but a redirect could bounce the connection to an + // internal address the check never saw. A legitimate signed Linear upload + // URL serves the bytes directly (no redirect), so reject any redirect rather + // than blindly follow it to an unvalidated host. (Narrows the residual + // DNS-rebinding TOCTOU window too — the fetch can't be steered off-host.) + redirect: 'error', + }); + if (resp.status === 401 || resp.status === 403) { + return { kind: 'auth' }; + } + if (!resp.ok) { + return { kind: 'error', message: `HTTP ${resp.status}` }; + } + const contentType = (resp.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase(); + const reader = resp.body?.getReader(); + if (!reader) { + return { kind: 'error', message: 'empty response body' }; + } + const chunks: Buffer[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.length; + if (total > MAX_ATTACHMENT_SIZE_BYTES) { + await reader.cancel(); + return { kind: 'error', message: 'exceeds size limit' }; + } + chunks.push(Buffer.from(value)); + } + return { kind: 'ok', content: Buffer.concat(chunks), contentType }; + } catch (err) { + return { kind: 'error', message: err instanceof Error ? err.message : String(err) }; + } finally { + clearTimeout(timer); + } +} + +/** A native "paperclip" attachment surfaced from the issue's `attachments` + * connection (distinct from description-embedded markdown links). */ +export interface LinearPaperclipAttachment { + /** The attachment's URL (only `uploads.linear.app` ones are hydrated here). */ + readonly url: string; + /** The attachment title — the original filename, used for messages + typing. */ + readonly title: string; +} + +/** + * Fetch, screen, and store the `uploads.linear.app` files an issue carries, + * returning `passed` AttachmentRecords for `createTaskCore` to persist verbatim. + * + * Sources BOTH kinds of Linear attachment (review finding #1): + * - files embedded in the description as markdown `[label](uploads.linear.app/…)`; + * - native paperclip attachments (the `attachments` connection) passed in via + * `paperclipAttachments` — the webhook processor reads those from the context + * probe. Only `uploads.linear.app`-hosted paperclips are hydrated; a paperclip + * that's an external link (GitHub, Figma, …) is left alone. + * De-duped across both sources by upload id. + * + * @param description the Linear issue description markdown (untrusted). + * @param remainingSlots attachment slots still free after public-URL image + * extraction (so the combined total respects the + * per-task cap of {@link MAX_ATTACHMENTS_PER_TASK}). + * @param ctx OAuth token + S3/screening storage deps. + * @param paperclipAttachments native paperclip attachments from the probe (optional). + * @throws LinearAttachmentError if a SELECTED upload cannot be safely fetched, + * validated, or screened, or if the count overflows the budget + * (fail-closed — reject the task). + */ +export async function downloadScreenAndStoreLinearAttachments( + description: string | undefined, + remainingSlots: number, + ctx: LinearAttachmentStorage, + paperclipAttachments: readonly LinearPaperclipAttachment[] = [], +): Promise { + const all = collectLinearUploads(description); + + // Merge native paperclip attachments (uploads.linear.app only), de-duped by id + // against the description-scanned set so a file that's both attached AND linked + // isn't fetched twice. + const seenIds = new Set(all.map((u) => u.id)); + let pcIndex = 0; + for (const pc of paperclipAttachments) { + if (all.length >= SCAN_HARD_CAP) break; + if (!isLinearUploadsUrl(pc.url)) continue; // external-link paperclip — not ours to fetch + const { id, filename } = deriveUploadIdentity(pc.url, pcIndex++); + if (seenIds.has(id)) continue; + seenIds.add(id); + all.push({ url: pc.url, filename, id, displayName: (pc.title || '').trim() || filename }); + } + + if (all.length === 0) return []; + + // Overflow is a LOUD error, not a silent truncation (review #2 + #6). This is + // reached AFTER collecting uploads (not short-circuited on remainingSlots<=0), + // so a Linear-hosted spec behind 10 public images is REJECTED, never silently + // dropped. Budget = the smaller of free per-task slots (public-URL images + // already consumed some) and the per-issue Linear cap. + const budget = Math.min(remainingSlots, MAX_LINEAR_UPLOADS_PER_ISSUE, MAX_ATTACHMENTS_PER_TASK); + const slotsConsumedElsewhere = remainingSlots < MAX_LINEAR_UPLOADS_PER_ISSUE; + if (all.length > budget) { + throw new LinearAttachmentError( + budget <= 0 + ? `This issue has ${all.length} Linear attachment(s), but the ${MAX_ATTACHMENTS_PER_TASK}-attachment ` + + 'limit is already used up by other images in the description. Remove some and re-apply the trigger label.' + : `This issue has ${all.length} Linear attachments, over the limit of ${budget} that can be processed ` + + `for one task${slotsConsumedElsewhere ? ' (some slots are used by other images in the description)' : ''}. ` + + 'Remove some attachments and re-apply the trigger label.', + ); + } + const selected = all; + + const records: PassedAttachmentRecord[] = []; + // Keys uploaded so far this batch — deleted on any failure so a + // partially-successful batch doesn't orphan S3 objects. + const uploadedKeys: string[] = []; + // Running total of REAL downloaded bytes (declared sizes don't exist for a + // signed URL, so this is the only ceiling). + let totalBytes = 0; + + try { + for (const upload of selected) { + let outcome = await fetchUploadBytes(ctx.accessToken, upload.url); + + // uploads.linear.app auth failures are terminal here: the caller already + // resolved a fresh workspace token (resolveLinearOauthToken refreshes + // proactively within 60s of expiry), so a 401/403 means the signed URL + // itself is stale/invalid, not a refreshable token — fail closed. + if (outcome.kind === 'auth') { + throw new LinearAttachmentError( + `Attachment '${upload.displayName}' could not be downloaded: Linear rejected the credential ` + + '(the signed upload URL may have expired — re-trigger the task).', + ); + } + if (outcome.kind === 'error') { + throw new LinearAttachmentError( + `Attachment '${upload.displayName}' could not be downloaded (${outcome.message}).`, + ); + } + + const content = outcome.content; + + totalBytes += content.length; + if (totalBytes > MAX_TOTAL_ATTACHMENT_SIZE_BYTES) { + throw new LinearAttachmentError( + `Issue attachments exceed the total size limit of ${MAX_TOTAL_ATTACHMENT_SIZE_BYTES} bytes.`, + ); + } + if (content.length === 0) { + throw new LinearAttachmentError(`Attachment '${upload.displayName}' is empty (0 bytes).`); + } + + // Infer the MIME from the response content-type, the filename extension, + // then the magic bytes (in that order of trust). The extension comes from + // the markdown LABEL (displayName — the original filename like `design.pdf`), + // not the S3 filename (derived from the URL path, a UUID with no extension). + // Linear embeds uploaded images inline and attaches uploaded files (PDFs, + // logs, CSVs, JSON, text) as links — both come through here. The platform + // allowlist gates the supported set: images (PNG/JPEG) and files + // (PDF/text/csv/markdown/json/log). Anything else (docx, zip, …) fails closed. + const mimeType = inferMime(outcome.contentType, upload.displayName, content); + const isImage = mimeType.startsWith('image/'); + const attachmentType = isImage ? 'image' : 'file'; + if (!mimeType || !isAllowedMimeType(mimeType, attachmentType)) { + // Unsupported type (docx, zip, …) — there's no safe screening path, so + // fail closed and tell the user to remove it and re-trigger. (We reject + // rather than silently skip: a user who attached a spec would otherwise + // get no signal it was ignored.) The supported-extension list is derived + // from the allowlist so it never drifts. + logger.warn('Rejecting Linear task: unsupported attachment type', { + linear_workspace_id: ctx.linearWorkspaceId, + attachment_filename: upload.displayName, + content_type: outcome.contentType || 'unknown', + inferred_mime: mimeType || 'unknown', + }); + // Message states the fact + the supported set; the processor's reject + // wrapper appends the "remove and re-apply the trigger label" instruction, + // so we don't repeat it here. + throw new LinearAttachmentError( + `Attachment '${upload.displayName}' is not a supported file type ` + + `(supported: ${SUPPORTED_ATTACHMENT_EXTENSIONS_LABEL}).`, + ); + } + // Confirm the bytes match the resolved type (blocks a masquerading/polyglot + // payload). Text types have no signature — validateMagicBytes checks for + // valid, null-free UTF-8 instead. + if (!validateMagicBytes(content, mimeType)) { + throw new LinearAttachmentError( + `Attachment '${upload.displayName}' content does not match its declared type '${mimeType}'.`, + ); + } + + // Screen through the same Bedrock Guardrail pipeline as every other + // attachment — images visually, files as text. Any block or screening + // failure is fail-closed. (Pass displayName — screening uses it only in + // its own error text, never as a path.) + let screenResult; + try { + screenResult = isImage + ? await screenImage(content, mimeType, upload.displayName, ctx.screeningConfig) + : await screenTextFile(content, mimeType, upload.displayName, ctx.screeningConfig); + } catch (err) { + if (err instanceof AttachmentScreeningError) { + throw new LinearAttachmentError( + `Attachment '${upload.displayName}' was blocked by content screening: ${err.message}`, + { cause: err }, + ); + } + throw new LinearAttachmentError( + `Attachment '${upload.displayName}' could not be screened: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ); + } + if (screenResult.screening.status === 'blocked') { + throw new LinearAttachmentError( + `Attachment '${upload.displayName}' was blocked by content policy: ${screenResult.screening.categories.join(', ')}`, + ); + } + + // S3 key + record filename stay path-safe (upload.filename) — the agent + // writes the attachment to disk at `/` (agent/src/attachments.py), + // so it must never carry the raw user label. + const s3Key = `${ATTACHMENT_OBJECT_KEY_PREFIX}${ctx.userId}/${ctx.taskId}/${upload.id}/${upload.filename}`; + let putResult; + try { + putResult = await ctx.s3Client.send(new PutObjectCommand({ + Bucket: ctx.bucketName, + Key: s3Key, + Body: screenResult.content, + ContentType: mimeType, + })); + } catch (s3Err) { + logger.error('S3 upload failed for Linear attachment', { + linear_workspace_id: ctx.linearWorkspaceId, + attachment_filename: upload.displayName, + s3_key: s3Key, + error: s3Err instanceof Error ? s3Err.message : String(s3Err), + metric_type: 'linear_attachment_upload_failure', + }); + throw new LinearAttachmentError( + `Attachment '${upload.displayName}' could not be stored.`, + { cause: s3Err }, + ); + } + uploadedKeys.push(s3Key); + + // Only images carry a token estimate (files aren't fed to the model as + // vision tokens). Mirrors jira-attachments. + const tokenEstimate = isImage + ? estimateImageTokensFromBuffer(screenResult.content, mimeType) + : undefined; + + records.push(createAttachmentRecord({ + attachment_id: upload.id, + type: attachmentType, + content_type: mimeType, + filename: upload.filename, + s3_key: s3Key, + s3_version_id: putResult.VersionId ?? 'unversioned', + size_bytes: screenResult.content.length, + screening: { status: 'passed', screened_at: new Date().toISOString() }, + checksum_sha256: screenResult.checksum, + ...(tokenEstimate !== undefined && { token_estimate: tokenEstimate }), + }) as PassedAttachmentRecord); + + logger.info('Linear attachment downloaded, screened, and stored', { + linear_workspace_id: ctx.linearWorkspaceId, + attachment_filename: upload.displayName, + s3_key: s3Key, + }); + } + } catch (err) { + // Fail-closed: a mid-batch failure must not orphan objects already uploaded. + await deleteS3Objects(ctx.s3Client, ctx.bucketName, uploadedKeys); + throw err; + } + + return records; +} + +/** Does `content` start with the given magic-byte signature? */ +function startsWith(content: Buffer, magic: readonly number[]): boolean { + if (content.length < magic.length) return false; + return magic.every((byte, i) => content[i] === byte); +} + +/** + * Resolve the MIME type of a downloaded upload. Trust order — and crucially, the + * filename `label` is ATTACKER-CONTROLLED (it's the markdown `[label]`), so it + * must never by itself promote bytes to a BINARY type (review finding #3): + * 1. response content-type, if it is itself a platform-allowed type; + * 2. magic-byte signature for the BINARY types (PNG/JPEG/PDF) — proven by the + * bytes, not the label; + * 3. the label extension, but ONLY when it maps to a TEXT type. Binary + * extensions are ignored here — a `[x.pdf]` label can't make a non-PDF a + * PDF; only step 2's magic bytes can. Text is safe to key on the label + * because the caller then runs validateMagicBytes, whose UTF-8 check + * rejects a binary payload wearing a `.txt`/`.csv` label. + * Returns '' if none applies (caller rejects the upload as unsupported). + */ +/** Is this an allowed TEXT-family MIME (screened as UTF-8 text, never a binary)? */ +function isAllowedTextMime(mime: string): boolean { + return (mime.startsWith('text/') || mime === 'application/json') && isAllowedMimeType(mime, 'file'); +} + +function inferMime(contentType: string, label: string, content: Buffer): string { + // BYTES ARE AUTHORITATIVE (review #2): a recognised binary magic signature wins + // over ANY content-type or label. This blocks a PDF served as `text/plain` (or + // labeled `.txt`) from skipping PDF extraction and being screened as raw text. + if (startsWith(content, PNG_MAGIC)) return 'image/png'; + if (startsWith(content, JPEG_MAGIC)) return 'image/jpeg'; + if (startsWith(content, PDF_MAGIC)) return 'application/pdf'; + // No binary signature. A content-type claiming a BINARY allowed type (pdf/png/ + // jpeg) but lacking the matching magic bytes is a mismatch → reject (don't store + // bytes lying about being a PDF/image). Only a TEXT content-type is trusted here, + // and validateMagicBytes' UTF-8 gate still confirms the bytes are really text. + if (contentType && isAllowedTextMime(contentType)) return contentType; + // Fall back to the label extension — TEXT types only. The label is + // attacker-controlled, so it can never promote bytes to a binary type (#3). + const byExt = EXTENSION_TO_MIME[extensionOf(label)]; + if (byExt && isAllowedTextMime(byExt)) return byExt; + return ''; +} + +/** + * Best-effort deletion of S3 objects by key. Never throws — cleanup failure is + * logged and left to the bucket's 90-day lifecycle. Mirrors + * `jira-attachments.deleteS3Objects`. + */ +async function deleteS3Objects(s3Client: S3Client, bucketName: string, keys: readonly string[]): Promise { + if (keys.length === 0) return; + try { + const result = await s3Client.send(new DeleteObjectsCommand({ + Bucket: bucketName, + Delete: { Objects: keys.map((Key) => ({ Key })) }, + })); + if (result.Errors && result.Errors.length > 0) { + logger.error('Partial cleanup of Linear attachment objects — some remain', { + failed_keys: result.Errors.map((e) => e.Key), + }); + } + } catch (err) { + logger.error('Cleanup of Linear attachment objects failed (90-day lifecycle is backstop)', { + keys, + error: err instanceof Error ? err.message : String(err), + }); + } +} + +/** Delete previously-stored pre-screened Linear attachment objects (for the + * processor to call when createTaskCore rejects the task after upload). */ +export async function cleanupPreScreenedAttachments( + s3Client: S3Client, + bucketName: string, + records: readonly PassedAttachmentRecord[], +): Promise { + await deleteS3Objects(s3Client, bucketName, records.map((r) => r.s3_key).filter((k): k is string => Boolean(k))); +} diff --git a/cdk/src/handlers/shared/linear-feedback.ts b/cdk/src/handlers/shared/linear-feedback.ts index 6597d9122..aa97e0aab 100644 --- a/cdk/src/handlers/shared/linear-feedback.ts +++ b/cdk/src/handlers/shared/linear-feedback.ts @@ -17,15 +17,17 @@ * SOFTWARE. */ +import { isTerminalMaturingReply, preservePreviewSuffix } from './iteration-reply'; import { resolveLinearOauthToken } from './linear-oauth-resolver'; import { logger } from './logger'; +import { isBotAuthoredComment } from './orchestration-comment-trigger'; /** * Lambda-side helper for posting comments and reactions onto Linear issues * via direct GraphQL. Used by the webhook processor to give users feedback * on pre-container failures (guardrail block, concurrency cap, unmapped - * project, etc.) — paths where the agent never starts and the agent-side - * Linear MCP / `linear_reactions.py` cannot run. + * project, etc.) — paths where the agent never starts, so the agent-side + * `linear_reactions.py` (its only Linear I/O — there is no Linear MCP) can't run. * * All calls are best-effort. Errors are logged at WARN and swallowed — * Linear feedback is advisory and must never gate task-rejection logic. @@ -35,8 +37,19 @@ const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql'; const REQUEST_TIMEOUT_MS = 5000; -/** Reaction emoji short-code for the failure marker. Matches `EMOJI_FAILURE` in `agent/src/linear_reactions.py`. */ -const EMOJI_FAILURE = 'x'; +/** + * Reaction emoji short-codes. Match the agent-side child markers in + * ``agent/src/linear_reactions.py`` so the PARENT epic shows the same + * status signal as its sub-issues: 👀 at start, ✅/❌ at completion. + */ +export const EMOJI_STARTED = 'eyes'; +export const EMOJI_SUCCESS = 'white_check_mark'; +export const EMOJI_FAILURE = 'x'; +// A comment on a parent epic that we could NOT route to one specific sub-issue is +// a QUESTION, not work-in-progress — leaving the 👀 (EMOJI_STARTED) on it makes it +// look like the agent is still working. Swap to ❓ so the reaction matches the +// "I need you to clarify / pick a sub-issue" disambiguation reply. +export const EMOJI_NEEDS_INPUT = 'question'; const COMMENT_CREATE_MUTATION = ` mutation CreateComment($issueId: String!, $body: String!) { @@ -46,6 +59,107 @@ mutation CreateComment($issueId: String!, $body: String!) { } `.trim(); +/** Create a comment and return its id (for later edit-in-place). */ +const COMMENT_CREATE_RETURNING_ID_MUTATION = ` +mutation CreateCommentReturningId($issueId: String!, $body: String!) { + commentCreate(input: { issueId: $issueId, body: $body }) { + success + comment { id } + } +} +`.trim(); + +/** Edit an existing comment in place — this is what makes one comment able to + * serve as a live, self-updating status block instead of a stream of new ones. */ +const COMMENT_UPDATE_MUTATION = ` +mutation UpdateComment($id: String!, $body: String!) { + commentUpdate(id: $id, input: { body: $body }) { + success + } +} +`.trim(); + +/** Delete a comment — used to clear the transient "on it" ack once the revised + * plan it was acknowledging has been written into the thread. */ +const COMMENT_DELETE_MUTATION = ` +mutation DeleteComment($id: String!) { + commentDelete(id: $id) { success } +} +`.trim(); + +/** + * List an issue's TOP-LEVEL comments (id + body). Used to tidy the thread once a + * pending proposal is settled: we can't + * track every fire-and-forget note id (they're posted from ~15 sites), so we + * fetch the thread once and delete the bot's own ``🗂️``/``👋`` notes by prefix, + * keeping the frozen plan reference + the (differently-prefixed) live panel. + * ``first: 100`` comfortably covers a plan phase (a few notes + revise rounds); + * pagination is unnecessary for the transient-note volume this sweeps. + */ +const ISSUE_COMMENTS_QUERY = ` +query IssueComments($issueId: String!) { + issue(id: $issueId) { + comments(first: 100) { + nodes { id body } + } + } +} +`.trim(); + +/** + * Fetch comments with the author metadata needed to tell a human turn from a + * bot/integration one. All Linear I/O is deliberately platform-side and + * deterministic (ADR-016), so the agent cannot read the thread at runtime — it is + * fetched here and pre-hydrated into the task context instead. + * ``user`` is the human author (null when a comment + * was posted by an OAuth app / integration); ``botActor`` is present precisely + * for those app/integration comments — so @bgagent's own progress and ack + * comments carry a ``botActor`` and no ``user``. ``createdAt`` orders the thread. + * + * We fetch the first 100 (matching {@link ISSUE_COMMENTS_QUERY}) and sort + + * slice to the most recent human comments CLIENT-SIDE, so the result is + * independent of Linear's connection sort direction. 100 covers every realistic + * issue thread; the rare over-100 issue simply may miss the oldest turns, which + * is acceptable for advisory context. + */ +const RECENT_COMMENTS_QUERY = ` +query RecentComments($issueId: String!) { + issue(id: $issueId) { + comments(first: 100) { + nodes { + id + body + createdAt + user { displayName name } + botActor { id } + } + } + } +} +`.trim(); + +/** + * Post a THREADED REPLY beneath an existing comment, so the agent's response to a + * request lands under that request rather than as a new top-level comment. + * ``parentId`` is the comment being replied to; the reply notifies and reads + * as a conversation turn under it. Returns the new reply's id (for a possible + * later edit), distinct from a top-level comment. + * + * IMPORTANT (verified against the live API): Linear's ``commentCreate`` requires + * ``issueId`` to be present EVEN for a threaded reply — ``parentId`` alone + * fails ``commentCreate`` argument validation ("Exactly one of …issueId must + * be defined"). So the reply carries BOTH the parent comment id and its + * issue id. + */ +const COMMENT_REPLY_RETURNING_ID_MUTATION = ` +mutation ReplyToComment($issueId: String!, $parentId: String!, $body: String!) { + commentCreate(input: { issueId: $issueId, parentId: $parentId, body: $body }) { + success + comment { id } + } +} +`.trim(); + const REACTION_CREATE_MUTATION = ` mutation ReactIssue($issueId: String!, $emoji: String!) { reactionCreate(input: { issueId: $issueId, emoji: $emoji }) { @@ -54,6 +168,122 @@ mutation ReactIssue($issueId: String!, $emoji: String!) { } `.trim(); +/** + * React to a specific COMMENT (not the issue) — the instant "on it" ack on an + * ``@bgagent`` comment. (Verified against the live API: ``reactionCreate`` input + * accepts ``commentId``.) + */ +const REACTION_CREATE_ON_COMMENT_MUTATION = ` +mutation ReactComment($commentId: String!, $emoji: String!) { + reactionCreate(input: { commentId: $commentId, emoji: $emoji }) { + success + } +} +`.trim(); + +const REACTION_DELETE_MUTATION = ` +mutation UnreactIssue($id: String!) { + reactionDelete(id: $id) { success } +} +`.trim(); + +/** Read an issue's reactions (id + emoji) — to swap one bgagent marker for another. */ +const ISSUE_REACTIONS_QUERY = ` +query IssueReactions($issueId: String!) { + issue(id: $issueId) { reactions { id emoji } } +} +`.trim(); + +/** Read a COMMENT's reactions (id + emoji) — to swap the comment's bgagent marker. */ +const COMMENT_REACTIONS_QUERY = ` +query CommentReactions($commentId: String!) { + comment(id: $commentId) { reactions { id emoji } } +} +`.trim(); + +/** Read a COMMENT's current body — to append to it (e.g. the deploy-preview link + * that arrives after the reply has already settled). */ +const COMMENT_BODY_QUERY = ` +query CommentBody($commentId: String!) { + comment(id: $commentId) { body } +} +`.trim(); + +/** + * The bgagent status-marker emojis we manage on the PARENT epic. Mirrors + * ``_BGAGENT_EMOJIS`` in ``agent/src/linear_reactions.py``. Only these are + * ever deleted by {@link swapIssueReaction} — a human's reaction is never + * touched. + */ +const BGAGENT_EMOJIS: ReadonlySet = new Set([ + EMOJI_STARTED, EMOJI_SUCCESS, EMOJI_FAILURE, EMOJI_NEEDS_INPUT, +]); + +/** + * Fetch the workflow states for the TEAM that owns ``issueId``, so we can + * resolve a target state by its semantic ``type`` (Linear state IDs are + * per-team UUIDs, not knowable a priori). ``type`` values: + * ``backlog`` | ``unstarted`` (Todo) | ``started`` (In Progress / In Review) | + * ``completed`` (Done) | ``canceled``. + */ +const ISSUE_TEAM_STATES_QUERY = ` +query IssueTeamStates($issueId: String!) { + issue(id: $issueId) { + state { id type name position } + team { states { nodes { id type name position } } } + } +} +`.trim(); + +const ISSUE_SET_STATE_MUTATION = ` +mutation SetIssueState($issueId: String!, $stateId: String!) { + issueUpdate(id: $issueId, input: { stateId: $stateId }) { + success + } +} +`.trim(); + +interface TeamState { + readonly id: string; + readonly type: string; + readonly name: string; + readonly position: number; +} + +async function graphqlData( + accessToken: string, + query: string, + variables: Record, +): Promise | null> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const resp = await fetch(LINEAR_GRAPHQL_URL, { + method: 'POST', + headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + signal: controller.signal, + }); + if (!resp.ok) { + logger.warn('Linear feedback GraphQL non-2xx', { status: resp.status }); + return null; + } + const body = (await resp.json()) as { data?: Record; errors?: unknown }; + if (body.errors) { + logger.warn('Linear feedback GraphQL errors', { errors: body.errors }); + return null; + } + return body.data ?? null; + } catch (err) { + logger.warn('Linear feedback request failed', { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } finally { + clearTimeout(timer); + } +} + /** * Outcome of a Linear API call. ``retryable`` distinguishes transient * failures (network error, request timeout, HTTP 5xx/429) — where a @@ -78,9 +308,9 @@ async function graphqlRequest( const resp = await fetch(LINEAR_GRAPHQL_URL, { method: 'POST', headers: { - // OAuth tokens use Bearer; legacy PAK was the bare value. Phase - // 2.0b: all tokens stored in Secrets Manager are OAuth bearer - // tokens so we always Bearer-prefix. + // OAuth tokens use Bearer; a legacy personal API key was sent as the bare + // value. Every token stored in Secrets Manager is now an OAuth bearer + // token, so we always Bearer-prefix. 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, @@ -165,6 +395,181 @@ export async function postIssueComment( return graphqlRequest(token, COMMENT_CREATE_MUTATION, { issueId, body }); } +/** + * Upsert the orchestration's live status block — ONE comment on the parent epic + * that is rewritten as the run progresses, rather than a new comment per + * transition. If ``existingCommentId`` is given, EDIT that comment in place; otherwise + * CREATE a fresh comment and return its id so the caller can persist it and + * edit on the next transition. Returns the comment id on success (the + * existing id on update, the new id on create), or null on any failure. + * Best-effort — never throws; the status block is advisory. + */ +export async function upsertStatusComment( + ctx: LinearFeedbackContext, + issueId: string, + body: string, + existingCommentId?: string, +): Promise { + const token = await resolveToken(ctx); + if (!token) return null; + + if (existingCommentId) { + // graphqlRequest now returns a LinearPostResult — read .ok (an object is + // always truthy, so a bare `ok ?` would wrongly report success). + const ok = (await graphqlRequest(token, COMMENT_UPDATE_MUTATION, { id: existingCommentId, body })).ok; + return ok ? existingCommentId : null; + } + + const data = await graphqlData(token, COMMENT_CREATE_RETURNING_ID_MUTATION, { issueId, body }); + const created = data?.commentCreate as { success?: boolean; comment?: { id?: string } } | undefined; + return created?.success && created.comment?.id ? created.comment.id : null; +} + +/** + * Delete a comment — used for the transient "🗂️ On it — updating…" ack once the + * revised plan it announced has been posted. Best-effort: returns true on + * success, false on any failure (a lingering ack is a cosmetic nit, never a + * breakage). Note Linear may already have fired a notification for the ack that + * deletion can't un-send — acceptable. + */ +export async function deleteComment( + ctx: LinearFeedbackContext, + commentId: string, +): Promise { + const token = await resolveToken(ctx); + if (!token) return false; + return (await graphqlRequest(token, COMMENT_DELETE_MUTATION, { id: commentId })).ok; +} + +/** + * Bot-comment prefixes that mark a TRANSIENT note — an interim ack, an error note, + * a nudge, a wrong-handle correction. These are the ``🗂️``/``👋`` comments a + * thread cleanup sweeps once the thing they were about is settled. + * + * Deliberately does NOT include the live epic panel (``🔄``/``⚠️``/``✅``) or the + * agent's own progress (``🤖``) — those aren't ours to delete here, and the panel + * is the thing we're KEEPING. Mirrors the self-trigger guard's + * ``BOT_COMMENT_PREFIXES`` but scoped to transient notes. + */ +const TRANSIENT_NOTE_PREFIXES: readonly string[] = ['🗂️', '👋']; + +/** + * Sweep the bot's transient notes off an issue once the thing they were about is + * settled, leaving any frozen reference + the live epic panel. Fetches the thread + * once, then deletes every top-level comment that (a) starts with a transient-note + * prefix and (b) is NOT ``keepCommentId`` (a reference to keep). Best-effort and + * total: a failed list returns 0 (nothing swept — a lingering note is a + * cosmetic nit, never a breakage), and each delete is independent so one + * failure doesn't abort the rest. Returns the count deleted (for logging/tests). + * + * Prefix-scoping is the robustness win: interim notes are posted from ~15 + * fire-and-forget sites whose ids we don't track, and future note types are + * covered automatically — while the panel (different prefix) and human comments + * (no bot prefix) are provably untouched. + */ +export async function sweepTransientNotes( + ctx: LinearFeedbackContext, + issueId: string, + keepCommentId?: string, +): Promise { + const token = await resolveToken(ctx); + if (!token) return 0; + const data = await graphqlData(token, ISSUE_COMMENTS_QUERY, { issueId }); + const issue = data?.issue as { comments?: { nodes?: Array<{ id?: string; body?: string }> } } | undefined; + const nodes = issue?.comments?.nodes ?? []; + let deleted = 0; + for (const node of nodes) { + const id = node?.id; + const body = (node?.body ?? '').trimStart(); + if (!id || id === keepCommentId) continue; + if (!TRANSIENT_NOTE_PREFIXES.some((p) => body.startsWith(p))) continue; + const ok = (await graphqlRequest(token, COMMENT_DELETE_MUTATION, { id })).ok; + if (ok) deleted += 1; + } + if (deleted > 0) { + logger.info('Swept transient notes', { + issue_id: issueId, deleted, kept_reference: keepCommentId ?? null, + }); + } + return deleted; +} + +/** A rendered issue comment folded into the task context (mirrors the Jira + * ``RenderedComment`` shape so the two processors read alike). */ +export interface RenderedComment { + readonly author: string; + readonly createdAt: string; + readonly markdown: string; +} + +/** Default cap on recent human comments folded into the task context. */ +const DEFAULT_MAX_RECENT_COMMENTS = 20; + +interface RawLinearComment { + readonly id?: string; + readonly body?: string; + readonly createdAt?: string; + readonly user?: { readonly displayName?: string; readonly name?: string } | null; + readonly botActor?: { readonly id?: string } | null; +} + +/** + * Fetch the most recent HUMAN-authored comments on an issue, rendered to + * markdown oldest-first, for pre-hydrating the task context (the agent has no + * Linear tooling of its own to read the thread at runtime). Best-effort / fail-open: any + * failure (token, GraphQL error, malformed body) logs a WARN and returns ``[]`` + * so the task still proceeds — comments are advisory context, not a gate. + * + * "Human" excludes app/integration comments two ways (belt and suspenders): + * 1. ``botActor`` present, or ``user`` absent → posted by an OAuth app / + * integration (this is how @bgagent's own comments are marked); + * 2. the body starts with one of the bot's own rendered-comment markers + * ({@link isBotAuthoredComment}) — catches anything mis-attributed to a user. + */ +export async function fetchRecentComments( + ctx: LinearFeedbackContext, + issueId: string, + maxComments: number = DEFAULT_MAX_RECENT_COMMENTS, +): Promise { + const token = await resolveToken(ctx); + if (!token) return []; + const data = await graphqlData(token, RECENT_COMMENTS_QUERY, { issueId }); + const issue = data?.issue as { comments?: { nodes?: RawLinearComment[] } } | undefined; + const nodes = issue?.comments?.nodes ?? []; + + const human: RenderedComment[] = []; + for (const node of nodes) { + // Skip app/integration comments (bgagent + other bots): they carry a + // botActor and no human user. + if (node.botActor || !node.user) continue; + const body = (node.body ?? '').trim(); + if (!body) continue; + // Belt and suspenders: drop anything that reads as one of our own rendered + // comments even if it slipped through as a "user" comment. + if (isBotAuthoredComment(body)) continue; + human.push({ + author: node.user.displayName?.trim() || node.user.name?.trim() || 'Unknown', + createdAt: typeof node.createdAt === 'string' ? node.createdAt : '', + markdown: body, + }); + } + + // Order oldest-first so the thread reads naturally, then keep the most recent + // ``maxComments`` (sort is client-side — independent of Linear's connection + // sort direction). Comments without a timestamp sort last (treated as newest). + human.sort((a, b) => (a.createdAt || '￿').localeCompare(b.createdAt || '￿')); + const recent = human.length > maxComments ? human.slice(human.length - maxComments) : human; + + if (recent.length > 0) { + logger.info('Fetched recent human Linear comments for task context', { + linear_workspace_id: ctx.linearWorkspaceId, + issue_id: issueId, + count: recent.length, + }); + } + return recent; +} + /** * Add an emoji reaction onto a Linear issue. Defaults to ❌ — the failure marker * the agent uses on the success/failure side. Same result contract as @@ -180,6 +585,318 @@ export async function addIssueReaction( return graphqlRequest(token, REACTION_CREATE_MUTATION, { issueId, emoji }); } +/** + * React to a specific Linear COMMENT. Used as the + * instant "on it" acknowledgement when a human ``@bgagent``s a comment — + * 👀 ({@link EMOJI_STARTED}) lands immediately, before the iteration task is + * even created, so the human knows the agent saw their request with zero + * comment clutter. Best-effort; returns true on success. + */ +export async function reactToComment( + ctx: LinearFeedbackContext, + commentId: string, + emoji: string = EMOJI_STARTED, +): Promise { + const token = await resolveToken(ctx); + if (!token) return false; + // graphqlRequest returns a LinearPostResult (an object, so always truthy); + // this best-effort helper just needs the success bool off it. + return (await graphqlRequest(token, REACTION_CREATE_ON_COMMENT_MUTATION, { commentId, emoji })).ok; +} + +/** + * Post a THREADED REPLY beneath a Linear comment. Used + * when the agent's work on an ``@bgagent`` comment lands ("✅ Updated — PR #N") + * or fails ("❌ …"). Unlike an edit, a reply NOTIFIES and reads as a + * conversation turn under the original request, keeping the thread contextual. + * Returns the new reply's comment id (for a possible later edit) or null on any + * failure. Best-effort — never throws. + * + * ``issueId`` is the issue the parent comment lives on — Linear requires it on + * ``commentCreate`` even for a reply (see {@link COMMENT_REPLY_RETURNING_ID_MUTATION}). + */ +export async function replyToComment( + ctx: LinearFeedbackContext, + issueId: string, + parentCommentId: string, + body: string, +): Promise { + const token = await resolveToken(ctx); + if (!token) return null; + const data = await graphqlData(token, COMMENT_REPLY_RETURNING_ID_MUTATION, { + issueId, parentId: parentCommentId, body, + }); + const created = data?.commentCreate as { success?: boolean; comment?: { id?: string } } | undefined; + return created?.success && created.comment?.id ? created.comment.id : null; +} + +/** + * The MATURING THREADED REPLY for a comment-iteration. + * If ``existingReplyId`` is given, EDIT that reply in place; otherwise CREATE a + * new reply threaded under ``parentCommentId`` and return its id. Mirrors + * {@link upsertStatusComment} but as a THREADED reply (carries ``parentId``) so + * one iteration shows a single reply that matures 👀→🔄→✅/💬 instead of N + * top-level comments. Returns the reply id (existing on edit, new on create), + * or null on any failure. Best-effort — never throws. + * + * Linear requires ``issueId`` even for a threaded reply (parentId alone fails + * commentCreate validation — verified against the live API), so the create + * carries both. + */ +export async function upsertThreadedReply( + ctx: LinearFeedbackContext, + issueId: string, + parentCommentId: string, + body: string, + existingReplyId?: string, + options?: { preservePreview?: boolean; skipIfSettled?: boolean; repairIfOverwritten?: boolean }, +): Promise { + const token = await resolveToken(ctx); + if (!token) return null; + + if (existingReplyId) { + let finalBody = body; + // Both options below need the CURRENT body, so read it once. + let current: string | undefined; + if (options?.preservePreview || options?.skipIfSettled) { + const data = await graphqlData(token, COMMENT_BODY_QUERY, { commentId: existingReplyId }); + current = (data?.comment as { body?: string } | undefined)?.body; + } + // A PROGRESS edit must never overwrite an outcome. The terminal settle and the + // (stream-driven, so possibly delayed) progress milestone edit the same + // comment, and the body is the ground truth about which has landed — a + // task-record marker is stamped slightly BEFORE the render it announces, so + // checking the body is what closes that window. Yield rather than clobber: + // losing a progress edit is cosmetic, losing the outcome is not. + // + // This check is a read followed by a separate write, so it NARROWS the window + // rather than closing it: a settle landing in between is still overwritten. + // Linear's comment update takes only an id and a body — there is no version + // or etag to make the write conditional on what was read (verified against + // the live schema) — so no amount of care here makes it atomic. The terminal + // writer therefore verifies its own body afterwards; see + // ``repairIfOverwritten`` below, which is what actually recovers the outcome. + if (options?.skipIfSettled && isTerminalMaturingReply(current)) { + logger.info('Skipping a progress edit — the reply already shows a terminal outcome', { + comment_id: existingReplyId, + }); + return existingReplyId; + } + // The deploy-preview link is appended by a SEPARATE async path (the + // screenshot webhook), so a terminal-settle re-render here can clobber a + // preview that already landed — this was observed live, the preview appended + // and then wiped by the terminal edit ~14 seconds later. When asked, carry an + // existing `[preview]` segment onto the new body so the two writers converge + // regardless of which one runs last. + if (options?.preservePreview) { + finalBody = preservePreviewSuffix(body, current); + } + const ok = (await graphqlRequest(token, COMMENT_UPDATE_MUTATION, { id: existingReplyId, body: finalBody })).ok; + if (!ok) return null; + if (options?.repairIfOverwritten) { + await repairOverwrittenOutcome(token, existingReplyId, finalBody); + } + return existingReplyId; + } + + const data = await graphqlData(token, COMMENT_REPLY_RETURNING_ID_MUTATION, { + issueId, parentId: parentCommentId, body, + }); + const created = data?.commentCreate as { success?: boolean; comment?: { id?: string } } | undefined; + return created?.success && created.comment?.id ? created.comment.id : null; +} + +/** + * After writing an OUTCOME, check it is still there, and put it back if a + * concurrently-delivered progress edit landed on top of it. + * + * Why this exists rather than a conditional write: the progress writers avoid + * clobbering by reading the body and refusing when it already shows an outcome, + * but read-then-write is not atomic and Linear's comment update accepts only an + * id and a body — there is no version or etag to condition on (verified against + * the live schema), so the interleaving cannot be prevented at the surface. It + * can, however, be detected and undone by the writer that knows which body + * matters, and only the outcome is worth that extra round trip. + * + * Deliberately narrow. It re-asserts only when the body has regressed to a + * NON-terminal render, so it never fights a later legitimate outcome (a failure + * reply superseding a success, or a second iteration's settle) and never touches + * an unrecognised body a human may have edited. One repair attempt, no loop: if + * it loses again the next progress edit will itself observe the terminal body and + * yield. Best-effort throughout — a failed repair is logged, never thrown. + */ +async function repairOverwrittenOutcome( + accessToken: string, + commentId: string, + outcomeBody: string, +): Promise { + if (!isTerminalMaturingReply(outcomeBody)) return; // only outcomes are worth defending + const data = await graphqlData(accessToken, COMMENT_BODY_QUERY, { commentId }); + const current = (data?.comment as { body?: string } | undefined)?.body; + // Unreadable → leave it alone: acting on an unknown body could overwrite + // something newer, and the reply most likely still holds the outcome. + if (typeof current !== 'string') return; + if (current === outcomeBody || isTerminalMaturingReply(current)) return; + + logger.warn('A progress edit overwrote a settled reply — restoring the outcome', { + event: 'iteration_reply.outcome_restored', + comment_id: commentId, + }); + // Re-assert the outcome, carrying over any preview the screenshot webhook may + // have appended in the meantime so the repair doesn't drop it. + const restored = preservePreviewSuffix(outcomeBody, current); + const ok = (await graphqlRequest(accessToken, COMMENT_UPDATE_MUTATION, { id: commentId, body: restored })).ok; + if (!ok) { + logger.warn('Could not restore the overwritten outcome — the reply may read as in-progress', { + event: 'iteration_reply.outcome_restore_failed', + comment_id: commentId, + }); + } +} + +/** + * Append a one-line suffix to an existing comment, idempotently. + * Reads the comment's current body, and if it does NOT already contain + * ``marker``, appends ``\n``+``line`` and updates. Used by the screenshot webhook + * to add the ``· [preview](url)`` link to the iteration's settle reply once the + * (async) capture finishes — the reply has usually already rendered ✅ + cost by + * then, so the link arrives a few seconds later as an in-place edit rather than a + * new comment. ``marker`` is a stable substring (e.g. ``[preview]``) so a webhook + * redelivery doesn't append twice. Best-effort; returns true only if appended. + */ +export async function appendOnceToComment( + ctx: LinearFeedbackContext, + commentId: string, + line: string, + marker: string, +): Promise { + const token = await resolveToken(ctx); + if (!token) return false; + const data = await graphqlData(token, COMMENT_BODY_QUERY, { commentId }); + const current = (data?.comment as { body?: string } | undefined)?.body; + if (typeof current !== 'string') return false; + if (current.includes(marker)) return false; // already appended (idempotent) + const ok = (await graphqlRequest(token, COMMENT_UPDATE_MUTATION, { + id: commentId, body: `${current}\n${line}`, + })).ok; + return ok; +} + +/** + * Delete one of our own status markers, retrying ONCE on a transient failure. + * + * A marker we fail to remove is not cosmetic: it leaves two of our reactions on + * the same item ("saw it" beside "done"), no caller inspects the swap's result, + * and nothing else ever revisits it — so the contradiction is permanent. That is + * exactly what happened live: a 5s request timeout aborted one delete and the + * pair stayed. One retry costs a single call and covers the blip; anything + * terminal (auth, a marker another writer already removed) is not retried, + * because re-sending cannot change the answer. + * + * Returns true when the marker is gone. + */ +async function deleteOwnMarker(accessToken: string, reactionId: string): Promise { + const first = await graphqlRequest(accessToken, REACTION_DELETE_MUTATION, { id: reactionId }); + if (first.ok) return true; + if (!first.retryable) return false; + logger.info('Retrying a status-marker removal after a transient failure', { reaction_id: reactionId }); + return (await graphqlRequest(accessToken, REACTION_DELETE_MUTATION, { id: reactionId })).ok; +} + +/** + * Swap the PARENT epic's bgagent status marker so only ONE is shown at a + * time (👀 → ✅/❌), mirroring the children's reaction behaviour. The + * children capture the reaction id in-process and delete it; the parent's + * markers are added across SEPARATE lambda invocations (👀 at seed, ✅/❌ at + * completion), so we instead query the issue's reactions, delete every + * bgagent marker EXCEPT the target, then add the target if absent. Only + * bgagent emojis (👀/✅/❌) are ever removed — a human's reaction is left + * untouched. Best-effort; returns true if the target marker is present + * afterwards. + */ +export async function swapIssueReaction( + ctx: LinearFeedbackContext, + issueId: string, + emoji: string, +): Promise { + const token = await resolveToken(ctx); + if (!token) return false; + + const data = await graphqlData(token, ISSUE_REACTIONS_QUERY, { issueId }); + const reactions = ((data?.issue as { reactions?: Array<{ id: string; emoji: string }> } | undefined)?.reactions) ?? []; + + // Delete our stale markers (any bgagent emoji that isn't the target). + let targetPresent = false; + let staleLeft = 0; + for (const r of reactions) { + if (r.emoji === emoji) { targetPresent = true; continue; } + if (BGAGENT_EMOJIS.has(r.emoji)) { + if (!(await deleteOwnMarker(token, r.id))) staleLeft += 1; + } + } + + // A marker we could not remove means the issue still shows two of our own + // reactions at once, which is the contradictory state this swap exists to + // prevent — so don't report the promised single marker. + if (staleLeft > 0) { + logger.warn('Could not remove a stale status marker from the issue', { + event: 'linear_feedback.issue_reaction_swap_incomplete', + issue_id: issueId, + stale_count: staleLeft, + }); + } + if (targetPresent) return staleLeft === 0; // already present; the deletes decide + const added = (await graphqlRequest(token, REACTION_CREATE_MUTATION, { issueId, emoji })).ok; + return added && staleLeft === 0; +} + +/** + * Swap the bgagent status marker on a COMMENT (👀 → ✅/❌), so the trigger + * comment shows ONE marker reflecting the outcome — mirrors + * {@link swapIssueReaction} but on a comment. The 👀 lands at + * receipt ({@link reactToComment}); when the iteration settles we swap it for + * ✅ (success) / ❌ (failure) so the comment itself reads done at a glance, not + * just the threaded reply. Queries the comment's reactions, deletes every + * bgagent marker except the target, adds the target if absent. Only bgagent + * emojis (👀/✅/❌) are removed — a human's reaction is never touched. + * Idempotent (a reconciler redelivery re-converges to the same single marker). + * Best-effort; returns true only when the target is present AND no stale marker + * of ours was left behind — two markers at once is the state this prevents. + */ +export async function swapCommentReaction( + ctx: LinearFeedbackContext, + commentId: string, + emoji: string, +): Promise { + const token = await resolveToken(ctx); + if (!token) return false; + + const data = await graphqlData(token, COMMENT_REACTIONS_QUERY, { commentId }); + const reactions = ((data?.comment as { reactions?: Array<{ id: string; emoji: string }> } | undefined)?.reactions) ?? []; + + let targetPresent = false; + let staleLeft = 0; + for (const r of reactions) { + if (r.emoji === emoji) { targetPresent = true; continue; } + if (BGAGENT_EMOJIS.has(r.emoji)) { + if (!(await deleteOwnMarker(token, r.id))) staleLeft += 1; + } + } + + // Same all-or-nothing result as the issue swap: a leftover marker leaves the + // comment showing "saw it" beside "done", so it is not a success. + if (staleLeft > 0) { + logger.warn('Could not remove a stale status marker from the comment', { + event: 'linear_feedback.comment_reaction_swap_incomplete', + comment_id: commentId, + stale_count: staleLeft, + }); + } + if (targetPresent) return staleLeft === 0; + const added = (await graphqlRequest(token, REACTION_CREATE_ON_COMMENT_MUTATION, { commentId, emoji })).ok; + return added && staleLeft === 0; +} + /** * Convenience: post a feedback comment **and** drop a ❌ reaction in one call. * Both calls run in parallel; both are best-effort. Returns void — callers @@ -195,3 +912,179 @@ export async function reportIssueFailure( addIssueReaction(ctx, issueId, EMOJI_FAILURE), ]); } + +/** + * Pick the target workflow state by semantic preference. ``preferredNames`` + * (case-insensitive) is tried first so e.g. "In Review" wins over "In + * Progress" when both share Linear ``type: started``; falls back to the + * lowest-``position`` state of ``type``. Returns null if the team has no + * state of that type. + */ +function pickState( + states: readonly TeamState[], + type: string, + preferredNames: readonly string[], +): TeamState | null { + const ofType = states.filter((s) => s.type === type); + if (ofType.length === 0) return null; + for (const name of preferredNames) { + const hit = ofType.find((s) => s.name.toLowerCase() === name.toLowerCase()); + if (hit) return hit; + } + return [...ofType].sort((a, b) => a.position - b.position)[0]; +} + +/** + * Transition a Linear issue to a workflow state chosen by semantic ``type`` + * (+ optional name preference). Used by the orchestration reconciler to move the + * PARENT epic through its lifecycle — ``In Progress`` when the orchestration + * seeds, ``In Review`` when all children succeed — since the parent spawns no + * task and Linear's GitHub automation (which moves the children on PR-open) + * never touches it. + * + * Best-effort, like the rest of this module: resolves the team's states, + * picks the target, and issues ``issueUpdate``. Returns true only on a + * confirmed transition. Skips (returns false) if the issue is already in the + * target state or moving backward (we never demote, e.g. a human already + * pushed the epic to Done). Never throws. + */ +export async function transitionIssueState( + ctx: LinearFeedbackContext, + issueId: string, + targetType: 'started' | 'completed', + preferredNames: readonly string[] = [], + /** + * Allow a WITHIN-TYPE regression (e.g. In Review → In Progress, both + * ``started``). By default the backward-move guard blocks it — correct for most + * callers, but it silently no-op'd the orchestration rollup's deliberate + * re-open (a settled epic that gains a new or re-run child must go back from In + * Review to In Progress, and since both are ``started`` type the + * position-tiebreak blocked it). Cross-TYPE demotion (completed → started) is + * still ALWAYS blocked — this only relaxes the same-type position tiebreak. + */ + allowSameTypeRegression = false, +): Promise { + const token = await resolveToken(ctx); + if (!token) return false; + + const data = await graphqlData(token, ISSUE_TEAM_STATES_QUERY, { issueId }); + const issue = data?.issue as + | { state?: TeamState; team?: { states?: { nodes?: TeamState[] } } } + | undefined; + const states = issue?.team?.states?.nodes ?? []; + if (states.length === 0) { + logger.warn('Linear state transition: no team states resolved', { issue_id: issueId }); + return false; + } + + const target = pickState(states, targetType, preferredNames); + if (!target) { + logger.warn('Linear state transition: no state of target type', { issue_id: issueId, target_type: targetType }); + return false; + } + + const current = issue?.state; + if (current?.id === target.id) { + // Already there — idempotent no-op (e.g. reconciler re-fires). + return false; + } + // Never move backward. Order by state TYPE first (the lifecycle: + // backlog → unstarted → started → completed/canceled), then by position + // within the same type. Raw position is NOT lifecycle order — e.g. Done + // (completed, position 3) sorts numerically before In Review (started, + // position 1002), so a position-only guard would wrongly demote a + // human-completed epic back to In Review. We never demote across types + // (a human/automation advanced it) nor backward within a type. + if (current) { + const TYPE_RANK: Record = { + backlog: 0, unstarted: 1, started: 2, completed: 3, canceled: 3, triage: 0, + }; + const curRank = TYPE_RANK[current.type] ?? 0; + const tgtRank = TYPE_RANK[target.type] ?? 0; + const crossTypeDemotion = curRank > tgtRank; + const sameTypeRegression = curRank === tgtRank && current.position >= target.position; + // Cross-type demotion (e.g. completed → started) is NEVER allowed — a human + // or automation advanced it. A same-type regression (In Review → In Progress) + // is allowed ONLY when the caller opts in, which is how the orchestration + // rollup deliberately re-opens a settled epic. + const backward = crossTypeDemotion || (sameTypeRegression && !allowSameTypeRegression); + if (backward) { + logger.info('Linear state transition: skipping backward move', { + issue_id: issueId, + current_state: current.name, + target_state: target.name, + cross_type: crossTypeDemotion, + }); + return false; + } + } + + const ok = (await graphqlRequest(token, ISSUE_SET_STATE_MUTATION, { issueId, stateId: target.id })).ok; + if (ok) { + logger.info('Linear issue state transitioned', { + issue_id: issueId, + from: current?.name, + to: target.name, + }); + } + return ok; +} + +/** + * Move an issue BACKWARD to a not-started state + * (``unstarted`` "Todo", else ``backlog``), used when a planning + * run finishes and the issue is now awaiting the reviewer's approve. The webhook + * moved it to In Progress at dispatch (so the board showed the ~1-2 min planning + * WAS happening — {@link transitionIssueState}); once the plan is posted and + * nothing is running, "In Progress" would lie ("looks like work started while + * it's just a pending plan"). So we revert it. + * + * This is the ONE sanctioned backward move, and it's tightly guarded to avoid + * clobbering a human: it ONLY fires when the issue is CURRENTLY in a ``started`` + * state (i.e. still the In Progress we set) — if a human already pushed it to + * Done/Canceled, or pulled it back to Backlog themselves, we leave it. Prefers a + * "Todo" then "Backlog" target name; falls back to the lowest-position + * unstarted/backlog state. Best-effort, never throws. + */ +export async function revertIssueToNotStarted( + ctx: LinearFeedbackContext, + issueId: string, +): Promise { + const token = await resolveToken(ctx); + if (!token) return false; + + const data = await graphqlData(token, ISSUE_TEAM_STATES_QUERY, { issueId }); + const issue = data?.issue as + | { state?: TeamState; team?: { states?: { nodes?: TeamState[] } } } + | undefined; + const states = issue?.team?.states?.nodes ?? []; + const current = issue?.state; + if (states.length === 0 || !current) return false; + + // Only revert OUR "In Progress" — never demote a human-advanced (completed/ + // canceled) or a human-pulled-back (already backlog/unstarted) issue. + if (current.type !== 'started') { + logger.info('Revert-to-not-started: issue not in a started state — leaving it', { + issue_id: issueId, current_state: current.name, current_type: current.type, + }); + return false; + } + + // Prefer an unstarted "Todo" (the natural "not started, waiting" state); fall + // back to backlog. Within a type, prefer the named state, else lowest position. + const target = pickState(states, 'unstarted', ['Todo', 'To Do']) + ?? pickState(states, 'backlog', ['Backlog', 'Triage']); + if (!target) { + logger.info('Revert-to-not-started: no unstarted/backlog state on the team — leaving it', { issue_id: issueId }); + return false; + } + if (target.id === current.id) return false; + + const ok = (await graphqlRequest(token, ISSUE_SET_STATE_MUTATION, { issueId, stateId: target.id })).ok; + if (ok) { + logger.info('Linear issue reverted to not-started (awaiting approval)', { + issue_id: issueId, from: current.name, to: target.name, + }); + } + return ok; +} diff --git a/cdk/src/handlers/shared/linear-issue-context-probe.ts b/cdk/src/handlers/shared/linear-issue-context-probe.ts new file mode 100644 index 000000000..dd5e87a4a --- /dev/null +++ b/cdk/src/handlers/shared/linear-issue-context-probe.ts @@ -0,0 +1,300 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { logger } from './logger'; + +/** + * Best-effort probe for additional Linear context attached to an issue — + * paperclip attachments (linked resources) and project documents — surfaced + * as a PRESENCE SIGNAL in the task description. + * + * ADR-016: the agent runs Linear deterministically and has no Linear MCP, so + * it can't fetch these at runtime. This probe therefore just FLAGS that they + * exist (titles + counts) so the agent knows the issue may reference material + * it wasn't given, and can proceed with best judgment / note the gap rather + * than assume the description is complete. It does NOT pre-fetch bodies, screen + * content, or upload to S3 — description-embedded `uploads.linear.app` files are + * pre-hydrated separately by `linear-attachments.ts`, and recent human comments + * by `linear-feedback.fetchRecentComments`. + * + * The webhook payload itself does NOT carry attachments or project.documents, + * so we ask Linear's GraphQL API once at task-creation time. + */ + +const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql'; +const REQUEST_TIMEOUT_MS = 5000; + +/** + * Cap on attachment titles listed inline in the task-description hint; any + * beyond this are summarized as "(+N more)" so the prepended hint stays short. + */ +const MAX_HINTED_ATTACHMENT_TITLES = 5; + +/** Cap on project documents whose CONTENT is pulled into the task context. */ +const MAX_HYDRATED_PROJECT_DOCS = 5; + +/** + * Upper bound for COUNTING project docs (review #3). The content page is capped + * at {@link MAX_HYDRATED_PROJECT_DOCS}, but the presence-hint needs the TRUE + * total so it can flag docs beyond that cap (previously the count came from the + * capped content page, so docs 6+ were invisible to the hint). A second aliased + * id-only connection counts up to this bound cheaply (no bodies fetched); a + * project with more docs than this is vanishingly rare and the hint's "+N more" + * is approximate past it anyway. + */ +const MAX_COUNTED_PROJECT_DOCS = 50; + +const ISSUE_CONTEXT_QUERY = ` +query IssueContext($id: String!, $docs: Int!, $docCount: Int!) { + issue(id: $id) { + id + attachments(first: 25) { + nodes { + id + title + url + } + } + project { + id + name + documents(first: $docs) { + nodes { id title content } + } + documentsForCount: documents(first: $docCount) { + nodes { id } + } + } + } +} +`.trim(); + +/** A native paperclip attachment (title + url) from the `attachments` connection. */ +export interface LinearProbeAttachment { + readonly title: string; + readonly url: string; +} + +/** A project wiki document's title + markdown body (ADR-016 doc pre-hydration). */ +export interface LinearProbeDocument { + readonly title: string; + readonly content: string; +} + +export interface LinearIssueContextProbe { + /** Paperclip attachment titles surfaced on the issue, if any (for the hint). */ + readonly attachmentTitles: readonly string[]; + /** + * Native paperclip attachments with their URLs — the webhook processor + * hydrates the `uploads.linear.app` ones through the attachment pipeline + * (review finding #1). Distinct from description-embedded markdown links. + */ + readonly attachments: readonly LinearProbeAttachment[]; + /** Project name (only present when the issue belongs to a project). */ + readonly projectName: string | null; + /** True when the issue's project has at least one document attached. */ + readonly projectHasDocuments: boolean; + /** + * Project wiki documents WITH their content (ADR-016: pre-hydrated at + * task-creation because the agent has no Linear MCP to fetch them). The + * webhook processor screens these through the Bedrock Guardrail and folds them + * into the task description. Capped at {@link MAX_HYDRATED_PROJECT_DOCS}. + */ + readonly projectDocuments: readonly LinearProbeDocument[]; + /** + * Whether the probe actually reached Linear and parsed a result. `false` on a + * non-2xx / GraphQL error / network failure / timeout — in which case the + * empty arrays mean "we don't KNOW", not "there is nothing". Attachment + * hydration keys on this to fail-CLOSED (reject the task) rather than run blind + * when a paperclip-only spec could be silently missing (review finding #5). The + * doc/hint consumers stay advisory (fail-open) as before. + */ + readonly ok: boolean; + /** + * Total project documents Linear reported (incl. empty-body / over-cap ones NOT + * in `projectDocuments`). Lets the hint flag "there are docs we didn't hand you" + * precisely — hydratedCount < totalCount — instead of suppressing the hint the + * moment ANY doc is hydrated (review finding #6). + */ + readonly projectDocumentCount: number; +} + +const EMPTY: LinearIssueContextProbe = { + attachmentTitles: [], + attachments: [], + projectName: null, + projectHasDocuments: false, + projectDocuments: [], + ok: true, + projectDocumentCount: 0, +}; + +/** Probe result for the FAILURE case — same empty shape but `ok:false` so a + * caller can distinguish "unknown" from "genuinely empty". */ +const PROBE_FAILED: LinearIssueContextProbe = { ...EMPTY, ok: false }; + +/** + * Issue the GraphQL query. Never throws. On failure (network, auth, GraphQL + * errors, timeout) returns a probe with `ok:false` + empty arrays; on a genuine + * empty result (2xx, issue has no attachments/docs) returns `ok:true`. The + * distinction matters: a failed probe means "we don't KNOW" (a paperclip-only + * spec could be present-but-unread), so the attachment-hydration caller + * fails-CLOSED on `ok:false` rather than treating it as "nothing here" (review + * finding #5). The doc/hint consumers stay advisory (fail-open) either way. + */ +export async function probeLinearIssueContext( + accessToken: string, + issueId: string, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const resp = await fetch(LINEAR_GRAPHQL_URL, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: ISSUE_CONTEXT_QUERY, + variables: { id: issueId, docs: MAX_HYDRATED_PROJECT_DOCS, docCount: MAX_COUNTED_PROJECT_DOCS }, + }), + signal: controller.signal, + }); + if (!resp.ok) { + logger.warn('Linear issue context probe non-2xx', { status: resp.status, issue_id: issueId }); + return PROBE_FAILED; + } + const body = (await resp.json()) as { + data?: { + issue?: { + attachments?: { nodes?: Array<{ id?: string; title?: string; url?: string }> }; + project?: { + id?: string; + name?: string; + documents?: { nodes?: Array<{ id?: string; title?: string; content?: string }> }; + documentsForCount?: { nodes?: Array<{ id?: string }> }; + } | null; + }; + }; + errors?: unknown; + }; + if (body.errors) { + logger.warn('Linear issue context probe graphql errors', { issue_id: issueId, errors: body.errors }); + return PROBE_FAILED; + } + const issue = body.data?.issue; + // A well-formed 2xx GraphQL response whose `issue` is null/absent (deleted / + // not visible) is a genuine "nothing here", not a transport failure → ok:true. + if (!issue) return EMPTY; + const attachmentNodes = issue.attachments?.nodes ?? []; + const attachmentTitles = attachmentNodes + .map((a) => (typeof a?.title === 'string' ? a.title.trim() : '')) + .filter((t): t is string => t.length > 0); + const attachments = attachmentNodes + .filter((a): a is { title?: string; url: string } => typeof a?.url === 'string' && a.url.length > 0) + .map((a) => ({ title: typeof a.title === 'string' ? a.title.trim() : '', url: a.url })); + const project = issue.project ?? null; + const projectName = typeof project?.name === 'string' && project.name.trim() ? project.name.trim() : null; + const documentNodes = project?.documents?.nodes ?? []; + const projectHasDocuments = documentNodes.length > 0; + // Keep docs that actually have body text (an empty wiki page adds nothing but + // noise + a guardrail round-trip). Title defaults to "Untitled document". + const projectDocuments = documentNodes + .filter((d): d is { title?: string; content: string } => typeof d?.content === 'string' && d.content.trim().length > 0) + .map((d) => ({ title: typeof d.title === 'string' && d.title.trim() ? d.title.trim() : 'Untitled document', content: d.content })); + // Review #3: the TRUE doc total from the id-only count connection (up to + // MAX_COUNTED_PROJECT_DOCS), NOT the capped content page — so the presence + // hint can flag docs beyond the hydration cap. Fall back to the content page + // length if the count connection is absent (older API shape / test mock). + const countNodes = project?.documentsForCount?.nodes; + const projectDocumentCount = Array.isArray(countNodes) ? countNodes.length : documentNodes.length; + return { + attachmentTitles, + attachments, + projectName, + projectHasDocuments: projectHasDocuments || projectDocumentCount > 0, + projectDocuments, + ok: true, + projectDocumentCount, + }; + } catch (err) { + logger.warn('Linear issue context probe request failed', { + issue_id: issueId, + error: err instanceof Error ? err.message : String(err), + }); + return PROBE_FAILED; + } finally { + clearTimeout(timer); + } +} + +/** + * Render a one-paragraph hint the webhook processor prepends to the task + * description when the probe surfaced anything worth flagging. Returns + * an empty string when there's nothing to hint about — the processor + * skips the prepend in that case. + * + * ADR-016: the agent has no Linear MCP, so this is a PRESENCE SIGNAL for the + * material we could NOT hand it (a non-uploads paperclip like a Figma/GitHub + * link; a project doc whose body was empty or beyond the hydration cap). Project + * documents WITH content are pre-hydrated into the description separately + * (see the processor's project-docs section), so they are NOT flagged here — + * flagging included content would wrongly tell the agent to go find it. Names + * what's missing WITHOUT pointing at any (now non-existent) fetch tool. + */ +export function renderIssueContextHint(probe: LinearIssueContextProbe): string { + const bits: string[] = []; + if (probe.attachmentTitles.length > 0) { + const titles = probe.attachmentTitles + .slice(0, MAX_HINTED_ATTACHMENT_TITLES).map((t) => `"${t}"`).join(', '); + const more = probe.attachmentTitles.length > MAX_HINTED_ATTACHMENT_TITLES + ? ` (+${probe.attachmentTitles.length - MAX_HINTED_ATTACHMENT_TITLES} more)` : ''; + bits.push(`paperclip attachments — ${titles}${more}`); + } + // Flag docs we did NOT hydrate content for — empty body, or beyond the + // hydration cap. Hydrated docs are in the description already, so don't tell + // the agent to hunt for them; but if SOME docs were left out (hydrated < total) + // still flag the remainder (review finding #6 — a single hydrated doc used to + // suppress the hint for all the others). Default arrays defensively — a + // hand-built probe object in a test may omit the newer fields. + const hydratedDocCount = (probe.projectDocuments ?? []).length; + // Prefer the exact count; when a probe object omits it (older shape / test + // mock), infer: hydrated>0 → assume those are all (no extra); else if docs are + // known to exist → treat as ≥1 unhydrated so the presence hint still fires. + const totalDocCount = typeof probe.projectDocumentCount === 'number' + ? probe.projectDocumentCount + : (hydratedDocCount > 0 ? hydratedDocCount : (probe.projectHasDocuments ? 1 : 0)); + const unhydratedDocCount = Math.max(0, totalDocCount - hydratedDocCount); + if (unhydratedDocCount > 0) { + const noun = unhydratedDocCount === 1 ? 'wiki document' : `${unhydratedDocCount} wiki documents`; + const qualifier = hydratedDocCount > 0 ? ' (empty or beyond the included set)' : ''; + if (probe.projectName) { + bits.push(`project "${probe.projectName}" has ${noun}${qualifier} not included here`); + } else { + bits.push(`the project has ${noun}${qualifier} not included here`); + } + } + if (bits.length === 0) return ''; + return ( + `The Linear issue references additional context not included here: ${bits.join('; ')}. ` + + 'These live in Linear and are not attached to this task — work from the description and ' + + 'attachments you were given, and if one of these turns out to be essential, say so in the PR rather than guessing.' + ); +} diff --git a/cdk/src/handlers/shared/linear-issue-lookup.ts b/cdk/src/handlers/shared/linear-issue-lookup.ts index b23738875..9073f5291 100644 --- a/cdk/src/handlers/shared/linear-issue-lookup.ts +++ b/cdk/src/handlers/shared/linear-issue-lookup.ts @@ -25,7 +25,7 @@ import { logger } from './logger'; const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); /** - * Linear issue identifier shape, e.g. `ABCA-42`. Linear identifiers are + * Linear issue identifier shape, e.g. `ENG-42`. Linear identifiers are * `-` where the key is uppercase letters and digits is * a positive integer. We bound the team key length [1,10] and number * length [1,8] to avoid pathological inputs. @@ -33,11 +33,11 @@ const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); const LINEAR_IDENTIFIER_RE = /\b([A-Z][A-Z0-9]{0,9})-(\d{1,8})\b/g; /** - * Pull the first Linear issue identifier (e.g. `ABCA-42`) found in + * Pull the first Linear issue identifier (e.g. `ENG-42`) found in * the given text. PR titles and bodies typically include this either * because the agent's task_description carries the identifier, or * because Linear's own GitHub integration auto-injects an - * `ABCA-42 ` reference. + * `ENG-42 ` reference. * * Returns the first match in document order. If multiple distinct * identifiers are present we still return the first — multi-issue PRs @@ -52,6 +52,35 @@ export function extractLinearIdentifier(text: string | null | undefined): string return match ? `${match[1]}-${match[2]}` : null; } +/** + * Pull the Linear identifier out of an ABCA-generated git branch name. + * + * This is the *authoritative* identifier source for the screenshot + * router, and it must be tried before PR title/body. ABCA derives every + * task branch as `bgagent/{taskId}/{slug}` where the slug is + * `slugify("ENG-151: ")` — so the identifier is ALWAYS the + * leading slug segment (see `generateBranchName` / `slugify` in + * `gateway.ts`, and the `${identifier}: ${title}` description built in + * `linear-webhook-processor.ts` / `orchestration-release.ts`). + * + * Why branch-first matters: in a stacked sub-issue + * orchestration, an agent's PR *body* commonly narrates the predecessor + * issue ("cherry-picked from ENG-151 … Closes ENG-152") before the + * issue the PR actually closes. `extractLinearIdentifier` returns the + * first match in document order, so body-first routing misattributes the + * screenshot to the predecessor. The branch name has no such ambiguity — + * it encodes exactly one issue, the PR's own. + * + * The slug is lowercased by `slugify`, so we upper-case before matching + * (the identifier regex anchors on `[A-Z]`). The ULID `taskId` segment + * contains no `-`, so it can never produce a false `<KEY>-<n>` match + * ahead of the real identifier. + */ +export function extractLinearIdentifierFromBranch(branchName: string | null | undefined): string | null { + if (!branchName) return null; + return extractLinearIdentifier(branchName.toUpperCase()); +} + /** * Resolved Linear issue location, paired with the workspace that owns * it. The screenshot processor uses these to construct a @@ -73,19 +102,19 @@ query IssueByIdentifier($identifier: String!) { `.trim(); /** - * Look up a Linear issue by identifier (e.g. `ABCA-42`). + * Look up a Linear issue by identifier (e.g. `ENG-42`). * * Routing strategy: * 1. Scan active workspaces (one round-trip — typical stacks have 1–2). - * 2. If any row's `team_keys` contains the identifier's team key (`ABCA`), + * 2. If any row's `team_keys` contains the identifier's team key (`ENG`), * query that workspace directly and return on hit. * 3. Otherwise fall back to iterating every active workspace until one - * returns a match. This handles legacy rows missing `team_keys` (the - * column was added in #96 and back-fills only on next `setup` / - * `add-workspace` re-run) and the rare case where a team was added in - * Linear after the workspace was registered. + * returns a match. This handles rows written before `team_keys` existed + * (it back-fills only on the next `setup` / `add-workspace` re-run) and + * the rare case where a team was added in Linear after the workspace was + * registered. * - * @param identifier `ABCA-42`-style Linear issue identifier + * @param identifier `ENG-42`-style Linear issue identifier * @param registryTableName name of LinearWorkspaceRegistryTable * @returns issue location, or null if no workspace contains the issue */ @@ -122,7 +151,7 @@ export async function findLinearIssueByIdentifier( return null; } - // Identifier prefix is the part before the first dash (`ABCA-42` → `ABCA`). + // Identifier prefix is the part before the first dash (`ENG-42` → `ENG`). // Compare uppercase since Linear team keys are upper-case but inbound text // (PR titles, branch names) is mixed-case. const teamKey = identifier.split('-', 1)[0]?.toUpperCase(); diff --git a/cdk/src/handlers/shared/linear-oauth-resolver.ts b/cdk/src/handlers/shared/linear-oauth-resolver.ts index 48cc6f895..4efb0fdf8 100644 --- a/cdk/src/handlers/shared/linear-oauth-resolver.ts +++ b/cdk/src/handlers/shared/linear-oauth-resolver.ts @@ -23,7 +23,7 @@ import { PutSecretValueCommand, SecretsManagerClient, } from '@aws-sdk/client-secrets-manager'; -import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; +import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import { logger } from './logger'; /** @@ -64,6 +64,13 @@ export interface RegistryRow { readonly workspace_slug: string; readonly oauth_secret_arn: string; readonly status: RegistryRowStatus; + /** + * When the CURRENT authorization was installed. Rewritten by every + * (re-)authorization, which is what makes it usable as an installation + * identity: a diagnosis about one grant must not be applied to its successor. + * Optional — rows written before it was recorded have none. + */ + readonly installed_at?: string; } export interface StoredOauthToken { @@ -101,6 +108,13 @@ export interface ResolverOptions { readonly dynamoDbClient?: DynamoDBDocumentClient; /** Override fetch for token-endpoint refresh in tests. */ readonly fetchImpl?: typeof fetch; + /** + * Called once the authorization is known dead (the refresh token was rejected + * and no concurrent caller had rotated it). Injected rather than written + * inline so this module keeps doing one job — resolving a token — and callers + * with registry write access opt in. Must not throw; the caller wraps it. + */ + readonly onAuthorizationRevoked?: (linearWorkspaceId: string) => Promise<void>; } interface CacheEntry<T> { @@ -188,6 +202,17 @@ export async function resolveLinearOauthToken( // ─── Step 3: Refresh if expiring ───────────────────────────────── if (isTokenExpiring(token.expires_at)) { + // The revoked-marker is OPT-IN, not defaulted. + // + // Every Lambda that resolves a token holds READ-ONLY access to the registry + // table, and no stack grants it write. Defaulting the marker on therefore + // meant the write ran and failed AccessDenied on every revoked refresh, and + // the failure was swallowed — so the feature read as working while being + // permanently inert, which is worse than being visibly absent. + // + // A caller that genuinely holds registry write (or supplies its own recorder) + // passes ``onAuthorizationRevoked`` explicitly. When the grant lands, flip the + // default here in the same change — not before. const refreshed = await refreshLinearToken(token, sm, row.oauth_secret_arn, options); if (!refreshed) { // Refresh failed — return null so the caller can fall back to @@ -222,6 +247,75 @@ export async function resolveLinearOauthToken( * task). Mixing the two contracts in one function silently fails open; * splitting them keeps each call site honest. */ +/** + * Mark a workspace's registry row as ``revoked``, so the dead authorization is + * discoverable instead of living only in a log line. The resolver already + * refuses a non-active row, so this also stops the pointless + * refresh-then-fail work on every subsequent event. + * + * NOT YET EFFECTIVE IN PRODUCTION: every Lambda that resolves a token currently + * has READ-ONLY access to the registry table, so this write fails AccessDenied + * and is swallowed (deliberately — recording the diagnosis must never break token + * resolution). Granting the write is deferred; until then the operator-facing + * signal is the indeterminate state from `bgagent platform doctor`, which reports + * that the workspace could not be confirmed rather than claiming it is fine. + * Tracked in the backlog under the Linear auth-revocation item. + * + * Scoped to the installation it actually diagnosed. ``status = active`` alone is + * not enough: a re-authorization writes ``active`` again, so a straggler holding + * the OLD token — a queued event, a retry, another Lambda mid-flight — would find + * the condition satisfied and revoke the working grant the operator had just + * installed, taking the workspace down again with a stale verdict. Conditioning + * on ``installed_at`` (rewritten by every re-authorization) makes the write apply + * only while the row still describes the same installation. ``expectedInstalledAt`` + * is passed by the caller rather than re-read here, because a re-read would race + * the same way. + * + * When the caller has no ``installed_at`` to name (a row written before it was + * recorded), the write falls back to requiring the attribute to still be absent — + * so a re-authorization, which adds it, likewise takes the row out of scope. + */ +export async function markWorkspaceRevoked( + ddb: DynamoDBDocumentClient, + tableName: string, + linearWorkspaceId: string, + expectedInstalledAt?: string, + now: string = new Date().toISOString(), +): Promise<void> { + try { + await ddb.send(new UpdateCommand({ + TableName: tableName, + Key: { linear_workspace_id: linearWorkspaceId }, + UpdateExpression: 'SET #s = :revoked, revoked_at = :now, revoked_reason = :reason', + ConditionExpression: expectedInstalledAt === undefined + ? '#s = :active AND attribute_not_exists(installed_at)' + : '#s = :active AND installed_at = :installed', + ExpressionAttributeNames: { '#s': 'status' }, + ExpressionAttributeValues: { + ':revoked': 'revoked', + ':active': 'active', + ':now': now, + ':reason': 'refresh_token_rejected', + ...(expectedInstalledAt !== undefined && { ':installed': expectedInstalledAt }), + }, + })); + logger.warn('Marked Linear workspace as revoked — re-authorization required', { + linear_workspace_id: linearWorkspaceId, + }); + } catch (err) { + if ((err as { name?: string })?.name === 'ConditionalCheckFailedException') { + // Already marked, or re-authorized since this diagnosis was made — either + // way the verdict no longer describes the row, so leave it alone. + logger.info('Skipped the revoked marker — the registry row is no longer the installation diagnosed', { + linear_workspace_id: linearWorkspaceId, + }); + return; + } + throw err; + } + registryCache.delete(linearWorkspaceId); +} + export async function getRegistryRowStrict( ddb: DynamoDBDocumentClient, tableName: string, @@ -300,6 +394,7 @@ function parseRegistryRow(rawItem: unknown, linearWorkspaceId: string): Registry workspace_slug: item.workspace_slug, oauth_secret_arn: item.oauth_secret_arn, status, + ...(typeof item.installed_at === 'string' && { installed_at: item.installed_at }), }; registryCache.set(linearWorkspaceId, { value: row, expiresAt: Date.now() + REGISTRY_CACHE_TTL_MS }); return row; @@ -439,6 +534,24 @@ async function refreshLinearToken( secret_arn: secretArn, workspace_id: current.workspace_id, }); + // RECORD the verdict, don't just log it. This is the only moment the + // platform knows the authorization is dead: from here on every event for + // this workspace is dropped, and without a durable marker the sole evidence + // is this log line — so an operator sees their trigger label do nothing and + // has no way to find out why (live-caught 2026-07-25, silent for over an + // hour). Marking the registry row makes `bgagent platform doctor` able to + // report it and name the remedy. Best-effort: a failed write must not turn a + // feedback outage into a thrown handler. + if (options.onAuthorizationRevoked) { + try { + await options.onAuthorizationRevoked(current.workspace_id); + } catch (err) { + logger.warn('Could not mark the Linear workspace as revoked (non-fatal)', { + workspace_id: current.workspace_id, + error: err instanceof Error ? err.message : String(err), + }); + } + } invalidateLinearOauthCache(current.workspace_id, secretArn); return null; } diff --git a/cdk/src/handlers/shared/linear-subissue-fetch.ts b/cdk/src/handlers/shared/linear-subissue-fetch.ts new file mode 100644 index 000000000..62ec27c01 --- /dev/null +++ b/cdk/src/handlers/shared/linear-subissue-fetch.ts @@ -0,0 +1,331 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Fetch a Linear parent issue's sub-issue dependency graph, as authored + * by a human in the tracker. Reads ``children`` (sub-issues) and, per + * child, its ``inverseRelations`` of type ``blocks`` (what blocks it) to + * build ``depends_on`` edges, then hands the result to + * ``orchestration-dag.ts::validateDag``. + * + * Direct GraphQL against Linear, Bearer-authenticated with the + * per-workspace OAuth token resolved by ``resolveLinearOauthToken``. + * Mirrors the request shape proven in ``linear-feedback.ts``. + * + * Unlike the best-effort feedback path, discovery is load-bearing: a + * fetch failure must be distinguishable from "this issue genuinely has + * no sub-issues" so the caller (the webhook processor) can decide + * whether to fall back to a single task or surface an error. Hence the + * discriminated ``FetchSubIssueGraphResult`` rather than a bare array. + */ + +import { logger } from './logger'; +import type { DagNode } from './orchestration-dag'; + +const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql'; + +const REQUEST_TIMEOUT_MS = 8000; + +/** Linear `IssueRelation.type` value meaning "source blocks target". */ +const RELATION_TYPE_BLOCKS = 'blocks'; + +/** + * Page size for the children / relations connections. + * + * This query fetches a SINGLE page (no cursor loop), and nothing upstream caps + * the size of a human-authored sub-issue graph. So an over-size epic used to be + * SILENTLY truncated to the first 100 children AND the dropped children's + * dependency edges were filtered out, leaving the survivors to release out of + * order. Rather than truncate, we detect ``hasNextPage`` on either connection + * and return an explicit ``error`` (fail loud) so the operator splits the epic + * instead of getting a wrong-order run. 100 is the ceiling for a single epic; a + * genuinely larger workload should be several epics. + */ +const CONNECTION_PAGE_SIZE = 100; + +/** + * GraphQL: fetch a parent issue's children and each child's blockers. + * + * For child C, ``inverseRelations`` of type ``blocks`` are relations + * whose *source* issue blocks C — i.e. C's predecessors. We take the + * related issue id from each as a ``depends_on`` edge. ``pageInfo.hasNextPage`` + * on both connections lets us detect (and reject) a truncated over-size graph. + */ +const SUB_ISSUE_GRAPH_QUERY = ` +query SubIssueGraph($issueId: String!, $first: Int!) { + issue(id: $issueId) { + id + identifier + children(first: $first) { + pageInfo { hasNextPage } + nodes { + id + identifier + title + description + inverseRelations(first: $first) { + pageInfo { hasNextPage } + nodes { + type + issue { id } + } + } + } + } + } +} +`.trim(); + +/** One sub-issue plus the metadata the orchestration row needs. */ +export interface SubIssueNode extends DagNode { + /** Linear sub-issue UUID (same as ``id``). */ + readonly id: string; + /** Human-readable identifier (e.g. ``ENG-42``) for comments/logs. */ + readonly identifier?: string; + /** Sub-issue title for the task description. */ + readonly title?: string; + /** + * Sub-issue scope/description. A planner writes a rich + * per-piece scope — often naming a concrete deliverable (a file, a route) — + * and that scope is what the reviewer approves. It must reach the coding + * agent so it builds what the plan promised (e.g. the exact filename), not a + * title-only guess. Populated from the plan when a planner seeded the graph; + * absent when an existing sub-issue graph is fetched by title only. + */ + readonly description?: string; + /** Sub-issue ids that block this one (intra-epic predecessors). */ + readonly depends_on: readonly string[]; +} + +export type FetchSubIssueGraphResult = + | { readonly kind: 'ok'; readonly parentIssueId: string; readonly children: readonly SubIssueNode[] } + | { readonly kind: 'no_children'; readonly parentIssueId: string } + | { readonly kind: 'error'; readonly message: string }; + +interface RawRelationNode { + readonly type?: string; + readonly issue?: { readonly id?: string } | null; +} + +interface RawPageInfo { + readonly hasNextPage?: boolean; +} + +interface RawChildNode { + readonly id?: string; + readonly identifier?: string; + readonly title?: string; + readonly description?: string; + readonly inverseRelations?: { readonly pageInfo?: RawPageInfo; readonly nodes?: readonly RawRelationNode[] } | null; +} + +interface RawSubIssueGraph { + readonly data?: { + readonly issue?: { + readonly id?: string; + readonly children?: { readonly pageInfo?: RawPageInfo; readonly nodes?: readonly RawChildNode[] } | null; + } | null; + }; + readonly errors?: unknown; +} + +export interface FetchSubIssueGraphOptions { + /** Override fetch for tests. */ + readonly fetchImpl?: typeof fetch; +} + +/** + * Fetch + shape a parent issue's sub-issue dependency graph. + * + * Returns: + * - ``ok`` — at least one child; ``children`` carry ``depends_on`` + * edges restricted to siblings within this child set (edges pointing + * outside the set are dropped here and surface as a dangling-edge + * rejection only if the caller chooses to keep them; we keep them so + * ``validateDag`` can flag a genuinely malformed graph). + * - ``no_children`` — the issue exists but has no sub-issues (caller + * falls back to a single task). + * - ``error`` — network / auth / GraphQL failure (caller surfaces + * a retryable error; does NOT silently treat as "no children"). + * + * Never throws. + */ +export async function fetchSubIssueGraph( + accessToken: string, + parentIssueId: string, + options: FetchSubIssueGraphOptions = {}, +): Promise<FetchSubIssueGraphResult> { + const fetchImpl = options.fetchImpl ?? fetch; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + let raw: RawSubIssueGraph; + try { + const resp = await fetchImpl(LINEAR_GRAPHQL_URL, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: SUB_ISSUE_GRAPH_QUERY, + variables: { issueId: parentIssueId, first: CONNECTION_PAGE_SIZE }, + }), + signal: controller.signal, + }); + if (!resp.ok) { + logger.warn('Linear sub-issue fetch non-2xx', { status: resp.status, parent_issue_id: parentIssueId }); + return { kind: 'error', message: `Linear API returned status ${resp.status}.` }; + } + raw = (await resp.json()) as RawSubIssueGraph; + } catch (err) { + logger.warn('Linear sub-issue fetch failed', { + parent_issue_id: parentIssueId, + error: err instanceof Error ? err.message : String(err), + }); + return { kind: 'error', message: 'Could not reach the Linear API to read sub-issues.' }; + } finally { + clearTimeout(timer); + } + + if (raw.errors) { + logger.warn('Linear sub-issue fetch GraphQL errors', { parent_issue_id: parentIssueId, errors: raw.errors }); + return { kind: 'error', message: 'Linear API reported an error reading sub-issues.' }; + } + + const issue = raw.data?.issue; + if (!issue || !issue.id) { + return { kind: 'error', message: 'Linear issue not found or not accessible with the workspace token.' }; + } + + const childNodes = issue.children?.nodes ?? []; + if (childNodes.length === 0) { + return { kind: 'no_children', parentIssueId: issue.id }; + } + + // Fail LOUD on truncation instead of silently dropping children (and + // their dependency edges). If Linear reports more children than + // one page holds, or any child has more blockers than one page holds, we can't + // build a correct DAG from a single fetch — reject so the operator splits the + // epic rather than getting a silently-wrong-order run. + if (issue.children?.pageInfo?.hasNextPage) { + logger.warn('Linear sub-issue fetch truncated — parent has more children than one page', { + parent_issue_id: issue.id, page_size: CONNECTION_PAGE_SIZE, + }); + return { + kind: 'error', + message: `This epic has more than ${CONNECTION_PAGE_SIZE} sub-issues — too many for a single ` + + `orchestration. Split it into multiple epics of at most ${CONNECTION_PAGE_SIZE} sub-issues each.`, + }; + } + const truncatedBlockersChild = childNodes.find((c) => c.inverseRelations?.pageInfo?.hasNextPage); + if (truncatedBlockersChild) { + logger.warn('Linear sub-issue fetch truncated — a child has more blockers than one page', { + parent_issue_id: issue.id, child_id: truncatedBlockersChild.id, page_size: CONNECTION_PAGE_SIZE, + }); + return { + kind: 'error', + message: `A sub-issue has more than ${CONNECTION_PAGE_SIZE} blocking relations — too many to order ` + + `reliably. Reduce the cross-dependencies on sub-issue ${truncatedBlockersChild.identifier ?? truncatedBlockersChild.id}.`, + }; + } + + // Restrict depends_on edges to ids that are themselves children of + // this parent — a "blocks" relation pointing at an issue outside the + // epic is not an intra-epic ordering constraint. (validateDag also + // guards dangling edges, but filtering here keeps the persisted graph + // clean and the dangling check meaningful for genuinely malformed + // intra-epic references only.) + const childIds = new Set( + childNodes.map((c) => c.id).filter((id): id is string => typeof id === 'string'), + ); + + const children: SubIssueNode[] = []; + for (const c of childNodes) { + if (!c.id) continue; + const blockers = (c.inverseRelations?.nodes ?? []) + .filter((r) => r.type === RELATION_TYPE_BLOCKS) + .map((r) => r.issue?.id) + .filter((id): id is string => typeof id === 'string' && id !== c.id && childIds.has(id)); + children.push({ + id: c.id, + ...(c.identifier !== undefined && { identifier: c.identifier }), + ...(c.title !== undefined && { title: c.title }), + // A human-authored sub-issue often carries its OWN scope in the + // description (a checklist, an acceptance criterion). This used to fetch + // title-only, so the child agent built from a title guess. Carry the + // description so buildChildDescription hands it to the coding agent + // (matching a planner-written scope, which already populates this). + ...(typeof c.description === 'string' && c.description.trim() !== '' && { description: c.description }), + // Dedup edges (Linear can surface a relation from both directions). + depends_on: [...new Set(blockers)], + }); + } + + return { kind: 'ok', parentIssueId: issue.id, children }; +} + +/** GraphQL: an issue's parent id (for the comment trigger — sub-issue → parent). */ +const ISSUE_PARENT_QUERY = ` +query IssueParent($issueId: String!) { + issue(id: $issueId) { id parent { id } } +}`; + +/** + * Fetch a sub-issue's parent issue id, for the comment trigger. A Linear + * comment names the issue it is on (the sub-issue); to find its orchestration + * we need the PARENT (orchestration_id is derived from the parent). Returns the + * parent id, or null when the issue has no parent (a top-level issue — not part + * of any orchestration) or on any fetch/auth/GraphQL failure. Never throws. + */ +export async function fetchIssueParentId( + accessToken: string, + issueId: string, + options: FetchSubIssueGraphOptions = {}, +): Promise<string | null> { + const fetchImpl = options.fetchImpl ?? fetch; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const resp = await fetchImpl(LINEAR_GRAPHQL_URL, { + method: 'POST', + headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: ISSUE_PARENT_QUERY, variables: { issueId } }), + signal: controller.signal, + }); + if (!resp.ok) { + logger.warn('Linear issue-parent fetch non-2xx', { status: resp.status, issue_id: issueId }); + return null; + } + const raw = (await resp.json()) as { data?: { issue?: { parent?: { id?: string } } }; errors?: unknown }; + if (raw.errors) { + logger.warn('Linear issue-parent fetch GraphQL errors', { issue_id: issueId, errors: raw.errors }); + return null; + } + return raw.data?.issue?.parent?.id ?? null; + } catch (err) { + logger.warn('Linear issue-parent fetch failed', { + issue_id: issueId, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } finally { + clearTimeout(timer); + } +} diff --git a/cdk/src/handlers/shared/orchestration-base-branch.ts b/cdk/src/handlers/shared/orchestration-base-branch.ts new file mode 100644 index 000000000..fd65a1a91 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-base-branch.ts @@ -0,0 +1,93 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Pure base-branch selection for stacked child PRs. + * + * A released child must SEE its predecessors' code without waiting for a + * human merge. A git branch has exactly one base, so: + * - 0 predecessors (root) → branch off the repo default branch (main). + * - 1 predecessor (linear) → stack: base = that predecessor's branch + * (a true stacked PR; the child's diff shows only its own changes). + * - N predecessors (diamond) → branch off main and MERGE all + * predecessor branches into the child's branch before work starts, + * so the child sees every predecessor's code. (No human merge needed; + * starts as soon as all predecessors are task-complete.) + * + * Pure: takes the predecessors' resolved branch names + the repo default + * branch, returns the base + merge-list the release path threads to the + * agent. No I/O, so the diamond/linear/root branching is unit-testable in + * isolation. + */ + +/** A predecessor whose branch the child may stack on / merge in. */ +export interface PredecessorBranch { + readonly sub_issue_id: string; + /** The predecessor task's current head branch (persisted branch_name). */ + readonly branch_name: string; +} + +export interface BaseBranchSelection { + /** Branch the child is cut from (and its PR targets). */ + readonly base_branch: string; + /** + * Predecessor branches to merge into the child's branch before work + * (multi-predecessor only). Empty for root + linear children. + */ + readonly merge_branches: readonly string[]; + /** Shape, for logging/observability. */ + readonly shape: 'root' | 'linear' | 'diamond'; +} + +export interface SelectBaseBranchParams { + /** Predecessors of the child being released (already terminal-success). */ + readonly predecessors: readonly PredecessorBranch[]; + /** Repo default branch (root base / diamond base). Defaults to 'main'. */ + readonly defaultBranch?: string; +} + +/** + * Choose a child's base branch + any predecessor branches to merge in. + * + * Predecessors missing a usable ``branch_name`` are dropped from the + * merge/stack decision (they can't be stacked on); if that leaves a + * single-predecessor child with no branch, it degrades to a root-style + * branch off main rather than producing an invalid base. + */ +export function selectBaseBranch(params: SelectBaseBranchParams): BaseBranchSelection { + const defaultBranch = params.defaultBranch ?? 'main'; + // Dedup BEFORE the count check: two predecessors resolving to the same + // branch are one stack target, not a diamond — stack cleanly rather + // than needlessly branching off main to "merge" a single branch. + const branches = [...new Set( + params.predecessors + .map((p) => p.branch_name) + .filter((b): b is string => typeof b === 'string' && b.length > 0), + )].sort(); + + if (branches.length === 0) { + return { base_branch: defaultBranch, merge_branches: [], shape: 'root' }; + } + if (branches.length === 1) { + return { base_branch: branches[0], merge_branches: [], shape: 'linear' }; + } + // Diamond: branch off the default branch, merge every distinct + // predecessor branch in (already deduped + sorted above). + return { base_branch: defaultBranch, merge_branches: branches, shape: 'diamond' }; +} diff --git a/cdk/src/handlers/shared/orchestration-channel-factory.ts b/cdk/src/handlers/shared/orchestration-channel-factory.ts new file mode 100644 index 000000000..3bd53a796 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-channel-factory.ts @@ -0,0 +1,147 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Registry of surface adapters, and the lookup that picks one at runtime. + * + * Event-driven paths (the reconciler, the stranded-orchestration sweep) act on an + * orchestration they LOAD rather than one they were triggered from, so they can't + * know the surface at build time — it's a property of the stored row. This maps + * that stored value to an adapter, so the engine picks a surface from data instead + * of hardcoding one. + * + * A REGISTRY rather than a switch, deliberately: a closed ``switch`` (or a closed + * ``ChannelKind`` union) means adding a tracker requires editing this file, so + * every consumer would have to merge a core change to support one more surface — + * the thing the extensibility tenet exists to prevent. Instead each adapter + * declares how to build itself, and a surface can be registered from outside this + * module entirely via {@link registerChannelFactory}. + * + * What that does and does NOT buy, stated plainly so the claim isn't read too + * broadly: a downstream surface needs no change to the engine or to this lookup, + * and resolves an adapter here from any string. It still needs its credentials + * registry named in each handler that will act on it (see + * {@link ChannelRegistryTables}) — a deployment decision about which tenants' + * secrets a Lambda may read, so deliberately explicit — and ``ChannelSource`` in + * ``types.ts`` remains a closed union shared with task records and the CLI, so a + * downstream surface can drive feedback before it can be written into a task + * record. The in-tree adapters are imported here only to seed the common cases; + * nothing about the mechanism requires it. + * + * A surface whose credentials registry isn't configured for the caller, or which + * has no registered adapter, yields ``undefined``: the caller skips feedback for + * that orchestration rather than guessing at another surface's adapter (which + * would address the wrong tenant, or fail every call). Entry points that are + * surface-specific by definition — a Linear webhook processor only ever handles + * Linear — should keep building their adapter directly; this exists for the paths + * that genuinely can't know. + */ + +import { logger } from './logger'; +import { type Channel } from './orchestration-channel'; +import { makeJiraChannel } from './orchestration-channel-jira'; +import { makeLinearChannel } from './orchestration-channel-linear'; +import { makeSlackChannel } from './orchestration-channel-slack'; + +/** + * Per-surface credentials-registry table names, keyed by channel source. An + * absent (or empty) entry means that surface isn't wired for this caller, so no + * adapter is built for it. + * + * An open map rather than named fields, for the same reason the registry itself + * is open: a new surface adds a key, not a core type change. + */ +export type ChannelRegistryTables = Readonly<Record<string, string | undefined>>; + +/** + * How to build one surface's adapter from its credentials-registry table name. + * Returning a {@link Channel} is the whole contract — everything else about the + * surface (auth, comment format, reaction vocabulary, dependency model) stays + * inside the adapter. + */ +export type ChannelFactory = (registryTableName: string) => Channel; + +/** + * The surface a stored row with NO recorded channel is treated as. Rows seeded + * before ``channel_source`` existed carry none, and Linear was the only surface + * that could have seeded them — so defaulting keeps their feedback working + * rather than silencing it. Not a statement that Linear is privileged. + */ +export const LEGACY_DEFAULT_CHANNEL_SOURCE = 'linear'; + +/** + * Registered adapters, keyed by the ``channel_source`` value that selects them. + * Mutable so a surface can register from its own module; seeded here with the + * adapters that ship in-tree. + */ +const registry = new Map<string, ChannelFactory>([ + ['linear', makeLinearChannel], + ['jira', makeJiraChannel], + // Slack is a chat surface, not a tracker: it omits the workflow-state ops + // entirely, which is what proves the engine's capability guards work against a + // genuinely different shape of surface rather than only in principle. + ['slack', makeSlackChannel], +]); + +/** + * Register (or replace) the adapter for a channel source. Lets a surface live + * entirely in its own module — including one added downstream — without editing + * the lookup below. Returns a function that restores the previous registration, + * so a test can register a fake surface without leaking into other tests. + */ +export function registerChannelFactory(source: string, factory: ChannelFactory): () => void { + const previous = registry.get(source); + registry.set(source, factory); + return () => { + if (previous) registry.set(source, previous); + else registry.delete(source); + }; +} + +/** Channel sources that currently have a registered adapter (for diagnostics). */ +export function registeredChannelSources(): readonly string[] { + return [...registry.keys()].sort(); +} + +/** + * Build the adapter for ``source`` — the orchestration row's ``channel_source``. + * + * Returns undefined when the surface has no registered adapter (e.g. a trigger + * channel with no issue-tracking surface at all, like an API or chat submission) + * or when its credentials registry isn't configured for this caller. + */ +export function channelForSource( + source: string | undefined, + tables: ChannelRegistryTables, +): Channel | undefined { + const key = source ?? LEGACY_DEFAULT_CHANNEL_SOURCE; + const factory = registry.get(key); + if (!factory) return undefined; + const registryTableName = tables[key]; + if (!registryTableName) { + // Distinguishable from "no such surface": the surface IS supported, this + // caller just can't reach its credentials — which is why its feedback goes + // silent, and worth a breadcrumb rather than an indistinguishable skip. + logger.warn('No credentials registry configured for this channel — skipping its feedback', { + channel_source: key, + }); + return undefined; + } + return factory(registryTableName); +} diff --git a/cdk/src/handlers/shared/orchestration-channel-jira.ts b/cdk/src/handlers/shared/orchestration-channel-jira.ts new file mode 100644 index 000000000..2103f0881 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-channel-jira.ts @@ -0,0 +1,90 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Jira implementation of the surface-agnostic {@link Channel}. It maps the + * channel-neutral feedback operations onto the existing Jira comment-back helpers + * (`postIssueComment` / `reportIssueFailure`, which render the body as an Atlassian + * Document Format doc under the hood). + * + * This adapter demonstrates the capability-awareness of the interface: Jira's + * feedback surface is comment-only, so the adapter implements the REQUIRED methods + * (`postComment`, `upsertComment`, `reportFailure`) and OMITS the optional ones + * the surface can't do today — there's no reaction API, no workflow-state + * transition wired here, and the sub-issue DAG isn't derived from Jira. The + * orchestration engine checks for those methods and no-ops gracefully when a + * surface omits them, so the same engine drives Jira without any Jira-specific + * branching in the core. + * + * ``credentialsRef`` on an {@link IssueRef} is the Atlassian tenant id (``cloudId``) + * that keys the Jira token registry. + */ + +import { + postIssueComment, + reportIssueFailure, + type JiraFeedbackContext, +} from './jira-feedback'; +import { type Channel, type IssueRef } from './orchestration-channel'; + +/** + * Build a Jira {@link Channel}. ``registryTableName`` is the Jira workspace + * registry the token resolver reads; an {@link IssueRef}'s ``credentialsRef`` is + * the ``cloudId`` that keys it, and ``issueId`` is the Jira issue id-or-key. + */ +export function makeJiraChannel(registryTableName: string): Channel { + const ctxFor = (issue: IssueRef): JiraFeedbackContext => ({ + cloudId: issue.credentialsRef, + registryTableName, + }); + + return { + kind: 'jira', + + async postComment(issue, body) { + const ok = await postIssueComment(ctxFor(issue), issue.issueId, body); + // The Jira helper doesn't return the new comment id, so an edit-in-place + // isn't possible yet (see upsertComment); report success/failure only. + return ok ? { commentId: '' } : null; + }, + + async upsertComment(issue, body) { + // Jira has no comment-update helper wired today, so a repeated "upsert" + // posts a fresh comment rather than editing in place. Behaviourally safe + // (the reviewer sees the latest state); a true edit-in-place needs a Jira + // update-comment call and a returned comment id — tracked as a follow-up. + const ok = await postIssueComment(ctxFor(issue), issue.issueId, body); + return ok ? { commentId: '' } : null; + }, + + async reportFailure(issue, message) { + await reportIssueFailure(ctxFor(issue), issue.issueId, message); + }, + + // Every optional capability is intentionally omitted — Jira's wired feedback + // surface is comment-only today: no reaction API (reactToComment, + // replaceCommentReaction, replaceIssueReaction), no workflow transition + // (transitionState, revertState), no threaded-reply helper + // (postThreadedReply, upsertThreadedReply), no note sweep (sweepNotes), and + // the sub-issue DAG isn't derived from Jira (fetchChildGraph). The engine + // checks for each method and skips it, so the same orchestration core drives + // Jira without Jira-specific branching. Implementing one here is additive: + // no engine change is needed to pick it up. + }; +} diff --git a/cdk/src/handlers/shared/orchestration-channel-linear.ts b/cdk/src/handlers/shared/orchestration-channel-linear.ts new file mode 100644 index 000000000..693caee92 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-channel-linear.ts @@ -0,0 +1,190 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Linear implementation of the surface-agnostic {@link Channel}. A thin adapter: + * it maps the channel-neutral operations onto the existing Linear feedback + + * sub-issue-graph helpers, so the orchestration engine gets exactly today's + * Linear behavior through the abstraction — no behavior change, just an indirection + * the engine can also satisfy for other surfaces. + * + * Surface-specific choices made here (kept OUT of the engine): the reaction-emoji + * mapping, the markdown comment format (Linear-native), and reading the sub-issue + * DAG from Linear's `blocks` relations. + */ + +import { + EMOJI_FAILURE, + EMOJI_NEEDS_INPUT, + EMOJI_STARTED, + EMOJI_SUCCESS, + postIssueComment, + reactToComment as addCommentReaction, + replyToComment, + reportIssueFailure, + revertIssueToNotStarted, + swapCommentReaction, + swapIssueReaction, + sweepTransientNotes, + transitionIssueState, + upsertStatusComment, + upsertThreadedReply as upsertLinearThreadedReply, + type LinearFeedbackContext, +} from './linear-feedback'; +import { resolveLinearOauthToken } from './linear-oauth-resolver'; +import { fetchSubIssueGraph } from './linear-subissue-fetch'; +import { logger } from './logger'; +import { + type Channel, + type ChannelSubIssueNode, + type IssueRef, + type Reaction, + type StateIntent, +} from './orchestration-channel'; + +/** Map the engine's small reaction vocabulary to Linear's emoji names. This is + * the one place the surface's reaction set is known. */ +const REACTION_EMOJI: Record<Reaction, string> = { + started: EMOJI_STARTED, + succeeded: EMOJI_SUCCESS, + failed: EMOJI_FAILURE, + needs_input: EMOJI_NEEDS_INPUT, +}; + +/** + * Map the engine's state intent onto how Linear is asked for that state: the + * semantic state TYPE plus the state NAMES to prefer within it. + * + * The name preference is load-bearing, not decoration. Linear models both "In + * Progress" and "In Review" as type ``started``, so type alone cannot say which + * one the engine meant — without the name the transition would resolve to + * whichever the team happens to order first and the two intents would collapse + * into the same move. Teams that lack the preferred name still resolve to a + * sensible state of the right type. + */ +const STATE_TARGET: Record<StateIntent, { type: 'started' | 'completed'; prefer: readonly string[] }> = { + started: { type: 'started', prefer: ['In Progress'] }, + in_review: { type: 'started', prefer: ['In Review'] }, + completed: { type: 'completed', prefer: [] }, +}; + +/** + * Build a Linear {@link Channel}. ``registryTableName`` is the workspace-registry + * table the token resolver reads; an {@link IssueRef}'s ``credentialsRef`` is the + * Linear workspace (organization) id that keys it. The feedback helpers each take + * a {@link LinearFeedbackContext} built from that pair. + */ +export function makeLinearChannel(registryTableName: string): Channel { + const ctxFor = (issue: IssueRef): LinearFeedbackContext => ({ + linearWorkspaceId: issue.credentialsRef, + registryTableName, + }); + + return { + kind: 'linear', + + async postComment(issue, body) { + const res = await postIssueComment(ctxFor(issue), issue.issueId, body); + // postIssueComment doesn't return the new comment id, so callers that need + // to edit later use upsertComment instead. Report success/failure only. + return res.ok ? { commentId: '' } : null; + }, + + async upsertComment(issue, body, existing) { + const id = await upsertStatusComment(ctxFor(issue), issue.issueId, body, existing?.commentId); + return id ? { commentId: id } : null; + }, + + async reportFailure(issue, message) { + await reportIssueFailure(ctxFor(issue), issue.issueId, message); + }, + + async reactToComment(comment, issue, reaction) { + // ADD-only (no delete of prior markers) — the instant receipt ack. + return addCommentReaction(ctxFor(issue), comment.commentId, REACTION_EMOJI[reaction]); + }, + + async replaceCommentReaction(comment, issue, reaction) { + return swapCommentReaction(ctxFor(issue), comment.commentId, REACTION_EMOJI[reaction]); + }, + + async replaceIssueReaction(issue, reaction) { + return swapIssueReaction(ctxFor(issue), issue.issueId, REACTION_EMOJI[reaction]); + }, + + async transitionState(issue, intent, options) { + const target = STATE_TARGET[intent]; + return transitionIssueState( + ctxFor(issue), + issue.issueId, + target.type, + target.prefer, + options?.allowRegression ?? false, + ); + }, + + async revertState(issue) { + return revertIssueToNotStarted(ctxFor(issue), issue.issueId); + }, + + async postThreadedReply(issue, parent, body) { + const id = await replyToComment(ctxFor(issue), issue.issueId, parent.commentId, body); + return id ? { commentId: id } : null; + }, + + async upsertThreadedReply(issue, parent, body, existing, options) { + const id = await upsertLinearThreadedReply( + ctxFor(issue), + issue.issueId, + parent.commentId, + body, + existing?.commentId, + { + preservePreview: options?.preservePreview ?? false, + skipIfSettled: options?.skipIfSettled ?? false, + repairIfOverwritten: options?.repairIfOverwritten ?? false, + }, + ); + return id ? { commentId: id } : null; + }, + + async sweepNotes(issue, keep) { + return sweepTransientNotes(ctxFor(issue), issue.issueId, keep?.commentId); + }, + + async fetchChildGraph(parent): Promise<readonly ChannelSubIssueNode[]> { + const resolved = await resolveLinearOauthToken(parent.credentialsRef, registryTableName); + if (!resolved?.accessToken) { + logger.warn('Linear channel: no token to fetch sub-issue graph', { + issue_id: parent.issueId, + }); + return []; + } + const graph = await fetchSubIssueGraph(resolved.accessToken, parent.issueId); + if (graph.kind !== 'ok') return []; + // Map Linear's SubIssueNode (blocks-derived depends_on) to the neutral shape. + return graph.children.map((n) => ({ + issueId: n.id, + ...(n.identifier !== undefined && { displayId: n.identifier }), + ...(n.title !== undefined && { title: n.title }), + dependsOn: n.depends_on, + })); + }, + }; +} diff --git a/cdk/src/handlers/shared/orchestration-channel-slack.ts b/cdk/src/handlers/shared/orchestration-channel-slack.ts new file mode 100644 index 000000000..436724cfd --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-channel-slack.ts @@ -0,0 +1,275 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Slack implementation of the surface-agnostic {@link Channel}. + * + * Slack is deliberately the SECOND-most-different surface available: it is a chat + * product, not an issue tracker, so it exercises the interface's capability + * gating for real rather than hypothetically. Two mappings carry the weight: + * + * - **A "thread" is the issue.** ``IssueRef.issueId`` is ``<channel>:<thread_ts>`` + * — the conversation an orchestration reports into. Slack has no issue object, + * so the thread is the durable thing a panel can live in. + * - **A message ts is the comment id.** ``CommentRef.commentId`` is a message + * ``ts``; ``chat.update`` edits it, which is what lets one status panel mature + * in place instead of streaming new messages. + * + * ``credentialsRef`` is the Slack ``team_id``, which keys the per-workspace bot + * token secret (``bgagent/slack/<team_id>``) — the same lookup the Slack event + * handlers use. + * + * DELIBERATELY OMITTED — Slack has no workflow state, so there is nothing for + * ``transitionState`` / ``revertState`` to move. They are absent rather than + * stubbed to a no-op, so the engine's capability guards skip them: a silent + * success would claim the platform mirrored a state it never did, and the panel + * is then the only place the epic's progress is visible. ``sweepNotes`` is also + * omitted (deleting other messages in a shared channel is a blunt instrument that + * needs its own product decision, not a quiet default), as is ``fetchChildGraph`` + * — Slack has no dependency model to read a DAG from, so a Slack-triggered + * orchestration supplies its graph declaratively. + */ + +import { logger } from './logger'; +import { + type Channel, + type IssueRef, + type Reaction, +} from './orchestration-channel'; +import { slackFetch, slackFetchTs } from './slack-api'; +import { getSlackSecret, SLACK_SECRET_PREFIX } from './slack-verify'; + +/** + * Map the engine's reaction vocabulary to Slack emoji names. The one place + * Slack's reaction set is known — the engine only ever names a {@link Reaction}. + */ +const REACTION_EMOJI: Record<Reaction, string> = { + started: 'eyes', + succeeded: 'white_check_mark', + failed: 'x', + needs_input: 'question', +}; + +/** Every emoji this adapter may have applied, so a "replace" clears its own + * prior markers without touching a human's reaction. */ +const OWN_EMOJI: readonly string[] = Object.values(REACTION_EMOJI); + +/** Separator between the channel id and thread ts inside an ``issueId``. A colon + * can't appear in either part, so the split is unambiguous. */ +const THREAD_REF_SEPARATOR = ':'; + +/** Build the ``issueId`` for a Slack thread. Exported so a Slack-side seeding + * path composes the ref the same way this adapter parses it. */ +export function slackThreadRef(channelId: string, threadTs: string): string { + return `${channelId}${THREAD_REF_SEPARATOR}${threadTs}`; +} + +/** Split an ``issueId`` back into its channel + thread ts. */ +function parseThreadRef(issueId: string): { channel: string; threadTs: string } | null { + const at = issueId.indexOf(THREAD_REF_SEPARATOR); + if (at <= 0 || at === issueId.length - 1) return null; + return { channel: issueId.slice(0, at), threadTs: issueId.slice(at + 1) }; +} + +/** + * Build a Slack {@link Channel}. ``secretPrefix`` is the Secrets Manager prefix + * the per-workspace bot tokens live under; an {@link IssueRef}'s + * ``credentialsRef`` is the ``team_id`` that completes it. + * + * Signature matches the other adapters (one string) so it registers as a + * {@link ChannelFactory} without special-casing. + */ +export function makeSlackChannel(secretPrefix: string = SLACK_SECRET_PREFIX): Channel { + /** Resolve the workspace bot token, or null when the workspace isn't installed. */ + const tokenFor = async (issue: IssueRef): Promise<string | null> => { + const token = await getSlackSecret(`${secretPrefix}${issue.credentialsRef}`); + if (!token) { + logger.warn('Slack channel: no bot token for workspace — skipping feedback', { + team_id: issue.credentialsRef, + }); + } + return token; + }; + + /** Resolve the token AND the thread the ref names; null if either is missing. */ + const contextFor = async (issue: IssueRef) => { + const thread = parseThreadRef(issue.issueId); + if (!thread) { + logger.warn('Slack channel: issue ref is not a <channel>:<thread_ts> pair', { + issue_id: issue.issueId, + }); + return null; + } + const token = await tokenFor(issue); + return token ? { token, ...thread } : null; + }; + + return { + kind: 'slack', + + async postComment(issue, body) { + const ctx = await contextFor(issue); + if (!ctx) return null; + const ts = await slackFetchTs(ctx.token, 'chat.postMessage', { + channel: ctx.channel, + thread_ts: ctx.threadTs, + text: body, + }); + return ts ? { commentId: ts } : null; + }, + + async upsertComment(issue, body, existing) { + const ctx = await contextFor(issue); + if (!ctx) return null; + if (existing?.commentId) { + // Edit in place — this is what makes the maturing panel one message + // rather than a stream. chat.update echoes the ts it edited. + const ts = await slackFetchTs(ctx.token, 'chat.update', { + channel: ctx.channel, + ts: existing.commentId, + text: body, + }); + return ts ? { commentId: ts } : null; + } + const ts = await slackFetchTs(ctx.token, 'chat.postMessage', { + channel: ctx.channel, + thread_ts: ctx.threadTs, + text: body, + }); + return ts ? { commentId: ts } : null; + }, + + async reportFailure(issue, message) { + const ctx = await contextFor(issue); + if (!ctx) return; + await slackFetch(ctx.token, 'chat.postMessage', { + channel: ctx.channel, + thread_ts: ctx.threadTs, + text: message, + }); + }, + + async reactToComment(comment, issue, reaction) { + // ADD-only — the instant receipt ack, which must not disturb any marker + // already present. + const ctx = await contextFor(issue); + if (!ctx) return false; + return slackFetch(ctx.token, 'reactions.add', { + channel: ctx.channel, + timestamp: comment.commentId, + name: REACTION_EMOJI[reaction], + }); + }, + + async replaceCommentReaction(comment, issue, reaction) { + const ctx = await contextFor(issue); + if (!ctx) return false; + // Slack has no atomic swap, so clear this adapter's OWN markers then add + // the target. Scoped to OWN_EMOJI so a human's reaction survives; a + // `no_reaction` error on a marker that isn't there is benign and treated + // as success by slackFetch. + const target = REACTION_EMOJI[reaction]; + const stale: string[] = []; + for (const emoji of OWN_EMOJI) { + if (emoji === target) continue; + const removed = await slackFetch(ctx.token, 'reactions.remove', { + channel: ctx.channel, + timestamp: comment.commentId, + name: emoji, + }); + if (!removed) stale.push(emoji); + } + const added = await slackFetch(ctx.token, 'reactions.add', { + channel: ctx.channel, + timestamp: comment.commentId, + name: target, + }); + if (stale.length > 0) { + // Reporting the add alone would claim the promised end state — ONE marker + // — while the message still carries a contradictory one ("saw it" beside + // "done"), and the contradiction is precisely what a caller checks this + // result to rule out. + logger.warn('Slack channel: a stale status marker could not be removed', { + event: 'slack_channel.reaction_replace_incomplete', + message_ts: comment.commentId, + target, + stale, + }); + } + return added && stale.length === 0; + }, + + async replaceIssueReaction(issue, reaction) { + // The "issue" is a thread, so its at-a-glance marker goes on the thread's + // root message — the closest equivalent to reacting to an issue. + const thread = parseThreadRef(issue.issueId); + if (!thread) return false; + return this.replaceCommentReaction!( + { commentId: thread.threadTs }, + issue, + reaction, + ); + }, + + async postThreadedReply(issue, parent, body) { + const ctx = await contextFor(issue); + if (!ctx) return null; + // Slack threads are one level deep: a reply goes to the thread the parent + // belongs to. Using the parent's own ts as thread_ts starts a thread on it + // when the parent is a root, and stays in-thread otherwise. + const ts = await slackFetchTs(ctx.token, 'chat.postMessage', { + channel: ctx.channel, + thread_ts: parent.commentId, + text: body, + }); + return ts ? { commentId: ts } : null; + }, + + // The convergence options (preview preservation, settle checks, outcome + // repair) are deliberately not implemented here: each needs a read of the + // current message text, and this adapter posts and edits without reading + // back. Ignoring them is what the interface specifies for a surface that + // can't, and it costs nothing today because no multi-writer maturing reply + // runs on Slack — the orchestration engine's late-progress race is between + // Lambdas that write to the issue surface. Anything wiring a maturing reply + // onto Slack needs to honour them first, via `conversations.replies`. + async upsertThreadedReply(issue, parent, body, existing) { + const ctx = await contextFor(issue); + if (!ctx) return null; + if (existing?.commentId) { + const ts = await slackFetchTs(ctx.token, 'chat.update', { + channel: ctx.channel, + ts: existing.commentId, + text: body, + }); + return ts ? { commentId: ts } : null; + } + return this.postThreadedReply!(issue, parent, body); + }, + + // transitionState / revertState: omitted — Slack has no workflow state. + // sweepNotes: omitted — bulk-deleting messages in a shared channel needs its + // own product decision. + // fetchChildGraph: omitted — no dependency model; graphs arrive declaratively. + } satisfies Channel as Channel; +} + +/** Re-exported so a caller can name the reaction set without importing Slack + * internals — used by the adapter's own tests. */ +export const SLACK_OWN_REACTION_EMOJI = OWN_EMOJI; diff --git a/cdk/src/handlers/shared/orchestration-channel.ts b/cdk/src/handlers/shared/orchestration-channel.ts new file mode 100644 index 000000000..4b924f781 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-channel.ts @@ -0,0 +1,271 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Surface-agnostic channel abstraction for sub-issue orchestration. + * + * The orchestration engine (discovery, release, reconcile, rollup) drives issue + * feedback — panel comments, reactions, state transitions, failure notices — and + * reads the sub-issue graph. Historically it called the Linear API directly, so + * the engine was Linear-only. This interface lets the engine speak to ANY + * issue-tracking surface (Linear today, Jira next — a Jira comment-back path + * already exists) without naming one: the engine holds a `Channel` and calls + * these methods; each surface ships an adapter that implements them. + * + * Design boundary — what stays surface-SPECIFIC (behind the adapter): + * - **Dependency/blocking relations.** Linear models a sub-issue DAG with native + * `blocks` relations; another surface may have no equivalent and derive the + * graph differently. So `fetchChildGraph` is the adapter's job — the engine + * only consumes the resulting DAG. + * - **Comment formatting.** One surface takes markdown, another a structured + * document format. The adapter renders; the engine passes a plain-text body. + * - **Reaction vocabulary.** Each surface has its own reaction set; the engine + * speaks the small {@link Reaction} enum and the adapter maps it. + * - **Auth.** Credentials live behind an opaque `credentialsRef` the adapter + * resolves; the engine never touches surface auth. + * + * Capability-awareness: not every surface supports every operation (e.g. a + * surface may have no reactions or no workflow-state transitions). Those methods + * are OPTIONAL; the engine checks for presence and no-ops gracefully when a + * surface can't do them, so the core stays uniform. + * + * Every method is best-effort and must not throw — feedback is advisory and must + * never gate the orchestration itself (mirrors the existing per-surface helpers). + */ + +/** + * Which surface an adapter talks to — for logging and metrics only. The engine + * never branches on it, which is why this is an open string rather than a union + * of the surfaces that happen to exist today: a closed union here would mean + * adding a surface requires editing this file, i.e. every consumer merging a + * core change to support one more tracker. Adapters live outside this module and + * name themselves. + */ +export type ChannelKind = string; + +/** + * A reference to an issue on some surface, plus the opaque credentials handle + * the adapter needs to act on it. The engine treats every field as opaque. + */ +export interface IssueRef { + /** The surface's issue identifier (Linear issue UUID, Jira issue key, …). */ + readonly issueId: string; + /** Opaque credentials handle the adapter resolves (e.g. a workspace id that + * keys an OAuth-token registry row). The engine never interprets it. */ + readonly credentialsRef: string; + /** Optional human-facing id for display in panels (e.g. ``ENG-42``). */ + readonly displayId?: string; +} + +/** A reference to a comment the adapter created, so it can be edited/reacted to. */ +export interface CommentRef { + readonly commentId: string; +} + +/** The small vocabulary of reactions the engine uses; the adapter maps each to + * the surface's own reaction (emoji, etc.). */ +export type Reaction = 'started' | 'succeeded' | 'failed' | 'needs_input'; + +/** + * Workflow-state intent the engine expresses; the adapter maps it to the + * surface's actual states. Three intents rather than two because the engine + * genuinely distinguishes "work is running" from "work is done, awaiting human + * review" — on some surfaces (Linear included) both are the same underlying + * state *category*, so collapsing them would make the engine unable to say + * which one it meant: + * - ``started`` ≈ In Progress — a child was released and is running. + * - ``in_review`` ≈ In Review — work finished, a human still has to merge it. + * - ``completed`` ≈ Done — the surface's terminal state. + */ +export type StateIntent = 'started' | 'in_review' | 'completed'; + +/** Options for {@link Channel.transitionState}. */ +export interface TransitionOptions { + /** + * Allow a move that the adapter would otherwise refuse as backward *within + * the same state category* — the deliberate re-open, when a settled epic + * gains a new or re-run child and must go from "awaiting review" back to + * "running". Without this the move is silently dropped as a regression. + * Demotion across categories (something already terminal, e.g. a human + * marked it done) stays blocked regardless. + */ + readonly allowRegression?: boolean; +} + +/** Options for {@link Channel.upsertThreadedReply}. */ +export interface ThreadedReplyOptions { + /** + * Carry over a preview/deploy link that a SEPARATE async writer may already + * have appended to this reply. Two writers converge on one comment (the + * orchestration settle and the preview-capture callback), and whichever + * lands second would otherwise clobber the other's text. When set, the + * adapter reads the current body and preserves that segment. + */ + readonly preservePreview?: boolean; + /** + * Set on a PROGRESS render to yield to an outcome that has already landed. + * Progress and terminal states are written by independent paths, and the + * progress one can be delivered late — overwriting a settled reply with + * "working" would contradict both the outcome and the surface's own markers. + * A surface that cannot read a body back simply ignores this. + */ + readonly skipIfSettled?: boolean; + /** + * Set on a TERMINAL render to check the outcome survived, and restore it if a + * concurrently-delivered progress render landed on top. + * + * The counterpart to {@link skipIfSettled}, and needed because that check is a + * read followed by a separate write: it narrows the window without closing it. + * A surface offering no conditional/versioned update (Linear does not) cannot + * close it at all, so the writer that holds the body worth keeping verifies + * afterwards instead. Adapters without a body read simply ignore this. + */ + readonly repairIfOverwritten?: boolean; +} + +/** A node in the sub-issue graph, as the adapter surfaces it to the engine. */ +export interface ChannelSubIssueNode { + readonly issueId: string; + readonly displayId?: string; + readonly title?: string; + /** issueIds this node depends on (blocked-by). How the adapter derives this + * is surface-specific (Linear: `blocks` relations); the engine just reads it. */ + readonly dependsOn: readonly string[]; +} + +/** + * A surface adapter. The orchestration engine holds one of these and never names + * a concrete surface. Implementations: {@link makeLinearChannel} today; a Jira + * adapter unifies the existing Jira comment-back onto the same interface. + */ +export interface Channel { + readonly kind: ChannelKind; + + // --- feedback (every surface must support these) --- + + /** Post a comment on an issue; returns a ref so it can be edited later, or + * null if the post failed (best-effort). ``body`` is plain text/markdown; the + * adapter renders it for the surface. */ + postComment(issue: IssueRef, body: string): Promise<CommentRef | null>; + + /** Edit a previously-posted comment in place (the maturing status panel). When + * no ref is given, create + return one. Returns the (new or existing) ref, or + * null on failure. */ + upsertComment(issue: IssueRef, body: string, existing?: CommentRef): Promise<CommentRef | null>; + + /** Post a failure notice on the issue (❌ + message). Best-effort. */ + reportFailure(issue: IssueRef, message: string): Promise<void>; + + // --- optional per-surface capabilities (engine no-ops if absent) --- + + /** + * ADD a reaction to a comment, leaving any existing reaction in place. This is + * the instant "I saw your request" ack, set the moment a comment arrives — + * before any work exists to report on, so there is nothing to replace yet. + * + * Deliberately distinct from {@link replaceCommentReaction}: adding is not the + * same as replacing, and conflating them would either strip a marker the ack + * path never meant to touch, or leave two contradictory markers on a settled + * comment. Returns true if the reaction landed. + */ + reactToComment?(comment: CommentRef, issue: IssueRef, reaction: Reaction): Promise<boolean>; + + /** + * Make ``reaction`` the SOLE bot reaction on a comment, clearing the bot's own + * prior markers first — used when work settles, so the comment shows one + * outcome rather than an accumulated pile ("saw it" + "done" at once). + * A human's reactions are never touched. Idempotent: re-running converges on + * the same single marker. + * + * Returns true only when that end state was actually reached: the target is + * present AND no prior bot marker was left behind. Reporting the add alone + * would claim success while the surface still shows two contradictory markers — + * and a caller checking this result is checking for exactly that contradiction, + * since it is what makes a settled item look unsettled. + */ + replaceCommentReaction?(comment: CommentRef, issue: IssueRef, reaction: Reaction): Promise<boolean>; + + /** + * Make ``reaction`` the sole bot reaction on the ISSUE itself (not a comment) — + * the at-a-glance status marker on a parent epic or a child, which matures + * across separate invocations. Same replace-only-our-own-markers contract and + * same all-or-nothing result as {@link replaceCommentReaction}. + */ + replaceIssueReaction?(issue: IssueRef, reaction: Reaction): Promise<boolean>; + + /** + * Move the issue to a workflow state. Optional — a surface without a + * transition API omits this. Adapters must refuse to move an issue BACKWARD + * (something a human or automation already advanced stays advanced), except + * where ``options.allowRegression`` opts into a same-category re-open. + * Returns true only on a confirmed transition (false when skipped as + * already-there or backward). + */ + transitionState?(issue: IssueRef, intent: StateIntent, options?: TransitionOptions): Promise<boolean>; + + /** + * Move the issue BACK to a not-started state. The one sanctioned backward + * move: work the engine had marked as running has stopped without succeeding + * (a plan now awaits a human's approval, or a child failed), so leaving it + * "in progress" would misreport a run that isn't happening. Adapters must + * guard this to only demote an issue still in the state the engine itself + * set, never one a human has since advanced or pulled back. Returns true only + * on a confirmed move. + */ + revertState?(issue: IssueRef): Promise<boolean>; + + /** + * Post a threaded reply beneath an existing comment. Unlike editing, a reply + * notifies and reads as a conversation turn under the original request. + * Returns a ref to the new reply, or null on failure. + */ + postThreadedReply?(issue: IssueRef, parent: CommentRef, body: string): Promise<CommentRef | null>; + + /** + * The MATURING threaded reply: edit ``existing`` in place when given, else + * create a reply under ``parent``. One reply that matures through its + * lifecycle, instead of a new comment per transition. Returns the (new or + * existing) ref, or null on failure. + */ + upsertThreadedReply?( + issue: IssueRef, + parent: CommentRef, + body: string, + existing?: CommentRef, + options?: ThreadedReplyOptions, + ): Promise<CommentRef | null>; + + /** + * Delete the engine's own transient planning notes from an issue thread, + * keeping ``keep`` if given. Interim notes are posted fire-and-forget from + * many places, so once a plan settles the thread needs collapsing to just the + * outcome. Adapters must scope deletion to the bot's own notes — never a + * human's comment, and never the durable status panel. Returns how many were + * removed. + */ + sweepNotes?(issue: IssueRef, keep?: CommentRef): Promise<number>; + + // --- graph (surface-specific derivation, uniform result) --- + + /** Read the sub-issue graph rooted at a parent, with dependency edges resolved + * however the surface expresses them. Optional — a surface with no native + * sub-issue/dependency model omits this and the engine falls back to a + * declarative graph source. */ + fetchChildGraph?(parent: IssueRef): Promise<readonly ChannelSubIssueNode[]>; +} diff --git a/cdk/src/handlers/shared/orchestration-comment-trigger.ts b/cdk/src/handlers/shared/orchestration-comment-trigger.ts new file mode 100644 index 000000000..8b2484f94 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-comment-trigger.ts @@ -0,0 +1,394 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Pure logic for the comment trigger that iterates on an existing PR. A + * reviewer who wants + * a sub-issue's PR changed mentions ``@bgagent`` in a Linear comment on that + * sub-issue; the platform runs a ``coding/pr-iteration-v1`` task on the + * sub-issue's PR (and the reconciler then cascades the re-stack to dependents). + * + * This module decides — from a comment body alone — whether the comment is an + * instruction for the agent and what the instruction text is. Kept pure (no + * I/O, no Linear/AWS types) so the mention parsing is unit-testable and reused + * regardless of how the comment arrives. The processor does the I/O (resolve + * sub-issue → orchestration → PR, spawn the task). + */ + +/** The mention token that turns a Linear comment into an agent instruction. */ +export const MENTION_TOKEN = '@bgagent'; + +export interface CommentTrigger { + /** True when the comment is an explicit instruction for the agent. */ + readonly triggered: boolean; + /** + * The instruction text with the mention token stripped, trimmed. Empty when + * not triggered, or when the mention had no accompanying text (the caller + * treats an empty instruction as "address the latest review" — still valid). + */ + readonly instruction: string; +} + +/** + * Decide whether a comment body is an ``@bgagent`` instruction, and extract + * the instruction text. + * + * Rules (deliberately strict to avoid false-positives on human discussion and, + * critically, on the agent's OWN progress comments which never contain the + * mention token): + * - Must contain ``@bgagent`` (case-insensitive), as a token boundary so + * ``@bgagentx`` / an email-like ``foo@bgagent.io`` do NOT trigger. + * - The instruction is everything after stripping the token (all occurrences), + * collapsed/trimmed. A bare ``@bgagent`` with no text still triggers + * (instruction === ''). + */ +export function parseCommentTrigger(body: string | undefined | null): CommentTrigger { + if (!body) return { triggered: false, instruction: '' }; + // SELF-COMMENT GUARD: the bot's OWN rendered comments must NEVER trigger it, + // or it talks to itself forever. This happened in practice — the + // disambiguation reply embedded a literal "@bgagent ENG-123: …" example, so + // the reply re-matched the mention and spawned another reply, ~50 deep. + // The agent's progress comments are also bot-authored. + // Cheapest robust signal that needs no actor-identity config: a body that + // STARTS WITH one of our own template markers is ours, not a user + // instruction. (Linear strips a leading emoji to its own line sometimes, so + // we test the trimmed start.) Keep this list in sync with the rendered + // comment prefixes (panel, acks, disambiguation, agent progress). + if (isBotAuthoredComment(body)) return { triggered: false, instruction: '' }; + // Token-boundary match: @bgagent not immediately followed by a word char or + // a '.' (so it won't fire on @bgagentbot or an @bgagent.io address). + const re = /@bgagent(?![\w.])/gi; + if (!re.test(body)) return { triggered: false, instruction: '' }; + const instruction = body.replace(/@bgagent(?![\w.])/gi, ' ').replace(/\s+/g, ' ').trim(); + return { triggered: true, instruction }; +} + +/** + * Markers that begin a comment the BOT itself rendered (panel, acks, + * disambiguation reply, agent progress). A comment starting with any of these + * is never a human instruction — used to break self-trigger loops. + */ +const BOT_COMMENT_PREFIXES = [ + '👋', // disambiguation "which sub-issue?" reply + '✅', // "✅ Updated — PR #…" ack / "✅ **ABCA orchestration complete**" panel + '❌', // failure reply + '⚠️', // "finished with failures" panel + '🔄', // in-progress panel + '🤖', // agent progress ("🤖 Starting…") + '🖼️', // preview screenshot comment + '🔗', // "PR opened" / combined-PR + '🗂️', // transient platform notes (may embed a literal "@bgagent …" instruction) + '💬', // maturing-reply "answered" state (a no-change/question iteration) + '👀', // instant "on it" ack reply (posted at trigger time) +] as const; + +/** True when ``body`` is one of the bot's own rendered comments (loop guard). */ +export function isBotAuthoredComment(body: string): boolean { + const trimmed = body.trimStart(); + return BOT_COMMENT_PREFIXES.some((p) => trimmed.startsWith(p)); +} + +/** + * Near-miss mention handles. A reviewer who + * addresses the bot by the WRONG handle (most often ``@abca`` — confusing the + * trigger LABEL for the mention handle — or a boundary-miss like ``@bgagentx``) + * previously fell into a silent black hole: {@link parseCommentTrigger} returned + * ``triggered: false`` and the webhook dropped the comment with no reply and no + * reaction, so the reviewer had no idea their instruction was never seen. + * + * This is a DELIBERATELY NARROW allowlist of handles that are clearly meant for + * THIS bot but aren't the exact ``@bgagent`` token — so the near-miss nudge never + * fires on a real teammate mention. Generic words (``@agent``/``@bot``) are + * intentionally EXCLUDED (they can be real usernames); only bot-specific + * near-misses qualify. Matching is done by {@link detectNearMissMention}. + */ +const NEAR_MISS_MENTION_PATTERNS: readonly RegExp[] = [ + // @abca (+ optional :suffix) — the label-name confusion. + /@abca\b/i, + // @bgagent immediately followed by a word char — a boundary-miss that + // parseCommentTrigger's `@bgagent(?![\w.])` deliberately does NOT trigger + // (@bgagentbot, @bgagentx). NOT `@bgagent ` (a space → real trigger) nor + // `@bgagent.` (an email-like foo@bgagent.io → not a mention). + /@bgagent\w/i, + // Hyphen/underscore variants. The separator is REQUIRED (not optional) so these + // match @bg-agent / @bg_agent but NOT the canonical @bgagent (which parses as a + // real trigger, not a near-miss) — an optional separator would wrongly flag it. + /@bg[-_]agent\b/i, + // @bgbot / @bg-bot / @bg_bot — a plausible shorthand. Distinct from @bgagent. + /@bg[-_]?bot\b/i, + // The spelled-out name — @backgroundagent / @background-agent. Distinct too. + /@background[-_]?agent\b/i, +]; + +/** + * Detect a NEAR-MISS bot mention: the reviewer clearly meant to + * address the bot but used the wrong handle (``@abca``, ``@bgagentx``, …), so + * {@link parseCommentTrigger} didn't fire. Returns true so the caller can nudge + * ("I answer to ``@bgagent``") instead of silently dropping the comment. + * + * Only consulted in the NOT-triggered branch (a real ``@bgagent`` never reaches + * here). Skips the bot's own comments (never nudge ourselves). Strict allowlist + * ({@link NEAR_MISS_MENTION_PATTERNS}) so it can't misfire on human discussion or + * a genuine teammate mention. + */ +export function detectNearMissMention(body: string | undefined | null): boolean { + if (!body) return false; + if (isBotAuthoredComment(body)) return false; + return NEAR_MISS_MENTION_PATTERNS.some((re) => re.test(body)); +} + +/** + * Build the task description handed to ``coding/pr-iteration-v1`` from the + * comment instruction. When the reviewer left explicit text, that IS the + * instruction; when they only mentioned ``@bgagent`` with no text, fall back + * to a generic "address the latest review feedback on this PR" so the agent + * still has a directive. + */ +export function buildIterationInstruction(trigger: CommentTrigger): string { + if (trigger.instruction.length > 0) return trigger.instruction; + return 'Address the latest review feedback on this pull request.'; +} + +/** + * Does an ``@bgagent`` comment read as a RETRY request — + * "re-run the work that failed" — rather than a change instruction? The failure + * panel tells the user "reply here to try again", so a bare ``@bgagent retry`` / + * "try again" / "re-run" must route to the epic-retry machinery (reset + re-run + * the failed/skipped children), NOT to the disambiguation/iteration path, which + * either dead-ended or looped and so never actually re-ran anything. + * + * Conservative, mirroring {@link parsePlanVerdict}'s discipline: only fires when + * the instruction is a SHORT (≤{@link MAX_VERDICT_WORDS}-word) comment led by (or + * consisting of) a retry phrase — so "retry the footer but change the color and …" + * (a substantive edit that happens to start with "retry") is NOT swallowed as a + * bare retry; it falls through to the normal iterate/revise path. An empty + * instruction (bare ``@bgagent``) is NOT a retry — that stays "address the latest + * review" per {@link buildIterationInstruction}. + */ +const RETRY_PHRASES = [ + 'retry', 'retries', 'try again', 'rerun', 're-run', 're run', 'run again', 'run it again', +] as const; +export function parseRetryIntent(instruction: string): boolean { + const text = instruction.replace(/[*_`>]/g, ' ').trim().toLowerCase().replace(/\s+/g, ' '); + if (!text) return false; + if (text.split(' ').length > MAX_VERDICT_WORDS) return false; + const firstWord = text.split(/[\s.,!?—–-]+/)[0]; + if (firstWord === 'retry' || firstWord === 'rerun') return true; + return RETRY_PHRASES.some((p) => hasPhrase(text, p)); +} + +/** The command words ABCA recognises in an ``@bgagent`` comment on an epic. + * Today there is exactly one — ``retry``. Surfaced in the epic + * disambiguation/fallback reply so the user always sees what they CAN type + * (rather than us trying to guess a typo). Extend this together with + * {@link RETRY_PHRASES} when a new command is added. */ +export const KNOWN_EPIC_COMMANDS = ['retry'] as const; + +/** + * The verdict of an ``@bgagent`` comment on a pending + * plan — the proposed sub-issue breakdown of a plain issue, awaiting a + * reviewer's go-ahead before any work starts. + * ``none`` means the comment is an ordinary change instruction (routes to + * the revise loop). ``ambiguous`` means an unqualified negation ("no", "no + * thanks", "don't approve") that is NOT a clear discard — the processor nudges + * the reviewer to pick (approve / reject / change) rather than destroy the plan. + */ +export type PlanVerdict = 'approve' | 'reject' | 'none' | 'ambiguous'; + +/** + * Natural ways a reviewer signals "go ahead" on a pending plan. Real people don't + * type the exact keyword — a strict ``approve``-only parser silently swallowed + * "lgtm", "yes go ahead", "👍", "looks good", each confirmed against real + * reviewer comments. Multi-word phrases are matched as phrases; single tokens as + * whole words. + */ +const APPROVE_PHRASES = [ + 'approve', 'approved', 'approves', 'lgtm', 'sgtm', 'yes', 'yep', 'yeah', 'yup', + 'ok', 'okay', 'sure', 'proceed', 'accept', 'accepted', 'confirm', 'confirmed', + 'ship it', 'shipit', 'do it', 'go ahead', 'go for it', 'sounds good', + 'looks good', 'looks great', 'send it', '+1', +] as const; +/** + * EXPLICIT, unambiguous "kill it" words — these DISCARD the pending plan (the one + * destructive, irreversible action in the flow: a discarded plan is gone, whereas + * an approved plan's sub-issues can still be closed). A discard therefore demands + * explicit intent. A SOFT negation ("no", "don't") is deliberately NOT here — see + * {@link SOFT_NEGATION_PHRASES}: it is ambiguous between "discard" and "change it", + * so it must never silently destroy the plan. + */ +const EXPLICIT_REJECT_PHRASES = [ + 'reject', 'rejected', 'rejects', 'cancel', 'cancelled', 'canceled', + 'stop', 'discard', 'abort', +] as const; +/** + * SOFT negations. On their own — or as pure negativity ("no, looks wrong") — these + * are AMBIGUOUS: "no" could mean "discard it" or "no, change it". Rather than + * guess-and-destroy, we nudge the reviewer to pick. When a soft negation is + * FOLLOWED BY a substantive change instruction ("no, make it 3 tasks") it is a + * REVISE. This closes a destructive bug seen in practice: "no, just 2 tasks" was + * parsed as reject and DELETED the pending plan; the earlier word-count guard + * only saved LONG negations, not a short one carrying an instruction. + */ +const SOFT_NEGATION_PHRASES = [ + 'no', 'nope', 'nah', "don't", 'do not', 'dont', '-1', + // 'not' was once MISSING here, so "not sure" / "not ok" / "not approved" + // fell through to APPROVE (they contain approve words). As a soft negation it + // now routes to ambiguous (nudge) or, with a change instruction, revise — + // never a silent approval against the reviewer's "not". + 'not', +] as const; +/** + * Change-instruction signals that mark a soft-negation comment as a REVISE (re-plan + * from the feedback) rather than a bare negation. An imperative change verb + * ("make", "split", "merge", "keep", …) or a numeric-count directive ("2 tasks", + * "3 sub-issues"). Best-effort: an unrecognized instruction falls back to a NUDGE + * (safe — asks the reviewer to rephrase; the choice between revise and nudge is + * purely UX, since BOTH are non-destructive — only reject destroys). + */ +const CHANGE_VERBS = [ + 'make', 'split', 'merge', 'combine', 'consolidate', 'add', 'remove', 'drop', + 'delete', 'keep', 'change', 'rename', 'reorder', 'move', 'reduce', 'increase', + 'use', 'separate', 'group', 'break', 'expand', 'swap', 'replace', +] as const; +/** Emoji affirmations/negations — matched by inclusion (no word boundaries). */ +const APPROVE_EMOJI = ['👍', '✅', '🚀']; +const REJECT_EMOJI = ['👎', '🛑', '❌']; + +/** + * A comment with at most this many words is read as a verdict if it contains ANY + * approve/reject phrase; a longer comment only counts when its FIRST word is a + * verdict word — so a genuine edit request ("also approve the dialog copy and …") + * isn't hijacked as approval. + */ +const MAX_VERDICT_WORDS = 6; + +/** Word/phrase boundary match: the phrase appears as whole words in ``text``. */ +function hasPhrase(text: string, phrase: string): boolean { + // Escape regex metachars (e.g. "+1", "don't"); match on non-word boundaries so + // "approve" doesn't fire on "approval" and "no" doesn't fire on "notify". + const esc = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(^|[^a-z0-9])${esc}([^a-z0-9]|$)`, 'i').test(text); +} + +/** + * Does ``text`` carry a substantive CHANGE instruction (beyond the leading verdict + * word)? True when it contains an imperative change verb ({@link CHANGE_VERBS}) or a + * numeric-count directive ("2 tasks", "3 sub-issues", "into 4"). Used to tell a + * bare soft negation ("no", "no thanks", "no, looks wrong") from a negation that + * asks for a re-plan ("no, make it 3 tasks", "no, just 2 tasks"). + */ +function hasChangeInstruction(text: string): boolean { + if (CHANGE_VERBS.some((v) => hasPhrase(text, v))) return true; + // Numeric-count directive: a number adjacent to a plan-unit noun, or "just N", + // "into N" — "no, just 2 tasks" / "3 sub-issues" / "into 4". + if (/\b\d+\s+(tasks?|sub-?issues?|units?|parts?|pieces?|steps?|prs?)\b/.test(text)) return true; + if (/\b(just|only|into|to)\s+\d+\b/.test(text)) return true; + return false; +} + +/** + * Classify an already-parsed comment instruction (only consulted when a pending + * plan exists on the issue). Four outcomes: + * - ``approve`` — a clear go-ahead (natural affirmations included: lgtm/yes/👍/…). + * - ``reject`` — an EXPLICIT, unambiguous discard ({@link EXPLICIT_REJECT_PHRASES} + * / 👎🛑❌). Discard is the one destructive, irreversible action, so it demands + * explicit intent — a bare "no" is NOT enough. + * - ``none`` — a change instruction → the revise loop (re-plan). Includes any + * LONGER comment (>6 words: an edit request, not a verdict — "no, go back to two + * sub-issues …") AND a SHORT soft-negation that carries a change instruction + * ("no, make it 3 tasks", which used to parse as reject and DELETE the plan). + * - ``ambiguous`` — a soft negation with NO change instruction ("no", "no thanks", + * "don't approve", "no, looks wrong"): could mean discard OR change. Never + * guess-and-destroy — the processor nudges the reviewer to pick. + * + * ``reject``/``ambiguous`` precede ``approve`` so a negation that also contains an + * affirmative word ("don't approve", "not approved") isn't read as approval. A + * reject EMOJI discards only when it LEADS the comment or appears in a short + * verdict — not merely buried in prose. An approve word paired + * with a contrastive/change qualifier ("yes, but smaller") routes to revise, not + * approve. An approve emoji (👍) is honoured regardless of length. + */ +export function parsePlanVerdict(instruction: string): PlanVerdict { + // Normalize: drop markdown emphasis/backticks, lowercase, collapse whitespace. + const text = instruction.replace(/[*_`>]/g, ' ').trim().toLowerCase().replace(/\s+/g, ' '); + if (!text) return 'none'; + + const wordCount = text.split(' ').length; + const firstWord = text.split(/[\s.,!?—–-]+/)[0]; + const short = wordCount <= MAX_VERDICT_WORDS; + + // ── DISCARD: explicit destructive intent only ──────────────────────────── + // A discard is irreversible (the plan is gone), so it requires an EXPLICIT kill + // word — never a bare soft negation. + // + // A reject EMOJI only discards when it is the VERDICT, not + // merely present. The old ``instruction.includes(❌)`` fired on a long comment + // that happened to contain ❌ anywhere ("this isn't ❌ a blocker, looks fine") + // → irreversibly deleting the plan. Require the reject emoji to lead the comment + // (first non-space char) OR appear in a SHORT (≤6-word) verdict — the same + // discipline the phrase branches already use. + const trimmed = instruction.trim(); + const rejectEmojiIsVerdict = REJECT_EMOJI.some((e) => trimmed.startsWith(e)) + || (short && REJECT_EMOJI.some((e) => instruction.includes(e))); + if (rejectEmojiIsVerdict) return 'reject'; + if (short && EXPLICIT_REJECT_PHRASES.includes(firstWord as (typeof EXPLICIT_REJECT_PHRASES)[number])) return 'reject'; + if (short && EXPLICIT_REJECT_PHRASES.some((p) => hasPhrase(text, p))) return 'reject'; + + // ── SOFT NEGATION in a SHORT comment: ambiguous, never destroy ──────────── + // A short soft negation could mean "discard" or "no, change it". If it carries a + // change instruction (a verb like "make/split" or a count like "2 tasks"), it's + // a REVISE → ``none`` (routes to the re-plan loop; this is what keeps + // "no, just 2 tasks" from falling through to discard). + // Otherwise it's genuinely ambiguous → ``ambiguous`` (the processor nudges: + // approve / reject / change). + // + // Only SHORT: a LONG comment is already substantive (an edit request) and falls + // through to ``none`` below — preserving the verified behavior that + // "no, I'd rather have three sub-issues: split the API …" REVISES (worded counts + // like "three" wouldn't match the change-instruction heuristic, so we must NOT + // route long comments through the ambiguity check or they'd wrongly nudge). + const softNegationLed = + SOFT_NEGATION_PHRASES.includes(firstWord as (typeof SOFT_NEGATION_PHRASES)[number]) + || SOFT_NEGATION_PHRASES.some((p) => hasPhrase(text, p)); + if (short && softNegationLed) { + return hasChangeInstruction(text) ? 'none' : 'ambiguous'; + } + + // ── APPROVE: clear go-ahead, short comment only ────────────────────────── + // An approve word paired with a substantive change instruction + // ("yes, but smaller", "ok but split the API layer") is NOT a clean go-ahead — + // approving would start spending against a plan the reviewer wants changed. + // Route it to the revise loop (``none``) instead. A pure approve emoji (👍) has + // no qualifier so it stays a straight approve. + if (APPROVE_EMOJI.some((e) => instruction.includes(e))) return 'approve'; + const approveLed = + (short && APPROVE_PHRASES.includes(firstWord as (typeof APPROVE_PHRASES)[number])) + || (short && APPROVE_PHRASES.some((p) => hasPhrase(text, p))); + if (approveLed) { + // A contrastive qualifier ("yes, BUT smaller"; "ok, HOWEVER split it") or an + // explicit change instruction means the reviewer is NOT cleanly approving the + // plan as-is — route to revise rather than spend on it. A bare approve + // ("yes", "lgtm", "approve") has neither → straight approve. + const hasQualifier = /(^|[^a-z0-9])(but|however|though|although|except)([^a-z0-9]|$)/i.test(text); + return (hasQualifier || hasChangeInstruction(text)) ? 'none' : 'approve'; + } + + // Anything else (incl. a long non-negation edit request) → revise loop. + return 'none'; +} diff --git a/cdk/src/handlers/shared/orchestration-dag.ts b/cdk/src/handlers/shared/orchestration-dag.ts new file mode 100644 index 000000000..8cbaece69 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-dag.ts @@ -0,0 +1,192 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Pure dependency-graph (DAG) logic for parent/sub-issue orchestration. + * No I/O: takes a set of nodes with ``depends_on`` edges and either + * rejects the graph (cycle, dangling edge) or returns a topological + * layering the reconciler uses to release children in dependency order. + * + * Kept deliberately free of Linear/AWS types so it is trivially unit- + * testable and reusable by a planner, which + * validates its own generated graph with the same cycle check before + * writing sub-issues back to the tracker. + */ + +/** A single node in the dependency graph (one Linear sub-issue). */ +export interface DagNode { + /** Stable identifier — the Linear sub-issue id (the orchestration SK). */ + readonly id: string; + /** Ids this node is blocked by; must all reach terminal-success first. */ + readonly depends_on: readonly string[]; +} + +/** Why a graph was rejected. Surfaced to the user as a terminal comment. */ +export type DagRejectionReason = 'cycle' | 'dangling_edge' | 'duplicate_id'; + +export interface DagValidationOk { + readonly ok: true; + /** + * Topological layers. ``layers[0]`` are roots (no predecessors); + * every node in ``layers[n]`` depends only on nodes in + * ``layers[<n]``. The reconciler uses layer 0 as the initial release + * set; deeper layers are released as predecessors succeed. The flat + * order (``layers.flat()``) is a valid topological sort. + */ + readonly layers: readonly (readonly string[])[]; +} + +export interface DagValidationError { + readonly ok: false; + readonly reason: DagRejectionReason; + /** + * The node ids implicated in the rejection — the cycle members, the + * nodes carrying dangling edges, or the duplicated ids. Sorted for + * stable, testable output. + */ + readonly offendingIds: readonly string[]; + /** Human-readable, user-facing explanation (used verbatim in the Linear comment). */ + readonly message: string; +} + +export type DagValidationResult = DagValidationOk | DagValidationError; + +/** + * Validate a dependency graph and, on success, return its topological + * layering. + * + * Rejects (fail-closed — a bad graph must never start any child): + * - ``duplicate_id`` — two nodes share an id (ambiguous gating). + * - ``dangling_edge`` — a ``depends_on`` points at an id not in the node set. + * - ``cycle`` — the edges form a cycle (no valid start order exists). + * + * Uses Kahn's algorithm: repeatedly peel off nodes with zero remaining + * predecessors. Each peel is one layer. If nodes remain when no node + * has zero in-degree, those nodes form (or feed) a cycle. + */ +export function validateDag(nodes: readonly DagNode[]): DagValidationResult { + // ── Duplicate ids ──────────────────────────────────────────────── + const seen = new Set<string>(); + const duplicates = new Set<string>(); + for (const n of nodes) { + if (seen.has(n.id)) duplicates.add(n.id); + seen.add(n.id); + } + if (duplicates.size > 0) { + const ids = [...duplicates].sort(); + return { + ok: false, + reason: 'duplicate_id', + offendingIds: ids, + message: + `Duplicate sub-issue id(s) in the dependency graph: ${ids.join(', ')}. ` + + 'Each sub-issue must appear once.', + }; + } + + // ── Dangling edges (depends_on → unknown id) ───────────────────── + const ids = new Set(nodes.map((n) => n.id)); + const dangling = new Set<string>(); + for (const n of nodes) { + for (const dep of n.depends_on) { + if (!ids.has(dep)) dangling.add(n.id); + } + } + if (dangling.size > 0) { + const offending = [...dangling].sort(); + return { + ok: false, + reason: 'dangling_edge', + offendingIds: offending, + message: + `Sub-issue(s) ${offending.join(', ')} depend on an issue that isn't part ` + + 'of this parent\'s sub-issue set. Blocking relations must stay within the epic.', + }; + } + + // ── Kahn's algorithm: peel zero-in-degree nodes into layers ────── + // in-degree = number of (deduplicated) predecessors still unresolved. + const remainingDeps = new Map<string, Set<string>>(); + for (const n of nodes) { + remainingDeps.set(n.id, new Set(n.depends_on)); + } + + // Reverse adjacency: dep -> nodes that depend on it (to decrement fast). + const dependents = new Map<string, string[]>(); + for (const n of nodes) { + for (const dep of new Set(n.depends_on)) { + const list = dependents.get(dep) ?? []; + list.push(n.id); + dependents.set(dep, list); + } + } + + const layers: string[][] = []; + let frontier = nodes.filter((n) => remainingDeps.get(n.id)!.size === 0).map((n) => n.id); + let resolvedCount = 0; + + while (frontier.length > 0) { + // Sort each layer for deterministic, testable output. + const layer = [...frontier].sort(); + layers.push(layer); + resolvedCount += layer.length; + + const next: string[] = []; + for (const resolvedId of layer) { + for (const dependentId of dependents.get(resolvedId) ?? []) { + const deps = remainingDeps.get(dependentId)!; + deps.delete(resolvedId); + if (deps.size === 0) next.push(dependentId); + } + } + frontier = next; + } + + if (resolvedCount < nodes.length) { + // Whatever never resolved is in (or downstream of) a cycle. + const stuck = nodes + .filter((n) => remainingDeps.get(n.id)!.size > 0) + .map((n) => n.id) + .sort(); + return { + ok: false, + reason: 'cycle', + offendingIds: stuck, + message: + 'The sub-issue blocking relations form a cycle ' + + `(involving: ${stuck.join(', ')}), so there is no valid order to start them. ` + + 'Remove the circular `blocked by` relation and re-apply the trigger.', + }; + } + + return { ok: true, layers }; +} + +/** + * Convenience: the flat topological order (roots first). Only valid to + * call on a graph ``validateDag`` accepted; throws otherwise so a caller + * can't accidentally order a rejected graph. + */ +export function topologicalOrder(nodes: readonly DagNode[]): readonly string[] { + const result = validateDag(nodes); + if (!result.ok) { + throw new Error(`Cannot order an invalid dependency graph: ${result.reason}`); + } + return result.layers.flat(); +} diff --git a/cdk/src/handlers/shared/orchestration-epic-tip.ts b/cdk/src/handlers/shared/orchestration-epic-tip.ts new file mode 100644 index 000000000..5f4528bb2 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-epic-tip.ts @@ -0,0 +1,94 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Pure "epic tip" selection — where a NEWLY-ADDED sub-issue with NO declared + * dependency should stack. + * + * The rule: a node added to an in-flight epic must NOT branch off bare + * ``main`` — it inherits the epic's accumulated, + * unmerged work by stacking on the epic's TIP (the most-recent leaf the rest + * of the graph already builds on). "Fall back to ``main`` only when the + * predecessor is genuinely merged (branch gone)" is handled downstream by the + * agent's runtime base-branch fetch fallback (``agent/src/repo.py`` — a base + * branch that no longer exists on origin degrades to a branch off default), + * so this layer only needs to NAME the tip; it never has to detect merge. + * + * The tip is the **leaf frontier**: nodes that nothing else depends on. Among + * those, we pick the most-recently-created real (non-integration) leaf — the + * single node a linear chain naturally extends from. This keeps the common + * "epic was a chain, add one more step" case a clean linear stack; a fan-out + * epic with multiple independent leaves yields a multi-predecessor (diamond) + * implicit dependency so the new node sees ALL of the accumulated work. + */ + +import { isIntegrationNode } from './orchestration-integration-node'; + +/** Minimal shape needed to compute the tip — a subset of OrchestrationChildRow. */ +export interface TipCandidate { + readonly sub_issue_id: string; + readonly depends_on: readonly string[]; + readonly created_at: string; +} + +/** + * Resolve the implicit predecessor set for a new unconstrained node added to + * an existing epic. Returns the sub_issue_ids the new node should stack on / + * merge in (its synthetic ``depends_on``), or ``[]`` when the epic has no + * usable tip (e.g. empty epic — degrade to root/main). + * + * Algorithm: + * 1. Consider only the EXISTING nodes (the new node isn't in the graph yet). + * 2. The leaf frontier = nodes that appear in no other node's ``depends_on``. + * 3. If an INTEGRATION node exists, it already depends on every real leaf — + * it IS the single combined tip, so stack on it alone (avoids a redundant + * diamond that re-merges what integration already merged). + * 4. Otherwise return every real leaf. One leaf → a clean linear stack; many + * leaves → a diamond so the new node inherits all parallel branches. + * + * Pure + deterministic (ties broken by sub_issue_id); no I/O. + */ +export function resolveEpicTip(existing: readonly TipCandidate[]): string[] { + if (existing.length === 0) return []; + + // A node is depended-upon if it appears in any other node's depends_on. + const dependedUpon = new Set<string>(); + for (const node of existing) { + for (const dep of node.depends_on) dependedUpon.add(dep); + } + + const leaves = existing.filter((n) => !dependedUpon.has(n.sub_issue_id)); + if (leaves.length === 0) { + // Pathological (every node depended upon ⇒ a cycle, which the DAG + // validator rejects upstream). Degrade to root rather than throw. + return []; + } + + // An integration node already merges all real leaves — it is the combined + // tip. Stack on it alone. + const integration = leaves.find((n) => isIntegrationNode(n.sub_issue_id)); + if (integration) return [integration.sub_issue_id]; + + // Real leaves only (defensive — integration handled above). One → linear + // stack; many → diamond. Sorted for deterministic depends_on ordering. + return leaves + .filter((n) => !isIntegrationNode(n.sub_issue_id)) + .map((n) => n.sub_issue_id) + .sort(); +} diff --git a/cdk/src/handlers/shared/orchestration-graph-source.ts b/cdk/src/handlers/shared/orchestration-graph-source.ts new file mode 100644 index 000000000..f34d9b4b8 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-graph-source.ts @@ -0,0 +1,103 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Trigger-agnostic orchestration graph source. + * + * The executor (validate → seed → reconcile → release → stack → + * rollup → parent lifecycle) is source-agnostic: once a DAG of + * ``{ id, depends_on, title? }`` nodes exists it doesn't care where the + * graph came from. What VARIES per trigger is only how the graph is + * *produced*. This module is that seam. + * + * "Sub-issues" is just one way to express a DAG. Three adapter tiers: + * + * 1. NATIVE graph — the tool already has the structure; the adapter + * READS it. Linear: parent → children + ``blocks`` relations + * ({@link linearGraphSource}, wrapping ``fetchSubIssueGraph``). A Jira + * adapter would map epic → stories + issue links the same way. + * + * 2. DECLARATIVE graph — the trigger has no native sub-issues, so the + * caller SUPPLIES the DAG. {@link declarativeGraphSource} takes a + * ready-made node list. This is the slot for: + * - CLI / API: a request body carrying tasks + ``depends_on`` edges. + * - A planner agent turning ONE plain request into + * a phased DAG and hands the nodes here — reusing the ENTIRE + * executor instead of reimplementing gating/stacking/rollup. + * + * 3. DELEGATE / single — a structureless trigger (e.g. a plain Slack + * message) either stays single-task or references a native epic by id + * (tier 1). No adapter needed here. + * + * A source is a zero-arg async thunk so the caller binds whatever inputs + * it needs (token + issue id for Linear; a node list for declarative) before + * handing the discovery step a uniform interface — the consumer that resolves a + * graph from one of these sources lands with the orchestration compute plane. + */ + +import { fetchSubIssueGraph, type FetchSubIssueGraphOptions, type SubIssueNode } from './linear-subissue-fetch'; + +/** + * Channel-neutral graph result. Mirrors ``FetchSubIssueGraphResult`` but + * without Linear's ``parentIssueId`` — the discovery composer already + * holds the parent id separately. + * - ``ok`` — a non-empty DAG to validate + seed. + * - ``no_children`` — no graph; caller falls through to a single task. + * - ``error`` — transient failure; caller surfaces retryable, does + * NOT silently degrade to a single task (that would drop the structure). + */ +export type OrchestrationGraphResult = + | { readonly kind: 'ok'; readonly children: readonly SubIssueNode[] } + | { readonly kind: 'no_children' } + | { readonly kind: 'error'; readonly message: string }; + +/** A bound, zero-arg producer of an orchestration DAG. */ +export type OrchestrationGraphSource = () => Promise<OrchestrationGraphResult>; + +/** + * Tier 1 — Linear native graph. Reads the parent issue's sub-issues + + * blocking relations via the existing ``fetchSubIssueGraph`` and maps the + * result to the channel-neutral shape. + */ +export function linearGraphSource( + accessToken: string, + parentIssueId: string, + fetchOptions?: FetchSubIssueGraphOptions, +): OrchestrationGraphSource { + return async () => { + const fetched = await fetchSubIssueGraph(accessToken, parentIssueId, fetchOptions); + if (fetched.kind === 'error') return { kind: 'error', message: fetched.message }; + if (fetched.kind === 'no_children') return { kind: 'no_children' }; + return { kind: 'ok', children: fetched.children }; + }; +} + +/** + * Tier 2 — declarative graph. The caller already has the node list (a + * CLI/API request, or a planner's output). An empty + * list means "no graph" → single task. Never errors (the nodes are + * in-memory); DAG validity (cycles/dangling/dupes) is still enforced + * downstream by ``validateDag`` in the discovery composer. + */ +export function declarativeGraphSource(children: readonly SubIssueNode[]): OrchestrationGraphSource { + return async () => { + if (children.length === 0) return { kind: 'no_children' }; + return { kind: 'ok', children }; + }; +} diff --git a/cdk/src/handlers/shared/orchestration-integration-node.ts b/cdk/src/handlers/shared/orchestration-integration-node.ts new file mode 100644 index 000000000..4aa56959d --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-integration-node.ts @@ -0,0 +1,99 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Auto-integration node for fan-out orchestrations. + * + * When a validated DAG has MORE THAN ONE leaf (a sub-issue with no + * successors), each leaf is an independent PR and nothing combines them — + * there is no single "see it all together" artifact. We append a synthetic + * integration node that depends on ALL leaves. Because it has multiple + * predecessors it is a diamond fan-in, so the existing multi-predecessor + * path (``selectBaseBranch`` → ``_merge_predecessor_branch``) merges every + * leaf branch into the integration branch with no new merge code — its PR + * is the combined result. + * + * Pure (no I/O), so the leaf computation + node construction is unit-tested + * in isolation. The discovery composer calls this AFTER ``validateDag`` + * (it needs the validated node set to compute leaves) and BEFORE + * ``seedOrchestration``, re-validating the augmented graph. + * + * Cases: + * - 0–1 leaf (linear chain, or an explicit diamond fan-in): nothing added — + * a single leaf already IS the combined result. + * - >1 leaf (pure fan-out): one synthetic node added over all leaves. + */ + +import type { SubIssueNode } from './linear-subissue-fetch'; + +/** + * Suffix marking a synthetic, platform-injected node (not a real Linear + * sub-issue). Uses ``_`` separators, NOT ``#``: the node's ``sub_issue_id`` + * flows into ``releaseChild``'s idempotency key (``${orch}_${sub}``), which + * createTaskCore validates against ``/^[a-zA-Z0-9_-]{1,128}$/`` — a ``#`` + * would 400 the child and it would never start (the same trap the meta-row + * ``#meta`` SK can use safely because it never becomes an idempotency key). + */ +export const INTEGRATION_NODE_SUFFIX = '__integration'; + +/** + * True if ``subIssueId`` is a platform-synthesized integration node rather + * than a real Linear sub-issue. Callers that would address a real Linear + * issue (reactions, MCP comments) can guard on this. + */ +export function isIntegrationNode(subIssueId: string): boolean { + return subIssueId.endsWith(INTEGRATION_NODE_SUFFIX); +} + +/** Node ids that no other node depends on — the DAG's leaves. */ +export function computeLeaves(nodes: readonly SubIssueNode[]): readonly string[] { + const hasSuccessor = new Set<string>(); + for (const n of nodes) { + for (const dep of n.depends_on) hasSuccessor.add(dep); + } + return nodes.map((n) => n.id).filter((id) => !hasSuccessor.has(id)); +} + +/** + * Given a validated DAG, return the node list to seed: unchanged when there + * is 0–1 leaf, or with a synthetic integration node appended (depending on + * all leaves) when there is more than one leaf. + * + * ``orchestrationId`` namespaces the synthetic node's id so it is unique + + * recognizable (``<orchestrationId>__integration`` — see + * {@link INTEGRATION_NODE_SUFFIX} for why the separator is ``_`` and not ``#``). + * The node carries no + * ``identifier`` (there is no Linear issue) and a fixed ``title`` so the + * status block / rollup render "Integration …" gracefully. + */ +export function withIntegrationNode( + nodes: readonly SubIssueNode[], + orchestrationId: string, +): { readonly nodes: readonly SubIssueNode[]; readonly added: boolean } { + const leaves = computeLeaves(nodes); + if (leaves.length <= 1) { + return { nodes, added: false }; + } + const integration: SubIssueNode = { + id: `${orchestrationId}${INTEGRATION_NODE_SUFFIX}`, + depends_on: leaves, + title: 'Integration — combine sub-issue results', + }; + return { nodes: [...nodes, integration], added: true }; +} diff --git a/cdk/src/handlers/shared/orchestration-log-events.ts b/cdk/src/handlers/shared/orchestration-log-events.ts new file mode 100644 index 000000000..a17746d69 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-log-events.ts @@ -0,0 +1,96 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Stable, machine-greppable log-event names for sub-issue orchestration. + * Emitted as the ``event`` field on structured logs. + * + * WHY A CENTRAL MODULE: these strings are a TEST CONTRACT. End-to-end and + * automated dev tests assert on orchestration behavior by grepping + * CloudWatch for these exact event names (the orchestration plane is + * event-driven and has no synchronous API to assert against). Defining + * them in one place means: + * - a test references ``ORCH_LOG.childReleased``, not a copy-pasted + * string that silently drifts when a log line is reworded; + * - renaming an event is a single edit that the type system propagates; + * - this file IS the catalogue of "what to look for in the logs", + * which is exactly the long-term automated-testing question. + * + * Convention: ``orch.<phase>.<outcome>`` so a test can match a whole + * phase with a prefix (``orch.reconcile.*``) or an exact transition. + * Every emit site should also include the structured fields listed in the + * doc comment so log-based assertions can bind to ids, not just names. + */ +export const ORCH_LOG = { + // ── Discovery (webhook → seed) ────────────────────────────────── + /** A labeled parent had a valid sub-issue graph; rows seeded. + * Fields: orchestration_id, parent_linear_issue_id, child_count, root_count. */ + discoverySeeded: 'orch.discovery.seeded', + /** Parent had no sub-issues → fell back to a single task. + * Fields: parent_linear_issue_id. */ + discoverySingleTask: 'orch.discovery.single_task', + /** Graph rejected (cycle / dangling / dup) — no rows, terminal comment. + * Fields: parent_linear_issue_id, reason, offending_ids. */ + discoveryRejected: 'orch.discovery.rejected', + /** Transient Linear error reading sub-issues — terminal comment, no seed. + * Fields: parent_linear_issue_id, message. */ + discoveryError: 'orch.discovery.error', + + // ── Release (root + reconciler) ───────────────────────────────── + /** A child task was created (released). Fields: orchestration_id, + * sub_issue_id, child_task_id, base_branch, merge_branch_count, source + * ('root' | 'reconciler' | 'sweep'). */ + childReleased: 'orch.child.released', + /** A release attempt's createTaskCore returned non-success. Fields: + * orchestration_id, sub_issue_id, status, response_body. */ + childReleaseFailed: 'orch.child.release_failed', + + // ── Reconcile (TaskTable stream → gating) ─────────────────────── + /** A child reached terminal-success; gating re-evaluated. Fields: + * orchestration_id, sub_issue_id, released_count. */ + reconcileSuccess: 'orch.reconcile.success', + /** A child failed/cancelled/timed-out or built-broken; dependents + * skipped. Fields: orchestration_id, sub_issue_id, skipped_ids. */ + reconcileFailurePropagated: 'orch.reconcile.failure_propagated', + + // ── Rollup (parent comment via this plane) ────────────────────── + /** A parent rollup comment was posted. Fields: orchestration_id, + * parent_linear_issue_id, rollup_kind ('progress' | 'complete' | + * 'partial_failure' | 'cancelled'). */ + rollupPosted: 'orch.rollup.posted', + /** Posting the parent rollup comment failed (best-effort). Fields: + * orchestration_id, parent_linear_issue_id, rollup_kind. */ + rollupFailed: 'orch.rollup.failed', + + // ── Completion / cancel ───────────────────────────────────────── + /** Every child reached a terminal orchestration state. Fields: + * orchestration_id, parent_linear_issue_id, succeeded, failed, skipped. */ + orchestrationComplete: 'orch.complete', + /** Parent cancel cascaded to non-terminal children. Fields: + * orchestration_id, parent_linear_issue_id, cancelled_count. */ + cancelCascaded: 'orch.cancel.cascaded', + + // ── Backstop (scheduled sweep) ────────────────────────────────── + /** The sweep recovered a child the live reconciler missed. Fields: + * orchestration_id, sub_issue_id, recovery ('lost_release' | + * 'lost_terminal'). */ + sweepRecovered: 'orch.sweep.recovered', +} as const; + +export type OrchLogEvent = (typeof ORCH_LOG)[keyof typeof ORCH_LOG]; diff --git a/cdk/src/handlers/shared/orchestration-store.ts b/cdk/src/handlers/shared/orchestration-store.ts new file mode 100644 index 000000000..5e671191d --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-store.ts @@ -0,0 +1,956 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Persistence for the orchestration DAG. + * Writes one row per sub-issue to ``OrchestrationTable`` (PK + * ``orchestration_id``, SK ``sub_issue_id``) after the graph has been + * fetched (``linear-subissue-fetch``) and validated + * (``orchestration-dag``). + * + * Idempotent on webhook replay: the ``orchestration_id`` is *derived + * deterministically* from the parent Linear issue id (not random), and + * rows are written with a ``attribute_not_exists`` condition on first + * write. A replay of the same parent trigger therefore re-derives the + * same id and the conditional writes no-op instead of duplicating + * children. The reconciler owns child-status transitions; this module + * only seeds the initial ``blocked`` / ``ready`` rows. + */ + +import * as crypto from 'crypto'; +import { + type DynamoDBDocumentClient, + BatchWriteCommand, + GetCommand, + QueryCommand, + UpdateCommand, +} from '@aws-sdk/lib-dynamodb'; +import type { SubIssueNode } from './linear-subissue-fetch'; +import { logger } from './logger'; +import { validateDag } from './orchestration-dag'; +import { resolveEpicTip } from './orchestration-epic-tip'; +import type { AttachmentRecord } from './types'; + +/** Orchestration-local lifecycle marker on each sub-issue row. */ +export type ChildStatus = + | 'ready' // no predecessors / all predecessors succeeded — releasable + | 'blocked' // waiting on predecessors + | 'releasing' // TRANSIENT (flip status first, then create): claimed for release — + // the winner is between the atomic blocked|ready→releasing claim and the + // createTaskCore that follows. Never terminal; a crash mid-release leaves the + // row here and the sweep/rollback recovers it back to ready. + | 'released' // child task created + | 'succeeded' + | 'failed' + | 'skipped'; // a predecessor failed; this child will never start + +/** One persisted sub-issue row. */ +export interface OrchestrationChildRow { + readonly orchestration_id: string; + readonly sub_issue_id: string; + /** The epic this child belongs to, as the surface identifies it. */ + readonly parent_issue_ref: string; + /** Opaque credentials handle for the surface (a workspace/tenant id that keys + * a token registry). The engine never interprets it. */ + readonly credentials_ref: string; + readonly repo: string; + readonly depends_on: readonly string[]; + readonly child_status: ChildStatus; + /** + * The ABCA ``task_id`` created for this child once released. Stamped by + * ``releaseChild`` alongside the ``child_status → released`` flip; + * absent until the child is released. The ``ChildTaskIndex`` GSI is + * keyed on this so the reconciler resolves a terminal task back to its + * orchestration row. + */ + readonly child_task_id?: string; + /** + * The released child task's head branch. Persisted on the + * release flip so a DEPENDENT child can stack on / merge it. Absent + * until released. + */ + readonly child_branch_name?: string; + /** Human-facing id for display, when the surface has one (e.g. ``ENG-42``). */ + readonly display_id?: string; + /** Sub-issue title, used to build the child task description. */ + readonly title?: string; + /** + * Sub-issue scope/description. When the graph came from the planner + * a planner produced the piece, this is its rich per-piece scope — persisted at + * seed so the coding agent's task_description carries what the reviewer + * approved (e.g. a promised filename), not the title alone. Absent when the + * graph was read from sub-issues a human already wrote, since that path + * fetches titles only. + */ + readonly description?: string; + /** + * This sub-issue's OWN screened attachments (distinct from the parent epic's, + * which ride on the meta row's release_context). A human-authored + * sub-issue can carry a file attached to IT specifically (a mockup for just + * that piece); hydrated + stored at seed and merged with the inherited parent + * records when the child is released. JSON-encoded `passed` AttachmentRecords; + * absent when the sub-issue has no own attachments (the common case). + */ + readonly pre_screened_attachments?: readonly AttachmentRecord[]; + /** + * A terminal failure reason recorded when the + * child could NOT be turned into a task at all (a deterministic create + * failure — guardrail/content-policy block, validation error) so it never + * got a ``child_task_id``. The panel's failure-reason resolver falls back to + * this string when there's no task to read an ``error_message`` from, so the + * child's ❌ carries a "why + how to fix" line instead of a bare ❌. Absent + * for the normal case (a released child's reason comes from its task record). + */ + readonly failure_reason?: string; + readonly created_at: string; + readonly updated_at: string; + /** TTL epoch (seconds) for eventual cleanup. */ + readonly ttl?: number; +} + +/** + * Marshal one persisted item into a child row, reading the renamed attributes + * through {@link readRenamed} so a row written before the rename still loads. + * + * This is deliberately EXPLICIT rather than a cast. The previous + * ``item as unknown as OrchestrationChildRow`` made the compiler report success + * while the underlying attribute names were whatever the writer happened to use — + * so renaming a field on the interface would have silently yielded ``undefined`` + * for every existing row, with nothing in the type system or a new-shaped test + * fixture to catch it. + * + * Attributes that were never renamed are passed through as-is. + */ +function toChildRow(item: Record<string, unknown>): OrchestrationChildRow { + const parentIssueId = readRenamed(item, 'parent_issue_ref', 'parent_linear_issue_id') ?? ''; + const credentialsRef = readRenamed(item, 'credentials_ref', 'linear_workspace_id') ?? ''; + const displayId = readRenamed(item, 'display_id', 'linear_identifier'); + return { + ...(item as unknown as OrchestrationChildRow), + parent_issue_ref: parentIssueId, + credentials_ref: credentialsRef, + ...(displayId !== undefined && { display_id: displayId }), + }; +} + +/** + * Release context persisted on the parent-meta row so the reconciler can + * release downstream children WITHOUT re-resolving auth (the webhook + * already resolved the platform user + Linear OAuth at seed time). The + * reconciler runs off the TaskTable stream and has no Linear webhook + * payload to re-derive these from. + */ +export interface OrchestrationReleaseContext { + /** Platform user the children are attributed to (parent's submitter). */ + readonly platform_user_id: string; + /** + * The trigger channel that seeded this orchestration. Threaded onto child + * tasks (createTaskCore channelSource) and used by the reconciler to + * dispatch the parent rollup to the right plane. Defaults to ``'linear'`` + * when absent (back-compat: orchestrations seeded before this field + * existed, and the only wired trigger today). This is the trigger-agnostic + * seam: a future GitHub/Slack/Jira trigger seeds with its own source and the + * release + rollup paths follow it without code changes here. + */ + readonly channel_source?: string; + /** Linear OAuth secret ARN for the agent's outbound Linear GraphQL + * (reactions/state via linear_reactions.py — there is no Linear MCP). */ + readonly linear_oauth_secret_arn?: string; + readonly linear_workspace_slug?: string; + readonly linear_project_id?: string; + /** + * Parent-issue attachments, screened + stored ONCE at seed time, so every + * child inherits them: the parent's attached spec must reach the agents that + * write the code, not only the planner that never sees the repo. These are `passed` + * AttachmentRecords (S3 keys, not bytes); each released child references the + * same S3 objects read-only via createTaskCore's preScreenedAttachments seam. + * The PARENT owns the objects' lifecycle — children never delete them. + */ + readonly pre_screened_attachments?: readonly AttachmentRecord[]; +} + +export interface SeedOrchestrationParams { + readonly ddb: DynamoDBDocumentClient; + readonly tableName: string; + readonly parentIssueRef: string; + readonly credentialsRef: string; + readonly repo: string; + readonly children: readonly SubIssueNode[]; + /** ISO timestamp for created_at/updated_at (injected for testability). */ + readonly now: string; + /** Optional TTL epoch seconds. */ + readonly ttl?: number; + /** Release context stamped on the meta row for the reconciler. */ + readonly releaseContext: OrchestrationReleaseContext; +} + +export interface SeedOrchestrationResult { + readonly orchestrationId: string; + readonly rowsWritten: number; + /** True when an existing orchestration was found (replay) — no new rows. */ + readonly alreadyExisted: boolean; +} + +/** Hex chars of the sha256 kept for the orchestration id (128 bits — ample to + * avoid accidental collisions between distinct parent refs). */ +const ORCH_ID_HASH_HEX_LENGTH = 32; + +/** + * Deterministically derive the ``orchestration_id`` from the parent issue ref. + * Same parent → same id, which is what makes webhook replay idempotent. Prefixed + * + hashed so the id is opaque and fixed-length regardless of the ref format. + * + * NOT tenant-scoped: the ref alone is hashed, with no credentials/workspace + * component. That is safe for the refs in use today (Linear issue ids are + * workspace-unique UUIDs, so two tenants cannot produce the same one), but it is + * a property of those refs rather than of this function — a surface keying on a + * per-project ref like ``PROJ-42`` would have two tenants land on one id. + * + * Deliberately left as-is rather than mixing the tenant in: this id is a + * persisted partition key, so changing the derivation would orphan every + * in-flight epic's rows (the reconciler would re-derive a different id and find + * nothing, stranding the epic silently — the exact failure mode this codebase + * has been fixing). Instead the seed path REFUSES a collision it detects, so a + * surface with non-unique refs fails loudly at onboarding rather than letting two + * tenants share one graph. See {@link seedOrchestration}. + */ +export function deriveOrchestrationId(parentIssueRef: string): string { + const hash = crypto.createHash('sha256').update(parentIssueRef).digest('hex').slice(0, ORCH_ID_HASH_HEX_LENGTH); + return `orch_${hash}`; +} + +/** + * Read a persisted attribute that has been RENAMED, tolerating both spellings. + * + * Rows already in the table carry the old attribute name, and they outlive the + * deploy that renames it — an epic mid-flight when the rename ships must still + * settle. So every read prefers the new name and falls back to the legacy one, + * and writes emit BOTH for a transition period (see ``dualWrite``) so a rollback + * to the previous code also keeps working. + * + * Returns undefined when neither is present, so a caller can distinguish + * "absent" from "empty string". + */ +function readRenamed(item: Record<string, unknown>, current: string, legacy: string): string | undefined { + const value = item[current] ?? item[legacy]; + return value === undefined || value === null ? undefined : String(value); +} + +/** + * Emit a renamed attribute under BOTH names on write. Costs a duplicated small + * string per row and buys a reversible deploy: code running either spelling can + * read a row written by the other. Drop the legacy half only once no deployed + * code reads it and no in-flight rows predate the rename. + */ +function dualWrite<C extends string, L extends string>( + current: C, + legacy: L, + value: string, +): Record<C | L, string> { + return { [current]: value, [legacy]: value } as Record<C | L, string>; +} + +/** + * Parse the meta row's ``pre_screened_attachments_json`` back into records. + * Best-effort: a malformed/absent value yields ``[]`` (children just run without + * the parent attachments rather than the whole epic failing to release). + */ +function parsePreScreenedAttachments(raw: unknown, orchestrationId: string): AttachmentRecord[] { + if (typeof raw !== 'string' || raw.length === 0) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as AttachmentRecord[]) : []; + } catch (err) { + logger.warn('Orchestration meta pre_screened_attachments_json unparseable — releasing children without them', { + orchestration_id: orchestrationId, + error: err instanceof Error ? err.message : String(err), + }); + return []; + } +} + +/** DynamoDB BatchWriteItem hard limit: at most 25 put/delete requests per call. */ +const DDB_BATCH_WRITE_MAX_ITEMS = 25; + +/** Marker SK for the parent-meta row (sorts before any UUID sub_issue_id). */ +const PARENT_META_SK = '#meta'; + +/** + * An existing orchestration's id is held by a DIFFERENT parent issue or tenant. + * + * Its own class so the caller can tell it from a transient persistence failure: + * a write that failed is worth retrying, whereas a collision will recur on every + * attempt and needs the ref scheme changed, so telling the user to re-trigger + * would send them round a loop that cannot succeed. + */ +export class OrchestrationIdCollisionError extends Error { + constructor(public readonly orchestrationId: string) { + super(`Orchestration id ${orchestrationId} is already held by a different parent issue or tenant`); + this.name = 'OrchestrationIdCollisionError'; + } +} + +/** + * Who an existing orchestration belongs to, read from its meta row under either + * the neutral or the legacy attribute names. Used to tell a genuine replay from + * an id collision. + * + * A row missing these (written by code that predates them) reads as undefined, + * which deliberately does NOT trip the collision check — refusing to reconcile a + * legacy in-flight epic would be a worse failure than the collision this guards + * against, which cannot occur for the refs in use today. + */ +function toMetaOwner(item: Record<string, unknown>): { + parentIssueRef: string | undefined; + credentialsRef: string | undefined; +} { + return { + parentIssueRef: readRenamed(item, 'parent_issue_ref', 'parent_linear_issue_id'), + credentialsRef: readRenamed(item, 'credentials_ref', 'linear_workspace_id'), + }; +} + +/** + * Seed ``OrchestrationTable`` with one row per sub-issue plus a parent + * meta row. Idempotent: if the parent meta row already exists (replay), + * returns ``alreadyExisted: true`` and writes nothing. + * + * Initial ``child_status``: ``ready`` when ``depends_on`` is empty + * (a root — the reconciler releases these immediately), else + * ``blocked``. + * + * Refuses to adopt an existing orchestration that belongs to a DIFFERENT tenant + * (see {@link deriveOrchestrationId} for why the id itself isn't tenant-scoped). + */ +export async function seedOrchestration( + params: SeedOrchestrationParams, +): Promise<SeedOrchestrationResult> { + const { ddb, tableName, parentIssueRef, credentialsRef, repo, children, now, ttl, releaseContext } = params; + const orchestrationId = deriveOrchestrationId(parentIssueRef); + + // Idempotency gate: a prior run for this parent already seeded rows. + const existing = await ddb.send(new GetCommand({ + TableName: tableName, + Key: { orchestration_id: orchestrationId, sub_issue_id: PARENT_META_SK }, + })); + if (existing.Item) { + // The id is derived from the parent ref alone, so two tenants whose refs + // collide would derive the SAME id — and this replay gate would hand the + // second one the first one's graph: their epic would show the other tenant's + // children, and releases would run against the other tenant's repo under the + // other tenant's credentials. Cannot happen with the workspace-unique UUIDs + // in use today, but it must not degrade quietly if a surface with per-project + // refs is onboarded, so compare the identity the meta row already records. + const owner = toMetaOwner(existing.Item); + // Only a row that RECORDS a conflicting owner is a collision. An absent value + // (a row written before these attributes existed) is unknown, not different — + // treating it as a collision would refuse to reconcile a legacy in-flight + // epic, a worse and far likelier failure than the one being guarded against. + const collides = (owner.credentialsRef !== undefined && owner.credentialsRef !== credentialsRef) + || (owner.parentIssueRef !== undefined && owner.parentIssueRef !== parentIssueRef); + if (collides) { + logger.error('Orchestration id collision across tenants — refusing to seed', { + orchestration_id: orchestrationId, + parent_issue_ref: parentIssueRef, + existing_parent_issue_ref: owner.parentIssueRef, + // Ids, never credentials themselves. + credentials_ref: credentialsRef, + existing_credentials_ref: owner.credentialsRef, + }); + throw new OrchestrationIdCollisionError(orchestrationId); + } + logger.info('Orchestration already seeded — skipping (idempotent replay)', { + orchestration_id: orchestrationId, + parent_issue_ref: parentIssueRef, + }); + return { orchestrationId, rowsWritten: 0, alreadyExisted: true }; + } + + const childRows: OrchestrationChildRow[] = children.map((c) => ({ + orchestration_id: orchestrationId, + sub_issue_id: c.id, + // Renamed attributes are written under both spellings — see dualWrite. + ...dualWrite('parent_issue_ref', 'parent_linear_issue_id', parentIssueRef), + ...dualWrite('credentials_ref', 'linear_workspace_id', credentialsRef), + repo, + depends_on: c.depends_on, + child_status: c.depends_on.length === 0 ? 'ready' : 'blocked', + ...(c.identifier !== undefined && dualWrite('display_id', 'linear_identifier', c.identifier)), + ...(c.title !== undefined && { title: c.title }), + ...(c.description !== undefined && c.description !== '' && { description: c.description }), + created_at: now, + updated_at: now, + ...(ttl !== undefined && { ttl }), + })); + + const metaRow = { + orchestration_id: orchestrationId, + sub_issue_id: PARENT_META_SK, + ...dualWrite('parent_issue_ref', 'parent_linear_issue_id', parentIssueRef), + ...dualWrite('credentials_ref', 'linear_workspace_id', credentialsRef), + repo, + child_count: children.length, + // Release context for the reconciler (downstream releases run off the + // TaskTable stream with no Linear webhook payload to re-derive these). + platform_user_id: releaseContext.platform_user_id, + ...(releaseContext.channel_source !== undefined && { + channel_source: releaseContext.channel_source, + }), + ...(releaseContext.linear_oauth_secret_arn !== undefined && { + linear_oauth_secret_arn: releaseContext.linear_oauth_secret_arn, + }), + ...(releaseContext.linear_workspace_slug !== undefined && { + linear_workspace_slug: releaseContext.linear_workspace_slug, + }), + ...(releaseContext.linear_project_id !== undefined && { + linear_project_id: releaseContext.linear_project_id, + }), + // Parent attachments, inherited by every child — stored as a JSON string so the nested + // AttachmentRecord[] round-trips cleanly through the Document client without + // per-field marshalling. Omitted when the parent had none. + ...(releaseContext.pre_screened_attachments && releaseContext.pre_screened_attachments.length > 0 && { + pre_screened_attachments_json: JSON.stringify(releaseContext.pre_screened_attachments), + }), + created_at: now, + updated_at: now, + ...(ttl !== undefined && { ttl }), + }; + + // BatchWrite in chunks of 25 (DDB limit). The meta row goes last so a + // partial failure can't leave a meta row claiming a fully-seeded + // orchestration when child rows are missing — a replay re-derives the + // same id, sees no meta row, and re-seeds. + const allRows: Array<Record<string, unknown>> = [ + ...childRows.map((r) => ({ ...r })), + { ...metaRow }, + ]; + let rowsWritten = 0; + for (let i = 0; i < allRows.length; i += DDB_BATCH_WRITE_MAX_ITEMS) { + const chunk = allRows.slice(i, i + DDB_BATCH_WRITE_MAX_ITEMS); + await ddb.send(new BatchWriteCommand({ + RequestItems: { + [tableName]: chunk.map((Item) => ({ PutRequest: { Item } })), + }, + })); + rowsWritten += chunk.length; + } + + logger.info('Orchestration seeded', { + orchestration_id: orchestrationId, + parent_issue_ref: parentIssueRef, + child_count: children.length, + rows_written: rowsWritten, + }); + + return { orchestrationId, rowsWritten, alreadyExisted: false }; +} + +/** Result of extending an already-seeded orchestration with newly-added sub-issues. */ +export interface ExtendOrchestrationResult { + readonly orchestrationId: string; + /** Sub-issue ids newly ADDED to the DAG by this extend (empty if nothing new). */ + readonly addedSubIssueIds: readonly string[]; + /** + * Subset of ``addedSubIssueIds`` that are immediately releasable — their + * predecessors are all already ``succeeded`` (or they're new roots). The + * caller releases these now; the rest are ``blocked`` and the reconciler + * releases them as predecessors finish, exactly like seed-time children. + */ + readonly releasableSubIssueIds: readonly string[]; + /** Why an extend was rejected (cycle introduced by the new edges), if any. */ + readonly rejected?: { readonly reason: string; readonly message: string }; +} + +/** + * Extend an ALREADY-SEEDED orchestration with sub-issues added to the Linear + * epic after the first seed. The seed path is + * idempotent (frozen at first seed) so a graph can't grow on its own; this is + * the additive counterpart, invoked when a labeled parent that already has an + * orchestration is re-triggered. + * + * Diffs the freshly-fetched ``graph`` against the persisted children: + * - existing nodes are LEFT UNTOUCHED (their status/branch/task are preserved + * — we never re-seed or reset a node that already ran), + * - genuinely-new nodes are validated (the augmented graph must stay acyclic), + * then added as ``ready`` (deps all already succeeded, or no deps) or + * ``blocked``, + * - the meta ``child_count`` is bumped. + * + * Idempotent: re-running with no new nodes is a no-op (empty result). A cycle + * introduced by the new edges rejects WITHOUT writing anything. + * + * @param graph the full current sub-issue node set, already augmented with the + * synthetic integration node if the graph fans out, from the same source the + * seed used. + */ +export async function extendOrchestration(params: { + readonly ddb: DynamoDBDocumentClient; + readonly tableName: string; + readonly parentIssueRef: string; + readonly credentialsRef: string; + readonly repo: string; + readonly graph: readonly SubIssueNode[]; + readonly now: string; + readonly ttl?: number; +}): Promise<ExtendOrchestrationResult> { + const { ddb, tableName, parentIssueRef, credentialsRef, repo, graph, now, ttl } = params; + const orchestrationId = deriveOrchestrationId(parentIssueRef); + + const snapshot = await loadOrchestration(ddb, tableName, orchestrationId); + if (!snapshot) { + // No existing orchestration — caller should have seeded, not extended. + return { orchestrationId, addedSubIssueIds: [], releasableSubIssueIds: [] }; + } + + const existingIds = new Set(snapshot.children.map((c) => c.sub_issue_id)); + const newNodes = graph.filter((n) => !existingIds.has(n.id)); + if (newNodes.length === 0) { + return { orchestrationId, addedSubIssueIds: [], releasableSubIssueIds: [] }; + } + + // Validate the AUGMENTED graph (existing + new) — adding nodes/edges must not + // introduce a cycle or a dangling edge. Reject without writing if it does. + const validation = validateDag(graph.map((n) => ({ id: n.id, depends_on: n.depends_on }))); + if (!validation.ok) { + logger.warn('Orchestration extend rejected — augmented graph invalid', { + orchestration_id: orchestrationId, reason: validation.reason, + }); + return { + orchestrationId, + addedSubIssueIds: [], + releasableSubIssueIds: [], + rejected: { reason: validation.reason, message: validation.message }, + }; + } + + // A new node with NO declared dependency must NOT branch off bare + // main — it inherits the epic's accumulated unmerged work by stacking on the + // epic TIP (the existing leaf frontier). We inject that as a synthetic + // ``depends_on`` so the existing dependency gating + base-branch stacking treat + // it like any other dependent; "fall back to main only when merged" is handled + // downstream by the agent's base-fetch fallback. Nodes that DECLARED a + // dependency keep their explicit edges (user intent wins over the tip). + const epicTip = resolveEpicTip(snapshot.children); + const withImplicitDeps = newNodes.map((n) => ({ + node: n, + // Only unconstrained new nodes inherit the tip; and never self-depend + // (the tip is computed from EXISTING nodes, so a new id can't appear). + depends_on: n.depends_on.length > 0 ? n.depends_on : epicTip, + })); + + // A node is immediately releasable iff every predecessor is already + // ``succeeded`` (or it has none). Predecessors may be existing (check their + // persisted status) or other new nodes (not succeeded yet → blocked). + const succeeded = new Set( + snapshot.children.filter((c) => c.child_status === 'succeeded').map((c) => c.sub_issue_id), + ); + const releasable = new Set<string>(); + const newRows: OrchestrationChildRow[] = withImplicitDeps.map(({ node: n, depends_on }) => { + const allDepsSucceeded = depends_on.every((d) => succeeded.has(d)); + if (allDepsSucceeded) releasable.add(n.id); + return { + orchestration_id: orchestrationId, + sub_issue_id: n.id, + ...dualWrite('parent_issue_ref', 'parent_linear_issue_id', parentIssueRef), + ...dualWrite('credentials_ref', 'linear_workspace_id', credentialsRef), + repo, + depends_on, + child_status: allDepsSucceeded ? 'ready' : 'blocked', + ...(n.identifier !== undefined && dualWrite('display_id', 'linear_identifier', n.identifier)), + ...(n.title !== undefined && { title: n.title }), + ...(n.description !== undefined && n.description !== '' && { description: n.description }), + created_at: now, + updated_at: now, + ...(ttl !== undefined && { ttl }), + }; + }); + + // Persist new child rows (chunks of 25), then bump meta child_count. + for (let i = 0; i < newRows.length; i += DDB_BATCH_WRITE_MAX_ITEMS) { + const chunk = newRows.slice(i, i + DDB_BATCH_WRITE_MAX_ITEMS); + await ddb.send(new BatchWriteCommand({ + RequestItems: { [tableName]: chunk.map((Item) => ({ PutRequest: { Item } })) }, + })); + } + // Bump child_count AND clear rollup_posted_at: if this epic had ALREADY + // reached all-terminal and posted its rollup, adding a node re-opens it. + // Clearing the claim lets the reconciler re-settle the parent state to + // complete (re-claim) once the new node finishes — without this, a + // post-completion addition would leave the epic stuck "in progress" forever. + await ddb.send(new UpdateCommand({ + TableName: tableName, + Key: { orchestration_id: orchestrationId, sub_issue_id: PARENT_META_SK }, + UpdateExpression: 'SET child_count = :n, updated_at = :now REMOVE rollup_posted_at', + ExpressionAttributeValues: { ':n': snapshot.children.length + newRows.length, ':now': now }, + })); + + logger.info('Orchestration extended', { + orchestration_id: orchestrationId, + parent_issue_ref: parentIssueRef, + added: newRows.length, + releasable: releasable.size, + added_ids: newRows.map((r) => r.sub_issue_id), + }); + + return { + orchestrationId, + addedSubIssueIds: newRows.map((r) => r.sub_issue_id), + releasableSubIssueIds: [...releasable], + }; +} + +/** + * Claim the right to post the parent rollup comment exactly once. + * The orchestration can reach "all children terminal" on more than + * one TaskTable-stream event (the last child's record often gets two + * MODIFYs — e.g. status→COMPLETED then pr_url/build_passed written — both + * observing all-terminal), which without a guard posts the rollup twice. + * + * Conditionally stamps ``rollup_posted_at`` on the parent-meta row. The + * first caller wins (returns true → post the comment); a racing/repeat + * caller loses the conditional write (returns false → skip). Mirrors the + * release-flip idempotency pattern. + */ +export async function claimRollup( + ddb: DynamoDBDocumentClient, + tableName: string, + orchestrationId: string, + now: string, +): Promise<boolean> { + try { + await ddb.send(new UpdateCommand({ + TableName: tableName, + Key: { orchestration_id: orchestrationId, sub_issue_id: PARENT_META_SK }, + UpdateExpression: 'SET rollup_posted_at = :now', + ConditionExpression: 'attribute_not_exists(rollup_posted_at)', + ExpressionAttributeValues: { ':now': now }, + })); + return true; + } catch (err) { + if ((err as { name?: string })?.name === 'ConditionalCheckFailedException') return false; + throw err; + } +} + +/** + * Release the once-only rollup claim so a RE-COMPLETING epic can re-settle its + * parent state. When an already-completed epic re-opens + * (a cascade/iteration revives it), the ``rollup_posted_at`` stamp from the + * FIRST completion would otherwise make {@link claimRollup} fail forever — so + * the panel body re-settles to ✅ but the parent reaction/state never re-mirror + * (stuck on 👀/In Progress). ``extendOrchestration`` already clears it on the + * extend path; the cascade re-open path must too. Best-effort; unconditional + * REMOVE (idempotent — a no-op when already absent). + */ +export async function clearRollupClaim( + ddb: DynamoDBDocumentClient, + tableName: string, + orchestrationId: string, + now: string, +): Promise<void> { + await ddb.send(new UpdateCommand({ + TableName: tableName, + Key: { orchestration_id: orchestrationId, sub_issue_id: PARENT_META_SK }, + UpdateExpression: 'SET updated_at = :now REMOVE rollup_posted_at', + ExpressionAttributeValues: { ':now': now }, + })); +} + +/** + * Persist a sub-issue's OWN screened attachments on its child row: a + * human-authored sub-issue can carry a file attached to IT, distinct from + * the parent epic's. Written as a native list (createTaskCore's records are + * plain JSON objects; DynamoDB stores nested lists/maps directly, so + * ``loadOrchestration``'s row cast reads them back as ``AttachmentRecord[]`` with + * no bespoke parse). Conditional on the child row existing so a racing TTL reap + * can't resurrect it. Best-effort at the call site — a child's own attachment is + * enrichment on top of the load-bearing inherited parent spec. + */ +export async function setChildOwnAttachments( + ddb: DynamoDBDocumentClient, + tableName: string, + orchestrationId: string, + subIssueId: string, + records: readonly AttachmentRecord[], + now: string, +): Promise<void> { + if (records.length === 0) return; + await ddb.send(new UpdateCommand({ + TableName: tableName, + Key: { orchestration_id: orchestrationId, sub_issue_id: subIssueId }, + UpdateExpression: 'SET pre_screened_attachments = :recs, updated_at = :now', + ConditionExpression: 'attribute_exists(sub_issue_id)', + ExpressionAttributeValues: { ':recs': records, ':now': now }, + })); +} + +/** + * Claim the one-time "I responded to this comment" marker so a webhook + * REDELIVERY doesn't re-post. Linear redelivers + * a comment webhook when the handler exceeds its ~5s ack window; without a + * claim, the parent-epic disambiguation reply re-posted on every redelivery + * (50+ duplicates). Keyed on the orchestration + the triggering comment id, so + * the FIRST delivery wins and every redelivery is a no-op. The marker carries a + * TTL (the table's ``ttl`` attribute) so these rows self-expire — they're only + * needed for the redelivery window. Returns true only for the first caller. + * + * @param ttlEpochSeconds absolute epoch-seconds expiry for the marker row. + */ +export async function claimCommentAck( + ddb: DynamoDBDocumentClient, + tableName: string, + orchestrationId: string, + commentId: string, + now: string, + ttlEpochSeconds: number, +): Promise<boolean> { + try { + await ddb.send(new UpdateCommand({ + TableName: tableName, + Key: { orchestration_id: orchestrationId, sub_issue_id: `ack#${commentId}` }, + // attribute_not_exists on the PK is the standard "create-once" guard — + // a replay finds the row present and the condition fails. ``ttl`` is a + // DynamoDB reserved keyword → must be aliased via ExpressionAttributeNames. + UpdateExpression: 'SET acked_at = :now, #ttl = :ttl', + ConditionExpression: 'attribute_not_exists(orchestration_id)', + ExpressionAttributeNames: { '#ttl': 'ttl' }, + ExpressionAttributeValues: { ':now': now, ':ttl': ttlEpochSeconds }, + })); + return true; + } catch (err) { + if ((err as { name?: string })?.name === 'ConditionalCheckFailedException') return false; + throw err; + } +} + +/** Sort-key of the parent-meta row. Exported so the reconciler can + * separate it from child rows after a Query. */ +export const ORCHESTRATION_META_SK = PARENT_META_SK; + +/** Parsed parent-meta row, including the reconciler's release context. */ +export interface OrchestrationMeta { + readonly orchestration_id: string; + readonly parent_issue_ref: string; + readonly credentials_ref: string; + readonly repo: string; + readonly child_count: number; + readonly release_context: OrchestrationReleaseContext; + /** + * Linear comment id of the live status block, stamped at seed. + * The reconciler edits this comment in place on each child transition and + * one last time with the final rollup. Absent if the seed-time create + * failed (best-effort) — the reconciler then falls back to a fresh + * comment for the final rollup. + */ + readonly status_comment_id?: string; + /** + * The ``@bgagent retry`` comment that re-ran this epic, if one did. + * + * Recorded so the epic's next settle can move that comment's marker off the + * receipt 👀 to the actual outcome. The retry path acks the comment and then + * hands off to the reconciler, which is driven by task events and so has no + * other way to learn which comment asked for the run — which left the one view + * the user is watching stuck on "looking at it" indefinitely. Cleared once + * settled, so a later settle can't re-swap a marker on an old comment. + */ + readonly retry_comment_id?: string; +} + +/** + * Stamp the live status-block comment id on the parent-meta row. + * Called once at seed after the comment is created. Best-effort; a failure + * just means the reconciler can't edit-in-place and posts a fresh final + * rollup instead. Not conditional — the single seed path is the only writer. + */ +export async function setStatusCommentId( + ddb: DynamoDBDocumentClient, + tableName: string, + orchestrationId: string, + commentId: string, +): Promise<void> { + await ddb.send(new UpdateCommand({ + TableName: tableName, + Key: { orchestration_id: orchestrationId, sub_issue_id: PARENT_META_SK }, + UpdateExpression: 'SET status_comment_id = :cid', + ExpressionAttributeValues: { ':cid': commentId }, + })); +} + +/** + * Record (or clear) the ``@bgagent retry`` comment awaiting an outcome marker. + * + * Pass a comment id when a retry starts; pass undefined once its marker has been + * settled, so the next settle of the same epic doesn't re-swap a stale comment. + * Best-effort like the panel-id write: failing to record it costs a marker + * update, never the retry itself. + */ +export async function setRetryCommentId( + ddb: DynamoDBDocumentClient, + tableName: string, + orchestrationId: string, + commentId: string | undefined, +): Promise<void> { + await ddb.send(new UpdateCommand({ + TableName: tableName, + Key: { orchestration_id: orchestrationId, sub_issue_id: PARENT_META_SK }, + ...(commentId === undefined + ? { UpdateExpression: 'REMOVE retry_comment_id' } + : { + UpdateExpression: 'SET retry_comment_id = :cid', + ExpressionAttributeValues: { ':cid': commentId }, + }), + })); +} + +/** All rows for one orchestration: the meta row + every child row. */ +export interface OrchestrationSnapshot { + readonly meta: OrchestrationMeta; + readonly children: readonly OrchestrationChildRow[]; +} + +/** + * Load every row for an orchestration (meta + children). Returns null when the + * orchestration id has no rows (e.g. TTL-reaped). The reconciler calls this after + * resolving a terminal child's orchestration via the ChildTaskIndex GSI. + * + * Paginates: a DynamoDB Query returns at most one 1MB page, so a large epic + * (many children + accumulated ``ack#`` marker rows) would otherwise silently + * truncate — dropping children from the completion check / panel and stranding + * the epic. Follows ``LastEvaluatedKey`` to read all rows. Every paginated read + * of this table must do the same: a single page is a silent partial answer here, + * not an error, so the truncation shows up as a wrong decision rather than a + * failure. + */ +export async function loadOrchestration( + ddb: DynamoDBDocumentClient, + tableName: string, + orchestrationId: string, +): Promise<OrchestrationSnapshot | null> { + const items: Array<Record<string, unknown>> = []; + let exclusiveStartKey: Record<string, unknown> | undefined; + do { + const res: import('@aws-sdk/lib-dynamodb').QueryCommandOutput = await ddb.send(new QueryCommand({ + TableName: tableName, + KeyConditionExpression: 'orchestration_id = :oid', + ExpressionAttributeValues: { ':oid': orchestrationId }, + ...(exclusiveStartKey && { ExclusiveStartKey: exclusiveStartKey }), + })); + items.push(...((res.Items ?? []) as Array<Record<string, unknown>>)); + exclusiveStartKey = res.LastEvaluatedKey; + } while (exclusiveStartKey); + if (items.length === 0) return null; + + const metaItem = items.find((i) => i.sub_issue_id === PARENT_META_SK); + if (!metaItem) { + // A non-epic issue accumulates dedup MARKER rows (``ack#…``) under the same + // derived id without ever being seeded, so "rows but no meta" is the normal + // shape there, not a broken orchestration — a plain `@bgagent` on any + // plain issue reaches this. Only warn when a REAL child row is + // present, which is the genuinely inconsistent case; otherwise this cried wolf + // on a healthy path and taught readers to ignore the log. + const hasRealChild = items.some((i) => !String(i.sub_issue_id).includes('#')); + const detail = { orchestration_id: orchestrationId, row_count: items.length }; + if (hasRealChild) logger.warn('Orchestration child rows present but the meta row is missing', detail); + else logger.info('No orchestration for this issue — only dedup markers exist', detail); + return null; + } + + const children = items + // Exclude the meta row AND non-child marker rows (e.g. ``ack#<commentId>`` + // dedup markers) — only real sub-issue rows are children. A real child SK is + // an issue id or the ``…__integration`` synthetic id; markers use a + // ``<kind>#`` prefix that no real SK has. + .filter((i) => i.sub_issue_id !== PARENT_META_SK && !String(i.sub_issue_id).includes('#')) + .map(toChildRow); + + const preScreened = parsePreScreenedAttachments(metaItem.pre_screened_attachments_json, orchestrationId); + + const meta: OrchestrationMeta = { + orchestration_id: orchestrationId, + // Renamed attributes — read either spelling so a pre-rename epic still settles. + parent_issue_ref: readRenamed(metaItem, 'parent_issue_ref', 'parent_linear_issue_id') ?? '', + credentials_ref: readRenamed(metaItem, 'credentials_ref', 'linear_workspace_id') ?? '', + repo: metaItem.repo as string, + child_count: (metaItem.child_count as number) ?? children.length, + release_context: { + platform_user_id: metaItem.platform_user_id as string, + ...(metaItem.channel_source !== undefined && { + channel_source: metaItem.channel_source as string, + }), + ...(metaItem.linear_oauth_secret_arn !== undefined && { + linear_oauth_secret_arn: metaItem.linear_oauth_secret_arn as string, + }), + ...(metaItem.linear_workspace_slug !== undefined && { + linear_workspace_slug: metaItem.linear_workspace_slug as string, + }), + ...(metaItem.linear_project_id !== undefined && { + linear_project_id: metaItem.linear_project_id as string, + }), + ...(preScreened.length > 0 && { pre_screened_attachments: preScreened }), + }, + ...(metaItem.retry_comment_id !== undefined && { + retry_comment_id: metaItem.retry_comment_id as string, + }), + ...(metaItem.status_comment_id !== undefined && { + status_comment_id: metaItem.status_comment_id as string, + }), + }; + + return { meta, children }; +} + +/** + * Resolve a released child by its head branch, via the ChildBranchIndex GSI. + * Maps a branch name back to the child row (which carries + * ``orchestration_id`` + ``sub_issue_id``). + * + * RETAINED, currently unused. This backed the original GitHub + * ``pull_request`` restack trigger, which was replaced by + * a Linear-comment trigger + reconciler-driven cascade (the cascade resolves + * the changed node by sub_issue_id, not by branch). The helper + its GSI are + * deliberately kept rather than removed: dropping a GSI is a + * CFN-update-unfriendly stack change for zero functional gain, and a + * branch→child lookup is a plausible future need (e.g. a branch-delete + * cleanup path). If it stays unused long-term, remove the helper and the GSI + * together in a dedicated migration. + * + * Returns the child row, or null if no released child owns that branch. The + * GSI is sparse — only released children carry ``child_branch_name`` — so a + * miss is the common, cheap case. ``indexName`` is injected (the CDK construct + * owns the literal) to keep this module free of a CDK dependency. + */ +export async function findOrchestrationChildByBranch( + ddb: DynamoDBDocumentClient, + tableName: string, + indexName: string, + branchName: string, +): Promise<OrchestrationChildRow | null> { + const res = await ddb.send(new QueryCommand({ + TableName: tableName, + IndexName: indexName, + KeyConditionExpression: 'child_branch_name = :b', + ExpressionAttributeValues: { ':b': branchName }, + Limit: 1, + })); + const item = res.Items?.[0]; + // Marshalled, not cast: a raw cast would type-check while leaving the renamed + // attributes unread, so a row written under either naming would come back with + // empty parent/credentials refs. + return item ? toChildRow(item) : null; +} diff --git a/cdk/src/handlers/shared/orchestrator.ts b/cdk/src/handlers/shared/orchestrator.ts index 7308c4532..5e38ba561 100644 --- a/cdk/src/handlers/shared/orchestrator.ts +++ b/cdk/src/handlers/shared/orchestrator.ts @@ -168,16 +168,16 @@ export async function transitionTask( })); } -/** Correlation envelope stamped on orchestrator-plane task events (#245). */ +/** Correlation envelope stamped on orchestrator-plane task events. */ export interface EventCorrelation { readonly user_id?: string; - /** `owner/repo`, or absent for repo-less workflows (#248 Phase 3). Matches - * the source `TaskRecord.repo` (`string | undefined`). */ + /** `owner/repo`, or absent for repo-less workflows (those with no target + * repository). Matches the source `TaskRecord.repo` (`string | undefined`). */ readonly repo?: string; } /** - * Build the correlation envelope (#245) for a task from its identity fields: + * Build the correlation envelope for a task from its identity fields: * a `log` child stamping `{task_id, user_id, repo}` on every line, and the * matching `correlation` for `emitTaskEvent`. Single source so the log context * and the event envelope can't drift. `repo` is omitted (not null/empty) for @@ -199,8 +199,8 @@ export function envelopeFor( * @param eventType - the event type string. * @param metadata - optional event metadata. * @param correlation - optional `{ user_id, repo }` stamped as top-level fields - * so the event stream joins to orchestrator logs by the correlation envelope - * (#245). `repo` is omitted when null/absent (repo-less workflows). + * so the event stream joins to orchestrator logs by the correlation envelope. + * `repo` is omitted when null/absent (repo-less workflows). */ export async function emitTaskEvent( taskId: string, @@ -234,12 +234,13 @@ const MAX_POLL_INTERVAL_MS = 300_000; * @returns the merged blueprint config. */ export async function loadBlueprintConfig(task: TaskRecord): Promise<BlueprintConfig> { - // Correlation envelope (#245) on this shared function's logs too, matching the + // Correlation envelope on this shared function's logs too, matching the // orchestrate handler's child logger. `repo` omitted for repo-less workflows. const { log } = envelopeFor(task); - // Repo-less workflows (#248 Phase 3) have no per-repo Blueprint — use platform - // defaults directly rather than a RepoTable lookup on a missing repo. + // Repo-less workflows (those with no target repository) have no per-repo + // Blueprint — use platform defaults directly rather than a RepoTable lookup + // on a missing repo. const repoConfig = task.repo ? await loadRepoConfig(task.repo) : null; if (repoConfig) { @@ -268,6 +269,17 @@ export async function loadBlueprintConfig(task: TaskRecord): Promise<BlueprintCo } } + // Compute substrate is a per-repo property (``compute_type``, default + // ``agentcore``). It applies to ALL workflows on the repo — including a + // read-only pr-review task, because that task CLONES and + // READS the same repository the coding agent does, so its context/memory + // footprint is the same: a repo big enough to need the context-gated ECS + // tier for building is also big enough to OOM the fixed AgentCore microVM just + // reading it. So planning must run on the same substrate as the agent — do NOT + // special-case read-only workflows to agentcore. (An ecs-configured repo on a + // stack that hasn't wired the ECS substrate fails at session start; that's a + // stack-config gap surfaced by the honest "couldn't plan, nothing run — re-apply + // or run as single" note, not something to paper over by mis-routing compute.) return { compute_type: repoConfig?.compute_type ?? 'agentcore', runtime_arn: repoConfig?.runtime_arn ?? RUNTIME_ARN, @@ -277,6 +289,8 @@ export async function loadBlueprintConfig(task: TaskRecord): Promise<BlueprintCo system_prompt_overrides: repoConfig?.system_prompt_overrides, github_token_secret_arn: repoConfig?.github_token_secret_arn ?? process.env.GITHUB_TOKEN_SECRET_ARN, poll_interval_ms: pollIntervalMs, + build_command: repoConfig?.build_command, + lint_command: repoConfig?.lint_command, cedar_policies: repoConfig?.cedar_policies, approval_gate_cap: repoConfig?.approval_gate_cap, }; @@ -335,9 +349,9 @@ function resolveAttachmentPayloads( } /** - * Cedar HITL Chunk 7b: structural guard on the TaskRecord's persisted - * ``approval_gate_cap`` before we thread it into the agent payload. The - * submit path bounds-checks the blueprint value before writing it + * Structural guard on the TaskRecord's persisted ``approval_gate_cap`` (the + * human-in-the-loop approval limit) before we thread it into the agent payload. + * The submit path bounds-checks the blueprint value before writing it * (``create-task-core.ts``), so under a clean flow this guard is * tautologically satisfied. It exists to catch hand-edited DDB rows / * schema drift — a corrupted value would otherwise crash the agent's @@ -430,8 +444,8 @@ export async function hydrateAndTransition(task: TaskRecord, blueprintConfig?: B // max_budget_usd uses 2-tier override (no platform default — absent means unlimited). const effectiveBudget = task.max_budget_usd ?? blueprintConfig?.max_budget_usd; - // Chunk 7b: warn if a persisted approval_gate_cap is present but not - // a valid integer in [APPROVAL_GATE_CAP_MIN, APPROVAL_GATE_CAP_MAX]. + // Warn if a persisted approval_gate_cap is present but not a valid integer + // in [APPROVAL_GATE_CAP_MIN, APPROVAL_GATE_CAP_MAX]. // The payload block below silently omits invalid values (rather than // crashing container start on PolicyEngine.__init__), but the only // way to reach this branch is schema drift or a hand-edited DDB row, @@ -528,10 +542,10 @@ export async function hydrateAndTransition(task: TaskRecord, blueprintConfig?: B task_id: task.task_id, // user_id is required by the agent ONLY when ``trace`` is true — // the agent writes the trajectory to - // ``traces/<user_id>/<task_id>.jsonl.gz`` (design §10.1) and the - // handler's per-caller-prefix guard relies on the agent landing - // under the submitter's prefix. Threaded unconditionally so - // scripts that inspect the payload can always see it; costs one + // ``traces/<user_id>/<task_id>.jsonl.gz`` and the handler's + // per-caller-prefix guard relies on the agent landing under the + // submitter's prefix. Threaded unconditionally so scripts that + // inspect the payload can always see it; costs one // Cognito-sub-sized string in the JSON. user_id: task.user_id, branch_name: hydratedContext.resolved_branch_name ?? task.branch_name, @@ -539,6 +553,15 @@ export async function hydrateAndTransition(task: TaskRecord, blueprintConfig?: B resolved_workflow: task.resolved_workflow ?? { id: 'coding/new-task-v1', version: '1.0.0' }, ...(task.pr_number !== undefined && { pr_number: task.pr_number }), ...(hydratedContext.resolved_base_branch && { base_branch: hydratedContext.resolved_base_branch }), + // Orchestration children carry their stacked base branch + (diamond + // dependency case) predecessor branches to merge in, via channel_metadata. + // The PR-task ``resolved_base_branch`` path above wins if both are set + // (a task is never both a PR-iteration and an orchestration child). + ...(!hydratedContext.resolved_base_branch + && task.channel_metadata?.orchestration_base_branch + && { base_branch: task.channel_metadata.orchestration_base_branch }), + ...(task.channel_metadata?.orchestration_merge_branches + && { merge_branches: parseMergeBranches(task.channel_metadata.orchestration_merge_branches) }), ...(task.task_description && { prompt: task.task_description }), max_turns: task.max_turns ?? blueprintConfig?.max_turns ?? DEFAULT_MAX_TURNS, ...(effectiveBudget !== undefined && { max_budget_usd: effectiveBudget }), @@ -548,37 +571,42 @@ export async function hydrateAndTransition(task: TaskRecord, blueprintConfig?: B ...(task.trace === true && { trace: true }), ...(blueprintConfig?.model_id && { model_id: blueprintConfig.model_id }), ...(blueprintConfig?.system_prompt_overrides && { system_prompt_overrides: blueprintConfig.system_prompt_overrides }), + // Per-repo build/lint verification commands. Absent → agent defaults + // to ``mise run build`` / ``mise run lint``. Set for non-mise repos so + // build-regression gating actually runs the repo's real command. + ...(blueprintConfig?.build_command && { build_command: blueprintConfig.build_command }), + ...(blueprintConfig?.lint_command && { lint_command: blueprintConfig.lint_command }), ...(blueprintConfig?.cedar_policies && blueprintConfig.cedar_policies.length > 0 && { cedar_policies: blueprintConfig.cedar_policies }), - // Cedar HITL: the agent's PreToolUse hook uses this to compute - // the maxLifetime ceiling on per-gate approval timeouts (§6.5). + // The agent's PreToolUse hook uses this to compute the maxLifetime + // ceiling on per-gate human-in-the-loop approval timeouts. // Stamped at HYDRATING → RUNNING transition time so the clock // only starts when the container is alive. Format is ISO 8601 // UTC to match the rest of the TaskRecord timestamp fields. task_started_at: new Date().toISOString(), - // Cedar HITL pre-approval data (§7.3). Threaded so the agent's + // Human-in-the-loop pre-approval data. Threaded so the agent's // PolicyEngine can seed ApprovalAllowlist + set // task_default_timeout_s without a second DDB round-trip. ...(task.approval_timeout_s !== undefined && { approval_timeout_s: task.approval_timeout_s }), ...(task.initial_approvals && task.initial_approvals.length > 0 && { initial_approvals: task.initial_approvals }), - // Cedar HITL Chunk 7 (§13.6): seed the engine's per-task gate - // counter from the TaskTable-persisted value so a container - // restart mid-task resumes the cumulative gate budget instead of - // resetting to 0. Only forwarded when non-zero so the agent's - // default (0) path remains unchanged for fresh tasks; the - // TaskRecord is already loaded above, so no extra DDB read. + // Seed the engine's per-task approval-gate counter from the + // TaskTable-persisted value so a container restart mid-task resumes + // the cumulative gate budget instead of resetting to 0. Only + // forwarded when non-zero so the agent's default (0) path remains + // unchanged for fresh tasks; the TaskRecord is already loaded above, + // so no extra DDB read. ...(typeof task.approval_gate_count === 'number' && task.approval_gate_count > 0 && { initial_approval_gate_count: task.approval_gate_count, }), - // Cedar HITL Chunk 7b (§4 step 5, decision #13): thread the - // TaskRecord-persisted cap so the agent's PolicyEngine adopts the - // blueprint-configured value (or the platform default of 50 frozen - // at submit-time) instead of its compile-time fallback. Legacy task - // records predating Chunk 7b omit the field — the agent then falls - // back to its own default of 50. Extra guards past ``typeof`` - // because a hand-edited DDB row could carry NaN, Infinity, a float, - // or an out-of-bounds value — all would crash PolicyEngine.__init__ - // downstream; omitting here keeps the container starting cleanly - // with the engine default and leaves an operator-visible warning. + // Thread the TaskRecord-persisted approval-gate cap so the agent's + // PolicyEngine adopts the blueprint-configured value (or the platform + // default of 50 frozen at submit-time) instead of its compile-time + // fallback. Older task records that predate this field omit it — the + // agent then falls back to its own default of 50. Extra guards past + // ``typeof`` because a hand-edited DDB row could carry NaN, Infinity, + // a float, or an out-of-bounds value — all would crash + // PolicyEngine.__init__ downstream; omitting here keeps the container + // starting cleanly with the engine default and leaves an + // operator-visible warning. ...(isValidApprovalGateCap(task.approval_gate_cap) && { approval_gate_cap: task.approval_gate_cap }), prompt_version: promptVersion, @@ -688,8 +716,8 @@ export async function finalizeTask( ): Promise<void> { const task = await loadTask(taskId); const currentStatus = task.status; - // Correlation envelope (#245) on this function's own log lines too, not just - // the events it emits — admission→terminal logs must join by {user_id, repo}. + // Correlation envelope on this function's own log lines too, not just the + // events it emits — admission→terminal logs must join by {user_id, repo}. const { log, correlation } = envelopeFor(task); // Lost session: RUNNING but agent heartbeats stopped (crash/OOM) — fail fast @@ -774,7 +802,7 @@ export async function finalizeTask( logger, ); // Memory actorId: repo for coding tasks, user:{user_id} for repo-less - // workflows (#248 Phase 3, ADR-014 addendum 2026-06-08). + // workflows (those with no target repository). const actorNamespace = task.repo ?? `user:${task.user_id}`; const written = await writeMinimalEpisode( MEMORY_ID, @@ -804,9 +832,9 @@ export async function finalizeTask( // If still RUNNING / FINALIZING / AWAITING_APPROVAL after the poll // window closes, transition to TIMED_OUT. AWAITING_APPROVAL uses the - // same transition — the stranded-approval reconciler (Chunk 5, - // §13.6) is a secondary safety net with a longer timeout for tasks - // the orchestrator already lost track of. + // same transition — the stranded-approval reconciler is a secondary + // safety net with a longer timeout for tasks the orchestrator already + // lost track of. if ( currentStatus === TaskStatus.RUNNING || currentStatus === TaskStatus.FINALIZING @@ -866,7 +894,7 @@ export async function finalizeTask( * @param userId - the user who owns the task. * @param releaseConcurrency - whether to decrement the concurrency counter. * @param repo - optional target repo (`owner/repo`) for the correlation - * envelope (#245); omit for repo-less workflows. + * envelope; omit for repo-less workflows. */ export async function failTask( taskId: string, @@ -925,3 +953,26 @@ async function decrementConcurrency(userId: string): Promise<void> { } } } + +/** + * Parse the JSON-encoded predecessor merge-branch list that the + * orchestration release path stashes in + * ``channel_metadata.orchestration_merge_branches`` (the diamond-dependency + * case, where a child depends on more than one predecessor). Best-effort: a + * malformed value yields an empty list rather than failing the orchestration — + * the child still branches off its base, it just won't have the predecessor + * code merged in (surfaced as a normal build failure if it actually needed it, + * never a silent crash here). + */ +function parseMergeBranches(raw: string): string[] { + try { + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed) && parsed.every((b) => typeof b === 'string')) { + return parsed as string[]; + } + } catch { + // fall through + } + logger.warn('Ignoring malformed orchestration_merge_branches', { raw }); + return []; +} diff --git a/cdk/src/handlers/shared/screenshot-url.ts b/cdk/src/handlers/shared/screenshot-url.ts index a5b4f16e0..2a1f0581e 100644 --- a/cdk/src/handlers/shared/screenshot-url.ts +++ b/cdk/src/handlers/shared/screenshot-url.ts @@ -124,3 +124,23 @@ export function buildScreenshotKey(repo: string, sha: string, deploymentId?: num export function encodeMarkdownUrl(rawUrl: string): string { return rawUrl.replaceAll('(', '%28').replaceAll(')', '%29'); } + +/** + * Pull the ABCA ``taskId`` out of a deploy PR's head branch (#247 — parent + * panel combined screenshot). ABCA names every task branch + * ``bgagent/{taskId}/{slug}`` (see ``generateBranchName``), so the task id is + * always the SECOND path segment. Returns null for any branch that doesn't + * match the ABCA shape (a human-created branch, a fork default, etc.) so the + * screenshot pipeline simply skips persistence for non-ABCA deploys. + */ +// ``bgagent`` / ``{taskId}`` / ``{slug…}`` — the ABCA branch shape needs at +// least these three segments before a task id can be extracted. +const MIN_ABCA_BRANCH_SEGMENTS = 3; + +export function extractTaskIdFromBranch(branchName: string | null | undefined): string | null { + if (!branchName) return null; + const parts = branchName.split('/'); + if (parts.length < MIN_ABCA_BRANCH_SEGMENTS || parts[0] !== 'bgagent') return null; + const taskId = parts[1]; + return taskId && taskId.length > 0 ? taskId : null; +} diff --git a/cdk/src/handlers/shared/slack-api.ts b/cdk/src/handlers/shared/slack-api.ts index f76ba4c1c..71ef63b9d 100644 --- a/cdk/src/handlers/shared/slack-api.ts +++ b/cdk/src/handlers/shared/slack-api.ts @@ -70,3 +70,45 @@ export async function slackFetch( return false; } } + +/** + * POST to a Slack Web API method and return the message timestamp it produced. + * + * Distinct from {@link slackFetch} because a boolean is enough for fire-and-forget + * reactions but NOT for a message the caller must edit later: Slack addresses a + * message by its ``ts``, so a status panel that matures in place has to capture + * the one it created. Returns null on any failure (same best-effort contract). + */ +export async function slackFetchTs( + botToken: string, + method: string, + body: Record<string, unknown>, +): Promise<string | null> { + try { + const response = await fetch(`https://slack.com/api/${method}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': `Bearer ${botToken}`, + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + logger.warn('Slack API returned non-2xx', { method, status: response.status }); + return null; + } + const result = await response.json() as { ok: boolean; error?: string; ts?: string }; + if (!result.ok) { + logger.warn('Slack API returned error', { method, error: result.error }); + return null; + } + // chat.update echoes the ts it edited; chat.postMessage returns the new one. + return result.ts ?? null; + } catch (err) { + logger.warn('Slack API fetch threw', { + method, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} diff --git a/cdk/src/handlers/shared/strategies/ecs-strategy.ts b/cdk/src/handlers/shared/strategies/ecs-strategy.ts index a45ef1290..c685d94f6 100644 --- a/cdk/src/handlers/shared/strategies/ecs-strategy.ts +++ b/cdk/src/handlers/shared/strategies/ecs-strategy.ts @@ -22,6 +22,7 @@ import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client import type { ComputeStrategy, SessionHandle, SessionStatus } from '../compute-strategy'; import { logger } from '../logger'; import type { BlueprintConfig } from '../repo-config'; +import { DEFAULT_MAX_TURNS } from '../validation'; let sharedClient: ECSClient | undefined; function getClient(): ECSClient { @@ -41,7 +42,39 @@ function getS3Client(): S3Client { const ECS_CLUSTER_ARN = process.env.ECS_CLUSTER_ARN; const ECS_TASK_DEFINITION_ARN = process.env.ECS_TASK_DEFINITION_ARN; +/** + * The smaller, read-only "planning" task definition. Read-only workflows (which + * clone and read the repo to produce a plan but never build) run here instead of + * on the large build task. Falls back to the build task definition when unset + * (e.g. a deployment that hasn't provisioned the planning task) — never worse + * than running everything on the build task. + */ +const ECS_PLANNING_TASK_DEFINITION_ARN = process.env.ECS_PLANNING_TASK_DEFINITION_ARN; const ECS_SUBNETS = process.env.ECS_SUBNETS; + +/** + * Reduce a task-definition reference to its FAMILY (drop the `:revision` suffix), + * so `RunTask` always resolves the LATEST ACTIVE revision instead of a pinned one. + * + * Why: the orchestrator env carries a revision-pinned ARN + * (`…:task-definition/<family>:<rev>`, from `taskDefinition.taskDefinitionArn`). + * Every deploy that rebuilds the agent image registers a NEW revision and CDK/ECS + * deregisters the old one. A task dispatched against the now-stale pinned revision + * (e.g. minutes after a deploy) fails at RunTask with + * "InvalidParameterException: TaskDefinition is inactive". ECS accepts `family` + * (bare, no revision) and resolves it to the latest ACTIVE revision at call time, + * which is immune to that deploy race. We accept either a full ARN + * (`arn:aws:ecs:…:task-definition/<family>:<rev>`) or a plain `<family>:<rev>` and + * return just `<family>`; a value with no `/` and no `:` is returned unchanged. + */ +export function toTaskDefinitionFamily(ref: string): string { + // Take the segment after `task-definition/` when it's a full ARN, else the whole + // value; then strip a trailing `:<digits>` revision suffix. + const afterSlash = ref.includes('task-definition/') + ? ref.slice(ref.lastIndexOf('task-definition/') + 'task-definition/'.length) + : ref; + return afterSlash.replace(/:\d+$/, ''); +} const ECS_SECURITY_GROUP = process.env.ECS_SECURITY_GROUP; const ECS_CONTAINER_NAME = process.env.ECS_CONTAINER_NAME ?? 'AgentContainer'; const ECS_PAYLOAD_BUCKET = process.env.ECS_PAYLOAD_BUCKET; @@ -50,7 +83,7 @@ const ECS_PAYLOAD_BUCKET = process.env.ECS_PAYLOAD_BUCKET; * Inline-payload size (bytes) above which we warn that RunTask will likely * reject the call when no payload bucket is configured. ECS caps the TOTAL * containerOverrides blob at 8192 bytes; the other env vars + command consume - * some of that, so 6 KB of payload is the practical danger line (#502). + * some of that, so 6 KB of payload is the practical danger line. */ const INLINE_PAYLOAD_WARN_BYTES = 6144; @@ -96,6 +129,7 @@ export class EcsComputeStrategy implements ComputeStrategy { userId: string; payload: Record<string, unknown>; blueprintConfig: BlueprintConfig; + readOnly?: boolean; }): Promise<SessionHandle> { if (!ECS_CLUSTER_ARN || !ECS_TASK_DEFINITION_ARN || !ECS_SUBNETS || !ECS_SECURITY_GROUP) { // Config/deploy mismatch: this repo is compute_type=ecs but the stack was @@ -113,7 +147,22 @@ export class EcsComputeStrategy implements ComputeStrategy { } const subnets = ECS_SUBNETS.split(',').map(s => s.trim()).filter(Boolean); - const { taskId, payload, blueprintConfig } = input; + const { taskId, payload, blueprintConfig, readOnly } = input; + + // A read-only workflow (e.g. planning that only clones and reads the repo) + // runs on the smaller planning task def when it's wired; everything else runs + // on the large build def. Falls back to the build def if the planning def + // isn't configured — safe, just runs planning on the build-sized task. + const taskDefinitionRef = readOnly && ECS_PLANNING_TASK_DEFINITION_ARN + ? ECS_PLANNING_TASK_DEFINITION_ARN + : ECS_TASK_DEFINITION_ARN; + // Dispatch against the task-def FAMILY (not the pinned revision) so ECS + // resolves the latest ACTIVE revision at call time. A deploy that rebuilds the + // agent image registers a new revision + deregisters the old one; a task + // dispatched minutes after a deploy against the stale pinned revision fails + // with "InvalidParameterException: TaskDefinition is inactive". Using the + // family is immune to that deploy race. + const taskDefinition = toTaskDefinitionFamily(taskDefinitionRef); // The ECS container's default CMD starts the FastAPI server (uvicorn) which // waits for HTTP POST to /invocations — but in standalone ECS nobody sends @@ -122,7 +171,7 @@ export class EcsComputeStrategy implements ComputeStrategy { // This avoids the server entirely and runs the agent in batch mode. const payloadJson = JSON.stringify(payload); - // #502: the payload (esp. hydrated_context) routinely exceeds the 8192-byte + // The payload (especially hydrated_context) routinely exceeds the 8192-byte // cap that ECS RunTask enforces on the TOTAL containerOverrides blob, which // rejected the call with InvalidParameterException. Write the payload to S3 // and pass only a small pointer (AGENT_PAYLOAD_S3_URI); the container fetches @@ -158,14 +207,18 @@ export class EcsComputeStrategy implements ComputeStrategy { { name: 'REPO_URL', value: String(payload.repo_url ?? '') }, ...(payload.prompt ? [{ name: 'TASK_DESCRIPTION', value: String(payload.prompt) }] : []), ...(payload.issue_number ? [{ name: 'ISSUE_NUMBER', value: String(payload.issue_number) }] : []), - { name: 'MAX_TURNS', value: String(payload.max_turns ?? 100) }, + // Single source of truth with the hydrate path in `orchestrator.ts`, which + // resolves the same default via `DEFAULT_MAX_TURNS`. A literal here would + // silently disagree with it whenever the payload omits `max_turns`, and a + // turn ceiling that depends on which code path filled it in is a bug. + { name: 'MAX_TURNS', value: String(payload.max_turns ?? DEFAULT_MAX_TURNS) }, ...(payload.max_budget_usd !== undefined ? [{ name: 'MAX_BUDGET_USD', value: String(payload.max_budget_usd) }] : []), ...(blueprintConfig.model_id ? [{ name: 'ANTHROPIC_MODEL', value: blueprintConfig.model_id }] : []), ...(blueprintConfig.system_prompt_overrides ? [{ name: 'SYSTEM_PROMPT_OVERRIDES', value: blueprintConfig.system_prompt_overrides }] : []), { name: 'CLAUDE_CODE_USE_BEDROCK', value: '1' }, - // #502: prefer the S3 pointer; fall back to the inline payload when no - // bucket is configured (keeps small-payload / AgentCore-only deployments - // working with no behavior change). + // Prefer the S3 pointer; fall back to the inline payload when no bucket is + // configured (keeps small-payload / AgentCore-only deployments working with + // no behavior change). ...(payloadS3Uri ? [{ name: 'AGENT_PAYLOAD_S3_URI', value: payloadS3Uri }] : [{ name: 'AGENT_PAYLOAD', value: payloadJson }]), @@ -181,11 +234,12 @@ export class EcsComputeStrategy implements ComputeStrategy { // 2. Calls entrypoint.run_task_from_payload(p), which maps the WHOLE payload // dict to run_task's signature (rename prompt→task_description / // model_id→anthropic_model, filter to accepted params, coerce str/int). - // This replaces the old hand-listed kwarg subset that silently dropped - // channel_source/channel_metadata (no Linear/Jira reactions or channel - // MCP on ECS — ABCA-487), build_command, cedar_policies, base_branch/ - // merge_branches, attachments, trace, user_id, etc. Single source of - // truth in the agent, unit-tested (see test_run_task_from_payload). + // This replaces an older hand-listed kwarg subset that silently dropped + // fields such as channel_source/channel_metadata (which meant no + // Linear/Jira reactions or channel MCP on ECS), build_command, + // cedar_policies, base_branch/merge_branches, attachments, trace, user_id, + // etc. Single source of truth in the agent, unit-tested (see + // test_run_task_from_payload). // 3. Exits with code 0 on success, 1 on failure. // This bypasses the uvicorn server entirely — no HTTP, no OTEL noise. const bootCommand = [ @@ -205,8 +259,17 @@ export class EcsComputeStrategy implements ComputeStrategy { const command = new RunTaskCommand({ cluster: ECS_CLUSTER_ARN, - taskDefinition: ECS_TASK_DEFINITION_ARN, + taskDefinition, launchType: 'FARGATE', + // ECS RunTask idempotency. Without a clientToken, a client-side timeout on a + // RunTask that ACTUALLY launched (the SDK send() threw after AWS accepted it) + // makes the session-start auto-retry fire a SECOND RunTask with the same + // TASK_ID env — two Fargate containers cloning/committing/PRing in parallel, + // the first untrackable. clientToken makes AWS itself dedup an identical + // RunTask within its window: the retry returns the SAME task instead of + // launching another. taskId is a ULID (26 chars, well under the 64-char + // clientToken limit) and unique per task — the natural token. + clientToken: taskId, networkConfiguration: { awsvpcConfiguration: { subnets, @@ -235,6 +298,9 @@ export class EcsComputeStrategy implements ComputeStrategy { task_id: taskId, ecs_task_arn: ecsTask.taskArn, cluster: ECS_CLUSTER_ARN, + // Which task def was selected — planning (read-only) vs build. + task_definition: taskDefinition, + read_only: Boolean(readOnly), }); return { diff --git a/cdk/src/handlers/shared/workflows.ts b/cdk/src/handlers/shared/workflows.ts index 9f11719fd..84524d059 100644 --- a/cdk/src/handlers/shared/workflows.ts +++ b/cdk/src/handlers/shared/workflows.ts @@ -136,6 +136,16 @@ const DESCRIPTORS: Record<string, WorkflowDescriptor> = { readOnly: true, requiredInputs: { allOf: ['pr_number'] }, }, + // Re-stack: re-merge a changed predecessor branch into an existing stacked + // child PR. Writeable, repo-bound, and operates on an existing PR + // (``pr_number``). Platform-issued by the restack path, not user-facing. + 'coding/restack-v1': { + id: 'coding/restack-v1', + version: '1.0.0', + requiresRepo: true, + readOnly: false, + requiredInputs: { allOf: ['pr_number'] }, + }, 'default/agent-v1': { id: 'default/agent-v1', version: '1.0.0', diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 06a98deb3..8b28576ae 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -40,7 +40,7 @@ import { Blueprint } from '../constructs/blueprint'; import { CedarWasmLayer } from '../constructs/cedar-wasm-layer'; import { ConcurrencyReconciler } from '../constructs/concurrency-reconciler'; import { DnsFirewall } from '../constructs/dns-firewall'; -import { EcsAgentCluster } from '../constructs/ecs-agent-cluster'; +import { EcsAgentCluster, resolveEcsTaskSizing } from '../constructs/ecs-agent-cluster'; import { EcsPayloadBucket } from '../constructs/ecs-payload-bucket'; import { FanOutConsumer } from '../constructs/fanout-consumer'; import { GitHubScreenshotIntegration } from '../constructs/github-screenshot-integration'; @@ -588,7 +588,8 @@ export class AgentStack extends Stack { // K12 (2026-06-29): AgentCore's fixed microVM envelope OOM-kills heavy // CI-parity builds (ABCA's own ~2800-test `mise run build`). ECS Fargate // gives a bigger, tunable task (see EcsAgentCluster for the exact vCPU/memory - // sizing + its OOM history — 64 GB was itself OOM-killed, so it runs larger) + // sizing and the measurements behind it — a 32 GB task was OOM-killed by a + // fully parallel build, which is why the build tier serialises with MISE_JOBS=1) // for repos that set ``compute_type: 'ecs'``. GATED on the ``compute_type`` deploy context // (default 'agentcore') — ECS resources only synthesize when you deploy with // ``--context compute_type=ecs``, so the default synth (and the @@ -610,8 +611,18 @@ export class AgentStack extends Stack { }, ]); } + // ECS build-task sizing, from deploy context. The construct's defaults are + // deliberately modest so an adopter who changes nothing does not pay for the + // Fargate ceiling — but a large monorepo genuinely needs more, so the knobs + // have to be reachable WITHOUT editing the construct. Same shape as + // ``compute_type`` above: + // cdk deploy -c compute_type=ecs -c ecsBuildTaskCpu=16384 \ + // -c ecsBuildTaskMemoryMiB=122880 -c ecsBuildTaskEphemeralStorageGiB=100 + // cdk deploy -c ecsExtraBuildEnv='{"MISE_JOBS":"8"}' + const ecsTaskSizing = resolveEcsTaskSizing(this.node); const ecsCluster = computeType === 'ecs' ? new EcsAgentCluster(this, 'EcsAgentCluster', { + ...(ecsTaskSizing !== undefined && { taskSizing: ecsTaskSizing }), vpc: agentVpc.vpc, agentImageAsset: new ecr_assets.DockerImageAsset(this, 'AgentImage', { directory: repoRoot, @@ -630,10 +641,10 @@ export class AgentStack extends Stack { // #502: read-only grant so the container can fetch its payload from S3. payloadBucket: ecsPayloadBucket!.bucket, // #299 ECS-parity: the same bucket the runtime uses for ARTIFACTS_BUCKET_NAME — - // coding/decompose-v1 delivers its plan artifact here. Wires the + // A repo-bound artifact workflow delivers here. Wires the // ARTIFACTS_BUCKET_NAME env only; delivery writes go through the per-task // SessionRole (no direct task-role grant — see construct). Without the - // env, an ecs-repo :decompose fails at delivery. + // env, an ecs-repo artifact task fails at delivery. artifactsBucket: traceArtifactsBucket.bucket, // Per-session IAM scoping (#209): the ECS task role assumes the same // SessionRole as the AgentCore runtime for tenant-data access. The @@ -673,6 +684,7 @@ export class AgentStack extends Stack { ecsConfig: { clusterArn: ecsCluster.cluster.clusterArn, taskDefinitionArn: ecsCluster.taskDefinition.taskDefinitionArn, + planningTaskDefinitionArn: ecsCluster.planningTaskDefinition.taskDefinitionArn, subnets: agentVpc.vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }).subnetIds.join(','), securityGroup: ecsCluster.securityGroup.securityGroupId, containerName: ecsCluster.containerName, diff --git a/cdk/src/types/pdf-parse.d.ts b/cdk/src/types/pdf-parse.d.ts deleted file mode 100644 index 82ec15deb..000000000 --- a/cdk/src/types/pdf-parse.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -declare module 'pdf-parse' { - interface PdfParseResult { - text: string; - numpages: number; - info: Record<string, unknown>; - } - function pdfParse(data: Buffer, options?: { max?: number }): Promise<PdfParseResult>; - export = pdfParse; -} diff --git a/cdk/test/constructs/bedrock-models.test.ts b/cdk/test/constructs/bedrock-models.test.ts index e39ff504d..f0c3b84c8 100644 --- a/cdk/test/constructs/bedrock-models.test.ts +++ b/cdk/test/constructs/bedrock-models.test.ts @@ -17,6 +17,8 @@ * SOFTWARE. */ +import * as fs from 'fs'; +import * as path from 'path'; import { App, Stack } from 'aws-cdk-lib'; import { BEDROCK_MODELS_CONTEXT_KEY, @@ -70,3 +72,28 @@ describe('resolveBedrockModelIds', () => { ).toThrow(/bare foundation-model IDs/); }); }); + +/** + * Drift guard: the agent picks a fallback model when a repo pins none, and the + * IAM grant that lets it invoke that model is derived from + * DEFAULT_BEDROCK_MODEL_IDS. If the two disagree, every task on the stack fails + * at turn 0 with AccessDenied — and nothing else in the suite notices, because + * the agent-side default and the CDK-side grant live in different languages. + */ +describe('DEFAULT_BEDROCK_MODEL_IDS covers the agent runtime default', () => { + it('grants the fallback model the agent falls back to', () => { + const configPy = fs.readFileSync( + path.resolve(__dirname, '../../../agent/src/config.py'), 'utf8', + ); + // The fallback is the second argument to the ANTHROPIC_MODEL env lookup. + const match = configPy.match(/"ANTHROPIC_MODEL",\s*"([^"]+)"/); + expect(match).not.toBeNull(); + const agentDefault = match![1]; + + // The agent names the US inference profile (`us.anthropic.…`); the grant list + // holds bare foundation-model IDs and both grant sites add the `us.` prefix. + expect(agentDefault).toMatch(/^us\./); + const bare = agentDefault.replace(/^us\./, ''); + expect(DEFAULT_BEDROCK_MODEL_IDS).toContain(bare); + }); +}); diff --git a/cdk/test/constructs/ecs-agent-cluster.test.ts b/cdk/test/constructs/ecs-agent-cluster.test.ts index 929e8c890..df1393a8f 100644 --- a/cdk/test/constructs/ecs-agent-cluster.test.ts +++ b/cdk/test/constructs/ecs-agent-cluster.test.ts @@ -28,9 +28,21 @@ import * as s3 from 'aws-cdk-lib/aws-s3'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import { AgentMemory } from '../../src/constructs/agent-memory'; import { AgentSessionRole } from '../../src/constructs/agent-session-role'; -import { EcsAgentCluster } from '../../src/constructs/ecs-agent-cluster'; - -function createStack(overrides?: { memoryId?: string; bedrockModels?: string[] }): { stack: Stack; template: Template } { +import { EcsAgentCluster, resolveEcsTaskSizing } from '../../src/constructs/ecs-agent-cluster'; + +function createStack(overrides?: { + memoryId?: string; + bedrockModels?: string[]; + withMemory?: boolean; + taskSizing?: { + buildTaskCpu?: number; + buildTaskMemoryMiB?: number; + buildTaskEphemeralStorageGiB?: number; + planningTaskCpu?: number; + planningTaskMemoryMiB?: number; + extraBuildEnvironment?: Record<string, string>; + }; +}): { stack: Stack; template: Template } { const app = new App({ context: overrides?.bedrockModels ? { bedrockModels: overrides.bedrockModels } : undefined, }); @@ -57,6 +69,8 @@ function createStack(overrides?: { memoryId?: string; bedrockModels?: string[] } const githubTokenSecret = new secretsmanager.Secret(stack, 'GitHubTokenSecret'); + const agentMemory = overrides?.withMemory ? new AgentMemory(stack, 'AgentMemory') : undefined; + new EcsAgentCluster(stack, 'EcsAgentCluster', { vpc, agentImageAsset, @@ -65,12 +79,69 @@ function createStack(overrides?: { memoryId?: string; bedrockModels?: string[] } userConcurrencyTable, githubTokenSecret, memoryId: overrides?.memoryId, + agentMemory, + ...(overrides?.taskSizing && { taskSizing: overrides.taskSizing }), }); const template = Template.fromStack(stack); return { stack, template }; } +describe('resolveEcsTaskSizing — the sizing knobs must be reachable at deploy time', () => { + const nodeWith = (context: Record<string, unknown>) => new Stack(new App({ context }), 'S').node; + + test('returns undefined when nothing is set, so construct defaults apply', () => { + expect(resolveEcsTaskSizing(nodeWith({}))).toBeUndefined(); + }); + + test('a heavy monorepo can reach the Fargate ceiling from context alone', () => { + // The whole point of the modest default: raising it must NOT require editing + // the construct. Before this was wired, taskSizing had no caller and the + // ceiling was unreachable by any supported route. + expect(resolveEcsTaskSizing(nodeWith({ + ecsBuildTaskCpu: '16384', + ecsBuildTaskMemoryMiB: '122880', + ecsBuildTaskEphemeralStorageGiB: '100', + }))).toEqual({ + buildTaskCpu: 16384, + buildTaskMemoryMiB: 122880, + buildTaskEphemeralStorageGiB: 100, + }); + }); + + test('a malformed number throws at synth rather than silently defaulting', () => { + // "I set the flag and the build still OOM'd" is a worse afternoon than a + // failed synth. + expect(() => resolveEcsTaskSizing(nodeWith({ ecsBuildTaskCpu: 'lots' }))) + .toThrow(/must be a positive integer/); + expect(() => resolveEcsTaskSizing(nodeWith({ ecsBuildTaskMemoryMiB: '-1' }))) + .toThrow(/must be a positive integer/); + }); + + test('build-tool env overrides come through as JSON', () => { + expect(resolveEcsTaskSizing(nodeWith({ ecsExtraBuildEnv: '{"MISE_JOBS":"8"}' }))) + .toEqual({ extraBuildEnvironment: { MISE_JOBS: '8' } }); + }); + + test('a RESERVED platform env key is REJECTED, not merged', () => { + // extraBuildEnvironment spreads over the whole base container env, so without + // this guard a build-tool override could unset platform wiring. The sharp one + // is AGENT_SESSION_ROLE_ARN: absent, the agent falls back to ambient + // credentials and per-tenant scoping is silently off. + expect(() => resolveEcsTaskSizing(nodeWith({ + ecsExtraBuildEnv: '{"AGENT_SESSION_ROLE_ARN":""}', + }))).toThrow(/cannot set 'AGENT_SESSION_ROLE_ARN'/); + expect(() => resolveEcsTaskSizing(nodeWith({ + ecsExtraBuildEnv: '{"TASK_TABLE_NAME":"attacker-table"}', + }))).toThrow(/platform wiring/); + }); + + test('a non-string env value is rejected', () => { + expect(() => resolveEcsTaskSizing(nodeWith({ ecsExtraBuildEnv: '{"MISE_JOBS":8}' }))) + .toThrow(/must be a string/); + }); +}); + describe('EcsAgentCluster construct', () => { let baseTemplate: Template; @@ -89,10 +160,91 @@ describe('EcsAgentCluster construct', () => { }); }); - test('creates a Fargate task definition with 16 vCPU and 120 GB (ABCA-662: full parallel mise build OOM\'d at 64 GB → max Fargate RAM)', () => { + test('the BUILD def defaults to a MODEST size, not the Fargate maximum', () => { + // A default is what an adopter who changes nothing pays for. At the Fargate + // ceiling (16 vCPU / 120 GB) that is roughly 5x the per-build cost of this + // size. Under-provisioning is a slow or OOM-ing build — diagnosable, and one + // prop away from fixed; over-provisioning is a silent bill. baseTemplate.hasResourceProperties('AWS::ECS::TaskDefinition', { + Cpu: '4096', + Memory: '16384', + RequiresCompatibilities: ['FARGATE'], + RuntimePlatform: { + CpuArchitecture: 'ARM64', + OperatingSystemFamily: 'LINUX', + }, + }); + }); + + test('the BUILD def keeps real DISK margin, unlike CPU and memory', () => { + // Measured, not guessed. A full parallel build of a large TypeScript + Python + // monorepo peaked at ~3.1 GB of memory (~5x headroom at 16 GB, because + // MISE_JOBS=1 serialises the packages) but ~14.7 GiB of DISK. At Fargate's + // 21 GiB floor that is only ~1.4x, and running out of space surfaces as a + // spurious build failure rather than an obvious resource error — so disk is + // deliberately sized less aggressively than CPU and memory. Ephemeral storage + // is also a small fraction of the per-task cost, so this keeps the cost win. + baseTemplate.hasResourceProperties('AWS::ECS::TaskDefinition', { + Cpu: '4096', + EphemeralStorage: { SizeInGiB: 50 }, + }); + }); + + test('a heavy monorepo can raise the build task to the Fargate ceiling', () => { + // The size that a large TypeScript + Python monorepo actually needs, reached + // through the prop rather than by being everyone's default. + createStack({ + taskSizing: { + buildTaskCpu: 16384, + buildTaskMemoryMiB: 122880, + buildTaskEphemeralStorageGiB: 100, + }, + }).template.hasResourceProperties('AWS::ECS::TaskDefinition', { Cpu: '16384', Memory: '122880', + EphemeralStorage: { SizeInGiB: 100 }, + }); + }); + + test('build-tool env vars are overridable, so tuned values are not everyone\'s default', () => { + // The platform sets a verify timeout and parallelism caps measured against one + // monorepo's toolchain. A deployment with a different build shape replaces them + // through the prop instead of editing the construct. + const template = createStack({ + taskSizing: { extraBuildEnvironment: { MISE_JOBS: '8', JEST_MAX_WORKERS: '50%' } }, + }).template; + const taskDefs = template.findResources('AWS::ECS::TaskDefinition'); + const build = Object.values(taskDefs).find( + (d) => (d as { Properties: { Cpu: string } }).Properties.Cpu === '4096', + ); + expect(build).toBeDefined(); + const env = ((build as { + Properties: { ContainerDefinitions: Array<{ Environment?: Array<{ Name: string; Value: string }> }> }; + }).Properties.ContainerDefinitions[0].Environment ?? []); + const byName = Object.fromEntries(env.map((e) => [e.Name, e.Value])); + expect(byName.MISE_JOBS).toBe('8'); + expect(byName.JEST_MAX_WORKERS).toBe('50%'); + // A key the caller did NOT override keeps the platform value. + expect(byName.BUILD_VERIFY_TIMEOUT_S).toBe('3600'); + }); + + test('the PLANNING def keeps the 20 GiB default (no EphemeralStorage — a clone+read planner needs no extra disk)', () => { + const taskDefs = baseTemplate.findResources('AWS::ECS::TaskDefinition'); + const planning = Object.values(taskDefs).find( + d => d.Properties.Cpu === '2048' && d.Properties.Memory === '8192', + ); + expect(planning).toBeDefined(); + expect(planning!.Properties.EphemeralStorage).toBeUndefined(); + }); + + test('creates a second, smaller PLANNING task def (2 vCPU / 8 GB) for read-only workflows (#299 ECS_RIGHTSIZED_PLANNING)', () => { + // Two task defs now exist: the 64 GB build def (asserted above) and this + // 8 GB planning def. A read_only workflow runs on the smaller one so a + // clone+read plan doesn't over-allocate the build box. + baseTemplate.resourceCountIs('AWS::ECS::TaskDefinition', 2); + baseTemplate.hasResourceProperties('AWS::ECS::TaskDefinition', { + Cpu: '2048', + Memory: '8192', RequiresCompatibilities: ['FARGATE'], RuntimePlatform: { CpuArchitecture: 'ARM64', @@ -101,6 +253,76 @@ describe('EcsAgentCluster construct', () => { }); }); + // The default task sizes are generous (tuned for a large monorepo build). A + // consumer with a lighter repo can override them per substrate to cut Fargate + // cost, so the sizing is configuration, not a fixed value. + test('taskSizing prop overrides the build def size; fields left unset keep the default', () => { + const { template } = createStack({ + taskSizing: { + buildTaskCpu: 4096, // 4 vCPU + buildTaskMemoryMiB: 16384, // 16 GB + buildTaskEphemeralStorageGiB: 40, + // planning sizes intentionally omitted -> they should stay at their defaults + }, + }); + // The override changes the existing build task def in place — it does not + // add a third one. There should still be exactly two: build + planning. + template.resourceCountIs('AWS::ECS::TaskDefinition', 2); + // The build task def reflects the overridden sizes. + template.hasResourceProperties('AWS::ECS::TaskDefinition', { + Cpu: '4096', + Memory: '16384', + EphemeralStorage: { SizeInGiB: 40 }, + }); + // The planning task def is unchanged: omitted fields fall back to the default. + const planning = Object.values(template.findResources('AWS::ECS::TaskDefinition')) + .find(d => d.Properties.Cpu === '2048' && d.Properties.Memory === '8192'); + expect(planning).toBeDefined(); + }); + + test('both task defs share ONE task role and ONE execution role (parity by construction — the ABCA-488/#502 lesson)', () => { + // The build and planning defs pass the SAME shared task+execution roles, so a + // grant added for one is present on the other by construction (no drift). The + // template therefore holds exactly two ECS roles (task + execution), each + // referenced by both defs' TaskRoleArn/ExecutionRoleArn. + const roles = baseTemplate.findResources('AWS::IAM::Role', { + Properties: { + AssumeRolePolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Principal: { Service: 'ecs-tasks.amazonaws.com' }, + }), + ]), + }, + }, + }); + expect(Object.keys(roles)).toHaveLength(2); + + const taskDefs = baseTemplate.findResources('AWS::ECS::TaskDefinition'); + const taskRoleRefs = new Set<string>(); + const execRoleRefs = new Set<string>(); + for (const def of Object.values(taskDefs)) { + taskRoleRefs.add(JSON.stringify(def.Properties.TaskRoleArn)); + execRoleRefs.add(JSON.stringify(def.Properties.ExecutionRoleArn)); + } + // Both defs point at the same single task role and same single exec role. + expect(taskRoleRefs.size).toBe(1); + expect(execRoleRefs.size).toBe(1); + }); + + test('the PLANNING def carries no BUILD_VERIFY_TIMEOUT_S (a read-only planner runs no build verify)', () => { + const taskDefs = baseTemplate.findResources('AWS::ECS::TaskDefinition'); + const planningDef = Object.values(taskDefs).find( + d => d.Properties.Cpu === '2048' && d.Properties.Memory === '8192', + ); + expect(planningDef).toBeDefined(); + const env = planningDef!.Properties.ContainerDefinitions[0].Environment ?? []; + expect(env.some((e: { Name: string }) => e.Name === 'BUILD_VERIFY_TIMEOUT_S')).toBe(false); + // …but the shared env (Bedrock, task table) IS present on the planning def too. + expect(env.some((e: { Name: string }) => e.Name === 'CLAUDE_CODE_USE_BEDROCK')).toBe(true); + expect(env.some((e: { Name: string }) => e.Name === 'TASK_TABLE_NAME')).toBe(true); + }); + test('creates a security group with TCP 443 egress only', () => { baseTemplate.hasResourceProperties('AWS::EC2::SecurityGroup', { GroupDescription: 'ECS Agent Tasks - egress TCP 443 only', @@ -175,64 +397,6 @@ describe('EcsAgentCluster construct', () => { expect(hasLinearOauthGrant).toBe(true); }); - test('task role gets bedrock-agentcore:CreateEvent on the AgentMemory when wired (F-2 / ABCA-488-class)', () => { - // REGRESSION: the agent's cross-task learning writes (write_task_episode / - // write_repo_learnings) call bedrock-agentcore:CreateEvent on the AgentCore - // Memory. The runtime role gets this via agentMemory.grantReadWrite; the ECS - // task role did NOT, so writes hit AccessDenied and silently no-op'd (WARN) - // on the ECS substrate — learning never persisted on an ECS-only deploy. - // Build a stack WITH an AgentMemory and assert the CreateEvent grant exists, - // scoped to the memory ARN (not a wildcard). - const app = new App(); - const stack = new Stack(app, 'EcsMemStack'); - const vpc = new ec2.Vpc(stack, 'Vpc', { maxAzs: 2 }); - const agentImageAsset = new ecr_assets.DockerImageAsset(stack, 'AgentImage', { - directory: path.join(__dirname, '..', '..', '..', 'agent'), - }); - const mk = (id: string) => - new dynamodb.Table(stack, id, { partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING } }); - const userConcurrencyTable = new dynamodb.Table(stack, 'UserConcurrencyTable', { - partitionKey: { name: 'user_id', type: dynamodb.AttributeType.STRING }, - }); - const agentMemory = new AgentMemory(stack, 'AgentMemory'); - new EcsAgentCluster(stack, 'EcsAgentCluster', { - vpc, - agentImageAsset, - taskTable: mk('TaskTable'), - taskEventsTable: mk('TaskEventsTable'), - userConcurrencyTable, - githubTokenSecret: new secretsmanager.Secret(stack, 'GitHubTokenSecret'), - agentMemory, - }); - const template = Template.fromStack(stack); - const policies = template.findResources('AWS::IAM::Policy'); - let hasCreateEvent = false; - for (const [id, p] of Object.entries(policies)) { - if (!id.includes('TaskDefTaskRole')) continue; - for (const s of p.Properties.PolicyDocument.Statement) { - const actions = Array.isArray(s.Action) ? s.Action : [s.Action]; - if (actions.includes('bedrock-agentcore:CreateEvent')) { - hasCreateEvent = true; - // resource must reference the memory ARN, not a bare wildcard - expect(JSON.stringify(s.Resource)).toContain('MemoryArn'); - expect(s.Resource).not.toEqual('*'); - } - } - } - expect(hasCreateEvent).toBe(true); - }); - - test('task role has NO bedrock-agentcore grant when no AgentMemory is wired (isolated default)', () => { - const policies = baseTemplate.findResources('AWS::IAM::Policy'); - for (const [id, p] of Object.entries(policies)) { - if (!id.includes('TaskDefTaskRole')) continue; - for (const s of p.Properties.PolicyDocument.Statement) { - const actions = Array.isArray(s.Action) ? s.Action : [s.Action]; - expect(actions.some((a: string) => a.startsWith('bedrock-agentcore:'))).toBe(false); - } - } - }); - test('task role Bedrock InvokeModel is scoped to explicit model/inference-profile ARNs (no wildcard)', () => { const policies = baseTemplate.findResources('AWS::IAM::Policy'); let bedrockStatement: { Resource: unknown } | undefined; @@ -317,6 +481,27 @@ describe('EcsAgentCluster construct', () => { }); }); + test('build def caps build parallelism to prevent OOM (K14 / ABCA-691)', () => { + // The build task def serializes the mise DAG (MISE_JOBS=1) and pins the jest + // fleet (JEST_MAX_WORKERS=4) so the cross-package build storm can't OOM the + // box while the coding agent is still resident. Asserted per-var (one + // arrayWith objectLike each): a single arrayWith with multiple objectLike + // entries is matched unreliably by the CDK assertions matcher, so each env + // var gets its own hasResourceProperties call — which also pins each to the + // SAME build container (the one carrying BUILD_VERIFY_TIMEOUT_S). + const envHas = (name: string, value: string) => + baseTemplate.hasResourceProperties('AWS::ECS::TaskDefinition', { + ContainerDefinitions: Match.arrayWith([ + Match.objectLike({ + Environment: Match.arrayWith([Match.objectLike({ Name: name, Value: value })]), + }), + ]), + }); + envHas('MISE_JOBS', '1'); + envHas('JEST_MAX_WORKERS', '4'); + envHas('BUILD_VERIFY_TIMEOUT_S', '3600'); + }); + test('includes MEMORY_ID in container env when provided', () => { const { template } = createStack({ memoryId: 'mem-test-123' }); template.hasResourceProperties('AWS::ECS::TaskDefinition', { @@ -330,6 +515,36 @@ describe('EcsAgentCluster construct', () => { }); }); + // F-2 ECS-parity regression guard. The task role must be able to WRITE cross- + // task memory (bedrock-agentcore:CreateEvent), or episodic/semantic writes fail + // closed on ECS (memory_written: false — live-caught on the fork). This + // regressed silently because MEMORY_ID was wired into the env WITHOUT the + // matching grant, so the agent attempted a write it had no permission for. + describe('AgentCore Memory grant (F-2)', () => { + test('grants the task role bedrock-agentcore write when agentMemory is passed', () => { + const { template } = createStack({ withMemory: true }); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Effect: 'Allow', + Action: Match.arrayWith([Match.stringLikeRegexp('bedrock-agentcore:.*Event')]), + }), + ]), + }, + }); + }); + + test('does NOT grant bedrock-agentcore write when agentMemory is omitted', () => { + // Negative proof: the isolated-construct default (no memory) must not + // emit a memory grant — otherwise the positive test proves nothing. + const { stack } = createStack(); + const policies = Template.fromStack(stack).findResources('AWS::IAM::Policy'); + const asJson = JSON.stringify(policies); + expect(asJson).not.toMatch(/bedrock-agentcore:[A-Za-z]*Event/); + }); + }); + describe('with a SessionRole wired (#209)', () => { function createWithSessionRole(): Template { const app = new App(); @@ -393,7 +608,12 @@ describe('EcsAgentCluster construct', () => { // statements). The task-role policy must NOT contain any unconditioned // task-table DDB grant — that access now lives only on the SessionRole. const taskRolePolicies = Object.entries(policies).filter(([id, p]) => - id.includes('TaskDefTaskRole') + // #299 ECS_RIGHTSIZED_PLANNING: the task role is now a SHARED standalone + // `TaskRole` construct (was the auto-generated role nested under the single + // FargateTaskDefinition, id `...TaskDefTaskRole...`), so both the build and + // planning defs pass the same role — its logical id is `...TaskRole...` and + // `ExecutionRole` doesn't match this substring. + id.includes('TaskRole') && p.Properties.PolicyDocument.Statement.some((s: { Action: string | string[] }) => { const actions = Array.isArray(s.Action) ? s.Action : [s.Action]; return actions.includes('sts:AssumeRole'); @@ -558,7 +778,7 @@ describe('EcsAgentCluster artifacts bucket (#299 ECS-parity)', () => { }); test('does NOT grant the task role write on the artifacts bucket (the scoped SessionRole owns delivery)', () => { - // #596 review B1: coding/decompose-v1 delivers via the assumed SessionRole + // An artifact workflow delivers via the assumed SessionRole // (scoped to artifacts/${task_id}/*), exactly like the AgentCore runtime — // whose task role likewise has no direct artifacts grant. A whole-bucket // grantReadWrite here would over-privilege the untrusted-code role and break diff --git a/cdk/test/constructs/github-screenshot-integration.test.ts b/cdk/test/constructs/github-screenshot-integration.test.ts index 3e415c87a..6653b1322 100644 --- a/cdk/test/constructs/github-screenshot-integration.test.ts +++ b/cdk/test/constructs/github-screenshot-integration.test.ts @@ -20,6 +20,7 @@ import { App, Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import * as apigw from 'aws-cdk-lib/aws-apigateway'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import { GitHubScreenshotIntegration } from '../../src/constructs/github-screenshot-integration'; @@ -128,3 +129,75 @@ describe('GitHubScreenshotIntegration construct', () => { }); }); }); + +describe('GitHubScreenshotIntegration — task-table grants (iteration-UX)', () => { + let template: Template; + + beforeAll(() => { + const app = new App(); + const stack = new Stack(app, 'TaskTableStack'); + const api = new apigw.RestApi(stack, 'TestApi'); + const githubTokenSecret = new secretsmanager.Secret(stack, 'GitHubToken'); + // A table carrying the LinearIssueIndex GSI the processor must Query to + // find the iteration's maturing reply (to append the `· [preview]` link). + const taskTable = new dynamodb.Table(stack, 'TaskTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + }); + taskTable.addGlobalSecondaryIndex({ + indexName: 'LinearIssueIndex', + partitionKey: { name: 'linear_issue_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'created_at', type: dynamodb.AttributeType.STRING }, + }); + + new GitHubScreenshotIntegration(stack, 'Screenshot', { api, githubTokenSecret, taskTable }); + template = Template.fromStack(stack); + }); + + test('grants dynamodb:Query scoped to the LinearIssueIndex GSI (not a blanket read)', () => { + // REGRESSION: the preview-link append (findIterationReplyId) Queries + // LinearIssueIndex, but grantWriteData covers only UpdateItem — without an + // explicit Query grant the processor hit AccessDenied at runtime and + // silently logged "no reply id found" (unit-mocked ddb never caught it). + // Pin a Query statement whose resource ARN names the index. + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Effect: 'Allow', + Action: 'dynamodb:Query', + Resource: Match.objectLike({ + 'Fn::Join': Match.arrayWith([ + Match.arrayWith([Match.stringLikeRegexp('index/LinearIssueIndex')]), + ]), + }), + }), + ]), + }, + }); + }); + + test('grants dynamodb:GetItem on the TaskTable base ARN (head_sha attribution read)', () => { + // REGRESSION: findIterationReplyId Queries the GSI (granted above) then + // GetItems each candidate's head_sha on the BASE table to attribute a + // deploy to the right iteration when several iterations overlap. The + // Query GSI grant does NOT cover GetItem on the base table, so the read + // threw AccessDenied, was swallowed non-fatally, and the preview was posted + // to the PR but never appended to the Linear iteration reply. Pin a GetItem + // statement whose resource ARN is the base table (no index/ suffix). + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Effect: 'Allow', + Action: 'dynamodb:GetItem', + Resource: Match.objectLike({ + 'Fn::Join': Match.arrayWith([ + Match.arrayWith([Match.stringLikeRegexp('table/')]), + ]), + }), + }), + ]), + }, + }); + }); +}); diff --git a/cdk/test/constructs/orchestration-table.test.ts b/cdk/test/constructs/orchestration-table.test.ts new file mode 100644 index 000000000..3a69c1efe --- /dev/null +++ b/cdk/test/constructs/orchestration-table.test.ts @@ -0,0 +1,154 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { App, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { OrchestrationTable } from '../../src/constructs/orchestration-table'; + +describe('OrchestrationTable', () => { + let template: Template; + + beforeEach(() => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new OrchestrationTable(stack, 'OrchestrationTable'); + template = Template.fromStack(stack); + }); + + test('creates a DynamoDB table with orchestration_id (PK) + sub_issue_id (SK)', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + KeySchema: [ + { AttributeName: 'orchestration_id', KeyType: 'HASH' }, + { AttributeName: 'sub_issue_id', KeyType: 'RANGE' }, + ], + }); + }); + + test('uses PAY_PER_REQUEST billing mode', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + BillingMode: 'PAY_PER_REQUEST', + }); + }); + + test('enables point-in-time recovery by default', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + PointInTimeRecoverySpecification: { + PointInTimeRecoveryEnabled: true, + }, + }); + }); + + test('sets DESTROY removal policy by default', () => { + template.hasResource('AWS::DynamoDB::Table', { + DeletionPolicy: 'Delete', + UpdateReplacePolicy: 'Delete', + }); + }); + + test('enables TTL on ttl attribute', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + TimeToLiveSpecification: { + AttributeName: 'ttl', + Enabled: true, + }, + }); + }); + + test('creates ChildTaskIndex GSI with child_task_id as PK', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + GlobalSecondaryIndexes: Match.arrayWith([ + Match.objectLike({ + IndexName: 'ChildTaskIndex', + KeySchema: [ + { AttributeName: 'child_task_id', KeyType: 'HASH' }, + ], + Projection: { ProjectionType: 'ALL' }, + }), + ]), + }); + }); + + test('creates ChildBranchIndex GSI with child_branch_name as PK', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + GlobalSecondaryIndexes: Match.arrayWith([ + Match.objectLike({ + IndexName: 'ChildBranchIndex', + KeySchema: [ + { AttributeName: 'child_branch_name', KeyType: 'HASH' }, + ], + Projection: { ProjectionType: 'ALL' }, + }), + ]), + }); + }); + + test('declares all required attribute definitions', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + AttributeDefinitions: Match.arrayWith([ + { AttributeName: 'orchestration_id', AttributeType: 'S' }, + { AttributeName: 'sub_issue_id', AttributeType: 'S' }, + { AttributeName: 'child_task_id', AttributeType: 'S' }, + { AttributeName: 'child_branch_name', AttributeType: 'S' }, + ]), + }); + }); + + test('static index name constants match actual GSI names', () => { + expect(OrchestrationTable.CHILD_TASK_INDEX).toBe('ChildTaskIndex'); + expect(OrchestrationTable.CHILD_BRANCH_INDEX).toBe('ChildBranchIndex'); + }); +}); + +describe('OrchestrationTable with custom props', () => { + test('accepts custom table name', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new OrchestrationTable(stack, 'OrchestrationTable', { tableName: 'my-orchestrations' }); + const template = Template.fromStack(stack); + + template.hasResourceProperties('AWS::DynamoDB::Table', { + TableName: 'my-orchestrations', + }); + }); + + test('accepts custom removal policy', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new OrchestrationTable(stack, 'OrchestrationTable', { removalPolicy: RemovalPolicy.RETAIN }); + const template = Template.fromStack(stack); + + template.hasResource('AWS::DynamoDB::Table', { + DeletionPolicy: 'Retain', + UpdateReplacePolicy: 'Retain', + }); + }); + + test('accepts point-in-time recovery disabled', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new OrchestrationTable(stack, 'OrchestrationTable', { pointInTimeRecovery: false }); + const template = Template.fromStack(stack); + + template.hasResourceProperties('AWS::DynamoDB::Table', { + PointInTimeRecoverySpecification: { + PointInTimeRecoveryEnabled: false, + }, + }); + }); +}); diff --git a/cdk/test/handlers/fanout-task-events.test.ts b/cdk/test/handlers/fanout-task-events.test.ts index ade1c8a58..f332e9095 100644 --- a/cdk/test/handlers/fanout-task-events.test.ts +++ b/cdk/test/handlers/fanout-task-events.test.ts @@ -1451,7 +1451,7 @@ describe('fanout-task-events: Linear dispatcher (issue #239)', () => { // F-prlink (ABCA-584): the PR URL IS rendered on the ✅ success path. The old // behavior omitted it, assuming the agent's own step-2 "PR opened" comment // always carries it — but that comment can silently not fire (live-caught: a - // decompose→single task opened a PR but posted no PR-opened comment, so the + // A task opened a PR but posted no PR-opened comment, so the // link was lost entirely). The terminal completion comment is the // platform-owned surface, so it must carry the link; a duplicate is far // cheaper than a missing PR. diff --git a/cdk/test/handlers/orchestrate-task.test.ts b/cdk/test/handlers/orchestrate-task.test.ts index 4314896c9..67208b3f9 100644 --- a/cdk/test/handlers/orchestrate-task.test.ts +++ b/cdk/test/handlers/orchestrate-task.test.ts @@ -754,6 +754,33 @@ describe('loadBlueprintConfig', () => { const config = await loadBlueprintConfig(baseTask as any); expect(config.cedar_policies).toBeUndefined(); }); + + // Compute substrate is a per-repo property that applies to ALL workflows: a + // read-only review task clones + reads the SAME repo, so it needs the + // SAME compute (a repo big enough to need the 64GB ECS tier to build also OOMs + // the AgentCore microVM just reading it). So an ecs repo's ecs compute_type + // flows through regardless of the workflow's read-only-ness. + const ecsRepoConfig = { + repo: 'org/repo', + status: 'active' as const, + onboarded_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + compute_type: 'ecs' as const, + }; + + test('a read-only workflow (coding/pr-review-v1) on an ecs repo INHERITS ecs (same repo, same footprint)', async () => { + mockLoadRepoConfig.mockResolvedValueOnce(ecsRepoConfig); + const planTask = { ...baseTask, resolved_workflow: { id: 'coding/pr-review-v1', version: '1.0.0' } }; + const config = await loadBlueprintConfig(planTask as any); + expect(config.compute_type).toBe('ecs'); + }); + + test('a writeable workflow (coding/new-task-v1) on an ecs repo also uses ecs', async () => { + mockLoadRepoConfig.mockResolvedValueOnce(ecsRepoConfig); + const buildTask = { ...baseTask, resolved_workflow: { id: 'coding/new-task-v1', version: '1.0.0' } }; + const config = await loadBlueprintConfig(buildTask as any); + expect(config.compute_type).toBe('ecs'); + }); }); describe('hydrateAndTransition with blueprint config', () => { diff --git a/cdk/test/handlers/shared/attachment-screening.test.ts b/cdk/test/handlers/shared/attachment-screening.test.ts index 2f3405679..a1f53c1a5 100644 --- a/cdk/test/handlers/shared/attachment-screening.test.ts +++ b/cdk/test/handlers/shared/attachment-screening.test.ts @@ -20,6 +20,23 @@ import * as fs from 'fs'; import * as path from 'path'; import type { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime'; + +// pdf-parse v2 exposes a `PDFParse` CLASS (`new PDFParse({data}).getText()`), not +// the v1 callable default. Mock it at that shape so the PDF-screening tests drive +// the real v2 contract (see the PDF-extraction regression tests below). pdfjs runs +// headless in the nodejs24.x Lambda, but ts-jest's CJS transform can't spin its +// ESM worker under jest — so unit-mock the class and prove the wiring here; the +// real extraction is validated on the live deploy. +const pdfGetTextMock = jest.fn(); +const pdfDestroyMock = jest.fn().mockResolvedValue(undefined); +jest.mock('pdf-parse', () => ({ + __esModule: true, + PDFParse: jest.fn().mockImplementation(() => ({ + getText: (...args: unknown[]) => pdfGetTextMock(...args), + destroy: (...args: unknown[]) => pdfDestroyMock(...args), + })), +})); + import { assertImageUploadBytes, AttachmentScreeningError, @@ -318,6 +335,19 @@ describe('screenImage', () => { }); describe('screenTextFile', () => { + // jest config sets clearMocks:true, which wipes the PDFParse constructor's + // implementation + pdfDestroyMock's resolved value before each test. Re-establish + // them so `new PDFParse({data})` keeps returning the getText/destroy stubs. + beforeEach(() => { + (pdfDestroyMock as jest.Mock).mockResolvedValue(undefined); + // eslint-disable-next-line @typescript-eslint/no-require-imports -- re-arm the mocked ctor after clearMocks + const { PDFParse } = require('pdf-parse') as { PDFParse: jest.Mock }; + PDFParse.mockImplementation(() => ({ + getText: (...args: unknown[]) => pdfGetTextMock(...args), + destroy: (...args: unknown[]) => pdfDestroyMock(...args), + })); + }); + test('screens plain text content', async () => { const config = { bedrockClient: mockBedrockPass(), @@ -360,27 +390,110 @@ describe('screenTextFile', () => { } }); - test('throws for PDF with no extractable text', async () => { - // Mock pdf-parse to return empty text - jest.mock('pdf-parse', () => ({ - __esModule: true, - default: jest.fn().mockResolvedValue({ text: '' }), - }), { virtual: true }); + // pdf-parse v2 is mocked at the PDFParse-class level (see the jest.mock at the + // top of this file). The prior code called the v1 callable shape + // (`pdfParseFn(buf)`), undefined on v2, so every PDF fail-closed as + // "processing unavailable". These assert the v2 contract: + // `new PDFParse({data}).getText()` → `{ text }`. Real end-to-end PDF extraction + // is validated on the live deploy (pdfjs runs headless in nodejs24.x; ts-jest's + // CJS transform can't drive its ESM worker, so a class mock is the right unit). + test('extracts and screens a PDF via the v2 PDFParse class', async () => { + pdfGetTextMock.mockResolvedValueOnce({ text: 'Design spec: add CONTRIBUTORS', total: 1, pages: [] }); const config = { bedrockClient: mockBedrockPass(), guardrailId: 'test-guardrail', guardrailVersion: '1', }; + const result = await screenTextFile(Buffer.from('%PDF-1.4 real'), 'application/pdf', 'design.pdf', config); + expect(result.screening.status).toBe('passed'); + // The v2 API was actually driven: constructed with { data } + getText called. + expect(pdfGetTextMock).toHaveBeenCalledTimes(1); + expect(pdfDestroyMock).toHaveBeenCalledTimes(1); // worker released + }); - // A minimal PDF-like buffer (pdf-parse is mocked so content doesn't matter) - const content = Buffer.from('%PDF-1.4 empty'); + test('does not hand pdf-parse a view onto the CALLER\'s buffer', async () => { + // pdf-parse transfers ownership of whatever it is given to its worker, which + // DETACHES the underlying ArrayBuffer. A `new Uint8Array(content.buffer, …)` + // view shares that buffer with the caller's Buffer — and the caller uploads + // those same bytes to S3 AFTER screening returns. Sharing meant the upload read + // a detached buffer and threw "Cannot perform Construct on a detached + // ArrayBuffer"; fail-closed screening then rejected the task, so every PDF + // attachment was refused with "could not be stored". Observed in production. + // + // So this asserts the OWNERSHIP boundary: what pdf-parse receives must not share + // a backing store with what the caller keeps. + const { PDFParse } = jest.requireMock('pdf-parse') as { PDFParse: jest.Mock }; + PDFParse.mockClear(); + pdfGetTextMock.mockResolvedValueOnce({ text: 'Design spec', total: 1, pages: [] }); + const content = Buffer.from('%PDF-1.4 real pdf bytes here'); + await screenTextFile(content, 'application/pdf', 'design.pdf', { + bedrockClient: mockBedrockPass(), + guardrailId: 'test-guardrail', + guardrailVersion: '1', + }); + const passed = (PDFParse.mock.calls[0][0] as { data: Uint8Array }).data; + // Same bytes… + expect(Buffer.from(passed).toString()).toBe(content.toString()); + // …different backing store, so a transfer cannot detach the caller's copy. + expect(passed.buffer).not.toBe(content.buffer); + }); + test('throws for PDF with no extractable text', async () => { + pdfGetTextMock.mockResolvedValueOnce({ text: '', total: 1, pages: [] }); + const config = { + bedrockClient: mockBedrockPass(), + guardrailId: 'test-guardrail', + guardrailVersion: '1', + }; await expect( - screenTextFile(content, 'application/pdf', 'empty.pdf', config), + screenTextFile(Buffer.from('%PDF-1.4 empty'), 'application/pdf', 'empty.pdf', config), ).rejects.toThrow(/no extractable text/); }); + test('FAILS CLOSED on a PDF with more pages than can be screened (finding #1 — no partial-screen-full-deliver)', async () => { + // 51-page PDF but we only screen the first 50 → the whole file would be + // delivered to the agent with page 51 unscreened. Reject instead. + pdfGetTextMock.mockResolvedValueOnce({ text: 'benign pages 1-50', total: 51, pages: [] }); + const config = { bedrockClient: mockBedrockPass(), guardrailId: 'g', guardrailVersion: '1' }; + await expect( + screenTextFile(Buffer.from('%PDF-1.4 big'), 'application/pdf', 'big.pdf', config), + ).rejects.toThrow(/over the .*page limit|fully screen/i); + }); + + test('FAILS CLOSED when extracted text exceeds the screened byte cap (finding #1)', async () => { + pdfGetTextMock.mockResolvedValueOnce({ text: 'x'.repeat(2 * 1024 * 1024), total: 3, pages: [] }); + const config = { bedrockClient: mockBedrockPass(), guardrailId: 'g', guardrailVersion: '1' }; + await expect( + screenTextFile(Buffer.from('%PDF-1.4 verbose'), 'application/pdf', 'verbose.pdf', config), + ).rejects.toThrow(/more text than .*can fully screen/i); + }); + + test('a within-limits PDF (≤50 pages) still screens + passes', async () => { + pdfGetTextMock.mockResolvedValueOnce({ text: 'short spec', total: 3, pages: [] }); + const config = { bedrockClient: mockBedrockPass(), guardrailId: 'g', guardrailVersion: '1' }; + const result = await screenTextFile(Buffer.from('%PDF-1.4 ok'), 'application/pdf', 'ok.pdf', config); + expect(result.screening.status).toBe('passed'); + }); + + test('a pdfjs/DOMMatrix parse error logs a BUNDLING diagnostic, not just "corrupt PDF"', async () => { + // A Lambda that reaches this path but lacks the pdf-parse bundling carve-out + // throws a pdfjs/native-binding error. Detect the signature + log an + // actionable diagnostic so it's not misread as a bad user PDF. + const { logger } = jest.requireActual('../../../src/handlers/shared/logger') as { logger: { error: (...a: unknown[]) => void } }; + const errSpy = jest.spyOn(logger, 'error').mockImplementation(() => {}); + pdfGetTextMock.mockRejectedValueOnce(new Error('DOMMatrix is not defined')); + const config = { bedrockClient: mockBedrockPass(), guardrailId: 'g', guardrailVersion: '1' }; + await expect( + screenTextFile(Buffer.from('%PDF-1.4 real'), 'application/pdf', 'spec.pdf', config), + ).rejects.toBeInstanceOf(AttachmentScreeningError); + // The loud diagnostic names the bundling cause + the fix. + const logged = errSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(logged).toMatch(/BUNDLING bug/); + expect(logged).toMatch(/nodeModules/); + errSpy.mockRestore(); + }); + test('retries on transient Bedrock errors for text screening', async () => { const send = jest.fn() .mockRejectedValueOnce({ $metadata: { httpStatusCode: 503 }, message: 'service unavailable' }) diff --git a/cdk/test/handlers/shared/error-classifier.test.ts b/cdk/test/handlers/shared/error-classifier.test.ts index 43d0a315d..9649b3502 100644 --- a/cdk/test/handlers/shared/error-classifier.test.ts +++ b/cdk/test/handlers/shared/error-classifier.test.ts @@ -157,7 +157,7 @@ describe('classifyError', () => { expect(result!.retryable).toBe(true); }); - test('classifies claude Exec-format / broken-shim as a transient image issue (ABCA-659, not "Unexpected error")', () => { + test('classifies claude Exec-format / broken-shim as a transient image issue, not "Unexpected error"', () => { // The raw run_agent failure the broken agent image produced. const result = classifyError( "Workflow run_agent step failed: OSError: [Errno 8] Exec format error: 'claude'", @@ -244,6 +244,54 @@ describe('classifyError', () => { expect(result!.retryable).toBe(false); }); + test('build_ok=infra is a retryable COMPUTE fault, not "did not succeed"/build-failed', () => { + // A build killed by ENOSPC/OOM never verified the code — must read as a + // transient infra fault (retry / more capacity), NOT the generic + // agent-did-not-succeed or a bogus build failure. Ordered before the + // agent_status catch-all so it wins. + const result = classifyError( + "Task did not succeed (agent_status='success', build_ok=infra)", + ); + expect(result!.category).toBe(ErrorCategory.COMPUTE); + expect(result!.title).toMatch(/ran out of resources/i); + expect(result!.retryable).toBe(true); + expect(result!.errorClass).toBe(ErrorClass.TRANSIENT); + expect(result!.remedy).toMatch(/try again|capacity|admin/i); + }); + + test('deliverable=lost is a retryable AGENT fault (work not saved), not the generic did-not-succeed', () => { + // A new-work task reported agent-success but no commit reached the branch + // and no PR opened — the agent's changes were LOST (nested-clone workspace + // fault). Must read as retryable/transient with "not saved" copy, NOT the + // non-retryable "Agent task did not succeed". Ordered before the + // agent_status catch-all so it wins. + const result = classifyError( + '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.", + ); + expect(result!.category).toBe(ErrorCategory.AGENT); + expect(result!.title).toMatch(/not saved/i); + expect(result!.retryable).toBe(true); + expect(result!.errorClass).toBe(ErrorClass.TRANSIENT); + expect(result!.remedy).toMatch(/try again/i); + }); + + test('deliverable=no_pr says the work is SAFE on the branch (the pull request just did not open)', () => { + // A commit DID land but the PR never opened — recoverable, and the copy + // must reassure the change is not gone. Distinct from deliverable=lost. + const result = classifyError( + '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.', + ); + expect(result!.category).toBe(ErrorCategory.AGENT); + expect(result!.title).toMatch(/pull request did not open/i); + expect(result!.retryable).toBe(true); + expect(result!.errorClass).toBe(ErrorClass.TRANSIENT); + expect(result!.description).toMatch(/safe on the branch/i); + }); + test('classifies error_max_turns as TIMEOUT with specific title (ordered before generic catch-all)', () => { // Regression guard: pre-fix, the agent's specific // ``agent_status='error_max_turns'`` signal was swallowed by the @@ -258,14 +306,15 @@ describe('classifyError', () => { expect(result!.remedy).toMatch(/--max-turns/); }); - test('ABCA-662: max_turns with an observed repeated failure stays "Exceeded max turns" and makes NO causal claim', () => { + test('max_turns with an observed repeated failure stays "Exceeded max turns" and makes NO causal claim', () => { // When the agent capped out with the last several calls being the same // repeated failure, the pipeline appends a NEUTRAL observation ("last tool // calls repeated: …"). The classification must NOT re-title the failure as // "retrying a failing step" or assert more turns wouldn't help — the window // (last few calls) can't tell a hard blocker from a long task that hit a - // recoverable snag late (662: siblings pushed fine → transient). It stays the - // plain max_turns bucket; the observed detail rides along in the message. + // recoverable snag late (observed: sibling tasks pushed fine, so the same + // repeated push failure was transient after all). It stays the plain + // max_turns bucket; the observed detail rides along in the message. const result = classifyError( "Agent session error (subtype='error_max_turns') — last tool calls repeated: " + '`git push --force-with-lease` — remote: invalid credentials fatal: exit 128', @@ -299,12 +348,13 @@ describe('classifyError', () => { expect(result!.retryable).toBe(true); }); - test('classifies the runner.py "Agent session error (subtype=...)" wrapper, not just agent_status= (K5, live-caught ABCA-483)', () => { + test('classifies the runner.py "Agent session error (subtype=...)" wrapper, not just agent_status=', () => { // runner.py:515 emits ``Agent session error (subtype='error_max_turns')`` - // — a DIFFERENT wrapper from pipeline.py's ``agent_status=``. Pre-K5 this - // fell through to UNKNOWN → "Unexpected error" even though the task hit the - // 100-turn cap (live: a 1-line README task burned 101 turns, reply said - // "Unexpected error"). The pattern must match the subtype= wrapper too. + // — a DIFFERENT wrapper from pipeline.py's ``agent_status=``. Matching only + // the ``agent_status=`` form let this fall through to UNKNOWN → "Unexpected + // error" even though the task hit the 100-turn cap (observed: a 1-line + // README task burned 101 turns and the reply said "Unexpected error"). + // The pattern must match the subtype= wrapper too. const turns = classifyError("Agent session error (subtype='error_max_turns')"); expect(turns!.title).toBe('Exceeded max turns'); expect(turns!.category).toBe(ErrorCategory.TIMEOUT); @@ -408,8 +458,8 @@ describe('classifyError', () => { expect(result!.retryable).toBe(false); }); - test('classifies a build/verify command TIMEOUT distinctly from a crash (ABCA-667 live-caught)', () => { - // The fork's full `mise run build` exceeded the 600s cap → Python + test('classifies a build/verify command TIMEOUT distinctly from a crash', () => { + // A repo's full `mise run build` exceeded the 600s cap → Python // TimeoutExpired. Before this pattern it fell to "Unexpected error"; now it // reads as a build-time-out (user-actionable: retry / raise the cap), not a // mysterious crash. @@ -420,12 +470,13 @@ describe('classifyError', () => { expect(result!.title).toMatch(/didn't finish in time|timed out/i); // A timeout is user-actionable (retry / raise the cap), not a hard failure. expect(result!.retryable).toBe(true); + expect(result!.errorClass).toBe(ErrorClass.USER); // Must NOT fall through to the generic Unexpected error. expect(result!.title).not.toMatch(/Unexpected error/i); }); }); - // --- Environmental blockers (#251) --- + // --- Environmental blockers --- describe('blocker errors (canonical BLOCKED[<kind>] prefix)', () => { test('classifies missing_secret and extracts the secret name', () => { @@ -471,7 +522,7 @@ describe('classifyError', () => { expect(result!.remedy).toContain('scopes'); }); - test('auth_failure with a Secrets Manager ARN gives IAM remedy, not PAT scopes (#251 review)', () => { + test('auth_failure with a Secrets Manager ARN gives IAM remedy, not PAT scopes', () => { const arn = 'arn:aws:secretsmanager:us-east-1:123456789012:secret:gh-token-abc'; const result = classifyError(`BLOCKED[auth_failure]: the required GitHub token secret could not be read (resource: ${arn})`); expect(result!.category).toBe(ErrorCategory.BLOCKED); @@ -616,10 +667,10 @@ describe('classifyError', () => { expect(g).toMatch(/edit the request/i); }); - // #599 N3: pin the two USER fall-through branches so the #247 failure-renderer - // contract can't rot silently. Built as explicit classifications (the exact - // category/errorClass/retryable each branch keys on) rather than relying on a - // sample string that might reclassify later. + // Pin the two USER fall-through branches so the orchestration + // failure-renderer contract can't rot silently. Built as explicit + // classifications (the exact category/errorClass/retryable each branch keys + // on) rather than relying on a sample string that might reclassify later. test('retryGuidance: retryable USER (non-guardrail) → "reply here with any extra guidance"', () => { const cls: ErrorClassification = { category: ErrorCategory.AGENT, @@ -757,7 +808,7 @@ describe('classifyError', () => { expect(detail.turns_completed).toBeNull(); }); - // Compile-time regression for Finding #10 — ``ChannelSource`` is a + // Compile-time regression guard — ``ChannelSource`` is a // literal union, not ``string``. The ``satisfies`` assertions below // exercise the valid members; the ``@ts-expect-error`` comments pin // the narrowing — if someone widens ``ChannelSource`` to ``string`` diff --git a/cdk/test/handlers/shared/iteration-reply.test.ts b/cdk/test/handlers/shared/iteration-reply.test.ts new file mode 100644 index 000000000..0103e127e --- /dev/null +++ b/cdk/test/handlers/shared/iteration-reply.test.ts @@ -0,0 +1,315 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { + isNoChangeIteration, + isTerminalMaturingReply, + preservePreviewSuffix, + renderIterationSuccessReply, + renderMaturingReply, + renderPreviewBlock, +} from '../../../src/handlers/shared/iteration-reply'; + +describe('renderMaturingReply — the edit-in-place states', () => { + test('on_it → instant ack (no metadata)', () => { + expect(renderMaturingReply({ state: 'on_it' })).toBe('👀 On it — reading the PR…'); + }); + + test('working → names the PR being updated', () => { + expect(renderMaturingReply({ state: 'working', prNumber: 293 })).toBe('🔄 Working — updating PR #293…'); + expect(renderMaturingReply({ state: 'working' })).toBe('🔄 Working…'); + }); + + describe('liveness heartbeat on the working state', () => { + test('a fresh task (< 90s) shows the clean working line, no elapsed clause', () => { + expect(renderMaturingReply({ state: 'working', prNumber: 7, elapsedS: 30 })) + .toBe('🔄 Working — updating PR #7…'); + }); + + test('a long-running task shows "Nm elapsed" so it is not a silent black box', () => { + const r = renderMaturingReply({ state: 'working', prNumber: 7, elapsedS: 8 * 60 }); + expect(r).toContain('🔄 Working — updating PR #7…'); + expect(r).toContain('_8m elapsed_'); + }); + + test('a sanitized progress note is appended after elapsed', () => { + const r = renderMaturingReply({ + state: 'working', prNumber: 7, elapsedS: 5 * 60, progressNote: 'running build verification', + }); + expect(r).toContain('_5m elapsed · running build verification_'); + }); + + test('a progress note alone (no elapsed yet) still surfaces', () => { + const r = renderMaturingReply({ state: 'working', elapsedS: 10, progressNote: 'cloning repo' }); + // elapsed below floor → omitted; note still shown + expect(r).toContain('_cloning repo_'); + expect(r).not.toContain('elapsed'); + }); + + test('progress note whitespace is collapsed + over-long notes truncated', () => { + const r = renderMaturingReply({ + state: 'working', elapsedS: 200, progressNote: 'a'.repeat(200), + }); + // suffix line stays bounded (note capped at 80 + ellipsis) + const suffix = r.split('\n').pop()!; + expect(suffix.length).toBeLessThan(120); + expect(suffix.endsWith('…_')).toBe(true); + }); + + test('elapsed/note never appear on terminal states (working-only)', () => { + const updated = renderMaturingReply({ state: 'updated', prNumber: 7, elapsedS: 600, progressNote: 'x' }); + expect(updated).not.toContain('elapsed'); + expect(updated).not.toContain('\nx'); + }); + }); + + test('a PR url makes the reference a clickable markdown link', () => { + const w = renderMaturingReply({ state: 'working', prNumber: 293, prUrl: 'https://gh/pull/293' }); + expect(w).toBe('🔄 Working — updating [PR #293](https://gh/pull/293)…'); + const u = renderMaturingReply({ state: 'updated', prNumber: 293, prUrl: 'https://gh/pull/293' }); + expect(u).toContain('✅ Updated — [PR #293](https://gh/pull/293).'); + }); + + test('updated → ✅ + cost · duration · running total + clickable preview thumbnail', () => { + const r = renderMaturingReply({ + state: 'updated', + prNumber: 293, + costUsd: 0.79, + durationS: 309, + runningTotalUsd: 2.04, + screenshotUrl: 'https://cdn/x.png', + deployUrl: 'https://app.vercel.app', + }); + expect(r).toContain('✅ Updated — PR #293.'); + expect(r).toContain('$0.79'); + expect(r).toContain('5m 9s'); + expect(r).toContain('total this PR: $2.04'); + // Clickable image thumbnail: screenshot PNG embedded, linking to the deploy. + expect(r).toContain('[![preview](https://cdn/x.png)](https://app.vercel.app)'); + }); + + test('updated with screenshot but NO deploy url → plain embedded image (no link target)', () => { + const r = renderMaturingReply({ state: 'updated', prNumber: 7, screenshotUrl: 'https://cdn/y.png' }); + expect(r).toContain('![preview](https://cdn/y.png)'); + expect(r).not.toContain('[![preview]'); // not a link when no deploy url + }); + + test('updated with NO screenshot → no preview block at all', () => { + const r = renderMaturingReply({ state: 'updated', prNumber: 7, costUsd: 0.1 }); + expect(r).not.toContain('preview'); + expect(r).toContain('✅ Updated — PR #7.'); + }); + + test('answered → 💬 + the answer + cost (a question, no commit)', () => { + const r = renderMaturingReply({ state: 'answered', answerText: 'The login page is at /login.html', costUsd: 0.24 }); + expect(r).toContain('💬 The login page is at /login.html'); + expect(r).toContain('$0.24'); + expect(r).not.toContain('Updated'); + }); + + test('answered with no answer → honest no-change line', () => { + expect(renderMaturingReply({ state: 'answered' })).toContain('No code change was needed'); + }); + + test('failed → ❌ + sanitized reason', () => { + expect(renderMaturingReply({ state: 'failed', failureReason: 'build failed: tsc error' })) + .toContain('❌ build failed: tsc error'); + }); + + test('terminal metadata line omits unknown parts gracefully', () => { + // Only cost known → no duration, no total, no empty separators. + const r = renderMaturingReply({ state: 'updated', prNumber: 1, costUsd: 0.5 }); + expect(r).toContain('$0.50'); + expect(r).not.toContain('total this PR'); + expect(r).not.toMatch(/·\s*·/); // no doubled separators + }); + + test('on_it / working never carry a metadata line', () => { + expect(renderMaturingReply({ state: 'on_it', costUsd: 1 })).not.toContain('$'); + expect(renderMaturingReply({ state: 'working', costUsd: 1, prNumber: 2 })).not.toContain('$'); + }); +}); + +describe('renderIterationSuccessReply — changed (a real edit)', () => { + test('code changed + PR number → "✅ Updated — PR #N"', () => { + expect(renderIterationSuccessReply({ codeChanged: true, prNumber: 290 })) + .toBe('✅ Updated — PR #290.'); + }); + + test('code changed + no PR number → "✅ Updated."', () => { + expect(renderIterationSuccessReply({ codeChanged: true, prNumber: null })) + .toBe('✅ Updated.'); + }); + + test('codeChanged UNDEFINED (a caller that never sets it) → back-compat "✅ Updated — PR #N"', () => { + // The whole point of the back-compat default: anything that doesn't opt in + // behaves exactly as before. + expect(renderIterationSuccessReply({ prNumber: 178 })).toBe('✅ Updated — PR #178.'); + expect(renderIterationSuccessReply({})).toBe('✅ Updated.'); + }); + + test('an answer is IGNORED when code changed (the PR link is the signal)', () => { + expect(renderIterationSuccessReply({ codeChanged: true, prNumber: 5, answerText: 'irrelevant' })) + .toBe('✅ Updated — PR #5.'); + }); +}); + +describe('renderIterationSuccessReply — no change (a question)', () => { + test('no change + an answer → "💬 <answer>" (NOT a false ✅ Updated)', () => { + const r = renderIterationSuccessReply({ + codeChanged: false, + prNumber: 290, + answerText: 'The login page is at /login.html, but it is not yet linked from the nav.', + }); + expect(r).toBe('💬 The login page is at /login.html, but it is not yet linked from the nav.'); + expect(r).not.toContain('Updated'); + expect(r).not.toContain('290'); // a question reply must not imply a PR update + }); + + test('no change + NO answer → an honest "no change needed" (still not ✅ Updated)', () => { + const r = renderIterationSuccessReply({ codeChanged: false, prNumber: 290 }); + expect(r).toContain('No code change'); + expect(r).not.toContain('✅'); + }); + + test('a long answer is truncated with an ellipsis', () => { + const long = 'x'.repeat(5000); + const r = renderIterationSuccessReply({ codeChanged: false, answerText: long }); + // Cap is MAX_ANSWER_CHARS=2000 (aligned with the agent's persist cap so the + // renderer never drops chars the agent already bounded); '💬 ' prefix + ellipsis. + expect(r.length).toBeLessThanOrEqual(2003); + expect(r.length).toBeGreaterThan(1700); + expect(r.endsWith('…')).toBe(true); + }); + + test('whitespace-only answer falls back to the honest no-change line', () => { + const r = renderIterationSuccessReply({ codeChanged: false, answerText: ' ' }); + expect(r).toContain('No code change'); + }); +}); + +describe('isNoChangeIteration', () => { + test('only false counts as no-change (undefined/true do not)', () => { + expect(isNoChangeIteration(false)).toBe(true); + expect(isNoChangeIteration(true)).toBe(false); + expect(isNoChangeIteration(undefined)).toBe(false); + }); +}); + +describe('isTerminalMaturingReply — stop a late progress edit clobbering an outcome', () => { + // Two independent writers edit one reply: the terminal settle, and the + // stream-driven progress milestone. The milestone can be delivered AFTER the + // settle — observed just two seconds behind it — and the body is the ground + // truth about which has landed. + test('every TERMINAL render is recognised as settled', () => { + for (const state of ['updated', 'answered', 'failed'] as const) { + const body = renderMaturingReply({ + state, + prNumber: 42, + ...(state === 'answered' ? { answerText: 'no change needed' } : {}), + }); + expect(isTerminalMaturingReply(body)).toBe(true); + } + }); + + test('PROGRESS renders are NOT settled, so progress still flows', () => { + // If these came back true, a reply would freeze at "On it" forever. + for (const state of ['on_it', 'working'] as const) { + expect(isTerminalMaturingReply(renderMaturingReply({ state, prNumber: 42 }))).toBe(false); + } + }); + + test('a settled body with a preview block appended is still settled', () => { + // The screenshot webhook appends to the terminal body; that must not make it + // look un-settled. + const settled = renderMaturingReply({ state: 'updated', prNumber: 7 }); + expect(isTerminalMaturingReply(`${settled}\n\n[![preview](p.png)](https://d)`)).toBe(true); + }); + + test('an unknown or absent body is treated as NOT settled (fail open)', () => { + // Conservative on purpose: a missed progress edit is cosmetic, a reply stuck + // at "On it" is a black box. + expect(isTerminalMaturingReply(undefined)).toBe(false); + expect(isTerminalMaturingReply(null)).toBe(false); + expect(isTerminalMaturingReply('')).toBe(false); + expect(isTerminalMaturingReply('some other bot comment')).toBe(false); + }); + + test('leading whitespace does not hide a terminal marker', () => { + expect(isTerminalMaturingReply(' \n✅ Updated — PR #1.')).toBe(true); + }); +}); + +describe('preservePreviewSuffix — converge the two async writers of one reply', () => { + const PNG = 'https://cdn.example/screenshots/x.png'; + const DEPLOY = 'https://app.vercel.app'; + const BLOCK = `[![preview](${PNG})](${DEPLOY})`; + + test('carries an already-landed clickable thumbnail from current onto the new body', () => { + // The screenshot webhook appended the block; this terminal re-render would + // otherwise drop it. Convergence re-attaches the EXACT block on its own line. + const current = `✅ Updated — [PR #5](u). _$0.1_\n\n${BLOCK}`; + const newBody = '✅ Updated — [PR #5](u). _$0.2 · 35s · total this PR: $0.5_'; + expect(preservePreviewSuffix(newBody, current)).toBe(`${newBody}\n\n${BLOCK}`); + }); + + test('preserves a plain embed too (screenshot, no deploy link)', () => { + const current = `✅ Updated.\n\n![preview](${PNG})`; + const newBody = '✅ Updated — [PR #5](u). _$0.2_'; + expect(preservePreviewSuffix(newBody, current)).toBe(`${newBody}\n\n![preview](${PNG})`); + }); + + test('no-op when the new body already carries its own preview (settle-after-append)', () => { + const current = `✅ Updated.\n\n${BLOCK}`; + const newBody = `✅ Updated — [PR #5](u).\n\n${BLOCK}`; + expect(preservePreviewSuffix(newBody, current)).toBe(newBody); // not doubled + }); + + test('no-op when current has no preview (the common no-deploy case)', () => { + const current = '👀 On it — reading the PR…'; + const newBody = '✅ Updated — [PR #5](u). _$0.2_'; + expect(preservePreviewSuffix(newBody, current)).toBe(newBody); + }); + + test('null/undefined current → returns new body unchanged', () => { + expect(preservePreviewSuffix('✅ Updated.', null)).toBe('✅ Updated.'); + expect(preservePreviewSuffix('✅ Updated.', undefined)).toBe('✅ Updated.'); + }); + + test('idempotent across repeated settles', () => { + const newBody = '✅ Updated — [PR #5](u). _$0.2_'; + const once = preservePreviewSuffix(newBody, `x\n\n${BLOCK}`); + const twice = preservePreviewSuffix(once, once); // current now already has it + expect(twice).toBe(once); + }); +}); + +describe('renderPreviewBlock — clickable thumbnail vs plain embed', () => { + test('both urls → clickable image thumbnail', () => { + expect(renderPreviewBlock('https://cdn/s.png', 'https://deploy')).toBe('[![preview](https://cdn/s.png)](https://deploy)'); + }); + test('screenshot only → plain embed', () => { + expect(renderPreviewBlock('https://cdn/s.png')).toBe('![preview](https://cdn/s.png)'); + expect(renderPreviewBlock('https://cdn/s.png', null)).toBe('![preview](https://cdn/s.png)'); + }); + test('no screenshot → empty', () => { + expect(renderPreviewBlock(null, 'https://deploy')).toBe(''); + expect(renderPreviewBlock(undefined)).toBe(''); + }); +}); diff --git a/cdk/test/handlers/shared/linear-attachments.test.ts b/cdk/test/handlers/shared/linear-attachments.test.ts new file mode 100644 index 000000000..e9d4e6f3d --- /dev/null +++ b/cdk/test/handlers/shared/linear-attachments.test.ts @@ -0,0 +1,534 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const screenImageMock = jest.fn(); +const screenTextFileMock = jest.fn(); +jest.mock('../../../src/handlers/shared/attachment-screening', () => { + const actual = jest.requireActual('../../../src/handlers/shared/attachment-screening'); + return { + ...actual, + screenImage: (...args: unknown[]) => screenImageMock(...args), + screenTextFile: (...args: unknown[]) => screenTextFileMock(...args), + }; +}); + +// dns.lookup is used for the SSRF guard — resolve to a public IP by default. +const dnsLookupMock = jest.fn(); +jest.mock('dns/promises', () => ({ + lookup: (...args: unknown[]) => dnsLookupMock(...args), +})); + +import { AttachmentScreeningError, type ScreeningConfig } from '../../../src/handlers/shared/attachment-screening'; +import { + downloadScreenAndStoreLinearAttachments, + isLinearUploadsUrl, + LinearAttachmentError, +} from '../../../src/handlers/shared/linear-attachments'; +import { logger } from '../../../src/handlers/shared/logger'; +import { MAX_ATTACHMENT_SIZE_BYTES } from '../../../src/handlers/shared/validation'; + +const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00]); +const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); +const PDF_BYTES = Buffer.from('%PDF-1.7\n1 0 obj\n<<>>\nendobj\n'); +const TEXT_BYTES = Buffer.from('log line one\nlog line two\n'); + +const putSendMock = jest.fn(); +const s3Client = { send: putSendMock } as unknown as import('@aws-sdk/client-s3').S3Client; + +const screeningConfig: ScreeningConfig = { + guardrailId: 'gr-1', + guardrailVersion: '1', + bedrockClient: {} as never, +}; + +function storageCtx() { + return { + s3Client, + bucketName: 'attachments-bucket', + screeningConfig, + userId: 'user-1', + taskId: 'task-1', + accessToken: 'lin_oauth_at', + linearWorkspaceId: 'ws-1', + }; +} + +const UPLOAD_URL = 'https://uploads.linear.app/aaaa-1111/bbbb-2222/screenshot.png?signature=abc'; +function desc(...urls: string[]): string { + return `Some issue text\n\n${urls.map((u, i) => `![img${i}](${u})`).join('\n')}\n\nmore text`; +} +/** Description with plain-link (file) markdown `[label](url)` rather than image `![]()`. */ +function fileDesc(...urls: string[]): string { + return `Some issue text\n\n${urls.map((u, i) => `[file${i}](${u})`).join('\n')}\n\nmore text`; +} +/** Description with an explicit markdown link label (the original filename). */ +function labeledDesc(label: string, url: string): string { + return `Some issue text\n\n[${label}](${url})\n\nmore text`; +} + +/** A fetch Response-like object whose body streams the given buffer once. */ +function bytesResponse(buf: Buffer, status = 200, contentType = 'image/png'): Response { + let sent = false; + return { + ok: status >= 200 && status < 300, + status, + headers: { get: (h: string) => (h.toLowerCase() === 'content-type' ? contentType : null) }, + body: { + getReader() { + return { + read() { + if (sent) return Promise.resolve({ done: true, value: undefined }); + sent = true; + return Promise.resolve({ done: false, value: new Uint8Array(buf) }); + }, + cancel() { return Promise.resolve(); }, + }; + }, + }, + } as unknown as Response; +} + +beforeEach(() => { + screenImageMock.mockReset(); + screenImageMock.mockImplementation((content: Buffer) => Promise.resolve({ + content, + checksum: 'sha256:abc', + screening: { status: 'passed', screened_at: '2026-07-22T00:00:00Z' }, + })); + screenTextFileMock.mockReset(); + screenTextFileMock.mockImplementation((content: Buffer) => Promise.resolve({ + content, + checksum: 'sha256:def', + screening: { status: 'passed', screened_at: '2026-07-22T00:00:00Z' }, + })); + putSendMock.mockReset(); + putSendMock.mockResolvedValue({ VersionId: 'v1' }); + dnsLookupMock.mockReset(); + dnsLookupMock.mockResolvedValue([{ address: '203.0.113.7', family: 4 }]); + (global.fetch as unknown) = jest.fn(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('isLinearUploadsUrl', () => { + test('matches uploads.linear.app and subdomains, rejects others', () => { + expect(isLinearUploadsUrl('https://uploads.linear.app/x/y/z.png')).toBe(true); + expect(isLinearUploadsUrl('https://eu.uploads.linear.app/x/y/z.png')).toBe(true); + expect(isLinearUploadsUrl('https://cdn.example.com/z.png')).toBe(false); + expect(isLinearUploadsUrl('not a url')).toBe(false); + }); +}); + +describe('downloadScreenAndStoreLinearAttachments', () => { + test('happy path: fetches uploads.linear.app image with the OAuth bearer, screens, uploads, returns a passed record', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(PNG_BYTES)); + const records = await downloadScreenAndStoreLinearAttachments(desc(UPLOAD_URL), 10, storageCtx()); + + expect(records).toHaveLength(1); + expect(records[0].type).toBe('image'); + expect(records[0].content_type).toBe('image/png'); + expect(records[0].screening.status).toBe('passed'); + expect(screenImageMock).toHaveBeenCalled(); + expect(putSendMock).toHaveBeenCalledTimes(1); + // Bearer header carried the workspace token. + const init = (global.fetch as jest.Mock).mock.calls[0][1] as { headers: Record<string, string> }; + expect(init.headers.Authorization).toBe('Bearer lin_oauth_at'); + }); + + test('two uploads whose names differ only by . vs - are BOTH kept', async () => { + // The id collapsed '.', '-' and '/' to the same char, so design.v1.png and + // design-v1.png produced an identical id — and both de-dupe sites resolve a + // collision by DISCARDING the second file. A user who attached both silently + // got one, with nothing logged. Distinct paths must stay distinct. + (global.fetch as jest.Mock).mockImplementation(() => Promise.resolve(bytesResponse(PNG_BYTES))); + const records = await downloadScreenAndStoreLinearAttachments( + desc('https://uploads.linear.app/u/p/design.v1.png', 'https://uploads.linear.app/u/p/design-v1.png'), + 10, + storageCtx(), + ); + expect(records).toHaveLength(2); + expect(new Set(records.map((r) => r.attachment_id)).size).toBe(2); + }); + + test('a long descriptive label does NOT silently drop the attachment', async () => { + // A tight label bound made a link with long alt text stop matching, so the + // file vanished with nothing logged — trading a slow scan for lost user data. + (global.fetch as jest.Mock).mockImplementation(() => Promise.resolve(bytesResponse(PNG_BYTES))); + const longLabel = 'a detailed description of this diagram '.repeat(20); // ~780 chars + const records = await downloadScreenAndStoreLinearAttachments( + `![${longLabel}](https://uploads.linear.app/u/p/spec.png)`, 10, storageCtx(), + ); + expect(records).toHaveLength(1); + }); + + test('a hostile URL-shaped description is bounded too, not just a bracket run', async () => { + // BOTH variable parts of the pattern backtrack per start position. Bounding + // the label alone left `[](https://a` repeated just as slow as before, which + // is the shape a mangled paste produces. + // + // Asserts the SCAN INPUT is bounded rather than a wall-clock budget: a timing + // assertion in a parallel suite measures CPU contention as much as the code, + // and this one failed under full-suite load while passing alone. The warning + // is the observable proof the cap engaged — and that the truncation is + // announced rather than silent. + const warn = jest.spyOn(logger, 'warn').mockImplementation(() => undefined); + try { + const hostile = '[](https://a'.repeat(20_000); // ~240 KB, far past the cap + const records = await downloadScreenAndStoreLinearAttachments(hostile, 10, storageCtx()); + expect(records).toEqual([]); + const capped = warn.mock.calls.some(([msg]) => String(msg).includes('attachment-scan cap')); + expect(capped).toBe(true); + } finally { + warn.mockRestore(); + } + }); + + test('a large bracket-heavy description scans in bounded time, not quadratic', async () => { + // Making the leading '!' optional stopped the engine anchoring on a literal + // '!', so an unbounded label quantifier retried from every '[' — 50 KB of + // unmatched brackets took ~940 ms and ~150 KB blew the webhook timeout. No + // crafting needed; a big pasted table does it. + // Asserts the scan INPUT is bounded, not a wall-clock budget: a timing + // assertion in a parallel suite measures CPU contention as much as the code, + // and this one passed alone at ~690 ms while failing under full-suite load. + const warn = jest.spyOn(logger, 'warn').mockImplementation(() => undefined); + try { + const hostile = '['.repeat(200_000); + const records = await downloadScreenAndStoreLinearAttachments(hostile, 10, storageCtx()); + expect(records).toEqual([]); + expect(warn.mock.calls.some(([m]) => String(m).includes('attachment-scan cap'))).toBe(true); + } finally { + warn.mockRestore(); + } + }); + + test('ignores non-uploads.linear.app images (public CDN images go via the URL path)', async () => { + const records = await downloadScreenAndStoreLinearAttachments( + desc('https://cdn.example.com/pic.png'), 10, storageCtx(), + ); + expect(records).toHaveLength(0); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + test('no-op when description is empty or has no uploads', async () => { + expect(await downloadScreenAndStoreLinearAttachments(undefined, 10, storageCtx())).toEqual([]); + expect(await downloadScreenAndStoreLinearAttachments('plain text', 10, storageCtx())).toEqual([]); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + test('overflow past remainingSlots throws (loud, not a silent truncation) — finding #2', async () => { + (global.fetch as jest.Mock).mockImplementation(() => Promise.resolve(bytesResponse(PNG_BYTES))); + const urls = Array.from({ length: 5 }, (_, i) => `https://uploads.linear.app/u/${i}/p${i}.png`); + // 5 uploads but only 2 slots free → reject the task rather than drop 3 silently. + await expect( + downloadScreenAndStoreLinearAttachments(desc(...urls), 2, storageCtx()), + ).rejects.toThrow(/over the limit of 2|Remove some attachments/i); + // Nothing stored — rejected before the download loop. + expect(putSendMock).not.toHaveBeenCalled(); + }); + + test('exactly filling the slot budget is fine (no overflow error)', async () => { + (global.fetch as jest.Mock).mockImplementation(() => Promise.resolve(bytesResponse(PNG_BYTES))); + const urls = Array.from({ length: 2 }, (_, i) => `https://uploads.linear.app/u/${i}/p${i}.png`); + const records = await downloadScreenAndStoreLinearAttachments(desc(...urls), 2, storageCtx()); + expect(records).toHaveLength(2); + }); + + test('de-dupes the same upload referenced twice', async () => { + (global.fetch as jest.Mock).mockImplementation(() => Promise.resolve(bytesResponse(PNG_BYTES))); + const records = await downloadScreenAndStoreLinearAttachments(desc(UPLOAD_URL, UPLOAD_URL), 10, storageCtx()); + expect(records).toHaveLength(1); + }); + + test('zero remainingSlots WITH a Linear upload present → REJECTS (no silent drop, finding #6)', async () => { + // Regression: this used to silently return [] (the spec was dropped while the + // task ran). Now a Linear upload with no free slots fails loud. + await expect( + downloadScreenAndStoreLinearAttachments(desc(UPLOAD_URL), 0, storageCtx()), + ).rejects.toThrow(/already used up|limit/i); + expect(global.fetch).not.toHaveBeenCalled(); // rejected before fetching + }); + + test('zero remainingSlots with NO uploads → clean no-op', async () => { + const records = await downloadScreenAndStoreLinearAttachments('plain text, no uploads', 0, storageCtx()); + expect(records).toEqual([]); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + test('401 on download → LinearAttachmentError (signed URL stale; fail-closed, no refresh loop)', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(Buffer.alloc(0), 401)); + await expect( + downloadScreenAndStoreLinearAttachments(desc(UPLOAD_URL), 10, storageCtx()), + ).rejects.toBeInstanceOf(LinearAttachmentError); + expect((global.fetch as jest.Mock)).toHaveBeenCalledTimes(1); // no retry loop for a stale signed URL + }); + + test('zero-byte body → LinearAttachmentError (fail-closed)', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(Buffer.alloc(0))); + await expect( + downloadScreenAndStoreLinearAttachments(desc(UPLOAD_URL), 10, storageCtx()), + ).rejects.toBeInstanceOf(LinearAttachmentError); + expect(putSendMock).not.toHaveBeenCalled(); + }); + + test('magic-byte mismatch → LinearAttachmentError (fail-closed)', async () => { + // content-type says png but bytes are junk + (global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(Buffer.from([0x00, 0x01, 0x02]), 200, 'image/png')); + await expect( + downloadScreenAndStoreLinearAttachments(desc(UPLOAD_URL), 10, storageCtx()), + ).rejects.toBeInstanceOf(LinearAttachmentError); + expect(screenImageMock).not.toHaveBeenCalled(); + }); + + test('fetches a PDF file link, screens it as text, returns a file record', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(PDF_BYTES, 200, 'application/pdf')); + const records = await downloadScreenAndStoreLinearAttachments( + fileDesc('https://uploads.linear.app/u/p/design.pdf'), 10, storageCtx(), + ); + expect(records).toHaveLength(1); + expect(records[0].type).toBe('file'); + expect(records[0].content_type).toBe('application/pdf'); + expect(records[0].token_estimate).toBeUndefined(); // files don't carry a vision-token estimate + expect(screenTextFileMock).toHaveBeenCalled(); + expect(screenImageMock).not.toHaveBeenCalled(); + expect(putSendMock).toHaveBeenCalledTimes(1); + }); + + test('matches the angle-bracket autolink URL form Linear normalizes links into', async () => { + // Linear round-trips `[f](https://…)` into `[f](<https://…>)`. The un-bracketed + // pattern dropped it silently (observed in practice) — the attachment + // never reached S3 and the task ran without it. + (global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(PDF_BYTES, 200, 'application/pdf')); + const autolinkDesc = 'See [design.pdf](<https://uploads.linear.app/u/p/design.pdf>) attached.'; + const records = await downloadScreenAndStoreLinearAttachments(autolinkDesc, 10, storageCtx()); + expect(records).toHaveLength(1); + expect(records[0].type).toBe('file'); + expect(records[0].content_type).toBe('application/pdf'); + // The captured URL must NOT include the trailing '>' (the fetch must hit the real URL). + const fetchedUrl = (global.fetch as jest.Mock).mock.calls[0][0] as string; + expect(fetchedUrl).toBe('https://uploads.linear.app/u/p/design.pdf'); + }); + + test('types a generic octet-stream response by the .log extension in its markdown label', async () => { + // The extension comes from the markdown LABEL (the original filename), since + // the uploads.linear.app URL path is a UUID with no extension. + (global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(TEXT_BYTES, 200, 'application/octet-stream')); + const records = await downloadScreenAndStoreLinearAttachments( + labeledDesc('output.log', 'https://uploads.linear.app/u/l/9c8b-uuid'), 10, storageCtx(), + ); + expect(records).toHaveLength(1); + expect(records[0].type).toBe('file'); + expect(records[0].content_type).toBe('text/x-log'); + expect(screenTextFileMock).toHaveBeenCalled(); + }); + + test('REJECTS an unsupported type fail-closed, naming the FRIENDLY filename + supported types', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + bytesResponse(Buffer.from([0x50, 0x4b, 0x03, 0x04]), 200, 'application/zip'), + ); + // The URL path is a UUID; the friendly name comes from the markdown label. + await expect( + downloadScreenAndStoreLinearAttachments( + labeledDesc('spec.docx', 'https://uploads.linear.app/u/z/9a8b7c6d-uuid'), 10, storageCtx(), + ), + ).rejects.toThrow(/Attachment 'spec\.docx' is not a supported file type.*PDF/i); + // Never screened or stored — rejected before that. + expect(screenImageMock).not.toHaveBeenCalled(); + expect(screenTextFileMock).not.toHaveBeenCalled(); + expect(putSendMock).not.toHaveBeenCalled(); + }); + + test('surfaces the friendly filename (markdown label) in a screening-block error, not the UUID', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(PDF_BYTES, 200, 'application/pdf')); + screenTextFileMock.mockRejectedValueOnce(new AttachmentScreeningError('blocked: prompt attack')); + await expect( + downloadScreenAndStoreLinearAttachments( + labeledDesc('design.pdf', 'https://uploads.linear.app/u/p/deadbeef-uuid'), 10, storageCtx(), + ), + ).rejects.toThrow(/Attachment 'design\.pdf' was blocked by content screening/); + }); + + test('falls back to the path-safe filename when the markdown label is empty', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + bytesResponse(Buffer.from([0x50, 0x4b, 0x03, 0x04]), 200, 'application/zip'), + ); + // Empty label `[]( … )` → displayName falls back to the derived filename (no crash, still rejects). + await expect( + downloadScreenAndStoreLinearAttachments( + labeledDesc('', 'https://uploads.linear.app/u/z/bundle-uuid'), 10, storageCtx(), + ), + ).rejects.toThrow(/not a supported file type/i); + }); + + test('a .txt-LABELED binary payload is REJECTED, not stored as text (finding #3)', async () => { + // Attacker labels a binary octet-stream `[diagram.txt]`. The label must NOT + // promote it to text/plain past the UTF-8 gate — bytes with invalid UTF-8 / + // nulls fail validateMagicBytes, so it's rejected as unsupported. + const binary = Buffer.from([0xff, 0xfe, 0x00, 0x01, 0x80, 0x81]); // invalid UTF-8 + null + (global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(binary, 200, 'application/octet-stream')); + await expect( + downloadScreenAndStoreLinearAttachments( + labeledDesc('diagram.txt', 'https://uploads.linear.app/u/x/evil-uuid'), 10, storageCtx(), + ), + ).rejects.toThrow(/not a supported file type|does not match its declared type/i); + expect(screenTextFileMock).not.toHaveBeenCalled(); + expect(putSendMock).not.toHaveBeenCalled(); + }); + + test('a .pdf-LABELED non-PDF is NOT promoted to application/pdf by the label (finding #3)', async () => { + // Only magic bytes can vouch for a binary type. A `[x.pdf]` label over + // non-PDF octet-stream bytes must not be treated as a PDF. + (global.fetch as jest.Mock).mockResolvedValueOnce( + bytesResponse(Buffer.from([0x00, 0x01, 0x02, 0x03]), 200, 'application/octet-stream'), + ); + await expect( + downloadScreenAndStoreLinearAttachments( + labeledDesc('notreally.pdf', 'https://uploads.linear.app/u/x/fake-uuid'), 10, storageCtx(), + ), + ).rejects.toThrow(/not a supported file type/i); + }); + + test('hydrates a native paperclip attachment (uploads.linear.app) — finding #1', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(PDF_BYTES, 200, 'application/pdf')); + // No description link; the file arrives via the paperclip list (4th arg). + const records = await downloadScreenAndStoreLinearAttachments( + 'Implement the attached spec.', 10, storageCtx(), + [{ title: 'spec.pdf', url: 'https://uploads.linear.app/u/pc/paperclip-uuid' }], + ); + expect(records).toHaveLength(1); + expect(records[0].type).toBe('file'); + expect(records[0].content_type).toBe('application/pdf'); + expect(screenTextFileMock).toHaveBeenCalled(); + }); + + test('ignores an external-link paperclip (not uploads.linear.app)', async () => { + const records = await downloadScreenAndStoreLinearAttachments( + 'See the design.', 10, storageCtx(), + [{ title: 'Figma', url: 'https://figma.com/file/abc' }], + ); + expect(records).toHaveLength(0); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + test('de-dupes a file that is BOTH a paperclip and a description link', async () => { + (global.fetch as jest.Mock).mockImplementation(() => Promise.resolve(bytesResponse(PDF_BYTES, 200, 'application/pdf'))); + const url = 'https://uploads.linear.app/u/dup/same-uuid'; + const records = await downloadScreenAndStoreLinearAttachments( + labeledDesc('spec.pdf', url), 10, storageCtx(), + [{ title: 'spec.pdf', url }], + ); + expect(records).toHaveLength(1); // fetched once, not twice + }); + + test('sniffs JPEG when content-type is generic but bytes are a JPEG', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(JPEG_BYTES, 200, 'application/octet-stream')); + const records = await downloadScreenAndStoreLinearAttachments( + desc('https://uploads.linear.app/u/j/photo.jpg'), 10, storageCtx(), + ); + expect(records).toHaveLength(1); + expect(records[0].content_type).toBe('image/jpeg'); + }); + + test('a PDF served as text/plain is typed as PDF by magic bytes, NOT screened as raw text (finding #2)', async () => { + // Magic bytes are authoritative: %PDF- wins over a text/plain content-type, + // so it goes through PDF extraction (screenTextFile w/ application/pdf), never + // content.toString(utf-8) on binary bytes. + (global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(PDF_BYTES, 200, 'text/plain')); + const records = await downloadScreenAndStoreLinearAttachments( + labeledDesc('sneaky.txt', 'https://uploads.linear.app/u/p/sneaky-uuid'), 10, storageCtx(), + ); + expect(records).toHaveLength(1); + expect(records[0].content_type).toBe('application/pdf'); // typed by bytes, not the text/plain header + expect(screenTextFileMock).toHaveBeenCalledWith(expect.anything(), 'application/pdf', expect.anything(), expect.anything()); + }); + + test('SSRF: an IPv4-mapped IPv6 metadata address (::ffff:169.254.169.254) is rejected (finding #8)', async () => { + dnsLookupMock.mockResolvedValueOnce([{ address: '::ffff:169.254.169.254', family: 6 }]); + await expect( + downloadScreenAndStoreLinearAttachments(desc(UPLOAD_URL), 10, storageCtx()), + ).rejects.toBeInstanceOf(LinearAttachmentError); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + test('SSRF: an fe80::/10 link-local address beyond the fe80 prefix (fea9::1) is rejected (finding #8)', async () => { + dnsLookupMock.mockResolvedValueOnce([{ address: 'fea9::1', family: 6 }]); + await expect( + downloadScreenAndStoreLinearAttachments(desc(UPLOAD_URL), 10, storageCtx()), + ).rejects.toBeInstanceOf(LinearAttachmentError); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + test('screening block → LinearAttachmentError (fail-closed)', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(PNG_BYTES)); + screenImageMock.mockRejectedValueOnce(new AttachmentScreeningError('blocked: prompt attack')); + await expect( + downloadScreenAndStoreLinearAttachments(desc(UPLOAD_URL), 10, storageCtx()), + ).rejects.toBeInstanceOf(LinearAttachmentError); + expect(putSendMock).not.toHaveBeenCalled(); + }); + + test('screening returns blocked status → LinearAttachmentError', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(PNG_BYTES)); + screenImageMock.mockResolvedValueOnce({ + content: PNG_BYTES, + checksum: 'sha256:abc', + screening: { status: 'blocked', categories: ['VIOLENCE'], screened_at: '2026-07-22T00:00:00Z' }, + }); + await expect( + downloadScreenAndStoreLinearAttachments(desc(UPLOAD_URL), 10, storageCtx()), + ).rejects.toBeInstanceOf(LinearAttachmentError); + }); + + test('SSRF: host resolving to a private IP → LinearAttachmentError, no fetch', async () => { + dnsLookupMock.mockResolvedValueOnce([{ address: '10.0.0.5', family: 4 }]); + await expect( + downloadScreenAndStoreLinearAttachments(desc(UPLOAD_URL), 10, storageCtx()), + ).rejects.toBeInstanceOf(LinearAttachmentError); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + test('body exceeding size limit while streaming → LinearAttachmentError', async () => { + // Stream a body larger than the cap in one chunk. + const tooBig = Buffer.concat([PNG_BYTES, Buffer.alloc(MAX_ATTACHMENT_SIZE_BYTES + 1)]); + (global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(tooBig)); + await expect( + downloadScreenAndStoreLinearAttachments(desc(UPLOAD_URL), 10, storageCtx()), + ).rejects.toBeInstanceOf(LinearAttachmentError); + }); + + test('deletes already-uploaded objects when a later attachment fails (no orphans)', async () => { + const deleteSendMock = putSendMock; // same client.send + (global.fetch as jest.Mock) + .mockResolvedValueOnce(bytesResponse(PNG_BYTES)) // #1 ok + .mockResolvedValueOnce(bytesResponse(Buffer.alloc(0), 401)); // #2 auth-fail → throw + await expect( + downloadScreenAndStoreLinearAttachments( + desc('https://uploads.linear.app/u/1/a.png', 'https://uploads.linear.app/u/2/b.png'), + 10, storageCtx(), + ), + ).rejects.toBeInstanceOf(LinearAttachmentError); + // A DeleteObjectsCommand was sent to clean up the one uploaded object. + const sentDeletes = deleteSendMock.mock.calls.filter( + (c) => c[0]?.constructor?.name === 'DeleteObjectsCommand', + ); + expect(sentDeletes.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/cdk/test/handlers/shared/linear-feedback.test.ts b/cdk/test/handlers/shared/linear-feedback.test.ts index 3a19f4d93..68f124349 100644 --- a/cdk/test/handlers/shared/linear-feedback.test.ts +++ b/cdk/test/handlers/shared/linear-feedback.test.ts @@ -28,9 +28,21 @@ const fetchMock = jest.fn(); import { addIssueReaction, + appendOnceToComment, type LinearFeedbackContext, + deleteComment, + fetchRecentComments, postIssueComment, + reactToComment, + replyToComment, reportIssueFailure, + revertIssueToNotStarted, + sweepTransientNotes, + swapCommentReaction, + swapIssueReaction, + transitionIssueState, + upsertStatusComment, + upsertThreadedReply, } from '../../../src/handlers/shared/linear-feedback'; const CTX: LinearFeedbackContext = { @@ -71,7 +83,7 @@ describe('linear-feedback', () => { expect(url).toBe('https://api.linear.app/graphql'); expect(init.method).toBe('POST'); expect(init.headers).toMatchObject({ - // OAuth tokens use Bearer prefix per Phase 2.0b-O2. + // OAuth access tokens are sent with the Bearer prefix. 'Authorization': `Bearer ${TOKEN}`, 'Content-Type': 'application/json', }); @@ -158,6 +170,91 @@ describe('linear-feedback', () => { }); }); + describe('reactToComment — instant "on it" acknowledgement on the comment itself', () => { + test('reacts on the COMMENT (commentId), defaulting to 👀 (eyes)', async () => { + fetchMock.mockResolvedValue(jsonResponse({ data: { reactionCreate: { success: true } } })); + + const ok = await reactToComment(CTX, 'comment-77'); + + expect(ok).toBe(true); + const init = fetchMock.mock.calls[0][1]; + const body = JSON.parse(init.body as string) as { query: string; variables: { commentId: string; emoji: string } }; + expect(body.query).toContain('reactionCreate'); + // The variable is commentId — NOT issueId (reacts on the comment, not the issue). + expect(body.variables.commentId).toBe('comment-77'); + expect(body.variables.emoji).toBe('eyes'); + }); + + test('honours an explicit emoji argument', async () => { + fetchMock.mockResolvedValue(jsonResponse({ data: { reactionCreate: { success: true } } })); + await reactToComment(CTX, 'comment-77', 'white_check_mark'); + const init = fetchMock.mock.calls[0][1]; + const body = JSON.parse(init.body as string) as { variables: { emoji: string } }; + expect(body.variables.emoji).toBe('white_check_mark'); + }); + + test('returns false when the token cannot be resolved (no fetch)', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + const ok = await reactToComment(CTX, 'comment-77'); + expect(ok).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('returns false on network failure (swallowed)', async () => { + fetchMock.mockRejectedValueOnce(new Error('ECONNRESET')); + const ok = await reactToComment(CTX, 'comment-77'); + expect(ok).toBe(false); + }); + }); + + describe('replyToComment — threaded reply that notifies the commenter', () => { + test('POSTs commentCreate with BOTH issueId and parentId, returns the new reply id', async () => { + fetchMock.mockResolvedValue(jsonResponse({ data: { commentCreate: { success: true, comment: { id: 'reply-99' } } } })); + + const replyId = await replyToComment(CTX, ISSUE_ID, 'comment-77', '✅ Updated — PR #178'); + + expect(replyId).toBe('reply-99'); + const init = fetchMock.mock.calls[0][1]; + const body = JSON.parse(init.body as string) as { query: string; variables: { issueId: string; parentId: string; body: string } }; + expect(body.query).toContain('commentCreate'); + // CONTRACT (verified against the live Linear API): commentCreate REQUIRES + // issueId even for a threaded reply — parentId alone fails argument + // validation. Pin BOTH so the missing-issueId regression can't return. + expect(body.variables.issueId).toBe(ISSUE_ID); + expect(body.variables.parentId).toBe('comment-77'); + expect(body.variables.body).toBe('✅ Updated — PR #178'); + }); + + test('the mutation declares issueId as a required argument (regression guard)', async () => { + fetchMock.mockResolvedValue(jsonResponse({ data: { commentCreate: { success: true, comment: { id: 'r' } } } })); + await replyToComment(CTX, ISSUE_ID, 'comment-77', 'body'); + const init = fetchMock.mock.calls[0][1]; + const query = (JSON.parse(init.body as string) as { query: string }).query; + // The GraphQL op must pass issueId INTO commentCreate's input — not just + // accept it as a variable. Catches a half-fix that drops it from input. + expect(query).toMatch(/commentCreate\(\s*input:\s*\{[^}]*issueId:\s*\$issueId/); + }); + + test('returns null when commentCreate did not succeed', async () => { + fetchMock.mockResolvedValue(jsonResponse({ data: { commentCreate: { success: false } } })); + const replyId = await replyToComment(CTX, ISSUE_ID, 'comment-77', 'body'); + expect(replyId).toBeNull(); + }); + + test('returns null on GraphQL errors (no throw)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'parent not found' }] })); + const replyId = await replyToComment(CTX, ISSUE_ID, 'comment-77', 'body'); + expect(replyId).toBeNull(); + }); + + test('returns null when the token cannot be resolved (no fetch)', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + const replyId = await replyToComment(CTX, ISSUE_ID, 'comment-77', 'body'); + expect(replyId).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + describe('reportIssueFailure', () => { test('posts comment + ❌ in parallel via Promise.allSettled', async () => { await reportIssueFailure(CTX, ISSUE_ID, '❌ failed'); @@ -186,4 +283,772 @@ describe('linear-feedback', () => { await expect(reportIssueFailure(CTX, ISSUE_ID, 'msg')).resolves.toBeUndefined(); }); }); + + describe('swapIssueReaction — exactly one status marker on the issue at a time', () => { + const reactionsResp = (rs: Array<{ id: string; emoji: string }>) => + jsonResponse({ data: { issue: { reactions: rs } } }); + + test('👀 present → deletes it and adds the target (✅)', async () => { + fetchMock + .mockResolvedValueOnce(reactionsResp([{ id: 'r-eyes', emoji: 'eyes' }])) // query + .mockResolvedValueOnce(jsonResponse({ data: { reactionDelete: { success: true } } })) // delete 👀 + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); // add ✅ + const ok = await swapIssueReaction(CTX, ISSUE_ID, 'white_check_mark'); + expect(ok).toBe(true); + const deleteVars = JSON.parse(fetchMock.mock.calls[1][1].body).variables; + expect(deleteVars).toEqual({ id: 'r-eyes' }); + const createVars = JSON.parse(fetchMock.mock.calls[2][1].body).variables; + expect(createVars).toEqual({ issueId: ISSUE_ID, emoji: 'white_check_mark' }); + }); + + test('target already present → deletes other bgagent markers, does NOT re-create', async () => { + fetchMock + .mockResolvedValueOnce(reactionsResp([ + { id: 'r-eyes', emoji: 'eyes' }, + { id: 'r-check', emoji: 'white_check_mark' }, + ])) + .mockResolvedValueOnce(jsonResponse({ data: { reactionDelete: { success: true } } })); // delete 👀 only + const ok = await swapIssueReaction(CTX, ISSUE_ID, 'white_check_mark'); + expect(ok).toBe(true); + // 1 query + 1 delete (the 👀); no create (✅ already there). + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables).toEqual({ id: 'r-eyes' }); + }); + + test('never deletes a human (non-bgagent) reaction', async () => { + fetchMock + .mockResolvedValueOnce(reactionsResp([ + { id: 'r-eyes', emoji: 'eyes' }, + { id: 'r-tada', emoji: 'tada' }, // human reaction — must survive + ])) + .mockResolvedValueOnce(jsonResponse({ data: { reactionDelete: { success: true } } })) // delete 👀 + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); // add ✅ + await swapIssueReaction(CTX, ISSUE_ID, 'white_check_mark'); + const deletedIds = fetchMock.mock.calls + .filter((c) => JSON.parse(c[1].body).query.includes('reactionDelete')) + .map((c) => JSON.parse(c[1].body).variables.id); + expect(deletedIds).toEqual(['r-eyes']); // only the bgagent marker, never r-tada + }); + + test('no existing markers → just adds the target', async () => { + fetchMock + .mockResolvedValueOnce(reactionsResp([])) + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); + const ok = await swapIssueReaction(CTX, ISSUE_ID, 'eyes'); + expect(ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); // query + create, no deletes + }); + + test('a TRANSIENT delete failure is retried once, and then succeeds', async () => { + // Observed in practice: a 5s request timeout aborted the 👀 removal, so the + // comment kept 👀 beside ❓. No caller inspects this result and nothing + // revisits the item, so that contradiction was permanent — one retry + // covers the blip. + fetchMock + .mockResolvedValueOnce(reactionsResp([{ id: 'r-eyes', emoji: 'eyes' }])) + .mockResolvedValueOnce(jsonResponse({}, 503)) // delete: transient + .mockResolvedValueOnce(jsonResponse({ data: { reactionDelete: { success: true } } })) // retry wins + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); + expect(await swapIssueReaction(CTX, ISSUE_ID, 'white_check_mark')).toBe(true); + const deletes = fetchMock.mock.calls.filter((c) => JSON.parse(c[1].body).query.includes('reactionDelete')); + expect(deletes).toHaveLength(2); // the failed attempt plus one retry + }); + + test('a TERMINAL delete failure is NOT retried — a resend cannot change the answer', async () => { + fetchMock + .mockResolvedValueOnce(reactionsResp([{ id: 'r-eyes', emoji: 'eyes' }])) + .mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'not found' }] })) // terminal + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); + expect(await swapIssueReaction(CTX, ISSUE_ID, 'white_check_mark')).toBe(false); + const deletes = fetchMock.mock.calls.filter((c) => JSON.parse(c[1].body).query.includes('reactionDelete')); + expect(deletes).toHaveLength(1); + }); + + test('a retry that ALSO fails still reports failure', async () => { + fetchMock + .mockResolvedValueOnce(reactionsResp([{ id: 'r-eyes', emoji: 'eyes' }])) + .mockResolvedValueOnce(jsonResponse({}, 503)) + .mockResolvedValueOnce(jsonResponse({}, 503)) + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); + expect(await swapIssueReaction(CTX, ISSUE_ID, 'white_check_mark')).toBe(false); + }); + + test('a delete that FAILS is reported as failure, even though the target was added', async () => { + // Returning only the add's result would claim the promised single marker + // while the issue still shows 👀 beside ✅ — the contradictory state this + // swap exists to prevent, and what a caller checks the result to rule out. + fetchMock + .mockResolvedValueOnce(reactionsResp([{ id: 'r-eyes', emoji: 'eyes' }])) + .mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'nope' }] })) // delete 👀 fails + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); + expect(await swapIssueReaction(CTX, ISSUE_ID, 'white_check_mark')).toBe(false); + // The target is still attempted — one correct marker beats only a stale one. + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + test('target already present but a stale marker survives → failure', async () => { + fetchMock + .mockResolvedValueOnce(reactionsResp([ + { id: 'r-eyes', emoji: 'eyes' }, + { id: 'r-check', emoji: 'white_check_mark' }, + ])) + .mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'nope' }] })); + expect(await swapIssueReaction(CTX, ISSUE_ID, 'white_check_mark')).toBe(false); + }); + + test('no token → false, no fetch', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + expect(await swapIssueReaction(CTX, ISSUE_ID, 'eyes')).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe('swapCommentReaction — settle the trigger comment 👀→✅/❌', () => { + const commentReactionsResp = (rs: Array<{ id: string; emoji: string }>) => + jsonResponse({ data: { comment: { reactions: rs } } }); + + test('👀 on the comment → deletes it and adds ✅ (on the COMMENT, not the issue)', async () => { + fetchMock + .mockResolvedValueOnce(commentReactionsResp([{ id: 'r-eyes', emoji: 'eyes' }])) + .mockResolvedValueOnce(jsonResponse({ data: { reactionDelete: { success: true } } })) + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); + const ok = await swapCommentReaction(CTX, 'comment-77', 'white_check_mark'); + expect(ok).toBe(true); + // query targets the COMMENT + expect(JSON.parse(fetchMock.mock.calls[0][1].body).variables).toEqual({ commentId: 'comment-77' }); + // delete the stale 👀 + expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables).toEqual({ id: 'r-eyes' }); + // create the ✅ via reactionCreate(commentId) + const createVars = JSON.parse(fetchMock.mock.calls[2][1].body).variables; + expect(createVars).toEqual({ commentId: 'comment-77', emoji: 'white_check_mark' }); + }); + + test('target already present → no re-create (idempotent under redelivery)', async () => { + fetchMock + .mockResolvedValueOnce(commentReactionsResp([ + { id: 'r-eyes', emoji: 'eyes' }, + { id: 'r-check', emoji: 'white_check_mark' }, + ])) + .mockResolvedValueOnce(jsonResponse({ data: { reactionDelete: { success: true } } })); + const ok = await swapCommentReaction(CTX, 'comment-77', 'white_check_mark'); + expect(ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); // query + delete 👀; ✅ already present + }); + + test('the COMMENT swap retries a transient removal too', async () => { + // The failure this covers was seen on a comment: 👀 + ❓ both left on a + // disambiguation reply, because the removal timed out and nothing ever + // tried again. + fetchMock + .mockResolvedValueOnce(commentReactionsResp([{ id: 'r-eyes', emoji: 'eyes' }])) + .mockResolvedValueOnce(jsonResponse({}, 503)) + .mockResolvedValueOnce(jsonResponse({ data: { reactionDelete: { success: true } } })) + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); + expect(await swapCommentReaction(CTX, 'comment-77', 'question')).toBe(true); + }); + + test('a delete that FAILS on the comment is reported as failure too', async () => { + // Both swaps implement one interface method, so they must report the same + // all-or-nothing result; a caller cannot branch per surface. + fetchMock + .mockResolvedValueOnce(commentReactionsResp([{ id: 'r-eyes', emoji: 'eyes' }])) + .mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'nope' }] })) + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); + expect(await swapCommentReaction(CTX, 'comment-77', 'white_check_mark')).toBe(false); + }); + + test('never deletes a human reaction on the comment', async () => { + fetchMock + .mockResolvedValueOnce(commentReactionsResp([ + { id: 'r-eyes', emoji: 'eyes' }, + { id: 'r-heart', emoji: 'heart' }, // human — must survive + ])) + .mockResolvedValueOnce(jsonResponse({ data: { reactionDelete: { success: true } } })) + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); + await swapCommentReaction(CTX, 'comment-77', 'x'); + const deletedIds = fetchMock.mock.calls + .filter((c) => JSON.parse(c[1].body).query.includes('reactionDelete')) + .map((c) => JSON.parse(c[1].body).variables.id); + expect(deletedIds).toEqual(['r-eyes']); // never r-heart + }); + + test('no token → false, no fetch', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + expect(await swapCommentReaction(CTX, 'comment-77', 'eyes')).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe('upsertStatusComment — one live status comment edited in place', () => { + test('no existing id → creates a comment and returns the new id', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ data: { commentCreate: { success: true, comment: { id: 'cmt-new' } } } }), + ); + const id = await upsertStatusComment(CTX, ISSUE_ID, 'body'); + expect(id).toBe('cmt-new'); + // create mutation carries issueId + body + const vars = JSON.parse(fetchMock.mock.calls[0][1].body).variables; + expect(vars).toEqual({ issueId: ISSUE_ID, body: 'body' }); + }); + + test('existing id → edits in place and returns the same id', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); + const id = await upsertStatusComment(CTX, ISSUE_ID, 'new body', 'cmt-existing'); + expect(id).toBe('cmt-existing'); + const vars = JSON.parse(fetchMock.mock.calls[0][1].body).variables; + expect(vars).toEqual({ id: 'cmt-existing', body: 'new body' }); + }); + + test('create reporting success:false → null', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { commentCreate: { success: false } } })); + expect(await upsertStatusComment(CTX, ISSUE_ID, 'body')).toBeNull(); + }); + + test('update GraphQL failure → null (does not fabricate the id)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'not found' }] })); + expect(await upsertStatusComment(CTX, ISSUE_ID, 'body', 'cmt-x')).toBeNull(); + }); + + test('no token → null, no fetch', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + expect(await upsertStatusComment(CTX, ISSUE_ID, 'body')).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe('deleteComment — remove a transient acknowledgement comment', () => { + test('success → true, sends commentDelete with the id', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { commentDelete: { success: true } } })); + expect(await deleteComment(CTX, 'cmt-ack')).toBe(true); + const vars = JSON.parse(fetchMock.mock.calls[0][1].body).variables; + expect(vars).toEqual({ id: 'cmt-ack' }); + }); + + test('GraphQL error → false (best-effort, never throws)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'not found' }] })); + expect(await deleteComment(CTX, 'cmt-ack')).toBe(false); + }); + + test('no token → false, no fetch', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + expect(await deleteComment(CTX, 'cmt-ack')).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe('transitionIssueState', () => { + // Mirrors a typical Linear team's workflow states (by type + position). + const TEAM_STATES = [ + { id: 's-backlog', type: 'backlog', name: 'Backlog', position: 0 }, + { id: 's-todo', type: 'unstarted', name: 'Todo', position: 1 }, + { id: 's-inprogress', type: 'started', name: 'In Progress', position: 2 }, + { id: 's-inreview', type: 'started', name: 'In Review', position: 1002 }, + { id: 's-done', type: 'completed', name: 'Done', position: 3 }, + ]; + const statesResp = (current: { id: string; type: string; name: string; position: number }) => + jsonResponse({ data: { issue: { state: current, team: { states: { nodes: TEAM_STATES } } } } }); + const cur = (id: string) => TEAM_STATES.find((s) => s.id === id)!; + + test('Backlog → In Progress: picks the named started state, issues issueUpdate', async () => { + fetchMock + .mockResolvedValueOnce(statesResp(cur('s-backlog'))) // team-states query + .mockResolvedValueOnce(jsonResponse({ data: { issueUpdate: { success: true } } })); + const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Progress']); + expect(ok).toBe(true); + // second call is the mutation with the resolved stateId + const mutationVars = JSON.parse(fetchMock.mock.calls[1][1].body).variables; + expect(mutationVars).toEqual({ issueId: ISSUE_ID, stateId: 's-inprogress' }); + }); + + test('In Progress → In Review: name preference wins over position among started states', async () => { + fetchMock + .mockResolvedValueOnce(statesResp(cur('s-inprogress'))) + .mockResolvedValueOnce(jsonResponse({ data: { issueUpdate: { success: true } } })); + const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Review']); + expect(ok).toBe(true); + expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables.stateId).toBe('s-inreview'); + }); + + test('already in target state → no mutation, returns false', async () => { + fetchMock.mockResolvedValueOnce(statesResp(cur('s-inreview'))); + const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Review']); + expect(ok).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); // only the query, no mutation + }); + + test('never moves backward: Done (completed) is not demoted to In Review', async () => { + fetchMock.mockResolvedValueOnce(statesResp(cur('s-done'))); + const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Review']); + expect(ok).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + // The parent rollup's re-open (In Review → In Progress — BOTH of type + // 'started') was silently blocked by the same-type position tiebreak, so + // both directions of that tiebreak are pinned below. + test('same-type regression In Review → In Progress is BLOCKED by default', async () => { + fetchMock.mockResolvedValueOnce(statesResp(cur('s-inreview'))); + const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Progress']); + expect(ok).toBe(false); // In Progress (pos 2) < In Review (pos 1002) → backward + expect(fetchMock).toHaveBeenCalledTimes(1); // no mutation + }); + + test('same-type regression In Review → In Progress SUCCEEDS with allowSameTypeRegression (rollup re-open)', async () => { + fetchMock + .mockResolvedValueOnce(statesResp(cur('s-inreview'))) + .mockResolvedValueOnce(jsonResponse({ data: { issueUpdate: { success: true } } })); + const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Progress'], true); + expect(ok).toBe(true); + expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables.stateId).toBe('s-inprogress'); + }); + + test('allowSameTypeRegression does NOT permit a cross-type demotion (Done → In Progress still blocked)', async () => { + fetchMock.mockResolvedValueOnce(statesResp(cur('s-done'))); + const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Progress'], true); + expect(ok).toBe(false); // completed → started is cross-type; still refused + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('returns false when token cannot be resolved', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Review']); + expect(ok).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('returns false when the team has no state of the target type', async () => { + const noCompleted = TEAM_STATES.filter((s) => s.type !== 'completed'); + fetchMock.mockResolvedValueOnce( + jsonResponse({ data: { issue: { state: cur('s-inprogress'), team: { states: { nodes: noCompleted } } } } }), + ); + const ok = await transitionIssueState(CTX, ISSUE_ID, 'completed'); + expect(ok).toBe(false); + }); + }); + + describe('revertIssueToNotStarted — undo our own In Progress when work did not start', () => { + const TEAM_STATES = [ + { id: 's-backlog', type: 'backlog', name: 'Backlog', position: 0 }, + { id: 's-todo', type: 'unstarted', name: 'Todo', position: 1 }, + { id: 's-inprogress', type: 'started', name: 'In Progress', position: 2 }, + { id: 's-done', type: 'completed', name: 'Done', position: 3 }, + ]; + const statesResp = (current: { id: string; type: string; name: string; position: number }) => + jsonResponse({ data: { issue: { state: current, team: { states: { nodes: TEAM_STATES } } } } }); + const cur = (id: string) => TEAM_STATES.find((s) => s.id === id)!; + + test('In Progress → Todo: our In-Progress reverts to the unstarted state', async () => { + fetchMock + .mockResolvedValueOnce(statesResp(cur('s-inprogress'))) + .mockResolvedValueOnce(jsonResponse({ data: { issueUpdate: { success: true } } })); + const ok = await revertIssueToNotStarted(CTX, ISSUE_ID); + expect(ok).toBe(true); + expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables.stateId).toBe('s-todo'); + }); + + test('falls back to Backlog when the team has no unstarted state', async () => { + const noUnstarted = TEAM_STATES.filter((s) => s.type !== 'unstarted'); + fetchMock + .mockResolvedValueOnce( + jsonResponse({ data: { issue: { state: cur('s-inprogress'), team: { states: { nodes: noUnstarted } } } } }), + ) + .mockResolvedValueOnce(jsonResponse({ data: { issueUpdate: { success: true } } })); + const ok = await revertIssueToNotStarted(CTX, ISSUE_ID); + expect(ok).toBe(true); + expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables.stateId).toBe('s-backlog'); + }); + + test('does NOT demote a human-completed issue (only reverts a started state)', async () => { + fetchMock.mockResolvedValueOnce(statesResp(cur('s-done'))); + const ok = await revertIssueToNotStarted(CTX, ISSUE_ID); + expect(ok).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); // query only, no mutation + }); + + test('no-op when the issue is already in a not-started (backlog) state', async () => { + fetchMock.mockResolvedValueOnce(statesResp(cur('s-backlog'))); + const ok = await revertIssueToNotStarted(CTX, ISSUE_ID); + expect(ok).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('appendOnceToComment — add the preview link to a reply at most once', () => { + const COMMENT_ID = 'reply-cmt-1'; + + test('reads the body and appends the line when the marker is absent', async () => { + // 1st fetch = read body; 2nd = commentUpdate. + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { comment: { body: '✅ Updated — [PR #5](u). _$0.1_' } } })) + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); + const ok = await appendOnceToComment(CTX, COMMENT_ID, ' · [preview](https://cdn/x.png)', '[preview]'); + expect(ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + // The update carries the original body + the appended line. + const updateBody = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(updateBody.variables.body).toBe('✅ Updated — [PR #5](u). _$0.1_\n · [preview](https://cdn/x.png)'); + expect(updateBody.variables.id).toBe(COMMENT_ID); + }); + + test('idempotent: marker already present → no update (webhook redelivery)', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ data: { comment: { body: '✅ Updated — [PR #5](u). · [preview](https://cdn/x.png)' } } }), + ); + const ok = await appendOnceToComment(CTX, COMMENT_ID, ' · [preview](https://cdn/y.png)', '[preview]'); + expect(ok).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); // read only, NO update + }); + + test('missing comment body → no update, returns false', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { comment: null } })); + const ok = await appendOnceToComment(CTX, COMMENT_ID, ' · [preview](u)', '[preview]'); + expect(ok).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('no token → no fetch', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + const ok = await appendOnceToComment(CTX, COMMENT_ID, ' · [preview](u)', '[preview]'); + expect(ok).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe('upsertThreadedReply preservePreview — converge concurrent writers of one reply', () => { + const REPLY_ID = 'reply-cmt-9'; + const BLOCK = '[![preview](https://cdn/screenshots/x.png)](https://app.vercel.app)'; + + test('terminal edit carries an already-landed preview thumbnail from the current body', async () => { + // The screenshot webhook appended the clickable thumbnail block first; this + // terminal re-render reads the current body and re-attaches it, so the + // two writers converge instead of one dropping the other's content. + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { comment: { body: `✅ Updated.\n\n${BLOCK}` } } })) + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); + const newBody = '✅ Updated — [PR #5](u). _$0.2 · 35s_'; + const id = await upsertThreadedReply(CTX, ISSUE_ID, 'parent-1', newBody, REPLY_ID, { preservePreview: true }); + expect(id).toBe(REPLY_ID); + expect(fetchMock).toHaveBeenCalledTimes(2); // read body, then update + const updateBody = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(updateBody.variables.body).toBe(`${newBody}\n\n${BLOCK}`); + }); + + test('skipIfSettled REFUSES a progress edit over an already-settled reply', async () => { + // The failure this fixes: a stream-delivered "working" milestone landed + // AFTER the terminal settle and overwrote "✅ Updated", so the reply + // contradicted both the trigger comment's ✅ reaction and reality. + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { comment: { body: '✅ Updated — [PR #5](u).' } } })); + const id = await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', '🔄 Working — updating PR #5…', REPLY_ID, { skipIfSettled: true }, + ); + // Reports the reply id (the caller's intent is satisfied — it IS up to date) + // but performs NO update mutation. + expect(id).toBe(REPLY_ID); + expect(fetchMock).toHaveBeenCalledTimes(1); // the body read only + }); + + test('skipIfSettled still allows a progress edit while the reply is un-settled', async () => { + // The common case: the reply is at "On it" and this is the first progress + // tick. Suppressing here would freeze the reply and re-create the black box. + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { comment: { body: '👀 On it — reading the PR…' } } })) + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); + const progress = '🔄 Working — updating PR #5…'; + const id = await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', progress, REPLY_ID, { skipIfSettled: true }, + ); + expect(id).toBe(REPLY_ID); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables.body).toBe(progress); + }); + + test('a TERMINAL edit never sets skipIfSettled, so an outcome can always overwrite progress', async () => { + // Direction matters: progress yields to an outcome, never the reverse. + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); + const id = await upsertThreadedReply(CTX, ISSUE_ID, 'parent-1', '✅ Updated.', REPLY_ID); + expect(id).toBe(REPLY_ID); + expect(fetchMock).toHaveBeenCalledTimes(1); // straight update, no read + }); + + test('both options together read the body ONCE', async () => { + // The terminal settle asks for preview preservation; a progress edit asks + // for the settle check. Neither should double-read. + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { comment: { body: `👀 On it…\n\n${BLOCK}` } } })) + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); + await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', '🔄 Working…', REPLY_ID, + { preservePreview: true, skipIfSettled: true }, + ); + expect(fetchMock).toHaveBeenCalledTimes(2); // one read + one update + }); + + test('without preservePreview the edit does NOT read the body (single update)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); + const id = await upsertThreadedReply(CTX, ISSUE_ID, 'parent-1', '✅ Updated.', REPLY_ID); + expect(id).toBe(REPLY_ID); + expect(fetchMock).toHaveBeenCalledTimes(1); // straight update, no read + }); + }); + + describe('upsertThreadedReply repairIfOverwritten (the outcome defends itself)', () => { + const REPLY_ID = 'reply-cmt-9'; + const BLOCK = '[![preview](https://cdn/screenshots/x.png)](https://app.vercel.app)'; + const OUTCOME = '✅ Updated — [PR #5](u). _$0.2 · 35s_'; + + test('restores the outcome when a racing progress edit overwrote it', async () => { + // Why the progress writer's own body check is not enough: it reads, then + // writes, and a settle landing in between is still overwritten. Linear's + // comment update takes only an id and a body — no version to condition on — + // so the interleaving cannot be prevented, only detected and undone by the + // writer that knows which body matters. + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })) // settle write + .mockResolvedValueOnce(jsonResponse({ data: { comment: { body: '🔄 Working — updating PR #5…' } } })) + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); // repair + + const id = await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', OUTCOME, REPLY_ID, { repairIfOverwritten: true }, + ); + + expect(id).toBe(REPLY_ID); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(JSON.parse(fetchMock.mock.calls[2][1].body).variables.body).toBe(OUTCOME); + }); + + test('the repair carries over a preview appended during the race', async () => { + // The screenshot webhook is a third writer of this reply; restoring the + // outcome must not drop a thumbnail that landed while the race resolved. + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })) + .mockResolvedValueOnce(jsonResponse({ data: { comment: { body: `🔄 Working…\n\n${BLOCK}` } } })) + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); + + await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', OUTCOME, REPLY_ID, { repairIfOverwritten: true }, + ); + + expect(JSON.parse(fetchMock.mock.calls[2][1].body).variables.body).toBe(`${OUTCOME}\n\n${BLOCK}`); + }); + + test('no repair when the outcome is still there (one extra read, no write)', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })) + .mockResolvedValueOnce(jsonResponse({ data: { comment: { body: OUTCOME } } })); + + const id = await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', OUTCOME, REPLY_ID, { repairIfOverwritten: true }, + ); + + expect(id).toBe(REPLY_ID); + expect(fetchMock).toHaveBeenCalledTimes(2); // write + verify read, no second write + }); + + test('a DIFFERENT terminal body is left alone — never fight a later outcome', async () => { + // A failure reply superseding a success, or a second iteration's settle, is + // newer and correct. Re-asserting our own would undo real information. + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })) + .mockResolvedValueOnce(jsonResponse({ data: { comment: { body: '❌ The build failed.' } } })); + + await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', OUTCOME, REPLY_ID, { repairIfOverwritten: true }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); // no repair write + }); + + test('an unreadable body is left alone rather than guessed at', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })) + .mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'boom' }] })); + + const id = await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', OUTCOME, REPLY_ID, { repairIfOverwritten: true }, + ); + + // The settle itself succeeded, so report success; the verify is advisory. + expect(id).toBe(REPLY_ID); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test('a PROGRESS body is never defended — only outcomes are worth the round trip', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); + + await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', '🔄 Working…', REPLY_ID, { repairIfOverwritten: true }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); // no verify read at all + }); + + test('a failed settle is reported as failed and never repaired', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'bad id' }] })); + + const id = await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', OUTCOME, REPLY_ID, { repairIfOverwritten: true }, + ); + + expect(id).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('a repair that itself fails does not fail the settle', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })) + .mockResolvedValueOnce(jsonResponse({ data: { comment: { body: '🔄 Working…' } } })) + .mockResolvedValueOnce(jsonResponse({}, 500)); + + const id = await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', OUTCOME, REPLY_ID, { repairIfOverwritten: true }, + ); + + expect(id).toBe(REPLY_ID); + }); + }); + + describe('sweepTransientNotes — tidy the planning chatter once a plan is approved', () => { + // A representative plan-phase thread: the frozen plan reference (KEEP), the + // transient notes (🗂️/👋 → DELETE), the live epic panel (🔄 → a + // different prefix, KEEP), and a human comment (no bot prefix, KEEP). + const THREAD = { + data: { + issue: { + comments: { + nodes: [ + { id: 'plan-ref', body: '🗂️ **Approved plan** — 2 sub-issues' }, + { id: 'started-ack', body: '🗂️ On it — working out how to break this up…' }, + { id: 'nudge', body: '👋 I answer to `@bgagent`…' }, + { id: 'panel', body: '🔄 **ABCA orchestration** · 0/2 complete' }, + { id: 'human', body: 'looks good, ship it' }, + ], + }, + }, + }, + }; + + test('deletes the transient 🗂️/👋 notes, keeps the frozen reference + panel + human comment', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(THREAD)) // the comments list + .mockResolvedValue(jsonResponse({ data: { commentDelete: { success: true } } })); + const deleted = await sweepTransientNotes(CTX, ISSUE_ID, 'plan-ref'); + // started-ack + nudge deleted; plan-ref (kept), panel (🔄), human (no prefix) survive. + expect(deleted).toBe(2); + const deletedIds = fetchMock.mock.calls + .slice(1) + .map((c) => JSON.parse(c[1].body).variables.id); + expect(deletedIds.sort()).toEqual(['nudge', 'started-ack']); + expect(deletedIds).not.toContain('plan-ref'); + expect(deletedIds).not.toContain('panel'); + expect(deletedIds).not.toContain('human'); + }); + + test('with no keepCommentId, sweeps ALL bot notes incl. the untracked reference (nothing to preserve)', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(THREAD)) + .mockResolvedValue(jsonResponse({ data: { commentDelete: { success: true } } })); + const deleted = await sweepTransientNotes(CTX, ISSUE_ID); + // plan-ref + started-ack + nudge (all 🗂️/👋); panel + human still spared. + expect(deleted).toBe(3); + }); + + test('no token → no fetch, sweeps nothing', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + const deleted = await sweepTransientNotes(CTX, ISSUE_ID, 'plan-ref'); + expect(deleted).toBe(0); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('a failed comments-list is a clean no-op (best-effort, never throws)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'boom' }] }, 200)); + const deleted = await sweepTransientNotes(CTX, ISSUE_ID, 'plan-ref'); + expect(deleted).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(1); // only the (failed) list; no deletes + }); + + test('a leading-whitespace bot note is still matched (trimStart)', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ + data: { issue: { comments: { nodes: [{ id: 'ws', body: '\n 🗂️ over-cap note' }] } } }, + })) + .mockResolvedValue(jsonResponse({ data: { commentDelete: { success: true } } })); + const deleted = await sweepTransientNotes(CTX, ISSUE_ID, 'plan-ref'); + expect(deleted).toBe(1); + }); + }); + + describe('fetchRecentComments — pre-hydrate the human discussion for the agent', () => { + function commentsResponse(nodes: unknown[]): Response { + return jsonResponse({ data: { issue: { comments: { nodes } } } }); + } + + test('returns human comments oldest-first, rendered with author + timestamp', async () => { + fetchMock.mockResolvedValueOnce(commentsResponse([ + { id: 'c2', body: 'second', createdAt: '2026-07-20T10:00:00Z', user: { displayName: 'Bob' } }, + { id: 'c1', body: 'first', createdAt: '2026-07-19T09:00:00Z', user: { displayName: 'Alice' } }, + ])); + const result = await fetchRecentComments(CTX, ISSUE_ID); + expect(result).toEqual([ + { author: 'Alice', createdAt: '2026-07-19T09:00:00Z', markdown: 'first' }, + { author: 'Bob', createdAt: '2026-07-20T10:00:00Z', markdown: 'second' }, + ]); + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as { query: string; variables: Record<string, string> }; + expect(body.query).toContain('botActor'); + expect(body.variables).toEqual({ issueId: ISSUE_ID }); + }); + + test('drops app/integration comments (botActor present, or no user)', async () => { + fetchMock.mockResolvedValueOnce(commentsResponse([ + { id: 'h', body: 'human turn', createdAt: '2026-07-19T09:00:00Z', user: { displayName: 'Alice' } }, + { id: 'b', body: 'bot progress', createdAt: '2026-07-19T09:05:00Z', botActor: { id: 'app-1' } }, + { id: 'n', body: 'no author', createdAt: '2026-07-19T09:06:00Z', user: null }, + ])); + const result = await fetchRecentComments(CTX, ISSUE_ID); + expect(result).toHaveLength(1); + expect(result[0].markdown).toBe('human turn'); + }); + + test('drops bot-prefixed bodies even if attributed to a user (belt + suspenders)', async () => { + fetchMock.mockResolvedValueOnce(commentsResponse([ + { id: 'p', body: '🤖 Starting…', createdAt: '2026-07-19T09:00:00Z', user: { displayName: 'Someone' } }, + { id: 'h', body: 'real question', createdAt: '2026-07-19T09:01:00Z', user: { displayName: 'Alice' } }, + ])); + const result = await fetchRecentComments(CTX, ISSUE_ID); + expect(result.map((c) => c.markdown)).toEqual(['real question']); + }); + + test('keeps only the most recent maxComments', async () => { + const nodes = Array.from({ length: 5 }, (_, i) => ({ + id: `c${i}`, + body: `comment ${i}`, + createdAt: `2026-07-2${i}T00:00:00Z`, + user: { displayName: 'Alice' }, + })); + fetchMock.mockResolvedValueOnce(commentsResponse(nodes)); + const capped = await fetchRecentComments(CTX, ISSUE_ID, 2); + expect(capped.map((c) => c.markdown)).toEqual(['comment 3', 'comment 4']); + }); + + test('fail-open: no token → [] (never throws)', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + const result = await fetchRecentComments(CTX, ISSUE_ID); + expect(result).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('fail-open: GraphQL error → [] (never throws)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'boom' }] })); + const result = await fetchRecentComments(CTX, ISSUE_ID); + expect(result).toEqual([]); + }); + + test('skips comments with empty/whitespace bodies', async () => { + fetchMock.mockResolvedValueOnce(commentsResponse([ + { id: 'e', body: ' ', createdAt: '2026-07-19T09:00:00Z', user: { displayName: 'Alice' } }, + { id: 'h', body: 'kept', createdAt: '2026-07-19T09:01:00Z', user: { displayName: 'Bob' } }, + ])); + const result = await fetchRecentComments(CTX, ISSUE_ID); + expect(result.map((c) => c.markdown)).toEqual(['kept']); + }); + }); }); diff --git a/cdk/test/handlers/shared/linear-issue-lookup.test.ts b/cdk/test/handlers/shared/linear-issue-lookup.test.ts index 0635920d1..1d20951e3 100644 --- a/cdk/test/handlers/shared/linear-issue-lookup.test.ts +++ b/cdk/test/handlers/shared/linear-issue-lookup.test.ts @@ -17,12 +17,14 @@ * SOFTWARE. */ -// `findLinearIssueByIdentifier` is covered by PR #275's broader test -// suite (mocks the registry scan + GraphQL); this file only locks in -// `extractLinearIdentifier` since it's a pure function and the g-flag -// regex's `lastIndex` reset behavior is easy to regress across releases. +// `findLinearIssueByIdentifier` needs the registry scan and the GraphQL call +// mocked, so it is covered elsewhere. This file locks in the pure identifier +// parsers, whose g-flagged regex `lastIndex` reset behavior is easy to regress. -import { extractLinearIdentifier } from '../../../src/handlers/shared/linear-issue-lookup'; +import { + extractLinearIdentifier, + extractLinearIdentifierFromBranch, +} from '../../../src/handlers/shared/linear-issue-lookup'; describe('extractLinearIdentifier', () => { test('returns null for null / undefined / empty input', () => { @@ -57,10 +59,10 @@ describe('extractLinearIdentifier', () => { expect(extractLinearIdentifier('ABCA-1234567890')).toBeNull(); }); - // The regex is g-flagged at module scope. - // scope, which means `RegExp.prototype.exec` carries `lastIndex` - // across calls. The implementation explicitly resets it; this test - // pins the behavior so nobody removes the reset thinking it's dead. + // The regex is g-flagged and lives at module scope, which means + // `RegExp.prototype.exec` carries `lastIndex` across calls. The + // implementation explicitly resets it; this test pins the behavior so + // nobody removes the reset thinking it's dead code. test('back-to-back calls do not skip due to leftover g-flag lastIndex', () => { // Run the same call twice — without the explicit reset, the second // call would start scanning from where the first call left off and @@ -78,3 +80,32 @@ describe('extractLinearIdentifier', () => { expect(extractLinearIdentifier('fourth ABCA-1')).toBe('ABCA-1'); }); }); + +describe('extractLinearIdentifierFromBranch', () => { + test('pulls the canonical identifier from an agent task branch (lowercased slug)', () => { + // Branch shape is bgagent/{taskId}/{slug}, where the slug is a slugified + // "<identifier>: <issue title>" and so has lost the original casing. + expect( + extractLinearIdentifierFromBranch('bgagent/01KTSK8XGXHRMT0JX44GYRPJG7/abca-151-add-lisbon-guidehtml'), + ).toBe('ABCA-151'); + }); + + test('the ULID task-id segment does not false-match before the identifier', () => { + // The ULID has no dash, so it cannot produce a <KEY>-<n> match; the + // first real match is the issue identifier in the slug. + expect( + extractLinearIdentifierFromBranch('bgagent/01KTSKET9040HDJP3P2QE15DXC/abca-152-link-lisbon-from-destinationsht'), + ).toBe('ABCA-152'); + }); + + test('returns null for a branch with no identifier', () => { + expect(extractLinearIdentifierFromBranch('bgagent/01TASK/task')).toBeNull(); + expect(extractLinearIdentifierFromBranch('feature/some-thing')).toBeNull(); + }); + + test('returns null on null/undefined/empty', () => { + expect(extractLinearIdentifierFromBranch(null)).toBeNull(); + expect(extractLinearIdentifierFromBranch(undefined)).toBeNull(); + expect(extractLinearIdentifierFromBranch('')).toBeNull(); + }); +}); diff --git a/cdk/test/handlers/shared/linear-oauth-resolver.test.ts b/cdk/test/handlers/shared/linear-oauth-resolver.test.ts index 34fe749a2..768369d19 100644 --- a/cdk/test/handlers/shared/linear-oauth-resolver.test.ts +++ b/cdk/test/handlers/shared/linear-oauth-resolver.test.ts @@ -21,6 +21,7 @@ import { _resetCachesForTesting, invalidateLinearOauthCache, isTokenExpiring, + markWorkspaceRevoked, resolveLinearOauthToken, type StoredOauthToken, } from '../../../src/handlers/shared/linear-oauth-resolver'; @@ -356,6 +357,87 @@ describe('resolveLinearOauthToken', () => { expect(fetchImpl).toHaveBeenCalledTimes(1); }); + test('a permanently-rejected refresh records the revocation when a recorder is SUPPLIED', async () => { + // The failure this guards: when the authorization dies, every event for the + // workspace is dropped and the ONLY evidence was a log line, so an operator + // saw their trigger label do nothing with no way to find out why. Marking + // the row is what makes `bgagent platform doctor` able to say so. + // + // The recorder is OPT-IN: every Lambda that resolves a token holds read-only + // registry access, so defaulting it on meant the write failed AccessDenied + // and was swallowed on every revoked refresh — inert while reading as + // working. This passes it explicitly, the way a caller with the grant would. + const expiringSoon = new Date(Date.now() + 10 * 1000).toISOString(); + const stale = makeStoredToken({ refresh_token: 'rt-dead', expires_at: expiringSoon }); + + const smSend = jest.fn().mockImplementation((command: { constructor: { name: string } }) => { + if (command.constructor.name === 'GetSecretValueCommand') { + return { SecretString: JSON.stringify(stale) }; + } + return {}; + }); + const ddbSend = jest.fn().mockImplementation((command: { constructor: { name: string } }) => { + if (command.constructor.name === 'UpdateCommand') return {}; + return { Item: { workspace_slug: 'acme', oauth_secret_arn: 'arn:secret:acme', status: 'active' } }; + }); + const fetchImpl = jest.fn().mockResolvedValueOnce({ + ok: false, status: 400, json: async () => ({ error: 'invalid_grant' }), + }); + + type Opts = NonNullable<Parameters<typeof resolveLinearOauthToken>[2]>; + const ddbClient = { send: ddbSend } as unknown as Opts['dynamoDbClient']; + const result = await resolveLinearOauthToken('ws-uuid-revoke', REGISTRY_TABLE, { + dynamoDbClient: ddbClient, + secretsManagerClient: { send: smSend } as unknown as Opts['secretsManagerClient'], + fetchImpl: fetchImpl as unknown as typeof fetch, + onAuthorizationRevoked: (workspaceId) => markWorkspaceRevoked( + ddbClient as never, REGISTRY_TABLE, workspaceId, + ), + }); + expect(result).toBeNull(); + + const update = ddbSend.mock.calls + .map((c) => c[0] as { constructor: { name: string }; input?: Record<string, unknown> }) + .find((c) => c.constructor.name === 'UpdateCommand'); + expect(update).toBeDefined(); + const input = update!.input as { + ExpressionAttributeValues: Record<string, string>; + ConditionExpression: string; + }; + expect(input.ExpressionAttributeValues[':revoked']).toBe('revoked'); + expect(input.ExpressionAttributeValues[':reason']).toBe('refresh_token_rejected'); + // Conditional on still being active, so a late straggler can't clobber a + // workspace an operator has already re-authorized. + expect(input.ConditionExpression).toContain(':active'); + }); + + test('a marker write failure does NOT break token resolution', async () => { + // Recording the diagnosis is strictly a bonus; if the registry write fails + // the caller must still get its clean null rather than a thrown handler. + const expiringSoon = new Date(Date.now() + 10 * 1000).toISOString(); + const stale = makeStoredToken({ refresh_token: 'rt-dead2', expires_at: expiringSoon }); + const smSend = jest.fn().mockImplementation((command: { constructor: { name: string } }) => { + if (command.constructor.name === 'GetSecretValueCommand') { + return { SecretString: JSON.stringify(stale) }; + } + return {}; + }); + const ddbSend = jest.fn().mockImplementation((command: { constructor: { name: string } }) => { + if (command.constructor.name === 'UpdateCommand') throw new Error('AccessDenied'); + return { Item: { workspace_slug: 'acme', oauth_secret_arn: 'arn:secret:acme', status: 'active' } }; + }); + const fetchImpl = jest.fn().mockResolvedValueOnce({ + ok: false, status: 400, json: async () => ({ error: 'invalid_grant' }), + }); + + type Opts = NonNullable<Parameters<typeof resolveLinearOauthToken>[2]>; + await expect(resolveLinearOauthToken('ws-uuid-revoke-2', REGISTRY_TABLE, { + dynamoDbClient: { send: ddbSend } as unknown as Opts['dynamoDbClient'], + secretsManagerClient: { send: smSend } as unknown as Opts['secretsManagerClient'], + fetchImpl: fetchImpl as unknown as typeof fetch, + })).resolves.toBeNull(); + }); + test('cache invalidation on network failure: next call re-reads SM instead of looping on stale token', async () => { const expiringSoon = new Date(Date.now() + 10 * 1000).toISOString(); const stale = makeStoredToken({ expires_at: expiringSoon }); @@ -404,3 +486,130 @@ describe('resolveLinearOauthToken', () => { expect(getSecretCalls.length).toBeGreaterThanOrEqual(2); }); }); + +describe('markWorkspaceRevoked — the verdict must not outlive the grant it judged', () => { + const WS = 'ws-uuid-1'; + const INSTALLED = '2026-07-22T10:00:00.000Z'; + + /** The condition + values of the Nth Update. */ + function updateOf(send: jest.Mock, call = 0): { condition: string; values: Record<string, unknown> } { + const input = (send.mock.calls[call][0] as { + input: { ConditionExpression: string; ExpressionAttributeValues: Record<string, unknown> }; + }).input; + return { condition: input.ConditionExpression, values: input.ExpressionAttributeValues }; + } + + beforeEach(() => _resetCachesForTesting()); + + test('scopes the write to the installation it diagnosed, not merely to "active"', async () => { + // status = active alone is not enough: a re-authorization writes active + // again, so a straggler holding the OLD token would satisfy that condition + // and revoke the working grant the operator just installed. + const send = jest.fn().mockResolvedValue({}); + await markWorkspaceRevoked({ send } as never, REGISTRY_TABLE, WS, INSTALLED); + + const { condition, values } = updateOf(send); + expect(condition).toContain('installed_at = :installed'); + expect(values[':installed']).toBe(INSTALLED); + expect(condition).toContain('#s = :active'); + }); + + test('a row re-authorized since the diagnosis is left alone, and says so', async () => { + const conditional = new Error('The conditional request failed'); + (conditional as { name?: string }).name = 'ConditionalCheckFailedException'; + const send = jest.fn().mockRejectedValue(conditional); + + // Never throws — recording a diagnosis must not break token resolution. + await expect(markWorkspaceRevoked({ send } as never, REGISTRY_TABLE, WS, INSTALLED)) + .resolves.toBeUndefined(); + }); + + test('with no installation to name, it requires the attribute to still be ABSENT', async () => { + // A row predating installed_at: a re-authorization adds the attribute, so + // requiring its absence likewise takes the row out of scope. + const send = jest.fn().mockResolvedValue({}); + await markWorkspaceRevoked({ send } as never, REGISTRY_TABLE, WS); + + const { condition, values } = updateOf(send); + expect(condition).toContain('attribute_not_exists(installed_at)'); + expect(values).not.toHaveProperty(':installed'); + }); + + test('a non-conditional failure propagates — a silent AccessDenied is what kept this dormant', async () => { + const send = jest.fn().mockRejectedValue(new Error('AccessDeniedException')); + await expect(markWorkspaceRevoked({ send } as never, REGISTRY_TABLE, WS, INSTALLED)) + .rejects.toThrow('AccessDenied'); + }); + + test('the resolver writes NOTHING to the registry when no recorder is supplied', async () => { + // Guards the inert-but-looks-working state: the resolver used to default the + // marker on, so on every revoked refresh it issued a registry write that the + // caller's read-only role rejected, and the AccessDenied was swallowed. No + // stack in the arc grants registry write, so the correct behaviour with no + // recorder is to not attempt the write at all. + const stored = makeStoredToken({ expires_at: new Date(Date.now() + 60 * 1000).toISOString() }); + const clients = makeFakeClients({ + registryItem: { + workspace_slug: 'acme', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + installed_at: INSTALLED, + } as never, + storedToken: stored, + }); + const fetchImpl = jest.fn().mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ error: 'invalid_grant', error_description: 'refresh token revoked' }), + }); + + // No onAuthorizationRevoked supplied → no registry write is attempted. + await resolveLinearOauthToken(WS, REGISTRY_TABLE, { + ...clients, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + const updates = clients.ddbSend.mock.calls + .map((c) => c[0] as { constructor: { name: string }; input?: Record<string, unknown> }) + .filter((cmd) => cmd.constructor.name === 'UpdateCommand'); + expect(updates).toHaveLength(0); + }); + + test('a SUPPLIED recorder carries the installation from the row the resolver read', async () => { + // The value must come from the read that drove this refresh attempt. Re-reading + // it inside the recorder would race a concurrent re-authorization exactly as + // the status-only condition did, so this asserts the wiring, not just that + // some marker fired. + const stored = makeStoredToken({ expires_at: new Date(Date.now() + 60 * 1000).toISOString() }); + const clients = makeFakeClients({ + registryItem: { + workspace_slug: 'acme', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + installed_at: INSTALLED, + } as never, + storedToken: stored, + }); + const fetchImpl = jest.fn().mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ error: 'invalid_grant', error_description: 'refresh token revoked' }), + }); + + await resolveLinearOauthToken(WS, REGISTRY_TABLE, { + ...clients, + fetchImpl: fetchImpl as unknown as typeof fetch, + onAuthorizationRevoked: (workspaceId) => markWorkspaceRevoked( + clients.dynamoDbClient as never, REGISTRY_TABLE, workspaceId, INSTALLED, + ), + }); + + const updates = clients.ddbSend.mock.calls + .map((c) => c[0] as { constructor: { name: string }; input?: Record<string, unknown> }) + .filter((cmd) => cmd.constructor.name === 'UpdateCommand'); + expect(updates).toHaveLength(1); + expect(updates[0].input!.ConditionExpression).toContain('installed_at = :installed'); + expect((updates[0].input!.ExpressionAttributeValues as Record<string, unknown>)[':installed']) + .toBe(INSTALLED); + }); +}); diff --git a/cdk/test/handlers/shared/linear-subissue-fetch.test.ts b/cdk/test/handlers/shared/linear-subissue-fetch.test.ts new file mode 100644 index 000000000..285b8abea --- /dev/null +++ b/cdk/test/handlers/shared/linear-subissue-fetch.test.ts @@ -0,0 +1,215 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { fetchSubIssueGraph } from '../../../src/handlers/shared/linear-subissue-fetch'; + +/** Build a mock fetch returning a given JSON body + ok/status. */ +function mockFetch(body: unknown, init: { ok?: boolean; status?: number } = {}): typeof fetch { + return (async () => ({ + ok: init.ok ?? true, + status: init.status ?? 200, + json: async () => body, + })) as unknown as typeof fetch; +} + +/** Shape a Linear `issue.children` GraphQL response. */ +function graphResponse(children: Array<{ + id: string; + identifier?: string; + title?: string; + blockedBy?: string[]; // ids that block this child (inverseRelations type "blocks") +}>) { + return { + data: { + issue: { + id: 'PARENT', + children: { + nodes: children.map((c) => ({ + id: c.id, + identifier: c.identifier, + title: c.title, + inverseRelations: { + nodes: (c.blockedBy ?? []).map((bid) => ({ type: 'blocks', issue: { id: bid } })), + }, + })), + }, + }, + }, + }; +} + +describe('fetchSubIssueGraph — success shapes', () => { + test('maps children and blockedBy edges into depends_on', async () => { + const fetchImpl = mockFetch(graphResponse([ + { id: 'A', identifier: 'ENG-1', title: 'Root' }, + { id: 'B', identifier: 'ENG-2', title: 'Blocked by A', blockedBy: ['A'] }, + ])); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + expect(result.kind).toBe('ok'); + if (result.kind === 'ok') { + expect(result.parentIssueId).toBe('PARENT'); + expect(result.children).toEqual([ + { id: 'A', identifier: 'ENG-1', title: 'Root', depends_on: [] }, + { id: 'B', identifier: 'ENG-2', title: 'Blocked by A', depends_on: ['A'] }, + ]); + } + }); + + test('drops blockedBy edges that point outside the child set', async () => { + // C is blocked by GHOST (not a sibling) — edge dropped. + const fetchImpl = mockFetch(graphResponse([ + { id: 'C', blockedBy: ['GHOST'] }, + ])); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + if (result.kind === 'ok') expect(result.children[0].depends_on).toEqual([]); + }); + + test('ignores relation types other than "blocks"', async () => { + const fetchImpl = mockFetch({ + data: { + issue: { + id: 'PARENT', + children: { + nodes: [ + { id: 'A' }, + { + id: 'B', + inverseRelations: { + nodes: [ + { type: 'related', issue: { id: 'A' } }, // not a blocker + { type: 'duplicate', issue: { id: 'A' } }, + ], + }, + }, + ], + }, + }, + }, + }); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + if (result.kind === 'ok') expect(result.children[1].depends_on).toEqual([]); + }); + + test('dedups duplicate blocker edges', async () => { + const fetchImpl = mockFetch({ + data: { + issue: { + id: 'PARENT', + children: { + nodes: [ + { id: 'A' }, + { + id: 'B', + inverseRelations: { + nodes: [ + { type: 'blocks', issue: { id: 'A' } }, + { type: 'blocks', issue: { id: 'A' } }, + ], + }, + }, + ], + }, + }, + }, + }); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + if (result.kind === 'ok') expect(result.children[1].depends_on).toEqual(['A']); + }); + + test('ignores a self-blocking edge from the raw payload', async () => { + const fetchImpl = mockFetch(graphResponse([{ id: 'A', blockedBy: ['A'] }])); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + if (result.kind === 'ok') expect(result.children[0].depends_on).toEqual([]); + }); +}); + +describe('fetchSubIssueGraph — no children', () => { + test('returns no_children when the issue has an empty children set', async () => { + const fetchImpl = mockFetch(graphResponse([])); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + expect(result.kind).toBe('no_children'); + if (result.kind === 'no_children') expect(result.parentIssueId).toBe('PARENT'); + }); +}); + +describe('fetchSubIssueGraph — error shapes', () => { + test('non-2xx → error', async () => { + const fetchImpl = mockFetch({}, { ok: false, status: 503 }); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + expect(result.kind).toBe('error'); + }); + + test('GraphQL errors → error', async () => { + const fetchImpl = mockFetch({ errors: [{ message: 'boom' }] }); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + expect(result.kind).toBe('error'); + }); + + test('network throw → error (never throws)', async () => { + const fetchImpl = (async () => { throw new Error('ECONNRESET'); }) as unknown as typeof fetch; + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + expect(result.kind).toBe('error'); + }); + + test('missing issue in payload → error', async () => { + const fetchImpl = mockFetch({ data: { issue: null } }); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + expect(result.kind).toBe('error'); + }); + + // An over-size epic must FAIL LOUD, not silently truncate: dropping children + // also drops their dependency edges, so the survivors release out of order. + test('children truncated (hasNextPage) → error, not a silently-cut graph', async () => { + const fetchImpl = mockFetch({ + data: { + issue: { + id: 'PARENT', + children: { + pageInfo: { hasNextPage: true }, // Linear says there are more children + nodes: [{ id: 'A' }, { id: 'B' }], + }, + }, + }, + }); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + expect(result.kind).toBe('error'); + if (result.kind === 'error') expect(result.message).toMatch(/more than 100 sub-issues/i); + }); + + test("a child's blockers truncated (hasNextPage) → error (edges would be incomplete)", async () => { + const fetchImpl = mockFetch({ + data: { + issue: { + id: 'PARENT', + children: { + pageInfo: { hasNextPage: false }, + nodes: [{ + id: 'A', + identifier: 'ENG-1', + inverseRelations: { pageInfo: { hasNextPage: true }, nodes: [] }, + }], + }, + }, + }, + }); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + expect(result.kind).toBe('error'); + if (result.kind === 'error') expect(result.message).toMatch(/blocking relations/i); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-base-branch.test.ts b/cdk/test/handlers/shared/orchestration-base-branch.test.ts new file mode 100644 index 000000000..58cc75b05 --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-base-branch.test.ts @@ -0,0 +1,84 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { selectBaseBranch } from '../../../src/handlers/shared/orchestration-base-branch'; + +const pred = (sub_issue_id: string, branch_name: string) => ({ sub_issue_id, branch_name }); + +describe('selectBaseBranch', () => { + test('root (no predecessors) → default branch, no merges', () => { + expect(selectBaseBranch({ predecessors: [] })).toEqual({ + base_branch: 'main', merge_branches: [], shape: 'root', + }); + }); + + test('respects a custom default branch for roots', () => { + expect(selectBaseBranch({ predecessors: [], defaultBranch: 'develop' }).base_branch).toBe('develop'); + }); + + test('linear (1 predecessor) → stack on its branch, no merges', () => { + const sel = selectBaseBranch({ predecessors: [pred('A', 'bgagent/taskA/step-a')] }); + expect(sel).toEqual({ + base_branch: 'bgagent/taskA/step-a', merge_branches: [], shape: 'linear', + }); + }); + + test('diamond (2 predecessors) → base main + merge both branches', () => { + const sel = selectBaseBranch({ + predecessors: [pred('B', 'bgagent/taskB/b'), pred('C', 'bgagent/taskC/c')], + }); + expect(sel.shape).toBe('diamond'); + expect(sel.base_branch).toBe('main'); + expect(sel.merge_branches).toEqual(['bgagent/taskB/b', 'bgagent/taskC/c']); + }); + + test('diamond merge list is deduped and sorted (deterministic)', () => { + const sel = selectBaseBranch({ + predecessors: [pred('C', 'z-branch'), pred('B', 'a-branch'), pred('D', 'a-branch')], + }); + expect(sel.merge_branches).toEqual(['a-branch', 'z-branch']); + }); + + test('diamond uses default branch as base, not a predecessor', () => { + const sel = selectBaseBranch({ + predecessors: [pred('B', 'feat-b'), pred('C', 'feat-c')], defaultBranch: 'trunk', + }); + expect(sel.base_branch).toBe('trunk'); + }); + + test('predecessors missing a branch_name are ignored', () => { + // One real predecessor branch + one empty → degrades to linear on the real one. + const sel = selectBaseBranch({ predecessors: [pred('A', 'feat-a'), pred('B', '')] }); + expect(sel.shape).toBe('linear'); + expect(sel.base_branch).toBe('feat-a'); + }); + + test('all predecessors missing branches → degrade to root (never invalid base)', () => { + const sel = selectBaseBranch({ predecessors: [pred('A', ''), pred('B', '')] }); + expect(sel).toEqual({ base_branch: 'main', merge_branches: [], shape: 'root' }); + }); + + test('two predecessors that share a branch collapse to a single (linear) merge', () => { + // After dedup, only one distinct branch → treated as linear, not diamond. + const sel = selectBaseBranch({ predecessors: [pred('A', 'same'), pred('B', 'same')] }); + expect(sel.shape).toBe('linear'); + expect(sel.base_branch).toBe('same'); + expect(sel.merge_branches).toEqual([]); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-channel-factory.test.ts b/cdk/test/handlers/shared/orchestration-channel-factory.test.ts new file mode 100644 index 000000000..228c905d1 --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-channel-factory.test.ts @@ -0,0 +1,116 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// The adapters resolve tokens on use, not on construction, so the factory can be +// exercised without any network — only the modules it selects between are stubbed +// out at the transport layer. +jest.mock('../../../src/handlers/shared/linear-feedback', () => ({ + EMOJI_STARTED: 'eyes', + EMOJI_SUCCESS: 'white_check_mark', + EMOJI_FAILURE: 'x', + EMOJI_NEEDS_INPUT: 'question', +})); +jest.mock('../../../src/handlers/shared/jira-feedback', () => ({})); + +import { type Channel } from '../../../src/handlers/shared/orchestration-channel'; +import { + channelForSource, + registerChannelFactory, + registeredChannelSources, +} from '../../../src/handlers/shared/orchestration-channel-factory'; + +const BOTH = { linear: 'LinearRegistry', jira: 'JiraRegistry' }; + +describe('channelForSource', () => { + test('picks the adapter matching the stored channel', () => { + expect(channelForSource('linear', BOTH)?.kind).toBe('linear'); + expect(channelForSource('jira', BOTH)?.kind).toBe('jira'); + }); + + test('an orchestration with no recorded channel is treated as Linear', () => { + // Rows seeded before the field existed carry no source, and Linear was the + // only surface that could have seeded them — so defaulting keeps their + // feedback working rather than silencing it. + expect(channelForSource(undefined, BOTH)?.kind).toBe('linear'); + }); + + test('a surface whose registry is not configured yields no adapter', () => { + // Skipping is the safe answer: building another surface's adapter would + // address the wrong tenant. + expect(channelForSource('jira', { linear: 'LinearRegistry' })).toBeUndefined(); + expect(channelForSource('linear', { jira: 'JiraRegistry' })).toBeUndefined(); + expect(channelForSource(undefined, {})).toBeUndefined(); + }); + + test('a trigger channel with no issue-tracking surface yields no adapter', () => { + // 'api' / 'slack' submissions have no issue to post a panel on; the engine + // must skip rather than guess at a surface. + for (const source of ['api', 'webhook', 'slack', 'unknown-future-surface']) { + expect(channelForSource(source, BOTH)).toBeUndefined(); + } + }); + + test('a NEW surface can be added without editing this module or the interface', () => { + // The extensibility tenet: adopting ABCA for another tracker must not require + // patching the core. A closed union/switch would force every consumer to merge + // an upstream change for one more surface, so this asserts the registry is + // genuinely open — a surface registers itself and the lookup finds it. + const built: string[] = []; + const unregister = registerChannelFactory('acme-tracker', (table) => { + built.push(table); + return { kind: 'acme-tracker', postComment: jest.fn(), upsertComment: jest.fn(), reportFailure: jest.fn() } as unknown as Channel; + }); + try { + const ch = channelForSource('acme-tracker', { 'acme-tracker': 'AcmeRegistry' }); + expect(ch?.kind).toBe('acme-tracker'); + // The factory got its own credentials table, not another surface's. + expect(built).toEqual(['AcmeRegistry']); + expect(registeredChannelSources()).toContain('acme-tracker'); + } finally { + unregister(); + } + }); + + test('unregistering restores the prior state, so surfaces do not leak between callers', () => { + const unregister = registerChannelFactory('temp-surface', () => ({ kind: 'temp-surface' } as unknown as Channel)); + unregister(); + expect(registeredChannelSources()).not.toContain('temp-surface'); + expect(channelForSource('temp-surface', { 'temp-surface': 'T' })).toBeUndefined(); + }); + + test('replacing a registered surface is reversible', () => { + // A test (or a downstream override) may swap an adapter; restoring must put + // the original back rather than deleting the entry. + const unregister = registerChannelFactory('linear', () => ({ kind: 'stub-linear' } as unknown as Channel)); + expect(channelForSource('linear', BOTH)?.kind).toBe('stub-linear'); + unregister(); + expect(channelForSource('linear', BOTH)?.kind).toBe('linear'); + }); + + test('the returned adapter carries the registry it was given', async () => { + // A wrong registry would resolve a token for the wrong tenant, so prove the + // per-surface table actually reaches the adapter. + const linear = channelForSource('linear', BOTH); + expect(linear?.kind).toBe('linear'); + const jira = channelForSource('jira', BOTH); + expect(jira?.kind).toBe('jira'); + // Distinct adapters, not the same object handed back twice. + expect(linear).not.toBe(jira); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-channel-slack.test.ts b/cdk/test/handlers/shared/orchestration-channel-slack.test.ts new file mode 100644 index 000000000..edbd496ae --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-channel-slack.test.ts @@ -0,0 +1,213 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// Mock at the Slack transport so the adapter's real mapping (channel op → Slack +// method + params) is what's asserted, with no network. +const slackFetchMock = jest.fn(); +const slackFetchTsMock = jest.fn(); +jest.mock('../../../src/handlers/shared/slack-api', () => ({ + slackFetch: (...a: unknown[]) => slackFetchMock(...a), + slackFetchTs: (...a: unknown[]) => slackFetchTsMock(...a), +})); + +const getSlackSecretMock = jest.fn(); +jest.mock('../../../src/handlers/shared/slack-verify', () => ({ + SLACK_SECRET_PREFIX: 'bgagent/slack/', + getSlackSecret: (...a: unknown[]) => getSlackSecretMock(...a), +})); + +jest.mock('../../../src/handlers/shared/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +import { type IssueRef } from '../../../src/handlers/shared/orchestration-channel'; +import { makeSlackChannel, slackThreadRef } from '../../../src/handlers/shared/orchestration-channel-slack'; + +/** A Slack "issue" is a thread: channel + thread_ts, keyed by team_id. */ +const thread: IssueRef = { issueId: slackThreadRef('C123', '1700000000.001'), credentialsRef: 'T99' }; + +beforeEach(() => { + jest.clearAllMocks(); + getSlackSecretMock.mockResolvedValue('xoxb-token'); + slackFetchMock.mockResolvedValue(true); + slackFetchTsMock.mockResolvedValue('1700000000.002'); +}); + +describe('Slack channel adapter — the capability-gated surface', () => { + const ch = makeSlackChannel(); + + test('kind is slack', () => expect(ch.kind).toBe('slack')); + + test('OMITS the workflow-state ops, because Slack has no workflow state', () => { + // The point of the whole capability split: these must be ABSENT, not stubbed + // to a silent success. A no-op that returns true would tell the engine the + // platform mirrored a state it never moved. + expect('transitionState' in ch).toBe(false); + expect('revertState' in ch).toBe(false); + // Slack has no dependency model, so a DAG can't be read from it. + expect('fetchChildGraph' in ch).toBe(false); + // Bulk-deleting messages in a shared channel needs its own decision. + expect('sweepNotes' in ch).toBe(false); + }); + + test('still satisfies the required contract every surface must support', () => { + expect(typeof ch.postComment).toBe('function'); + expect(typeof ch.upsertComment).toBe('function'); + expect(typeof ch.reportFailure).toBe('function'); + }); + + test('resolves the bot token per WORKSPACE, from the ref credentials', () => { + // A wrong token would post into a different customer's Slack. + return ch.postComment(thread, 'hi').then(() => { + expect(getSlackSecretMock).toHaveBeenCalledWith('bgagent/slack/T99'); + }); + }); + + test('postComment posts INTO the thread and returns the new message ts', async () => { + const res = await ch.postComment(thread, 'hello'); + expect(res).toEqual({ commentId: '1700000000.002' }); + const [token, method, body] = slackFetchTsMock.mock.calls[0]; + expect(token).toBe('xoxb-token'); + expect(method).toBe('chat.postMessage'); + expect(body).toMatchObject({ channel: 'C123', thread_ts: '1700000000.001', text: 'hello' }); + }); + + test('upsertComment EDITS the given message in place — the maturing panel', async () => { + // Without edit-in-place a Slack epic would stream a new message per + // transition, which is the surface this design exists to avoid. + slackFetchTsMock.mockResolvedValue('1700000000.005'); + const res = await ch.upsertComment(thread, 'panel v2', { commentId: '1700000000.005' }); + expect(res).toEqual({ commentId: '1700000000.005' }); + const [, method, body] = slackFetchTsMock.mock.calls[0]; + expect(method).toBe('chat.update'); + expect(body).toMatchObject({ channel: 'C123', ts: '1700000000.005', text: 'panel v2' }); + expect(body).not.toHaveProperty('thread_ts'); // an edit targets a ts, not a thread + }); + + test('upsertComment with no existing ref posts a fresh in-thread message', async () => { + await ch.upsertComment(thread, 'panel v1'); + expect(slackFetchTsMock.mock.calls[0][1]).toBe('chat.postMessage'); + }); + + test('reactToComment ADDS without clearing anything', async () => { + await ch.reactToComment!({ commentId: '1700000000.009' }, thread, 'started'); + const calls = slackFetchMock.mock.calls; + expect(calls).toHaveLength(1); + expect(calls[0][1]).toBe('reactions.add'); + expect(calls[0][2]).toMatchObject({ channel: 'C123', timestamp: '1700000000.009', name: 'eyes' }); + }); + + test('replaceCommentReaction clears only its OWN prior markers, then adds the target', async () => { + // Slack has no atomic swap. Removing every emoji would strip a human's + // reaction; removing none would leave contradictory markers on a settled + // message. So: remove this adapter's own set, minus the target, then add. + await ch.replaceCommentReaction!({ commentId: '1700000000.009' }, thread, 'succeeded'); + const removes = slackFetchMock.mock.calls.filter((c) => c[1] === 'reactions.remove'); + const adds = slackFetchMock.mock.calls.filter((c) => c[1] === 'reactions.add'); + expect(removes.map((c) => (c[2] as { name: string }).name).sort()) + .toEqual(['eyes', 'question', 'x']); // own markers, excluding the target + expect(adds).toHaveLength(1); + expect((adds[0][2] as { name: string }).name).toBe('white_check_mark'); + }); + + test('a swap that succeeds fully reports success', async () => { + expect(await ch.replaceCommentReaction!({ commentId: '1700000000.009' }, thread, 'succeeded')).toBe(true); + }); + + test('reports FAILURE when a stale marker could not be removed', async () => { + // Returning the add's result alone would claim the promised end state — one + // marker — while the message still shows "saw it" beside "done". That + // contradiction is what a caller checks this result to rule out. + slackFetchMock.mockImplementation((_token: string, method: string, body: { name: string }) => + Promise.resolve(!(method === 'reactions.remove' && body.name === 'eyes'))); + + expect(await ch.replaceCommentReaction!({ commentId: '1700000000.009' }, thread, 'succeeded')).toBe(false); + // The target is still attempted — a partial improvement beats leaving only + // the stale marker. + expect(slackFetchMock.mock.calls.filter((c) => c[1] === 'reactions.add')).toHaveLength(1); + }); + + test('reports FAILURE when the target could not be added', async () => { + slackFetchMock.mockImplementation((_token: string, method: string) => + Promise.resolve(method !== 'reactions.add')); + + expect(await ch.replaceCommentReaction!({ commentId: '1700000000.009' }, thread, 'succeeded')).toBe(false); + }); + + test('the issue-level swap reports the same all-or-nothing result', async () => { + slackFetchMock.mockImplementation((_token: string, method: string, body: { name: string }) => + Promise.resolve(!(method === 'reactions.remove' && body.name === 'eyes'))); + + expect(await ch.replaceIssueReaction!(thread, 'failed')).toBe(false); + }); + + test('replaceIssueReaction marks the THREAD ROOT — the nearest thing to an issue', async () => { + await ch.replaceIssueReaction!(thread, 'failed'); + const adds = slackFetchMock.mock.calls.filter((c) => c[1] === 'reactions.add'); + expect((adds[0][2] as { timestamp: string }).timestamp).toBe('1700000000.001'); + expect((adds[0][2] as { name: string }).name).toBe('x'); + }); + + test('postThreadedReply replies under the parent message', async () => { + const res = await ch.postThreadedReply!(thread, { commentId: '1700000000.077' }, 'done'); + expect(res).toEqual({ commentId: '1700000000.002' }); + const [, method, body] = slackFetchTsMock.mock.calls[0]; + expect(method).toBe('chat.postMessage'); + expect(body).toMatchObject({ thread_ts: '1700000000.077', text: 'done' }); + }); + + test('upsertThreadedReply edits the existing reply, else creates one', async () => { + await ch.upsertThreadedReply!(thread, { commentId: 'p' }, 'settled', { commentId: '1700000000.088' }); + expect(slackFetchTsMock.mock.calls[0][1]).toBe('chat.update'); + slackFetchTsMock.mockClear(); + await ch.upsertThreadedReply!(thread, { commentId: 'p' }, 'settled'); + expect(slackFetchTsMock.mock.calls[0][1]).toBe('chat.postMessage'); + }); + + test('an uninstalled workspace skips cleanly instead of throwing', async () => { + // Feedback is advisory and must never gate the orchestration. + getSlackSecretMock.mockResolvedValue(null); + await expect(ch.postComment(thread, 'x')).resolves.toBeNull(); + await expect(ch.reportFailure(thread, 'x')).resolves.toBeUndefined(); + expect(slackFetchTsMock).not.toHaveBeenCalled(); + }); + + test('a malformed thread ref is refused rather than posting to a guessed channel', async () => { + // Posting into the wrong channel is worse than posting nowhere. + const bad: IssueRef = { issueId: 'no-separator', credentialsRef: 'T99' }; + await expect(ch.postComment(bad, 'x')).resolves.toBeNull(); + expect(slackFetchTsMock).not.toHaveBeenCalled(); + expect(getSlackSecretMock).not.toHaveBeenCalled(); + }); + + test('a failed Slack call reports null rather than a bogus ref', async () => { + slackFetchTsMock.mockResolvedValue(null); + await expect(ch.postComment(thread, 'x')).resolves.toBeNull(); + }); +}); + +describe('slackThreadRef', () => { + test('round-trips through the adapter that parses it', async () => { + // The seeding side and the adapter must agree on the ref format, or every + // Slack orchestration silently posts nowhere. + const ch = makeSlackChannel(); + await ch.postComment({ issueId: slackThreadRef('CABC', '123.456'), credentialsRef: 'T1' }, 'x'); + expect(slackFetchTsMock.mock.calls[0][2]).toMatchObject({ channel: 'CABC', thread_ts: '123.456' }); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-channel.test.ts b/cdk/test/handlers/shared/orchestration-channel.test.ts new file mode 100644 index 000000000..36942f94a --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-channel.test.ts @@ -0,0 +1,275 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// Mock the per-surface feedback + graph helpers so the adapter tests exercise the +// mapping (channel op → surface call) without any network. +const linearPostIssueComment = jest.fn(); +const linearUpsertStatusComment = jest.fn(); +const linearReportIssueFailure = jest.fn(); +const linearAddCommentReaction = jest.fn(); +const linearSwapCommentReaction = jest.fn(); +const linearSwapIssueReaction = jest.fn(); +const linearTransitionIssueState = jest.fn(); +const linearRevertIssueToNotStarted = jest.fn(); +const linearReplyToComment = jest.fn(); +const linearUpsertThreadedReply = jest.fn(); +const linearSweepTransientNotes = jest.fn(); +jest.mock('../../../src/handlers/shared/linear-feedback', () => ({ + EMOJI_STARTED: 'eyes', + EMOJI_SUCCESS: 'white_check_mark', + EMOJI_FAILURE: 'x', + EMOJI_NEEDS_INPUT: 'question', + postIssueComment: (...a: unknown[]) => linearPostIssueComment(...a), + upsertStatusComment: (...a: unknown[]) => linearUpsertStatusComment(...a), + reportIssueFailure: (...a: unknown[]) => linearReportIssueFailure(...a), + reactToComment: (...a: unknown[]) => linearAddCommentReaction(...a), + swapCommentReaction: (...a: unknown[]) => linearSwapCommentReaction(...a), + swapIssueReaction: (...a: unknown[]) => linearSwapIssueReaction(...a), + transitionIssueState: (...a: unknown[]) => linearTransitionIssueState(...a), + revertIssueToNotStarted: (...a: unknown[]) => linearRevertIssueToNotStarted(...a), + replyToComment: (...a: unknown[]) => linearReplyToComment(...a), + upsertThreadedReply: (...a: unknown[]) => linearUpsertThreadedReply(...a), + sweepTransientNotes: (...a: unknown[]) => linearSweepTransientNotes(...a), +})); + +const resolveLinearOauthToken = jest.fn(); +jest.mock('../../../src/handlers/shared/linear-oauth-resolver', () => ({ + resolveLinearOauthToken: (...a: unknown[]) => resolveLinearOauthToken(...a), +})); + +const fetchSubIssueGraph = jest.fn(); +jest.mock('../../../src/handlers/shared/linear-subissue-fetch', () => ({ + fetchSubIssueGraph: (...a: unknown[]) => fetchSubIssueGraph(...a), +})); + +const jiraPostIssueComment = jest.fn(); +const jiraReportIssueFailure = jest.fn(); +jest.mock('../../../src/handlers/shared/jira-feedback', () => ({ + postIssueComment: (...a: unknown[]) => jiraPostIssueComment(...a), + reportIssueFailure: (...a: unknown[]) => jiraReportIssueFailure(...a), +})); + +import { type IssueRef } from '../../../src/handlers/shared/orchestration-channel'; +import { makeJiraChannel } from '../../../src/handlers/shared/orchestration-channel-jira'; +import { makeLinearChannel } from '../../../src/handlers/shared/orchestration-channel-linear'; + +const linearIssue: IssueRef = { issueId: 'lin-issue-1', credentialsRef: 'org-1', displayId: 'ENG-1' }; +const jiraIssue: IssueRef = { issueId: 'ABC-1', credentialsRef: 'cloud-1' }; + +beforeEach(() => jest.clearAllMocks()); + +describe('Linear channel adapter', () => { + const ch = makeLinearChannel('LinearRegistry'); + + test('kind is linear', () => expect(ch.kind).toBe('linear')); + + test('postComment builds the LinearFeedbackContext from the issue and returns ok', async () => { + linearPostIssueComment.mockResolvedValue({ ok: true }); + const res = await ch.postComment(linearIssue, 'hi'); + expect(res).not.toBeNull(); + const [ctx, id, body] = linearPostIssueComment.mock.calls[0]; + expect(ctx).toEqual({ linearWorkspaceId: 'org-1', registryTableName: 'LinearRegistry' }); + expect(id).toBe('lin-issue-1'); + expect(body).toBe('hi'); + }); + + test('upsertComment threads the existing comment id + returns the new id', async () => { + linearUpsertStatusComment.mockResolvedValue('cmt-99'); + const res = await ch.upsertComment(linearIssue, 'panel', { commentId: 'cmt-7' }); + expect(res).toEqual({ commentId: 'cmt-99' }); + expect(linearUpsertStatusComment.mock.calls[0][3]).toBe('cmt-7'); // existing id passed through + }); + + test('reactToComment ADDS a reaction without clearing existing ones', async () => { + // The receipt ack must not disturb other markers, so it maps to the + // add-only helper — never the swap helper, which deletes prior markers. + await ch.reactToComment!({ commentId: 'c1' }, linearIssue, 'started'); + expect(linearAddCommentReaction.mock.calls[0][2]).toBe('eyes'); + expect(linearSwapCommentReaction).not.toHaveBeenCalled(); + }); + + test('replaceCommentReaction clears prior markers via the swap helper', async () => { + await ch.replaceCommentReaction!({ commentId: 'c1' }, linearIssue, 'succeeded'); + expect(linearSwapCommentReaction.mock.calls[0][1]).toBe('c1'); + expect(linearSwapCommentReaction.mock.calls[0][2]).toBe('white_check_mark'); + expect(linearAddCommentReaction).not.toHaveBeenCalled(); + }); + + test('replaceIssueReaction targets the ISSUE, not a comment', async () => { + await ch.replaceIssueReaction!(linearIssue, 'failed'); + expect(linearSwapIssueReaction.mock.calls[0][1]).toBe('lin-issue-1'); + expect(linearSwapIssueReaction.mock.calls[0][2]).toBe('x'); + expect(linearSwapCommentReaction).not.toHaveBeenCalled(); + }); + + test('transitionState distinguishes running from awaiting-review by state name', async () => { + // Linear types both as `started`, so the preferred NAME is what separates + // them; dropping it would collapse the two intents into one move. + await ch.transitionState!(linearIssue, 'started'); + expect(linearTransitionIssueState.mock.calls[0][2]).toBe('started'); + expect(linearTransitionIssueState.mock.calls[0][3]).toEqual(['In Progress']); + + await ch.transitionState!(linearIssue, 'in_review'); + expect(linearTransitionIssueState.mock.calls[1][2]).toBe('started'); + expect(linearTransitionIssueState.mock.calls[1][3]).toEqual(['In Review']); + + await ch.transitionState!(linearIssue, 'completed'); + expect(linearTransitionIssueState.mock.calls[2][2]).toBe('completed'); + }); + + test('transitionState only permits a same-category re-open when asked', async () => { + await ch.transitionState!(linearIssue, 'started'); + expect(linearTransitionIssueState.mock.calls[0][4]).toBe(false); + await ch.transitionState!(linearIssue, 'started', { allowRegression: true }); + expect(linearTransitionIssueState.mock.calls[1][4]).toBe(true); + }); + + test('revertState routes to the guarded backward move', async () => { + linearRevertIssueToNotStarted.mockResolvedValue(true); + expect(await ch.revertState!(linearIssue)).toBe(true); + expect(linearRevertIssueToNotStarted.mock.calls[0][1]).toBe('lin-issue-1'); + }); + + test('postThreadedReply carries both the issue and the parent comment', async () => { + // Linear rejects a reply that names only the parent comment, so the issue + // id must travel with it. + linearReplyToComment.mockResolvedValue('reply-1'); + const res = await ch.postThreadedReply!(linearIssue, { commentId: 'parent-1' }, 'done'); + expect(res).toEqual({ commentId: 'reply-1' }); + const [, issueId, parentId, body] = linearReplyToComment.mock.calls[0]; + expect(issueId).toBe('lin-issue-1'); + expect(parentId).toBe('parent-1'); + expect(body).toBe('done'); + }); + + test('postThreadedReply returns null when the reply fails', async () => { + linearReplyToComment.mockResolvedValue(null); + expect(await ch.postThreadedReply!(linearIssue, { commentId: 'p' }, 'x')).toBeNull(); + }); + + test('upsertThreadedReply edits the existing reply and preserves a preview link on request', async () => { + linearUpsertThreadedReply.mockResolvedValue('reply-9'); + const res = await ch.upsertThreadedReply!( + linearIssue, { commentId: 'parent-1' }, 'settled', { commentId: 'reply-9' }, + { preservePreview: true }, + ); + expect(res).toEqual({ commentId: 'reply-9' }); + const [, issueId, parentId, body, existingId, options] = linearUpsertThreadedReply.mock.calls[0]; + expect(issueId).toBe('lin-issue-1'); + expect(parentId).toBe('parent-1'); + expect(body).toBe('settled'); + expect(existingId).toBe('reply-9'); + // The adapter forwards EVERY convergence flag explicitly, so the surface + // helper never has to guess a default. + expect(options).toEqual({ preservePreview: true, skipIfSettled: false, repairIfOverwritten: false }); + }); + + test('upsertThreadedReply defaults to not preserving a preview link', async () => { + linearUpsertThreadedReply.mockResolvedValue('reply-2'); + await ch.upsertThreadedReply!(linearIssue, { commentId: 'p' }, 'body'); + expect(linearUpsertThreadedReply.mock.calls[0][4]).toBeUndefined(); + expect(linearUpsertThreadedReply.mock.calls[0][5]) + .toEqual({ preservePreview: false, skipIfSettled: false, repairIfOverwritten: false }); + }); + + test('sweepNotes passes the comment to keep through and returns the deleted count', async () => { + linearSweepTransientNotes.mockResolvedValue(3); + expect(await ch.sweepNotes!(linearIssue, { commentId: 'keep-1' })).toBe(3); + expect(linearSweepTransientNotes.mock.calls[0][1]).toBe('lin-issue-1'); + expect(linearSweepTransientNotes.mock.calls[0][2]).toBe('keep-1'); + }); + + test('sweepNotes with nothing to keep passes no keep id', async () => { + linearSweepTransientNotes.mockResolvedValue(0); + expect(await ch.sweepNotes!(linearIssue)).toBe(0); + expect(linearSweepTransientNotes.mock.calls[0][2]).toBeUndefined(); + }); + + test('fetchChildGraph maps blocks-derived depends_on to the neutral node shape', async () => { + resolveLinearOauthToken.mockResolvedValue({ accessToken: 'tok' }); + fetchSubIssueGraph.mockResolvedValue({ + kind: 'ok', + parentIssueId: 'lin-issue-1', + children: [ + { id: 'a', identifier: 'ENG-2', title: 'A', depends_on: [] }, + { id: 'b', identifier: 'ENG-3', title: 'B', depends_on: ['a'] }, + ], + }); + const nodes = await ch.fetchChildGraph!(linearIssue); + expect(nodes).toEqual([ + { issueId: 'a', displayId: 'ENG-2', title: 'A', dependsOn: [] }, + { issueId: 'b', displayId: 'ENG-3', title: 'B', dependsOn: ['a'] }, + ]); + }); + + test('fetchChildGraph returns [] when the graph is unavailable (best-effort)', async () => { + resolveLinearOauthToken.mockResolvedValue({ accessToken: 'tok' }); + fetchSubIssueGraph.mockResolvedValue({ kind: 'error', message: 'boom' }); + expect(await ch.fetchChildGraph!(linearIssue)).toEqual([]); + }); +}); + +describe('Jira channel adapter (capability-limited surface)', () => { + const ch = makeJiraChannel('JiraRegistry'); + + test('kind is jira', () => expect(ch.kind).toBe('jira')); + + test('postComment builds the JiraFeedbackContext (cloudId) from the issue', async () => { + jiraPostIssueComment.mockResolvedValue(true); + const res = await ch.postComment(jiraIssue, 'hello'); + expect(res).not.toBeNull(); + const [ctx, id, body] = jiraPostIssueComment.mock.calls[0]; + expect(ctx).toEqual({ cloudId: 'cloud-1', registryTableName: 'JiraRegistry' }); + expect(id).toBe('ABC-1'); + expect(body).toBe('hello'); + }); + + test('reportFailure routes to the Jira failure helper', async () => { + await ch.reportFailure(jiraIssue, '❌ nope'); + expect(jiraReportIssueFailure).toHaveBeenCalledWith( + { cloudId: 'cloud-1', registryTableName: 'JiraRegistry' }, 'ABC-1', '❌ nope', + ); + }); + + test('OMITS every optional capability Jira lacks — the engine no-ops them', () => { + // Check presence via the key, not the bound method, so the engine's + // `if (channel.reactToComment)` capability guard is what's exercised. + for (const capability of [ + 'reactToComment', + 'replaceCommentReaction', + 'replaceIssueReaction', + 'transitionState', + 'revertState', + 'postThreadedReply', + 'upsertThreadedReply', + 'sweepNotes', + 'fetchChildGraph', + ]) { + expect(capability in ch).toBe(false); + } + }); + + test('still satisfies the required feedback contract every surface must support', () => { + // The point of the capability split: omitting the optional ops must not make + // Jira an incomplete Channel. + expect(typeof ch.postComment).toBe('function'); + expect(typeof ch.upsertComment).toBe('function'); + expect(typeof ch.reportFailure).toBe('function'); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-comment-trigger.test.ts b/cdk/test/handlers/shared/orchestration-comment-trigger.test.ts new file mode 100644 index 000000000..f48e64894 --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-comment-trigger.test.ts @@ -0,0 +1,210 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { + buildIterationInstruction, + detectNearMissMention, + isBotAuthoredComment, + KNOWN_EPIC_COMMANDS, + parseCommentTrigger, + parseRetryIntent, +} from '../../../src/handlers/shared/orchestration-comment-trigger'; + +describe('parseCommentTrigger', () => { + test('mention with instruction → triggered, instruction stripped + trimmed', () => { + const t = parseCommentTrigger('@bgagent the session timeout should be 30 min, not 60'); + expect(t.triggered).toBe(true); + expect(t.instruction).toBe('the session timeout should be 30 min, not 60'); + }); + + test('mention mid-sentence still triggers', () => { + const t = parseCommentTrigger('Hey @bgagent please add a dark-mode toggle'); + expect(t.triggered).toBe(true); + expect(t.instruction).toBe('Hey please add a dark-mode toggle'); + }); + + test('case-insensitive', () => { + expect(parseCommentTrigger('@BgAgent fix it').triggered).toBe(true); + }); + + test('bare mention with no text → triggered with empty instruction', () => { + const t = parseCommentTrigger('@bgagent'); + expect(t.triggered).toBe(true); + expect(t.instruction).toBe(''); + }); + + test('no mention → not triggered (ordinary human discussion)', () => { + expect(parseCommentTrigger('I think this looks good, merging soon').triggered).toBe(false); + }); + + test('empty / null / undefined body → not triggered', () => { + expect(parseCommentTrigger('').triggered).toBe(false); + expect(parseCommentTrigger(null).triggered).toBe(false); + expect(parseCommentTrigger(undefined).triggered).toBe(false); + }); + + test('the agent\'s own progress comment (no mention) never triggers', () => { + expect(parseCommentTrigger('🤖 Starting on the task — cloning repo now.').triggered).toBe(false); + expect(parseCommentTrigger('✅ PR opened: https://github.com/o/r/pull/5').triggered).toBe(false); + }); + + test('token boundary: @bgagentbot and email-like do NOT trigger', () => { + expect(parseCommentTrigger('ping @bgagentbot for help').triggered).toBe(false); + expect(parseCommentTrigger('email me at foo@bgagent.io').triggered).toBe(false); + }); + + test('multiple mentions are all stripped', () => { + const t = parseCommentTrigger('@bgagent do X and @bgagent also Y'); + expect(t.triggered).toBe(true); + expect(t.instruction).toBe('do X and also Y'); + }); + + // The bot's OWN comments must never re-trigger it, even when (especially + // when) they contain a literal @bgagent — otherwise it replies to itself + // forever. + describe('self-comment guard — the bot never triggers on its own comments', () => { + test('the disambiguation reply does NOT trigger, even though it embeds an "@bgagent <issue>:" example', () => { + // This EXACT body once spammed ~50 replies: it starts with 👋 and contains + // a literal @bgagent example, which an earlier regex re-matched → loop. + const body = '👋 I couldn\'t tell which sub-issue that\'s about.\n\nOtherwise, comment on the ' + + 'specific sub-issue, or name it here — e.g. `@bgagent ABCA-123: <what to change>`. The sub-issues are:'; + expect(parseCommentTrigger(body).triggered).toBe(false); + expect(isBotAuthoredComment(body)).toBe(true); + }); + + test('all bot template prefixes are recognized as bot-authored (never trigger)', () => { + for (const body of [ + '👋 That could apply to more than one sub-issue…', + '✅ Updated — PR #193.', + '✅ **ABCA orchestration complete**', + '❌ I made the change, but the build/tests didn\'t pass.', + '⚠️ **ABCA orchestration finished with failures**', + '🔄 **ABCA orchestration** · 1/3 complete', + '🤖 Starting on this issue…', + '🖼️ **Preview screenshot**', + '🔗 PR opened: https://github.com/o/r/pull/9', + ]) { + expect(isBotAuthoredComment(body)).toBe(true); + expect(parseCommentTrigger(body).triggered).toBe(false); + } + }); + + test('a genuine human @bgagent comment is NOT misclassified as bot-authored', () => { + expect(isBotAuthoredComment('@bgagent for the footer change the tagline')).toBe(false); + expect(parseCommentTrigger('@bgagent for the footer change the tagline').triggered).toBe(true); + }); + + test('leading whitespace before a bot marker is still caught', () => { + expect(isBotAuthoredComment(' \n✅ Updated — PR #193.')).toBe(true); + }); + }); +}); + +describe('buildIterationInstruction', () => { + test('uses the comment instruction when present', () => { + expect(buildIterationInstruction({ triggered: true, instruction: 'make the header sticky' })) + .toBe('make the header sticky'); + }); + + test('falls back to a generic directive for a bare mention', () => { + expect(buildIterationInstruction({ triggered: true, instruction: '' })) + .toMatch(/latest review feedback/i); + }); +}); + +describe('detectNearMissMention — a wrong-handle mention gets a nudge, not silence', () => { + test('@abca (label-name confusion) is a near-miss → nudge', () => { + expect(detectNearMissMention('@abca approve')).toBe(true); + expect(detectNearMissMention('hey @abca can you make it 2 tasks')).toBe(true); + // …even with a :suffix the reviewer copied from the label. + expect(detectNearMissMention('@abca:auto please')).toBe(true); + }); + + test('a boundary-miss @bgagent handle is a near-miss (parseCommentTrigger deliberately skips it)', () => { + // parseCommentTrigger's `@bgagent(?![\w.])` does NOT trigger on these … + expect(parseCommentTrigger('ping @bgagentbot for help').triggered).toBe(false); + // … so detectNearMissMention catches them for the nudge instead of a silent drop. + expect(detectNearMissMention('ping @bgagentbot for help')).toBe(true); + expect(detectNearMissMention('@bgagentx approve')).toBe(true); + }); + + test('spelled-out / hyphenated variants are near-misses', () => { + expect(detectNearMissMention('@bg-agent approve')).toBe(true); + expect(detectNearMissMention('@bg_agent approve')).toBe(true); + expect(detectNearMissMention('@background-agent approve')).toBe(true); + expect(detectNearMissMention('@bgbot approve')).toBe(true); + }); + + test('the CORRECT @bgagent handle is NOT a near-miss (it triggers normally)', () => { + // A real trigger never reaches the near-miss branch, but assert it here too: + // the exact token must not be flagged as a wrong handle. + expect(detectNearMissMention('@bgagent approve')).toBe(false); + expect(detectNearMissMention('@bgagent make it 2 tasks')).toBe(false); + }); + + test('an email-like foo@bgagent.io is NOT a near-miss (not a mention at all)', () => { + expect(detectNearMissMention('email me at foo@bgagent.io')).toBe(false); + }); + + test('ordinary human discussion with no bot handle → not a near-miss', () => { + expect(detectNearMissMention('this looks good, merging soon')).toBe(false); + expect(detectNearMissMention('cc @teammate can you review')).toBe(false); + expect(detectNearMissMention('')).toBe(false); + expect(detectNearMissMention(null)).toBe(false); + expect(detectNearMissMention(undefined)).toBe(false); + }); + + test("the bot's own comments are never near-misses (no self-nudge loop)", () => { + // The wrong-mention nudge is 👋-prefixed → bot-authored → must not re-detect. + expect(detectNearMissMention('👋 I answer to `@bgagent` — I don\'t pick up other @-names')).toBe(false); + // A plan comment embeds a literal "@bgagent approve" example — still not a near-miss. + expect(detectNearMissMention('🗂️ Proposed breakdown … reply `@bgagent approve`')).toBe(false); + }); +}); + +describe('parseRetryIntent — recognise a plain "retry" command', () => { + test('bare retry phrases → true', () => { + for (const s of ['retry', 'Retry', 're-run', 'rerun', 'try again', 'run again', 'run it again', 'retry please']) { + expect(parseRetryIntent(s)).toBe(true); + } + }); + + test('a retry word leading a SUBSTANTIVE edit is NOT a bare retry (routes to iterate/revise)', () => { + // "retry the footer but change the color and spacing too" is an edit request, + // not a bare retry — must fall through so it isn't swallowed by the retry path. + expect(parseRetryIntent('retry the footer but change the color and spacing and margins')).toBe(false); + }); + + test('empty / non-retry instructions → false', () => { + expect(parseRetryIntent('')).toBe(false); + expect(parseRetryIntent('looks good, ship it')).toBe(false); + expect(parseRetryIntent('change the header color')).toBe(false); + // A bare @bgagent (empty instruction) is "address latest review", never a retry. + expect(parseRetryIntent(' ')).toBe(false); + }); + + test('markdown emphasis around the word is tolerated', () => { + expect(parseRetryIntent('`retry`')).toBe(true); + expect(parseRetryIntent('**retry**')).toBe(true); + }); + + test('KNOWN_EPIC_COMMANDS lists retry (kept in sync with the parser + panel copy)', () => { + expect(KNOWN_EPIC_COMMANDS).toContain('retry'); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-dag.test.ts b/cdk/test/handlers/shared/orchestration-dag.test.ts new file mode 100644 index 000000000..4ef2ec4e7 --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-dag.test.ts @@ -0,0 +1,148 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { validateDag, topologicalOrder, type DagNode } from '../../../src/handlers/shared/orchestration-dag'; + +const node = (id: string, ...depends_on: string[]): DagNode => ({ id, depends_on }); + +describe('validateDag — valid graphs', () => { + test('empty graph is valid with no layers', () => { + const result = validateDag([]); + expect(result).toEqual({ ok: true, layers: [] }); + }); + + test('single root node → one layer', () => { + const result = validateDag([node('A')]); + expect(result).toEqual({ ok: true, layers: [['A']] }); + }); + + test('independent siblings all land in layer 0', () => { + const result = validateDag([node('A'), node('B'), node('C')]); + expect(result.ok).toBe(true); + if (result.ok) expect(result.layers).toEqual([['A', 'B', 'C']]); + }); + + test('linear chain A→B→C produces three single-node layers', () => { + // B depends on A, C depends on B. + const result = validateDag([node('C', 'B'), node('B', 'A'), node('A')]); + expect(result.ok).toBe(true); + if (result.ok) expect(result.layers).toEqual([['A'], ['B'], ['C']]); + }); + + test('diamond A→{B,C}→D layers B and C together, D last', () => { + const result = validateDag([ + node('A'), + node('B', 'A'), + node('C', 'A'), + node('D', 'B', 'C'), + ]); + expect(result.ok).toBe(true); + if (result.ok) expect(result.layers).toEqual([['A'], ['B', 'C'], ['D']]); + }); + + test('layers are sorted for deterministic output', () => { + const result = validateDag([node('z'), node('a'), node('m')]); + if (result.ok) expect(result.layers[0]).toEqual(['a', 'm', 'z']); + }); + + test('tolerates a duplicated edge to the same predecessor', () => { + // depends_on lists A twice — should not double-count in-degree. + const result = validateDag([node('A'), { id: 'B', depends_on: ['A', 'A'] }]); + expect(result.ok).toBe(true); + if (result.ok) expect(result.layers).toEqual([['A'], ['B']]); + }); +}); + +describe('validateDag — rejected graphs', () => { + test('self-loop is a cycle', () => { + const result = validateDag([node('A', 'A')]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('cycle'); + expect(result.offendingIds).toEqual(['A']); + } + }); + + test('two-node cycle A↔B', () => { + const result = validateDag([node('A', 'B'), node('B', 'A')]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('cycle'); + expect(result.offendingIds).toEqual(['A', 'B']); + } + }); + + test('cycle is reported even when valid roots exist', () => { + // R is a clean root; X→Y→Z→X is a cycle hanging off nothing. + const result = validateDag([ + node('R'), + node('X', 'Z'), + node('Y', 'X'), + node('Z', 'Y'), + ]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('cycle'); + expect(result.offendingIds).toEqual(['X', 'Y', 'Z']); + } + }); + + test('dangling edge → depends_on points outside the node set', () => { + const result = validateDag([node('A'), node('B', 'GHOST')]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('dangling_edge'); + expect(result.offendingIds).toEqual(['B']); + } + }); + + test('duplicate id', () => { + const result = validateDag([node('A'), node('A')]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('duplicate_id'); + expect(result.offendingIds).toEqual(['A']); + } + }); + + test('duplicate-id check precedes dangling/cycle checks', () => { + // Duplicate A plus a dangling edge — duplicate wins (checked first). + const result = validateDag([node('A'), node('A', 'GHOST')]); + if (!result.ok) expect(result.reason).toBe('duplicate_id'); + }); + + test('rejection carries a user-facing message', () => { + const result = validateDag([node('A', 'B'), node('B', 'A')]); + if (!result.ok) { + expect(result.message).toMatch(/cycle/i); + expect(result.message.length).toBeGreaterThan(0); + } + }); +}); + +describe('topologicalOrder', () => { + test('returns a flat valid order for an accepted graph', () => { + const order = topologicalOrder([node('C', 'B'), node('B', 'A'), node('A')]); + expect(order).toEqual(['A', 'B', 'C']); + }); + + test('throws on an invalid graph', () => { + expect(() => topologicalOrder([node('A', 'B'), node('B', 'A')])).toThrow(/invalid dependency graph/i); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-epic-tip.test.ts b/cdk/test/handlers/shared/orchestration-epic-tip.test.ts new file mode 100644 index 000000000..c1c100c0a --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-epic-tip.test.ts @@ -0,0 +1,66 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { resolveEpicTip, type TipCandidate } from '../../../src/handlers/shared/orchestration-epic-tip'; + +const node = (id: string, depends_on: string[] = [], created_at = '2026-01-01'): TipCandidate => + ({ sub_issue_id: id, depends_on, created_at }); + +describe('resolveEpicTip — where a new node with no declared dependency stacks', () => { + test('empty epic → no tip (degrade to a root node off the default branch)', () => { + expect(resolveEpicTip([])).toEqual([]); + }); + + test('linear chain A→B→C → tip is the single leaf C', () => { + const epic = [node('A'), node('B', ['A']), node('C', ['B'])]; + expect(resolveEpicTip(epic)).toEqual(['C']); + }); + + test('single node epic → that node is the tip', () => { + expect(resolveEpicTip([node('A')])).toEqual(['A']); + }); + + test('fan-out (two independent leaves) → diamond: both leaves, sorted', () => { + // root R; B and C both depend on R, nothing depends on B or C. + const epic = [node('R'), node('B', ['R']), node('C', ['R'])]; + expect(resolveEpicTip(epic)).toEqual(['B', 'C']); + }); + + test('integration node present → it IS the combined tip (stack on it alone, no redundant diamond)', () => { + // A and B are leaves; the integration node depends on both, so it is the + // single most-downstream node. A new node stacks on integration only. + const epic = [ + node('A'), + node('B'), + node('orch_x__integration', ['A', 'B']), + ]; + expect(resolveEpicTip(epic)).toEqual(['orch_x__integration']); + }); + + test('multiple roots, one chain → only the genuine leaf is the tip', () => { + // A→B (B is a leaf); D is a standalone leaf. Two leaves → diamond. + const epic = [node('A'), node('B', ['A']), node('D')]; + expect(resolveEpicTip(epic)).toEqual(['B', 'D']); + }); + + test('deterministic ordering regardless of input order', () => { + const epic = [node('C', ['R']), node('R'), node('B', ['R'])]; + expect(resolveEpicTip(epic)).toEqual(['B', 'C']); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-graph-source.test.ts b/cdk/test/handlers/shared/orchestration-graph-source.test.ts new file mode 100644 index 000000000..4ae531688 --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-graph-source.test.ts @@ -0,0 +1,104 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +jest.mock('../../../src/handlers/shared/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +import { + declarativeGraphSource, + linearGraphSource, +} from '../../../src/handlers/shared/orchestration-graph-source'; + +/** A `fetch` impl returning a Linear children payload, for the Linear source. */ +function linearFetch(children: Array<{ id: string; blockedBy?: string[] }>): typeof fetch { + return (async () => ({ + ok: true, + status: 200, + json: async () => ({ + data: { + issue: { + id: 'PARENT', + children: { + nodes: children.map((c) => ({ + id: c.id, + inverseRelations: { nodes: (c.blockedBy ?? []).map((b) => ({ type: 'blocks', issue: { id: b } })) }, + })), + }, + }, + }, + }), + })) as unknown as typeof fetch; +} + +describe('declarativeGraphSource', () => { + test('non-empty node list → ok with the same children', async () => { + const nodes = [ + { id: 'a', depends_on: [], title: 'A' }, + { id: 'b', depends_on: ['a'], title: 'B' }, + ]; + const result = await declarativeGraphSource(nodes)(); + expect(result.kind).toBe('ok'); + if (result.kind === 'ok') expect(result.children).toEqual(nodes); + }); + + test('empty node list → no_children (caller falls through to single task)', async () => { + const result = await declarativeGraphSource([])(); + expect(result.kind).toBe('no_children'); + }); + + test('never errors — validity is enforced downstream, not here', async () => { + // A cyclic graph is still "ok" from the source's perspective; validateDag + // (in discoverOrchestration) is what rejects it. + const result = await declarativeGraphSource([ + { id: 'x', depends_on: ['y'] }, + { id: 'y', depends_on: ['x'] }, + ])(); + expect(result.kind).toBe('ok'); + }); +}); + +describe('linearGraphSource', () => { + test('maps a Linear children payload to ok', async () => { + const result = await linearGraphSource('tok', 'PARENT', { + fetchImpl: linearFetch([{ id: 'A' }, { id: 'B', blockedBy: ['A'] }]), + })(); + expect(result.kind).toBe('ok'); + if (result.kind === 'ok') { + expect(result.children.map((c) => c.id)).toEqual(['A', 'B']); + expect(result.children[1].depends_on).toEqual(['A']); + } + }); + + test('no children → no_children', async () => { + const empty = (async () => ({ + ok: true, + status: 200, + json: async () => ({ data: { issue: { id: 'PARENT', children: { nodes: [] } } } }), + })) as unknown as typeof fetch; + const result = await linearGraphSource('tok', 'PARENT', { fetchImpl: empty })(); + expect(result.kind).toBe('no_children'); + }); + + test('Linear API failure → error (not silently empty)', async () => { + const fail = (async () => ({ ok: false, status: 500, json: async () => ({}) })) as unknown as typeof fetch; + const result = await linearGraphSource('tok', 'PARENT', { fetchImpl: fail })(); + expect(result.kind).toBe('error'); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-integration-node.test.ts b/cdk/test/handlers/shared/orchestration-integration-node.test.ts new file mode 100644 index 000000000..2cc011290 --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-integration-node.test.ts @@ -0,0 +1,92 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { SubIssueNode } from '../../../src/handlers/shared/linear-subissue-fetch'; +import { + computeLeaves, + INTEGRATION_NODE_SUFFIX, + isIntegrationNode, + withIntegrationNode, +} from '../../../src/handlers/shared/orchestration-integration-node'; + +const n = (id: string, deps: string[] = []): SubIssueNode => ({ id, depends_on: deps }); +const ORCH = 'orch_abc123'; + +describe('computeLeaves', () => { + test('linear chain A→B→C → only C is a leaf', () => { + expect(computeLeaves([n('A'), n('B', ['A']), n('C', ['B'])])).toEqual(['C']); + }); + + test('pure fan-out A→{B,C} → B and C are leaves (A is not)', () => { + expect([...computeLeaves([n('A'), n('B', ['A']), n('C', ['A'])])].sort()).toEqual(['B', 'C']); + }); + + test('diamond A→{B,C}→D → only D is a leaf', () => { + expect(computeLeaves([n('A'), n('B', ['A']), n('C', ['A']), n('D', ['B', 'C'])])).toEqual(['D']); + }); + + test('all independent roots → all are leaves', () => { + expect([...computeLeaves([n('A'), n('B'), n('C')])].sort()).toEqual(['A', 'B', 'C']); + }); +}); + +describe('withIntegrationNode', () => { + test('linear chain (1 leaf) → unchanged, not added', () => { + const r = withIntegrationNode([n('A'), n('B', ['A'])], ORCH); + expect(r.added).toBe(false); + expect(r.nodes).toHaveLength(2); + }); + + test('explicit diamond (1 leaf D) → unchanged, not added', () => { + const r = withIntegrationNode([n('A'), n('B', ['A']), n('C', ['A']), n('D', ['B', 'C'])], ORCH); + expect(r.added).toBe(false); + }); + + test('pure fan-out (>1 leaf) → appends a synthetic node over all leaves', () => { + const r = withIntegrationNode([n('A'), n('B', ['A']), n('C', ['A'])], ORCH); + expect(r.added).toBe(true); + expect(r.nodes).toHaveLength(4); + const integ = r.nodes[r.nodes.length - 1]; + expect(integ.id).toBe(`${ORCH}${INTEGRATION_NODE_SUFFIX}`); + expect([...integ.depends_on].sort()).toEqual(['B', 'C']); + expect(integ.title).toContain('Integration'); + expect(integ.identifier).toBeUndefined(); + }); + + test('three independent roots → integration node depends on all three', () => { + const r = withIntegrationNode([n('A'), n('B'), n('C')], ORCH); + expect(r.added).toBe(true); + expect([...r.nodes[r.nodes.length - 1].depends_on].sort()).toEqual(['A', 'B', 'C']); + }); + + test('synthetic node id is idempotency-key safe (no "#", matches /^[A-Za-z0-9_-]+$/)', () => { + const r = withIntegrationNode([n('A'), n('B')], ORCH); + const id = r.nodes[r.nodes.length - 1].id; + // releaseChild builds `${orch}_${sub}` and createTaskCore validates it. + expect(`${ORCH}_${id}`).toMatch(/^[a-zA-Z0-9_-]{1,128}$/); + }); +}); + +describe('isIntegrationNode', () => { + test('true for the synthetic suffix, false for real ids', () => { + expect(isIntegrationNode(`${ORCH}${INTEGRATION_NODE_SUFFIX}`)).toBe(true); + expect(isIntegrationNode('a1b2c3-uuid')).toBe(false); + expect(isIntegrationNode('#meta')).toBe(false); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-store.test.ts b/cdk/test/handlers/shared/orchestration-store.test.ts new file mode 100644 index 000000000..da2750c80 --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-store.test.ts @@ -0,0 +1,1041 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { GetCommand, BatchWriteCommand, UpdateCommand, QueryCommand } from '@aws-sdk/lib-dynamodb'; +import type { SubIssueNode } from '../../../src/handlers/shared/linear-subissue-fetch'; +import { + seedOrchestration, + setRetryCommentId, + extendOrchestration, + deriveOrchestrationId, + OrchestrationIdCollisionError, + claimRollup, + clearRollupClaim, + claimCommentAck, + loadOrchestration, + findOrchestrationChildByBranch, +} from '../../../src/handlers/shared/orchestration-store'; + +jest.mock('../../../src/handlers/shared/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +const child = (id: string, depends_on: string[] = [], extra: Partial<SubIssueNode> = {}): SubIssueNode => ({ + id, + depends_on, + ...extra, +}); + +interface MockDdb { + send: jest.Mock; +} + +function makeDdb(): MockDdb { + return { send: jest.fn() }; +} + +const TABLE = 'OrchestrationTable'; +const NOW = '2026-06-09T12:00:00.000Z'; +const RC = { platform_user_id: 'platform-user-1' }; + +describe('deriveOrchestrationId', () => { + test('is deterministic for the same parent id', () => { + expect(deriveOrchestrationId('ISSUE-123')).toBe(deriveOrchestrationId('ISSUE-123')); + }); + + test('differs for different parent ids', () => { + expect(deriveOrchestrationId('A')).not.toBe(deriveOrchestrationId('B')); + }); + + test('is prefixed and fixed-length', () => { + const id = deriveOrchestrationId('anything'); + expect(id).toMatch(/^orch_[0-9a-f]{32}$/); + }); +}); + +describe('seedOrchestration — first write', () => { + test('writes one row per child plus a meta row', async () => { + const ddb = makeDdb(); + ddb.send + .mockResolvedValueOnce({ Item: undefined }) // GetCommand: no existing meta + .mockResolvedValueOnce({}); // BatchWrite + + const result = await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A'), child('B', ['A'])], + now: NOW, + releaseContext: RC, + }); + + expect(result.alreadyExisted).toBe(false); + // 2 children + 1 meta row. + expect(result.rowsWritten).toBe(3); + expect(result.orchestrationId).toBe(deriveOrchestrationId('PARENT')); + + // First call is the idempotency GetCommand. + expect(ddb.send.mock.calls[0][0]).toBeInstanceOf(GetCommand); + // Second is the BatchWrite. + const batch = ddb.send.mock.calls[1][0]; + expect(batch).toBeInstanceOf(BatchWriteCommand); + const puts = batch.input.RequestItems[TABLE]; + expect(puts).toHaveLength(3); + }); + + test('roots get child_status=ready, blocked children get blocked', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Item: undefined }).mockResolvedValueOnce({}); + + await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A'), child('B', ['A'])], + now: NOW, + releaseContext: RC, + }); + + const puts = ddb.send.mock.calls[1][0].input.RequestItems[TABLE] as Array<{ PutRequest: { Item: Record<string, unknown> } }>; + const byId = Object.fromEntries(puts.map((p) => [p.PutRequest.Item.sub_issue_id, p.PutRequest.Item])); + expect(byId.A.child_status).toBe('ready'); + expect(byId.B.child_status).toBe('blocked'); + expect(byId.B.depends_on).toEqual(['A']); + }); + + test('persists linear_identifier and title when present', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Item: undefined }).mockResolvedValueOnce({}); + + await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A', [], { identifier: 'ENG-1', title: 'Do thing' })], + now: NOW, + releaseContext: RC, + }); + + const puts = ddb.send.mock.calls[1][0].input.RequestItems[TABLE] as Array<{ PutRequest: { Item: Record<string, unknown> } }>; + const a = puts.find((p) => p.PutRequest.Item.sub_issue_id === 'A')!.PutRequest.Item; + expect(a.linear_identifier).toBe('ENG-1'); + expect(a.title).toBe('Do thing'); + }); + + test('persists the planner description onto the child row (and omits an empty one)', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Item: undefined }).mockResolvedValueOnce({}); + + await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [ + child('A', [], { title: 'Dashboard', description: 'Create `dashboard.html` at the root.' }), + child('B', [], { title: 'No scope' }), // no description + ], + now: NOW, + releaseContext: RC, + }); + + const puts = ddb.send.mock.calls[1][0].input.RequestItems[TABLE] as Array<{ PutRequest: { Item: Record<string, unknown> } }>; + const a = puts.find((p) => p.PutRequest.Item.sub_issue_id === 'A')!.PutRequest.Item; + const b = puts.find((p) => p.PutRequest.Item.sub_issue_id === 'B')!.PutRequest.Item; + expect(a.description).toBe('Create `dashboard.html` at the root.'); + expect(b).not.toHaveProperty('description'); // absent, not an empty string + }); + + test('chunks BatchWrite into groups of 25', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValue({}); // Get + all batches + ddb.send.mockResolvedValueOnce({ Item: undefined }); // first call = Get + + // 30 children + 1 meta = 31 rows → 2 batches (25 + 6). + const children = Array.from({ length: 30 }, (_, i) => child(`C${i}`)); + const result = await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children, + now: NOW, + releaseContext: RC, + }); + + expect(result.rowsWritten).toBe(31); + // 1 Get + 2 BatchWrite = 3 sends. + expect(ddb.send).toHaveBeenCalledTimes(3); + }); + + test('includes ttl on rows when provided', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Item: undefined }).mockResolvedValueOnce({}); + + await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A')], + now: NOW, + releaseContext: RC, + ttl: 9999999999, + }); + + const puts = ddb.send.mock.calls[1][0].input.RequestItems[TABLE] as Array<{ PutRequest: { Item: Record<string, unknown> } }>; + expect(puts.every((p) => p.PutRequest.Item.ttl === 9999999999)).toBe(true); + }); + + test('persists channel_source on the meta row when supplied (so feedback goes back to the right surface)', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Item: undefined }).mockResolvedValueOnce({}); + + await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A')], + now: NOW, + releaseContext: { platform_user_id: 'u1', channel_source: 'linear' }, + }); + + const puts = ddb.send.mock.calls[1][0].input.RequestItems[TABLE] as Array<{ PutRequest: { Item: Record<string, unknown> } }>; + const meta = puts.find((p) => p.PutRequest.Item.sub_issue_id === '#meta')!.PutRequest.Item; + expect(meta.channel_source).toBe('linear'); + }); + + test('omits channel_source from the meta row when not supplied (back-compat)', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Item: undefined }).mockResolvedValueOnce({}); + + await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A')], + now: NOW, + releaseContext: RC, // no channel_source + }); + + const puts = ddb.send.mock.calls[1][0].input.RequestItems[TABLE] as Array<{ PutRequest: { Item: Record<string, unknown> } }>; + const meta = puts.find((p) => p.PutRequest.Item.sub_issue_id === '#meta')!.PutRequest.Item; + expect(meta.channel_source).toBeUndefined(); + }); +}); + +describe('seedOrchestration — idempotent replay', () => { + test('skips writing when a meta row already exists', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Item: { orchestration_id: 'x', sub_issue_id: '#meta' } }); + + const result = await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A'), child('B', ['A'])], + now: NOW, + releaseContext: RC, + }); + + expect(result.alreadyExisted).toBe(true); + expect(result.rowsWritten).toBe(0); + // Only the Get fired — no BatchWrite. + expect(ddb.send).toHaveBeenCalledTimes(1); + expect(ddb.send.mock.calls[0][0]).toBeInstanceOf(GetCommand); + }); + + test('a row that records the SAME owner is a replay, not a collision', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ + Item: { + orchestration_id: 'x', + sub_issue_id: '#meta', + parent_issue_ref: 'PARENT', + credentials_ref: 'WS', + }, + }); + + const result = await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A')], + now: NOW, + releaseContext: RC, + }); + + expect(result.alreadyExisted).toBe(true); + }); + + test('recognises the owner under the LEGACY attribute names too', async () => { + // A row written before the neutral names existed still records who owns it. + // Reading only the new names would see "no owner recorded" and wave a genuine + // cross-tenant collision through, so this fixture is a legacy row whose owner + // DIFFERS — the case that distinguishes reading both names from reading one. + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ + Item: { + orchestration_id: 'x', + sub_issue_id: '#meta', + parent_linear_issue_id: 'PARENT', + linear_workspace_id: 'OTHER-TENANT', + }, + }); + + await expect(seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A')], + now: NOW, + releaseContext: RC, + })).rejects.toThrow(OrchestrationIdCollisionError); + }); + + test('a legacy row with MATCHING legacy owner fields still replays', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ + Item: { + orchestration_id: 'x', + sub_issue_id: '#meta', + parent_linear_issue_id: 'PARENT', + linear_workspace_id: 'WS', + }, + }); + + const result = await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A')], + now: NOW, + releaseContext: RC, + }); + + expect(result.alreadyExisted).toBe(true); + }); + + test('REFUSES to adopt an orchestration owned by a different tenant', async () => { + // The id is derived from the parent ref alone, so a surface whose refs are + // only project-unique would let two tenants derive the same id — and the + // replay gate would hand the second one the first one's children, releasing + // work against the other tenant's repo under the other tenant's credentials. + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ + Item: { + orchestration_id: 'x', + sub_issue_id: '#meta', + parent_issue_ref: 'PARENT', + credentials_ref: 'OTHER-TENANT', + }, + }); + + await expect(seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A')], + now: NOW, + releaseContext: RC, + })).rejects.toThrow(OrchestrationIdCollisionError); + + // Nothing written — refusing must not half-seed. + expect(ddb.send).toHaveBeenCalledTimes(1); + }); + + test('REFUSES when the id is held by a different parent issue', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ + Item: { + orchestration_id: 'x', + sub_issue_id: '#meta', + parent_issue_ref: 'SOME-OTHER-EPIC', + credentials_ref: 'WS', + }, + }); + + await expect(seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A')], + now: NOW, + releaseContext: RC, + })).rejects.toThrow(OrchestrationIdCollisionError); + }); +}); + +describe('claimRollup — exactly-once parent rollup', () => { + test('first claim wins (conditional write succeeds) → true', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({}); + const won = await claimRollup(ddb as never, TABLE, 'orch_1', NOW); + expect(won).toBe(true); + const cmd = ddb.send.mock.calls[0][0] as UpdateCommand; + expect(cmd).toBeInstanceOf(UpdateCommand); + expect(cmd.input.ConditionExpression).toContain('attribute_not_exists(rollup_posted_at)'); + expect(cmd.input.Key).toMatchObject({ sub_issue_id: '#meta' }); + }); + + test('second claim loses (ConditionalCheckFailed) → false, no throw', async () => { + const ddb = makeDdb(); + const e = Object.assign(new Error('c'), { name: 'ConditionalCheckFailedException' }); + ddb.send.mockRejectedValueOnce(e); + const won = await claimRollup(ddb as never, TABLE, 'orch_1', NOW); + expect(won).toBe(false); + }); + + test('non-conditional error propagates', async () => { + const ddb = makeDdb(); + ddb.send.mockRejectedValueOnce(new Error('throttle')); + await expect(claimRollup(ddb as never, TABLE, 'orch_1', NOW)).rejects.toThrow('throttle'); + }); +}); + +describe('clearRollupClaim — release the claim so a re-completing epic re-settles', () => { + test('REMOVEs rollup_posted_at on the meta row (unconditional, idempotent)', async () => { + const ddb = { send: jest.fn().mockResolvedValueOnce({}) }; + await clearRollupClaim(ddb as never, TABLE, 'orch_1', NOW); + const cmd = ddb.send.mock.calls[0][0] as UpdateCommand; + expect(cmd).toBeInstanceOf(UpdateCommand); + expect(cmd.input.UpdateExpression).toContain('REMOVE rollup_posted_at'); + expect(cmd.input.Key).toMatchObject({ sub_issue_id: '#meta', orchestration_id: 'orch_1' }); + // No conditional — a no-op when already absent. + expect(cmd.input.ConditionExpression).toBeUndefined(); + }); +}); + +describe('claimCommentAck — exactly-once per comment, so a redelivered webhook is deduped', () => { + test('first delivery wins → true, conditional create-once on a per-comment SK + TTL', async () => { + const ddb = { send: jest.fn().mockResolvedValueOnce({}) }; + const won = await claimCommentAck(ddb as never, TABLE, 'orch_1', 'cmt-9', NOW, 1781800000); + expect(won).toBe(true); + const cmd = ddb.send.mock.calls[0][0] as UpdateCommand; + expect(cmd).toBeInstanceOf(UpdateCommand); + expect(cmd.input.Key).toMatchObject({ orchestration_id: 'orch_1', sub_issue_id: 'ack#cmt-9' }); + expect(cmd.input.ConditionExpression).toContain('attribute_not_exists(orchestration_id)'); + expect(cmd.input.ExpressionAttributeValues).toMatchObject({ ':ttl': 1781800000 }); + // ``ttl`` is a DynamoDB reserved keyword — must be aliased, else the write + // 400s with ValidationException. Observed in practice: the unaliased form + // errored out the whole handler, silently dropping the comment. + expect(cmd.input.ExpressionAttributeNames).toMatchObject({ '#ttl': 'ttl' }); + expect(cmd.input.UpdateExpression).toContain('#ttl'); + }); + + test('redelivery of the same comment loses (ConditionalCheckFailed) → false, no throw', async () => { + const ddb = { send: jest.fn().mockRejectedValueOnce(Object.assign(new Error('c'), { name: 'ConditionalCheckFailedException' })) }; + expect(await claimCommentAck(ddb as never, TABLE, 'orch_1', 'cmt-9', NOW, 1781800000)).toBe(false); + }); + + test('non-conditional error propagates', async () => { + const ddb = { send: jest.fn().mockRejectedValueOnce(new Error('throttle')) }; + await expect(claimCommentAck(ddb as never, TABLE, 'orch_1', 'cmt-9', NOW, 1781800000)).rejects.toThrow('throttle'); + }); +}); + +describe('renamed row attributes stay readable across the rename', () => { + // Rows already in the table outlive the deploy that renames an attribute, so an + // epic mid-flight when the rename ships must still load and settle. These pin + // BOTH spellings, and the fact that a row carrying only the legacy names loads + // is the actual back-compat guarantee. + // Named for what it builds — a persisted ROW — to keep it distinct from the + // file-level `child`, which builds a SubIssueNode (an input, not a row). + const childRow = (extra: Record<string, unknown>) => ({ + orchestration_id: 'orch_1', sub_issue_id: 'uuid-A', depends_on: [], child_status: 'succeeded', ...extra, + }); + + test('a row written BEFORE the rename (legacy names only) still loads', async () => { + const ddb = { + send: jest.fn().mockResolvedValueOnce({ + Items: [ + { + orchestration_id: 'orch_1', + sub_issue_id: '#meta', + repo: 'o/r', + platform_user_id: 'u1', + child_count: 1, + parent_linear_issue_id: 'P-old', + linear_workspace_id: 'WS-old', + }, + childRow({ parent_linear_issue_id: 'P-old', linear_workspace_id: 'WS-old', linear_identifier: 'ENG-1' }), + ], + }), + }; + const snap = await loadOrchestration(ddb as never, TABLE, 'orch_1'); + expect(snap!.meta.parent_issue_ref).toBe('P-old'); + expect(snap!.meta.credentials_ref).toBe('WS-old'); + expect(snap!.children[0].parent_issue_ref).toBe('P-old'); + expect(snap!.children[0].credentials_ref).toBe('WS-old'); + expect(snap!.children[0].display_id).toBe('ENG-1'); + }); + + test('a row written AFTER the rename (neutral names only) loads too', async () => { + const ddb = { + send: jest.fn().mockResolvedValueOnce({ + Items: [ + { + orchestration_id: 'orch_1', + sub_issue_id: '#meta', + repo: 'o/r', + platform_user_id: 'u1', + child_count: 1, + parent_issue_ref: 'P-new', + credentials_ref: 'WS-new', + }, + childRow({ parent_issue_ref: 'P-new', credentials_ref: 'WS-new', display_id: 'ENG-2' }), + ], + }), + }; + const snap = await loadOrchestration(ddb as never, TABLE, 'orch_1'); + expect(snap!.meta.parent_issue_ref).toBe('P-new'); + expect(snap!.meta.credentials_ref).toBe('WS-new'); + expect(snap!.children[0].parent_issue_ref).toBe('P-new'); + expect(snap!.children[0].credentials_ref).toBe('WS-new'); + expect(snap!.children[0].display_id).toBe('ENG-2'); + }); + + test('the neutral name wins when a row carries both', async () => { + const ddb = { + send: jest.fn().mockResolvedValueOnce({ + Items: [ + { + orchestration_id: 'orch_1', + sub_issue_id: '#meta', + repo: 'o/r', + platform_user_id: 'u1', + child_count: 1, + parent_issue_ref: 'P-new', + parent_linear_issue_id: 'P-old', + credentials_ref: 'WS-new', + linear_workspace_id: 'WS-old', + }, + childRow({ parent_issue_ref: 'P-new', parent_linear_issue_id: 'P-old' }), + ], + }), + }; + const snap = await loadOrchestration(ddb as never, TABLE, 'orch_1'); + expect(snap!.meta.parent_issue_ref).toBe('P-new'); + expect(snap!.meta.credentials_ref).toBe('WS-new'); + expect(snap!.children[0].parent_issue_ref).toBe('P-new'); + }); +}); + +describe('loadOrchestration — dedup marker rows are not children', () => { + test('excludes ack#<commentId> marker rows from children (only real sub-issues count)', async () => { + const ddb = { + send: jest.fn().mockResolvedValueOnce({ + Items: [ + { orchestration_id: 'orch_1', sub_issue_id: '#meta', parent_linear_issue_id: 'P', linear_workspace_id: 'WS', repo: 'o/r', platform_user_id: 'u1', child_count: 2 }, + { orchestration_id: 'orch_1', sub_issue_id: 'uuid-A', depends_on: [], child_status: 'succeeded' }, + { orchestration_id: 'orch_1', sub_issue_id: 'orch_1__integration', depends_on: ['uuid-A'], child_status: 'succeeded' }, + { orchestration_id: 'orch_1', sub_issue_id: 'ack#cmt-9', acked_at: NOW, ttl: 1781800000 }, // marker — must NOT be a child + ], + }), + }; + const snap = await loadOrchestration(ddb as never, TABLE, 'orch_1'); + expect(snap).not.toBeNull(); + const ids = snap!.children.map((c) => c.sub_issue_id).sort(); + expect(ids).toEqual(['orch_1__integration', 'uuid-A']); // ack# row excluded; integration kept + }); + + test('round-trips pre_screened_attachments through the meta row', async () => { + const att = { + attachment_id: 'a1', + type: 'file', + content_type: 'application/pdf', + filename: 'spec.pdf', + s3_key: 'attachments/u1/epic-P/a1/spec.pdf', + s3_version_id: 'v1', + size_bytes: 42, + screening: { status: 'passed', screened_at: NOW }, + checksum_sha256: 'x'.repeat(64), + }; + // Seed writes the JSON string onto the meta row. + const seedDdb = makeDdb(); + seedDdb.send.mockResolvedValueOnce({ Item: undefined }).mockResolvedValueOnce({}); + await seedOrchestration({ + ddb: seedDdb as never, + tableName: TABLE, + parentIssueRef: 'P', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A')], + now: NOW, + releaseContext: { platform_user_id: 'u1', pre_screened_attachments: [att as never] }, + }); + const batch = seedDdb.send.mock.calls[1][0]; + const metaPut = batch.input.RequestItems[TABLE].map((r: { PutRequest: { Item: Record<string, unknown> } }) => r.PutRequest.Item) + .find((i: Record<string, unknown>) => i.sub_issue_id === '#meta'); + expect(typeof metaPut.pre_screened_attachments_json).toBe('string'); + + // Load parses it back into release_context.pre_screened_attachments. + const loadDdb = { + send: jest.fn().mockResolvedValueOnce({ + Items: [ + { orchestration_id: 'orch_1', sub_issue_id: '#meta', parent_linear_issue_id: 'P', linear_workspace_id: 'WS', repo: 'o/r', platform_user_id: 'u1', child_count: 1, pre_screened_attachments_json: JSON.stringify([att]) }, + { orchestration_id: 'orch_1', sub_issue_id: 'uuid-A', depends_on: [], child_status: 'ready' }, + ], + }), + }; + const snap = await loadOrchestration(loadDdb as never, TABLE, 'orch_1'); + expect(snap!.meta.release_context.pre_screened_attachments).toHaveLength(1); + expect(snap!.meta.release_context.pre_screened_attachments![0].s3_key).toBe('attachments/u1/epic-P/a1/spec.pdf'); + }); + + test('a malformed pre_screened_attachments_json degrades to no attachments (best-effort)', async () => { + const loadDdb = { + send: jest.fn().mockResolvedValueOnce({ + Items: [ + { orchestration_id: 'orch_1', sub_issue_id: '#meta', parent_linear_issue_id: 'P', linear_workspace_id: 'WS', repo: 'o/r', platform_user_id: 'u1', child_count: 1, pre_screened_attachments_json: '{not json' }, + { orchestration_id: 'orch_1', sub_issue_id: 'uuid-A', depends_on: [], child_status: 'ready' }, + ], + }), + }; + const snap = await loadOrchestration(loadDdb as never, TABLE, 'orch_1'); + expect(snap).not.toBeNull(); + expect(snap!.meta.release_context.pre_screened_attachments).toBeUndefined(); + }); + + test('paginates a multi-page Query so a large epic is NOT truncated to one 1MB page', async () => { + // A single Query returns at most 1MB; a large epic (many children + ack# + // markers) would otherwise silently drop children → mis-settle/strand. Two + // pages: the first returns the meta + child A with a LastEvaluatedKey, the + // second returns child B and no key. Both children must appear. + const ddb = { + send: jest.fn() + .mockResolvedValueOnce({ + Items: [ + { orchestration_id: 'orch_1', sub_issue_id: '#meta', parent_linear_issue_id: 'P', linear_workspace_id: 'WS', repo: 'o/r', platform_user_id: 'u1', child_count: 2 }, + { orchestration_id: 'orch_1', sub_issue_id: 'uuid-A', depends_on: [], child_status: 'succeeded' }, + ], + LastEvaluatedKey: { orchestration_id: 'orch_1', sub_issue_id: 'uuid-A' }, + }) + .mockResolvedValueOnce({ + Items: [ + { orchestration_id: 'orch_1', sub_issue_id: 'uuid-B', depends_on: ['uuid-A'], child_status: 'blocked' }, + ], + }), + }; + const snap = await loadOrchestration(ddb as never, TABLE, 'orch_1'); + expect(ddb.send).toHaveBeenCalledTimes(2); // followed LastEvaluatedKey + expect(snap).not.toBeNull(); + expect(snap!.children.map((c) => c.sub_issue_id).sort()).toEqual(['uuid-A', 'uuid-B']); + // 2nd Query carried ExclusiveStartKey from the 1st page's LastEvaluatedKey. + const secondCall = ddb.send.mock.calls[1][0] as QueryCommand; + expect((secondCall.input as { ExclusiveStartKey?: unknown }).ExclusiveStartKey).toEqual({ + orchestration_id: 'orch_1', sub_issue_id: 'uuid-A', + }); + }); +}); + +describe('loadOrchestration — "rows but no meta" is only alarming with a real child', () => { + const loggerMock = jest.requireMock('../../../src/handlers/shared/logger').logger as { + info: jest.Mock; warn: jest.Mock; error: jest.Mock; + }; + beforeEach(() => { loggerMock.info.mockClear(); loggerMock.warn.mockClear(); }); + + // A plain issue still accumulates dedup MARKER rows (`ack#…`) under the + // same derived id, so this state is NORMAL for it — a plain `@bgagent` on any such + // issue reaches it. Warning there cried wolf on a healthy path. + test('marker rows only → info, not warn', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ + Items: [{ orchestration_id: 'orch_1', sub_issue_id: 'ack#help', acked_at: NOW }], + }); + + expect(await loadOrchestration(ddb as never, TABLE, 'orch_1')).toBeNull(); + expect(loggerMock.info).toHaveBeenCalledWith( + expect.stringContaining('only dedup markers'), expect.anything(), + ); + expect(loggerMock.warn).not.toHaveBeenCalled(); + }); + + test('a REAL child row with no meta is the genuinely inconsistent case → warn', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ + Items: [ + { orchestration_id: 'orch_1', sub_issue_id: 'ack#help', acked_at: NOW }, + { orchestration_id: 'orch_1', sub_issue_id: 'uuid-A', child_status: 'ready', depends_on: [] }, + ], + }); + + expect(await loadOrchestration(ddb as never, TABLE, 'orch_1')).toBeNull(); + expect(loggerMock.warn).toHaveBeenCalledWith( + expect.stringContaining('meta row is missing'), expect.anything(), + ); + }); +}); + +describe('setRetryCommentId — remember the retry comment awaiting an outcome', () => { + test('records the comment id on the meta row', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({}); + + await setRetryCommentId(ddb as never, TABLE, 'orch_1', 'retry-cmt-1'); + + const cmd = ddb.send.mock.calls[0][0] as { input: Record<string, unknown> }; + expect(cmd.input.Key).toEqual({ orchestration_id: 'orch_1', sub_issue_id: '#meta' }); + expect(cmd.input.UpdateExpression).toBe('SET retry_comment_id = :cid'); + expect(cmd.input.ExpressionAttributeValues).toEqual({ ':cid': 'retry-cmt-1' }); + }); + + test('clearing REMOVEs it, so a later settle cannot re-answer an answered comment', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({}); + + await setRetryCommentId(ddb as never, TABLE, 'orch_1', undefined); + + const cmd = ddb.send.mock.calls[0][0] as { input: Record<string, unknown> }; + expect(cmd.input.UpdateExpression).toBe('REMOVE retry_comment_id'); + // No stray value binding on a REMOVE — DynamoDB rejects unused ones. + expect(cmd.input.ExpressionAttributeValues).toBeUndefined(); + }); + + test('loadOrchestration surfaces it on the meta snapshot', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ + Items: [{ + orchestration_id: 'orch_1', + sub_issue_id: '#meta', + parent_issue_ref: 'PARENT', + credentials_ref: 'WS', + repo: 'o/r', + child_count: 1, + platform_user_id: 'u1', + retry_comment_id: 'retry-cmt-1', + }], + }); + + const snap = await loadOrchestration(ddb as never, TABLE, 'orch_1'); + expect(snap?.meta.retry_comment_id).toBe('retry-cmt-1'); + }); +}); + +describe('findOrchestrationChildByBranch — map a PR branch back to its child row', () => { + test('queries the ChildBranchIndex GSI by branch and returns the child row', async () => { + const ddb = makeDdb(); + const row = { orchestration_id: 'orch_1', sub_issue_id: 'SUB-A', child_branch_name: 'bgagent/01T/abca-1-x' }; + ddb.send.mockResolvedValueOnce({ Items: [row] }); + + const result = await findOrchestrationChildByBranch( + ddb as never, TABLE, 'ChildBranchIndex', 'bgagent/01T/abca-1-x', + ); + + // Marshalled, so the neutral refs are always present (empty when the row + // carried neither naming, as this minimal fixture does). + expect(result).toEqual({ ...row, parent_issue_ref: '', credentials_ref: '' }); + const cmd = ddb.send.mock.calls[0][0] as QueryCommand; + expect(cmd).toBeInstanceOf(QueryCommand); + expect(cmd.input.IndexName).toBe('ChildBranchIndex'); + expect(cmd.input.KeyConditionExpression).toBe('child_branch_name = :b'); + expect(cmd.input.ExpressionAttributeValues).toEqual({ ':b': 'bgagent/01T/abca-1-x' }); + expect(cmd.input.Limit).toBe(1); + }); + + test('marshals the row, so a pre-rename row still yields its parent + credentials refs', async () => { + // A raw cast type-checked while leaving the renamed attributes unread, which + // would hand the caller a row with empty refs rather than failing visibly. + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ + Items: [{ + orchestration_id: 'orch_1', + sub_issue_id: 'SUB-A', + child_branch_name: 'bgagent/01T/abca-1-x', + parent_linear_issue_id: 'parent-uuid-1', + linear_workspace_id: 'ws-uuid-1', + linear_identifier: 'ABCA-1', + }], + }); + + const result = await findOrchestrationChildByBranch( + ddb as never, TABLE, 'ChildBranchIndex', 'bgagent/01T/abca-1-x', + ); + + expect(result).toMatchObject({ + parent_issue_ref: 'parent-uuid-1', + credentials_ref: 'ws-uuid-1', + display_id: 'ABCA-1', + }); + }); + + test('returns null when no released child owns the branch (non-orchestration PR)', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Items: [] }); + const result = await findOrchestrationChildByBranch( + ddb as never, TABLE, 'ChildBranchIndex', 'feature/some-human-branch', + ); + expect(result).toBeNull(); + }); +}); + +describe('extendOrchestration — add nodes to an already-seeded epic', () => { + const PARENT = 'parent-issue-1'; + const ORCH = deriveOrchestrationId(PARENT); + + /** A loadOrchestration Query response: meta + existing child rows. */ + function existing(children: Array<{ id: string; deps?: string[]; status: string }>) { + const meta = { + orchestration_id: ORCH, + sub_issue_id: '#meta', + parent_linear_issue_id: PARENT, + linear_workspace_id: 'WS', + repo: 'o/r', + child_count: children.length, + platform_user_id: 'u1', + created_at: NOW, + updated_at: NOW, + }; + const rows = children.map((c) => ({ + orchestration_id: ORCH, + sub_issue_id: c.id, + parent_linear_issue_id: PARENT, + linear_workspace_id: 'WS', + repo: 'o/r', + depends_on: c.deps ?? [], + child_status: c.status, + created_at: NOW, + updated_at: NOW, + })); + return { Items: [meta, ...rows] }; + } + + function extendParams(graph: SubIssueNode[]) { + return { + tableName: TABLE, + parentIssueRef: PARENT, + credentialsRef: 'WS', + repo: 'o/r', + graph, + now: NOW, + }; + } + + test('adds a NEW node blocked-by a finished node → releasable immediately', async () => { + const ddb = makeDdb(); + // load (Query) → existing A succeeded; then BatchWrite (new rows) + Update (meta). + ddb.send + .mockResolvedValueOnce(existing([{ id: 'A', status: 'succeeded' }])) + .mockResolvedValueOnce({}) // BatchWrite + .mockResolvedValueOnce({}); // Update meta + // Graph now has A (existing) + B (new, depends on the finished A). + const result = await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A'), child('B', ['A'], { title: 'UI' })]), + }); + expect(result.addedSubIssueIds).toEqual(['B']); + expect(result.releasableSubIssueIds).toEqual(['B']); // A already succeeded + // The new row was written as 'ready' (deps satisfied). + const bw = ddb.send.mock.calls.find((c) => c[0] instanceof BatchWriteCommand)![0]; + const written = (bw.input.RequestItems[TABLE] as Array<{ PutRequest: { Item: { sub_issue_id: string; child_status: string } } }>)[0].PutRequest.Item; + expect(written.sub_issue_id).toBe('B'); + expect(written.child_status).toBe('ready'); + }); + + test('adds a NEW node whose predecessor is NOT yet done → blocked, not releasable', async () => { + const ddb = makeDdb(); + ddb.send + .mockResolvedValueOnce(existing([{ id: 'A', status: 'released' }])) // A still running + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + const result = await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A'), child('B', ['A'])]), + }); + expect(result.addedSubIssueIds).toEqual(['B']); + expect(result.releasableSubIssueIds).toEqual([]); // A not succeeded → B blocked + }); + + // A new node with NO declared dependency stacks on the epic TIP (the leaf + // frontier of the existing nodes), not on the bare default branch. + test('new UNCONSTRAINED node → implicit depends_on = epic tip (linear chain → its leaf)', async () => { + const ddb = makeDdb(); + ddb.send + .mockResolvedValueOnce(existing([ + { id: 'A', status: 'succeeded' }, + { id: 'B', deps: ['A'], status: 'succeeded' }, // B is the leaf / tip + ])) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + // New node C declares NO dependency. + const result = await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A'), child('B', ['A']), child('C', [], { title: 'New step' })]), + }); + expect(result.addedSubIssueIds).toEqual(['C']); + const bw = ddb.send.mock.calls.find((c) => c[0] instanceof BatchWriteCommand)![0]; + const written = (bw.input.RequestItems[TABLE] as Array<{ PutRequest: { Item: { sub_issue_id: string; depends_on: string[]; child_status: string } } }>)[0].PutRequest.Item; + expect(written.sub_issue_id).toBe('C'); + // Stacked on the tip B (not []), and B succeeded so C is releasable. + expect(written.depends_on).toEqual(['B']); + expect(written.child_status).toBe('ready'); + expect(result.releasableSubIssueIds).toEqual(['C']); + }); + + test('new unconstrained node, tip NOT done → blocked on the tip (stacks, waits)', async () => { + const ddb = makeDdb(); + ddb.send + .mockResolvedValueOnce(existing([{ id: 'A', status: 'released' }])) // tip A still running + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + const result = await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A'), child('B', [])]), + }); + const bw = ddb.send.mock.calls.find((c) => c[0] instanceof BatchWriteCommand)![0]; + const written = (bw.input.RequestItems[TABLE] as Array<{ PutRequest: { Item: { depends_on: string[]; child_status: string } } }>)[0].PutRequest.Item; + expect(written.depends_on).toEqual(['A']); // stacked on the tip + expect(written.child_status).toBe('blocked'); + expect(result.releasableSubIssueIds).toEqual([]); + }); + + test('new unconstrained node on a fan-out epic → diamond implicit deps (all leaves)', async () => { + const ddb = makeDdb(); + ddb.send + .mockResolvedValueOnce(existing([ + { id: 'R', status: 'succeeded' }, + { id: 'B', deps: ['R'], status: 'succeeded' }, + { id: 'C', deps: ['R'], status: 'succeeded' }, // B and C are both leaves + ])) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + const result = await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('R'), child('B', ['R']), child('C', ['R']), child('D', [])]), + }); + const bw = ddb.send.mock.calls.find((c) => c[0] instanceof BatchWriteCommand)![0]; + const written = (bw.input.RequestItems[TABLE] as Array<{ PutRequest: { Item: { sub_issue_id: string; depends_on: string[] } } }>)[0].PutRequest.Item; + expect(written.depends_on).toEqual(['B', 'C']); // diamond over both leaves + expect(result.releasableSubIssueIds).toEqual(['D']); // both succeeded + }); + + test('new node WITH an explicit dependency keeps it (user intent wins over the tip)', async () => { + const ddb = makeDdb(); + ddb.send + .mockResolvedValueOnce(existing([ + { id: 'A', status: 'succeeded' }, + { id: 'B', deps: ['A'], status: 'succeeded' }, // tip would be B + ])) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + // New node C explicitly depends on A (not the tip B). + const result = await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A'), child('B', ['A']), child('C', ['A'])]), + }); + const bw = ddb.send.mock.calls.find((c) => c[0] instanceof BatchWriteCommand)![0]; + const written = (bw.input.RequestItems[TABLE] as Array<{ PutRequest: { Item: { depends_on: string[] } } }>)[0].PutRequest.Item; + expect(written.depends_on).toEqual(['A']); // explicit edge preserved, NOT overridden to ['B'] + expect(result.addedSubIssueIds).toEqual(['C']); + }); + + test('no new nodes (graph unchanged) → no-op, no writes', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce(existing([{ id: 'A', status: 'succeeded' }])); + const result = await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A')]), + }); + expect(result.addedSubIssueIds).toEqual([]); + // Only the load Query ran — no BatchWrite/Update. + expect(ddb.send.mock.calls.filter((c) => c[0] instanceof BatchWriteCommand)).toHaveLength(0); + expect(ddb.send.mock.calls.filter((c) => c[0] instanceof UpdateCommand)).toHaveLength(0); + }); + + test('a new edge that introduces a CYCLE → rejected, nothing written', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce(existing([ + { id: 'A', status: 'succeeded' }, { id: 'B', deps: ['A'], status: 'succeeded' }, + ])); + // New node C depends on B, but the augmented graph also makes A depend on C → cycle. + const result = await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A', ['C']), child('B', ['A']), child('C', ['B'])]), + }); + expect(result.rejected?.reason).toBe('cycle'); + expect(result.addedSubIssueIds).toEqual([]); + expect(ddb.send.mock.calls.filter((c) => c[0] instanceof BatchWriteCommand)).toHaveLength(0); + }); + + test('no existing orchestration (load returns nothing) → empty result', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Items: [] }); // loadOrchestration → null + const result = await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A')]), + }); + expect(result.addedSubIssueIds).toEqual([]); + }); + + test('bumps meta child_count by the number of added nodes', async () => { + const ddb = makeDdb(); + ddb.send + .mockResolvedValueOnce(existing([{ id: 'A', status: 'succeeded' }, { id: 'B', deps: ['A'], status: 'succeeded' }])) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A'), child('B', ['A']), child('C', ['A']), child('D', ['B'])]), + }); + const upd = ddb.send.mock.calls.find((c) => c[0] instanceof UpdateCommand)![0]; + // 2 existing + 2 new (C, D) = 4. + expect(upd.input.ExpressionAttributeValues[':n']).toBe(4); + }); + + test('clears rollup_posted_at so an epic extended after completion can roll up again', async () => { + const ddb = makeDdb(); + ddb.send + .mockResolvedValueOnce(existing([{ id: 'A', status: 'succeeded' }])) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A'), child('B', [])]), + }); + const upd = ddb.send.mock.calls.find((c) => c[0] instanceof UpdateCommand)![0]; + // The meta update REMOVEs rollup_posted_at so the reconciler can re-claim + // and re-settle the parent state when the added node finishes. + expect(upd.input.UpdateExpression).toContain('REMOVE rollup_posted_at'); + }); +}); diff --git a/cdk/test/handlers/shared/screenshot-url.test.ts b/cdk/test/handlers/shared/screenshot-url.test.ts index a02d4f1dc..3e6461423 100644 --- a/cdk/test/handlers/shared/screenshot-url.test.ts +++ b/cdk/test/handlers/shared/screenshot-url.test.ts @@ -17,7 +17,28 @@ * SOFTWARE. */ -import { buildScreenshotKey, encodeMarkdownUrl, isAllowedScreenshotUrl } from '../../../src/handlers/shared/screenshot-url'; +import { buildScreenshotKey, encodeMarkdownUrl, extractTaskIdFromBranch, isAllowedScreenshotUrl } from '../../../src/handlers/shared/screenshot-url'; + +describe('extractTaskIdFromBranch (#247 — screenshot → parent panel)', () => { + test('pulls the taskId from a standard ABCA branch (2nd segment)', () => { + expect(extractTaskIdFromBranch('bgagent/01TASKID123/abca-300-book-with-points')) + .toBe('01TASKID123'); + }); + test('tolerates extra trailing segments (taskId is always 2nd)', () => { + expect(extractTaskIdFromBranch('bgagent/01TASKID123/abca-300/extra')).toBe('01TASKID123'); + }); + test('null for a non-ABCA branch (human / fork default / too few segments)', () => { + expect(extractTaskIdFromBranch('main')).toBeNull(); + expect(extractTaskIdFromBranch('feature/foo')).toBeNull(); + expect(extractTaskIdFromBranch('bgagent')).toBeNull(); + expect(extractTaskIdFromBranch('bgagent//slug')).toBeNull(); // empty taskId + }); + test('null for empty / nullish', () => { + expect(extractTaskIdFromBranch('')).toBeNull(); + expect(extractTaskIdFromBranch(undefined)).toBeNull(); + expect(extractTaskIdFromBranch(null)).toBeNull(); + }); +}); describe('buildScreenshotKey', () => { test('produces a screenshots/<owner>_<repo>/<sha>-<id>-<suffix>.png shape', () => { diff --git a/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts b/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts index d3b6a0d01..db474ddd3 100644 --- a/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts +++ b/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts @@ -86,8 +86,14 @@ describe('EcsComputeStrategy', () => { const call = mockSend.mock.calls[0][0]; expect(call.input.cluster).toBe(CLUSTER_ARN); - expect(call.input.taskDefinition).toBe(TASK_DEF_ARN); + // Dispatch against the task-def FAMILY, not the pinned revision, so a deploy + // that deregisters the old revision can't strand the task ("TaskDefinition is + // inactive", ABCA-660/663). TASK_DEF_ARN ends in `agent:1` → family `agent`. + expect(call.input.taskDefinition).toBe('agent'); expect(call.input.launchType).toBe('FARGATE'); + // review #5c: clientToken = taskId dedups a double-RunTask (client-side + // timeout on a launch that actually succeeded → auto-retry re-fires). + expect(call.input.clientToken).toBe('TASK001'); expect(call.input.networkConfiguration.awsvpcConfiguration.subnets).toEqual(['subnet-aaa', 'subnet-bbb']); expect(call.input.networkConfiguration.awsvpcConfiguration.securityGroups).toEqual(['sg-12345']); expect(call.input.networkConfiguration.awsvpcConfiguration.assignPublicIp).toBe('DISABLED'); @@ -118,6 +124,24 @@ describe('EcsComputeStrategy', () => { expect(override.command[0]).toBe('python'); }); + test('readOnly falls back to the build def when no planning def is wired (older deploy — never worse)', async () => { + // The top-of-file import has NO ECS_PLANNING_TASK_DEFINITION_ARN, so even a + // read-only workflow must run on the build def (pre-rightsize behavior). + mockSend.mockResolvedValueOnce({ tasks: [{ taskArn: TASK_ARN }] }); + + const strategy = new EcsComputeStrategy(); + await strategy.startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: { repo_url: 'org/repo' }, + blueprintConfig: { compute_type: 'ecs', runtime_arn: '' }, + readOnly: true, + }); + + // No planning def wired → build def, resolved to its family (`agent`). + expect(mockSend.mock.calls[0][0].input.taskDefinition).toBe('agent'); + }); + test('throws when RunTask returns no task', async () => { mockSend.mockResolvedValueOnce({ tasks: [], @@ -459,6 +483,71 @@ describe('EcsComputeStrategy with ECS_PAYLOAD_BUCKET (S3-pointer path, #502)', ( }); }); +// #299 ECS_RIGHTSIZED_PLANNING: the planning task def ARN is a module-level +// constant, so set it BEFORE import via isolateModules (mirrors the #502 bucket +// pattern above) — this keeps it out of the inline tests at the top. +describe('EcsComputeStrategy read-only planning-def selection (#299 ECS_RIGHTSIZED_PLANNING)', () => { + const PLANNING_DEF_ARN = 'arn:aws:ecs:us-east-1:123456789012:task-definition/agent-planning:1'; + + function loadStrategyWithPlanningDef(): typeof import('../../../../src/handlers/shared/strategies/ecs-strategy').EcsComputeStrategy { + let Strategy!: typeof import('../../../../src/handlers/shared/strategies/ecs-strategy').EcsComputeStrategy; + jest.isolateModules(() => { + process.env.ECS_CLUSTER_ARN = CLUSTER_ARN; + process.env.ECS_TASK_DEFINITION_ARN = TASK_DEF_ARN; + process.env.ECS_PLANNING_TASK_DEFINITION_ARN = PLANNING_DEF_ARN; + process.env.ECS_SUBNETS = 'subnet-aaa,subnet-bbb'; + process.env.ECS_SECURITY_GROUP = 'sg-12345'; + process.env.ECS_CONTAINER_NAME = 'AgentContainer'; + // eslint-disable-next-line @typescript-eslint/no-require-imports + Strategy = require('../../../../src/handlers/shared/strategies/ecs-strategy').EcsComputeStrategy; + }); + return Strategy; + } + + afterEach(() => { + delete process.env.ECS_PLANNING_TASK_DEFINITION_ARN; + }); + + test('a read-only workflow runs on the PLANNING def', async () => { + mockSend.mockResolvedValueOnce({ tasks: [{ taskArn: TASK_ARN }] }); + const Strategy = loadStrategyWithPlanningDef(); + await new Strategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: { repo_url: 'org/repo' }, + blueprintConfig: { compute_type: 'ecs', runtime_arn: '' }, + readOnly: true, + }); + // read-only → planning def, resolved to its family (`agent-planning`). + expect(mockSend.mock.calls[0][0].input.taskDefinition).toBe('agent-planning'); + }); + + test('a non-read-only workflow still runs on the BUILD def even when a planning def is wired', async () => { + mockSend.mockResolvedValueOnce({ tasks: [{ taskArn: TASK_ARN }] }); + const Strategy = loadStrategyWithPlanningDef(); + await new Strategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: { repo_url: 'org/repo' }, + blueprintConfig: { compute_type: 'ecs', runtime_arn: '' }, + readOnly: false, + }); + expect(mockSend.mock.calls[0][0].input.taskDefinition).toBe('agent'); + }); + + test('omitting readOnly defaults to the BUILD def', async () => { + mockSend.mockResolvedValueOnce({ tasks: [{ taskArn: TASK_ARN }] }); + const Strategy = loadStrategyWithPlanningDef(); + await new Strategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: { repo_url: 'org/repo' }, + blueprintConfig: { compute_type: 'ecs', runtime_arn: '' }, + }); + expect(mockSend.mock.calls[0][0].input.taskDefinition).toBe('agent'); + }); +}); + describe('deleteEcsPayload without ECS_PAYLOAD_BUCKET', () => { test('no-ops when no payload bucket is configured', async () => { // The top-of-file import has no ECS_PAYLOAD_BUCKET set. diff --git a/cdk/test/handlers/shared/validation.test.ts b/cdk/test/handlers/shared/validation.test.ts index fc4bd8952..2b82b6b96 100644 --- a/cdk/test/handlers/shared/validation.test.ts +++ b/cdk/test/handlers/shared/validation.test.ts @@ -35,6 +35,9 @@ import { parseBody, parseLimit, parseStatusFilter, + EXTENSION_TO_MIME, + MIME_TO_EXTENSION, + SUPPORTED_ATTACHMENT_EXTENSIONS_LABEL, validateAttachments, validateMagicBytes, validateMaxBudgetUsd, @@ -699,16 +702,27 @@ describe('validateMagicBytes', () => { expect(validateMagicBytes(binary, 'text/plain')).toBe(false); }); - test('rejects a text type whose bytes are not decodable UTF-8', () => { - // 0xC3 starts a 2-byte sequence but 0x28 is not a valid continuation byte, and - // 0xFF never appears in UTF-8 at all. Neither carries a NUL, so the null-byte - // scan below would pass them — only the decode catches this. - expect(validateMagicBytes(Buffer.from([0x48, 0xC3, 0x28]), 'text/plain')).toBe(false); - expect(validateMagicBytes(Buffer.from([0xFF, 0xFE, 0x41]), 'application/json')).toBe(false); + test('rejects INVALID UTF-8 masquerading as text (finding #3)', () => { + // A lone 0xFF / 0x80 continuation byte is not valid UTF-8 — the old null-only + // check let these through; the real decoder now rejects them. + expect(validateMagicBytes(Buffer.from([0xff, 0xfe, 0x80, 0x81]), 'text/plain')).toBe(false); + expect(validateMagicBytes(Buffer.from([0xc3, 0x28]), 'application/json')).toBe(false); // invalid 2-byte seq + }); + + test('accepts valid multi-byte UTF-8 text (emoji / accents)', () => { + expect(validateMagicBytes(Buffer.from('café — 日本語 ✅', 'utf-8'), 'text/markdown')).toBe(true); + }); + + test('does not false-reject a multi-byte char that straddles the old 8 KB cutoff', () => { + // 8191 ASCII bytes + a 3-byte char crossing what used to be the 8192-byte + // cutoff. Decoding the whole buffer sees the complete character, so it must + // pass; a prefix-only decode would have cut it in half and rejected it. + const buf = Buffer.concat([Buffer.alloc(8191, 0x61), Buffer.from('本', 'utf-8')]); + expect(validateMagicBytes(buf, 'text/plain')).toBe(true); }); test('a long ASCII preamble does not launder trailing binary', () => { - // The check used to look at only the first 8 KB, so a benign preamble longer + // The check once looked at only the first 8 KB, so a benign preamble longer // than that let arbitrary bytes through behind it. Validate the whole buffer. const payload = Buffer.concat([ Buffer.from('A'.repeat(9000)), @@ -717,11 +731,6 @@ describe('validateMagicBytes', () => { expect(validateMagicBytes(payload, 'text/plain')).toBe(false); }); - test('accepts multi-byte UTF-8 text (not just ASCII)', () => { - // The decode must not reject legitimate non-ASCII text. - expect(validateMagicBytes(Buffer.from('héllo — 世界 🎉', 'utf8'), 'text/plain')).toBe(true); - }); - test('rejects mismatched signatures', () => { const jpeg = Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]); expect(validateMagicBytes(jpeg, 'image/png')).toBe(false); @@ -812,3 +821,36 @@ describe('createAttachmentRecord', () => { expect(record.token_estimate).toBeUndefined(); }); }); + +describe('attachment type maps (derived, single source of truth)', () => { + test('EXTENSION_TO_MIME is the reverse of the allowlist + covers the .jpeg alias', () => { + expect(EXTENSION_TO_MIME.pdf).toBe('application/pdf'); + expect(EXTENSION_TO_MIME.png).toBe('image/png'); + expect(EXTENSION_TO_MIME.jpg).toBe('image/jpeg'); + expect(EXTENSION_TO_MIME.jpeg).toBe('image/jpeg'); // alias + expect(EXTENSION_TO_MIME.log).toBe('text/x-log'); + // Unsupported extensions resolve to nothing. + expect(EXTENSION_TO_MIME.docx).toBeUndefined(); + expect(EXTENSION_TO_MIME.zip).toBeUndefined(); + }); + + test('SUPPORTED_ATTACHMENT_EXTENSIONS_LABEL is a human list derived from the allowlist (incl. JPEG alias)', () => { + const label = SUPPORTED_ATTACHMENT_EXTENSIONS_LABEL; + // JPEG alias is listed (design nit): a user with a .jpeg file sees it's supported. + for (const ext of ['PNG', 'JPG', 'JPEG', 'TXT', 'CSV', 'MD', 'JSON', 'PDF', 'LOG']) { + expect(label).toContain(ext); + } + // Derived + upper-cased, comma-separated, no unsupported types leak in. + expect(label).not.toMatch(/docx|zip/i); + }); + + test('every MIME in MIME_TO_EXTENSION is actually allowed by isAllowedMimeType (design concern)', () => { + // Guards against the allowlist + the extension map drifting: the rejection + // message (derived from the map) must never advertise a type that + // isAllowedMimeType still rejects. + for (const mime of Object.keys(MIME_TO_EXTENSION)) { + const kind = mime.startsWith('image/') ? 'image' : 'file'; + expect(isAllowedMimeType(mime, kind)).toBe(true); + } + }); +}); 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]), diff --git a/cdk/test/stacks/agent.test.ts b/cdk/test/stacks/agent.test.ts index 9319c3f53..09f79c8a3 100644 --- a/cdk/test/stacks/agent.test.ts +++ b/cdk/test/stacks/agent.test.ts @@ -523,12 +523,69 @@ describe('AgentStack with the ECS substrate gate (--context compute_type=ecs)', template = Template.fromStack(stack); }); - test('provisions an ECS cluster + Fargate task definition', () => { + test('provisions an ECS cluster + both Fargate task definitions (build + planning)', () => { template.resourceCountIs('AWS::ECS::Cluster', 1); - template.resourceCountIs('AWS::ECS::TaskDefinition', 1); + // Two task defs: the large build def and the smaller read-only planning def + // (read-only workflows run on the planning def so a clone-and-read task + // doesn't allocate the full build task's CPU/memory). + template.resourceCountIs('AWS::ECS::TaskDefinition', 2); }); test('outputs ComputeSubstrate=ecs so the CLI allows compute_type=ecs onboarding', () => { template.hasOutput('ComputeSubstrate', { Value: 'ecs' }); }); + + test('the orchestrator gets the PLANNING task-def ARN, not just the build one', () => { + // Without this env var the ECS strategy's `readOnly && + // ECS_PLANNING_TASK_DEFINITION_ARN` guard is always falsy, so the planning + // def would be synthesized, billed for, and never receive a workflow — the + // feature inert while looking present in the template. + // + // This has to be asserted at the STACK level. The strategy's own unit tests + // set the env var by hand to exercise the routing branch, so they pass + // whether or not anything in the stack actually supplies it; only synth can + // tell us the wiring exists. Both ARNs are asserted together because the bug + // this pins is one being present without the other. + const envs = Object.values( + template.findResources('AWS::Lambda::Function'), + ).map(fn => fn.Properties?.Environment?.Variables ?? {}); + const orchestrator = envs.filter(e => 'ECS_TASK_DEFINITION_ARN' in e); + expect(orchestrator).toHaveLength(1); + expect(orchestrator[0]).toHaveProperty('ECS_PLANNING_TASK_DEFINITION_ARN'); + // ...and the two must be DIFFERENT task defs, or read-only workflows are + // silently running on the build box anyway. + expect(orchestrator[0].ECS_PLANNING_TASK_DEFINITION_ARN) + .not.toEqual(orchestrator[0].ECS_TASK_DEFINITION_ARN); + }); + + test('build-task sizing is reachable from deploy context, not only from the construct', () => { + // The construct's default is deliberately modest so an adopter who changes + // nothing does not pay for the Fargate ceiling. That is only defensible if a + // heavy monorepo can RAISE it without editing the construct — and `taskSizing` + // had no caller at all, so the ceiling was unreachable by any supported route. + // This asserts the whole path: context -> resolver -> construct -> template. + const app = new App({ + context: { + compute_type: 'ecs', + ecsBuildTaskCpu: '16384', + ecsBuildTaskMemoryMiB: '122880', + ecsBuildTaskEphemeralStorageGiB: '100', + }, + }); + const sized = Template.fromStack(new AgentStack(app, 'SizedEcsStack', { + env: { account: '123456789012', region: 'us-east-1' }, + })); + sized.hasResourceProperties('AWS::ECS::TaskDefinition', { + Cpu: '16384', + Memory: '122880', + EphemeralStorage: { SizeInGiB: 100 }, + }); + }); + + test('the DEFAULT ECS build task stays modest', () => { + template.hasResourceProperties('AWS::ECS::TaskDefinition', { + Cpu: '4096', + Memory: '16384', + }); + }); }); diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts index aac4fe0a6..57e4d0ac3 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -113,12 +113,18 @@ export function renderLinearAppTemplate(opts: LinearAppTemplateOptions = {}): st '', 'Click Save, copy the Client ID and Client Secret, then return here.', '', - 'Why these specific fields:', - ' • GitHub username with [bot] suffix gates the actor=app agent flow.', - ' Without it, Linear surfaces a misleading "Invalid redirect_uri" error.', + 'Non-obvious gotchas (Linear explains the fields themselves inline):', + ' • GitHub username is REQUIRED for actor=app — leaving it blank surfaces a', + ' misleading "Invalid redirect_uri" error, not a "missing username" one.', ' • Webhooks toggle must be ON for the same reason; the URL value is unused', ' by the OAuth dance and can be a placeholder.', ' • Wildcard callback URLs are not accepted by Linear; list each URL fully.', + ' • Do NOT enable Linear "agent" / app-notification events on this app. ABCA', + ' is a COMMENT-based integration (it replies + reacts on ordinary comments).', + ' With agent events on, Linear renders an @mention of the app as its', + ' interactive agent-activity surface instead of a comment thread, which', + ' breaks the reply/reaction UX. Leave agent/app events OFF; the trigger comes', + ' from the workspace webhook (Issues + Comments), configured separately next.', bar, ].join('\n'); } @@ -373,7 +379,9 @@ export function makeLinearCommand(): Command { console.log('In Linear → Settings → API → Webhooks → + New webhook, paste:'); console.log(); console.log(` URL: ${webhookUrl}`); - console.log(' Resource types: Issues'); + console.log(' Resource types: Issues, Comments'); + console.log(' (Issues = label-triggered tasks + epic orchestration;'); + console.log(' Comments = @bgagent re-iteration on a sub-issue PR)'); console.log(' Team: (whichever team owns the projects you map)'); console.log(); console.log('Save, then open the webhook detail page and copy the signing secret'); @@ -446,6 +454,7 @@ export function makeLinearCommand(): Command { .option('--client-secret <secret>', 'Linear OAuth app Client Secret (else prompted; prefer interactive)') .option('--no-browser', 'Print the authorization URL instead of opening a browser (for SSH/headless)') .option('--no-actor-app', 'Drop actor=app from the OAuth flow (diagnostic: isolates whether agent-install is blocking)') + .option('--no-force-consent', 'Omit prompt=consent (diagnostic: restores the pre-fix behaviour that dead-ends on an already-installed app)') .action(async (slug: string, opts) => { if (!SLUG_RE.test(slug)) { throw new CliError( @@ -524,6 +533,11 @@ export function makeLinearCommand(): Command { state, codeChallenge: pkce.codeChallenge, actorApp: useActorApp, + // Always force the consent screen. A FRESH install shows it anyway, so + // this only changes the already-installed case — which without it + // dead-ends on "already installed" and returns no code, making a + // revoked-but-installed workspace unrecoverable by this command. + forceConsent: opts.forceConsent !== false, }); if (!useActorApp) { console.log(' ⚠ --no-actor-app: dropping actor=app for diagnosis. Token will not be agent-scoped.'); @@ -610,13 +624,14 @@ export function makeLinearCommand(): Command { // first) — that silently breaks signature verification (401 "Invalid // signature") for every workspace after the first in a multi-workspace // deployment. Rotation stays the job of `update-webhook-secret`. - // Fail CLOSED (#612 review B1): read this workspace's existing signing - // secret before the overwrite. Only ResourceNotFoundException is a clean - // first-install; any other SM error (AccessDenied, KMSAccessDenied, + // Fail CLOSED: read this workspace's existing signing secret before the + // overwrite. Only ResourceNotFoundException is a clean first-install; + // any other Secrets Manager error (AccessDenied, KMSAccessDenied, // Throttling, network) — or a corrupt bundle — must surface, NOT default // to undefined (which would mirror the stack-wide secret over a working - // per-workspace one → the #611 401 clobber, silently, behind a green - // "Setup complete"). Extracted to readExistingWebhookSecret + unit-tested. + // per-workspace one, producing a silent 401 on every webhook delivery + // behind a green "Setup complete"). Extracted to + // readExistingWebhookSecret + unit-tested. let existingWebhookSecret: string | undefined; try { existingWebhookSecret = await readExistingWebhookSecret( @@ -653,8 +668,8 @@ export function makeLinearCommand(): Command { installed_at: now, updated_at: now, installed_by_platform_user_id: cognitoSub, - // Fold the preserved secret into the INITIAL bundle (#612 review N2): - // the OAuth-secret write below then lands the correct bundle in ONE + // Fold the preserved secret into the INITIAL bundle so that + // the OAuth-secret write below lands the correct bundle in ONE // PutSecretValue, and the preserve path skips the later re-write. This // also closes a narrow window — if any fallible step between the two // writes threw, the bundle was left persisted WITHOUT the secret (401 @@ -676,8 +691,8 @@ export function makeLinearCommand(): Command { const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); // Best-effort: fetch team keys so the screenshot processor can - // prefix-route Linear issue lookups (e.g. ABCA-42 → workspace - // owning ABCA) instead of scanning every active workspace. + // prefix-route Linear issue lookups (e.g. ENG-42 → the workspace + // owning the ENG team) instead of scanning every active workspace. const teamKeys = await queryLinearTeamKeys(`Bearer ${tokenResponse.access_token}`); await ddb.send(new PutCommand({ TableName: workspaceRegistryTable!, @@ -728,9 +743,10 @@ export function makeLinearCommand(): Command { // • preserve — this workspace ALREADY has its own `lin_wh_` // secret (a re-run). Keep it; do NOT overwrite from // the stack-wide fallback, which is a DIFFERENT - // workspace's secret once >1 is installed. This is - // the #611 clobber fix — the stack-wide value is NOT - // necessarily this workspace's. + // workspace's secret once >1 is installed. The + // stack-wide value is NOT necessarily this + // workspace's, so overwriting from it breaks + // signature verification for this workspace. // • mirror-stackwide — no per-workspace secret yet but stack-wide is // set: mirror it (correct for the first/only // workspace; warn for an additional one). @@ -851,6 +867,7 @@ export function makeLinearCommand(): Command { .option('--stack-name <name>', 'CloudFormation stack name', 'backgroundagent-dev') .option('--no-browser', 'Print the authorization URL instead of opening a browser (for SSH/headless)') .option('--no-actor-app', 'Drop actor=app from the OAuth flow (diagnostic)') + .option('--no-force-consent', 'Omit prompt=consent (diagnostic)') .action(async (slug: string, opts) => { if (!SLUG_RE.test(slug)) { throw new CliError( @@ -948,6 +965,11 @@ export function makeLinearCommand(): Command { state, codeChallenge: pkce.codeChallenge, actorApp: useActorApp, + // Always force the consent screen. A FRESH install shows it anyway, so + // this only changes the already-installed case — which without it + // dead-ends on "already installed" and returns no code, making a + // revoked-but-installed workspace unrecoverable by this command. + forceConsent: opts.forceConsent !== false, }); if (!useActorApp) { console.log(' ⚠ --no-actor-app: dropping actor=app for diagnosis. Token will not be agent-scoped.'); diff --git a/cli/src/commands/platform.ts b/cli/src/commands/platform.ts index 7fe622eb3..eecd4e225 100644 --- a/cli/src/commands/platform.ts +++ b/cli/src/commands/platform.ts @@ -19,6 +19,7 @@ import { Command } from 'commander'; import { CliError } from '../errors'; +import { makeLinearRefreshVerifier } from '../linear-auth-health'; import { DEFAULT_STACK_NAME, resolveOperatorContext } from '../operator-context'; import { doctorChecksPassed, runPlatformDoctor } from '../platform-doctor'; import { listStackOutputs } from '../stack-outputs'; @@ -90,9 +91,19 @@ export function makePlatformCommand(): Command { .option('--region <region>', 'AWS region (defaults to configured region or AWS_REGION)') .option('--stack-name <name>', 'CloudFormation stack name', DEFAULT_STACK_NAME) .option('--output <format>', 'Output format: text or json', 'text') + .option( + '--verify-refresh', + 'Settle any INDETERMINATE Linear workspace by attempting its token refresh. ' + + 'Rotates and re-saves that workspace\'s token (what the platform does on every event), ' + + 'so it is safe but state-changing — hence opt-in.', + ) .action(async (opts) => { const { region, stackName } = resolveOperatorContext(opts); - const results = await runPlatformDoctor({ region, stackName }); + const results = await runPlatformDoctor({ + region, + stackName, + ...(opts.verifyRefresh && { linearVerifyRefresh: makeLinearRefreshVerifier(region) }), + }); const passed = doctorChecksPassed(results); if (opts.output === 'json') { diff --git a/cli/src/linear-auth-health.ts b/cli/src/linear-auth-health.ts new file mode 100644 index 000000000..2c84d8cc2 --- /dev/null +++ b/cli/src/linear-auth-health.ts @@ -0,0 +1,372 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Is each onboarded Linear workspace's OAuth authorization still usable? + * + * Why this needs its own check: when a workspace's authorization dies, the + * platform goes SILENT rather than loud. The webhook processor can't resolve a + * token, logs "workspace not resolvable — dropping event", and returns — so a + * user applies the trigger label and gets nothing at all: no comment, no + * reaction, no state change. The only evidence is a CloudWatch line nobody is + * watching. Observed 2026-07-25: a workspace's authorization was revoked + * upstream and every event silently dropped for over an hour. + * + * The distinction this check exists to make: an EXPIRED access token is normal + * and self-healing (the resolver refreshes it on the next call), whereas a + * REVOKED authorization needs a human to re-authorize. Both look like "auth + * broken" from the outside, so reporting "expired" as a failure would cry wolf + * on every idle workspace, and reporting "revoked" as fine would hide a total + * outage. So we ask the surface, and only treat a rejected REFRESH as a failure. + * + * Read-only: this never consumes a refresh token (which would rotate it and + * disrupt the very thing being diagnosed). It probes with the stored access + * token, and only when that is rejected does it distinguish expiry from + * revocation using the locally-known expiry. + */ + +import { + GetSecretValueCommand, + PutSecretValueCommand, + SecretsManagerClient, +} from '@aws-sdk/client-secrets-manager'; +import { ScanCommand } from '@aws-sdk/lib-dynamodb'; +import { documentClient } from './dynamo-clients'; +import { verifyLinearRefreshAndPersist } from './linear-oauth'; + +/** Linear's GraphQL endpoint — a cheap authenticated probe target. */ +const LINEAR_GRAPHQL_ENDPOINT = 'https://api.linear.app/graphql'; + +/** Smallest authenticated query that proves a token is live. */ +const VIEWER_PROBE_QUERY = '{ viewer { id } }'; + +/** How each workspace's authorization looks right now. */ +export type LinearAuthState = + /** Access token accepted — the workspace is fully working. */ + | 'active' + /** + * INDETERMINATE, and deliberately never reported as healthy. The access token + * is rejected and expired and a refresh token is stored — which is BOTH the + * normal idle-workspace shape AND the exact shape of a workspace whose refresh + * token has been revoked (live-caught 2026-07-25: the access token had expired + * ~48 minutes earlier and the refresh token was dead, and this check reported + * it as fine). + * + * The shallow probe cannot separate those two, so it must not pretend to. Pass + * ``verifyRefresh`` to resolve it definitively — see + * {@link CheckLinearAuthOptions.verifyRefresh}. + */ + | 'expired_indeterminate' + /** + * Access token rejected while NOT expired, or no refresh token stored. The + * authorization itself is gone (revoked upstream, app uninstalled) — events + * are being dropped until someone re-authorizes. + */ + | 'revoked' + /** Registry row marked inactive by an operator — dropping events is intended. */ + | 'disabled' + /** Couldn't determine (secret unreadable, network failure). Not a verdict. */ + | 'unknown'; + +export interface LinearWorkspaceAuthHealth { + readonly workspaceId: string; + readonly workspaceSlug: string; + readonly state: LinearAuthState; + /** Operator-facing explanation, including the remedy when there is one. */ + readonly detail: string; +} + +/** Registry row fields this check reads. */ +interface RegistryRow { + readonly linear_workspace_id?: string; + readonly workspace_slug?: string; + readonly oauth_secret_arn?: string; + readonly status?: string; + /** Stamped by the platform when it detected the authorization was dead. */ + readonly revoked_at?: string; + readonly revoked_reason?: string; +} + +/** The stored-secret fields this check reads. Deliberately narrow: the token + * values are used only to make one probe call and are never returned. */ +interface StoredToken { + readonly access_token?: string; + readonly refresh_token?: string; + readonly expires_at?: string; + readonly workspace_slug?: string; +} + +/** Probe outcome, kept separate from the verdict so the mapping is testable. */ +export type ProbeResult = 'accepted' | 'rejected' | 'error'; + +/** + * Outcome of ATTEMPTING the refresh — the only way to settle + * ``expired_indeterminate``. + * + * ``refreshed`` means the grant is alive AND the rotated token was persisted. + * That persistence is not optional: Linear rotates the refresh token on every + * use, so a verifier that refreshes without saving the result destroys the + * workspace's token chain — which is exactly how an ad-hoc probe stranded a + * healthy workspace on 2026-07-25. A verifier that cannot persist must not + * refresh at all. + */ +export type RefreshVerifyResult = 'refreshed' | 'rejected' | 'error'; + +/** + * Attempt the refresh for one workspace and persist the rotation. Injected + * rather than implemented here so this module keeps doing one job (reporting), + * the destructive-if-done-wrong half is written once beside the other OAuth + * write paths, and tests can supply a fake. + */ +export type LinearRefreshVerifier = (workspace: { + readonly workspaceId: string; + readonly oauthSecretArn: string; +}) => Promise<RefreshVerifyResult>; + +/** Ask the surface whether an access token is still accepted. Injectable so + * tests don't reach the network. */ +export type LinearProbe = (accessToken: string) => Promise<ProbeResult>; + +/** Default probe: a minimal authenticated GraphQL query. */ +export const probeLinearAccessToken: LinearProbe = async (accessToken) => { + try { + const response = await fetch(LINEAR_GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` }, + body: JSON.stringify({ query: VIEWER_PROBE_QUERY }), + }); + if (response.status === 401 || response.status === 403) return 'rejected'; + if (!response.ok) return 'error'; + // Linear reports auth failures as 200 + an errors array as well as via 401, + // so a body check is needed — treating a 200 as proof would report a dead + // token as healthy. + const body = await response.json() as { data?: { viewer?: { id?: string } } }; + return body?.data?.viewer?.id ? 'accepted' : 'rejected'; + } catch { + return 'error'; + } +}; + +/** True when ``expiresAt`` is in the past (or unparseable, which we treat as + * expired — the resolver does the same, preferring an extra refresh). */ +export function isExpired(expiresAt: string | undefined, now: Date = new Date()): boolean { + if (!expiresAt) return true; + const ts = new Date(expiresAt).getTime(); + if (Number.isNaN(ts)) return true; + return now.getTime() >= ts; +} + +/** + * Map a probe result + stored-token facts to a verdict. Pure, so the + * expired-vs-revoked distinction — the whole point of the check — is testable + * without a network or a live workspace. + */ +export function classifyAuthState( + probe: ProbeResult, + token: { hasRefreshToken: boolean; expiresAt?: string }, + now: Date = new Date(), +): LinearAuthState { + if (probe === 'accepted') return 'active'; + if (probe === 'error') return 'unknown'; + // Rejected. An expired token with a refresh token in hand is the normal + // idle-workspace case and heals itself on the next resolve. + if (isExpired(token.expiresAt, now) && token.hasRefreshToken) return 'expired_indeterminate'; + return 'revoked'; +} + +export interface CheckLinearAuthOptions { + readonly region: string; + readonly registryTableName: string; + /** Injectable for tests. */ + readonly probe?: LinearProbe; + /** + * Supply this to RESOLVE ``expired_indeterminate`` instead of reporting it. + * Omitted by default because it performs a real token rotation: correct and + * non-destructive (it persists the new token, exactly as the platform's own + * refresh does), but a state-changing action an operator should opt into. + */ + readonly verifyRefresh?: LinearRefreshVerifier; + readonly now?: Date; +} + +/** + * Report the authorization health of every workspace in the registry. + * Never throws: a workspace that can't be assessed comes back ``unknown`` so + * one broken row doesn't hide the others. + */ +export async function checkLinearWorkspaceAuth( + options: CheckLinearAuthOptions, +): Promise<LinearWorkspaceAuthHealth[]> { + const { region, registryTableName } = options; + const probe = options.probe ?? probeLinearAccessToken; + const now = options.now ?? new Date(); + + const ddb = documentClient(region); + // Paginate. A single Scan page stops at DynamoDB's 1MB limit, so a registry + // large enough to span pages would silently omit workspaces — and an omitted + // REVOKED workspace is the one case that must never be missed, because the + // report would then read as clean. Any read failure propagates to the caller, + // which reports it as a warn rather than a pass (a partial scan is not a + // clean bill of health). + const rows: RegistryRow[] = []; + let exclusiveStartKey: Record<string, unknown> | undefined; + do { + const page = await ddb.send(new ScanCommand({ + TableName: registryTableName, + ...(exclusiveStartKey && { ExclusiveStartKey: exclusiveStartKey }), + })); + rows.push(...((page.Items ?? []) as RegistryRow[])); + exclusiveStartKey = page.LastEvaluatedKey; + } while (exclusiveStartKey); + + const sm = new SecretsManagerClient({ region }); + const out: LinearWorkspaceAuthHealth[] = []; + + for (const row of rows) { + const workspaceId = row.linear_workspace_id ?? '(unknown)'; + const slug = row.workspace_slug ?? workspaceId; + + if (row.status === 'revoked') { + // The platform itself recorded this when a refresh was rejected, which is + // the authoritative signal — more reliable than anything this check can + // infer from a token, and it carries when it happened. + out.push({ + workspaceId, + workspaceSlug: slug, + state: 'revoked', + detail: describeState('revoked', undefined, row.revoked_at), + }); + continue; + } + if (row.status && row.status !== 'active') { + out.push({ + workspaceId, + workspaceSlug: slug, + state: 'disabled', + detail: `Registry row status is '${row.status}' — events for this workspace are dropped by design.`, + }); + continue; + } + if (!row.oauth_secret_arn) { + out.push({ + workspaceId, + workspaceSlug: slug, + state: 'revoked', + detail: 'Registry row has no oauth_secret_arn. Re-run `bgagent linear setup` for this workspace.', + }); + continue; + } + + let stored: StoredToken | null = null; + try { + const secret = await sm.send(new GetSecretValueCommand({ SecretId: row.oauth_secret_arn })); + stored = secret.SecretString ? JSON.parse(secret.SecretString) as StoredToken : null; + } catch (err) { + out.push({ + workspaceId, + workspaceSlug: slug, + state: 'unknown', + detail: `Could not read the OAuth secret: ${err instanceof Error ? err.message : String(err)}`, + }); + continue; + } + + if (!stored?.access_token) { + out.push({ + workspaceId, + workspaceSlug: slug, + state: 'revoked', + detail: 'Stored OAuth secret has no access token. Re-run `bgagent linear setup` for this workspace.', + }); + continue; + } + + let state = classifyAuthState( + await probe(stored.access_token), + { hasRefreshToken: Boolean(stored.refresh_token), expiresAt: stored.expires_at }, + now, + ); + + // Resolve the one state the shallow probe cannot decide. Only attempted when + // the operator opted in, and only for that state — never for a workspace + // already known active or revoked, so a healthy grant is never rotated just + // to produce a report. + if (state === 'expired_indeterminate' && options.verifyRefresh) { + const verified = await options.verifyRefresh({ + workspaceId, + oauthSecretArn: row.oauth_secret_arn, + }); + // A verifier error leaves the state indeterminate rather than guessing: it + // must not turn a transient network failure into a "revoked" verdict that + // sends an operator to re-authorize a working workspace. + if (verified === 'refreshed') state = 'active'; + else if (verified === 'rejected') state = 'revoked'; + } + + out.push({ + workspaceId, + workspaceSlug: stored.workspace_slug ?? slug, + state, + detail: describeState(state, stored.expires_at), + }); + } + + return out; +} + +/** Operator-facing wording per state, remedy included where one applies. */ +function describeState(state: LinearAuthState, expiresAt?: string, revokedAt?: string): string { + switch (state) { + case 'active': + return `Authorization is live (access token valid until ${expiresAt ?? 'unknown'}).`; + case 'expired_indeterminate': + return 'INDETERMINATE — the access token has expired. This is what a healthy idle workspace looks ' + + 'like AND what a revoked one looks like; the two are indistinguishable without attempting the ' + + 'refresh. Re-run with `--verify-refresh` for a definitive answer (it performs the same refresh ' + + 'the platform would, and persists the rotated token).'; + case 'revoked': + return `Authorization was REVOKED${revokedAt ? ` at ${revokedAt}` : ''} — the platform cannot post ` + + 'to this workspace and is dropping its events. Re-authorize with `bgagent linear setup` ' + + '(or `bgagent linear add-workspace` for an additional workspace).'; + case 'disabled': + return 'Workspace is disabled in the registry.'; + case 'unknown': + default: + return 'Could not determine authorization state.'; + } +} + +/** + * The production {@link LinearRefreshVerifier}: binds Secrets Manager to + * {@link verifyLinearRefreshAndPersist}, which owns the refresh-and-save + * sequencing (and the rule that a rotation which wasn't persisted is reported as + * an error, never as health). + */ +export function makeLinearRefreshVerifier(region: string): LinearRefreshVerifier { + const sm = new SecretsManagerClient({ region }); + return async ({ oauthSecretArn }) => verifyLinearRefreshAndPersist({ + readSecret: async () => { + const res = await sm.send(new GetSecretValueCommand({ SecretId: oauthSecretArn })); + return res.SecretString; + }, + writeSecret: async (secretString) => { + await sm.send(new PutSecretValueCommand({ SecretId: oauthSecretArn, SecretString: secretString })); + }, + }); +} diff --git a/cli/src/linear-oauth.ts b/cli/src/linear-oauth.ts index 30a77c942..27906fba3 100644 --- a/cli/src/linear-oauth.ts +++ b/cli/src/linear-oauth.ts @@ -159,6 +159,14 @@ export function generatePkce(): { codeVerifier: string; codeChallenge: string } /** * Build the Linear authorization URL the CLI opens in the browser. * `actorApp: true` adds `actor=app` (the Agent install variant). + * + * ``forceConsent`` adds ``prompt=consent``, which Linear documents as showing + * the consent screen "every time, even if all scopes were previously granted". + * That is what makes RE-authorization possible: without it, an app already + * installed in the workspace short-circuits with "already installed" and never + * returns an authorization code — so the one command meant to recover a revoked + * authorization could not recover it (live-caught 2026-07-25, where a workspace's + * grant was revoked while the app install stayed active). */ export function buildAuthorizationUrl(opts: { clientId: string; @@ -167,6 +175,7 @@ export function buildAuthorizationUrl(opts: { codeChallenge: string; scopes?: readonly string[]; actorApp?: boolean; + forceConsent?: boolean; }): string { const params = new URLSearchParams({ client_id: opts.clientId, @@ -183,6 +192,9 @@ export function buildAuthorizationUrl(opts: { if (opts.actorApp ?? true) { params.set('actor', 'app'); } + if (opts.forceConsent) { + params.set('prompt', 'consent'); + } return `${LINEAR_AUTHORIZE_ENDPOINT}?${params.toString()}`; } @@ -378,3 +390,77 @@ export async function readExistingWebhookSecret( ? bundle.webhook_signing_secret : undefined; } + +/** + * Attempt a workspace's refresh and PERSIST the rotated token — the only way to + * settle "is this expired token's grant still alive?" definitively. + * + * Persistence is the whole safety property, not a convenience. Linear rotates the + * refresh token on every use, so a caller that refreshes and discards the result + * has silently consumed the workspace's only key: the stored token is now the + * spent one, and the workspace is stranded exactly as if it had been revoked. + * (That is not hypothetical — an ad-hoc probe did it to a healthy workspace on + * 2026-07-25 and it had to be repaired by hand.) So this function refreshes and + * writes in one step, and reports `error` rather than a verdict if the write + * fails, because a rotation that happened but wasn't saved is the dangerous case + * and must not be reported as health. + * + * `readSecret` / `writeSecret` are injected so this stays testable without + * Secrets Manager and so the caller owns client construction. + */ +export async function verifyLinearRefreshAndPersist(args: { + readSecret: () => Promise<string | undefined>; + writeSecret: (secretString: string) => Promise<void>; + fetchImpl?: typeof fetch; + now?: Date; +}): Promise<'refreshed' | 'rejected' | 'error'> { + let stored: StoredLinearOauthToken; + try { + const raw = await args.readSecret(); + if (!raw) return 'error'; + stored = JSON.parse(raw) as StoredLinearOauthToken; + } catch { + return 'error'; + } + if (!stored.refresh_token || !stored.client_id || !stored.client_secret) { + // Nothing to attempt: without these the grant cannot be renewed by anyone, + // which is a real dead end rather than an inconclusive probe. + return 'rejected'; + } + + let refreshed; + try { + refreshed = await refreshAccessToken({ + refreshToken: stored.refresh_token, + clientId: stored.client_id, + clientSecret: stored.client_secret, + ...(args.fetchImpl && { fetchImpl: args.fetchImpl }), + }); + } catch (err) { + // Only Linear's own `invalid_grant` proves the grant is dead. A network + // blip, a 5xx, or a malformed body must NOT be reported as revoked — that + // would send an operator to re-authorize a working workspace. + const message = err instanceof Error ? err.message : String(err); + return message.includes('invalid_grant') ? 'rejected' : 'error'; + } + + const now = args.now ?? new Date(); + const next: StoredLinearOauthToken = { + ...stored, + access_token: refreshed.access_token, + // Persist the ROTATED refresh token; re-using the old one always fails. + refresh_token: refreshed.refresh_token ?? stored.refresh_token, + expires_at: computeExpiresAt(refreshed.expires_in, now), + scope: refreshed.scope, + updated_at: now.toISOString(), + }; + try { + await args.writeSecret(JSON.stringify(next)); + } catch { + // The rotation HAPPENED but wasn't saved, so the stored token is now spent. + // Report `error`, never `refreshed`: the operator must see that this needs + // attention rather than a green tick over a workspace we just stranded. + return 'error'; + } + return 'refreshed'; +} diff --git a/cli/src/platform-doctor.ts b/cli/src/platform-doctor.ts index 2d9611601..f7fd80cd7 100644 --- a/cli/src/platform-doctor.ts +++ b/cli/src/platform-doctor.ts @@ -24,6 +24,7 @@ import { DescribeUserPoolCommand, } from '@aws-sdk/client-cognito-identity-provider'; import { isGithubTokenConfigured } from './github-token'; +import { checkLinearWorkspaceAuth, type LinearProbe, type LinearRefreshVerifier } from './linear-auth-health'; import { PLATFORM_REPO_DEFAULTS } from './repo-display'; import { countActiveRepos } from './repo-lookup'; import { getStackOutput } from './stack-outputs'; @@ -51,6 +52,14 @@ export interface DoctorCheckResult { export interface RunPlatformDoctorOptions { readonly region: string; readonly stackName: string; + /** Injectable Linear auth probe (tests supply a fake; production uses the default). */ + readonly linearProbe?: LinearProbe; + /** + * Opt-in resolver for the indeterminate auth state. Absent by default because + * it rotates a real token (safely — it persists the rotation), which an + * operator should choose rather than have a read-only-looking command do. + */ + readonly linearVerifyRefresh?: LinearRefreshVerifier; } /** Smoke-check deployed platform readiness (operator AWS credentials). */ @@ -64,12 +73,14 @@ export async function runPlatformDoctor( appClientId, githubTokenSecretArn, repoTableName, + linearRegistryTableName, ] = await Promise.all([ getStackOutput(region, stackName, 'ApiUrl'), getStackOutput(region, stackName, 'UserPoolId'), getStackOutput(region, stackName, 'AppClientId'), getStackOutput(region, stackName, 'GitHubTokenSecretArn'), getStackOutput(region, stackName, 'RepoTableName'), + getStackOutput(region, stackName, 'LinearWorkspaceRegistryTableName'), ]); const checks: DoctorCheckResult[] = []; @@ -79,6 +90,9 @@ export async function runPlatformDoctor( checks.push(await checkGithubToken(region, githubTokenSecretArn)); checks.push(await checkActiveRepos(region, repoTableName)); checks.push(await checkBedrockModel(region, DEFAULT_BEDROCK_MODEL_ID)); + checks.push(await checkLinearAuth( + region, linearRegistryTableName, options.linearProbe, options.linearVerifyRefresh, + )); return checks; } @@ -232,6 +246,90 @@ async function checkBedrockModel(region: string, modelId: string): Promise<Docto } } +/** + * Linear workspaces whose OAuth authorization has died. This is the one failure + * mode that is otherwise INVISIBLE: the webhook processor can't resolve a token, + * drops the event, and the user sees their label do nothing at all. A revoked + * authorization is a total outage for that workspace, so it fails the check; an + * expired-but-refreshable token is normal and self-healing, so it doesn't. + */ +async function checkLinearAuth( + region: string, + registryTableName: string | null, + probe?: LinearProbe, + verifyRefresh?: LinearRefreshVerifier, +): Promise<DoctorCheckResult> { + const id = 'linear_workspace_auth'; + const label = 'Linear workspace authorizations live'; + if (!registryTableName) { + // Linear is optional — a stack with no Linear integration is not broken. + return { id, label, status: 'pass', detail: 'No Linear workspace registry on this stack (integration not deployed).' }; + } + + try { + const health = await checkLinearWorkspaceAuth({ + region, + registryTableName, + ...(probe && { probe }), + ...(verifyRefresh && { verifyRefresh }), + }); + if (health.length === 0) { + return { id, label, status: 'pass', detail: 'No Linear workspaces onboarded yet.' }; + } + + const revoked = health.filter((w) => w.state === 'revoked'); + // Indeterminate is NOT healthy. It is the exact shape of the workspace whose + // authorization died on 2026-07-25 — expired access token, refresh token + // present but dead — and reporting it as a pass is how that outage stayed + // invisible for over an hour. It is a warn rather than a fail because the + // same shape is also a perfectly healthy idle workspace, and failing every + // quiet workspace would train operators to ignore the check. + const indeterminate = health.filter((w) => w.state === 'expired_indeterminate'); + const unknown = health.filter((w) => w.state === 'unknown'); + const summary = health + .map((w) => `${w.workspaceSlug}=${w.state}`) + .join(', '); + + if (revoked.length > 0) { + const remedies = revoked.map((w) => ` ${w.workspaceSlug}: ${w.detail}`).join('\n'); + return { + id, + label, + status: 'fail', + detail: `${revoked.length} of ${health.length} workspace(s) have a REVOKED authorization — their ` + + `Linear events are being dropped silently.\n${remedies}\n (${summary})`, + }; + } + if (indeterminate.length > 0) { + return { + id, + label, + status: 'warn', + detail: `${indeterminate.length} of ${health.length} workspace(s) could NOT be confirmed ` + + 'authorized — an expired access token looks identical whether the refresh token behind it ' + + 'is alive or revoked. Re-run `bgagent platform doctor --verify-refresh` to settle it ' + + `(that performs the refresh the platform would, and persists the rotated token).\n (${summary})`, + }; + } + if (unknown.length > 0) { + return { + id, + label, + status: 'warn', + detail: `Could not assess ${unknown.length} of ${health.length} workspace(s): ${summary}`, + }; + } + return { id, label, status: 'pass', detail: `${health.length} workspace(s) authorized: ${summary}` }; + } catch (err) { + return { + id, + label, + status: 'warn', + detail: `Could not read the Linear workspace registry: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + /** True when every check passed (warnings are acceptable). */ export function doctorChecksPassed(results: readonly DoctorCheckResult[]): boolean { return results.every((r) => r.status === 'pass' || r.status === 'warn'); diff --git a/cli/test/commands/linear.test.ts b/cli/test/commands/linear.test.ts index db0cf3de0..5bd40d878 100644 --- a/cli/test/commands/linear.test.ts +++ b/cli/test/commands/linear.test.ts @@ -162,6 +162,15 @@ describe('renderLinearAppTemplate', () => { expect(out).toContain('REQUIRED for actor=app'); }); + test('warns against enabling Linear agent / app-notification events (breaks comment-thread UX)', () => { + // ABCA is a comment-based integration; an OAuth app in Linear "agent" mode + // makes @mentions render as interactive agent activity instead of comment + // threads. The template must steer operators away from that toggle. + const out = renderLinearAppTemplate(); + expect(out.toLowerCase()).toContain('agent'); + expect(out).toMatch(/do not enable .*agent/i); + }); + test('defaults the callback URL to the localhost endpoint that setup listens on', () => { // Phase 2.0b-O2 (shipped) uses an ephemeral localhost server during // `bgagent linear setup`. Printing the right URL by default diff --git a/cli/test/linear-auth-health.test.ts b/cli/test/linear-auth-health.test.ts new file mode 100644 index 000000000..073b9dee0 --- /dev/null +++ b/cli/test/linear-auth-health.test.ts @@ -0,0 +1,216 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const ddbSend = jest.fn(); +jest.mock('../src/dynamo-clients', () => ({ + documentClient: () => ({ send: (...a: unknown[]) => ddbSend(...a) }), +})); + +const smSend = jest.fn(); +jest.mock('@aws-sdk/client-secrets-manager', () => ({ + SecretsManagerClient: jest.fn(() => ({ send: (...a: unknown[]) => smSend(...a) })), + GetSecretValueCommand: jest.fn((input: unknown) => ({ _type: 'GetSecret', input })), + PutSecretValueCommand: jest.fn((input: unknown) => ({ _type: 'PutSecret', input })), +})); + +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + ScanCommand: jest.fn((input: unknown) => ({ _type: 'Scan', input })), +})); + +import { checkLinearWorkspaceAuth, classifyAuthState, isExpired } from '../src/linear-auth-health'; + +const NOW = new Date('2026-07-25T13:00:00.000Z'); +const FUTURE = '2026-07-25T14:00:00.000Z'; +const PAST = '2026-07-25T12:00:00.000Z'; + +describe('isExpired', () => { + test('a future expiry is not expired', () => { + expect(isExpired(FUTURE, NOW)).toBe(false); + }); + + test('a past expiry is expired', () => { + expect(isExpired(PAST, NOW)).toBe(true); + }); + + test('absent or unparseable expiry counts as expired', () => { + // Matches the platform resolver: prefer an unnecessary refresh over + // assuming a token nobody can date is still good. + expect(isExpired(undefined, NOW)).toBe(true); + expect(isExpired('not-a-date', NOW)).toBe(true); + }); +}); + +describe('classifyAuthState — expired vs revoked is the point of this check', () => { + test('an accepted token is active', () => { + expect(classifyAuthState('accepted', { hasRefreshToken: true, expiresAt: FUTURE }, NOW)).toBe('active'); + }); + + test('rejected + already expired + a refresh token = INDETERMINATE, never healthy', () => { + // This shape is genuinely ambiguous: it is what a quiet-but-healthy workspace + // looks like AND what a revoked one looks like. The classifier must refuse to + // guess — failing every idle workspace would train operators to ignore the + // check, and passing them hides a real outage. + expect(classifyAuthState('rejected', { hasRefreshToken: true, expiresAt: PAST }, NOW)) + .toBe('expired_indeterminate'); + }); + + test('rejected while NOT expired = the authorization itself is gone', () => { + // A token that the surface refuses even though it should still be valid + // means the grant was revoked upstream — every event is being dropped. + expect(classifyAuthState('rejected', { hasRefreshToken: true, expiresAt: FUTURE }, NOW)) + .toBe('revoked'); + }); + + test('rejected with NO refresh token = revoked, since nothing can recover it', () => { + // Even if it merely expired, without a refresh token there is no path back + // without a human, so it must not be reported as self-healing. + expect(classifyAuthState('rejected', { hasRefreshToken: false, expiresAt: PAST }, NOW)) + .toBe('revoked'); + }); + + test('a probe that could not complete is unknown, never a verdict', () => { + // A network blip must not be reported as a revoked authorization — that + // would send an operator to re-authorize a healthy workspace. + expect(classifyAuthState('error', { hasRefreshToken: true, expiresAt: FUTURE }, NOW)).toBe('unknown'); + expect(classifyAuthState('error', { hasRefreshToken: false, expiresAt: PAST }, NOW)).toBe('unknown'); + }); + + test('the live 2026-07-25 incident is INDETERMINATE from the shallow probe alone', () => { + // The REAL shape, not a convenient variant: the maguireb workspace had a + // refresh token present and only ~25h old, and an access token that had + // expired ~48 minutes earlier. Linear rejected both, but the shallow probe + // only sees the access token, so it CANNOT tell this from a healthy idle + // workspace. Pinning it as `revoked` here (by passing hasRefreshToken:false) + // is what let the real bug hide: the check reported that workspace as needing + // no action while every one of its events was being dropped. The honest + // classification is indeterminate — and `verifyRefresh` is what settles it. + const liveIncident = { hasRefreshToken: true, expiresAt: '2026-07-25T12:12:48.012Z' }; + expect(classifyAuthState('rejected', liveIncident, NOW)).toBe('expired_indeterminate'); + // And critically: indeterminate must NOT be treated as healthy by the caller. + expect(classifyAuthState('rejected', liveIncident, NOW)).not.toBe('active'); + }); +}); + +describe('checkLinearWorkspaceAuth — the real function, end to end', () => { + /** The live-incident secret: refresh token present, access token long expired. */ + const INDETERMINATE_SECRET = JSON.stringify({ + access_token: 'lin_dead', + refresh_token: 'rt-present', + expires_at: '2026-07-25T12:12:48.012Z', + workspace_slug: 'maguireb', + client_id: 'cid', + client_secret: 'sec', + }); + + const row = (extra: Record<string, unknown> = {}) => ({ + linear_workspace_id: 'ws-1', + workspace_slug: 'maguireb', + oauth_secret_arn: 'arn:secret:maguireb', + status: 'active', + ...extra, + }); + + beforeEach(() => { + jest.clearAllMocks(); + smSend.mockResolvedValue({ SecretString: INDETERMINATE_SECRET }); + }); + + test('WITHOUT a verifier the live-incident workspace is indeterminate, not active', async () => { + ddbSend.mockResolvedValue({ Items: [row()] }); + const health = await checkLinearWorkspaceAuth({ + region: 'us-east-1', + registryTableName: 'Reg', + probe: async () => 'rejected', + }); + expect(health).toHaveLength(1); + expect(health[0].state).toBe('expired_indeterminate'); + expect(health[0].detail).toMatch(/INDETERMINATE/); + }); + + test('WITH a verifier that rejects, the same workspace is reported REVOKED', async () => { + // This is the bug the review caught: previously this workspace read as + // "no action needed" while every one of its events was being dropped. + ddbSend.mockResolvedValue({ Items: [row()] }); + const health = await checkLinearWorkspaceAuth({ + region: 'us-east-1', + registryTableName: 'Reg', + probe: async () => 'rejected', + verifyRefresh: async () => 'rejected', + }); + expect(health[0].state).toBe('revoked'); + expect(health[0].detail).toMatch(/REVOKED/); + }); + + test('WITH a verifier that refreshes, it is reported active', async () => { + ddbSend.mockResolvedValue({ Items: [row()] }); + const health = await checkLinearWorkspaceAuth({ + region: 'us-east-1', + registryTableName: 'Reg', + probe: async () => 'rejected', + verifyRefresh: async () => 'refreshed', + }); + expect(health[0].state).toBe('active'); + }); + + test('a verifier ERROR leaves it indeterminate — never a revoked verdict', async () => { + ddbSend.mockResolvedValue({ Items: [row()] }); + const health = await checkLinearWorkspaceAuth({ + region: 'us-east-1', + registryTableName: 'Reg', + probe: async () => 'rejected', + verifyRefresh: async () => 'error', + }); + expect(health[0].state).toBe('expired_indeterminate'); + }); + + test('the verifier is NOT invoked for a workspace already known healthy', async () => { + // Rotating a live workspace's token merely to produce a report would be a + // side-effect nobody asked for. + ddbSend.mockResolvedValue({ Items: [row()] }); + const verify = jest.fn<Promise<'refreshed'>, unknown[]>().mockResolvedValue('refreshed'); + const health = await checkLinearWorkspaceAuth({ + region: 'us-east-1', + registryTableName: 'Reg', + probe: async () => 'accepted', + verifyRefresh: verify as never, + }); + expect(health[0].state).toBe('active'); + expect(verify).not.toHaveBeenCalled(); + }); + + test('the registry scan is PAGINATED — a revoked workspace on page 2 is still reported', async () => { + // Past DynamoDB's 1MB page a single Scan silently truncates, and an omitted + // revoked workspace would make the whole report read as clean. + ddbSend + .mockResolvedValueOnce({ Items: [row({ linear_workspace_id: 'ws-page1' })], LastEvaluatedKey: { k: 'next' } }) + .mockResolvedValueOnce({ Items: [row({ linear_workspace_id: 'ws-page2', status: 'revoked' })] }); + const health = await checkLinearWorkspaceAuth({ + region: 'us-east-1', + registryTableName: 'Reg', + probe: async () => 'accepted', + }); + // Asserted on the workspace id, not the slug: for a live workspace the slug + // is taken from the stored secret, so it would not distinguish the two rows. + expect(health.map((w) => w.workspaceId)).toEqual(['ws-page1', 'ws-page2']); + expect(health[1].state).toBe('revoked'); + // The second Scan must carry the continuation key. + expect((ddbSend.mock.calls[1][0] as { input: Record<string, unknown> }).input.ExclusiveStartKey) + .toEqual({ k: 'next' }); + }); +}); diff --git a/cli/test/linear-oauth.test.ts b/cli/test/linear-oauth.test.ts index c3a682b3b..52780b702 100644 --- a/cli/test/linear-oauth.test.ts +++ b/cli/test/linear-oauth.test.ts @@ -31,6 +31,7 @@ import { readExistingWebhookSecret, refreshAccessToken, resolveWebhookSecretAction, + verifyLinearRefreshAndPersist, } from '../src/linear-oauth'; describe('linearOauthSecretName', () => { @@ -106,6 +107,46 @@ describe('buildAuthorizationUrl', () => { const parsed = new URL(url); expect(parsed.searchParams.has('actor')).toBe(false); }); + + test('forceConsent adds prompt=consent so an already-installed app can RE-authorize', () => { + // Without this, Linear short-circuits an installed app with "already + // installed" and returns no authorization code — so `linear setup`, the + // documented remedy for a revoked authorization, could not actually recover + // one (live-caught 2026-07-25). + const url = buildAuthorizationUrl({ + clientId: 'cid', + redirectUri: 'https://localhost:8443/oauth/callback', + state: 'state-uuid', + codeChallenge: 'challenge', + forceConsent: true, + }); + expect(new URL(url).searchParams.get('prompt')).toBe('consent'); + }); + + test('prompt is omitted unless asked, so the param set stays minimal by default', () => { + const url = buildAuthorizationUrl({ + clientId: 'cid', + redirectUri: 'https://localhost:8443/oauth/callback', + state: 'state-uuid', + codeChallenge: 'challenge', + }); + expect(new URL(url).searchParams.has('prompt')).toBe(false); + }); + + test('forceConsent composes with actor=app — the combination the install path needs', () => { + // The failing case is specifically an actor=app re-install, so both params + // must survive together. + const parsed = new URL(buildAuthorizationUrl({ + clientId: 'cid', + redirectUri: 'https://localhost:8443/oauth/callback', + state: 'state-uuid', + codeChallenge: 'challenge', + actorApp: true, + forceConsent: true, + })); + expect(parsed.searchParams.get('actor')).toBe('app'); + expect(parsed.searchParams.get('prompt')).toBe('consent'); + }); }); describe('isAccessTokenExpiring', () => { @@ -284,6 +325,121 @@ describe('refreshAccessToken', () => { }); }); +describe('verifyLinearRefreshAndPersist', () => { + // This is the only code that can settle "is this workspace's authorization + // actually alive?", and it does so DESTRUCTIVELY: Linear rotates the refresh + // token on every use, so an attempt that isn't persisted spends the stored + // token and strands the workspace. Both halves are pinned here. + + const STORED = JSON.stringify({ + access_token: 'lin_old', + refresh_token: 'lin_refresh_old', + client_id: 'cid', + client_secret: 'csec', + expires_at: '2026-07-25T12:00:00.000Z', + workspace_slug: 'acme', + }); + + const refreshOk = () => jest.fn().mockResolvedValueOnce(mockResponse(200, { + access_token: 'lin_new', + token_type: 'Bearer', + expires_in: 86400, + refresh_token: 'lin_refresh_rotated', + scope: 'read write', + })); + + test('a live grant is refreshed AND the rotated token is persisted', async () => { + const writeSecret = jest.fn().mockResolvedValue(undefined); + const result = await verifyLinearRefreshAndPersist({ + readSecret: async () => STORED, + writeSecret, + fetchImpl: refreshOk() as unknown as typeof fetch, + now: new Date('2026-07-26T10:00:00.000Z'), + }); + + expect(result).toBe('refreshed'); + const saved = JSON.parse(writeSecret.mock.calls[0][0]); + // The rotated refresh token is what makes the NEXT refresh possible; saving + // the old one back would work once and then fail forever. + expect(saved.refresh_token).toBe('lin_refresh_rotated'); + expect(saved.access_token).toBe('lin_new'); + expect(saved.expires_at).toBe('2026-07-27T10:00:00.000Z'); + // Fields it does not own are carried through untouched — the webhook secret + // and client credentials live in this same bundle. + expect(saved.client_secret).toBe('csec'); + expect(saved.workspace_slug).toBe('acme'); + }); + + test('a rotation that could not be saved reports error, never health', async () => { + // The token has already been spent at this point, so a green tick here would + // hide a workspace this very check just broke. + const result = await verifyLinearRefreshAndPersist({ + readSecret: async () => STORED, + writeSecret: async () => { throw new Error('AccessDeniedException on PutSecretValue'); }, + fetchImpl: refreshOk() as unknown as typeof fetch, + }); + expect(result).toBe('error'); + }); + + test("only Linear's invalid_grant is read as revoked", async () => { + const fetchImpl = jest.fn().mockResolvedValueOnce(mockResponse(400, { + error: 'invalid_grant', error_description: 'refresh token was revoked', + })); + const result = await verifyLinearRefreshAndPersist({ + readSecret: async () => STORED, + writeSecret: async () => { throw new Error('must not be called'); }, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(result).toBe('rejected'); + }); + + test('a 5xx or network failure is error — NOT revoked', async () => { + // Reporting revoked here would send an operator to re-authorize a perfectly + // healthy workspace because Linear had a bad minute. + for (const failing of [ + jest.fn().mockResolvedValueOnce(mockResponse(503, { error: 'service_unavailable' })), + jest.fn().mockRejectedValueOnce(new Error('ECONNRESET')), + ]) { + const result = await verifyLinearRefreshAndPersist({ + readSecret: async () => STORED, + writeSecret: async () => { throw new Error('must not be called'); }, + fetchImpl: failing as unknown as typeof fetch, + }); + expect(result).toBe('error'); + } + }); + + test('a bundle with no refresh token is rejected without any network call', async () => { + // Nothing can renew this grant, which is a genuine dead end rather than an + // inconclusive probe. + const fetchImpl = jest.fn(); + const result = await verifyLinearRefreshAndPersist({ + readSecret: async () => JSON.stringify({ access_token: 'lin_old', client_id: 'cid', client_secret: 'csec' }), + writeSecret: async () => { throw new Error('must not be called'); }, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(result).toBe('rejected'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + test('an unreadable or malformed secret is error, and nothing is spent', async () => { + const fetchImpl = jest.fn(); + for (const readSecret of [ + async () => { throw new Error('AccessDenied'); }, + async () => undefined, + async () => 'not json', + ]) { + const result = await verifyLinearRefreshAndPersist({ + readSecret: readSecret as () => Promise<string | undefined>, + writeSecret: async () => { throw new Error('must not be called'); }, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(result).toBe('error'); + } + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); + describe('resolveWebhookSecretAction', () => { it('PRESERVES an existing per-workspace secret over the stack-wide one (multi-workspace re-run — the bug)', () => { // The regression: re-running `setup` on an already-installed workspace must diff --git a/cli/test/platform-doctor.test.ts b/cli/test/platform-doctor.test.ts new file mode 100644 index 000000000..e13386765 --- /dev/null +++ b/cli/test/platform-doctor.test.ts @@ -0,0 +1,128 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// Scope: how doctor TURNS workspace auth health into an operator verdict. That +// mapping is where a real outage hid — a workspace whose events were all being +// dropped was reported as needing no action — so each state gets a pinned +// verdict here. The individual health states are classified (and tested) in +// linear-auth-health; this file only covers the pass/warn/fail decision. + +const healthMock = jest.fn(); +jest.mock('../src/linear-auth-health', () => ({ + checkLinearWorkspaceAuth: (...args: unknown[]) => healthMock(...args), +})); + +// Only the Linear registry output is wanted; every other check short-circuits on +// a missing stack output rather than reaching AWS. +const stackOutputMock = jest.fn(); +jest.mock('../src/stack-outputs', () => ({ + getStackOutput: (...args: unknown[]) => stackOutputMock(...args), +})); + +jest.mock('@aws-sdk/client-bedrock', () => ({ + BedrockClient: jest.fn(() => ({ send: jest.fn().mockRejectedValue(new Error('not under test')) })), + GetFoundationModelCommand: jest.fn(), +})); + +import { runPlatformDoctor, type DoctorCheckResult } from '../src/platform-doctor'; + +const REGISTRY = 'LinearWorkspaceRegistry'; + +/** One health entry, defaulting to the shape the live incident had. */ +function workspace(state: string, slug = 'maguireb') { + return { workspaceId: `ws-${slug}`, workspaceSlug: slug, state, detail: `${state} detail` }; +} + +async function linearCheck(): Promise<DoctorCheckResult> { + const checks = await runPlatformDoctor({ region: 'us-east-1', stackName: 'Abca' }); + const check = checks.find((c) => c.id === 'linear_workspace_auth'); + if (!check) throw new Error('doctor no longer reports a Linear auth check'); + return check; +} + +beforeEach(() => { + jest.clearAllMocks(); + stackOutputMock.mockImplementation(async (_region: string, _stack: string, output: string) => + (output === 'LinearWorkspaceRegistryTableName' ? REGISTRY : null)); +}); + +describe('doctor verdict for Linear workspace auth', () => { + test('an indeterminate workspace does NOT pass — it warns, with the remedy', async () => { + // The regression this pins: reporting indeterminate as a pass is how a fully + // broken workspace read as healthy while every event it produced was dropped. + healthMock.mockResolvedValue([workspace('expired_indeterminate')]); + + const check = await linearCheck(); + expect(check.status).toBe('warn'); + expect(check.status).not.toBe('pass'); + expect(check.detail).toContain('--verify-refresh'); + }); + + test('a revoked workspace fails, and the detail carries its re-authorize remedy', async () => { + healthMock.mockResolvedValue([workspace('revoked')]); + + const check = await linearCheck(); + expect(check.status).toBe('fail'); + expect(check.detail).toContain('revoked detail'); + }); + + test('revoked outranks indeterminate — the actionable outage is the headline', async () => { + healthMock.mockResolvedValue([workspace('expired_indeterminate', 'quiet'), workspace('revoked', 'dead')]); + + const check = await linearCheck(); + expect(check.status).toBe('fail'); + // Both still appear in the summary, so the warn isn't lost behind the fail. + expect(check.detail).toContain('quiet=expired_indeterminate'); + expect(check.detail).toContain('dead=revoked'); + }); + + test('all-active passes', async () => { + healthMock.mockResolvedValue([workspace('active')]); + expect((await linearCheck()).status).toBe('pass'); + }); + + test('an unassessable workspace warns rather than passing', async () => { + healthMock.mockResolvedValue([workspace('unknown')]); + expect((await linearCheck()).status).toBe('warn'); + }); + + test('a stack with no Linear registry passes — the integration is optional', async () => { + stackOutputMock.mockResolvedValue(null); + expect((await linearCheck()).status).toBe('pass'); + expect(healthMock).not.toHaveBeenCalled(); + }); + + test('a registry read failure warns — a partial answer is not a clean bill of health', async () => { + healthMock.mockRejectedValue(new Error('AccessDeniedException on Scan')); + const check = await linearCheck(); + expect(check.status).toBe('warn'); + expect(check.detail).toContain('AccessDeniedException'); + }); + + test('the refresh verifier reaches the health check only when the operator opts in', async () => { + healthMock.mockResolvedValue([workspace('active')]); + const verify = jest.fn(); + + await runPlatformDoctor({ region: 'us-east-1', stackName: 'Abca' }); + expect(healthMock.mock.calls[0][0]).not.toHaveProperty('verifyRefresh'); + + await runPlatformDoctor({ region: 'us-east-1', stackName: 'Abca', linearVerifyRefresh: verify as never }); + expect(healthMock.mock.calls[1][0]).toHaveProperty('verifyRefresh', verify); + }); +}); diff --git a/yarn.lock b/yarn.lock index f87bb4e67..6ea6dda69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3006,13 +3006,6 @@ dependencies: undici-types "~7.18.0" -"@types/pdf-parse@^1.1.5": - version "1.1.5" - resolved "https://registry.yarnpkg.com/@types/pdf-parse/-/pdf-parse-1.1.5.tgz#a0959022604457169177622b512ed03b975f10e2" - integrity sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA== - dependencies: - "@types/node" "*" - "@types/sax@^1.2.1": version "1.2.7" resolved "https://registry.yarnpkg.com/@types/sax/-/sax-1.2.7.tgz#ba5fe7df9aa9c89b6dff7688a19023dd2963091d" @@ -7222,7 +7215,7 @@ path-scurry@^1.11.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" -pdf-parse@^2.4.5: +pdf-parse@2.4.5: version "2.4.5" resolved "https://registry.yarnpkg.com/pdf-parse/-/pdf-parse-2.4.5.tgz#fcbf9774d985a7f573e899c22618ab53a508a9f3" integrity sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==