Skip to content

Commit 7ae4af3

Browse files
isadeksbgagentkrokoko
authored
fix(agent): build/verify failure diagnosis — timeout classify, claude-binary Exec-format, failing-line surfacing (aws-samples#597)
* fix(ecs): write task payload to S3, not inline overrides (aws-samples#502) The ECS compute strategy inlined the full orchestrator payload (incl. the large hydrated_context) into the AGENT_PAYLOAD container-override env var. ECS RunTask caps the TOTAL containerOverrides blob at 8192 bytes, so any real task was rejected before the container started: InvalidParameterException: Container Overrides length must be at most 8192 AgentCore is unaffected — it passes the payload in the InvokeAgentRuntime request body, which has no comparable limit. The bug only surfaces with a realistic hydrated payload, which is why the prior ECS smoke test (a small Rust cargo-check, aws-samples#494) didn't catch it. Fix — stash the payload out-of-band and pass only a pointer: - New EcsPayloadBucket construct (mirrors TraceArtifactsBucket): BLOCK_ALL, enforceSSL, S3_MANAGED encryption, 1-day lifecycle TTL (payloads are ephemeral — read once at boot). Dedicated bucket so the ECS task role's S3 read is scoped to payloads only and can't touch attachments/traces. - ecs-strategy: when ECS_PAYLOAD_BUCKET is set, PutObject the payload to <task_id>/payload.json and pass AGENT_PAYLOAD_S3_URI in the override; the boot command fetches+parses it via boto3. Inline AGENT_PAYLOAD remains as a fallback (small payloads / no bucket), so nothing regresses. deleteEcsPayload helper removes the object. - orchestrate-task finalize: best-effort deleteEcsPayload for ECS tasks once terminal (the container has long since read it); lifecycle rule is the crash backstop. - EcsAgentCluster: accept payloadBucket, inject ECS_PAYLOAD_BUCKET env, grant the task role READ ONLY (untrusted repo code must not write/delete payloads; the trusted orchestrator owns write+delete). Session-role-aware. - task-orchestrator: ecsPayloadBucket prop → grantPut + grantDelete to the orchestrator; @aws-sdk/client-s3 added to bundling externals. - agent.ts: updated the commented uncomment-to-enable ECS scaffolding to wire the payload bucket. Tests: new bucket construct (TTL/SSL/block-public/autoDelete); strategy S3-write + URI-pointer + inline fallback + deleteEcsPayload (incl. best-effort swallow + no-op without bucket); cluster read-grant + env var + read-only (no put/delete). Full build green. Closes aws-samples#502 * fix: enable corepack in the agent image so yarn/pnpm resolve Root cause of the exit-127 inert gate: the agent Dockerfile installs node/npm/ mise/uv but NOT yarn, so any repo whose build runs 'yarn install' (incl. ABCA) hit 'yarn: command not found' and the agent hand-built a ~/bin/yarn shim every run. corepack (ships with Node 24) installs the yarn/pnpm shims at image-build time. Systemic — fixes every yarn repo, no per-run shim. Needs redeploy to take effect. (cherry picked from commit 2fd5fca) (cherry picked from commit d5e0e64) * wire ECS/Fargate substrate (context-gated) at 32GB/8vCPU for heavy builds AgentCore's fixed microVM envelope OOM-kills CI-parity builds (live-caught dogfooding ABCA-on-ABCA: the ~2800-test `mise run build` killed the VM at the build-gate step). AgentCore memory isn't tunable, so route repos that set `compute_type: 'ecs'` to a Fargate task instead. - agent.ts: gate EcsAgentCluster + the orchestrator ecsConfig on the `compute_type` deploy context (default 'agentcore'). ECS resources only synthesize under `--context compute_type=ecs`, so default synth (and the bootstrap-coverage test) stays agentcore-only. Mirrors upstream's context-gating of the ECS construct. - ecs-agent-cluster.ts: size the Fargate task at 8 vCPU / 32 GB (was 2/4) — a valid ARM64 combo with headroom for the jest worker fleet + tsc that AgentCore's envelope can't give. Comment documents the live OOM. - test: assert the new 8192/32768 sizing. Verified: default synth has 0 ECS resources; `--context compute_type=ecs` synth emits AWS::ECS::Cluster + TaskDefinition. Full `mise run build` green. (cherry picked from commit 8cb7cfa) (cherry picked from commit 632a36b) * bump ECS Fargate to 64GB/16vCPU + BUILD_VERIFY_TIMEOUT_S=3600 Live-fired the full `mise run build` on the 32GB ECS task (fork dogfood): install OK, build ran ~50 min then OOM-killed at the cap (ECS stoppedReason "OutOfMemoryError: container killed due to memory usage", exit 137; peak working set ~31.6 GB). Root cause: the monorepo build fans out 4 heavy jobs in PARALLEL (agent:quality ‖ cdk:build ‖ cli:build ‖ docs:build), each with its own worker fleet (jest, pytest, esbuild Lambda bundling) — 32 GB had no headroom for that concurrent peak, and the ~50-min wall-clock also blew the 1800s build-gate cap. Fix (keep the full build as the gate, give it room): - ecs-agent-cluster.ts: Fargate 8vCPU/32GB → 16vCPU/64GB (valid ARM64 combo; 16 vCPU admits 32–120 GB). 2× memory headroom for the parallel storm + more cores to shorten wall-clock. Comment records the full sizing history. - ecs-agent-cluster.ts: inject BUILD_VERIFY_TIMEOUT_S=3600 into the container env so a slow-but-healthy CI-parity build isn't mis-flagged as a timeout (post_hooks.py reads it; default 1800 stays for AgentCore repos). - tests: assert 16384/65536 + the new env var. Verified: default synth ECS-free; --context compute_type=ecs synth emits Cpu 16384 / Memory 65536 / BUILD_VERIFY_TIMEOUT_S 3600. Full build green. (cherry picked from commit 11fb3a5) (cherry picked from commit cd7b8eb) * fix(ecs): map full payload to run_task so channel/build/cedar fields aren't dropped (ABCA-487) The ECS boot command hand-listed ~18 run_task kwargs and silently dropped the rest. On ECS this meant channel_source/channel_metadata never reached run_task, so resolve_linear_api_token never ran, LINEAR_API_TOKEN was never set, and the Linear/Jira reaction + channel MCP silently no-op'd — @bgagent tasks on ECS posted NO 👀 reaction or anything (live-caught: ABCA-487 on the fork, RUNNING with mcp_servers:[] and no reaction). Also dropped: build_command/lint_command (build-gate used defaults), cedar_policies + approval_* (HITL guardrails), base_branch/merge_branches (orchestration stacking), attachments, trace, user_id. Same "hand-rolled partial payload copy" root as aws-samples#502. Fix — single source of truth, unit-testable: - pipeline.run_task_from_payload(p): maps the WHOLE payload dict to run_task's signature — rename prompt→task_description / model_id→anthropic_model, filter to accepted params (unknown keys ignored, not passed as **kwargs), coerce issue_number/pr_number→str + max_turns→int, aws_region falls back to env. _RUN_TASK_PARAMS computed once at import (patch-safe). - entrypoint exports it; ecs-strategy boot command now calls run_task_from_payload(p) instead of the hand-listed kwargs. - tests: 9 agent tests (rename, channel fields ABCA-487, build/cedar/branch, coercion, unknown-key drop, None-drop, aws_region, signature guard) + ecs-strategy asserts the boot command calls the mapper and no longer hand-lists. Full build green: agent + cdk 2852 + cli 571 + docs. Deployed to dev (--context compute_type=ecs; agent image rebuilt). (cherry picked from commit 777ee0e) (cherry picked from commit 1639c27) * fix(ecs): grant task role GetSecretValue on bgagent-{linear,jira}-oauth-* (ABCA-488) A Linear/Jira-channel task resolves its per-workspace OAuth token from Secrets Manager (bgagent-linear-oauth-<slug>) at startup to fire the 👀→✅ reaction and drive the channel MCP. The AgentCore runtime role + orchestrator/fanout/ screenshot/linear-integration roles all have the bgagent-linear-oauth-* prefix grant, but the ECS task role only had GetSecretValue on the GitHub token. So on ECS the token fetch hit AccessDenied → LINEAR_API_TOKEN unset → linear_reactions skipped AND the Linear MCP stayed needs-auth (agent couldn't post comments either). Live-caught dogfooding on the fork (ABCA-488, ECS task 828dab35). Fourth ECS-parity gap after aws-samples#502 (payload size) + ABCA-487 (dropped channel fields): the AgentCore path had a capability the ECS task role didn't. Fix: grant the ECS task role secretsmanager:GetSecretValue on the bgagent-linear-oauth-* and bgagent-jira-oauth-* prefixes (GetSecretValue only — the container reads; the orchestrator owns refresh/PutSecretValue). Nag reason updated + regression test asserting the prefix grant. Full build green (2853). (cherry picked from commit 5a886bb) (cherry picked from commit 49235d4) * fix(ecs): wire ARTIFACTS_BUCKET_NAME into the Fargate task (ECS-parity, aws-samples#299) Live-caught: a :decompose on an ecs-configured repo ran on ECS (fix confirmed — no more 'ECS_CLUSTER_ARN missing') but then FAILED at delivery with 'ValueError: deliver_artifact: ARTIFACTS_BUCKET_NAME is not configured'. The AgentCore runtime sets ARTIFACTS_BUCKET_NAME; the EcsAgentCluster task def never got the bucket — an ECS-parity gap (same class as aws-samples#502/ABCA-487). A repo-bound artifact workflow (coding/decompose-v1) delivers its plan JSON there. - EcsAgentCluster: new optional artifactsBucket prop → ARTIFACTS_BUCKET_NAME in the container env + grantReadWrite on the task role (read+write: the container DELIVERS the artifact, unlike the read-only payload bucket). IAM5 suppression extended to cover the second scoped S3 grant. - agent.ts: pass traceArtifactsBucket.bucket (same bucket the runtime uses). - 3 construct tests (env injected / read+write grant / omitted when absent). 2872 CDK + 571 CLI + 1184 agent green. Held on branch. (cherry picked from commit 7617d72) (cherry picked from commit e0dfc93) * feat(compute): guard compute_type=ecs against a stack without the ECS substrate A repo onboarded as compute_type=ecs on a stack deployed WITHOUT --context compute_type=ecs fails every task at session start (no ECS_* env vars). Catch the config/deploy mismatch early + make the runtime error actionable: - agent.ts: new ComputeSubstrate CfnOutput — 'ecs' when the gate is on, else 'agentcore' (ecs is additive; the AgentCore runtime is always present, so an agentcore repo works on either). - cli repo onboard: when --compute-type ecs, read ComputeSubstrate and refuse with a redeploy message if it's an explicit non-ecs value. Null (older stacks predating the output) is treated as unknown → proceed (runtime backstop). - ecs-strategy: the missing-env error now names the root cause + remedy (redeploy with the gate, or set the repo to agentcore) instead of a bare env-var list. - Tests: CDK output flips with the gate (agentcore default / ecs gated); CLI onboard refuses/allows/back-commpat; ecs construct unchanged. 2875 CDK + 575 CLI + 1184 agent green. Held on branch. (cherry picked from commit cb7c8c6) (cherry picked from commit 9048db0) * fix(cdk): raise BUILD task memory 64→120 GB (max Fargate) — ABCA-662 baseline OOM ABCA-662's pre-agent baseline build was OOM-killed (exit 137) at 64 GB: dogfooding ABCA-on-ABCA, the full parallel `mise run build` (agent:quality ‖ cdk:build ‖ cli:build ‖ docs:build, each with its own worker fleet) peaked past 64 GB. Each build task is memory-ISOLATED (its own Fargate microVM), so limiting concurrency does NOT help a single over-64 GB build — only more per-task RAM or less build parallelism does. 120 GB is the MAX Fargate admits at 16 vCPU (32–120 in 8 GB steps), so this is the clean experiment: if a build still OOMs here, the only remaining lever is cutting the build's peak parallelism (serialize the mise DAG / cap jest --maxWorkers) — there is no more RAM to give. Pairs with the repo.py fix (an OOM-killed baseline is now infra, not 'already broken'), so even if a build does hit the ceiling the verdict stays honest. Tests updated to assert Memory 122880 (both BUILD-def assertions). (cherry picked from commit 25e2bb1) * fix(cdk): make ecs-strategy top-of-file import hermetic vs ambient env The inline-fallback / no-op tests in the top-of-file describe blocks assume the OPTIONAL vars ECS_PAYLOAD_BUCKET and ECS_PLANNING_TASK_DEFINITION_ARN are ABSENT when the module is first imported (it reads them as module-level constants). A dev shell has neither set, so the suite passed locally — but the REAL ECS agent container HAS ECS_PAYLOAD_BUCKET set (aws-samples#502 payload bucket), so on ECS the const was truthy and the 'no bucket → inline fallback' + 'no-op' assertions failed: FAIL test/handlers/shared/strategies/ecs-strategy.test.ts ● startSession › sends RunTaskCommand with correct params ... ● deleteEcsPayload without ECS_PAYLOAD_BUCKET › no-ops ... This was the residual fork baseline-build exit-1: 2 failed / 3093 passed, only ever reproducible inside the ECS microVM. Surfaced by the live-streaming build log (the buffered summary had hidden it). Fix: delete both optional vars before the top-of-file import so it's hermetic regardless of the runner's environment; the aws-samples#502/aws-samples#299 describe blocks already set them via jest.isolateModules. (cherry picked from commit 7a2bde9) (cherry picked from commit 8267c29) * fix(cdk): grant ec2:DescribeAvailabilityZones to ECS agent task role (cdk synth on fresh clone) A CDK-based target repo's build gate runs `cdk synth`. When the stack is wired to a concrete env ({account, region}), CDK does a synth-time availability-zone context lookup (ec2:DescribeAvailabilityZones). A developer box caches the result in the gitignored cdk.context.json so synth is hermetic; the agent clones fresh, so there is no cache and the live lookup fires. The ECS task role lacked the action, so synth failed with: ERROR ...EcsAgentClusterTaskRole... is not authorized to perform: ec2:DescribeAvailabilityZones ... Synthesis finished with errors → a FALSE build-gate failure on code that builds fine everywhere else. Same ECS-parity class as ABCA-488 (GetSecretValue) and F-2 (CreateEvent): a permission the AgentCore path had but the ECS task role didn't. Live-caught on the ABCA fork: baseline `mise run build` passed all 3095 tests then died at `cdk synth -q`, surfaced only by the live-streaming build log. Grant is a read-only describe (Resource:* — EC2 describe actions have no resource-level scoping; IAM5 suppressed, no mutation/data access). +1 regression test pins the statement. (cherry picked from commit 3fc9060) (cherry picked from commit 2bf12f4) * fix(ecs): grant ECS task role AgentCore Memory access (F-2, ABCA-488-class) Live-caught during the fork stress smoke (ABCA-495): the ECS TaskDefTaskRole got AccessDeniedException on bedrock-agentcore:CreateEvent for the AgentMemory resource, so the agent's cross-task learning writes (write_task_episode / write_repo_learnings) silently no-op'd (WARN) on EVERY ECS task — memory never persisted on the fork's ECS-only substrate. Same class as ABCA-488: the AgentCore runtime role gets memory access via agentMemory.grantReadWrite(runtime) in agent.ts, and the orchestrator gets it too, but the ECS task role never did. Fix: EcsAgentCluster gains an optional agentMemory prop; when provided the task role gets agentMemory.grantReadWrite (read actions + CreateEvent), scoped to the memory ARN (synth-verified: no wildcard resource, so the existing IAM5 nag suppression is unaffected). agent.ts passes agentMemory to the ECS cluster alongside the existing memoryId. Omitted in isolated construct tests / memory-less deploys → no grant (asserted). Tests: construct asserts CreateEvent on the task role scoped to MemoryArn when wired, and NO bedrock-agentcore grant when unwired. Full cdk build green (2883). (cherry picked from commit c23d49fbead30ed52860e3975815e3643b22ade0) (cherry picked from commit 44bb71e) * docs(bootstrap): correct the ECS ComputeTypes enable instructions The //cdk:bootstrap task hint and DEPLOYMENT_ROLES.md both said to enable the ECS compute backend with 'mise //cdk:bootstrap -- --context ComputeTypes=agentcore,ecs'. That does NOT work: ComputeTypes is a CloudFormation *template parameter*, and this CDK version's 'cdk bootstrap' has no way to set one — there's no --parameters flag, and --context sets CDK construct context, not a CFN parameter. So the ECS policy (IaCRole-ABCA-Compute-ECS, conditional on ComputeTypes containing 'ecs') was never attached, and a --context compute_type=ecs deploy then failed with AccessDenied on ecs:* — live-caught deploying the substrate to dev. Correct it to the mechanism that actually works: bootstrap normally, then set the parameter directly on the CDKToolkit stack via 'aws cloudformation update-stack ... ParameterKey=ComputeTypes,ParameterValue=agentcore,ecs' (with a describe-stacks verify). Fixed in the mise task description + comment and both DEPLOYMENT_ROLES.md occurrences; Starlight mirror regenerated. Drift gate green. (cherry picked from commit a46683e) * fix(errors): classify a build/verify command TIMEOUT (live-caught ABCA-667, was 'Unexpected error') Replication finding: a live fork coding child (ABCA-667) failed with 'TimeoutExpired: Command [mise run build] timed out after 600 seconds' — the full ~2800-test fork build exceeded the 600s cap. That's the pre-agent verify-build subprocess timeout, which lands raw in error_message and fell through to 'Unexpected error' + generic guidance — exactly the transient-vs-real confusion this rework targets. It's not a crash and not broken code: the build ran too long. Add a TIMEOUT classification for / → 'Build/tests didn't finish in time', user class, retryable, remedy names the real fix (retry / raise BUILD_VERIFY_TIMEOUT_S or move to the larger ECS build box). Ordered before the generic poll-timeout pattern. Test uses the exact ABCA-667 shape. 60 classifier tests green. (cherry picked from commit 6394a59) (cherry picked from commit b9c1a94) * fix(agent): ensure claude-code native binary is placed at image build (Exec format error) claude-code@2.1.191 uses a shim (bin/claude.exe) + platform-optional-dependency install model: its postinstall (install.cjs) is supposed to replace the shim with the platform-native ELF binary. When that postinstall silently falls back (e.g. it hits EACCES overwriting the shim under npm's root de-privileging), the error-shim stays on PATH and every agent run dies at the run_agent step with 'OSError: [Errno 8] Exec format error: \'claude\'' — even though the native arm64 binary is present in the image as an optional dep, just never wired up. Live-caught on ABCA-659's retry: all 3 ECS runs failed this way on a freshly rebuilt image (the prior working image ran the older single-binary 2.1.142). Re-run install.cjs explicitly after the npm install so the native binary is placed at build time, and hard-verify with 'claude --version' so a still-broken shim fails the image build instead of every task at runtime. (cherry picked from commit 7cb4a87) (cherry picked from commit 7de778b) * fix(agent): log stdout on run_cmd failure so build-gate failures are debuggable The post-agent build gate logged only stderr on a non-zero exit. But 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. So a red `mise run build` surfaced every task STARTING in CloudWatch but never WHICH one failed or why (ABCA-662: a 7s exit-1 with zero captured cause, indistinguishable from a phantom, and the reason Linear showed a build-fail GitHub CI passed). run_cmd now also tails the last 20 lines of stdout on failure (redacted — repo build output is untrusted). 4 tests pin: stdout surfaced, tailed-not-headed, redacted, and NOT dumped on success. (cherry picked from commit 67d84db) (cherry picked from commit bb578fd) * fix(agent): surface the FAILING line from a parallel build DAG, not just a tail The stdout-on-failure logging (67d84db) tailed the last 20 lines — enough for a serial command, but NOT for `mise run build`, which runs 4 packages in PARALLEL and interleaves their output. The failing task's error lands in the MIDDLE while the tail captures whatever finished LAST (e.g. a PASSING package's coverage table) — so the log showed a green coverage table on a red build and named nothing (ABCA-662: three verification runs where the actual failing sub-task was never visible in CloudWatch). run_cmd now scans the WHOLE stdout for failure-signature lines (FAIL/✕/●/error TS/ELIFECYCLE/coverage-threshold/mise 'no task'/'task failed'/traceback/…), filters benign noise ('0 errors', '--no-error'), and logs those FIRST, then a trailing tail for context — deduped, capped at 40 lines. Falls back to the tail when nothing matches (unknown tool). This is the tooling gap that made the whole ABCA-662 build investigation slow: every failure was diagnosable only by luck or local repro. 5 tests incl. the mid-DAG-failure + coverage-threshold cases. (cherry picked from commit 3fe99c9) (cherry picked from commit aaa4f18) * fix(ecs): remove dead ECS_PAYLOAD_OBJECT_KEY_PREFIX export (review B1) The `export const ECS_PAYLOAD_OBJECT_KEY_PREFIX = ''` constant was referenced nowhere — ecsPayloadKey() (ecs-strategy.ts) builds `<task_id>/payload.json` independently and already documents that layout in its own docstring. The dead export was a "prefix" whose value was '' with a docstring describing a key layout it didn't produce, and it raised the knip dead-code count, tripping the ratchet inside `mise run build`. Delete it; ecsPayloadKey remains the single source of truth for the key layout. * docs+feat: address review nits N1–N4 on the ECS substrate Review follow-ups for aws-samples#596 (fix itself unchanged; B1 dead-export fixed in the base PR aws-samples#503): - N1: correct the stale "64 GB / 16 vCPU" comment in agent.ts — the task is larger (64 GB was itself OOM-killed per the construct's sizing history). Defer the exact figure to EcsAgentCluster rather than duplicate/drift it. - N2: reword the memory-grant + oauth-grant comments — an AccessDenied on those paths is LOGGED (memory.py treats it as an infra failure; config.py's token resolver logs it), not "silently" no-op'd. Describe the user-visible effect (no persisted learning / no 👀→✅ reaction) rather than pin a log level that drifts. - N3: the ec2:DescribeAvailabilityZones comment already frames it correctly as a FALSE build-gate failure (not a WARN no-op) — no change needed; noted here for completeness. - N4: run_task_from_payload now emits a WARN when it drops a KNOWN orchestrator key (build_command / merge_branches / base_branch / github_token_secret_arn) that run_task doesn't accept — expected today (consumed elsewhere / not yet wired), but makes a future "wired one side, forgot the other" no-op visible instead of silent (the ABCA-487 class). Foreign keys still drop quietly. + 2 tests (warns on a known dropped key; stays quiet on a foreign key). Full cdk build green (2286) + full agent suite green (1194); eslint/ruff clean. * style: ruff-format the N4 payload-key block (fix build-mutation failure) CI's "Fail build on mutation" tripped: I ran `ruff check` (passed) but not `ruff format`, so the `_KNOWN_ORCHESTRATOR_KEYS = frozenset({...})` set literal and the two-line `log(...)` call weren't in ruff's canonical multi-line form. `ruff format` normalizes them; no behavior change (12 payload tests still pass). * fix+docs: WARN on dropped task_started_at (HITL parity) + defensive max_turns + comment nits Follow-up on the aws-samples#596 re-review (code was already approved-quality; these are the new non-blocking finding + 3 nits): - task_started_at HITL parity: 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 never sets it, so run_task_from_payload dropped it SILENTLY — a fail-open AgentCore↔ECS HITL divergence. Added task_started_at to _KNOWN_ORCHESTRATOR_KEYS so the drop now WARNs (surfaces the gap). Fully wiring TASK_STARTED_AT into the ECS containerEnv is a tracked follow-up; this makes the divergence visible today. - max_turns defensive coercion: a malformed max_turns raised ValueError mid-boot while every other field defaults defensively. Now drops it (run_task default applies) with a WARN, matching the surrounding style. - Nit: artifactsBucket JSDoc no longer claims this bucket is the `--trace` upload target (it wires ARTIFACTS_BUCKET_NAME only; the trace uploader reads TRACE_ARTIFACTS_BUCKET_NAME, unset here — noted as a separate ECS-parity gap). - Nit: dropped `2>/dev/null` from the Dockerfile corepack prepare so the prepare-failed diagnostic isn't hidden (the `|| corepack enable` fallback still guarantees resolvable shims — no regression to the exit-127 inert gate). Tests: +3 (task_started_at WARN, malformed-max_turns dropped-not-raised, valid max_turns still coerced). Full agent suite green (1195); cdk tsc + construct tests green; ruff format + eslint clean. * fix(ecs): address aws-samples#596 review — drop over-privileged artifacts grant + nits B1 (least-privilege regression): remove props.artifactsBucket.grantReadWrite from the ECS task role. coding/decompose-v1 delivers its plan via 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). The whole-bucket grant over-privileged the untrusted-code role and broke cross-task isolation (a task could read/clobber other tasks' artifacts/<other_id>/, traces/, attachments/). Task role keeps only the ARTIFACTS_BUCKET_NAME env. Correct the inverted 'parity' comment and drop the now-stale artifacts clause from the cdk-nag IAM5 reason. Test: flip 'grants READ+WRITE on artifacts' → asserts the task role has NO S3 Put/Delete action at all (the read-only aws-samples#502 payload GetObject*/List* grant is not flagged). N4: add lint_command to _KNOWN_ORCHESTRATOR_KEYS (sibling of build_command) so a future 'wired build_command, forgot lint_command' contract gap WARNs instead of dropping silently. (N1/N2/N3 comment nits were already addressed on the branch head; B1 dead-const ECS_PAYLOAD_OBJECT_KEY_PREFIX no longer present. B2 governance handled separately.) * fix(ecs): address aws-samples#596 re-review nits — stale parity comments + WARN noise + max_turns coercion Scott's fresh re-review (head 86ab249) is Approve-with-nits: B1 (over-privilege) and B2 (governance) resolved; remaining asks are doc-accuracy + WARN-channel hygiene. - N1: rewrite the artifactsBucket JSDoc (ecs-agent-cluster.ts) that still claimed a read/write task-role grant the B1 fix deliberately removed — now states env-only wiring with delivery through the task_id-scoped SessionRole (parity with the AgentCore runtime role, which has no artifacts grant). The grant block ~30 lines below was already corrected in the B1 commit; this was its residual half. - N2: same inverted-comment fix at the agent.ts wiring site — '(read+write grant in the construct)' → env-only + SessionRole delivery. - N3: drop github_token_secret_arn from _KNOWN_ORCHESTRATOR_KEYS. It is ALWAYS present and ALWAYS resolved via the GITHUB_TOKEN_SECRET_ARN env, so listing it fired the known-key WARN on 100% of ECS boots — pure noise diluting the channel meant for genuine future contract gaps. Now falls through as a quiet foreign-key drop; comment records why it must not be re-added. +test asserting no WARN. - N4: max_turns int() coercion accepted a bool (int(True)==1) and truncated a non-integral float (int(3.9)==3), and the comment falsely claimed it 'matches how every other field is handled' (it is the one non-str coercion). Now rejects bools + non-integral floats with a breadcrumb; valid int / int-string / int-float still pass. +test. Reachable only via a corrupt payload (orchestrator emits a real int), so cosmetic — bundled since cheap. - N5: no-op — the Dockerfile corepack line already has no 2>/dev/null. Gates: cdk 2355 tests + eslint + synth green; agent 1268 tests + ruff + ty green. Non-blocking aws-samples#608 test-seam gaps (finalize deleteEcsPayload, boot-uri parse, entrypoint re-export, zero-ECS-synth assert) remain tracked, not in scope here. * fix(agent): address aws-samples#597 review — honest TIMEOUT remedy + nits (B1, N1-N4) Scott's re-review (vs clean main) — one blocker + nits, all in the error-legibility feature this PR ships. - B1 (blocker): the new TIMEOUT classification pointed operators at BUILD_VERIFY_TIMEOUT_S — but NOTHING in agent/ reads it on this branch (verify_build/verify_lint call run_cmd with no timeout=, so they use the 600s default; the env is only SET in CDK). And the anchoring comment's 'live-caught on ABCA-667 via mise run build' narrative is contradicted by the code: the verify path CATCHES TimeoutExpired and returns a plain failure, so the regex never fires for that path. Took Scott's option (b): reworded the comment to describe the real trigger (any UNCAUGHT run_cmd TimeoutExpired — clone/setup), and softened the remedy to a generic 'retry / contact admin' with no dead-knob promise. (The actual BUILD_VERIFY_TIMEOUT_S wiring lives on the ECS-substrate track, not this main-bound branch — out of scope here.) Also reconciled the ecs-agent-cluster.ts comment to say the env is provisioned ahead of its consuming wiring rather than currently effective. - N1: added explicit errorClass: ErrorClass.USER with a comment stating the no-auto-retry intent (re-running an unchanged slow build just times out again; must NOT be 'fixed' to TRANSIENT). - N2: hardened the regex to match float seconds (\d+(?:\.\d+)?). - N3: replaced the false-confidence allowlist test with a real one — a line that hits a marker (error:) AND a noise term (0 errors), placed outside the tail window so it's only reachable via the marker scan; deleting _FAILURE_LINE_NOISE now fails the test. - N4: added a >40-marker-line test that pins the truncation-cap breadcrumb. Gates: cdk 2439 tests + synth + eslint; agent 1278 tests + ruff + ty. --------- Co-authored-by: bgagent <bgagent@noreply.github.com> Co-authored-by: Alain Krok <alkrok@amazon.com>
1 parent 7ef21dc commit 7ae4af3

6 files changed

Lines changed: 287 additions & 5 deletions

File tree

agent/Dockerfile

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,19 @@ RUN corepack enable && corepack prepare yarn@stable --activate || corepack enabl
6161
# returned creds are cached until 5 min before the JSON's `Expiration`, so an
6262
# 8 h task re-assumes the 1 h-capped SessionRole before expiry. Older builds
6363
# only refreshed hourly on a timer, racing the role-chaining cap.
64+
# claude-code@2.1.x ships an error-shim at bin/claude.exe that its postinstall
65+
# (install.cjs) replaces with the platform-native ELF binary. That postinstall
66+
# can silently fall back (leaving the shim in place) — the shim then dies at
67+
# runtime with "Exec format error: 'claude'" even though the native binary is
68+
# present as an optional dep, just never wired up. Re-run install.cjs explicitly
69+
# after the install so the native binary is placed at build time, and hard-verify
70+
# it exec's (``claude --version`` fails the build if the shim is still on PATH).
6471
RUN npm install -g npm@latest && \
6572
npm install -g @anthropic-ai/claude-code@2.1.191 && \
6673
CLAUDE_NPM_ROOT="$(npm root -g)/@anthropic-ai/claude-code" && \
67-
npm --prefix "${CLAUDE_NPM_ROOT}" update tar minimatch glob cross-spawn picomatch
74+
npm --prefix "${CLAUDE_NPM_ROOT}" update tar minimatch glob cross-spawn picomatch && \
75+
node "${CLAUDE_NPM_ROOT}/install.cjs" && \
76+
claude --version
6877

6978
# Install uv (fast Python package manager) — pinned for reproducibility
7079
COPY --from=ghcr.io/astral-sh/uv:0.11.14 /uv /usr/local/bin/uv

agent/src/shell.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,83 @@ def _clean_env() -> dict[str, str]:
160160
return env
161161

162162

163+
# Substrings that mark a line as a real failure in build/test tool output — used
164+
# to pull the FAILING line out of the MIDDLE of a large interleaved parallel-DAG
165+
# log (a plain tail misses it). Lower-cased comparison; conservative so we don't
166+
# flood on benign "warning"/"0 errors" lines.
167+
_FAILURE_LINE_MARKERS = (
168+
"fail ", # jest "FAIL test/foo.test.ts", pytest "FAILED"
169+
"failed",
170+
"✕",
171+
"✗",
172+
"✖",
173+
"●", # jest failed-assertion bullet
174+
"error ts", # tsc "error TS2345:"
175+
"error:",
176+
"elifecycle", # yarn/npm lifecycle failure
177+
"npm err!",
178+
"does not meet", # jest coverage-threshold "global … does not meet threshold"
179+
"coverage threshold",
180+
'jest: "global"',
181+
"not trusted", # mise untrusted config
182+
"no task ", # mise "no task named"
183+
"missing script", # npm missing script
184+
"assertionerror",
185+
"traceback (most recent call last)",
186+
"task failed", # mise "[//pkg:task] ERROR task failed"
187+
)
188+
189+
# Benign lines that CONTAIN a marker substring but are not failures — filtered so
190+
# the surfaced set stays signal. e.g. "0 errors", "--no-error-on-unmatched".
191+
_FAILURE_LINE_NOISE = (
192+
"0 errors",
193+
"0 failed",
194+
"no error",
195+
"--no-error",
196+
"0 problems",
197+
"may fail", # advisory prose
198+
)
199+
200+
# Cap surfaced failure lines so a genuinely huge red run (hundreds of failing
201+
# assertions) can't flood CloudWatch; the count + a tail still convey scale.
202+
_MAX_SURFACED_FAILURE_LINES = 40
203+
_FAILURE_TAIL_LINES = 15
204+
205+
206+
def _surface_failure_lines(stdout: str) -> list[str]:
207+
"""From a failed command's stdout, return the lines most likely to name the
208+
cause: every failure-signature line (scanning the WHOLE output, not just the
209+
tail — a parallel task DAG interleaves output so the red line is often in the
210+
middle) followed by a trailing-context tail. Deduped, order-preserving,
211+
capped. This is the fix for build-gate failures that a plain tail couldn't
212+
explain (ABCA-662: the tail was a passing package's coverage table)."""
213+
lines = stdout.strip().splitlines()
214+
matched: list[str] = []
215+
for ln in lines:
216+
low = ln.lower()
217+
if any(m in low for m in _FAILURE_LINE_MARKERS) and not any(
218+
n in low for n in _FAILURE_LINE_NOISE
219+
):
220+
matched.append(ln.rstrip())
221+
if len(matched) >= _MAX_SURFACED_FAILURE_LINES:
222+
matched.append("… (more failure lines truncated)")
223+
break
224+
tail = [ln.rstrip() for ln in lines[-_FAILURE_TAIL_LINES:]]
225+
# Order: failure-signature lines first (the WHY), then the tail (context),
226+
# dropping tail lines already surfaced as matches.
227+
seen = set(matched)
228+
out = list(matched)
229+
if matched and tail:
230+
out.append("--- (trailing output) ---")
231+
for ln in tail:
232+
if ln not in seen:
233+
out.append(ln)
234+
seen.add(ln)
235+
# Fallback: nothing matched a failure marker (unknown tool) → just the tail,
236+
# so we never log NOTHING on a failure.
237+
return out or tail
238+
239+
163240
def run_cmd(
164241
cmd: list[str],
165242
label: str,
@@ -182,6 +259,24 @@ def run_cmd(
182259
if result.stderr:
183260
for line in result.stderr.strip().splitlines()[:20]:
184261
log("CMD", f" {line}")
262+
# ALSO surface stdout on failure. Build/test tooling (jest, tsc, the mise
263+
# task DAG) writes the ACTUAL failing-task error to STDOUT, not stderr —
264+
# stderr often carries only the runner's plan echo. Logging stderr alone
265+
# made build-gate failures undebuggable: a red ``mise run build`` showed
266+
# every task STARTING but never WHICH one failed or why (ABCA-662).
267+
#
268+
# A plain tail is NOT enough for a PARALLEL task DAG: `mise run build`
269+
# runs 4 packages concurrently and interleaves their output, so the
270+
# failing task's error scrolls into the MIDDLE while the tail captures
271+
# whatever finished LAST (e.g. a passing package's coverage table) —
272+
# ABCA-662 follow-up: the tail showed a coverage table, not the red task.
273+
# So FIRST scan the whole output for failure-signature lines and surface
274+
# those (this is what names the failing sub-task), THEN a larger tail for
275+
# trailing context. Redact — repo build output is untrusted.
276+
if result.stdout:
277+
surfaced = _surface_failure_lines(result.stdout)
278+
for line in surfaced:
279+
log("CMD", f" {redact_secrets(line)}")
185280
if check:
186281
stderr_snippet = redact_secrets(result.stderr.strip()[:500]) if result.stderr else ""
187282
raise RuntimeError(f"{label} failed (exit {result.returncode}): {stderr_snippet}")

agent/tests/test_shell.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from shell import (
77
is_transient_cmd_failure,
88
redact_secrets,
9+
run_cmd,
910
run_cmd_with_backoff,
1011
slugify,
1112
truncate,
@@ -185,3 +186,135 @@ def test_backoff_delays_are_exponential(self):
185186
with patch("shell.run_cmd", side_effect=lambda *a, **k: seq.pop(0)):
186187
run_cmd_with_backoff(["git", "clone"], "clone", base_delay_s=2.0, sleep=delays.append)
187188
assert delays == [2.0, 4.0] # 2 * 2**0, 2 * 2**1
189+
190+
191+
class TestRunCmdFailureLogging:
192+
"""A failing command must surface its ACTUAL error. Build/test tooling (jest,
193+
tsc, the mise task DAG) writes the failing-task error to STDOUT, not stderr —
194+
so logging stderr alone made build-gate failures undebuggable (ABCA-662: a red
195+
``mise run build`` showed every task starting but never WHICH one failed)."""
196+
197+
def _completed(self, rc, stdout="", stderr=""):
198+
return SimpleNamespace(returncode=rc, stdout=stdout, stderr=stderr)
199+
200+
def _run_capturing_logs(self, proc):
201+
logs = []
202+
with (
203+
patch("shell.subprocess.run", return_value=proc),
204+
patch("shell.log", side_effect=lambda prefix, text: logs.append((prefix, text))),
205+
):
206+
run_cmd(["mise", "run", "build"], "verify-build-post", check=False)
207+
return logs
208+
209+
def test_stdout_is_logged_on_failure(self):
210+
# The failing task's error lives in stdout — it MUST reach the log.
211+
proc = self._completed(
212+
1,
213+
stdout="[//cdk:test] FAIL test/foo.test.ts\n expected 1 got 2\n[//cdk:test] exit 1",
214+
stderr="",
215+
)
216+
logs = self._run_capturing_logs(proc)
217+
blob = "\n".join(text for _, text in logs)
218+
assert "FAIL test/foo.test.ts" in blob
219+
assert "expected 1 got 2" in blob
220+
221+
def test_no_markers_falls_back_to_tail(self):
222+
# Unknown tool output with no failure signature → fall back to the tail.
223+
proc = self._completed(
224+
1,
225+
stdout="\n".join(f"line {i}" for i in range(50)),
226+
stderr="",
227+
)
228+
logs = self._run_capturing_logs(proc)
229+
blob = "\n".join(text for _, text in logs)
230+
assert "line 49" in blob # last line present (tail)
231+
assert "line 0" not in blob # earliest lines dropped
232+
233+
def test_failure_line_in_the_MIDDLE_is_surfaced(self):
234+
# ABCA-662 root cause of the tooling gap: a PARALLEL mise DAG interleaves
235+
# output, so the failing task's line is in the MIDDLE while the tail is a
236+
# passing package's coverage table. The failing line MUST be surfaced.
237+
mid = "[//cdk:test] FAIL test/handlers/foo.test.ts — expected 1 got 2"
238+
stdout = (
239+
"\n".join(f"[//cdk:test] passing line {i}" for i in range(30))
240+
+ f"\n{mid}\n"
241+
+ "\n".join(f"[//agent:test] coverage {i} | 100 | 100" for i in range(30))
242+
)
243+
proc = self._completed(1, stdout=stdout, stderr="")
244+
logs = self._run_capturing_logs(proc)
245+
blob = "\n".join(text for _, text in logs)
246+
assert "FAIL test/handlers/foo.test.ts" in blob # the mid-DAG red is surfaced
247+
assert "coverage 29" in blob # tail context still present
248+
249+
def test_coverage_threshold_failure_is_surfaced(self):
250+
# jest prints a coverage table then exits 1 with "does not meet threshold"
251+
# — no ✕/FAIL line. That threshold line must be surfaced.
252+
stdout = (
253+
"\n".join(f"file{i}.ts | 100 | 100 | 100 | 100" for i in range(40))
254+
+ '\nJest: "global" coverage threshold for branches (82%) not met: 79%'
255+
)
256+
proc = self._completed(1, stdout=stdout, stderr="")
257+
logs = self._run_capturing_logs(proc)
258+
blob = "\n".join(text for _, text in logs)
259+
assert "coverage threshold for branches" in blob
260+
261+
def test_benign_zero_errors_line_not_surfaced_as_failure(self):
262+
# "0 errors" / "no error" must NOT be pulled in as a failure marker.
263+
stdout = "eslint: 0 errors, 0 warnings\n" + "\n".join(f"ok {i}" for i in range(20))
264+
proc = self._completed(1, stdout=stdout, stderr="")
265+
logs = self._run_capturing_logs(proc)
266+
# It falls back to tail (no real failure markers); the "0 errors" line is
267+
# not falsely elevated as THE failure.
268+
blob = "\n".join(text for _, text in logs)
269+
assert "ok 19" in blob
270+
271+
def test_marker_line_with_noise_term_is_filtered_by_allowlist(self):
272+
# N3: the LOAD-BEARING allowlist case. A line that hits a real marker
273+
# (`error:`) AND a noise term (`0 errors`) must be filtered OUT of the
274+
# surfaced set. Placed in the MIDDLE (before the tail window) so it is
275+
# only reachable via the marker scan — if _FAILURE_LINE_NOISE were
276+
# deleted, this line WOULD be surfaced and the assertion would fail.
277+
# A genuine failure line follows so the scan still produces a match set.
278+
noisy = "error: eslint produced 0 errors after autofix retry" # marker + noise
279+
real = "[//cdk:test] FAIL test/handlers/bar.test.ts — boom"
280+
# 30 trailing passing lines push both lines above out of the tail window,
281+
# so `noisy` is only reachable via the marker scan (where the allowlist runs).
282+
stdout = f"{noisy}\n{real}\n" + "\n".join(f"[//agent:test] passing {i}" for i in range(30))
283+
proc = self._completed(1, stdout=stdout, stderr="")
284+
logs = self._run_capturing_logs(proc)
285+
blob = "\n".join(text for _, text in logs)
286+
assert "FAIL test/handlers/bar.test.ts" in blob # real failure surfaced
287+
assert "0 errors after autofix retry" not in blob # noisy marker filtered by allowlist
288+
289+
def test_surfaced_failure_lines_are_capped_and_truncation_marked(self):
290+
# N4: a genuinely huge red run (more than _MAX_SURFACED_FAILURE_LINES
291+
# marker lines) must be capped, with an explicit truncation breadcrumb —
292+
# so it can't flood CloudWatch. Exercises the cap branch the other tests
293+
# never reach.
294+
from shell import _MAX_SURFACED_FAILURE_LINES
295+
296+
n = _MAX_SURFACED_FAILURE_LINES + 10
297+
stdout = "\n".join(f"FAIL test/case_{i}.test.ts — assertion {i}" for i in range(n))
298+
proc = self._completed(1, stdout=stdout, stderr="")
299+
logs = self._run_capturing_logs(proc)
300+
blob = "\n".join(text for _, text in logs)
301+
# The marker-scan hit the cap and emitted the truncation breadcrumb.
302+
assert "… (more failure lines truncated)" in blob
303+
# The breadcrumb appears within the surfaced set BEFORE the tail divider —
304+
# i.e. the matched-line scan was capped, not the whole output dumped.
305+
head = blob.split("--- (trailing output) ---")[0]
306+
head_cases = sum(1 for i in range(n) if f"case_{i}.test.ts" in head)
307+
assert head_cases <= _MAX_SURFACED_FAILURE_LINES
308+
309+
def test_stdout_is_redacted(self):
310+
proc = self._completed(1, stdout="error: pushing with ghp_supersecrettoken123", stderr="")
311+
logs = self._run_capturing_logs(proc)
312+
blob = "\n".join(text for _, text in logs)
313+
assert "ghp_supersecrettoken123" not in blob
314+
315+
def test_success_does_not_dump_stdout(self):
316+
# On success we don't spam stdout — only the OK line.
317+
proc = self._completed(0, stdout="lots of build output", stderr="")
318+
logs = self._run_capturing_logs(proc)
319+
blob = "\n".join(text for _, text in logs)
320+
assert "lots of build output" not in blob

cdk/src/constructs/ecs-agent-cluster.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,13 @@ export class EcsAgentCluster extends Construct {
184184
LOG_GROUP_NAME: logGroup.logGroupName,
185185
GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecret.secretArn,
186186
// Heavy CI-parity builds on this big-box substrate legitimately run
187-
// longer than the 1800s default (ABCA's own `mise run build` is
188-
// ~50 min cold). Raise the post-agent build-verify cap so a slow-but-
189-
// healthy build isn't mis-reported as a timeout (see post_hooks.py
190-
// BUILD_VERIFY_TIMEOUT_S). ECS-only: AgentCore repos keep the default.
187+
// long (ABCA's own `mise run build` is ~50 min cold), so set a generous
188+
// post-agent build-verify cap here for the ECS-only path. NOTE: the
189+
// consuming side (verify_build/verify_lint reading BUILD_VERIFY_TIMEOUT_S
190+
// and passing it as the subprocess timeout) ships with the ECS-substrate
191+
// work; on `main` today the verify subprocess still uses run_cmd's
192+
// default cap, so this env is provisioned ahead of the wiring rather
193+
// than currently effective. ECS-only: AgentCore repos don't set it.
191194
BUILD_VERIFY_TIMEOUT_S: '3600',
192195
...(props.memoryId && { MEMORY_ID: props.memoryId }),
193196
// #502: the payload bucket name so the orchestrator-issued

cdk/src/handlers/shared/error-classifier.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,32 @@ const PATTERNS: readonly ErrorPattern[] = [
433433
},
434434

435435
// --- Timeout ---
436+
{
437+
// An ``agent/`` subprocess (``run_cmd``) hit its wall-clock cap and Python
438+
// raised an uncaught ``TimeoutExpired … timed out after N seconds``, which
439+
// otherwise surfaces as a bare "Unexpected error". This is NOT a code
440+
// failure — the command didn't fail, it ran too long — so name it precisely
441+
// and point at "slow, retry", not the diff. NOTE: this matches only
442+
// UNCAUGHT ``run_cmd`` timeouts (clone/setup/etc.); the post-agent build/lint
443+
// VERIFY path catches ``TimeoutExpired`` itself and returns a plain build
444+
// failure, so it does not reach here. The remedy is deliberately generic
445+
// ("retry / it may be slow") rather than naming a specific env knob — on
446+
// ``main`` the verify timeout is not operator-tunable, so promising a lever
447+
// here would misdirect (the tunable ``BUILD_VERIFY_TIMEOUT_S`` + larger ECS
448+
// build compute live on the ECS-substrate track, not this branch).
449+
pattern: /TimeoutExpired.*timed out after \d+(?:\.\d+)? ?s(econds)?|Command .*build.* timed out/i,
450+
classification: {
451+
category: ErrorCategory.TIMEOUT,
452+
title: 'Build/tests didn\'t finish in time (timed out)',
453+
description: 'The configured build/verify command was still running when it hit the time limit and was stopped — it did not fail, it ran too long.',
454+
remedy: 'This is usually a slow build, not broken code — retry, since a one-off may just have been slow. If a repo\'s build is legitimately long, its build environment likely needs more time or capacity; contact your ABCA admin.',
455+
retryable: true,
456+
// Intentionally USER (no auto-retry): re-running an unchanged slow build
457+
// just times out again, so this must NOT be TRANSIENT — a human decides
458+
// whether to retry or right-size the build. Do not "fix" this to TRANSIENT.
459+
errorClass: ErrorClass.USER,
460+
},
461+
},
436462
{
437463
pattern: /poll timeout exceeded/i,
438464
classification: {

cdk/test/handlers/shared/error-classifier.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,22 @@ describe('classifyError', () => {
407407
expect(result!.title).toBe('Task timed out');
408408
expect(result!.retryable).toBe(false);
409409
});
410+
411+
test('classifies a build/verify command TIMEOUT distinctly from a crash (ABCA-667 live-caught)', () => {
412+
// The fork's full `mise run build` exceeded the 600s cap → Python
413+
// TimeoutExpired. Before this pattern it fell to "Unexpected error"; now it
414+
// reads as a build-time-out (user-actionable: retry / raise the cap), not a
415+
// mysterious crash.
416+
const result = classifyError(
417+
"TimeoutExpired: Command '['bash', '-lc', 'mise run install && MISE_EXPERIMENTAL=1 mise run build']' timed out after 600 seconds",
418+
);
419+
expect(result!.category).toBe(ErrorCategory.TIMEOUT);
420+
expect(result!.title).toMatch(/didn't finish in time|timed out/i);
421+
// A timeout is user-actionable (retry / raise the cap), not a hard failure.
422+
expect(result!.retryable).toBe(true);
423+
// Must NOT fall through to the generic Unexpected error.
424+
expect(result!.title).not.toMatch(/Unexpected error/i);
425+
});
410426
});
411427

412428
// --- Environmental blockers (#251) ---

0 commit comments

Comments
 (0)