mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 00:03:11 +00:00
* feat: run turns in several flow chat conversations at once Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep finished turns finished and cached chats current in the flow chat pool Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: attribute a turn's rows by job id as well as sequence Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: count only real stream updates and retry the job-id read Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep a chat that holds an unsent draft, and take one back when its first turn is withdrawn Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: ignore a stale running-turn snapshot, keep a withdrawn chat's draft, poll after clean stream ends Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: follow the turn running now when the listing named one already over Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep replacement turns and SSE fallback moving * fix: keep replacement turn handoffs active * fix: preserve unread badge line height * fix: settle local fallback handoffs * fix: settle refused turn handoffs * fix: scope turn handoffs to conversation * fix: drop stale turn handoffs * refactor: move the queued message and 409 handling into per-conversation turns Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address cubic's review of the parallel flow chat turns Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: clear a stale failure on refresh, and tighten the docs and test waits Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: recover running rows past the first page, and drop the failure a re-read disproves Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: check the running-turn query at compile time, and narrow what a refresh clears Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: settle a failed turn only from an answer that turn wrote Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: settle a failed turn from its own answer, and only while it is still the failure shown Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop a failure whose answer arrived even when a newer turn owns the error Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: free an answered failure whatever the turn that started meanwhile is doing Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop a rows read that a turn outran, rather than merging it under newer messages Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop a rows read whose conversation was left and opened again Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: hand over a file still being read when its composer goes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: count a drop's routing as work in flight, so its file is handed over too Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: hold the send until every file a conversation is owed has landed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: keep a panel mounted per conversation instead of handing its draft over Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the withdrawn chat whose composer was written in, not the empty one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the chat in front of the reader when both withdrawn composers were written in Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep a retry's own run arguments when a turn elsewhere refuses it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: name panels apart across pools, and read a flow's inputs when its chat is built Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
122 lines
5.2 KiB
TypeScript
122 lines
5.2 KiB
TypeScript
import { WindmillApiError, type WindmillChatApi } from './api'
|
|
import { createStreamEventParser, type AgentStreamEvent } from './stream'
|
|
import { abortError, isAbortError, sleep } from './utils'
|
|
|
|
const RECONNECT_DELAY_MS = 300
|
|
const MAX_RECONNECT_DELAY_MS = 5000
|
|
/** Consecutive failed connections before the job is polled instead. */
|
|
const MAX_CONNECTION_FAILURES = 3
|
|
const RESULT_POLL_MS = 2000
|
|
|
|
export type FollowEvent =
|
|
/** Agent events decoded from the job's result stream; empty when a chunk ended mid-line. */
|
|
| { type: 'stream'; events: AgentStreamEvent[] }
|
|
/** `streamLost`: the result was polled after the stream failed, so what streamed may stop short. */
|
|
| { type: 'completed'; result: unknown; streamLost?: boolean }
|
|
|
|
/**
|
|
* Follows a job to completion across the server's stream timeouts: every
|
|
* connection resumes from the last `stream_offset`, so no delta is repeated and
|
|
* the flow is never re-run. `onOffset` reports each offset, and its loss, so a
|
|
* caller can resume later from another connection (see the AI SDK transport).
|
|
*
|
|
* The offset indexes the stream of one sub-job (`flow_stream_job_id`, the flow's
|
|
* streaming step). A retried step gets a new one, so when the id changes the
|
|
* offset is dropped and the connection reopened from that sub-job's start.
|
|
*
|
|
* A connection that fails (a proxy restarting, the network dropping) is retried with
|
|
* backoff. The run is still going, so after a few failures in a row the job's result is
|
|
* polled instead: the rest of the answer is not streamed, but the turn still ends.
|
|
*/
|
|
export async function* followJob(
|
|
api: WindmillChatApi,
|
|
jobId: string,
|
|
options: { signal?: AbortSignal; streamOffset?: number; onOffset?: (offset: number | undefined) => void } = {}
|
|
): AsyncGenerator<FollowEvent> {
|
|
let parser = createStreamEventParser()
|
|
let offset = options.streamOffset
|
|
let streamJobId: string | undefined
|
|
let failures = 0
|
|
while (failures < MAX_CONNECTION_FAILURES) {
|
|
let reopen = false
|
|
try {
|
|
for await (const update of api.streamJob(jobId, { streamOffset: offset, signal: options.signal })) {
|
|
// Opening a connection does not prove it is carrying the job. Only stream progress
|
|
// clears the count, or status snapshots followed by EOF would never reach polling.
|
|
if (update.type === 'ping') continue
|
|
if (update.type === 'timeout') {
|
|
reopen = true
|
|
break
|
|
}
|
|
if (update.type === 'error') throw new Error(update.error)
|
|
if (update.type === 'notfound') throw new Error(`Job ${jobId} not found`)
|
|
if (update.flow_stream_job_id && update.flow_stream_job_id !== streamJobId) {
|
|
const switched = streamJobId !== undefined && offset !== undefined
|
|
streamJobId = update.flow_stream_job_id
|
|
if (switched) {
|
|
// This connection skipped the new sub-job's first chunks: start it over.
|
|
failures = 0
|
|
offset = undefined
|
|
options.onOffset?.(undefined)
|
|
parser = createStreamEventParser()
|
|
reopen = true
|
|
break
|
|
}
|
|
}
|
|
if (update.stream_offset !== undefined) {
|
|
if (update.stream_offset !== offset) failures = 0
|
|
offset = update.stream_offset
|
|
options.onOffset?.(offset)
|
|
}
|
|
if (update.new_result_stream) {
|
|
failures = 0
|
|
yield { type: 'stream', events: parser.push(update.new_result_stream) }
|
|
}
|
|
if (update.completed) {
|
|
const rest = parser.flush()
|
|
if (rest.length > 0) yield { type: 'stream', events: rest }
|
|
yield { type: 'completed', result: update.only_result }
|
|
return
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (options.signal?.aborted || isAbortError(e) || !isConnectionFailure(e)) throw e
|
|
failures++
|
|
if (failures >= MAX_CONNECTION_FAILURES) break
|
|
await sleep(Math.min(RECONNECT_DELAY_MS * 2 ** failures, MAX_RECONNECT_DELAY_MS), options.signal)
|
|
continue
|
|
}
|
|
if (options.signal?.aborted) throw abortError()
|
|
// The server closes the connection after its timeout; a connection that ends without
|
|
// that event, and without the job completing, carried nothing to its end. The offset
|
|
// lets the next one resume, and it counts like a failed one so a gateway closing every
|
|
// stream this way still reaches the polling below rather than reconnecting for ever.
|
|
if (!reopen) {
|
|
failures++
|
|
if (failures >= MAX_CONNECTION_FAILURES) break
|
|
await sleep(RECONNECT_DELAY_MS, options.signal)
|
|
}
|
|
}
|
|
while (true) {
|
|
await sleep(RESULT_POLL_MS, options.signal)
|
|
try {
|
|
const { completed, result } = await api.getCompletedResult(jobId, options.signal)
|
|
if (completed) {
|
|
yield { type: 'completed', result, streamLost: true }
|
|
return
|
|
}
|
|
} catch (e) {
|
|
if (options.signal?.aborted || isAbortError(e) || !isConnectionFailure(e)) throw e
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A failure that says nothing about the job: the request never reached Windmill, or a
|
|
* gateway in front of it answered. A 4xx from Windmill itself (not found, refused) does.
|
|
*/
|
|
function isConnectionFailure(e: unknown): boolean {
|
|
if (e instanceof WindmillApiError) return e.status >= 500 || e.status === 0
|
|
return e instanceof TypeError
|
|
}
|