Conversation
Builds the real transport behind the existing SkillStore seam, on
LaunchDarkly's SDK-facing FDv2 channel. Nothing above the seam changed:
the accessors, integrity verification, and write_skills are untouched,
which is the point of the seam and the evidence it was drawn correctly.
The design's original Plan A — a bespoke poller against a gonfalon
/private/flagdlv route — is dead. That route is authenticated by Cognito
machine-token OAuth scopes with no per-tenant authorization, and the
security review ruled out both relaxing its auth and shipping a machine
credential to customer hosts. This uses GET /sdk/poll and GET /sdk/stream
with the environment's server-side SDK key instead, which is also the
channel payload signing will eventually cover.
skills_fdv2.py — FDv2SkillStore: authenticate, poll or stream, maintain
selector/basis state, deserialize inline-resource/skill objects, hold
them keyed by (key, objectVersion), and serve the seam. Bounded retries
with capped jittered backoff, Retry-After honoured, If-None-Match/304.
Standard library only, so the content path adds no dependency.
The trap, stated loudly and asserted in both directions: objectVersion is
the skill's own version — the one {key, version} pins — while version is
the payload version. Confusing them fails silently. seam_object_from_put
is the only place the translation happens.
Flag and segment objects arrive on the same connection and are skipped
cleanly rather than erroring; erroring is the unknown-kind reconnect loop
this feature must not reproduce. Changes commit at payload-transferred,
so an interrupted full transfer leaves last known good intact.
contentHash is read from the envelope and verification semantics are
unchanged. The field has not shipped on the write path yet, so its
absence is made loudly diagnosable — an error per object naming
missing_content_hash, a summary per wholly-hashless payload, and a
StoreDiagnostics counter — rather than a silent empty store. There is
deliberately no fallback that skips verification.
skills_watch.py — watch_skills/SkillWatcher pull the change-listener
re-reconcile forward out of phase 4. A delete-object reaches a live
stream in seconds, so a revoked skill's SKILL.md now leaves the disk
within a debounce interval instead of at the next restart, which
materially improves the review's AV-1. on_unavailable="keep" stays the
default, as the review endorses.
Server-side only: a mobile key or client-side environment ID raises from
the constructor. Skill content is customer-confidential and payload
assignment is shared across auth types, so this is the SDK-side half of
that boundary.
close() shuts the socket down under the blocked read rather than only
setting a flag — without it every shutdown of a healthy stream blocks for
the full join timeout.
108 tests against an in-process fake FDv2 endpoint that implements the
contract: put/delete, objectVersion vs version, mixed and unknown-kind
payloads, 304, basis round-tripping, reconnect/backoff, Retry-After,
bounded retries, seam parity with InMemorySkillStore, and a
missing-contentHash envelope producing withheld skills with the right
reason code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This repo is public and customers read the source while consuming the SDK, so the comments, docstrings, and user-visible log strings in the FDv2 transport are reworked to be instructional rather than internal. Removes internal references: the abandoned private-route paragraph, the ticket number and internal setting name that appeared in two log strings customers see (the missing-contentHash error and the HTTP 403 advice, both of which now point at LaunchDarkly support), and the review/design-doc pointers in skills_watch. Removes development history: the "an earlier design did X", "the transport was replaced wholesale", and "deferred to a later phase" framing. Replaces internal jargon with formal terms — "seam" becomes "interface" throughout, along with "duck-typed", "load-bearing", "belt and braces", "blip", and "smuggle in". The 403 test asserted on the internal setting name in the message and now asserts on the replacement wording; TestSeamParity is renamed to TestInterfaceParity. No behaviour changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ng while reporting healthy Stream mode never reset the consecutive-failure count. A streaming connection only ever ends by being dropped, so the reset placed after the loop body returned was unreachable in stream mode, and every healthy server-recycled connection counted as a failure. After max_consecutive_failures + 1 recycles the store gave up for the process lifetime, including revocations. The count now lives on the store and is reset at each committed payload; the reset on a normal return is kept so a polled HTTP 304 still counts as a success. An overflowing Retry-After killed the delivery thread. float() accepts inf and out-of-range literals, and Event.wait(inf) raises OverflowError from inside the retry handler, which no sibling handler catches. The thread died with `failed` still None. The parser now ignores non-finite values, and the honoured delay is clamped to max_backoff so a header cannot park delivery for longer than the cap promises. close() during a slow connect hung for the full join timeout. The connection is published only after stream() returns, so a close() landing in that window had nothing to interrupt and the thread went on to block in the read. The loop now re-checks the stop flag as soon as the connection is published. Tests cover all three in stream mode, which previously had no failure-counting coverage at all. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e, not per process `_warn_hashless` deduped on a module-global set, so a store recreated in the same process (reconnect wrapper, config reload, credential rotation) never reported a hashless `(key, version)` again, and two stores in one process suppressed each other's diagnostics. The set also grew for the life of the process. The docstring promised per-store behaviour the code did not deliver. The dedupe set now lives on `_ProtocolReader`, alongside the per-store `StoreDiagnostics`. The lock is dropped: `handle` only runs under the owning store's lock, on that store's single delivery thread. Tests drive two readers directly to show a recreated store reports again, a re-delivered payload still logs once per store, and two live stores do not quieten each other. The autouse fixture that cleared the global set is gone with the global. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ls honestly FDv2SkillStore accepted connect_timeout and forwarded it to _Requester, which stored it and never read it: both poll and stream passed only read_timeout to urllib. So FDv2SkillStore(key, connect_timeout=2.0) did nothing, and a poll against a host that accepts and never answers hung for read_timeout (300s) rather than the 10s the parameter advertised. Removed rather than wired up. urllib's timeout is the socket timeout for the whole operation, so bounding the connect separately from the reads means a custom connection class and handler in a module that is deliberately standard-library only. One timeout that is honoured beats two where one lies. Removing it leaves the gap it was presumably meant to cover: 300s is right for a stream (heartbeats arrive well inside it, and the timeout is per read) but far too long for a single poll request. read_timeout now defaults per mode — DEFAULT_POLL_TIMEOUT (10s) bounding the whole poll, DEFAULT_STREAM_READ_TIMEOUT (300s) bounding each stream read — and an explicit value overrides either. A non-positive value is rejected. Tests drive a socket that accepts and never responds, and measure that a poll and a stream open both fail in roughly read_timeout; the store records the failure and keeps retrying. The constructor is asserted to reject connect_timeout. README and agents.md document the single timeout and why there is no second one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s store Add the optional remove_listener(kind, fn) half of the SkillStore listener interface to InMemorySkillStore and FDv2SkillStore, and have SkillWatcher.close() unregister notify. Until now a closed watcher stayed in the store's listener list for the store's lifetime, so repeated watchers accumulated and every committed change walked a list of dead listeners. SkillWatcher now takes the store, registers before starting its worker, and detaches once under a guard so close() stays idempotent. A store that offers add_listener but not remove_listener still works; the watcher probes and skips detaching rather than failing the close. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Closes out the remaining convention drift in the FDv2 transport and watcher modules: British spelling for deserialisation/serialised, the one PR-added "seam" in agents.md, a missing `import os` in the README example, and the FDv2SkillStore.close docstring now names the package-level shutdown() coroutine so it no longer reads as a method on the store. Restores the public note that `mv` is the one request parameter not confirmed against a live server. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the FDv2 transport
- Drop the unused `_Change` dataclass. Listeners have always received the
raw skill object or a `{"key", "version"}` tombstone, as documented on
`FDv2SkillStore.add_listener`; nothing constructed or referenced the class.
- Drop the `hashless_before_this_payload` parameter that
`_warn_if_nothing_can_verify` discarded on entry, and the caller's local
that existed only to pass it.
- Drop the unreachable `except _FatalTransportError: raise` clauses in
`_Requester.poll` and `_Requester.stream`. `_classify_status` raises
from inside the sibling `HTTPError` handler, which the same `try` never
catches, and no test double raises it through the opener.
No behaviour change. The recoverable catch-all is untouched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Move the watch_skills / SkillWatcher tests out of test_skills_fdv2.py into test_skills_watch.py, matching the one-test-file-per-module convention. The watcher is wired to the SkillStore interface, not to the FDv2 transport, so the moved tests drive it from InMemorySkillStore and small store doubles. The three cases that only mean something with a transport underneath — a wire-level revocation, an objectVersion bump, and an outage — stay in test_skills_fdv2.py as TestWatchSkillsOverTheTransport. The remove_listener no-op test is a store test and moves to TestListenerRegistration there. No assertion changes. 141 tests before and after (133 + 8). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…essage NO_STORE_MESSAGE offered only InMemorySkillStore, so the first thing a user saw on a missing store pointed them at the development store. Now that FDv2SkillStore is the production path, name it first and keep the in-memory store as what it is: local development and testing. Deferred earlier as "a one-line follow-up rather than touching reviewed code here". That reason no longer holds — adding remove_listener already edits this file — and it is cheaper to fix than to explain. Callers match on "skill store", which the new wording keeps. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ers private
Four module-level helpers carried public names while being used only inside
skills_fdv2.py and its tests, and were never exported from the package root:
is_skill_event -> _is_skill_event
seam_object_from_put -> _store_object_from_put
tombstone_from_delete -> _tombstone_from_delete
backoff_delay -> _backoff_delay
tombstone_from_delete was the clearest tell — a public-looking function
returning the private _Tombstone. They now read consistently with
_SkillObjectSet, _ProtocolReader, and _Requester alongside them.
The rename also retires the last "seam" in the module. The prose pass
converted every sentence but could not reach the identifier, which agents.md
quotes as the single place the objectVersion translation happens; that
reference follows the new name. "store" matches the docstring, which already
describes the result as the shape the SkillStore interface defines.
No behaviour change, and no assertion changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ansport The module made the same five points in many places: hashless objects are held not dropped, changes commit at payload-transferred, there is one network timeout, close must interrupt the socket, and standard library only. Each now lives in agents.md for the rationale and in the one docstring nearest the decision for the code-level reason; everywhere else points at those. The bare string literals after attribute assignments, which read as docstrings but are no-op expressions, become comments. Two small code duplications go with it: the put and delete handlers share their skill-event preamble, and the poll and stream loops no longer each map a transfer outcome onto an exception. The README section loses the paragraphs that restated agents.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This was referenced Sep 10, 2026
Contributor
Author
XieX
added a commit
that referenced
this pull request
Sep 15, 2026
Stacked on the protocol-layer PR (`xie/skills-fdv2-protocol`). Third of three PRs split out of #69, and the one that makes the feature real: the network underneath the protocol reader, exported as `FDv2SkillStore`. ## Why this shape Skill content arrives over `GET /sdk/poll` and `GET /sdk/stream`, authenticated with the environment's server-side SDK key. These are the SDK-facing endpoints the base SDK's FDv2 data source uses, and the channel that payload signing will eventually cover. No private route is involved, and no credential other than the environment's own SDK key ships to a customer host. Standard library only, so the content path adds no dependency to a package whose sole runtime dependency is `opentelemetry-api`. ## What's here **`FDv2SkillStore`.** Authenticates, streams (default) or polls, carries `basis` across requests, sends `If-None-Match` and treats 304 as a first-class current answer, and serves `get_object` / `all_objects` / `add_listener` / `remove_listener` from what the protocol reader has committed. Capped jittered backoff; `Retry-After` honoured but clamped to `max_backoff` and rejected when non-finite; bounded consecutive-failure retries, where a committed payload resets the count. One network timeout, `read_timeout`, whose default follows the mode: 10s for a whole poll, 300s between reads on a stream. A mobile key or client-side environment ID raises from the constructor. Last known good survives every failure; `diagnostics` and `failed` report the degradation. **`close` interrupts the socket.** The delivery thread parks in a read no flag can reach, and closing a urllib response from another thread does not unblock CPython's buffered reader, so `_interrupt_read` shuts the socket down underneath it. Without that every shutdown of a *healthy* stream blocked for the full join timeout. **Above the interface**, two strings: `NO_STORE_MESSAGE` now names `FDv2SkillStore` first, since it is the first thing a user sees on a missing store and offering only the development store was wrong once a production transport existed; and `watch_skills`' refusal message names it as the store with a delivery transport. ## Bugs found and fixed while testing the loop Five, all sharing one shape: the store stopped delivering while continuing to report itself healthy. - **The consecutive-failure counter never reset in stream mode.** `_stream_once` always ends by raising, so a reset on return was unreachable and `failures` grew for the whole process lifetime. Eleven *fully successful* payload transfers were enough to trip `max_consecutive_failures` and stop delivery for good, revocations included. A commit now resets the count, in `_apply`. - **A non-finite `Retry-After` killed the delivery thread.** `float("inf")` parses, and `Event.wait(inf)` raises `OverflowError` from inside the recoverable-error handler. Non-finite values are rejected and every honoured delay is clamped to `max_backoff`. - **`close()` during the initial connect waited out its full join timeout.** `self._connection` was assigned after the connect returned, so a `close()` in that window found nothing to interrupt. The stop flag is re-checked immediately after the assignment. - **`close()` blocked for the full join timeout on every healthy stream.** See `_interrupt_read` above. - **A stream interrupted by our own `close` was reported as a delivery failure.** Also: `connect_timeout` was accepted and never used, so a poll against a black-holed host hung for 300s rather than 10. It is gone, with the request timeout now chosen by mode, and `TestTimeouts` measures the bound against a socket that accepts and never answers. ## Tests > **Rebase note.** The previous push of this branch had silently reverted the protocol PR's last commit (payload identity: `payloads_ignored`, `_is_foreign_payload`, `TestPayloadIdentity`). Rebasing onto the updated protocol branch restored it; the full suite passes with it present. `_FakeFDv2Endpoint` is an in-process `ThreadingHTTPServer` implementing the wire contract, so request construction and header handling are exercised over real sockets rather than mocked. Covers skill put/delete over the wire, mixed payloads, 304, `basis` round-tripping, reconnect/backoff in both modes, `Retry-After` including non-finite and oversized values, bounded retries and the reset on commit, prompt shutdown during connect and during a healthy stream, hashless envelopes end to end through the accessors, server-side-only credentials, timeouts, and `watch_skills` over the transport: a wire-level revocation pruning a file without a restart. Full suite 1629 passing, 11 skipped; `ruff`, `ruff format`, and `mypy` clean. ## Open items, none in this PR's scope - 🔴 **`contentHash` is not on the wire yet.** Against a real environment today every skill resolves to nothing. This PR makes that loud (an error per hashless object, a summary per wholly-hashless payload, `diagnostics.hashless_objects`) rather than surviving it. - 🔴 **Server-side skill delivery is not deployed.** The wire shape this store reads — kind `skill`, key `<key>:<version>`, generic payload — is what [streamer #4681](launchdarkly/streamer#4681) and [gonfalon #70638](launchdarkly/gonfalon#70638) emit; both are still open. No account can receive skill objects until they ship and the producer is enabled. - 🟡 **FDv2 is opt-in per account.** A real environment returns 403 today; the store reports it as fatal and explains what to do. - ~~🟡 **`mv` is a guess.**~~ Resolved: the request sends no `mv`. That parameter selects the *flag* data model and the connection rejects any value but the flag default, while the generic agent-skill payload is served regardless of it. The `data_model_version` constructor argument is gone with it. - 🟡 **No payload signing** on this channel yet, so Beta is TLS-only. - 🟡 **The connection also carries the environment's flags.** Skipped and counted; a transport property, not fixable here. - 🟡 **`ld-relay` does not speak the FDv2 endpoints**, so relay-only deployments cannot receive skills in Beta. **Nothing here has touched a real LaunchDarkly environment**, because it cannot yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > Adds **`FDv2SkillStore`**, a production `SkillStore` that pulls agent skills over LaunchDarkly’s SDK FDv2 **`/sdk/poll`** and **`/sdk/stream`** endpoints (stdlib HTTP, background delivery thread, stream-by-default). It implements **`SkillStore`** (`get_object`, listeners, etc.) on top of the existing protocol reader, plus **`StoreDiagnostics`**, **`wait_for_skills`**, capped backoff with **`Retry-After`**, and **`close`** that interrupts blocked socket reads so shutdown is prompt. > > **Public surface:** `FDv2SkillStore` and `StoreDiagnostics` are exported from the package; README documents production setup with `init_client` and `watch_skills`. Server-side SDK keys only; mobile/client credentials are rejected. Outages keep last-known-good content; accessors above the store are unchanged. > > **Delivery-loop fixes** bundled here: reset consecutive-failure counts on successful commits / up-to-date answers (so healthy stream recycling does not stop delivery), safe handling of non-finite **`Retry-After`**, and not treating intentional **`close`** interrupts as transport failures. Removed unused **`connect_timeout`**; **`read_timeout`** is the single knob with mode-specific defaults. > > **Tests:** in-process fake FDv2 server exercises poll/stream, basis/ETag/304, revocations, retries, timeouts, hashless payloads, and **`watch_skills`** over the transport. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 88c225e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
XieX
added a commit
that referenced
this pull request
Sep 15, 2026
…network (#82) Stacked on the `watch_skills` PR (`xie/skills-watch`). Second of three PRs split out of #69; the transport that puts a connection underneath this follows. The half of the delivery transport that has no I/O: identifying a skill object on the wire, translating it into the raw object shape the `SkillStore` interface defines, holding it by `(key, objectVersion)`, and applying a payload's events as one consistent commit. Splitting it out lets the three decisions that matter most be reviewed without a socket in the way. ## Three things worth reviewing closely **1. The skill's version is in the object's `key`; `version` is the payload's.** Each version of a skill is its own object on the wire, identified as `<key>:<version>` (`pdf-extraction:3`), and that is the only place the skill's version appears. The event's `version` is the payload's, and confusing the two fails *silently*: the object verifies, the hash matches, and the caller gets content under a version number that means nothing. The wire key is split in exactly one place (`_split_wire_key`), both the put and the delete translation go through it, and `TestVersionTranslation` asserts it in both directions. A key that will not split cleanly is *held* rather than dropped — version-less, or with the offending text as its version — so verification withholds it with `invalid_version` under a key the caller recognises; only a key with nothing before the delimiter is dropped. **2. Changes commit at `payload-transferred`, not per object.** A payload version is the unit of consistency. A half-applied full transfer would publish a state the server never described and would briefly empty the store, which, with pruning on, is the difference between a reconcile and deleting a customer's skill files. An interrupted transfer leaves last known good intact, and listeners fire once per commit. **3. A hashless object is held, not dropped.** Dropping it at the transport would report `absent`, indistinguishable from "no such skill", and would let a prune delete the last known-good copy on disk. Holding it means verification withholds it with `missing_content_hash`, which is diagnosable: an ERROR per `(key, version)`, deduped per reader rather than per process so two stores never quieten each other, a summary per wholly-hashless payload, and a `StoreDiagnostics.hashless_objects` counter. There is deliberately no fallback that synthesises a hash from the delivered content. ## Also here - **Skills are `kind == "skill"`; everything else is ignored, not rejected.** Object kinds on the SDK-facing channel are open strings and the agent-skill payload is classified `generic`, so a skill arrives under the kind its producer registered — the bare category name — with no `category` or `objectVersion` field ([streamer #4681](launchdarkly/streamer#4681), [gonfalon #70638](launchdarkly/gonfalon#70638)). An environment's assignment carries its flag payload alongside its agent-skill payload, so flag and segment objects arrive as a matter of course. Erroring on them would turn a normal payload into a permanent reconnect loop. - **`_SkillObjectSet`** holds several versions of one key, with lookup semantics identical to `InMemorySkillStore` down to the fall-through to a version-less entry. Its opaque snapshot keys are spelt `<key>:<version>`, the same as the wire, and a test pins that round trip. `TestInterfaceParity` asserts the two resolve identically. - **`_require_server_side_credential`** refuses a mobile key or client-side environment ID. Its tests arrive with the store constructor that calls it. Nothing here is exported yet; the store exports it. The module imports nothing from the feature but the version validator. ## Tests `test_skills_fdv2.py` drives `_ProtocolReader` directly: identification, version translation, full and change transfers, interruption, revocation, tombstones, mixed payloads, unknown kinds and events, error and goodbye, and the hashless dedupe across readers. The wire builders it introduces are shared with the transport PR's fake endpoint. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > Adds **`skills_fdv2.py`**, a stdlib-only layer below `SkillStore` that parses LaunchDarkly FDv2 events without sockets. It maps wire `put-object`/`delete-object` into the raw store shape, keeps skills in **`_SkillObjectSet`**, and applies updates atomically in **`_ProtocolReader`** at **`payload-transferred`** (not per object). > > The critical wire rule is **`key` = `skillKey:objectVersion`** while the event’s **`version` is the payload revision** and is dropped—confusing them would silently serve the wrong pinned version. Non-`kind == "skill"` objects (flags, segments) are **ignored**, not errors. **Foreign payload** full transfers are declined once skills’ payload id is known, so a flag `xfer-full` cannot wipe held skills (and trigger prune). **Hashless** skills are retained for verification to withhold with `missing_content_hash`, with **`StoreDiagnostics`** and loud logging. **`_require_server_side_credential`** rejects mobile/client credentials (for the upcoming networked store). > > **`agents.md`** documents the transport contract; **`skills_core`** clarifies `SKILL_OBJECT_KIND` vs wire kind. **`test_skills_fdv2.py`** (~900 lines) exercises the reader, version translation, payload identity, and parity with `InMemorySkillStore`. Nothing is exported or wired from accessors yet—that’s the follow-on transport PR. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit efc4ca7. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
XieX
added a commit
that referenced
this pull request
Sep 15, 2026
…ls (#81) Stacked on #66 (`split/skills-review-closeout`). First of three PRs split out of #69; the FDv2 protocol layer and the transport follow on top of this one. `write_skills` is a one-shot reconcile, so a revocation takes effect at the next process restart. `watch_skills` runs that reconcile now and again whenever the configured store reports a change, so a revoked skill's `SKILL.md` leaves the disk within a debounce interval of the store learning about it, rather than at the next restart. ## What's here **`skills_watch.py`** — `watch_skills` / `SkillWatcher`. Wired to the `SkillStore` interface, not to any one transport: it needs a store that implements `add_listener` and nothing more. It refuses loudly when the store does not, because a watcher that silently never fires looks exactly like one whose skills never changed. The listener itself only sets an event; the reconcile runs on a single worker thread, debounced, so a burst of changes coalesces into one pass and a slow disk never stalls the store's delivery thread. A reconcile that raises is logged and the watcher continues. `on_unavailable="keep"` stays the default: an outage must not read as "everything was revoked". **`remove_listener(kind, fn)`** joins `add_listener` as the optional second half of change notification, on the `SkillStore` contract and on `InMemorySkillStore`. `SkillWatcher.close()` needs it to detach; without it a store held every watcher ever created for the rest of its life. The watcher probes for it and skips detaching when a store does not implement it, so a customer's own store keeps working. ## Tests `test_skills_watch.py` drives the watcher through `InMemorySkillStore`, whose `put` notifies synchronously, and through small store doubles: initial reconcile, coalescing, refusal of a store without `add_listener`, detaching on close, and a store without `remove_listener`. Three tests on `InMemorySkillStore.remove_listener` join `test_skills.py`. The end-to-end case, a `delete-object` arriving over a live connection and pruning a file, lands with the transport PR where the fake endpoint lives. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > Adds **`watch_skills`** and **`SkillWatcher`** so agent skill files stay in sync with the configured skill store without waiting for a process restart. The API runs an initial `write_skills` reconcile, then listens for store delivery changes and re-reconciles on a **debounced background thread** (listener only wakes the worker; disk I/O never blocks the delivery thread). Revocations can prune `SKILL.md` within the debounce window. **`on_unavailable="keep"`** remains the default so transport outages are not treated as mass revocations. > > Extends the optional **`SkillStore`** change-notification contract with **`remove_listener(kind, fn)`**, implemented on **`InMemorySkillStore`**, so **`SkillWatcher.close()`** can detach; stores without it still work but keep listeners registered. **`watch_skills`** raises if no store is configured or the store lacks **`add_listener`**, and cleans up the listener if the initial reconcile fails. > > Public exports and README/agents docs are updated; new tests cover watcher behavior (coalescing, mid-startup revocations, close/detach) and **`remove_listener`** semantics. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 29ad3fe. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
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.
Stacked on #66 (
split/skills-review-closeout), the tip of the Agent Skills stack. Review that first.Builds the real delivery transport behind the existing
SkillStoreinterface, on LaunchDarkly's SDK-facing FDv2 channel. The transport itself needed nothing above the interface — the accessors, integrity verification, andwrite_skillsare untouched. Two small additive changes above the line came along with it, both described under Above the interface below.Why this shape
Skill content arrives over
GET /sdk/pollandGET /sdk/stream, authenticated with the environment's server-side SDK key. These are the genuinely SDK-facing endpoints — the same ones the base SDK's FDv2 data source uses, and the channel that payload signing will eventually cover. No private or internal route is involved, and no credential other than the environment's own SDK key ships to a customer host.What's here
skills_fdv2.py—FDv2SkillStore. Authenticates, streams (default) or polls, carries selector/basisacross requests, sendsIf-None-Matchand treats 304 as a first-class current answer, deserialisesinline-resource/skillobjects, holds them by(key, objectVersion), and servesget_object/all_objects/add_listener/remove_listener. Capped jittered backoff;Retry-Afterhonoured but clamped tomax_backoffand rejected when non-finite; bounded consecutive-failure retries, where a committed payload resets the count. Request timeout follows the mode — 10s for a poll, 300s for a stream, since a stream is meant to sit idle. Standard library only, so the content path adds no dependency to a package whose sole runtime dep isopentelemetry-api.skills_watch.py—watch_skills/SkillWatcher. The change-listener re-reconcile, detaching from the store onclose.Above the interface
Two additive changes, neither of them in the retrieval or reconcile path:
remove_listener(kind, fn)on theSkillStoreinterface and onInMemorySkillStore.SkillWatcher.close()needs to detach, and without it a store held every watcher ever created for the rest of its life. Optional in the same wayadd_listeneris: the watcher probes for it and skips detaching when a store does not implement it, so a customer's own store keeps working.NO_STORE_MESSAGEnow namesFDv2SkillStorefirst and keepsInMemorySkillStoreas what it is. It is the first thing a user sees on a missing store, and offering only the development store was wrong once a production transport existed.Three things worth reviewing closely
1.
objectVersionis the skill's version;versionis the payload's. On the wire a skillput-objectcarries both, and confusing them fails silently — the object verifies, the hash matches, and the caller gets content under a version number that means nothing. Flags and segments carry onlyversionand omit bothcategoryandobjectVersion, which is exactly why the two look interchangeable. The translation happens in exactly one place (_store_object_from_put) andTestVersionTranslationasserts it in both directions, including end-to-end.2. Changes commit at
payload-transferred, not per object. A payload version is the unit of consistency. A half-applied full transfer would publish a state the server never described and would briefly empty the store — which, with pruning on, is the difference between a reconcile and deleting a customer's skill files. An interrupted transfer therefore leaves last known good intact, and listeners fire once per commit, which is also the granularity the re-reconcile wants.3. A hashless object is held, not dropped. Dropping it at the transport would report
absent— indistinguishable from "no such skill" — and would let a prune delete the last known-good copy on disk. Holding it means verification withholds it withmissing_content_hash, which is diagnosable. See the open items below.Security and safety properties
delete-objectreaches a live stream in seconds, sowatch_skillsgets a revoked skill'sSKILL.mdoff disk within a debounce interval instead of at the next restart.on_unavailable="keep"stays the default: an outage must not read as "everything was revoked".Bugs found and fixed while testing
Five, all in the delivery loop, and all sharing one shape: the store stopped delivering while continuing to report itself healthy.
_stream_oncealways ends by raising, so the reset in_runwas unreachable andfailuresgrew for the whole process lifetime. Eleven fully successful payload transfers were enough to tripmax_consecutive_failuresand stop delivery for good — including revocations, which defeats the whole point of streaming. A commit now resets the count. This is the one to read the diff for.Retry-Afterkilled the delivery thread.float("inf")parses, andEvent.wait(inf)raisesOverflowErrorfrom inside the recoverable-error handler, where the siblingexcept Exceptioncannot catch it. The thread died withfailedstillNone. Non-finite values are now rejected and every honoured delay is clamped tomax_backoff.close()during the initial connect waited out its full join timeout.self._connectionwas assigned after the connect returned, so aclose()in that window found nothing to interrupt. Now re-checked immediately after the assignment.close()blocked for the full join timeout on every healthy stream. The delivery thread parks in a socket read no flag can reach, and closing a urllib response from another thread does not unblock CPython's buffered reader._interrupt_readshuts the socket down underneath it.closewas reported as a delivery failure.Also fixed while cleaning up: the hashless-object ERROR deduped per process rather than per store, so a second store in one process was silently quieter than the first — and that error is the loudest signal of the
contentHashgap below. Andconnect_timeoutwas accepted and never used, so a poll against a black-holed host hung for 300s rather than 10; it is gone, with the request timeout now chosen by mode.Tests
141 tests: 133 in
test_skills_fdv2.pyagainst_FakeFDv2Endpoint, an in-processThreadingHTTPServerimplementing the wire contract — real sockets, so request construction and header handling are exercised rather than mocked — plus 8 intest_skills_watch.py, which drives the watcher through the interface rather than through a transport. Covers skill put/delete,objectVersionvsversion, mixed payloads where flag and segment objects are skipped, unknown kinds ignored, 304,basisround-tripping, reconnect/backoff in both modes,Retry-Afterincluding non-finite and oversized values, bounded retries and the reset on commit, prompt shutdown during connect and during a healthy stream, listener detachment, interface parity withInMemorySkillStore, and a missing-contentHashenvelope producing withheld skills with the correct reason code rather than a crash.Full suite 1629 passing, 11 skipped;
ruff,ruff format, andmypyclean.Open items — none of these are in this PR's scope
contentHashis not on the wire yet. The delivered envelope is still{contentType, content, name, description}. Verification withholds any skill without a hash, so against a real environment today every skill resolves to nothing. This PR makes that outcome loud rather than surviving it: an error per(key, version)namingmissing_content_hash, one summary per wholly-hashless payload, and aStoreDiagnostics.hashless_objectscounter. There is deliberately no fallback that skips verification — a hash the SDK computed from the content it was handed would certify the content against itself.inline-resourcedelivery is not deployed. No account can receive skill objects at all until it ships.mvis a guess. The base SDK's own FDv2 data source doesn't sendmvat all, so I could not observe what the server expects. Defaulted to1and overridable via constructor. This is the most likely thing to be wrong on first contact with a real server — needs confirmation before Beta.ld-relaydoes not speak the FDv2 endpoints, so relay-only deployments cannot receive skills in Beta.Nothing here has touched a real LaunchDarkly environment, because it cannot yet. Everything is verified against the fake endpoint.
Known inconsistency, deliberately not fixed here
The modules in this PR say "interface";
skills_core.pybelow it still says "seam" a dozen times, including a section header andSKILL_OBJECT_KIND's docstring. Converting reviewed code in the lower half of the stack belongs in its own change, so the feature reads both ways until then.🤖 Generated with Claude Code