feat(chat): block new sessions while the supplier is busy

The supplier is single-slot, but the buyer posted the escrow BEFORE learning
the supplier was busy, stranding a second user's funds in an Open escrow. Now:
- startChat does a pre-flight GET /status and refuses (409, no escrow posted)
  when the supplier is "working".
- ChatForm polls the supplier status (indexer) every 8s and disables the
  Start chat button (\"Supplier busy\") until it returns to free.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
david
2026-06-08 18:00:26 +02:00
parent 4434c787be
commit a0f6ff4444
2 changed files with 78 additions and 11 deletions

View File

@@ -822,6 +822,30 @@ export class Marketplace extends EventEmitterBase {
}
const advertDatum = decodeAdvertDatum(advertUtxo.datumHex);
const supplierHttp = new HttpClient({ baseUrl: advertDatum.endpoint_url, fetch: this.fetchImpl });
// Pre-flight: refuse to lock any funds if the supplier is already serving
// another chat. The supplier is single-slot, so a second concurrent escrow
// would only 409 at Claim and be left stranded Open with funds locked.
// Checking /status first keeps the buyer's funds unlocked. Best-effort: if
// /status is unreachable we proceed, and the supplier's Claim still guards
// the slot.
try {
const statusRes = await supplierHttp.getJson("/status");
const liveStatus = statusRes.body && typeof statusRes.body === "object"
? (statusRes.body as { status?: string }).status
: undefined;
if (liveStatus === "working") {
throw new SupplierError("supplier_busy", {
status: 409,
message: "supplier is busy with another chat session",
});
}
} catch (err) {
if (err instanceof SupplierError) throw err;
/* /status unreachable — proceed; Claim still enforces the single slot */
}
const sessionNonce = randomNonce();
let escrowResult;
try {
@@ -850,7 +874,6 @@ export class Marketplace extends EventEmitterBase {
/* mock providers / tests */
}
const supplierHttp = new HttpClient({ baseUrl: advertDatum.endpoint_url, fetch: this.fetchImpl });
let startRes;
try {
startRes = await supplierHttp.postJson(

View File

@@ -21,7 +21,7 @@
* data: {"type":"error","message":"…"}\n\n
*/
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { OutputReference } from "@marketplace/shared/chain";
export interface ChatFormProps {
@@ -104,6 +104,7 @@ async function streamChat(
export default function ChatForm({ advertRef, payment_lovelace }: ChatFormProps = {}): JSX.Element {
const isPaid = advertRef !== undefined && payment_lovelace !== undefined;
const advertRefStr = advertRef ? `${advertRef.txHash}#${advertRef.index}` : null;
const [turns, setTurns] = useState<ChatTurn[]>([]);
const [streaming, setStreaming] = useState<string | null>(null); // in-progress assistant text
@@ -116,11 +117,43 @@ export default function ChatForm({ advertRef, payment_lovelace }: ChatFormProps
const [starting, setStarting] = useState(false);
const [ending, setEnding] = useState(false);
const [charged, setCharged] = useState<string | null>(null);
// Live supplier availability (paid mode): null = unknown/checking, true = free, false = busy/offline.
const [available, setAvailable] = useState<boolean | null>(null);
const sessionRef = useRef<Session | null>(null);
sessionRef.current = session;
const active = isPaid ? session !== null && charged === null : true;
// Poll the supplier's status while the Start button is showing, so a second
// user can't start a chat while one is already active. The supplier is
// single-slot (status "working" between Claim and Submit); we disable Start
// until it's "free" again. (startChat also does an authoritative pre-flight
// /status check, so this is the UX layer over that guard.)
useEffect(() => {
if (!isPaid || !advertRefStr || session !== null || charged !== null) return;
let cancelled = false;
const check = async (): Promise<void> => {
try {
const r = await fetch("/v1/indexer/suppliers?capability_id=llm.chat.v1");
if (!r.ok) throw new Error(String(r.status));
const rows = (await r.json()) as Array<{ utxo_ref: string; status: string }>;
const row = rows.find((x) => x.utxo_ref === advertRefStr);
// Unknown (row missing / indexer hiccup) → null, which does NOT hard-block
// (the pre-flight check still guards funds); only an explicit non-free
// status disables the button.
if (!cancelled) setAvailable(row ? row.status === "free" : null);
} catch {
if (!cancelled) setAvailable(null);
}
};
void check();
const id = setInterval(check, 8000);
return () => {
cancelled = true;
clearInterval(id);
};
}, [isPaid, advertRefStr, session, charged]);
async function startChat(): Promise<void> {
if (!advertRef || payment_lovelace === undefined) return;
setStarting(true);
@@ -226,15 +259,26 @@ export default function ChatForm({ advertRef, payment_lovelace }: ChatFormProps
return (
<div className="space-y-4 rounded border border-gray-200 bg-white p-4" data-testid="chat-form">
{isPaid && session === null && (
<button
type="button"
onClick={startChat}
disabled={starting}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:bg-gray-400"
data-testid="chat-start"
>
{starting ? "Opening escrow…" : "Start chat"}
</button>
<div className="space-y-2">
<button
type="button"
onClick={startChat}
disabled={starting || available === false}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:bg-gray-400"
data-testid="chat-start"
>
{starting
? "Opening escrow…"
: available === false
? "Supplier busy"
: "Start chat"}
</button>
{available === false && (
<p className="text-sm text-amber-700" data-testid="chat-busy">
Another chat is in progress on this supplier. The button re-enables when it ends.
</p>
)}
</div>
)}
{(active || streaming !== null || turns.length > 0) && (