History

Encode the drift mark as a token, not a timestamp

Replace connection.tools_stale_at (bigint ms) with tools_stale_token (nullable text). The column answers "was this catalog invalidated since the listing now finishing began" — a version question, which a wall clock answers only as precisely as it ticks. markToolsStale writes a fresh token; an authoritative listing captures the token it observed at its start and clears it by compare-and-set inside replaceCatalog's existing transaction, so a mark that landed mid-listing carries a different token and survives. A missed clear fails closed and converges on the next re-list. This restores the read scan's zero-rows steady state: nothing ever cleared the timestamp column, so the prefilter's drift arm re-selected every once-drifted connection on every read forever. Contract change from a1fdc72d6: the same-millisecond stale/synced tie is gone rather than resolved either way, so the schedule tests that pinned inclusive comparison now pin token presence instead. Deliberate. Also: - produceConnectionTools returns outcome synced/incomplete/lost_claim instead of a boolean, with its own counter and span attribute. A plugin reporting bad news correctly is no longer counted as a sync, nor as a refresh that crashed. writeSyncOutcome reports whether it wrote, so an incomplete listing that had also lost its lease reports lost_claim. - The auth park gates expired only. A never-synced parked connection has no catalog to serve, so it walks the retry ladder instead, which caps the waste and restores recovery for credentials repaired upstream. - markToolsStale runs under the catalog persist lock; it was the one lifecycle write left exposed to enrollment in another fiber's open transaction. Migration 0016 and its snapshot are edited in place: unpushed, never run.

Rhys Sullivan committed 2 months ago

Commit e48fe131 parent a1fdc7211 files · +509 −219Browse files

Unified · Split

11 files
  1. .changeset/tool-sync-lifecycle.md +4 −2
  2. .claude/skills/prod-telemetry/SKILL.md +40 −17
  3. apps/cloud/drizzle/0016_icy_jocasta.sql +1 −1
  4. apps/cloud/drizzle/meta/0016_snapshot.json +3 −3
  5. apps/cloud/src/db/executor-schema.ts +1 −1
  6. packages/core/sdk/src/core-schema.ts +15 −10
  7. packages/core/sdk/src/executor.ts +204 −88
  8. packages/core/sdk/src/plugin.ts +6 −1
  9. packages/core/sdk/src/tool-sync-schedule.test.ts +86 −35
  10. packages/core/sdk/src/tool-sync-schedule.ts +47 −37
  11. packages/core/sdk/src/tools-sync-scope.test.ts +102 −24
