Deploy strata-qa as a container-image Lambda behind a Function URL - #39
Merged
baonguyenNava merged 22 commits intoJul 27, 2026
Merged
Conversation
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
marked this pull request as ready for review
July 27, 2026 22:57
This was referenced Jul 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
QaResultJSON comes back.Relates to #30
How
Two thin modules over the already-tested
runQa()seam, with no new QA logic.src/lambda/core.tsexposes an HTTP-freehandleQuestion(job, seam, config), the seam a future Slack dispatcher calls after its 3-second ACK.src/lambda/handler.tsadapts 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_MSbounds each agent call, butrunQacan 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 reachesrecycle(), 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 tighteningdeploy.sh's arithmetic keeps the guarantee independent of how many bounded callsrunQahappens to make.The recycle exists because
withTimeoutraces 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/sdkbinary are baked into the image at build time from the repo-root context.deploy.shscripts 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 staleCURSOR_API_KEYcannot overwrite a working key — a live hazard here, not a hypothetical one.One pre-existing file changed beyond a moved constant:
log.tsnow swallows filesystem errors to stderr, becauserunQalogs 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 existingAgentSeamfakes, no live model calls);npm run buildandtsc --noEmitcompile clean;python -m pytest65/65;lint_manifest,lint_docs, andbuild_grapheach print their_OKsentinel with no resulting graph diff.HOME=/tmp, returning a grounded answer in 12.1s at ~117 MiB peak RSS;docsVersioncorrectly falls back to thesha256:hash since the image has no.git.answered(~10.5s), bad body 400, one structured CloudWatch log line per invocation carryinggitSha, noEROFSanywhere.deploy.shagainst a live AWS account. Syntax-checked withbash -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/sdkstays pinned at exactly 1.0.24: plan-mode is the only runtime containment for attacker-supplied questions, and a bump requires re-running thePWNED.txtprobe fromNOTES.md.Invoking the Function URL needs
lambda:InvokeFunctionUrlin the caller's own IAM policy.deploy.shattaches 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
poisonedflag 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 inhandler.tsbeside 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
replyTois 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.