Skip to content

Deploy strata-qa as a container-image Lambda behind a Function URL - #39

Merged
baonguyenNava merged 22 commits into
baonguyenNava/30-strata-qa-clifrom
baonguyenNava/30-strata-qa-lambda
Jul 27, 2026
Merged

Deploy strata-qa as a container-image Lambda behind a Function URL#39
baonguyenNava merged 22 commits into
baonguyenNava/30-strata-qa-clifrom
baonguyenNava/30-strata-qa-lambda

Conversation

@baonguyenNava

@baonguyenNava baonguyenNava commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

What

Deploys the strata-qa docs Q&A CLI as a container-image AWS Lambda behind an IAM-authed Function URL: a question goes in over HTTPS, the existing QaResult JSON comes back.

Relates to #30

How

Two thin modules over the already-tested runQa() seam, with no new QA logic. src/lambda/core.ts exposes an HTTP-free handleQuestion(job, seam, config), the seam a future Slack dispatcher calls after its 3-second ACK. src/lambda/handler.ts adapts it to the Function URL: request validation (method, base64, 2000-char question cap, model allowlist), exit-code to HTTP mapping (refusals are 200; auth/docs 500, model/parse/transport 502, timeout 504), Secrets Manager key loading with rotation-safe retry, and the two wall-clock guards below.

Timeouts are bounded twice, per call and per invocation. AGENT_TIMEOUT_MS bounds each agent call, but runQa can make two (retrieval, then a tool-less repair) and the handler retries once on auth, so per-call bounds do not sum to a request bound — at 90s per call one request could spend 180s against a Lambda timeout of 120. The handler therefore also derives a whole-invocation budget from the Lambda context's remaining time and bounds the entire run against it. That guarantee matters less for the lost answer than for the recycle below: a Lambda hard-kill is the one path that returns no 504 and never reaches recycle(), so it would leave the orphaned agent alive in exactly the container the recycle exists to replace. Bounding the invocation from the context rather than tightening deploy.sh's arithmetic keeps the guarantee independent of how many bounded calls runQa happens to make.

The recycle exists because withTimeout races but never cancels the agent; on Lambda's freeze/thaw the orphan would resume inside the next invocation, so the handler poisons the container and lets Lambda replace it.

Docs, the CLI, and the Linux @cursor/sdk binary are baked into the image at build time from the repo-root context. deploy.sh scripts ECR, Secrets Manager, IAM, the function (reserved concurrency 3 as a spend ceiling), and the Function URL. Writing the secret is opt-in after the first deploy (ROTATE_SECRET=1), so a redeploy from a shell holding a stale CURSOR_API_KEY cannot overwrite a working key — a live hazard here, not a hypothetical one.

One pre-existing file changed beyond a moved constant: log.ts now swallows filesystem errors to stderr, because runQa logs on its success path and Lambda's filesystem is read-only outside /tmp, so a failed log write must not destroy a paid-for answer.

Test plan

  • npm test: 160/160 vitest units pass (67 Lambda tests, all against the existing AgentSeam fakes, no live model calls); npm run build and tsc --noEmit compile clean; python -m pytest 65/65; lint_manifest, lint_docs, and build_graph each print their _OK sentinel with no resulting graph diff.
  • Gate 1 (SDK feasibility): the arm64 image runs plan-mode retrieval with a read-only root filesystem and HOME=/tmp, returning a grounded answer in 12.1s at ~117 MiB peak RSS; docsVersion correctly falls back to the sha256: hash since the image has no .git.
  • Gate 2 (orphan/recycle) via the Runtime Interface Emulator: a forced 3s timeout returned a complete 504 body with the requestId echoed, the poisoned container exited, and the post-reset invocation was clean with zero log chatter from the orphaned run.
  • RIE smoke: happy path 200 answered (~10.5s), bad body 400, one structured CloudWatch log line per invocation carrying gitSha, no EROFS anywhere.
  • deploy.sh against a live AWS account. Syntax-checked with bash -n; the live deploy and the SigV4 smoke have not been run, so the whole script is unexercised end to end.

