From 3601da8731201342ef148ec425540687def6041f Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Mon, 3 Aug 2026 23:23:38 +0000 Subject: [PATCH 1/2] RALPH: feat(desktop): provider-surface freshness invariant (buzz#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: toon-protocol/buzz#84, part of the agent-fleet-money epic (toon-meta#262 decision 12 / connector#698). Scope is deliberately narrow: this ticket's four asks (advertise a capability, inbound job feed, quote, availability) each depend on protocol pieces that are not yet decided or not yet publishable from this repo, so this first slice ships the one piece that is fully specified and buildable now — the cross-plane freshness invariant ("the socket is the lease") every other piece must honour once it lands. Key decisions: - New `desktop/src/features/providers/lib/providerAvailability.ts` (+test, RGR): `deriveProviderAvailability` derives `unadvertised | pending | available | stale` from advertising-enabled, local session-connected, last-advertised-at, and the connector's session lease TTL. A dropped session reads `stale` immediately — never waiting out the lease — because offline must cost reachability only, and the asymmetry is deliberate: reading a live provider as unavailable costs a little missed work, reading an unreachable one as available costs a buyer a rejected payment. `canQuoteJobs` gates the one action that costs money (a quote) on `available` only, per the issue's "do not auto-quote broadly" gotcha. - `sessionLeaseTtlMs` is a caller-supplied parameter, not hardcoded. connector#698 exports it as `SESSION_LEASE_BACKSTOP_TTL` and its own doc comment says to read it from there rather than duplicate the value — but that export is not present in any `@toon-protocol/client` (pinned 0.25.1; 0.26.0 adds `getClaimState` but still not the TTL) or `@toon-protocol/connector` release published as of this writing, so there is no live caller yet. Mirrors the `agentNetworkFlow.ts` precedent (buzz#86): pure, fully unit-tested domain logic shipped ahead of its data source, ready for a caller once the value exists. - `refreshIntervalForLease` scales the mesh's own reference ratio (`mesh_llm/discovery.rs` 120s freshness / `coordinator.rs` 45s republish, ~0.375) to whatever lease the connector actually grants, rather than hardcoding either side of that ratio. Files changed: desktop/src/features/providers/lib/providerAvailability.ts (new, +test). Full local gate green: fmt-check, desktop-tauri-fmt-check, clippy, test-unit (864 tests), desktop-check, desktop-test (4220 passed), desktop-build, web-check, web-build. Blockers for next iteration (all real, not scoping choices): - Advertise a capability (item 1) needs a decided wire format — no NIP-90 kind or tag shape for "this agent serves job type X" exists anywhere in this repo or the epic's decisions; NIP-89 kind:31990 advertising is explicitly parked in toon-meta#262, and no in-relay substitute is specified. This is protocol design, not implementation, and should not be invented unreviewed in a single ticket. - Inbound job feed (item 2) needs a job-list UI reading kind:5097 from g.toon.relay — the desktop has no open-relay job feed subscription today (only the free NIP-01 pattern huddle audio uses on the member relay, `toonRelayReader.ts`), and NIP-59 gift-wrapped briefs need a generalized unwrap (today's only precedent, `channelKeyDelivery.ts::unwrapChannelKey`, hardcodes its rumor kind). - Quote (item 3) needs `getClaimState` (toon-client#494, requires bumping the pinned `@toon-protocol/client` to >=0.26.0) plus a pricing/increment-schedule policy the epic doc does not specify. - `SESSION_LEASE_BACKSTOP_TTL` (connector#698) is still unpublished — confirmed against the live npm registry for both `@toon-protocol/client@0.26.0` and `@toon-protocol/connector@4.0.0`. `deriveProviderAvailability` is ready to consume it the moment it is. Signed-off-by: Claude Sonnet 5 --- .../lib/providerAvailability.test.mjs | 120 ++++++++++++++++++ .../providers/lib/providerAvailability.ts | 116 +++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 desktop/src/features/providers/lib/providerAvailability.test.mjs create mode 100644 desktop/src/features/providers/lib/providerAvailability.ts diff --git a/desktop/src/features/providers/lib/providerAvailability.test.mjs b/desktop/src/features/providers/lib/providerAvailability.test.mjs new file mode 100644 index 00000000000..c862a775cd5 --- /dev/null +++ b/desktop/src/features/providers/lib/providerAvailability.test.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + canQuoteJobs, + deriveProviderAvailability, + providerAvailabilityCaption, + refreshIntervalForLease, +} from "./providerAvailability.ts"; + +/** + * Covers buzz#84's freshness invariant (toon-meta#262 decision 12): never + * advertise a provider as available for longer than it stays routable on the + * connector, and a dropped session must cost reachability only — never the + * channel, balance, nonce position or reputation. Pure-function tests in the + * `huddleFeeQuote` mold — no DOM, no live connector. + */ + +test("the refresh cadence mirrors the mesh's 45s/120s reference ratio", () => { + assert.equal(refreshIntervalForLease(120_000), 45_000); + assert.equal(refreshIntervalForLease(60_000), 22_500); +}); + +test("advertising off reads as unadvertised regardless of session state", () => { + const state = deriveProviderAvailability({ + advertisingEnabled: false, + sessionConnected: true, + lastAdvertisedAtMs: 1_000, + nowMs: 1_000, + sessionLeaseTtlMs: 120_000, + }); + + assert.deepEqual(state, { kind: "unadvertised" }); + assert.equal(canQuoteJobs(state), false); +}); + +test("advertising on with no publish yet is pending, not available", () => { + const state = deriveProviderAvailability({ + advertisingEnabled: true, + sessionConnected: true, + lastAdvertisedAtMs: null, + nowMs: 1_000, + sessionLeaseTtlMs: 120_000, + }); + + assert.deepEqual(state, { kind: "pending" }); + assert.equal(canQuoteJobs(state), false); +}); + +test("a live, fresh advertisement is available and reports its next refresh and expiry", () => { + const state = deriveProviderAvailability({ + advertisingEnabled: true, + sessionConnected: true, + lastAdvertisedAtMs: 100_000, + nowMs: 110_000, + sessionLeaseTtlMs: 120_000, + }); + + assert.deepEqual(state, { + kind: "available", + refreshDueAtMs: 145_000, + expiresAtMs: 220_000, + }); + assert.equal(canQuoteJobs(state), true); +}); + +test("a dropped session reads as stale immediately, mid-lease — offline costs reachability only", () => { + const state = deriveProviderAvailability({ + advertisingEnabled: true, + sessionConnected: false, + lastAdvertisedAtMs: 100_000, + nowMs: 100_500, + sessionLeaseTtlMs: 120_000, + }); + + assert.deepEqual(state, { kind: "stale" }); + assert.equal(canQuoteJobs(state), false); +}); + +test("a missed refresh past the lease reads as stale — never advertise longer than routable", () => { + const state = deriveProviderAvailability({ + advertisingEnabled: true, + sessionConnected: true, + lastAdvertisedAtMs: 0, + nowMs: 120_000, + sessionLeaseTtlMs: 120_000, + }); + + assert.deepEqual(state, { kind: "stale" }); + assert.equal(canQuoteJobs(state), false); +}); + +test("a lease boundary reads as fresh right up to, but not including, expiry", () => { + const state = deriveProviderAvailability({ + advertisingEnabled: true, + sessionConnected: true, + lastAdvertisedAtMs: 0, + nowMs: 119_999, + sessionLeaseTtlMs: 120_000, + }); + + assert.equal(state.kind, "available"); +}); + +test("caption text is honest and non-null only where there is something to say", () => { + assert.equal(providerAvailabilityCaption({ kind: "unadvertised" }), null); + assert.match(providerAvailabilityCaption({ kind: "pending" }), /Publishing/); + assert.match( + providerAvailabilityCaption({ + kind: "available", + refreshDueAtMs: 0, + expiresAtMs: 0, + }), + /available/i, + ); + assert.match( + providerAvailabilityCaption({ kind: "stale" }), + /not currently reachable/i, + ); +}); diff --git a/desktop/src/features/providers/lib/providerAvailability.ts b/desktop/src/features/providers/lib/providerAvailability.ts new file mode 100644 index 00000000000..ff420c3fd79 --- /dev/null +++ b/desktop/src/features/providers/lib/providerAvailability.ts @@ -0,0 +1,116 @@ +/** + * The provider-surface freshness invariant (buzz#84, toon-meta#262 decision + * 12): "the socket is the lease." A route exists exactly as long as the + * authenticated connector session, so an advertisement must never claim + * availability for longer than that session stays routable — or a buyer can + * pay for a job that cannot land. + * + * Two failure directions are asymmetric and the code below only ever errs + * toward the cheap one: reading a live provider as unavailable costs a + * little missed work; reading an unreachable one as available costs a buyer + * a rejected payment. A dropped session therefore reads as `stale` + * immediately, without waiting out the lease — and going offline costs + * reachability only, never the channel, balance, nonce position or + * reputation (nothing here touches any of those). + * + * `sessionLeaseTtlMs` is a caller-supplied parameter, not a constant defined + * here: connector#698 exports it as `SESSION_LEASE_BACKSTOP_TTL` and its own + * doc comment says this ticket should read it from there rather than + * duplicating the value. As of this writing that export is not yet present + * in any published `@toon-protocol/client`/`@toon-protocol/connector` + * release this repo can pin, so there is no live caller yet — this module is + * ready for one once the value is available, the `agentNetworkFlow.ts` + * precedent for shipping pure domain logic ahead of its data source. + * + * The refresh cadence mirrors the mesh's own reference pattern + * (`mesh_llm/discovery.rs` `STATUS_FRESHNESS_SECS` = 120s freshness window, + * `mesh_llm/coordinator.rs` `STATUS_PUBLISH_INTERVAL` = 45s republish) at the + * same ~0.375 ratio, scaled to whatever lease the connector actually grants + * rather than hardcoding either side of that ratio. + */ + +/** What the provider surface knows about its own current advertisement. */ +export type ProviderAvailability = + /** The owner has not turned provider advertising on for this agent. */ + | { kind: "unadvertised" } + /** Advertising is on, but no publish has landed yet — nothing to quote from. */ + | { kind: "pending" } + /** Advertised, session live, and inside the connector's lease window. */ + | { kind: "available"; refreshDueAtMs: number; expiresAtMs: number } + /** Session dropped, or the lease lapsed without a refresh — do not quote. */ + | { kind: "stale" }; + +/** + * How long after publishing an advertisement to refresh it, given the + * connector's session lease. Scaled at the mesh's own 45s/120s ratio so a + * refresh always lands well inside the lease rather than racing its edge. + */ +export function refreshIntervalForLease(sessionLeaseTtlMs: number): number { + return Math.floor(sessionLeaseTtlMs * (45 / 120)); +} + +/** + * Derive whether this agent's provider listing is currently honest to show + * as available, never throwing and never guessing in the buyer's favor. + */ +export function deriveProviderAvailability(input: { + /** Whether the owner has turned provider advertising on for this agent. */ + advertisingEnabled: boolean; + /** Whether the connector session (the lease) is currently up. */ + sessionConnected: boolean; + /** When the advertisement was last published, or null if never. */ + lastAdvertisedAtMs: number | null; + nowMs: number; + /** The connector's session lease TTL — see the module doc for its source. */ + sessionLeaseTtlMs: number; +}): ProviderAvailability { + const { + advertisingEnabled, + sessionConnected, + lastAdvertisedAtMs, + nowMs, + sessionLeaseTtlMs, + } = input; + + if (!advertisingEnabled) return { kind: "unadvertised" }; + if (lastAdvertisedAtMs === null) return { kind: "pending" }; + // Offline costs reachability only, and it costs it immediately — waiting + // out the lease on a dropped session would advertise a provider that + // cannot actually take the job. + if (!sessionConnected) return { kind: "stale" }; + + const expiresAtMs = lastAdvertisedAtMs + sessionLeaseTtlMs; + if (nowMs >= expiresAtMs) return { kind: "stale" }; + + return { + kind: "available", + refreshDueAtMs: + lastAdvertisedAtMs + refreshIntervalForLease(sessionLeaseTtlMs), + expiresAtMs, + }; +} + +/** + * Whether this agent may currently produce a paid quote (buzz#84 gotcha: + * quoting costs money, so nothing should auto-quote outside a state that is + * both advertised and honestly reachable right now). + */ +export function canQuoteJobs(availability: ProviderAvailability): boolean { + return availability.kind === "available"; +} + +/** The caption a provider settings surface shows for each state. */ +export function providerAvailabilityCaption( + availability: ProviderAvailability, +): string | null { + switch (availability.kind) { + case "unadvertised": + return null; + case "pending": + return "Publishing this agent's provider listing…"; + case "available": + return "Advertised as available to buyers on the open job market."; + case "stale": + return "Not currently reachable — this agent's provider listing will not accept new jobs until its connector session reconnects."; + } +} From 28620885157ce54d3f730b7c3fe5add6ce1d1fdc Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Mon, 3 Aug 2026 23:28:03 +0000 Subject: [PATCH 2/2] refactor(desktop): clean up providerAvailability comments and magic ratio Fix a dangling-clause sentence in the module doc, and pull the mesh's 45s/120s refresh ratio into named constants (matching the agentNetworkFlow.ts/huddleFeeQuote.ts precedent) instead of an inline magic-number fraction, removing the duplicate explanation of the same ratio that lived in both the module doc and the function doc. Signed-off-by: Claude Sonnet 5 --- .../providers/lib/providerAvailability.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/desktop/src/features/providers/lib/providerAvailability.ts b/desktop/src/features/providers/lib/providerAvailability.ts index ff420c3fd79..c0926df6b71 100644 --- a/desktop/src/features/providers/lib/providerAvailability.ts +++ b/desktop/src/features/providers/lib/providerAvailability.ts @@ -19,14 +19,9 @@ * duplicating the value. As of this writing that export is not yet present * in any published `@toon-protocol/client`/`@toon-protocol/connector` * release this repo can pin, so there is no live caller yet — this module is - * ready for one once the value is available, the `agentNetworkFlow.ts` - * precedent for shipping pure domain logic ahead of its data source. - * - * The refresh cadence mirrors the mesh's own reference pattern - * (`mesh_llm/discovery.rs` `STATUS_FRESHNESS_SECS` = 120s freshness window, - * `mesh_llm/coordinator.rs` `STATUS_PUBLISH_INTERVAL` = 45s republish) at the - * same ~0.375 ratio, scaled to whatever lease the connector actually grants - * rather than hardcoding either side of that ratio. + * ready for one once the value is available, mirroring the + * `agentNetworkFlow.ts` precedent for shipping pure domain logic ahead of + * its data source. */ /** What the provider surface knows about its own current advertisement. */ @@ -40,13 +35,24 @@ export type ProviderAvailability = /** Session dropped, or the lease lapsed without a refresh — do not quote. */ | { kind: "stale" }; +/** + * The mesh's own reference cadence: `mesh_llm/coordinator.rs` + * `STATUS_PUBLISH_INTERVAL` republishes every 45s within the 120s freshness + * window `mesh_llm/discovery.rs` `STATUS_FRESHNESS_SECS` allows. + */ +const MESH_REFRESH_INTERVAL_SECS = 45; +const MESH_FRESHNESS_WINDOW_SECS = 120; + /** * How long after publishing an advertisement to refresh it, given the - * connector's session lease. Scaled at the mesh's own 45s/120s ratio so a - * refresh always lands well inside the lease rather than racing its edge. + * connector's session lease. Scaled at the mesh's own ratio so a refresh + * always lands well inside the lease rather than racing its edge. */ export function refreshIntervalForLease(sessionLeaseTtlMs: number): number { - return Math.floor(sessionLeaseTtlMs * (45 / 120)); + return Math.floor( + (sessionLeaseTtlMs * MESH_REFRESH_INTERVAL_SECS) / + MESH_FRESHNESS_WINDOW_SECS, + ); } /**