.changeset/tool-sync-lifecycle.md+4−2
@@ -8,6 +8,8 @@ · 7 unmodified lines
8 8
9A tools read refreshes stale tool catalogs before it answers, and the executor stamped the connection's last-synced time even when the listing failed. A server that had been unreachable for a month therefore reported as "synced 30 seconds ago", earned a fresh handshake every freshness window, failed again, and re-stamped — indefinitely. Handshakes that could only ever be refused, because the credential itself had been revoked, were the single largest share of that traffic. Separately, two reads arriving at once had no way to see each other, so both dialed the same server for the same catalog.9A tools read refreshes stale tool catalogs before it answers, and the executor stamped the connection's last-synced time even when the listing failed. A server that had been unreachable for a month therefore reported as "synced 30 seconds ago", earned a fresh handshake every freshness window, failed again, and re-stamped — indefinitely. Handshakes that could only ever be refused, because the credential itself had been revoked, were the single largest share of that traffic. Separately, two reads arriving at once had no way to see each other, so both dialed the same server for the same catalog.
10 10
11Connections now carry a real sync lifecycle. The last-synced stamp is written only by a listing that actually succeeded, so freshness is honest; a drift signal is recorded alongside it rather than erasing it, so "the catalog changed" and "this has never synced" are finally different states with different diagnoses. Failed listings walk a jittered retry ladder that doubles up to a six-hour ceiling, and a listing refused on authentication parks the connection until something that could plausibly fix it happens: an explicit refresh, a connection edit, an OAuth reconnect, a healthy check, an integration config revision, or a fresh drift signal. The park holds off the clock, never an explicit invalidation, so a credential problem caused by integration configuration is retried as soon as that configuration is edited. Before dialing, a refresh claims the connection with a leased write token, so exactly one of any number of concurrent readers does the work and the rest answer from the existing catalog; a claimant that loses its lease discards its result instead of overwriting a newer one.11Connections now carry a real sync lifecycle. The last-synced stamp is written only by a listing that actually succeeded, so freshness is honest; a drift signal is recorded alongside it rather than erasing it, so "the catalog changed" and "this has never synced" are finally different states with different diagnoses. The drift signal is an opaque token rather than a timestamp, because the question it answers is "was this catalog invalidated since the listing now finishing began" — a version question, which a wall clock can only answer as precisely as it ticks. A listing captures the token it observed at its start and clears it only if that same token is still there, so a signal arriving mid-listing carries a different token, survives, and re-lists on the next read, while a resolved one is genuinely gone and stops being scanned.
12 12
13Plugins can now classify an incomplete listing as `auth`, `unreachable`, `protocol`, or `config` via `ResolveToolsResult.incompleteKind`. The MCP plugin reports the first three from the handshake (including reauthorization demands and 401/403 responses, which it previously discarded), and the OpenAPI plugin reports `config`. The tools read's spans gained the claim outcome and per-reason skip counts.13Failed listings walk a jittered retry ladder that doubles up to a six-hour ceiling. A listing refused on authentication parks the connection, which suppresses only the clock asking a working catalog to re-verify itself: an explicit drift signal, a config revision, and a connection that has never synced at all all still go through, the last because parking a connection with no catalog serves nothing at all until a human notices, and nothing here observes a credential repaired upstream. The park is cleared outright by anything that could plausibly have fixed it — an explicit refresh, a connection edit, an OAuth reconnect, a healthy check, a config revision, or a fresh drift signal. Before dialing, a refresh claims the connection with a leased write token, so exactly one of any number of concurrent readers does the work and the rest answer from the existing catalog; a claimant that loses its lease discards its result instead of overwriting a newer one.
14
15Plugins can now classify an incomplete listing as `auth`, `unreachable`, `protocol`, or `config` via `ResolveToolsResult.incompleteKind`. The MCP plugin reports the first three from the handshake (including reauthorization demands and 401/403 responses, which it previously discarded), and the OpenAPI plugin reports `config`. The tools read's spans report each refresh as one of `synced`, `incomplete`, `lost_claim` or `fail`, with a counter each, so a plugin reporting bad news correctly is no longer indistinguishable from a refresh that crashed or from one that succeeded — alongside the claim outcome and per-reason skip counts.
.claude/skills/prod-telemetry/SKILL.md+40−17
@@ -41,23 +41,42 @@ join the same traces via traceparent). · 40 unmodified lines
41 `.connection` (present only when the read was filtered),41 `.connection` (present only when the read was filtered),
42 `executor.tools.result_count`, and the catalog-refresh counters42 `executor.tools.result_count`, and the catalog-refresh counters
43 `executor.tools.sync.candidates` (rows the stale scan returned) /43 `executor.tools.sync.candidates` (rows the stale scan returned) /
44 `executor.tools.sync.synced` / `executor.tools.sync.failed` /44 `executor.tools.sync.synced` / `.incomplete` / `.failed` / `.lost_claim` /
45 `executor.tools.sync.skipped_claimed` / `.skipped_backoff` /45 `.skipped_claimed` / `.skipped_backoff` / `.skipped_parked`. A slow tools
46 `.skipped_parked`. A slow tools read is almost always `candidates` > 0:46 read is almost always `candidates` > 0: subtract the child span durations to
47 subtract the child span durations to confirm. A connection stuck permanently47 confirm.
48 stale shows up as `failed` > 0 on every read for its scope — the refresh is48
49 best-effort and never fails the read, so this counter and the49 `candidates` is the one to alert on for scan cost: in steady state it is
50 `executor tool catalog refresh failed` warning are the only signals it emits.50 ZERO. Every trigger is cleared by the listing that answers it, drift marks
51 included (`connection.tools_stale_token` is compare-and-set to NULL), so a
52 scope whose `candidates` never returns to 0 has connections that are due and
53 never resolving, not a busy fleet.
54
55 The four terminal counters partition every attempt, and the distinction that
56 matters when triaging is `incomplete` vs `failed`. `incomplete` is the PLUGIN
57 reporting bad news correctly — a refused credential, an unreachable server,
58 an unreadable spec — with the existing catalog kept and the retry ladder
59 advanced. `failed` is the refresh raising or dying before reaching any
60 verdict: the executor's own error channel, swallowed to keep the read
61 answerable, and the thing to page on. A connection stuck permanently stale
62 shows up as one or the other on every read for its scope; `failed` > 0 also
63 emits the `executor tool catalog refresh failed` warning, which is its only
64 other signal. `lost_claim` counts listings that completed and were then
65 discarded because another attempt had re-claimed the connection — wasted
66 upstream work that looks like success from every angle except the write.
51 67
52 The three `skipped_*` counters are how the sync lifecycle reports its work68 The three `skipped_*` counters are how the sync lifecycle reports its work
53 avoidance, and they are the ones to watch after a rollout:69 avoidance, and they are the ones to watch after a rollout:
54 `skipped_parked` counts connections whose credential was refused (an `auth`70 `skipped_parked` counts connections whose credential was refused (an `auth`
55 verdict, which no retry can fix), `skipped_backoff` counts ones inside their71 verdict) AND whose catalog is merely past its freshness window — the park
56 failure ladder, and `skipped_claimed` counts refreshes another concurrent72 gates re-verification only, so a never-synced connection with the same
57 read had already taken. `candidates` staying high while `synced` collapses73 verdict reports `skipped_backoff` instead and keeps walking the ladder.
58 toward the skip counters is the intended shape — it means dead upstreams74 `skipped_backoff` counts ones inside their failure ladder, and
59 stopped costing handshakes. `skipped_claimed` rising with request concurrency75 `skipped_claimed` counts refreshes another concurrent read had already taken.
60 for one integration is cross-isolate deduplication working.76 `synced` collapsing toward the skip counters is the intended shape — it means
77 dead upstreams stopped costing handshakes. `skipped_claimed` rising with
78 request concurrency for one integration is cross-isolate deduplication
79 working.
61 80
62- `executor.tools.sync` (child of the above, one per refreshed connection;81- `executor.tools.sync` (child of the above, one per refreshed connection;
63 older spans carry the previous name `executor.tools.sync_stale` and no82 older spans carry the previous name `executor.tools.sync_stale` and no
@@ -66,10 +85,14 @@ join the same traces via traceparent). · 2 unmodified lines
66 (`cold`/`stale_marked`/`config_revised`/`expired`),85 (`cold`/`stale_marked`/`config_revised`/`expired`),
67 `executor.tools.sync.claimed` (bool — false means another reader owned the86 `executor.tools.sync.claimed` (bool — false means another reader owned the
68 refresh and this one did nothing) and `executor.tools.sync.outcome`87 refresh and this one did nothing) and `executor.tools.sync.outcome`
69 (`ok`/`fail`). `cold` split out of `stale_marked` when `tools_stale_at`88 (`synced`/`incomplete`/`lost_claim`/`fail`). Older spans report `ok` where a
70 landed: before that, a never-synced connection and one invalidated89 current one reports `synced` or `incomplete`; that rename is the same
71 mid-invocation were the same row, so spans predating it report every90 distinction as the counters above, so any query matching `outcome == "ok"`
72 never-synced connection as `stale_marked`. The matching warning log carries91 needs both spellings to span the change. `cold` split out of `stale_marked`
92 when the drift mark became its own column: before that, a never-synced
93 connection and one invalidated mid-invocation were the same row, so spans
94 predating it report every never-synced connection as `stale_marked`. The
95 matching warning log carries
73 `integration`, `connection`, `trigger` and `errorTags` only: a refresh runs96 `integration`, `connection`, `trigger` and `errorTags` only: a refresh runs
74 on a live credential, so no cause or upstream message is logged and Axiom97 on a live credential, so no cause or upstream message is logged and Axiom
75 will not have one to search.98 will not have one to search.
apps/cloud/drizzle/0016_icy_jocasta.sql+1−1
@@ -1,4 +1,4 @@
1ALTER TABLE "connection" ADD COLUMN "tools_stale_at" bigint;--> statement-breakpoint1ALTER TABLE "connection" ADD COLUMN "tools_stale_token" text;--> statement-breakpoint
2ALTER TABLE "connection" ADD COLUMN "tools_sync_claim_id" text;--> statement-breakpoint2ALTER TABLE "connection" ADD COLUMN "tools_sync_claim_id" text;--> statement-breakpoint
3ALTER TABLE "connection" ADD COLUMN "tools_sync_claim_at" bigint;--> statement-breakpoint3ALTER TABLE "connection" ADD COLUMN "tools_sync_claim_at" bigint;--> statement-breakpoint
4ALTER TABLE "connection" ADD COLUMN "tools_sync_failures" bigint;--> statement-breakpoint4ALTER TABLE "connection" ADD COLUMN "tools_sync_failures" bigint;--> statement-breakpoint
apps/cloud/drizzle/meta/0016_snapshot.json+3−3
@@ -376,9 +376,9 @@ · 375 unmodified lines
376 "primaryKey": false,376 "primaryKey": false,
377 "notNull": false377 "notNull": false
378 },378 },
379 "tools_stale_at": {379 "tools_stale_token": {
380 "name": "tools_stale_at",380 "name": "tools_stale_token",
381 "type": "bigint",381 "type": "text",
382 "primaryKey": false,382 "primaryKey": false,
383 "notNull": false383 "notNull": false
384 },384 },
apps/cloud/src/db/executor-schema.ts+1−1
@@ -61,7 +61,7 @@ export const connection = pgTable( · 60 unmodified lines
61 description: text("description"),61 description: text("description"),
62 last_health: json("last_health"),62 last_health: json("last_health"),
63 tools_synced_at: bigint("tools_synced_at", { mode: "bigint" }),63 tools_synced_at: bigint("tools_synced_at", { mode: "bigint" }),
64 tools_stale_at: bigint("tools_stale_at", { mode: "bigint" }),64 tools_stale_token: text("tools_stale_token"),
65 tools_sync_claim_id: text("tools_sync_claim_id"),65 tools_sync_claim_id: text("tools_sync_claim_id"),
66 tools_sync_claim_at: bigint("tools_sync_claim_at", { mode: "bigint" }),66 tools_sync_claim_at: bigint("tools_sync_claim_at", { mode: "bigint" }),
67 tools_sync_failures: bigint("tools_sync_failures", { mode: "bigint" }),67 tools_sync_failures: bigint("tools_sync_failures", { mode: "bigint" }),
packages/core/sdk/src/core-schema.ts+15−10
@@ -225,15 +225,20 @@ export const coreTables = defineTables({ · 224 unmodified lines
225 // listing: a month-dead MCP server that reads as "synced 30s ago" is225 // listing: a month-dead MCP server that reads as "synced 30s ago" is
226 // re-dialed once per freshness window forever.226 // re-dialed once per freshness window forever.
227 tools_synced_at: nullableBigintColumn("tools_synced_at"),227 tools_synced_at: nullableBigintColumn("tools_synced_at"),
228 // Epoch ms an event declared this catalog drifted (an MCP228 // Set when an event declares this catalog drifted (an MCP
229 // `tools/list_changed`, an unknown-tool rejection). Separate from229 // `tools/list_changed`, an unknown-tool rejection); null when there is no
230 // `tools_synced_at` so marking a catalog stale no longer destroys the230 // outstanding drift. Separate from `tools_synced_at` so marking a catalog
231 // last-verified timestamp — "drifted" and "never synced" are different231 // stale no longer destroys the last-verified timestamp — "drifted" and
232 // states with different diagnoses. OUT-DATED by the next authoritative232 // "never synced" are different states with different diagnoses.
233 // listing rather than cleared by it: the marking fiber and the listing233 //
234 // fiber are different isolates, so a write that nulled this column would234 // An OPAQUE token, not a timestamp: it answers "was this catalog
235 // erase a drift signal that landed mid-listing.235 // invalidated since the listing now finishing began", which is a version
236 tools_stale_at: nullableBigintColumn("tools_stale_at"),236 // question, and a wall clock answers it only as precisely as it ticks. A
237 // listing captures the token it observed at its start and clears the
238 // column only if that same token is still there, so a mark that landed
239 // mid-listing carries a different token, survives the clear, and re-lists.
240 // Never compared for order.
241 tools_stale_token: nullableTextColumn("tools_stale_token"),
237 // The refresh lease. Concurrent reads land in different Workers isolates242 // The refresh lease. Concurrent reads land in different Workers isolates
238 // with no shared memory, so the row IS the coordination medium: a243 // with no shared memory, so the row IS the coordination medium: a
239 // refresh compare-and-sets its own nonce here before dialing, and the244 // refresh compare-and-sets its own nonce here before dialing, and the
@@ -497,7 +502,7 @@ export const CONNECTION_CATALOG_SCAN_COLUMNS = [ · 257 unmodified lines
497 "integration",502 "integration",
498 "name",503 "name",
499 "tools_synced_at",504 "tools_synced_at",
500 "tools_stale_at",505 "tools_stale_token",
501 "tools_sync_claim_id",506 "tools_sync_claim_id",
502 "tools_sync_claim_at",507 "tools_sync_claim_at",
503 "tools_sync_failures",508 "tools_sync_failures",
packages/core/sdk/src/executor.ts+204−88
@@ -692,7 +692,7 @@ export const DEFAULT_TOOLS_SYNC_TTL_MS = 15 * 60 * 1000; · 691 unmodified lines
692 692
693/** What sent a connection through tool production, for the per-connection span.693/** What sent a connection through tool production, for the per-connection span.
694 * The read path reports the classified {@link ToolSyncState} verbatim (`cold`694 * The read path reports the classified {@link ToolSyncState} verbatim (`cold`
695 * and `stale_marked` are finally distinguishable, now that `tools_stale_at`695 * and `stale_marked` are finally distinguishable, now that `tools_stale_token`
696 * records a drift without erasing the last-verified stamp); `explicit` is696 * records a drift without erasing the last-verified stamp); `explicit` is
697 * every human-initiated production — create, re-mint, `connections.refresh` —697 * every human-initiated production — create, re-mint, `connections.refresh` —
698 * which answers to a caller and so skips the read path's gates entirely. */698 * which answers to a caller and so skips the read path's gates entirely. */
@@ -710,33 +710,69 @@ interface ToolSyncAttempt { · 11 unmodified lines
710/** Production asked for by a caller who is waiting for the answer. */710/** Production asked for by a caller who is waiting for the answer. */
711const EXPLICIT_TOOL_SYNC: ToolSyncAttempt = { trigger: "explicit", claim: null };711const EXPLICIT_TOOL_SYNC: ToolSyncAttempt = { trigger: "explicit", claim: null };
712 712
713/** What one attempt at producing a connection's tools ended up with.713/**
714 * What one attempt at producing a connection's tools ended up with. Three
715 * outcomes rather than a boolean, because they are three genuinely different
716 * things to be told and only one of them is a sync:
717 *
718 * - `synced` an AUTHORITATIVE listing landed: the catalog persisted is the
719 * upstream's tool set, and the freshness stamp is honest. A
720 * legitimate empty catalog (no credential bound, or a plugin
721 * with no `resolveTools`) is one of these — "no tools" verified
722 * is still verified.
723 * - `incomplete` the plugin reported it could not produce an authoritative
724 * listing. The existing catalog was KEPT, the failure ladder
725 * advanced, and nothing claimed freshness.
726 * - `lost_claim` the listing completed and then found another attempt had
727 * re-claimed the connection and answered first, so nothing of it
728 * was written at all.
714 *729 *
715 * `lostClaim` is a distinct outcome rather than a flavour of success: the730 * Collapsing `incomplete` into success is how a fleet of month-dead upstreams
716 * listing completed, and then found another attempt had re-claimed the731 * reports as fully synced; collapsing it into `lost_claim` would blame
717 * connection and answered first, so nothing of it was persisted. Counting that732 * concurrency for a broken server.
718 * as a sync is how a fleet whose refreshes all lose their lease reports as733 *
719 * fully synced. `tools` is what the caller should answer with either way — the734 * `tools` is what the caller should answer with in every case — the fresh
720 * persisted catalog when the claim was lost, the fresh listing when it held. */735 * listing when it was persisted, the persisted catalog otherwise.
736 */
737type ToolProductionOutcome = "synced" | "incomplete" | "lost_claim";
738
721interface ToolProduction {739interface ToolProduction {
722 readonly tools: readonly Tool[];740 readonly tools: readonly Tool[];
723 readonly lostClaim: boolean;741 readonly outcome: ToolProductionOutcome;
724}742}
725 743
726/** How much work a read's catalog refresh found and did, for the enclosing744/**
727 * `executor.tools.list` span. `candidates` is what the stale scan returned —745 * How much work a read's catalog refresh found and did, for the enclosing
728 * the number the scan's filter scoping exists to keep at zero. `failed`746 * `executor.tools.list` span. `candidates` is what the stale scan returned —
729 * counts refreshes that were swallowed to keep the read answerable, so a747 * the number the scan's filter scoping exists to keep at zero.
730 * connection stuck permanently stale is visible as a rate rather than only748 *
731 * as an absent `synced`. `lostClaim` counts listings that completed and were749 * The four terminal counters partition every listing that actually ran — a
732 * then discarded because another attempt had re-claimed the connection —750 * candidate that never won its claim is `skippedClaimed`, not a listing — and
733 * wasted upstream work, and the one outcome that looks like a success from751 * each says something the others cannot:
734 * every angle except the write. The three `skipped*` counts are the whole point of752 *
735 * the lifecycle columns: a fleet whose reads are dominated by `skipped_parked`753 * - `synced` an authoritative listing was persisted (see
736 * is one where dead credentials stopped costing handshakes. */754 * {@link ToolProduction}).
755 * - `incomplete` the PLUGIN reported it could not produce a listing — a refused
756 * credential, an unreachable server, an unreadable spec. The
757 * catalog was kept and the ladder advanced.
758 * - `failed` the refresh raised or died before reaching a verdict, and was
759 * swallowed to keep the read answerable. This is the executor's
760 * own error channel, not the upstream's — an `incomplete` is a
761 * connection reporting bad news correctly, a `failed` is one
762 * nobody has a verdict for.
763 * - `lostClaim` the listing completed and was then discarded because another
764 * attempt had re-claimed the connection — wasted upstream work,
765 * and the one outcome that looks like a success from every angle
766 * except the write.
767 *
768 * The three `skipped*` counts are the whole point of the lifecycle columns: a
769 * fleet whose reads are dominated by `skipped_parked` is one where dead
770 * credentials stopped costing handshakes.
771 */
737interface CatalogRefreshSummary {772interface CatalogRefreshSummary {
738 readonly candidates: number;773 readonly candidates: number;
739 readonly synced: number;774 readonly synced: number;
775 readonly incomplete: number;
740 readonly failed: number;776 readonly failed: number;
741 readonly lostClaim: number;777 readonly lostClaim: number;
742 readonly skippedClaimed: number;778 readonly skippedClaimed: number;
@@ -747,6 +783,7 @@ interface CatalogRefreshSummary { · 4 unmodified lines
747const NO_CATALOG_REFRESH: CatalogRefreshSummary = {783const NO_CATALOG_REFRESH: CatalogRefreshSummary = {
748 candidates: 0,784 candidates: 0,
749 synced: 0,785 synced: 0,
786 incomplete: 0,
750 failed: 0,787 failed: 0,
751 lostClaim: 0,788 lostClaim: 0,
752 skippedClaimed: 0,789 skippedClaimed: 0,
@@ -773,6 +810,14 @@ const toolSyncJitter = (): number => · 20 unmodified lines
773const toolSyncClaimNonce = (): string =>810const toolSyncClaimNonce = (): string =>
774 `sync_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;811 `sync_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
775 812
813/** A drift mark, as an opaque token. Generated exactly like the claim nonce and
814 * for the same reason: it only has to be distinguishable from the value a
815 * concurrent listing observed, which is what makes the clear a compare-and-set
816 * rather than a timestamp comparison that ties inside one millisecond. Never
817 * parsed, never ordered, never an address. */
818const toolsStaleToken = (): string =>
819 `stale_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
820
776/** The error tags in a swallowed refresh cause, as a stable comma-joined set.821/** The error tags in a swallowed refresh cause, as a stable comma-joined set.
777 * Tags only: this cause can carry an upstream HTTP request/response, so no822 * Tags only: this cause can carry an upstream HTTP request/response, so no
778 * message, field or rendered cause from it may reach a log line. A defect823 * message, field or rendered cause from it may reach a log line. A defect
@@ -2669,10 +2714,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1890 unmodified lines
2669 // The terminal outcome of a listing that did NOT produce an authoritative2714 // The terminal outcome of a listing that did NOT produce an authoritative
2670 // catalog. `tools_synced_at` is deliberately untouched: stamping it here is2715 // catalog. `tools_synced_at` is deliberately untouched: stamping it here is
2671 // what made a month-dead server report as "synced 30s ago" and earn a fresh2716 // what made a month-dead server report as "synced 30s ago" and earn a fresh
2672 // handshake every freshness window. Any drift signal (`tools_stale_at`)2717 // handshake every freshness window. Any drift signal (`tools_stale_token`)
2673 // also stands, because nothing resolved it — the retry ladder is the only2718 // also stands, because nothing resolved it — the retry ladder is the only
2674 // thing holding the next attempt off, and an `auth` verdict parks the2719 // thing holding the next attempt off, and an `auth` verdict parks a
2675 // connection outright.2720 // connection the clock is merely asking to re-verify.
2676 //2721 //
2677 // `priorFailures` is the CONSECUTIVE count already on the row, so the2722 // `priorFailures` is the CONSECUTIVE count already on the row, so the
2678 // `failures` derived here is `>= 1` — the contract `scheduleAfterFailure`2723 // `failures` derived here is `>= 1` — the contract `scheduleAfterFailure`
@@ -2814,6 +2859,21 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 135 unmodified lines
2814 b("name", "=", String(ref.name)),2859 b("name", "=", String(ref.name)),
2815 );2860 );
2816 2861
2862 // The row as it stood when this listing began, read before anything is
2863 // dialed. Two things come out of it: the failure count the ladder
2864 // advances from, and the drift token this listing is answering.
2865 const existingRow = yield* findConnectionRow(ref);
2866 const failureSet = (kind: ToolSyncErrorKind | null, reason: string) =>
2867 toolSyncFailureSet(Number(existingRow?.tools_sync_failures ?? 0), kind, reason);
2868
2869 // THE drift mark this listing observed, or null if there was none. It is
2870 // a version token, not an instant: an authoritative listing clears the
2871 // column only if this exact value is still in it, so a mark written
2872 // while we were dialing carries a different token, survives the clear,
2873 // and re-lists the connection on the next read.
2874 const observedStaleToken =
2875 existingRow?.tools_stale_token == null ? null : String(existingRow.tools_stale_token);
2876
2817 // Does this attempt still own the connection? A read-path refresh takes2877 // Does this attempt still own the connection? A read-path refresh takes
2818 // a lease before dialing, and a slow one can lose it: another reader2878 // a lease before dialing, and a slow one can lose it: another reader
2819 // sees the lease expire, re-claims, re-lists and finishes first. The2879 // sees the lease expire, re-claims, re-lists and finishes first. The
@@ -2854,13 +2914,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 34 unmodified lines
2854 // report an incomplete listing without classifying it.2914 // report an incomplete listing without classifying it.
2855 //2915 //
2856 // The stamp is the instant the listing STARTED, not the instant it2916 // The stamp is the instant the listing STARTED, not the instant it
2857 // landed, and `tools_stale_at` is deliberately NOT cleared. Clearing it2917 // landed. It records freshness and NOTHING else: resolving the drift
2858 // was a lost update: `connections.markToolsStale` writes that column2918 // mark is a separate compare-and-set inside `replaceCatalog`, so a
2859 // from another fiber (a `tools/list_changed` arriving mid-invocation),2919 // signal that arrived mid-listing is not swallowed by a stamp that
2860 // and a drift signal landing while we were dialing describes a tool set2920 // happens to sort after it.
2861 // this listing never saw. `classifyToolSync` compares the two stamps
2862 // instead, so a mark that predates the listing is out-dated by it and
2863 // one that postdates it survives to re-list.
2864 const successSet = (row: ConnectionRow | null) => {2921 const successSet = (row: ConnectionRow | null) => {
2865 const recovering = Number(row?.tools_sync_failures ?? 0) > 0;2922 const recovering = Number(row?.tools_sync_failures ?? 0) > 0;
2866 return {2923 return {
@@ -2874,18 +2931,47 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines
2874 };2931 };
2875 };2932 };
2876 2933
2877 // Locked: these run inside a read's concurrent fan-out, and a bare2934 /** Record a NON-authoritative outcome, or report that the attempt lost
2878 // UPDATE issued while another fiber holds an open BEGIN on the shared2935 * its lease and wrote nothing. Returns false in the latter case.
2879 // SQLite connection is enrolled in that fiber's transaction — and lost2936 *
2880 // with it if it rolls back. The permit is NOT reentrant, so nothing2937 * Locked: this runs inside a read's concurrent fan-out, and a bare
2881 // reachable from here may take it again.2938 * UPDATE issued while another fiber holds an open BEGIN on the shared
2939 * SQLite connection is enrolled in that fiber's transaction — and lost
2940 * with it if it rolls back. The permit is NOT reentrant, so nothing
2941 * reachable from here may take it again. Transactional for the same
2942 * reason as {@link replaceCatalog}: `holdsClaim` is only sound read
2943 * inside the transaction that acts on it. */
2882 const writeSyncOutcome = (2944 const writeSyncOutcome = (
2883 set: Record<string, unknown>,2945 set: Record<string, unknown>,
2884 ): Effect.Effect<void, StorageFailure> =>2946 ): Effect.Effect<boolean, StorageFailure> =>
2885 withCatalogPersistLock(core.updateMany("connection", { where: outcomeWhere, set }));2947 withCatalogPersistLock(
2948 transaction(
2949 Effect.gen(function* () {
2950 if (!(yield* holdsClaim())) return false;
2951 yield* core.updateMany("connection", { where: outcomeWhere, set });
2952 return true;
2953 }),
2954 ),
2955 );
2886 2956
2887 /** Replace the persisted catalog and record the outcome together, or do2957 /** Replace the persisted catalog, record the outcome, and resolve the
2888 * neither. Returns false when the attempt lost its lease. */2958 * drift mark this listing answered — all three together, or none of
2959 * them. Returns false when the attempt lost its lease.
2960 *
2961 * Already holds the persist permit, which is not reentrant: nothing in
2962 * here may take it again.
2963 *
2964 * INVARIANT on the drift clear: it is a compare-and-set against the
2965 * token observed when this listing STARTED, never an unconditional
2966 * null. `connections.markToolsStale` writes a fresh token from another
2967 * fiber (a `tools/list_changed` arriving mid-invocation), and a mark
2968 * that landed while we were dialing describes a tool set this listing
2969 * never saw — its token differs, the WHERE misses, and the drift
2970 * survives to re-list on the next read.
2971 *
2972 * It fails CLOSED, and it converges: a clear that misses leaves the
2973 * connection drifted, so it re-lists, observes the token that is now
2974 * there, and clears it next time. */
2889 const replaceCatalog = (2975 const replaceCatalog = (
2890 toolRows: readonly Record<string, unknown>[],2976 toolRows: readonly Record<string, unknown>[],
2891 definitionRows: readonly Record<string, unknown>[],2977 definitionRows: readonly Record<string, unknown>[],
@@ -2900,18 +2986,31 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 8 unmodified lines
2900 yield* core.createMany("tool", toolRows);2986 yield* core.createMany("tool", toolRows);
2901 yield* core.createMany("definition", definitionRows);2987 yield* core.createMany("definition", definitionRows);
2902 yield* core.updateMany("connection", { where: outcomeWhere, set });2988 yield* core.updateMany("connection", { where: outcomeWhere, set });
2989 if (observedStaleToken !== null) {
2990 // Keyed on the connection and the token, deliberately NOT on
2991 // `outcomeWhere`: the write above already released the lease
2992 // (`successSet` nulls `tools_sync_claim_id`), so a predicate
2993 // carrying this attempt's claim would match nothing from here
2994 // on. The lease was checked by `holdsClaim` at the top of this
2995 // transaction, which is where it belongs.
2996 yield* core.updateMany("connection", {
2997 where: (b: AnyCb) =>
2998 b.and(connectionWhere(b), b("tools_stale_token", "=", observedStaleToken)),
2999 set: { tools_stale_token: null },
3000 });
3001 }
2903 return true;3002 return true;
2904 }),3003 }),
2905 ),3004 ),
2906 );3005 );
2907 3006
2908 const persistedTools = (3007 const persistedTools = (
2909 lostClaim: boolean,3008 outcome: ToolProductionOutcome,
2910 ): Effect.Effect<ToolProduction, StorageFailure> =>3009 ): Effect.Effect<ToolProduction, StorageFailure> =>
2911 core.findMany("tool", { where }).pipe(3010 core.findMany("tool", { where }).pipe(
2912 Effect.map((rows) => ({3011 Effect.map((rows) => ({
2913 tools: rows.map((row) => rowToTool(row as ConnectionToolRow)),3012 tools: rows.map((row) => rowToTool(row as ConnectionToolRow)),
2914 lostClaim,3013 outcome,
2915 })),3014 })),
2916 );3015 );
2917 3016
@@ -2923,9 +3022,6 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
2923 // carry their token outside `item_ids`; no-auth (`"none"` template)3022 // carry their token outside `item_ids`; no-auth (`"none"` template)
2924 // connections legitimately bind nothing (an empty `item_ids` is their3023 // connections legitimately bind nothing (an empty `item_ids` is their
2925 // canonical shape) — both are exempt.3024 // canonical shape) — both are exempt.
2926 const existingRow = yield* findConnectionRow(ref);
2927 const failureSet = (kind: ToolSyncErrorKind | null, reason: string) =>
2928 toolSyncFailureSet(Number(existingRow?.tools_sync_failures ?? 0), kind, reason);
2929 if (3025 if (
2930 existingRow &&3026 existingRow &&
2931 existingRow.oauth_client == null &&3027 existingRow.oauth_client == null &&
@@ -2935,17 +3031,17 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3 unmodified lines
2935 // A legitimate clear IS an authoritative listing: the answer is "no3031 // A legitimate clear IS an authoritative listing: the answer is "no
2936 // tools", verified as of now.3032 // tools", verified as of now.
2937 if (yield* replaceCatalog([], [], successSet(existingRow))) {3033 if (yield* replaceCatalog([], [], successSet(existingRow))) {
2938 return { tools: [], lostClaim: false };3034 return { tools: [], outcome: "synced" };
2939 }3035 }
2940 return yield* persistedTools(true);3036 return yield* persistedTools("lost_claim");
2941 }3037 }
2942 3038
2943 if (!runtime?.plugin.resolveTools) {3039 if (!runtime?.plugin.resolveTools) {
2944 // No dynamic tools — clear any existing rows and return empty.3040 // No dynamic tools — clear any existing rows and return empty.
2945 if (yield* replaceCatalog([], [], successSet(existingRow))) {3041 if (yield* replaceCatalog([], [], successSet(existingRow))) {
2946 return { tools: [], lostClaim: false };3042 return { tools: [], outcome: "synced" };
2947 }3043 }
2948 return yield* persistedTools(true);3044 return yield* persistedTools("lost_claim");
2949 }3045 }
2950 3046
2951 const result: ResolveToolsResult = yield* runtime.plugin3047 const result: ResolveToolsResult = yield* runtime.plugin
@@ -2974,14 +3070,18 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 22 unmodified lines
2974 // verdict parks the connection outright.3070 // verdict parks the connection outright.
2975 const reason = syncHealthReason(result);3071 const reason = syncHealthReason(result);
2976 const kind = result.incompleteKind ?? null;3072 const kind = result.incompleteKind ?? null;
2977 yield* writeSyncOutcome(failureSet(kind, reason));3073 // An attempt that had already lost its lease wrote nothing at all —
3074 // not the ladder, not the health detail — so it reports `lost_claim`.
3075 // Reporting `incomplete` there would attribute a verdict to a row the
3076 // winning attempt owns.
3077 const recorded = yield* writeSyncOutcome(failureSet(kind, reason));
2978 yield* Effect.logWarning("executor tool sync preserved catalog", {3078 yield* Effect.logWarning("executor tool sync preserved catalog", {
2979 reason,3079 reason,
2980 errorKind: kind ?? "unclassified",3080 errorKind: kind ?? "unclassified",
2981 integration: String(ref.integration),3081 integration: String(ref.integration),
2982 connection: String(ref.name),3082 connection: String(ref.name),
2983 });3083 });
2984 return yield* persistedTools(false);3084 return yield* persistedTools(recorded ? "incomplete" : "lost_claim");
2985 }3085 }
2986 3086
2987 if (3087 if (
@@ -2997,16 +3097,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 9 unmodified lines
2997 // existed is far more often a broken server than a real change, and3097 // existed is far more often a broken server than a real change, and
2998 // the plugin said nothing about why. Unclassified, so it backs off3098 // the plugin said nothing about why. Unclassified, so it backs off
2999 // but never parks.3099 // but never parks.
3000 yield* writeSyncOutcome(failureSet(null, reason));3100 const recorded = yield* writeSyncOutcome(failureSet(null, reason));
3001 yield* Effect.logWarning("executor tool sync preserved nonzero catalog", {3101 yield* Effect.logWarning("executor tool sync preserved nonzero catalog", {
3002 reason,3102 reason,
3003 integration: String(ref.integration),3103 integration: String(ref.integration),
3004 connection: String(ref.name),3104 connection: String(ref.name),
3005 existingToolCount: keptRows.length,3105 existingToolCount: keptRows.length,
3006 });3106 });
3107 // Re-read on a lost lease: `keptRows` predates the write, and the
3108 // attempt that owns the connection may have replaced the catalog
3109 // since.
3110 if (!recorded) return yield* persistedTools("lost_claim");
3007 return {3111 return {
3008 tools: keptRows.map((row) => rowToTool(row as ConnectionToolRow)),3112 tools: keptRows.map((row) => rowToTool(row as ConnectionToolRow)),
3009 lostClaim: false,3113 outcome: "incomplete",
3010 };3114 };
3011 }3115 }
3012 }3116 }
@@ -3044,11 +3148,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 31 unmodified lines
3044 // this catalog with a listing at least as recent as ours. Answer from3148 // this catalog with a listing at least as recent as ours. Answer from
3045 // what is persisted rather than overwriting it.3149 // what is persisted rather than overwriting it.
3046 if (!(yield* replaceCatalog(toolRows, definitionRows, successSet(existingRow)))) {3150 if (!(yield* replaceCatalog(toolRows, definitionRows, successSet(existingRow)))) {
3047 return yield* persistedTools(true);3151 return yield* persistedTools("lost_claim");
3048 }3152 }
3049 3153
3050 return {3154 return {
3051 lostClaim: false,3155 outcome: "synced",
3052 tools: result.tools.map((tool: ToolDef) =>3156 tools: result.tools.map((tool: ToolDef) =>
3053 rowToTool(3157 rowToTool(
3054 {3158 {
@@ -3835,16 +3939,29 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 780 unmodified lines
3835 // catalog indistinguishable from one that had never synced. The drift also3939 // catalog indistinguishable from one that had never synced. The drift also
3836 // un-parks — the server just told us its tool set moved, which is evidence3940 // un-parks — the server just told us its tool set moved, which is evidence
3837 // it is reachable and worth one more attempt.3941 // it is reachable and worth one more attempt.
3942 //
3943 // A FRESH token every time, unconditionally overwriting whatever was there.
3944 // That is what makes a mark landing mid-listing survive: the listing clears
3945 // the column only for the token it observed at its start, so overwriting
3946 // with a new one is precisely how this signal outlives the listing that
3947 // could not have seen it.
3948 //
3949 // Locked: this is a lifecycle write like every other, and it can run while
3950 // a read's refresh fan-out holds an open BEGIN on the shared SQLite
3951 // connection — where a bare UPDATE is enrolled in that transaction and lost
3952 // with it if it rolls back. Losing THIS write loses a drift signal outright.
3838 const connectionsMarkToolsStale = (ref: ConnectionRef): Effect.Effect<void, StorageFailure> =>3953 const connectionsMarkToolsStale = (ref: ConnectionRef): Effect.Effect<void, StorageFailure> =>
3839 core.updateMany("connection", {3954 withCatalogPersistLock(
3840 where: (b: AnyCb) =>3955 core.updateMany("connection", {
3841 b.and(3956 where: (b: AnyCb) =>
3842 byOwner(ref.owner)(b),3957 b.and(
3843 b("integration", "=", String(ref.integration)),3958 byOwner(ref.owner)(b),
3844 b("name", "=", String(ref.name)),3959 b("integration", "=", String(ref.integration)),
3845 ),3960 b("name", "=", String(ref.name)),
3846 set: { tools_stale_at: Date.now(), ...TOOL_SYNC_LADDER_RESET },3961 ),
3847 });3962 set: { tools_stale_token: toolsStaleToken(), ...TOOL_SYNC_LADDER_RESET },
3963 }),
3964 );
3848 3965
3849 // ------------------------------------------------------------------3966 // ------------------------------------------------------------------
3850 // Active policy source.3967 // Active policy source.
@@ -3963,9 +4080,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 112 unmodified lines
3963 // tool catalog is stale, scoped to that read's own filter — a read for one4080 // tool catalog is stale, scoped to that read's own filter — a read for one
3964 // integration never waits on another's upstream handshakes. Four triggers,4081 // integration never waits on another's upstream handshakes. Four triggers,
3965 // classified by `tool-sync-schedule`:4082 // classified by `tool-sync-schedule`:
3966 // - cold: never synced at all (`tools_synced_at` and `tools_stale_at` both4083 // - cold: never synced at all (`tools_synced_at` and `tools_stale_token`
3967 // NULL — a fresh row, or one a data migration reset);4084 // both NULL — a fresh row, or one a data migration reset);
3968 // - stale-marked: `tools_stale_at` postdates the catalog4085 // - stale-marked: `tools_stale_token` is set
3969 // (`connections.markToolsStale` — an MCP `tools/list_changed`4086 // (`connections.markToolsStale` — an MCP `tools/list_changed`
3970 // notification or unknown-tool rejection recorded the drift4087 // notification or unknown-tool rejection recorded the drift
3971 // mid-invocation);4088 // mid-invocation);
@@ -4032,14 +4149,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 60 unmodified lines
4032 // `blob.getMany`). The two NULL/NOT-NULL arms always stand, so the OR4149 // `blob.getMany`). The two NULL/NOT-NULL arms always stand, so the OR
4033 // can never degenerate to a bare `false`.4150 // can never degenerate to a bare `false`.
4034 //4151 //
4035 // The drift arm is `tools_stale_at IS NOT NULL` rather than a4152 // The drift arm is exactly `tools_stale_token IS NOT NULL`, and it is
4036 // column-to-column comparison against `tools_synced_at`, which the query4153 // exact rather than an over-selection because an authoritative listing
4037 // builder does not offer. A listing OUT-DATES a drift mark rather than4154 // CLEARS the token by compare-and-set. That is what keeps the zero-rows
4038 // clearing it (clearing it lost concurrent marks), so this arm4155 // steady state: encoding the mark as a timestamp nothing ever cleared
4039 // over-selects every connection that has ever drifted and the per-row4156 // made this arm re-select every once-drifted connection on every read,
4040 // classifier discards the resolved ones. Deliberate: the alternative is4157 // forever.
4041 // the lost update, and the cost is one projected row per
4042 // once-drifted connection.
4043 //4158 //
4044 // The revision arm is `<=`, matching `classifyToolSync`: `tools_synced_at`4159 // The revision arm is `<=`, matching `classifyToolSync`: `tools_synced_at`
4045 // is the instant the listing STARTED, so a revision landing in that same4160 // is the instant the listing STARTED, so a revision landing in that same
@@ -4054,7 +4169,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 8 unmodified lines
4054 filter?.connection === undefined ? true : b("name", "=", String(filter.connection)),4169 filter?.connection === undefined ? true : b("name", "=", String(filter.connection)),
4055 b.or(4170 b.or(
4056 b.isNull("tools_synced_at"),4171 b.isNull("tools_synced_at"),
4057 b.isNotNull("tools_stale_at"),4172 b.isNotNull("tools_stale_token"),
4058 revisedSlugs.length === 0 || latestRevision === null4173 revisedSlugs.length === 0 || latestRevision === null
4059 ? false4174 ? false
4060 : b.and(4175 : b.and(
@@ -4090,7 +4205,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 29 unmodified lines
4090 4205
4091 const candidate: ToolSyncCandidate = {4206 const candidate: ToolSyncCandidate = {
4092 toolsSyncedAt: asEpochMs(connection.tools_synced_at),4207 toolsSyncedAt: asEpochMs(connection.tools_synced_at),
4093 toolsStaleAt: asEpochMs(connection.tools_stale_at),4208 toolsStaleToken:
4209 connection.tools_stale_token == null ? null : String(connection.tools_stale_token),
4094 toolsSyncClaimId:4210 toolsSyncClaimId:
4095 connection.tools_sync_claim_id == null4211 connection.tools_sync_claim_id == null
4096 ? null4212 ? null
@@ -4144,17 +4260,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 47 unmodified lines
4144 trigger: state,4260 trigger: state,
4145 claim,4261 claim,
4146 }).pipe(4262 }).pipe(
4147 // `lost_claim` is its own outcome, not a flavour of `ok`: the4263 // Reported verbatim: `synced`, `incomplete` and `lost_claim`
4148 // listing ran, cost an upstream handshake, and was then thrown4264 // are three different things to be told, and only the first is
4149 // away because another attempt had re-claimed the connection.4265 // a sync. Folding the other two into it is how a fleet that
4150 // Reporting it as synced is how a fleet that persists nothing4266 // persists nothing reads as fully healthy.
4151 // reads as fully healthy.4267 Effect.flatMap((produced) =>
4152 Effect.flatMap((produced) => {4268 Effect.annotateCurrentSpan({
4153 const outcome = produced.lostClaim ? ("lost_claim" as const) : ("ok" as const);4269 "executor.tools.sync.outcome": produced.outcome,
4154 return Effect.annotateCurrentSpan({4270 }).pipe(Effect.as(produced.outcome)),
4155 "executor.tools.sync.outcome": outcome,4271 ),
4156 }).pipe(Effect.as(outcome));
4157 }),
4158 // catchCause, not catch: a plugin DEFECT is not in the error4272 // catchCause, not catch: a plugin DEFECT is not in the error
4159 // channel, and a tools READ must not be failable by one4273 // channel, and a tools READ must not be failable by one
4160 // connection's misbehaving refresh. The stale-but-working4274 // connection's misbehaving refresh. The stale-but-working
@@ -4225,7 +4339,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 64 unmodified lines
4225 4339
4226 return {4340 return {
4227 candidates: connections.length,4341 candidates: connections.length,
4228 synced: outcomes.filter((outcome) => outcome === "ok").length,4342 synced: outcomes.filter((outcome) => outcome === "synced").length,
4343 incomplete: outcomes.filter((outcome) => outcome === "incomplete").length,
4229 failed: outcomes.filter((outcome) => outcome === "fail").length,4344 failed: outcomes.filter((outcome) => outcome === "fail").length,
4230 lostClaim: outcomes.filter((outcome) => outcome === "lost_claim").length,4345 lostClaim: outcomes.filter((outcome) => outcome === "lost_claim").length,
4231 skippedClaimed:4346 skippedClaimed:
@@ -4287,6 +4402,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 55 unmodified lines
4287 "executor.tools.result_count": tools.length,4402 "executor.tools.result_count": tools.length,
4288 "executor.tools.sync.candidates": refresh.candidates,4403 "executor.tools.sync.candidates": refresh.candidates,
4289 "executor.tools.sync.synced": refresh.synced,4404 "executor.tools.sync.synced": refresh.synced,
4405 "executor.tools.sync.incomplete": refresh.incomplete,
4290 "executor.tools.sync.failed": refresh.failed,4406 "executor.tools.sync.failed": refresh.failed,
4291 "executor.tools.sync.lost_claim": refresh.lostClaim,4407 "executor.tools.sync.lost_claim": refresh.lostClaim,
4292 "executor.tools.sync.skipped_claimed": refresh.skippedClaimed,4408 "executor.tools.sync.skipped_claimed": refresh.skippedClaimed,
packages/core/sdk/src/plugin.ts+6−1
@@ -237,7 +237,12 @@ export interface PluginCtx<TStore = unknown> { · 236 unmodified lines
237 * arrive mid-invocation — e.g. an MCP server sending237 * arrive mid-invocation — e.g. an MCP server sending
238 * `notifications/tools/list_changed` or rejecting a call as an unknown238 * `notifications/tools/list_changed` or rejecting a call as an unknown
239 * tool — where an inline `refresh` would block the caller. The last239 * tool — where an inline `refresh` would block the caller. The last
240 * verified sync time is preserved; the drift is recorded alongside it. */240 * verified sync time is preserved; the drift is recorded alongside it.
241 *
242 * Safe to call at any point, including while a listing for the same
243 * connection is in flight: the mark is recorded as a fresh token, and a
244 * listing only resolves the token it observed when it started. A signal
245 * that arrives mid-listing therefore survives it and re-lists. */
241 readonly markToolsStale: (ref: ConnectionRef) => Effect.Effect<void, StorageFailure>;246 readonly markToolsStale: (ref: ConnectionRef) => Effect.Effect<void, StorageFailure>;
242 /** Resolve a connection's value through its provider (and OAuth refresh).247 /** Resolve a connection's value through its provider (and OAuth refresh).
243 * null if the provider can't produce one. */248 * null if the provider can't produce one. */
packages/core/sdk/src/tool-sync-schedule.test.ts+86−35
@@ -28,13 +28,17 @@ import { · 27 unmodified lines
28const NOW = 1_700_000_000_000;28const NOW = 1_700_000_000_000;
29const TTL = 15 * 60 * 1000;29const TTL = 15 * 60 * 1000;
30 30
31/** A drift mark, as `connections.markToolsStale` writes it. Opaque: nothing
32 * here may read structure out of it, and nothing may order two of them. */
33const STALE_TOKEN = "stale_m3k1x9q2";
34
31/** A row every case overrides into the state it is about. Neutral on purpose:35/** A row every case overrides into the state it is about. Neutral on purpose:
32 * naming it for one of the states under test (it started life as `fresh`)36 * naming it for one of the states under test (it started life as `fresh`)
33 * makes `classifyToolSync(fresh({ toolsSyncedAt: null }))` read as a37 * makes `classifyToolSync(fresh({ toolsSyncedAt: null }))` read as a
34 * contradiction with the assertion beside it. */38 * contradiction with the assertion beside it. */
35const candidate = (overrides: Partial<ToolSyncCandidate> = {}): ToolSyncCandidate => ({39const candidate = (overrides: Partial<ToolSyncCandidate> = {}): ToolSyncCandidate => ({
36 toolsSyncedAt: NOW - 1000,40 toolsSyncedAt: NOW - 1000,
37 toolsStaleAt: null,41 toolsStaleToken: null,
38 toolsSyncClaimId: null,42 toolsSyncClaimId: null,
39 toolsSyncClaimAt: null,43 toolsSyncClaimAt: null,
40 toolsSyncFailures: 0,44 toolsSyncFailures: 0,
@@ -72,32 +76,42 @@ describe("classifyToolSync", () => { · 31 unmodified lines
72 76
73 it("calls a connection with a drift mark stale_marked, keeping its stamp", () => {77 it("calls a connection with a drift mark stale_marked, keeping its stamp", () => {
74 expect(78 expect(
75 classifyToolSync(candidate({ toolsSyncedAt: NOW - 60_000, toolsStaleAt: NOW - 1000 }), NOW),79 classifyToolSync(
80 candidate({ toolsSyncedAt: NOW - 60_000, toolsStaleToken: STALE_TOKEN }),
81 NOW,
82 ),
76 ).toBe("stale_marked");83 ).toBe("stale_marked");
77 });84 });
78 85
79 it("still sees a drift mark that landed in the same millisecond as the stamp", () => {86 it("reads the drift mark as presence, never against the stamp", () => {
80 // Date.now() has millisecond granularity, and a `tools/list_changed`87 // The whole point of the token encoding. `tools_stale_token` answers "was
81 // arriving inside the millisecond it invalidates must not be swallowed.88 // this catalog invalidated since the listing now finishing began", which is
82 expect(classifyToolSync(candidate({ toolsSyncedAt: NOW, toolsStaleAt: NOW }), NOW)).toBe(89 // a version question; the earlier encoding asked it of a wall clock, and
83 "stale_marked",90 // `Date.now()` has millisecond granularity, so a mark and a stamp landing in
84 );91 // one millisecond tied. No stamp, however far ahead of the mark, resolves
92 // it — only the compare-and-set that nulls the column does.
93 for (const toolsSyncedAt of [null, 0, NOW - 60_000, NOW, NOW + 60_000]) {
94 expect(
95 classifyToolSync(candidate({ toolsSyncedAt, toolsStaleToken: STALE_TOKEN }), NOW),
96 ).toBe("stale_marked");
97 }
85 });98 });
86 99
87 it("leaves a drift mark the listing out-dated resolved", () => {100 it("leaves a connection whose drift mark was cleared resolved", () => {
88 // An authoritative listing does not clear `tools_stale_at`, it stamps101 // An authoritative listing clears the token it observed, and a cleared
89 // `tools_synced_at` past it. A mark older than the stamp is a settled102 // column is the only thing that settles a drift. This is also what keeps
90 // drift, and the connection must not re-list forever because the column is103 // the read's scan at zero rows in steady state: nothing ever cleared the
91 // still set.104 // old timestamp column, so every connection that had ever drifted was
105 // re-selected on every read, forever.
92 expect(106 expect(
93 classifyToolSync(candidate({ toolsSyncedAt: NOW - 1000, toolsStaleAt: NOW - 2000 }), NOW),107 classifyToolSync(candidate({ toolsSyncedAt: NOW - 1000, toolsStaleToken: null }), NOW),
94 ).toBe("fresh");108 ).toBe("fresh");
95 });109 });
96 110
97 it("calls a never-synced connection carrying a drift mark stale_marked, not cold", () => {111 it("calls a never-synced connection carrying a drift mark stale_marked, not cold", () => {
98 expect(classifyToolSync(candidate({ toolsSyncedAt: null, toolsStaleAt: NOW }), NOW)).toBe(112 expect(
99 "stale_marked",113 classifyToolSync(candidate({ toolsSyncedAt: null, toolsStaleToken: STALE_TOKEN }), NOW),
100 );114 ).toBe("stale_marked");
101 });115 });
102 116
103 it("calls a catalog older than its integration's config revision config_revised", () => {117 it("calls a catalog older than its integration's config revision config_revised", () => {
@@ -150,7 +164,11 @@ describe("classifyToolSync", () => { · 46 unmodified lines
150 it("ranks an explicit drift signal above a config revision and the clock", () => {164 it("ranks an explicit drift signal above a config revision and the clock", () => {
151 expect(165 expect(
152 classifyToolSync(166 classifyToolSync(
153 candidate({ toolsSyncedAt: NOW - TTL - 1, toolsStaleAt: NOW, configRevisedAt: NOW - 1 }),167 candidate({
168 toolsSyncedAt: NOW - TTL - 1,
169 toolsStaleToken: STALE_TOKEN,
170 configRevisedAt: NOW - 1,
171 }),
154 NOW,172 NOW,
155 ),173 ),
156 ).toBe("stale_marked");174 ).toBe("stale_marked");
@@ -215,7 +233,7 @@ describe("isToolSyncBackedOff", () => { · 58 unmodified lines
215 const drifted = candidate({233 const drifted = candidate({
216 toolsSyncFailures: 0,234 toolsSyncFailures: 0,
217 toolsSyncRetryAt: NOW + TTL,235 toolsSyncRetryAt: NOW + TTL,
218 toolsStaleAt: NOW,236 toolsStaleToken: STALE_TOKEN,
219 });237 });
220 expect(isToolSyncBackedOff(drifted, NOW)).toBe(false);238 expect(isToolSyncBackedOff(drifted, NOW)).toBe(false);
221 expect(isSyncEligible(drifted, NOW)).toBe(true);239 expect(isSyncEligible(drifted, NOW)).toBe(true);
@@ -243,11 +261,11 @@ describe("decideToolSync", () => { · 21 unmodified lines
243 expect(decideToolSync(candidate(), NOW)).toEqual({ kind: "skip", reason: "fresh" });261 expect(decideToolSync(candidate(), NOW)).toEqual({ kind: "skip", reason: "fresh" });
244 });262 });
245 263
246 it("skips a parked connection however overdue the clock says it is", () => {264 it("skips a parked connection however far past its freshness window it is", () => {
247 expect(265 expect(
248 decideToolSync(266 decideToolSync(
249 candidate({267 candidate({
250 toolsSyncedAt: null,268 toolsSyncedAt: NOW - 30 * TTL,
251 toolsSyncFailures: 9,269 toolsSyncFailures: 9,
252 toolsSyncErrorKind: "auth",270 toolsSyncErrorKind: "auth",
253 toolsSyncRetryAt: NOW - 1,271 toolsSyncRetryAt: NOW - 1,
@@ -312,7 +330,7 @@ describe("decideToolSync", () => { · 58 unmodified lines
312 expect(330 expect(
313 decideToolSync(331 decideToolSync(
314 candidate({332 candidate({
315 toolsSyncedAt: null,333 toolsSyncedAt: NOW - TTL - 1,
316 toolsSyncErrorKind: "auth",334 toolsSyncErrorKind: "auth",
317 toolsSyncFailures: 3,335 toolsSyncFailures: 3,
318 toolsSyncRetryAt: NOW + TTL,336 toolsSyncRetryAt: NOW + TTL,
@@ -322,6 +340,25 @@ describe("decideToolSync", () => { · 3 unmodified lines
322 ).toEqual({ kind: "skip", reason: "parked" });340 ).toEqual({ kind: "skip", reason: "parked" });
323 });341 });
324 342
343 it("reports backoff, not parked, for a COLD connection with an auth verdict", () => {
344 // The park does not apply to `cold`, so the ladder is what holds this
345 // connection off — and the reason an operator sees has to say so, because
346 // the read fans the two into different counters and they mean opposite
347 // things: `skipped_parked` is waste permanently avoided, `skipped_backoff`
348 // is a retry that will happen.
349 expect(
350 decideToolSync(
351 candidate({
352 toolsSyncedAt: null,
353 toolsSyncErrorKind: "auth",
354 toolsSyncFailures: 3,
355 toolsSyncRetryAt: NOW + TTL,
356 }),
357 NOW,
358 ),
359 ).toEqual({ kind: "skip", reason: "backoff" });
360 });
361
325 it("reports backoff over a live claim", () => {362 it("reports backoff over a live claim", () => {
326 expect(363 expect(
327 decideToolSync(364 decideToolSync(
@@ -338,9 +375,10 @@ describe("decideToolSync", () => { · 10 unmodified lines
338 });375 });
339 });376 });
340 377
341 // The park answers "the clock alone is not a reason to re-dial a rejected378 // The park answers exactly one question: "is re-VERIFYING a catalog we can
342 // credential". It is not an answer to somebody telling us the world changed.379 // still serve worth dialing a credential that was refused". Every other
343 describe("park yields to an explicit invalidation", () => {380 // trigger outranks it.
381 describe("park gates expired and nothing else", () => {
344 it("refreshes a parked connection whose integration config was revised", () => {382 it("refreshes a parked connection whose integration config was revised", () => {
345 // This is the repair path: an `auth` verdict caused by integration383 // This is the repair path: an `auth` verdict caused by integration
346 // configuration is fixed by editing that configuration, and384 // configuration is fixed by editing that configuration, and
@@ -361,21 +399,34 @@ describe("decideToolSync", () => { · 14 unmodified lines
361 it("refreshes a parked connection that was marked stale", () => {399 it("refreshes a parked connection that was marked stale", () => {
362 expect(400 expect(
363 decideToolSync(401 decideToolSync(
364 candidate({ toolsSyncedAt: NOW - 60_000, toolsStaleAt: NOW, toolsSyncErrorKind: "auth" }),402 candidate({
403 toolsSyncedAt: NOW - 60_000,
404 toolsStaleToken: STALE_TOKEN,
405 toolsSyncErrorKind: "auth",
406 }),
365 NOW,407 NOW,
366 ),408 ),
367 ).toEqual({ kind: "refresh", trigger: "stale_marked" });409 ).toEqual({ kind: "refresh", trigger: "stale_marked" });
368 });410 });
369 411
370 it("still parks when only the clock is asking", () => {412 it("refreshes a parked connection that has never synced at all", () => {
371 for (const overrides of [413 // A parked `cold` connection has NO catalog to serve, so parking it is
372 { toolsSyncedAt: null },414 // not "keep serving what we have", it is "serve nothing until a human
373 { toolsSyncedAt: NOW - TTL - 1 },415 // notices". The ladder still caps the cost at a few dials a day, and it
374 ] as const) {416 // is what lets a credential repaired upstream — where nothing in this
375 expect(417 // system observes the repair — recover on its own.
376 decideToolSync(candidate({ ...overrides, toolsSyncErrorKind: "auth" }), NOW),418 expect(
377 ).toEqual({ kind: "skip", reason: "parked" });419 decideToolSync(candidate({ toolsSyncedAt: null, toolsSyncErrorKind: "auth" }), NOW),
378 }420 ).toEqual({ kind: "refresh", trigger: "cold" });
421 });
422
423 it("still parks when the clock is only asking for a re-verification", () => {
424 expect(
425 decideToolSync(
426 candidate({ toolsSyncedAt: NOW - TTL - 1, toolsSyncErrorKind: "auth" }),
427 NOW,
428 ),
429 ).toEqual({ kind: "skip", reason: "parked" });
379 });430 });
380 });431 });
381});432});
packages/core/sdk/src/tool-sync-schedule.ts+47−37
@@ -46,11 +46,11 @@ export const isToolSyncErrorKind = (value: unknown): value is ToolSyncErrorKind · 45 unmodified lines
46 * Every non-`fresh` value is exactly the reason a read has to re-list, so this46 * Every non-`fresh` value is exactly the reason a read has to re-list, so this
47 * doubles as the refresh trigger reported on the `executor.tools.sync` span.47 * doubles as the refresh trigger reported on the `executor.tools.sync` span.
48 *48 *
49 * `cold` and `stale_marked` were one value before the `tools_stale_at` column49 * `cold` and `stale_marked` were one value before the `tools_stale_token`
50 * existed: `connections.markToolsStale` cleared `tools_synced_at`, which made a50 * column existed: `connections.markToolsStale` cleared `tools_synced_at`, which
51 * connection that had never synced indistinguishable from one whose catalog was51 * made a connection that had never synced indistinguishable from one whose
52 * invalidated mid-invocation, and destroyed the last-verified timestamp in the52 * catalog was invalidated mid-invocation, and destroyed the last-verified
53 * process.53 * timestamp in the process.
54 */54 */
55export type ToolSyncState = "fresh" | "cold" | "stale_marked" | "config_revised" | "expired";55export type ToolSyncState = "fresh" | "cold" | "stale_marked" | "config_revised" | "expired";
56 56
@@ -71,8 +71,11 @@ export interface ToolSyncCandidate { · 14 unmodified lines
71 /** Epoch ms of the last AUTHORITATIVE listing. Never stamped by a failed or71 /** Epoch ms of the last AUTHORITATIVE listing. Never stamped by a failed or
72 * incomplete one — a month-dead server must not read as "synced 30s ago". */72 * incomplete one — a month-dead server must not read as "synced 30s ago". */
73 readonly toolsSyncedAt: number | null;73 readonly toolsSyncedAt: number | null;
74 /** Epoch ms an event declared the persisted catalog drifted. */74 /** The outstanding drift mark, or null when there is none. An opaque token
75 readonly toolsStaleAt: number | null;75 * (see `connection.tools_stale_token`) — its presence is the whole signal
76 * and its value is only ever compared for equality, by the listing that
77 * clears it. */
78 readonly toolsStaleToken: string | null;
76 /** The nonce of the refresh attempt currently holding the write lease. */79 /** The nonce of the refresh attempt currently holding the write lease. */
77 readonly toolsSyncClaimId: string | null;80 readonly toolsSyncClaimId: string | null;
78 /** Epoch ms the claim was taken, against which the lease expires. */81 /** Epoch ms the claim was taken, against which the lease expires. */
@@ -118,26 +121,24 @@ export const TOOL_SYNC_JITTER_MAX = 1.2; · 39 unmodified lines
118 * Classify a connection's catalog. Total, and ordered by authority: an explicit121 * Classify a connection's catalog. Total, and ordered by authority: an explicit
119 * drift signal outranks a config revision, which outranks the clock.122 * drift signal outranks a config revision, which outranks the clock.
120 *123 *
121 * BOTH invalidation comparisons are inclusive of the stamp's own millisecond124 * A drift mark is read as PRESENCE, never as an instant: `tools_stale_token` is
122 * (`toolsStaleAt >= syncedAt`, `syncedAt <= configRevisedAt`). `Date.now()` has125 * an opaque token, and the listing that resolves it clears it by
123 * millisecond granularity and `tools_synced_at` records when the listing126 * compare-and-set on the token it observed. Comparing a drift TIMESTAMP against
124 * STARTED, so a signal landing in that millisecond describes a change the127 * `tools_synced_at` instead is what made the two stamps landing in one
125 * listing cannot have seen. Swallowing it would leave the connection serving a128 * millisecond ambiguous, and left the column set forever on every connection
126 * catalog that predates the change with no TTL backstop — a plugin without a129 * that had ever drifted.
127 * remote catalog never reaches the `expired` branch below.130 *
128 *131 * The config-revision comparison stays inclusive of the stamp's own millisecond
129 * This is also what resolves a drift mark: an authoritative listing does not132 * (`syncedAt <= configRevisedAt`). `Date.now()` has millisecond granularity and
130 * clear `tools_stale_at`, it out-dates it. Clearing the column was a lost133 * `tools_synced_at` records when the listing STARTED, so a revision landing in
131 * update, because the fiber that marked the drift and the fiber finishing the134 * that millisecond describes a config the listing cannot have read. Swallowing
132 * listing are different isolates with no coordination between them.135 * it would leave the connection serving a catalog that predates the change with
136 * no TTL backstop — a plugin without a remote catalog never reaches the
137 * `expired` branch below.
133 */138 */
134export const classifyToolSync = (candidate: ToolSyncCandidate, now: number): ToolSyncState => {139export const classifyToolSync = (candidate: ToolSyncCandidate, now: number): ToolSyncState => {
135 const syncedAt = candidate.toolsSyncedAt;140 const syncedAt = candidate.toolsSyncedAt;
136 // A never-synced connection sorts before every stamp, which is why the141 if (candidate.toolsStaleToken !== null) return "stale_marked";
137 // comparisons below read through `?? 0` rather than special-casing null.
138 if (candidate.toolsStaleAt !== null && candidate.toolsStaleAt >= (syncedAt ?? 0)) {
139 return "stale_marked";
140 }
141 if (syncedAt === null) return "cold";142 if (syncedAt === null) return "cold";
142 if (candidate.configRevisedAt !== null && syncedAt <= candidate.configRevisedAt) {143 if (candidate.configRevisedAt !== null && syncedAt <= candidate.configRevisedAt) {
143 return "config_revised";144 return "config_revised";
@@ -173,8 +174,8 @@ export const isToolSyncBackedOff = (candidate: ToolSyncCandidate, now: number): · 29 unmodified lines
173/** Whether the connection is parked: its credential is rejected, and no amount174/** Whether the connection is parked: its credential is rejected, and no amount
174 * of retrying the same grant will change that. Cleared by the human-initiated175 * of retrying the same grant will change that. Cleared by the human-initiated
175 * paths that can actually fix it (explicit refresh, connection update, an176 * paths that can actually fix it (explicit refresh, connection update, an
176 * OAuth re-mint, a healthy probe, a fresh drift signal), and yielded to by177 * OAuth re-mint, a healthy probe, a fresh drift signal). {@link decideToolSync}
177 * {@link decideToolSync} whenever an explicit invalidation is outstanding. */178 * applies it to `expired` alone — every other refresh trigger outranks it. */
178export const isToolSyncParked = (candidate: ToolSyncCandidate): boolean =>179export const isToolSyncParked = (candidate: ToolSyncCandidate): boolean =>
179 candidate.toolsSyncErrorKind === "auth";180 candidate.toolsSyncErrorKind === "auth";
180 181
@@ -196,15 +197,25 @@ export const isToolSyncParked = (candidate: ToolSyncCandidate): boolean => · 15 unmodified lines
196 * decision is the compare-and-set the caller runs against the database, and two197 * decision is the compare-and-set the caller runs against the database, and two
197 * readers that both see a free claim here still resolve to one refresh.198 * readers that both see a free claim here still resolve to one refresh.
198 *199 *
199 * The park yields to an EXPLICIT invalidation. `cold` and `expired` are the200 * The park gates `expired` and NOTHING else. `expired` is the only trigger that
200 * clock talking, and re-dialing a rejected credential because time passed is201 * is purely the clock asking a connection which already has a working catalog
201 * exactly the waste the park exists to stop; `stale_marked` and202 * to re-verify it, and re-dialing a rejected credential to re-verify a catalog
202 * `config_revised` are somebody telling us the world changed. A config revision203 * we can still serve is exactly the waste the park exists to stop.
203 * in particular is how an `auth` verdict caused by integration configuration204 *
204 * (the key in the wrong place, the wrong token endpoint) gets FIXED, and205 * `stale_marked` and `config_revised` are somebody telling us the world
205 * `integrations.update` is the one invalidation path that does not clear the206 * changed. A config revision in particular is how an `auth` verdict caused by
206 * ladder, so swallowing it here would leave the repaired connection parked207 * integration configuration (the key in the wrong place, the wrong token
207 * forever.208 * endpoint) gets FIXED, and `integrations.update` is the one invalidation path
209 * that does not clear the ladder, so swallowing it would leave the repaired
210 * connection parked forever.
211 *
212 * `cold` is deliberately NOT parked, and that is a change from parking every
213 * clock-driven trigger. A parked connection that has never synced has no
214 * catalog to serve, so parking it is not "keep serving what we have", it is
215 * "serve nothing, forever, until a human notices". The retry ladder already
216 * caps a never-synced auth failure at roughly four dials a day, and letting the
217 * clock drive it back is what restores automatic recovery when the credential
218 * is repaired upstream — where nothing in this system observes the repair.
208 */219 */
209export type ToolSyncDecision =220export type ToolSyncDecision =
210 | { readonly kind: "skip"; readonly reason: ToolSyncSkip }221 | { readonly kind: "skip"; readonly reason: ToolSyncSkip }
@@ -213,8 +224,7 @@ export type ToolSyncDecision = · 2 unmodified lines
213export const decideToolSync = (candidate: ToolSyncCandidate, now: number): ToolSyncDecision => {224export const decideToolSync = (candidate: ToolSyncCandidate, now: number): ToolSyncDecision => {
214 const state = classifyToolSync(candidate, now);225 const state = classifyToolSync(candidate, now);
215 if (state === "fresh") return { kind: "skip", reason: "fresh" };226 if (state === "fresh") return { kind: "skip", reason: "fresh" };
216 const timeDriven = state === "cold" || state === "expired";227 if (state === "expired" && isToolSyncParked(candidate)) return { kind: "skip", reason: "parked" };
217 if (timeDriven && isToolSyncParked(candidate)) return { kind: "skip", reason: "parked" };
218 if (isToolSyncBackedOff(candidate, now)) return { kind: "skip", reason: "backoff" };228 if (isToolSyncBackedOff(candidate, now)) return { kind: "skip", reason: "backoff" };
219 if (isToolSyncClaimLive(candidate, now)) return { kind: "skip", reason: "claimed" };229 if (isToolSyncClaimLive(candidate, now)) return { kind: "skip", reason: "claimed" };
220 return { kind: "refresh", trigger: state };230 return { kind: "refresh", trigger: state };
packages/core/sdk/src/tools-sync-scope.test.ts+102−24
@@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line
2import { Duration, Effect, Fiber, Latch } from "effect";2import { Duration, Effect, Fiber, Latch } from "effect";
3 3
4import { CONNECTION_CATALOG_SCAN_COLUMNS } from "./core-schema";4import { CONNECTION_CATALOG_SCAN_COLUMNS } from "./core-schema";
5import { createExecutor } from "./executor";5import { DEFAULT_TOOLS_SYNC_TTL_MS, createExecutor } from "./executor";
6import type { FumaDb } from "./fuma-runtime";6import type { FumaDb } from "./fuma-runtime";
7import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./ids";7import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./ids";
8import { definePlugin } from "./plugin";8import { definePlugin } from "./plugin";
@@ -58,6 +58,11 @@ const makeCountingPlugin = () => { · 49 unmodified lines
58 const plugin = definePlugin(() => ({58 const plugin = definePlugin(() => ({
59 id: "counting" as const,59 id: "counting" as const,
60 storage: () => ({}),60 storage: () => ({}),
61 // Stands in for an MCP server: a catalog only the upstream knows, so the
62 // freshness TTL can expire it. Without this the `expired` state is
63 // unreachable from these tests, and `expired` is the one state the park
64 // gates.
65 remoteToolCatalog: true,
61 resolveTools: (input) =>66 resolveTools: (input) =>
62 Effect.gen(function* () {67 Effect.gen(function* () {
63 const slug = String(input.connection.integration);68 const slug = String(input.connection.integration);
@@ -214,7 +219,7 @@ const SCAN_PROJECTION = [ · 150 unmodified lines
214 "integration",219 "integration",
215 "name",220 "name",
216 "tools_synced_at",221 "tools_synced_at",
217 "tools_stale_at",222 "tools_stale_token",
218 "tools_sync_claim_id",223 "tools_sync_claim_id",
219 "tools_sync_claim_at",224 "tools_sync_claim_at",
220 "tools_sync_failures",225 "tools_sync_failures",
@@ -255,7 +260,7 @@ const makeHarness = () => · 34 unmodified lines
255 /** Clear every connection's catalog stamp, which is the `cold` trigger:260 /** Clear every connection's catalog stamp, which is the `cold` trigger:
256 * a row with no stamp and no drift mark has never synced. NOT the261 * a row with no stamp and no drift mark has never synced. NOT the
257 * drift trigger — `connections.markToolsStale` leaves `tools_synced_at`262 * drift trigger — `connections.markToolsStale` leaves `tools_synced_at`
258 * alone and writes `tools_stale_at`, which is what263 * alone and writes `tools_stale_token`, which is what
259 * `executor.counting.markStale` drives. Used by the cases that only264 * `executor.counting.markStale` drives. Used by the cases that only
260 * need a connection to be due, whatever made it due. */265 * need a connection to be due, whatever made it due. */
261 clearEveryCatalogStamp: () =>266 clearEveryCatalogStamp: () =>
@@ -265,6 +270,17 @@ const makeHarness = () => · 3 unmodified lines
265 set: { tools_synced_at: null },270 set: { tools_synced_at: null },
266 }),271 }),
267 ),272 ),
273 /** Push every catalog past the freshness window, which is the `expired`
274 * trigger: a connection that HAS a working catalog and is only being
275 * asked to re-verify it. Distinct from `cold` in exactly the way the
276 * park cares about, so the two cannot share a helper. */
277 expireEveryCatalog: () =>
278 Effect.promise(() =>
279 observed.db.updateMany("connection", {
280 where: (b) => b.isNotNull("tools_synced_at"),
281 set: { tools_synced_at: Date.now() - DEFAULT_TOOLS_SYNC_TTL_MS - 1 },
282 }),
283 ),
268 /** Push every catalog stamp into the past. The `config_revised` trigger284 /** Push every catalog stamp into the past. The `config_revised` trigger
269 * compares `tools_synced_at` against a stamp taken later in the same285 * compares `tools_synced_at` against a stamp taken later in the same
270 * test; on an in-memory database both can land in one millisecond and286 * test; on an in-memory database both can land in one millisecond and
@@ -294,7 +310,7 @@ const makeHarness = () => · 23 unmodified lines
294 ? Effect.die(`no connection row named ${name}`)310 ? Effect.die(`no connection row named ${name}`)
295 : Effect.succeed({311 : Effect.succeed({
296 syncedAt: row.tools_synced_at == null ? null : Number(row.tools_synced_at),312 syncedAt: row.tools_synced_at == null ? null : Number(row.tools_synced_at),
297 staleAt: row.tools_stale_at == null ? null : Number(row.tools_stale_at),313 staleToken: row.tools_stale_token ?? null,
298 failures: row.tools_sync_failures == null ? 0 : Number(row.tools_sync_failures),314 failures: row.tools_sync_failures == null ? 0 : Number(row.tools_sync_failures),
299 retryAt:315 retryAt:
300 row.tools_sync_retry_at == null ? null : Number(row.tools_sync_retry_at),316 row.tools_sync_retry_at == null ? null : Number(row.tools_sync_retry_at),
@@ -513,7 +529,7 @@ describe("tools read catalog refresh lifecycle", () => { · 212 unmodified lines
513 const harness = yield* makeHarness();529 const harness = yield* makeHarness();
514 yield* harness.connect(INTEG_A, "main");530 yield* harness.connect(INTEG_A, "main");
515 // Genuinely drifted, through the path a plugin actually uses: the531 // Genuinely drifted, through the path a plugin actually uses: the
516 // connection keeps its stamp and carries a newer `tools_stale_at`.532 // connection keeps its stamp and carries a `tools_stale_token`.
517 yield* harness.executor.counting.markStale(INTEG_A, "main");533 yield* harness.executor.counting.markStale(INTEG_A, "main");
518 harness.renameToolsTo("redeploy");534 harness.renameToolsTo("redeploy");
519 harness.resolved.length = 0;535 harness.resolved.length = 0;
@@ -612,7 +628,10 @@ describe("tools read catalog refresh lifecycle", () => { · 92 unmodified lines
612 Effect.gen(function* () {628 Effect.gen(function* () {
613 const harness = yield* makeHarness();629 const harness = yield* makeHarness();
614 yield* harness.connect(INTEG_A, "main");630 yield* harness.connect(INTEG_A, "main");
615 yield* harness.clearEveryCatalogStamp();631 // EXPIRED, not cold: the connection has a working catalog and the clock
632 // is only asking it to re-verify. That is the one thing the park gates,
633 // and the case below pins the other side of the rule.
634 yield* harness.expireEveryCatalog();
616 harness.resolveIncompleteAs("auth");635 harness.resolveIncompleteAs("auth");
617 harness.resolved.length = 0;636 harness.resolved.length = 0;
618 637
@@ -621,8 +640,9 @@ describe("tools read catalog refresh lifecycle", () => { · 2 unmodified lines
621 expect((yield* harness.syncStateOf("main")).errorKind).toBe("auth");640 expect((yield* harness.syncStateOf("main")).errorKind).toBe("auth");
622 641
623 // Parked: not even elapsing the backoff window lets a read dial again.642 // Parked: not even elapsing the backoff window lets a read dial again.
624 // Retrying a rejected credential cannot change the verdict, and 401643 // Retrying a rejected credential to re-verify a catalog we can still
625 // handshakes were the largest single source of wasted upstream calls.644 // serve cannot change the verdict, and 401 handshakes were the largest
645 // single source of wasted upstream calls.
626 yield* harness.elapseEveryBackoff();646 yield* harness.elapseEveryBackoff();
627 yield* harness.executor.tools.list({ integration: INTEG_A });647 yield* harness.executor.tools.list({ integration: INTEG_A });
628 expect(harness.resolved).toEqual(["alpha/main"]);648 expect(harness.resolved).toEqual(["alpha/main"]);
@@ -645,6 +665,45 @@ describe("tools read catalog refresh lifecycle", () => { · 16 unmodified lines
645 ),665 ),
646 );666 );
647 667
668 it.effect("walks the ladder for a never-synced connection whose credential was refused", () =>
669 Effect.scoped(
670 Effect.gen(function* () {
671 const harness = yield* makeHarness();
672 yield* harness.connect(INTEG_A, "main");
673 // COLD: no catalog at all. Parking this serves nothing, forever, until
674 // a human notices — and nothing in this system observes a credential
675 // repaired upstream, so the clock has to be what brings it back.
676 yield* harness.clearEveryCatalogStamp();
677 harness.resolveIncompleteAs("auth");
678 harness.resolved.length = 0;
679
680 yield* harness.executor.tools.list({ integration: INTEG_A });
681 expect(harness.resolved).toEqual(["alpha/main"]);
682 expect((yield* harness.syncStateOf("main")).errorKind).toBe("auth");
683
684 // The LADDER holds it off, not the park — so reads inside the window
685 // still cost nothing.
686 yield* harness.executor.tools.list({ integration: INTEG_A });
687 expect(harness.resolved).toEqual(["alpha/main"]);
688
689 // Past the rung, a credential fixed upstream is picked up with no human
690 // in the loop. Under a park that gated `cold` this read never dials and
691 // the connection advertises no tools indefinitely.
692 harness.resolveIncompleteAs(undefined);
693 yield* harness.elapseEveryBackoff();
694
695 const tools = yield* harness.executor.tools.list({ integration: INTEG_A });
696 expect(harness.resolved).toEqual(["alpha/main", "alpha/main"]);
697 expect(tools.map((tool) => String(tool.name))).toEqual(["alpha_deploy"]);
698
699 const recovered = yield* harness.syncStateOf("main");
700 expect(recovered.errorKind).toBeNull();
701 expect(recovered.failures).toBe(0);
702 expect(recovered.syncedAt).not.toBeNull();
703 }),
704 ),
705 );
706
648 it.effect("keeps retrying a parked connection whose integration config was revised", () =>707 it.effect("keeps retrying a parked connection whose integration config was revised", () =>
649 Effect.scoped(708 Effect.scoped(
650 Effect.gen(function* () {709 Effect.gen(function* () {
@@ -696,7 +755,7 @@ describe("tools read catalog refresh lifecycle", () => { · 45 unmodified lines
696 755
697 const synced = yield* harness.syncStateOf("main");756 const synced = yield* harness.syncStateOf("main");
698 expect(synced.syncedAt).not.toBeNull();757 expect(synced.syncedAt).not.toBeNull();
699 expect(synced.staleAt).toBeNull();758 expect(synced.staleToken).toBeNull();
700 759
701 yield* harness.executor.counting.markStale(INTEG_A, "main");760 yield* harness.executor.counting.markStale(INTEG_A, "main");
702 761
@@ -704,7 +763,7 @@ describe("tools read catalog refresh lifecycle", () => { · 1 unmodified line
704 // Both facts survive: when it last verified, and that it has since763 // Both facts survive: when it last verified, and that it has since
705 // drifted. Clearing the stamp used to conflate this with "never synced".764 // drifted. Clearing the stamp used to conflate this with "never synced".
706 expect(drifted.syncedAt).toBe(synced.syncedAt);765 expect(drifted.syncedAt).toBe(synced.syncedAt);
707 expect(drifted.staleAt).not.toBeNull();766 expect(drifted.staleToken).not.toBeNull();
708 767
709 harness.renameToolsTo("redeploy");768 harness.renameToolsTo("redeploy");
710 harness.resolved.length = 0;769 harness.resolved.length = 0;
@@ -713,20 +772,26 @@ describe("tools read catalog refresh lifecycle", () => { · 2 unmodified lines
713 expect(harness.resolved).toEqual(["alpha/main"]);772 expect(harness.resolved).toEqual(["alpha/main"]);
714 expect(tools.map((tool) => String(tool.name))).toEqual(["alpha_redeploy"]);773 expect(tools.map((tool) => String(tool.name))).toEqual(["alpha_redeploy"]);
715 774
716 // The re-list settled the drift by OUT-DATING the mark, not by clearing775 // The re-list settled the drift by CLEARING the token it observed. It
717 // it: the stamp is now at or past `tools_stale_at`, which is what776 // is a compare-and-set, not an unconditional null (the case below is
718 // `classifyToolSync` reads. Clearing the column instead is the lost777 // the mark it must not clear), and the clear is what makes the
719 // update the case below is about.778 // resolution observable rather than inferred from two timestamps that
779 // can land in the same millisecond.
720 const settled = yield* harness.syncStateOf("main");780 const settled = yield* harness.syncStateOf("main");
721 expect(settled.staleAt).not.toBeNull();781 expect(settled.staleToken).toBeNull();
722 expect(settled.syncedAt).not.toBeNull();782 expect(settled.syncedAt).not.toBeNull();
723 expect(Number(settled.syncedAt)).toBeGreaterThanOrEqual(Number(settled.staleAt));
724 783
725 // And the connection is genuinely done: the next read neither scans it784 // And the connection is genuinely done: the next read does not dial
726 // as due nor dials it again.785 // it — and, the property the token encoding exists for, does not even
786 // SCAN it. A drift mark nothing ever clears leaves the scan's
787 // `tools_stale_token IS NOT NULL` arm matching this row on every read
788 // for the rest of its life, which is the steady state PR 1 established
789 // and its `candidates` gauge measures.
727 harness.resolved.length = 0;790 harness.resolved.length = 0;
791 harness.connectionScans.length = 0;
728 yield* harness.executor.tools.list({ integration: INTEG_A });792 yield* harness.executor.tools.list({ integration: INTEG_A });
729 expect(harness.resolved).toEqual([]);793 expect(harness.resolved).toEqual([]);
794 expect(harness.connectionScans).toEqual([{ rows: 0, select: SCAN_PROJECTION }]);
730 }),795 }),
731 ),796 ),
732 );797 );
@@ -737,6 +802,7 @@ describe("tools read catalog refresh lifecycle", () => { · 4 unmodified lines
737 const harness = yield* makeHarness();802 const harness = yield* makeHarness();
738 yield* harness.connect(INTEG_A, "main");803 yield* harness.connect(INTEG_A, "main");
739 yield* harness.executor.counting.markStale(INTEG_A, "main");804 yield* harness.executor.counting.markStale(INTEG_A, "main");
805 const observed = yield* harness.syncStateOf("main");
740 harness.renameToolsTo("redeploy");806 harness.renameToolsTo("redeploy");
741 harness.resolved.length = 0;807 harness.resolved.length = 0;
742 harness.blockResolves();808 harness.blockResolves();
@@ -751,16 +817,20 @@ describe("tools read catalog refresh lifecycle", () => { · 8 unmodified lines
751 // different fiber, no coordination with the one in flight, and it817 // different fiber, no coordination with the one in flight, and it
752 // describes a tool set this listing cannot have seen.818 // describes a tool set this listing cannot have seen.
753 yield* harness.executor.counting.markStale(INTEG_A, "main");819 yield* harness.executor.counting.markStale(INTEG_A, "main");
820 const marked = yield* harness.syncStateOf("main");
821 // A FRESH token, which is the entire mechanism: the listing will clear
822 // only the token it observed at its start, and this is not that token.
823 expect(marked.staleToken).not.toBe(observed.staleToken);
754 824
755 harness.releaseResolves();825 harness.releaseResolves();
756 yield* Fiber.join(listing);826 yield* Fiber.join(listing);
757 827
758 // The mark survived the success write. Nulling `tools_stale_at` here is828 // The mark survived the success write, verbatim. Nulling
759 // a lost update, and for a plugin with no remote catalog nothing else829 // `tools_stale_token` unconditionally here is a lost update, and for a
760 // would ever re-list the connection.830 // plugin with no remote catalog nothing else would ever re-list the
831 // connection.
761 const state = yield* harness.syncStateOf("main");832 const state = yield* harness.syncStateOf("main");
762 expect(state.staleAt).not.toBeNull();833 expect(state.staleToken).toBe(marked.staleToken);
763 expect(Number(state.staleAt)).toBeGreaterThanOrEqual(Number(state.syncedAt));
764 834
765 // Which is the fact that matters: the next read dials again.835 // Which is the fact that matters: the next read dials again.
766 harness.renameToolsTo("rerelease");836 harness.renameToolsTo("rerelease");
@@ -768,6 +838,12 @@ describe("tools read catalog refresh lifecycle", () => { · 1 unmodified line
768 const tools = yield* harness.executor.tools.list({ integration: INTEG_A });838 const tools = yield* harness.executor.tools.list({ integration: INTEG_A });
769 expect(harness.resolved).toEqual(["alpha/main"]);839 expect(harness.resolved).toEqual(["alpha/main"]);
770 expect(tools.map((tool) => String(tool.name))).toEqual(["alpha_rerelease"]);840 expect(tools.map((tool) => String(tool.name))).toEqual(["alpha_rerelease"]);
841
842 // And it CONVERGES. That read observed the surviving token and cleared
843 // it, so the drift is settled in one extra listing rather than leaving
844 // the connection permanently marked — a clear that misses costs one
845 // re-list, never a standing scan.
846 expect((yield* harness.syncStateOf("main")).staleToken).toBeNull();
771 }),847 }),
772 ),848 ),
773 );849 );
@@ -799,7 +875,9 @@ describe("tools read catalog refresh lifecycle", () => { · 25 unmodified lines
799 expect(state.syncedAt).toBe(synced.syncedAt);875 expect(state.syncedAt).toBe(synced.syncedAt);
800 expect(state.failures).toBe(4);876 expect(state.failures).toBe(4);
801 expect(state.errorKind).toBe("unreachable");877 expect(state.errorKind).toBe("unreachable");
802 expect(state.staleAt).not.toBeNull();878 // The drift mark also stands: only an authoritative listing clears it,
879 // and none of these four were one.
880 expect(state.staleToken).not.toBeNull();
803 }),881 }),
804 ),882 ),
805 );883 );