Notes for reviewers

Stacked on #38 (baonguyenNava/30-strata-qa-cli), which adds the CLI this wraps, so merge that first; only the 16 commits after that branch point are new here.

@cursor/sdk stays pinned at exactly 1.0.24: plan-mode is the only runtime containment for attacker-supplied questions, and a bump requires re-running the PWNED.txt probe from NOTES.md.

Invoking the Function URL needs lambda:InvokeFunctionUrl in the caller's own IAM policy. deploy.sh attaches no resource policy, so without it the URL answers 403 before the handler ever runs — easy to mistake for a broken deploy on a first smoke test.

The poisoned flag sacrifices one invocation after each timeout by design; timeouts are rare (9-14s typical answers against a 90s bound). The fallback if it ever proves unreliable in production — fork a worker per invocation and SIGKILL it on timeout, true cancellation for a ~100ms spawn — is described inline in handler.ts beside the recycle.

Two known deviations from the ideal, both cheap to live with for this slice. The auth-retry path emits a second structured log line, so "one line per invocation" holds everywhere except a retry. And replyTo is accepted, type-checked, and then ignored; it is reserved for the async dispatcher and will need URL allowlisting rather than a type check once something actually fetches it.

Known follow-up: /tmp/.cursor (SDK session state) grows ~3 MB per answered invocation on a warm container, roughly 330 invocations to the 1 GB tmpfs cap, so a post-invocation cleanup is a candidate for the Slack slice.

baonguyenNava and others added 22 commits July 24, 2026 10:15
Why: capture the approved brainstorming design before implementation.
Covers the container-image Lambda, Function URL, direct runQa handler,
exit-code to HTTP mapping, latency bounding, and the runtime-feasibility
spike gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Why: bite-sized TDD task breakdown derived from the approved design spec,
gated on a runtime-feasibility spike.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Image builds on the Lambda nodejs:22 base with the linux-arm64 @cursor/sdk
binary (no darwin one). The live read-only plan-mode probe is still pending:
the only available CURSOR_API_KEY is a Team key, which the SDK rejects for
non-Admin calls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A failed log write could turn a successful answer into an error. On Lambda,
where everything outside /tmp is read-only and runQa logs on its success
path, that would have failed every good response.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AGENT_TIMEOUT_MS bounds one agent call, but runQa can make two (the
retrieval call, then the tool-less repair) and the handler retries once
on an auth failure. Per-call bounds therefore do not sum to a request
bound: at 90s per call one request can spend 180s against a Lambda
timeout of 120 — and it is reachable, since the repair only runs
when the retrieval call already succeeded.

The lost answer is not the problem. A Lambda hard-kill is the one path
that returns no 504 and never reaches recycle(), so it leaves the
orphaned agent alive in exactly the container the recycle exists to
replace: the failure the recycle was built for, arrived at by the one
route that bypasses it.

Tightening the deploy.sh guard to 2x would have held today and rotted
tomorrow, since it couples the deploy script to how many bounded calls
runQa happens to make. Deriving the budget from the Lambda context's
remaining time holds whatever runQa does internally, so the guard stays
only as the per-call bound it always was.

errorResult is exported from run.ts so the synthesized timeout outcome
does not hand-copy the empty-grounding shape and drift as QaResult
grows.

Relates to #30

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
put-secret-value ran unconditionally, so a code-only redeploy from a
shell holding a stale CURSOR_API_KEY replaced a working secret with a
broken one and took the live function down. Stale keys in
non-interactive shells are a known hazard in this repo, which makes
that an accident waiting to happen rather than a theoretical one.

Writing the secret is now opt-in after the first deploy, and the key
requirement moved from one unconditional check to the two branches that
actually need it — so a routine redeploy does not need the key at all.

Also make a failed create-function exit non-zero. `&& break` hides the
failure from set -e, so five failed attempts fell through to
`wait function-active-v2`, which reported a confusing ResourceNotFound
instead of the real error.

Relates to #30

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The handler pointed at a design spec that c853e68 deleted, orphaning the
only record of the fork-a-worker fallback. Describe the fallback inline
instead, including its cost and why it lost, so the decision survives
without the file.

