Conversation
…ls yet" Delivery now narrows a connection to the payload kinds it declares and defaults to flags (launchdarkly/streamer#4730), so the store has to ask for the agent-skill payload or receive the environment's flags and no skills at all. The declaration goes on every request, before any basis exists as well as alongside one: it selects what the connection is served rather than describing what the store already holds. It also fixes something that was already wrong. A skill-enabled environment assigns two payloads, so the reader has been warning about the second and reading only the first intent, and the flag payload was re-downloaded and discarded on every reconnect because its basis was never adopted. Declaring one kind makes the connection single-payload, which is the shape the reader is built for. The 422 that comes with it is the interesting half. It is the answer when the credential is assigned no agent-skill payload, which is every project where no skill has ever been created -- gonfalon creates that row with the first skill and never lazily. As an ordinary recoverable failure it would spend maxConsecutiveFailures and then report "gave up after N consecutive failures: LaunchDarkly returned HTTP 422" for an ordinary configuration; as a fatal one, the skill created a minute later would never arrive without a process restart. So it is its own class: NoSkillPayloadError, which reuses the existing `expected` flag to stay off connectionFailures, lastError, failed and the per-attempt warning, is said once per store, counted under the new payloadUnavailable diagnostic, and retried at maxBackoffMs indefinitely. The retry is at the cap because `failures` deliberately never moves, so the exponential schedule would otherwise sit at the initial delay forever. Seven tests, each of which fails with the source reverted: the declaration on both endpoints and on both hosts, the two kind constants held apart by source text, the 422's classification, that it never stops delivery and never counts, that it waits the cap and not the initial delay, that a skill arriving after it is picked up, and that it leaves the store uninitialized so a wildcard reconcile prunes nothing. The fake endpoint gained a standing default status, since "every request is answered 422" is not something a queue can express. Also corrects docs that described the payload as classified `generic`, and the `mv` rationale: delivery overrides the requested model version for any non-flagging payload rather than refusing the connection over it. Gate: tsc --noEmit clean, biome check clean, vitest run 1024 passed / 10 skipped in packages/client (the ten are the capability-gated TOCTOU tests), and every workspace's suite green from the root. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mirrors the Python trim (launchdarkly/python-ai-sdk#109): the same sentence had been repeated at every site that touches the 422, so NoSkillPayloadError now owns the explanation and the others state only what is local to them — the diagnostic links the type and keeps the "not a connectionFailures" distinction, the loop keeps why it waits the cap, and classifyStatus keeps nothing, since the type it returns and the message it builds already say it twice over. No behaviour change, and the user-facing 422 message is untouched. 12 lines of comment removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…payload Not this PR's change -- the case is byte-identical to the one on the base branch -- but it went red on this PR's CI run and will keep doing so on a loaded runner. It queued the good payload and the hashless one up front, 20ms apart, then relied on `waitForSkills` before reconciling. `waitForSkills` promises the *first* commit and nothing about the second, so any pause longer than the poll interval between it and the first `writeSkills` -- an `mkdtemp` on a busy runner is enough -- lets the hashless payload commit first. Verification then withholds every object, the reconcile writes nothing, and the assertion fails as `ENOENT` on the read rather than as anything that names the cause. Queueing the hashless payload only after the first reconcile has been asserted removes the race without changing what the case asserts. Reproduced before the fix by standing a 60ms sleep in for the runner (identical ENOENT), and the fixed case survives 300ms in the same spot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Stacks on #67. Spec: ai-sdks-monorepo#23. Python counterpart: python-ai-sdk#109. Server side: streamer#4730.
This is the SDK half of the release blocker: other SDKs retry forever when they see a skill payload. Flag Delivery's answer is
?kinds=, which narrows a connection to the payload kinds it declares, defaulting to{flagging}.The declaration
Every request now carries
kinds=agent-skill— both endpoints, and on the first request as well as the ones after it, since it selects what the connection is served rather than describing what the store already holds. Without it the store receives the environment's flag payload and no skills at all, so this is a functional requirement rather than a courtesy.It also fixes something that was already wrong. A skill-enabled environment assigns two payloads, so
ProtocolReaderhas been hittingwarnMultiplePayloadson every connection, reading only the first intent, and never adopting a basis for the flag payload — re-downloading and discarding it on every reconnect. Declaring one kind makes the connection single-payload, which is the shape the reader is built for. (That is also why declaringflagging,agent-skillis not the safe-looking option it appears to be.)No
mv, still — but for a corrected reason. It selects the flag data model, andobjectQueryForCommandoverrides whatever a request asks for with the payload's own default for any non-flagging payload, so sending it would state a preference that is ignored. The old comment said the connection would be refused over it.The 422
Delivery answers 422
no_accepted_payloadswhen the credential's assignment holds no payload of the declared kind. That is every project in which no skill has ever been created: gonfalon'spayloadIDsForEnvironmentappends the agent-skill payload ID only "if one already exists" and never lazily creates it.Both obvious classifications are wrong. Reverting just the 422 case and rerunning the new tests gives you the recoverable reading:
gave up after N consecutive failures: LaunchDarkly returned HTTP 422, permanently, for an environment whose only problem is that nobody has made a skill yet. Fatal is no better: the skill created a minute later never arrives until the process restarts.So
NoSkillPayloadError, which reuses theexpectedflag the recycled-stream case already established:StoreDiagnostics.payloadUnavailable;maxBackoffMsindefinitely — at the cap becausefailuresdeliberately never moves, so the exponential schedule would otherwise sit at the initial delay forever;connectionFailures,lastError,failedand the per-attempt warning, whichexpectedhandles for free.It commits no payload, so
isInitialized()stays false andwriteSkills('*')still withholds the prune — the readiness gate #67 added, doing exactly what it was added for. "LaunchDarkly has no skill payload for this environment" and "this environment's every skill was revoked" are the two readings of an empty answer, and only the second may delete a customer's files.Tests
Seven, each of which fails with the source reverted (checked one mutation at a time, including unpinning the delay and removing the log-once guard separately):
/sdk/poll(first request and the one carrying a basis) and on/sdk/stream, plus the fourendpointsURL assertions, which now pin the full query string on both hosts;FDV2_PAYLOAD_KINDheld apart fromFDV2_OBJECT_KINDby source text, the way the seam-kind test already does it — an alias would satisfy a value assertion on its own;maxConsecutiveFailures: 1neither stops delivery nor counts, and is said exactly once;The fake endpoint gained
defaultPollStatus, since "every request is answered 422" is not something a queue can express.Gate
tsc --noEmitbiome check .vitest runinpackages/clientyarn testfrom the root🤖 Generated with Claude Code
Note
Overview
FDv2 skill delivery now declares
kinds=agent-skillon every poll and stream request so LaunchDarkly serves the agent-skill payload instead of defaulting to flags. A newFDV2_PAYLOAD_KINDconstant is kept separate from wire object kindskill, andStoreDiagnosticsgainspayloadUnavailablefor this idle state.HTTP 422 (“no payload of that kind”) is treated as no skills yet, not a broken connection:
NoSkillPayloadErrorincrementspayloadUnavailable, logs once, retries indefinitely atmaxBackoffMswithout touchingconnectionFailures,lastError, orfailed. The store stays uninitialized, so wildcard reconciles still do not prune disk.Docs (
README,agents.md) andskills-fdv2tests cover the query param on both endpoints, 422 classification, backoff behavior, recovery when a payload appears, and reconcile safety.Reviewed by Cursor Bugbot for commit dae8130. Bugbot is set up for automated code reviews on this repo. Configure here.