@langchain/langgraph-sdk
Version:
Client library for interacting with the LangGraph API
203 lines (202 loc) • 7.38 kB
JavaScript
import { bindThreadConfig } from "./dispatch-config.js";
import { EMPTY_QUEUE } from "./queue-adapter.js";
import { v7 } from "@langchain/core/utils/uuid";
//#region src/stream/queue-adapter-agent-server.ts
/**
* Backs "enqueue" with real, durable runs. An entry is a genuine
* server-side pending run the instant `enqueue()` resolves.
*
* Selected when `queue: "server"` is set (see {@link SubmitCoordinator}'s
* constructor).
*/
var AgentServerQueueAdapter = class {
#runs;
#assistantId;
#store;
#onError;
#getThread;
/** One shared subscription for this adapter's lifetime, not one per run. */
#unsubscribe;
/** ids canceled while their create() was still in flight. */
#pendingCancel = /* @__PURE__ */ new Set();
#threadId;
/** Guards against a stale hydrate() landing after a rapid thread-switch. */
#hydrateGeneration = 0;
/** Aborts in-flight bookkeeping `list()` calls on detach(); replaced (never reused) afterward. */
#abortController = new AbortController();
/** Handle for #refreshPending's pending retry, if any; cleared on detach(). */
#refreshRetryTimer;
/** Lets enqueue() detect a "started" event during its own create(). */
#startedEventCount = 0;
constructor(runs, assistantId, store, onError, getThread) {
this.#runs = runs;
this.#assistantId = assistantId;
this.#store = store;
this.#onError = onError;
this.#getThread = getThread;
}
async hydrate(threadId) {
this.#threadId = threadId;
this.#ensureWatching(threadId);
const generation = ++this.#hydrateGeneration;
const knownAtRequestTime = this.#knownRunIds();
let pending;
try {
pending = await this.#runs.list(threadId, {
status: "pending",
limit: 1e3,
select: [
"run_id",
"kwargs",
"created_at",
"multitask_strategy"
],
signal: this.#abortController.signal
});
} catch (error) {
if (generation === this.#hydrateGeneration) this.#onError(error);
return;
}
if (generation !== this.#hydrateGeneration) return;
const remoteIds = new Set(pending.map((run) => run.run_id));
this.#store.setState((current) => {
const knownRunIds = new Set(current.flatMap((e) => e.runId ? [e.runId] : []));
const stillValid = current.filter((e) => this.#isStillQueued(e, remoteIds, knownAtRequestTime));
return [...pending.filter((run) => !knownRunIds.has(run.run_id)).map((run) => this.#toEntry(run)), ...stillValid];
});
}
/**
* On a self-created thread, this can race the active run's own
* `run.start` request that creates the thread row: if this
* `runs.create()` reaches the server first, it may 404. The local
* adapter has no such race (it never hits the server while queued).
*/
async enqueue(threadId, values, options) {
this.#threadId = threadId;
this.#ensureWatching(threadId);
const startedEventsBefore = this.#startedEventCount;
const generationAtEnqueue = this.#hydrateGeneration;
const id = v7();
this.#store.setState((current) => [...current, {
id,
values,
options,
createdAt: /* @__PURE__ */ new Date()
}]);
try {
const run = await this.#runs.create(threadId, this.#assistantId, {
input: values,
config: bindThreadConfig(options?.config, threadId),
metadata: options?.metadata,
checkpointId: options?.forkFrom,
multitaskStrategy: "enqueue"
});
if (this.#pendingCancel.delete(id)) {
await this.#runs.cancel(threadId, run.run_id);
return;
}
this.#store.setState((current) => current.map((entry) => entry.id === id ? {
...entry,
runId: run.run_id
} : entry));
if (this.#startedEventCount !== startedEventsBefore && this.#hydrateGeneration === generationAtEnqueue) this.#refreshPending(threadId);
} catch (error) {
this.#pendingCancel.delete(id);
this.#store.setState((current) => current.filter((entry) => entry.id !== id));
this.#onError(error);
throw error;
}
}
async cancel(id) {
const entry = this.#store.getSnapshot().find((e) => e.id === id);
if (!entry) return false;
this.#store.setState((current) => current.filter((e) => e.id !== id));
await this.#cancelEntry(entry);
return true;
}
async clear() {
const entries = this.#store.getSnapshot();
this.#store.setState(() => EMPTY_QUEUE);
const failures = (await Promise.allSettled(entries.map((entry) => this.#cancelEntry(entry)))).filter((r) => r.status === "rejected");
if (failures.length === 1) this.#onError(failures[0].reason);
else if (failures.length > 1) this.#onError(new AggregateError(failures.map((f) => f.reason), `${failures.length} of ${entries.length} queued runs failed to cancel`));
}
detach() {
this.#hydrateGeneration++;
this.#unsubscribe?.();
this.#unsubscribe = void 0;
this.#threadId = void 0;
this.#abortController.abort();
this.#abortController = new AbortController();
clearTimeout(this.#refreshRetryTimer);
this.#refreshRetryTimer = void 0;
}
async #cancelEntry(entry) {
if (entry.runId && this.#threadId) await this.#runs.cancel(this.#threadId, entry.runId);
else this.#pendingCancel.add(entry.id);
}
/**
* Lazily opens ONE subscription for this adapter's lifetime, not one
* per run. `LifecycleEvent` carries no `run_id`, so a `"running"`
* event triggers a `runs.list` re-check instead of a direct match.
*/
#ensureWatching(threadId) {
if (this.#unsubscribe) return;
this.#unsubscribe = this.#getThread(threadId).onEvent((event) => {
if (event.method !== "lifecycle") return;
if (event.params.data.event !== "running") return;
this.#startedEventCount++;
this.#refreshPending(threadId);
});
}
/**
* Re-lists pending runs and drops any queue entry no longer reported
* pending: it's running now. Retried once after a network failure
* before surfacing an error — otherwise a single blip would leave a
* now-running entry stuck showing "queued" until the next promotion
* or a manual hydrate.
*/
async #refreshPending(threadId, attempt = 0) {
if (this.#store.getSnapshot().length === 0) return;
const generation = this.#hydrateGeneration;
const knownAtRequestTime = this.#knownRunIds();
try {
const pending = await this.#runs.list(threadId, {
status: "pending",
limit: 1e3,
signal: this.#abortController.signal
});
if (generation !== this.#hydrateGeneration) return;
const stillPendingIds = new Set(pending.map((run) => run.run_id));
this.#store.setState((current) => current.filter((entry) => this.#isStillQueued(entry, stillPendingIds, knownAtRequestTime)));
} catch (error) {
if (generation !== this.#hydrateGeneration) return;
if (attempt < 1) {
this.#refreshRetryTimer = setTimeout(() => {
this.#refreshRetryTimer = void 0;
this.#refreshPending(threadId, attempt + 1);
}, 2e3);
return;
}
this.#onError(error);
}
}
#toEntry(run) {
return {
id: run.run_id,
runId: run.run_id,
values: run.kwargs?.input,
createdAt: new Date(run.created_at)
};
}
#knownRunIds() {
return new Set(this.#store.getSnapshot().flatMap((e) => e.runId ? [e.runId] : []));
}
/** Shared by hydrate() and #refreshPending(): is a list() response grounds to evict this entry? */
#isStillQueued(entry, stillPendingIds, knownAtRequestTime) {
return !entry.runId || stillPendingIds.has(entry.runId) || !knownAtRequestTime.has(entry.runId);
}
};
//#endregion
export { AgentServerQueueAdapter };
//# sourceMappingURL=queue-adapter-agent-server.js.map