The build-only command omitted --platform, so following it on an x86
host silently produces an amd64 image the arm64 function cannot run.

README gains the rotation flow, the distinction between the per-call and
per-invocation timeouts, and the lambda:InvokeFunctionUrl the caller
needs. Without that permission the Function URL answers 403 before the
handler runs, which reads as an unexplained failure on a first smoke
test.

Relates to #30

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
runQa re-ran the whole preflight for every question: two REST round
trips (Cursor.me, models.list), a 56KB graph.json reparse, and a
computeDocsVersion that spawns git -- which the Lambda image has
neither a .git directory nor a git binary for, so it paid a failed
spawn and then SHA-256'd that same 56KB, per request. Measured at
~303ms warm and ~1238ms cold against the live API, for answers that
cannot change while a container lives.

Two invariants make the caches safe rather than merely faster:

The preflight cache is keyed by the API key and stores only success.
Keying on the key means the KeyLoader's rotation rewrite of
process.env.CURSOR_API_KEY is a cache miss by construction. Refusing
to cache failure is what preserves handleEvent's invalidate-and-retry
path: a cached `false` would turn a recoverable rotation into a hard
EXIT.AUTH, and a cached models.list throw would hide EXIT.TRANSPORT
behind a stale list. A TTL bounds the one remaining staleness window,
a key revoked server-side while its string is unchanged.

The graph caches key on docsRoot and hand back a defensive copy of the
node-path set, so one request cannot leak an edit into the next. They
assume docs under a root are immutable for the life of the process,
which holds where it matters: the image is immutable and the CLI is
one-shot.

The seam and config move to module scope for the same reason -- the
seam is stateless now that its cache is module-level, so rebuilding
both per invocation was pure allocation.

Relates to #30
The container poison-and-recycle rested on an assumption the SDK does
not actually justify: that a run can only be abandoned, never stopped.
That is true of Agent.prompt, which is documented as "create an agent,
run one prompt, and close" and resolves only once the run is over, so a
wall-clock race around it has no handle to act on. It is not true of the
SDK -- Agent.create -> send() hands back a Run while it is still going,
and that Run can be cancelled.

Probed live before committing to it (scripts/cancel-probe.ts, findings
in NOTES.md): send() returned a handle in 1643ms with status "running",
supports("cancel") is true for a LOCAL run, cancel() resolved in 4ms and
flipped the status to "cancelled", and wait() then resolves with that
terminal status rather than rejecting. Thirteen agent events arrived
before the cancel and none in the four seconds after it, which is the
evidence that the work stopped rather than merely being relabelled.

The process-tree check came back inconclusive and stays that way for a
reason worth recording: in plan mode the local runtime spawns no child
process at all, so there is no pid to reap. That also retires the
fork-and-SIGKILL fallback the old comment proposed.

The recycle survives as the fallback, because cancellation can fail: a
future SDK could return false from supports("cancel"), or cancel() could
throw. Abandonment is still unsurvivable on Lambda, so that case still
needs the container destroyed. TimeoutError.cancelled carries which
happened, and a run leaves the active set only once it is KNOWN to have
stopped -- deleting it unconditionally would report a clean container
while an uncancellable run was still going, which is the precise failure
the recycle exists to prevent.

Relates to #30
The timeout line added with run cancellation never fired in production.
`handler` passes no `emit`, and handleEvent left it optional, so
`emit?.(...)` resolved to nothing on exactly the path whose behavior we
most need to observe -- whether a timeout cancelled its run or fell back
to destroying the container. core.ts already defaults the same seam to
console.log; handleEvent now matches it, and the call site is
unconditional so a future undefined default fails loudly instead of
going quiet.

Found by forcing a timeout against the deployed function and finding no
event line in CloudWatch.

Relates to #30
@baonguyenNava
baonguyenNava marked this pull request as ready for review July 27, 2026 22:57
@baonguyenNava
baonguyenNava merged commit 33c3325 into baonguyenNava/30-strata-qa-cli Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant