Fix
Release rolling / release (push) Successful in 12m24s

1. engine.beginGeneration() — clears the abort latch at the start of each send
2. Stop unlocks the UI immediately — increments a runId, shows Send again, status: “Stopped — ready for a new message”
3. In-flight turn is invalidated — late stream/tool updates ignore a superseded run
4. New chat — stops any busy turn and resets the same way
5. Cancel timeout — IPC cancel won’t hang Stop for more than ~2.5s
This commit is contained in:
Raven Scott
2026-07-30 18:59:25 -04:00
parent fe10cca589
commit 7ccd1fade7
2 changed files with 70 additions and 15 deletions
+15 -1
View File
@@ -103,13 +103,18 @@ export function createQvacEngine(deps) {
/** /**
* Stop in-flight inference (and pending tool rounds). * Stop in-flight inference (and pending tool rounds).
* Safe to call when idle. * Safe to call when idle.
* Leaves abort latch set until {@link beginGeneration} (start of next send).
*/ */
async function stop() { async function stop() {
abortGeneration = true abortGeneration = true
const ipc = getElectronIpc() const ipc = getElectronIpc()
try { try {
if (ipc && sdkMode === 'main') { if (ipc && sdkMode === 'main') {
await ipc.invoke('peardata:qvac-cancel') // Don't hang the UI if cancel is slow — race a short timeout
await Promise.race([
ipc.invoke('peardata:qvac-cancel'),
new Promise((r) => setTimeout(r, 2500)),
])
} else if (sdk?.cancel) { } else if (sdk?.cancel) {
if (activeRequestId) { if (activeRequestId) {
try { try {
@@ -136,6 +141,12 @@ export function createQvacEngine(deps) {
return abortGeneration return abortGeneration
} }
/** Clear stop latch — call at the start of each user send. */
function beginGeneration() {
abortGeneration = false
activeRequestId = null
}
/** @type {any} */ /** @type {any} */
let sdk = null let sdk = null
/** @type {'direct'|'main'|null} */ /** @type {'direct'|'main'|null} */
@@ -483,6 +494,8 @@ export function createQvacEngine(deps) {
*/ */
async function complete(history, opts = {}) { async function complete(history, opts = {}) {
touchActivity() touchActivity()
// Fresh generation for this complete() call (stop during prior turns is cleared).
// UI also calls beginGeneration() at send start so multi-agent can run before this.
abortGeneration = false abortGeneration = false
const profile = getProfile(profileId || 'recommended') const profile = getProfile(profileId || 'recommended')
const prefs = deps.getPrefs?.() || {} const prefs = deps.getPrefs?.() || {}
@@ -873,6 +886,7 @@ export function createQvacEngine(deps) {
complete, complete,
stop, stop,
isAborted, isAborted,
beginGeneration,
getStatus, getStatus,
tryLoadSdk, tryLoadSdk,
touchActivity, touchActivity,
+55 -14
View File
@@ -112,6 +112,8 @@ export function createQvacView(opts) {
/** @type {Array<{ role: string, content: string, tools?: any[] }>} */ /** @type {Array<{ role: string, content: string, tools?: any[] }>} */
let messages = [] let messages = []
let busy = false let busy = false
/** Invalidates in-flight send when Stop is pressed so UI unlocks immediately. */
let runId = 0
let wizardStep = 0 let wizardStep = 0
/** @type {string} */ /** @type {string} */
let selectedProfile = 'recommended' let selectedProfile = 'recommended'
@@ -809,6 +811,17 @@ export function createQvacView(opts) {
} }
} }
/**
* Unlock composer after Stop even if the in-flight send is still winding down.
* Also clears the abort latch only after a new send begins (see beginGeneration).
*/
function finishStoppedUi() {
runId += 1
setBusyUi(false)
setStatus('Stopped — ready for a new message', 'warn')
syncModelChip()
}
async function stopInference() { async function stopInference() {
if (!busy) return if (!busy) return
setStatus('Stopping…', 'busy') setStatus('Stopping…', 'busy')
@@ -818,6 +831,8 @@ export function createQvacView(opts) {
} catch (err) { } catch (err) {
opts.log?.(`QVAC stop: ${err?.message || err}`) opts.log?.(`QVAC stop: ${err?.message || err}`)
} }
// Always re-enable chat immediately — do not wait for hung tools / stream
finishStoppedUi()
} }
async function send() { async function send() {
@@ -825,6 +840,9 @@ export function createQvacView(opts) {
const text = (input?.value || '').trim() const text = (input?.value || '').trim()
if (!text || busy) return if (!text || busy) return
if (input) input.value = '' if (input) input.value = ''
// Clear previous Stop latch so this turn can run
engine.beginGeneration?.()
const myRun = ++runId
setBusyUi(true) setBusyUi(true)
appendMsg('user', text) appendMsg('user', text)
const assistantUi = appendMsg('assistant', '…', { streaming: true }) const assistantUi = appendMsg('assistant', '…', { streaming: true })
@@ -837,6 +855,7 @@ export function createQvacView(opts) {
// Hide legacy sticky bar if still in DOM // Hide legacy sticky bar if still in DOM
opts.els.agentBar?.classList.add('hidden') opts.els.agentBar?.classList.add('hidden')
opts.els.agentBar && (opts.els.agentBar.innerHTML = '') opts.els.agentBar && (opts.els.agentBar.innerHTML = '')
const stillThisRun = () => myRun === runId
try { try {
// History for the model: clean answers only (no think tags) // History for the model: clean answers only (no think tags)
const hist = messages const hist = messages
@@ -894,11 +913,13 @@ export function createQvacView(opts) {
followMessagesScroll() followMessagesScroll()
}, },
}) })
if (engine.isAborted?.()) { if (engine.isAborted?.() || !stillThisRun()) {
swarm.fail('Stopped') if (stillThisRun()) {
acc = '_(Stopped during multi-agent investigation.)_' swarm.fail('Stopped')
paintAssistant(streamBody, acc, false) acc = '_(Stopped during multi-agent investigation.)_'
setStatus('Stopped', 'warn') paintAssistant(streamBody, acc, false)
setStatus('Stopped — ready for a new message', 'warn')
}
return return
} }
swarm.complete('All agents done — synthesizing answer…') swarm.complete('All agents done — synthesizing answer…')
@@ -929,20 +950,24 @@ export function createQvacView(opts) {
followMessagesScroll({ force: true }) followMessagesScroll({ force: true })
} }
if (engine.isAborted?.()) { if (engine.isAborted?.() || !stillThisRun()) {
acc = '_(Stopped.)_' if (stillThisRun()) {
paintAssistant(streamBody, acc, false) acc = '_(Stopped.)_'
setStatus('Stopped', 'warn') paintAssistant(streamBody, acc, false)
setStatus('Stopped — ready for a new message', 'warn')
}
return return
} }
const result = await engine.complete(hist, { const result = await engine.complete(hist, {
...completeOpts, ...completeOpts,
onToken: (t) => { onToken: (t) => {
if (!stillThisRun()) return
acc += t acc += t
paintAssistant(streamBody, mergeThinkStream(thinkingAcc, acc), true) paintAssistant(streamBody, mergeThinkStream(thinkingAcc, acc), true)
}, },
onThinking: (t) => { onThinking: (t) => {
if (!stillThisRun()) return
thinkingAcc += t thinkingAcc += t
const raw = thinkingAcc const raw = thinkingAcc
? `<think>\n${thinkingAcc}\n</think>\n${acc}` ? `<think>\n${thinkingAcc}\n</think>\n${acc}`
@@ -950,7 +975,7 @@ export function createQvacView(opts) {
paintAssistant(streamBody, raw, true) paintAssistant(streamBody, raw, true)
}, },
onTool: (name, args, res) => { onTool: (name, args, res) => {
if (engine.isAborted?.()) return if (engine.isAborted?.() || !stillThisRun()) return
toolLog.push({ name, args, result: res }) toolLog.push({ name, args, result: res })
setStatus(`Tool: ${name}`, 'busy') setStatus(`Tool: ${name}`, 'busy')
if (assistantUi?.div) { if (assistantUi?.div) {
@@ -961,6 +986,7 @@ export function createQvacView(opts) {
} }
}, },
}) })
if (!stillThisRun()) return
acc = result.contentText || acc acc = result.contentText || acc
if (thinkingAcc && !/<think/i.test(acc)) { if (thinkingAcc && !/<think/i.test(acc)) {
acc = `<think>\n${thinkingAcc}\n</think>\n${acc}` acc = `<think>\n${thinkingAcc}\n</think>\n${acc}`
@@ -1004,11 +1030,12 @@ export function createQvacView(opts) {
} }
} }
if (result.stopped || result.mode === 'stopped') { if (result.stopped || result.mode === 'stopped') {
setStatus('Stopped', 'warn') setStatus('Stopped — ready for a new message', 'warn')
} else { } else {
setStatus(result.mode === 'fallback' ? 'Ready (tools-only)' : 'Ready', 'ok') setStatus(result.mode === 'fallback' ? 'Ready (tools-only)' : 'Ready', 'ok')
} }
} catch (err) { } catch (err) {
if (!stillThisRun()) return
const msg = err?.message || String(err) const msg = err?.message || String(err)
if (/cancel|abort|stopped/i.test(msg)) { if (/cancel|abort|stopped/i.test(msg)) {
if (streamBody && !acc) { if (streamBody && !acc) {
@@ -1016,14 +1043,17 @@ export function createQvacView(opts) {
} else if (streamBody && acc) { } else if (streamBody && acc) {
paintAssistant(streamBody, acc + '\n\n_(Stopped.)_', false) paintAssistant(streamBody, acc + '\n\n_(Stopped.)_', false)
} }
setStatus('Stopped', 'warn') setStatus('Stopped — ready for a new message', 'warn')
} else { } else {
if (streamBody) streamBody.innerHTML = formatMdLite(`Error: ${msg}`) if (streamBody) streamBody.innerHTML = formatMdLite(`Error: ${msg}`)
setStatus(msg, 'error') setStatus(msg, 'error')
} }
} finally { } finally {
setBusyUi(false) // Only the active run owns the composer; Stop already unlocked via finishStoppedUi
syncModelChip() if (stillThisRun()) {
setBusyUi(false)
syncModelChip()
}
} }
} }
@@ -1036,6 +1066,14 @@ export function createQvacView(opts) {
} }
function newChat() { function newChat() {
// Cancel any in-flight turn and unlock composer
if (busy) {
engine.stop?.().catch(() => {})
finishStoppedUi()
} else {
engine.beginGeneration?.()
runId += 1
}
releaseLiveSwarm() releaseLiveSwarm()
messages = [] messages = []
if (opts.els.messages) opts.els.messages.innerHTML = '' if (opts.els.messages) opts.els.messages.innerHTML = ''
@@ -1045,6 +1083,9 @@ export function createQvacView(opts) {
'assistant', 'assistant',
'New chat. Ask about host health, metrics, anomalies, or processes.' 'New chat. Ask about host health, metrics, anomalies, or processes.'
) )
const mode = settings().qvacMode
setStatus(mode === 'qvac' ? 'Ready' : mode === 'fallback' ? 'Ready (tools-only)' : 'Ready', 'ok')
syncModelChip()
} }
function resetOnboarding() { function resetOnboarding() {