fix(quick-entry): treat a relay timeout as unknown and reconcile late acks
A relay timeout no longer deletes the correlation or invites a retry: it resolves as an unknown, non-retryable outcome and keeps the correlation reconcilable. A late ack for a timed-out submit fires onLateResult exactly once instead of being dropped, and never hides the window behind the user. main.ts forwards that outcome to the capture window over hermes:quick-entry:late-result. Regression: unknown-timeout contract, late success, late failure, and duplicate-late-ack coverage in electron/quick-entry.test.ts.
This commit is contained in:
@@ -18091,6 +18091,14 @@ ipcMain.handle('hermes:quick-entry:settings:set', async (_event, patch) => {
|
||||
// payload is `{ target, text }` — target routing (current chat / a picked
|
||||
// session / new) is the renderer's job too.
|
||||
const quickEntrySubmitRelay = createQuickEntrySubmitRelay({
|
||||
// A late ack for a timed-out submit proves the outcome. Forward it to the
|
||||
// capture window so it can reconcile the unknown state instead of the user
|
||||
// resending a prompt that may already be delivered.
|
||||
onLateResult: (correlationId, result) => {
|
||||
if (quickEntryWindow && !quickEntryWindow.isDestroyed()) {
|
||||
quickEntryWindow.webContents.send('hermes:quick-entry:late-result', { correlationId, result })
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
hideQuickEntryWindow()
|
||||
if (process.platform === 'darwin') {
|
||||
|
||||
@@ -31,15 +31,74 @@ describe('quick-entry submit ack relay', () => {
|
||||
expect(relay.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('resolves an unacknowledged submit as a timeout and cleans it up', async () => {
|
||||
it('resolves an unacknowledged submit as an unknown, non-retryable timeout and keeps it reconcilable', async () => {
|
||||
vi.useFakeTimers()
|
||||
const relay = createQuickEntrySubmitRelay({ onSuccess: vi.fn(), timeoutMs: 15000 })
|
||||
const pending = relay.begin(vi.fn())
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15000)
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ code: 'timeout', ok: false })
|
||||
await expect(pending).resolves.toMatchObject({ code: 'timeout', ok: false, retryable: false })
|
||||
expect(relay.pendingCount()).toBe(0)
|
||||
expect(relay.reconcilableCount()).toBe(1)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reconciles a late success through onLateResult without hiding the window', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onSuccess = vi.fn()
|
||||
const onLateResult = vi.fn()
|
||||
const relay = createQuickEntrySubmitRelay({ onLateResult, onSuccess, timeoutMs: 15000 })
|
||||
let correlationId = ''
|
||||
const pending = relay.begin(id => {
|
||||
correlationId = id
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15000)
|
||||
await pending
|
||||
relay.acknowledge(correlationId, { ok: true })
|
||||
|
||||
expect(onLateResult).toHaveBeenCalledTimes(1)
|
||||
expect(onLateResult).toHaveBeenCalledWith(correlationId, { ok: true })
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
expect(relay.reconcilableCount()).toBe(0)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reconciles a late failure through onLateResult', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onLateResult = vi.fn()
|
||||
const relay = createQuickEntrySubmitRelay({ onLateResult, onSuccess: vi.fn(), timeoutMs: 15000 })
|
||||
let correlationId = ''
|
||||
const pending = relay.begin(id => {
|
||||
correlationId = id
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15000)
|
||||
await pending
|
||||
relay.acknowledge(correlationId, { code: 'submit-failed', ok: false })
|
||||
|
||||
expect(onLateResult).toHaveBeenCalledTimes(1)
|
||||
expect(relay.reconcilableCount()).toBe(0)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('ignores a duplicate late ack', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onLateResult = vi.fn()
|
||||
const relay = createQuickEntrySubmitRelay({ onLateResult, onSuccess: vi.fn(), timeoutMs: 15000 })
|
||||
let correlationId = ''
|
||||
const pending = relay.begin(id => {
|
||||
correlationId = id
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15000)
|
||||
await pending
|
||||
relay.acknowledge(correlationId, { ok: true })
|
||||
relay.acknowledge(correlationId, { ok: true })
|
||||
|
||||
expect(onLateResult).toHaveBeenCalledTimes(1)
|
||||
expect(relay.reconcilableCount()).toBe(0)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
|
||||
@@ -37,9 +37,14 @@ export interface QuickEntrySubmitRelay {
|
||||
begin: (forward: (correlationId: string) => void) => Promise<QuickEntrySubmitRelayResult>
|
||||
acknowledge: (correlationId: string, result: QuickEntrySubmitRelayResult) => void
|
||||
pendingCount: () => number
|
||||
/** Correlations whose outcome is UNKNOWN: the relay timed out but the backend
|
||||
* may still have accepted the prompt. Kept so a late ack reconciles instead
|
||||
* of being silently dropped. */
|
||||
reconcilableCount: () => number
|
||||
}
|
||||
|
||||
export function createQuickEntrySubmitRelay(options: {
|
||||
onLateResult?: (correlationId: string, result: QuickEntrySubmitRelayResult) => void
|
||||
onSuccess: () => void
|
||||
timeoutMs?: number
|
||||
}): QuickEntrySubmitRelay {
|
||||
@@ -47,12 +52,23 @@ export function createQuickEntrySubmitRelay(options: {
|
||||
string,
|
||||
{ resolve: (result: QuickEntrySubmitRelayResult) => void; timer: NodeJS.Timeout }
|
||||
>()
|
||||
// Timed-out correlations. Delivery is UNCONFIRMED, so a late ack must
|
||||
// reconcile; a correlation is never resolved twice.
|
||||
const reconcilable = new Set<string>()
|
||||
let sequence = 0
|
||||
|
||||
return {
|
||||
acknowledge(correlationId, result) {
|
||||
const request = pending.get(correlationId)
|
||||
|
||||
if (!request) {
|
||||
// A late ack for a timed-out submit: the outcome is now known. Do NOT
|
||||
// hide the window here — the user may already be typing again. The
|
||||
// renderer reconciles the unknown outcome itself.
|
||||
if (reconcilable.delete(correlationId)) {
|
||||
options.onLateResult?.(correlationId, result)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,11 +85,14 @@ export function createQuickEntrySubmitRelay(options: {
|
||||
return new Promise(resolve => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(correlationId)
|
||||
// UNKNOWN, not failed: the prompt may already be accepted, so this
|
||||
// must never invite a retry. Keep the correlation for late acks.
|
||||
reconcilable.add(correlationId)
|
||||
resolve({
|
||||
code: 'timeout',
|
||||
message: 'Hermes did not confirm the prompt in time.',
|
||||
message: 'Hermes has not confirmed the prompt yet — it may still be delivered.',
|
||||
ok: false,
|
||||
retryable: true
|
||||
retryable: false
|
||||
})
|
||||
}, options.timeoutMs ?? 15_000)
|
||||
pending.set(correlationId, { resolve, timer })
|
||||
@@ -82,6 +101,9 @@ export function createQuickEntrySubmitRelay(options: {
|
||||
},
|
||||
pendingCount() {
|
||||
return pending.size
|
||||
},
|
||||
reconcilableCount() {
|
||||
return reconcilable.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user