feat(bookkeeping): correction-chain depth guard + Bedrock stream retry (#1581)
* feat(bookkeeping): bypassable chain-depth guard on corrections and stornos Correcting or reversing an entry that already sits 3+ links deep in a rattelse chain (correction_of_id/reverses_id walked in the DB, never description matching) now throws CORRECTION_CHAIN_TOO_DEEP, steering the caller to book ONE correction expressing the chain's net effect. Agents looped storno+rattelse 10 deep on a live company (63/193 vouchers noise). The guard is advisory, never a dead end: allow_deep_chain bypasses it on every surface (correctEntry/reverseEntry option, REST body, MCP tool arg staged through pending_operations, and confirm dialogs with Ratta anda / Aterfor anda in the web UI). MCP staging pre-flight fires the guard at stage time so the agent reconsiders in the same turn, and the executor re-checks at commit. tools/list payload ceiling bumped 59K -> 59.5K for the two bypass properties (trimmed to one sentence first). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): retry the Bedrock stream once on transient failures A transient stream death (429/5xx, transport cut, or the two known stream-corruption signatures: 'Unexpected event order' and 'request ended without sending any chunks') killed the whole chat turn, stranding the user mid-answer. The turn now retries once per turn after a short backoff: safe because nothing is persisted until finalMessage() succeeds. A new stream_restart event carries the pre-attempt text snapshot so the chat client resets the partial bubble, drops uncompleted tool chips, and shows 'Forsoker igen...' until the retried stream produces text. Non-transient errors (403, 400) keep the existing immediate-error path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): regenerate accounted-api skill and wire allow_deep_chain through v1 apiskill:check failed: CorrectJournalEntrySchema gained allow_deep_chain, making references/journal-entries.md stale. Regenerated (hand-applied: the generator output is deterministic from the registry). While wiring: the v1 correct route validated allow_deep_chain but dropped it, and the v1 reverse route's strict body schema would have rejected it outright, leaving API clients no bypass when the chain-depth guard fires. Both now forward the flag to the engine and document CORRECTION_CHAIN_TOO_DEEP as a pitfall. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: re-trigger CI after Vercel infra hang The preview for e527e4044 compiled in 91s then hung 40 minutes in the TypeScript phase and was killed with no error output; a CLI redeploy of the identical code went Ready in 5m. Empty commit to refresh the git- triggered deployment status. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): address CodeRabbit review on the chain-depth guard - correction-chain: report rootVoucher only when the walk reached a genuine parentless root; a broken link, cycle, or hop-cap now yields null instead of presenting an intermediate voucher as the chain root. - recordate: propagate allow_deep_chain end-to-end (recordateEntry option, route schema, and a Flytta anda bypass confirm in the dialog); a date move is another storno+rattelse layer and carried the guard with no override path. - v1 correct/reverse: run the chain-depth guard before the dry-run return so a dry run gives the same verdict as the real execution. - dashboard reverse route: 400 on malformed JSON or a non-boolean allow_deep_chain instead of silently reversing without the override; empty body stays the supported no-body case. Tests added. - AgentChat stream_restart: discard the dead attempt's reasoning and re-arm the post-tool paragraph break so a retried turn doesn't render thinking twice or glue its continuation onto restored text. - v1 reverse route doc comment updated for allow_deep_chain. Not changed: the journal-list reverse flow (flagged as a dead end) can never receive CORRECTION_CHAIN_TOO_DEEP: the list renders Aterfor only for entries that are neither storno nor correction, and such entries have no backward chain links, so their depth is always 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(bookkeeping): recordate route test expects the new options arg recordateEntry now takes { allowDeepChain } as a sixth argument; the route test's called-with assertion predates it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e494662530
commit
05380ddf54
@@ -256,6 +256,10 @@ export default function AgentChat({
|
||||
}
|
||||
}, [streaming, onStatus])
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
// True between a stream_restart event and the retried stream's first sign of
|
||||
// life: renders a discreet "Försöker igen…" line so the reset bubble doesn't
|
||||
// read as the assistant silently going blank.
|
||||
const [retryNotice, setRetryNotice] = useState(false)
|
||||
const scrollerRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
// Active turn's controller, kept in a ref (not state) so the stop button
|
||||
@@ -377,6 +381,7 @@ export default function AgentChat({
|
||||
|
||||
setStreaming(true)
|
||||
setErrorMessage(null)
|
||||
setRetryNotice(false)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
@@ -598,7 +603,31 @@ export default function AgentChat({
|
||||
})),
|
||||
)
|
||||
break
|
||||
case 'stream_restart': {
|
||||
// The server's model stream died on a transient error and is being
|
||||
// retried. Nothing from the dead attempt was persisted: reset the
|
||||
// in-progress bubble to the server's pre-attempt snapshot, drop tool
|
||||
// chips that never completed (eager chips from the dead stream), and
|
||||
// discard the dead attempt's reasoning so the retried attempt's
|
||||
// thinking doesn't render twice. The post-tool paragraph break is
|
||||
// re-armed only when restored text exists: the snapshot always ends
|
||||
// at an iteration boundary (after tool results), so the retried
|
||||
// continuation should open its own paragraph there.
|
||||
setRetryNotice(true)
|
||||
const restored = typeof ev.assistant_text === 'string' ? ev.assistant_text : ''
|
||||
breakBeforeNextTextRef.current = restored.length > 0
|
||||
setMessages((prev) =>
|
||||
updateLastAssistant(prev, (m) => ({
|
||||
...m,
|
||||
text: restored,
|
||||
reasoning: undefined,
|
||||
toolCalls: m.toolCalls?.filter((tc) => tc.completed),
|
||||
})),
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'text_delta':
|
||||
setRetryNotice(false)
|
||||
// Insert a paragraph break ONCE when text resumes after a tool
|
||||
// call, so post-tool narration starts on its own line instead of
|
||||
// gluing onto the previous sentence ("kategoriseras.Inget historik").
|
||||
@@ -706,9 +735,11 @@ export default function AgentChat({
|
||||
break
|
||||
}
|
||||
case 'error':
|
||||
setRetryNotice(false)
|
||||
setErrorMessage(ev.message as string)
|
||||
break
|
||||
case 'turn_complete': {
|
||||
setRetryNotice(false)
|
||||
if (!firstTurnFiredRef.current && conversationIdRef.current) {
|
||||
firstTurnFiredRef.current = true
|
||||
onFirstTurnComplete?.(conversationIdRef.current)
|
||||
@@ -811,6 +842,12 @@ export default function AgentChat({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{retryNotice && !errorMessage && (
|
||||
<div className="text-xs text-muted-foreground px-1">
|
||||
Anslutningen bröts, försöker igen…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
|
||||
@@ -58,6 +58,9 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
|
||||
const [lines, setLines] = useState<CorrectionLine[]>([])
|
||||
const [description, setDescription] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
// Non-null when the server refused with CORRECTION_CHAIN_TOO_DEEP: holds the
|
||||
// reported chain depth and opens the bypass confirm ("Rätta ändå").
|
||||
const [deepChainDepth, setDeepChainDepth] = useState<number | null>(null)
|
||||
// Index of the line whose combobox opened the create dialog, and the search
|
||||
// string it was showing. Null index = the dialog is closed.
|
||||
const [creatingAccountForLine, setCreatingAccountForLine] = useState<number | null>(null)
|
||||
@@ -191,7 +194,7 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
|
||||
|
||||
const hasValidLines = lines.length >= 2 && lines.every((l) => l.account_number.length === 4)
|
||||
|
||||
async function handleSubmit() {
|
||||
async function handleSubmit(allowDeepChain = false) {
|
||||
if (!isBalanced || !hasValidLines) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
@@ -211,17 +214,26 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
|
||||
// Only sent when the user changed the auto prefill: the server
|
||||
// fallback ("Rättelse: <original>") stays the source of truth.
|
||||
description: correctionDescriptionForSubmit(description, entry.description),
|
||||
...(allowDeepChain ? { allow_deep_chain: true } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
// Chain-depth guard: open the bypass confirm instead of a dead-end
|
||||
// toast. "Rätta ändå" resubmits with allow_deep_chain=true.
|
||||
const structured = (result as { error?: { code?: string; details?: { depth?: number } } })?.error
|
||||
if (structured?.code === 'CORRECTION_CHAIN_TOO_DEEP') {
|
||||
setDeepChainDepth(structured.details?.depth ?? 3)
|
||||
return
|
||||
}
|
||||
const error = new Error('Failed to create correction') as Error & { body?: unknown; status?: number }
|
||||
error.body = result
|
||||
error.status = res.status
|
||||
throw error
|
||||
}
|
||||
setDeepChainDepth(null)
|
||||
|
||||
const correctedId = result.data?.corrected?.id
|
||||
|
||||
@@ -411,12 +423,37 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={!isBalanced || !hasValidLines || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Skapar...' : 'Skapa ändringsverifikation'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
{/* Chain-depth guard confirm: the server refused because this entry
|
||||
already sits deep in a rättelse chain. Advisory, never a dead end:
|
||||
"Rätta ändå" resubmits with allow_deep_chain=true. */}
|
||||
<Dialog open={deepChainDepth != null} onOpenChange={(next) => { if (!next) setDeepChainDepth(null) }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('deep_chain_title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('deep_chain_body', { depth: deepChainDepth ?? 3 })}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeepChainDepth(null)} disabled={isSubmitting}>
|
||||
{t('deep_chain_cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => { setDeepChainDepth(null); void handleSubmit(true) }}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('deep_chain_correct_anyway')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogContent>
|
||||
|
||||
{/* Nested on purpose: closing this one (Esc, click-outside, Avbryt) must
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -38,11 +39,15 @@ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
||||
export default function RecordateEntryDialog({ entry, open, onOpenChange, onMoved }: Props) {
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const t = useTranslations('journal_detail')
|
||||
const [newDate, setNewDate] = useState(entry.entry_date)
|
||||
const [preview, setPreview] = useState<PeriodStatus | null>(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [previewError, setPreviewError] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
// Non-null when the server refused with CORRECTION_CHAIN_TOO_DEEP: holds the
|
||||
// reported chain depth and opens the bypass confirm ("Flytta ändå").
|
||||
const [deepChainDepth, setDeepChainDepth] = useState<number | null>(null)
|
||||
|
||||
// Reset to the original date each time the dialog opens.
|
||||
useEffect(() => {
|
||||
@@ -100,22 +105,33 @@ export default function RecordateEntryDialog({ entry, open, onOpenChange, onMove
|
||||
|
||||
const canSubmit = dateChanged && targetOpen && !isSubmitting
|
||||
|
||||
async function handleSubmit() {
|
||||
async function handleSubmit(allowDeepChain = false) {
|
||||
if (!canSubmit) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/bookkeeping/journal-entries/${entry.id}/recordate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ new_entry_date: newDate }),
|
||||
body: JSON.stringify({
|
||||
new_entry_date: newDate,
|
||||
...(allowDeepChain ? { allow_deep_chain: true } : {}),
|
||||
}),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (!res.ok) {
|
||||
// Chain-depth guard: open the bypass confirm instead of a dead-end
|
||||
// toast. "Flytta ändå" resubmits with allow_deep_chain=true.
|
||||
const structured = (result as { error?: { code?: string; details?: { depth?: number } } })?.error
|
||||
if (structured?.code === 'CORRECTION_CHAIN_TOO_DEEP') {
|
||||
setDeepChainDepth(structured.details?.depth ?? 3)
|
||||
return
|
||||
}
|
||||
const error = new Error('Failed to move entry') as Error & { body?: unknown; status?: number }
|
||||
error.body = result
|
||||
error.status = res.status
|
||||
throw error
|
||||
}
|
||||
setDeepChainDepth(null)
|
||||
const correctedId = result.data?.corrected?.id
|
||||
toast({
|
||||
title: 'Verifikationen flyttad',
|
||||
@@ -238,10 +254,35 @@ export default function RecordateEntryDialog({ entry, open, onOpenChange, onMove
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit}>
|
||||
<Button onClick={() => handleSubmit()} disabled={!canSubmit}>
|
||||
{isSubmitting ? 'Flyttar…' : 'Flytta verifikationen'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
{/* Chain-depth guard confirm: the server refused because this entry
|
||||
already sits deep in a rättelse chain. Advisory, never a dead end:
|
||||
"Flytta ändå" resubmits with allow_deep_chain=true. */}
|
||||
<Dialog open={deepChainDepth != null} onOpenChange={(next) => { if (!next) setDeepChainDepth(null) }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('deep_chain_title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('deep_chain_body', { depth: deepChainDepth ?? 3 })}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeepChainDepth(null)} disabled={isSubmitting}>
|
||||
{t('deep_chain_cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => { setDeepChainDepth(null); void handleSubmit(true) }}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('deep_chain_move_anyway')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user