fix(ecs): write task payload to S3 instead of 8 KB containerOverrides (#502)#503
Conversation
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, #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 #502
scottschreckengaust
left a comment
There was a problem hiding this comment.
Review — Principal Architect (Stack A: ECS substrate, PR 1/4 · reviewed bottom-up)
Base verified against fresh origin/main. Bootstrap ADR-002 coverage independently verified for the stack. All mandatory agents run (see the stack-tip review on #596 for the full agent table).
Verdict: Approve ✅
Clean solution to the 8 KB RunTask containerOverrides limit. A dedicated ephemeral payload bucket (1-day TTL, BLOCK_ALL, enforceSSL, S3_MANAGED, abort-incomplete-MPU) with a read-only task-role grant — and an explicit test asserting the task role gets s3:GetObject* but never s3:Put*/s3:Delete*. The orchestrator gets write+delete; deleteEcsPayload is best-effort with the lifecycle rule as the backstop. Stack wiring in agent.ts stays commented out here, so this PR synthesizes no new resources on its own — the deploy-time surface only appears in #596 (where I verified bootstrap coverage: the auto-named bucket falls under arn:aws:s3:::backgroundagent-dev-*, no AccessDenied gap).
Governance: fully compliant — branch fix/502-ecs-payload-s3-pointer, issue #502 carries the approved label.
Non-blocking (but fix it in the stack — it bites once wired)
- Dead, self-contradicting export.
cdk/src/constructs/ecs-payload-bucket.ts—export const ECS_PAYLOAD_OBJECT_KEY_PREFIX = ''is referenced nowhere (verified: only its own declaration;ecsPayloadKey()builds the key independently, and the test importsECS_PAYLOAD_TTL_DAYS, not this). It's a "prefix" whose value is''with a docstring describing a key layout it doesn't produce. Under the rootknip.json(project: ["src/**/*.ts!"], production-only) it raises the knip count 78→79, andscripts/check-deadcode-ratchet.mjsprocess.exit(1)s above baseline. The CI "Dead-code detection" job iscontinue-on-error/ advisory (won't fail the check), but the same ratchet runs insidemise run build, which this repo requires green before commit — so it breaks the required build. Introduced here, reachable once building. Fix: delete the constant + its doc block; fold the key-layout note intoecsPayloadKey()'s docstring (the real source of truth). Flagged as a blocking item on #596 too, since that's where the wiring lands — fix once, anywhere in the stack.
Tests
ecs-payload-bucket.test.ts is comprehensive (encryption, TLS-only policy, TTL, abort-MPU, DESTROY/autoDelete + custom-props override). ecs-strategy.test.ts covers the S3-pointer path (via jest.isolateModules), the inline fallback, and deleteEcsPayload delete + swallow-errors + no-op-without-bucket. One stack-level test-seam gap (verified in the #596 review): deleteEcsPayload is tested in isolation but nothing asserts the orchestrator's finalize step calls it only for compute_type==='ecs', and task-orchestrator.test.ts asserts neither the ECS_PAYLOAD_BUCKET env nor the grantDelete. Non-blocking follow-up.
Bootstrap synth-coverage: N/A for this PR (no resources synthesized until #596 flips the gate).
🤖 Generated with Claude Code
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.
Review follow-ups for #596 (fix itself unchanged; B1 dead-export fixed in the base PR #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.
|
Addressed the dead-export nit in One thing worth flagging that the review's delta-based check didn't surface: the dead-code ratchet is already failing on |
|
Filed #607 for the pre-existing dead-code ratchet failure on |
scottschreckengaust
left a comment
There was a problem hiding this comment.
Verdict: ✅ Approve
Clean, well-scoped bug fix for #502 (RunTask rejecting every ECS task at the 8 KB containerOverrides cap). Stashes the payload in a dedicated S3 bucket and passes an AGENT_PAYLOAD_S3_URI pointer, with an inline fallback for small payloads. ECS stays inert on main — the agent.ts scaffolding remains commented — so this fixes the latent strategy bug and plumbing without flipping the backend on. Governance is clean: #502 carries approved, branch fix/502-ecs-payload-s3-pointer conforms, CI green, MERGEABLE.
Vision alignment
Advances bounded blast radius: dedicated bucket (BLOCK_ALL + enforceSSL + S3_MANAGED), 1-day TTL, task role read-only (untrusted repo code can't delete other tasks' payloads), orchestrator owns write+delete. Correctly separated trust boundaries.
Bootstrap coverage — not required (verified)
EcsPayloadBucket is an AWS::S3::Bucket with a CDK-generated backgroundagent-dev-* name, already covered by observability.ts S3ApplicationBuckets (CreateBucket / PutBucketPolicy / PutLifecycleConfiguration / PutEncryptionConfiguration / PutBucketPublicAccessBlock all present) and resource-action-map.ts:93. It introduces no new CFN type, and it's ECS-gated so it never reaches synth-coverage.test.ts (default-context synth). No BOOTSTRAP_VERSION bump or golden-baseline change needed. Same pattern as the existing TraceArtifactsBucket/AttachmentsBucket.
Non-blocking nits (follow-ups; several already tracked in #608)
ecs-strategy.tslarge-payload branch — whenECS_PAYLOAD_BUCKETis unset and the payload exceedsINLINE_PAYLOAD_WARN_BYTES, it logs a WARN then proceeds into the inline path, where RunTask still throws the rawInvalidParameterException. The actionable cause isn't attached to the task's terminal error. This state is unreachable via the supported deploy (bucket + cluster are co-gated in #596), so it's latent — but consider failing fast with the cause+remedy instead of WARN-then-proceed if inline stays a supported mode.- Test seams (non-blocking): the large-payload WARN branch and the
orchestrate-task.tsfinalizedeleteEcsPayloadcall (fires only forcompute_type==='ecs') are untested; the boot-command S3-fetch one-liner is asserted only as a string. Tracked in #608.
Review agents run
code-reviewer,silent-failure-hunter,pr-test-analyzer,comment-analyzer✅ — no blocking defects.deleteEcsPayloadbest-effort swallow is correct (post-terminal, logged, 1-day lifecycle backstop). S3 URI parse (_uri.split("/",3)[2]/[3]) is correct fors3://bucket/keyand injection-safe (task_id is a ULID).- Bootstrap ADR-002 coverage ✅ (see above).
type-design-analyzer⏭️ — no new exported types of substance (props interfaces consumed in-file).
🤖 Reviewed with Claude Code
Summary
Fixes #502 — the ECS/Fargate compute strategy rejected every real task with:
Root cause:
ecs-strategy.tsinlined the entire orchestrator payload (incl. the largehydrated_context) into theAGENT_PAYLOADcontainer-override env var. ECSRunTaskcaps the totalcontainerOverridesblob at 8192 bytes, so the call failed before the container started. AgentCore is unaffected — it passes the payload in theInvokeAgentRuntimerequest body, which has no comparable limit. The earlier ECS smoke test (#494, a small Rustcargo check) had a payload that fit under 8 KB, so it didn't surface this.Fix — pass a pointer, not the payload
EcsPayloadBucket(new)TraceArtifactsBucket:BLOCK_ALL+enforceSSL+S3_MANAGED, 1-day lifecycle TTL. Dedicated (not co-tenant with attachments/traces) so the task role's read is scoped to payloads only.ecs-strategyPutObjectpayload →<task_id>/payload.json; passAGENT_PAYLOAD_S3_URIin the override; boot command fetches via boto3. InlineAGENT_PAYLOADkept as fallback (small payloads / no bucket) — no regression.deleteEcsPayloadhelper.orchestrate-taskfinalizedeleteEcsPayloadfor ECS tasks on terminal; the 1-day TTL is the crash backstop.EcsAgentClusterpayloadBucket, injectECS_PAYLOAD_BUCKET, grant task role read-only (untrusted repo code must not write/delete; the trusted orchestrator owns write+delete). Session-role-aware.task-orchestratorecsPayloadBucketprop →grantPut+grantDelete;@aws-sdk/client-s3added to bundling externals.agent.tsSecurity stance
Testing
ecs-payload-bucket.test.ts: TTL=1d, SSL-only, block-public, autoDelete.ecs-strategy.test.ts: S3 write +AGENT_PAYLOAD_S3_URIpointer (no inline blob); inline fallback when no bucket; boot command fetch-from-S3-with-fallback;deleteEcsPayloaddelete + best-effort swallow + no-op without bucket.ecs-agent-cluster.test.ts:ECS_PAYLOAD_BUCKETenv, task role read-only (asserts nos3:Put*/s3:Delete*), omitted when no bucket.mise run buildgreen (cdk tests + agent + cli + docs + synth + lint).Live verification (dev, ECS wired)
Deployed to a dev stack with
--context compute_type=ecsand fired a real fork task:compute_type=ecs,session_id= a real ECS task ARN →RunTasksucceeded (the prior tasks died here withInvalidParameterException).payload.json= 8455 bytes — above the 8192-bytecontainerOverridescap, i.e. exactly the payload that would have failed inline.Using hydrated context from orchestrator, then cloned/branched/ran the build — the agent received and parsed the full payload via the S3 pointer.s3:PutObject/DeleteObject; ECS task role read-only.Notes / scope
main(theagent.tswiring is the existing commented uncomment-to-enable scaffolding). This PR fixes the latent strategy bug + plumbing; it does not flip ECS on. Complementary to fix: two bugs that prevent ECS Fargate from working #494.Closes #502
🤖 Generated with Claude Code