fix: stabilize inbox confirmation retries

This commit is contained in:
2026-07-21 18:57:38 +08:00
parent c604423f0f
commit 9fa6744516
5 changed files with 165 additions and 41 deletions

View File

@@ -48,9 +48,13 @@ let expectingInboxRefreshError = false
let expectedInboxRefreshConsoleErrorCount = 0
let expectingInboxConflict = false
let expectedInboxConflictConsoleErrorCount = 0
let expectingInboxValidationError = false
let expectedInboxValidationConsoleErrorCount = 0
let failNextInboxConfirm = true
let failNextInboxWorkspaceRefresh = false
let conflictNextInboxConfirm = false
let validationNextInboxConfirm = false
let invalidResponseNextInboxConfirm = false
page.on('console', (message) => {
if (message.type() !== 'error') return
if (
@@ -85,6 +89,14 @@ page.on('console', (message) => {
expectedInboxConflictConsoleErrorCount += 1
return
}
if (
expectingInboxValidationError &&
expectedInboxValidationConsoleErrorCount === 0 &&
message.text() === 'Failed to load resource: the server responded with a status of 400 (Bad Request)'
) {
expectedInboxValidationConsoleErrorCount += 1
return
}
errors.push(message.text())
})
@@ -259,6 +271,23 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
})
return
}
if (validationNextInboxConfirm) {
validationNextInboxConfirm = false
await route.fulfill({
status: 400,
json: { error: { code: 'invalid_request', message: '建议选择无效,请重新核对' } },
})
return
}
if (invalidResponseNextInboxConfirm) {
invalidResponseNextInboxConfirm = false
await route.fulfill({
status: 200,
contentType: 'text/plain',
body: 'unparseable confirmation result',
})
return
}
const confirmedItemId = url.pathname.split('/').at(-2)
visualCheckWorkspace.inbox.find((item) => item.id === confirmedItemId).status = 'processed'
visualCheckWorkspace.channels.find((channel) => channel.type === 'inbox').count -= 1
@@ -719,8 +748,52 @@ if (await inboxChannelButton.count() === 0) {
failures.push(`expected one simulated Inbox confirm console error, got ${expectedInboxConfirmConsoleErrorCount}`)
}
const firstUncertainRequest = inboxConfirmRequests.at(-1)
const analyzeRequestCountAfterUncertainFailure = inboxAnalyzeRequests.length
const analyzeButtonAfterUncertainFailure = inboxPage.getByRole('button', { name: '分析内容', exact: true })
if (!await analyzeButtonAfterUncertainFailure.isDisabled()) {
failures.push('an uncertain Inbox confirmation must freeze re-analysis')
}
if (!await sourceSuggestionCheckbox.locator('input[type="checkbox"]').isDisabled()) {
failures.push('an uncertain Inbox confirmation must freeze suggestion selection')
}
if (!await secondInboxRow.isDisabled()) {
failures.push('an uncertain Inbox confirmation must freeze Inbox item switching')
}
const sourceCheckedBeforeForcedChange = await sourceSuggestionCheckbox.locator('input[type="checkbox"]').isChecked()
await sourceSuggestionCheckbox.click({ force: true })
if (await sourceSuggestionCheckbox.locator('input[type="checkbox"]').isChecked() !== sourceCheckedBeforeForcedChange) {
failures.push('an uncertain Inbox confirmation must reject forced checkbox changes')
}
await analyzeButtonAfterUncertainFailure.click({ force: true })
await page.waitForTimeout(50)
if (inboxAnalyzeRequests.length !== analyzeRequestCountAfterUncertainFailure) {
failures.push('an uncertain Inbox confirmation must not send another analyze request')
}
const retryConfirmButton = inboxPage.getByRole('button', { name: '重试确认', exact: true })
if (await retryConfirmButton.count() !== 1) {
failures.push('an uncertain Inbox confirmation must change the CTA to 重试确认')
}
const workspaceRequestCount = workspaceRequests.length
await confirmButton.click()
const retryAction = await retryConfirmButton.count() === 1 ? retryConfirmButton : confirmButton
invalidResponseNextInboxConfirm = true
await retryAction.click()
await page.waitForTimeout(350)
if (JSON.stringify(inboxConfirmRequests.at(-1)) !== JSON.stringify(firstUncertainRequest)) {
failures.push(`an invalid-response retry must reuse the exact uncertain request body, got ${JSON.stringify(inboxConfirmRequests.at(-1))}`)
}
const retryAfterInvalidResponse = inboxPage.getByRole('button', { name: '重试确认', exact: true })
if (await retryAfterInvalidResponse.count() !== 1) {
failures.push('an invalid confirmation response must preserve the uncertain retry state')
}
if (!await sourceSuggestionCheckbox.locator('input[type="checkbox"]').isDisabled()) {
failures.push('an invalid confirmation response must keep suggestion selection frozen')
}
const finalRetryAction = await retryAfterInvalidResponse.count() === 1
? retryAfterInvalidResponse
: inboxPage.getByRole('button', { name: '确认创建', exact: true })
await finalRetryAction.click()
await inboxPage.getByText('已创建 2 个对象', { exact: true }).waitFor({ state: 'visible', timeout: 2000 }).catch(() => {})
if (!await inboxPage.getByText('已创建 2 个对象', { exact: true }).isVisible()) {
failures.push('successful Inbox confirmation must show the created object count')
@@ -729,6 +802,12 @@ if (await inboxChannelButton.count() === 0) {
if (JSON.stringify(submitted) !== JSON.stringify([inboxTaskSuggestionId, inboxNoteSuggestionId])) {
failures.push(`Inbox confirm must send only checked saved suggestion identities, got ${JSON.stringify(submitted)}`)
}
if (JSON.stringify(inboxConfirmRequests.at(-1)) !== JSON.stringify(firstUncertainRequest)) {
failures.push(`an uncertain Inbox retry must reuse the exact first request body, got first=${JSON.stringify(firstUncertainRequest)} retry=${JSON.stringify(inboxConfirmRequests.at(-1))}`)
}
if (await secondInboxRow.isDisabled()) {
failures.push('a successful Inbox retry must clear the uncertain state for other Inbox items')
}
if (workspaceRequests.length <= workspaceRequestCount) {
failures.push('successful Inbox confirmation must refresh the project workspace')
}
@@ -757,9 +836,26 @@ if (await inboxChannelButton.count() === 0) {
await secondInboxRow.click()
await inboxPage.getByRole('button', { name: '分析内容', exact: true }).click()
await inboxPage.getByRole('checkbox').first().waitFor({ state: 'attached', timeout: 2000 }).catch(() => {})
expectingInboxRefreshError = true
validationNextInboxConfirm = true
expectingInboxValidationError = true
const secondConfirmButton = inboxPage.getByRole('button', { name: '确认创建', exact: true })
await secondConfirmButton.click()
await inboxPage.getByText('建议选择无效,请重新核对', { exact: true }).waitFor({ state: 'visible', timeout: 2000 }).catch(() => {})
if (await inboxPage.getByRole('button', { name: '重试确认', exact: true }).count() !== 0) {
failures.push('a deterministic validation failure must not enter the uncertain retry state')
}
if (await inboxPage.getByRole('checkbox').first().isDisabled()) {
failures.push('a deterministic validation failure must leave suggestion selection editable')
}
if (await inboxPage.getByRole('button', { name: '分析内容', exact: true }).isDisabled()) {
failures.push('a deterministic validation failure must leave re-analysis available')
}
expectingInboxValidationError = false
if (expectedInboxValidationConsoleErrorCount !== 1) {
failures.push(`expected one simulated Inbox validation console error, got ${expectedInboxValidationConsoleErrorCount}`)
}
expectingInboxRefreshError = true
await secondConfirmButton.click()
await inboxPage.getByText('已创建 3 个对象', { exact: true }).waitFor({ state: 'visible', timeout: 2000 }).catch(() => {})
if (!await inboxPage.getByText('已创建 3 个对象', { exact: true }).isVisible()) {
failures.push('a completed confirmation must preserve its created count when workspace refresh fails')

View File

@@ -13,6 +13,11 @@ type ProjectInboxProps = {
onConfirm: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
}
type UncertainConfirmation = {
inboxItemId: string
suggestionIds: string[]
}
export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectInboxProps) {
const [selectedItemId, setSelectedItemId] = useState(activeWorkspace.inbox[0]?.id ?? '')
const [draftSuggestions, setDraftSuggestions] = useState<InboxSuggestionDTO[]>([])
@@ -23,6 +28,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
const [success, setSuccess] = useState('')
const [refreshWarning, setRefreshWarning] = useState('')
const [locallyConfirmedItemIds, setLocallyConfirmedItemIds] = useState<string[]>([])
const [confirmationUncertain, setConfirmationUncertain] = useState<UncertainConfirmation | null>(null)
const analysisGeneration = useRef(0)
const selectedItem = useMemo(
() => activeWorkspace.inbox.find((item) => item.id === selectedItemId) ?? activeWorkspace.inbox[0],
@@ -30,6 +36,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
)
function selectItem(item: InboxItem) {
if (confirmationUncertain) return
analysisGeneration.current += 1
setSelectedItemId(item.id)
setDraftSuggestions([])
@@ -41,7 +48,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
}
async function analyzeSelectedItem() {
if (!selectedItem || selectedItem.status !== 'open') return
if (!selectedItem || selectedItem.status !== 'open' || confirmationUncertain) return
setAnalyzing(true)
setError('')
setSuccess('')
@@ -64,7 +71,11 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
async function confirmSelectedSuggestions() {
if (!selectedItem) return
if (selectedSuggestionIds.length === 0) {
const confirmation = confirmationUncertain ?? {
inboxItemId: selectedItem.id,
suggestionIds: [...selectedSuggestionIds],
}
if (confirmation.suggestionIds.length === 0) {
setError('请至少勾选一条建议')
return
}
@@ -72,11 +83,13 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
setError('')
setSuccess('')
try {
const outcome = await onConfirm(selectedItem.id, selectedSuggestionIds)
setLocallyConfirmedItemIds((current) => current.includes(selectedItem.id) ? current : [...current, selectedItem.id])
const outcome = await onConfirm(confirmation.inboxItemId, confirmation.suggestionIds)
setConfirmationUncertain(null)
setLocallyConfirmedItemIds((current) => current.includes(confirmation.inboxItemId) ? current : [...current, confirmation.inboxItemId])
setSuccess(`已创建 ${outcome.createdCount} 个对象`)
setRefreshWarning(outcome.refreshError ?? '')
} catch (reason) {
setConfirmationUncertain(isUncertainConfirmationError(reason) ? confirmation : null)
setError(confirmationErrorMessage(reason))
} finally {
setConfirming(false)
@@ -84,6 +97,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
}
function toggleSuggestion(identity: string, checked: boolean) {
if (confirmationUncertain) return
setSelectedSuggestionIds((current) => checked
? current.includes(identity) ? current : [...current, identity]
: current.filter((value) => value !== identity))
@@ -113,7 +127,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
key={item.id}
type="button"
className={selectedItem?.id === item.id ? 'mail-item active' : 'mail-item'}
disabled={confirming}
disabled={confirming || confirmationUncertain !== null}
onClick={() => selectItem(item)}
>
<span>{item.title}</span>
@@ -142,7 +156,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
<Button
icon={<IconRobot />}
loading={analyzing}
disabled={confirming || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds)}
disabled={confirming || confirmationUncertain !== null || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds)}
onClick={() => void analyzeSelectedItem()}
>
@@ -167,7 +181,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
<Checkbox
key={suggestion.id}
checked={selectedSuggestionIds.includes(suggestion.id)}
disabled={confirming || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds)}
disabled={confirming || confirmationUncertain !== null || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds)}
onChange={(checked) => toggleSuggestion(suggestion.id, checked)}
>
<span className="inbox-suggestion-copy">
@@ -195,7 +209,7 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
disabled={confirming || analyzing || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds) || draftSuggestions.length === 0}
onClick={() => void confirmSelectedSuggestions()}
>
{confirmationUncertain ? '重试确认' : '确认创建'}
</Button>
</div>
</>
@@ -207,8 +221,12 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
)
}
function isUncertainConfirmationError(reason: unknown) {
return reason instanceof ApiError && (reason.code === 'network_error' || reason.code === 'invalid_response')
}
function confirmationErrorMessage(reason: unknown) {
if (reason instanceof ApiError && (reason.code === 'network_error' || reason.code === 'invalid_response')) {
if (isUncertainConfirmationError(reason)) {
return '确认结果未知,服务端支持幂等处理,可安全重试'
}
return reason instanceof Error ? reason.message : '确认创建失败,请稍后重试'

View File

@@ -165,8 +165,8 @@ func (s *Service) Confirm(itemIdentity string, userID uint, selectedSuggestionId
return err
}
if item.Status != "open" {
result.CreatedCount, err = countConfirmedObjects(tx, item.ID, userID)
return err
result.CreatedCount = item.ConfirmedCount
return nil
}
selected, err := normalizeSelectedIdentities(selectedSuggestionIdentities)
@@ -195,23 +195,14 @@ func (s *Service) Confirm(itemIdentity string, userID uint, selectedSuggestionId
}
result.CreatedCount++
}
return tx.Model(item).Update("status", "processed").Error
return tx.Model(item).Updates(map[string]any{
"status": "processed",
"confirmed_count": result.CreatedCount,
}).Error
})
return result, err
}
func countConfirmedObjects(tx *gorm.DB, inboxItemID, userID uint) (int, error) {
total := int64(0)
for _, model := range []any{&models.SenlinAgentTask{}, &models.SenlinAgentNote{}, &models.SenlinAgentSource{}} {
var count int64
if err := tx.Model(model).Where("source_inbox_item_id = ? AND created_by = ?", inboxItemID, userID).Count(&count).Error; err != nil {
return 0, err
}
total += count
}
return int(total), nil
}
func findOwnedInboxItem(tx *gorm.DB, identity string, userID uint, lock bool) (*models.SenlinAgentInboxItem, error) {
var item models.SenlinAgentInboxItem
query := tx.Model(&models.SenlinAgentInboxItem{}).

View File

@@ -251,31 +251,42 @@ func TestConfirmIsTransactionalWhenASelectedWriteFails(t *testing.T) {
require.Equal(t, "open", reloaded.Status)
}
func TestRepeatedConfirmReturnsExistingResultWithoutDuplicateObjects(t *testing.T) {
func TestRepeatedConfirmReturnsPersistedResultAfterRelatedObjectsChange(t *testing.T) {
fixture := newInboxTestFixture(t)
item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID)
router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{
{Kind: "task", Title: "只创建一次", Body: "重复确认不能复制"},
{Kind: "note", Title: "不能追加创建", Body: "已确认后忽略不同建议"},
{Kind: "note", Title: "确认结果", Body: "原始确认创建两个对象"},
{Kind: "source", Title: "不能追加创建", Body: "已确认后忽略不同建议"},
}})
analysis := decodeInboxAnalysis(t, performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil))
body := gin.H{"suggestionIds": []string{analysis.Suggestions[0].ID}}
body := gin.H{"suggestionIds": []string{analysis.Suggestions[0].ID, analysis.Suggestions[1].ID}}
first := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", body)
second := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", body)
differentSelection := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{
"suggestionIds": []string{analysis.Suggestions[1].ID},
})
require.Equal(t, http.StatusOK, first.Code)
require.Equal(t, http.StatusOK, second.Code, second.Body.String())
require.Equal(t, http.StatusOK, differentSelection.Code, differentSelection.Body.String())
for _, response := range []*httptest.ResponseRecorder{first, second, differentSelection} {
var result ConfirmResult
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &result))
require.Equal(t, 1, result.CreatedCount)
requireConfirmCreatedCount(t, first, 2)
requireFormalObjectCounts(t, fixture.database, 1, 1, 0)
sourceInboxItemID := item.ID
extraSource := models.SenlinAgentSource{
ProjectID: item.ProjectID, CreatedBy: fixture.owner.ID, SourceInboxItemID: &sourceInboxItemID,
Kind: "text", Title: "后续关联资料", ContentText: "不得改变历史确认结果",
}
requireFormalObjectCounts(t, fixture.database, 1, 0, 0)
require.NoError(t, fixture.database.Create(&extraSource).Error)
second := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", body)
require.Equal(t, http.StatusOK, second.Code, second.Body.String())
requireConfirmCreatedCount(t, second, 2)
var createdTask models.SenlinAgentTask
require.NoError(t, fixture.database.Where("source_inbox_item_id = ?", item.ID).First(&createdTask).Error)
require.NoError(t, fixture.database.Delete(&createdTask).Error)
require.NoError(t, fixture.database.Delete(&extraSource).Error)
differentSelection := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{
"suggestionIds": []string{analysis.Suggestions[2].ID},
})
require.Equal(t, http.StatusOK, differentSelection.Code, differentSelection.Body.String())
requireConfirmCreatedCount(t, differentSelection, 2)
requireFormalObjectCounts(t, fixture.database, 0, 1, 0)
}
func (fixture inboxTestFixture) router(userID uint, analyzer Analyzer) http.Handler {
@@ -347,6 +358,13 @@ func requireFormalObjectCounts(t *testing.T, database *gorm.DB, tasks, notes, so
}
}
func requireConfirmCreatedCount(t *testing.T, response *httptest.ResponseRecorder, want int) {
t.Helper()
var result ConfirmResult
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &result))
require.Equal(t, want, result.CreatedCount)
}
func requireSourceInboxIdentity(t *testing.T, item models.SenlinAgentInboxItem, sourceID *uint, sourceIdentity *string) {
t.Helper()
require.NotNil(t, sourceID)

View File

@@ -13,6 +13,7 @@ type SenlinAgentInboxItem struct {
Title string
Body string
Status string `gorm:"not null;default:open"`
ConfirmedCount int `gorm:"not null;default:0"`
CreatedAt time.Time
UpdatedAt time.Time
}