feat(suggested-fix): add applied_pending status for deferred verification
Some checks failed
Mirror to GitHub / mirror (push) Has been cancelled
CI / backend (pull_request) Successful in 10m43s
CI / frontend (pull_request) Successful in 5m42s
CI / e2e (pull_request) Successful in 11m13s

Engineer applies a fix but can't verify yet (waiting on client power-cycle,
AD replication, async sync). Today the verifying banner forces a synchronous
verdict (worked / didn't / partial) — anything else means leaving the banner
stale or guessing wrong. This adds a fourth outcome that parks the fix in a
non-terminal "Awaiting verification" state with a reason ("waiting on what?")
and exposes it on the chat-anchored banner so the engineer doesn't lose track.

Backend
- New non-terminal status `applied_pending` parallel to `applied_partial`.
- New `pending_reason` column (nullable Text) — the "what are you waiting on?"
  prose, mirrors `partial_notes`. Required when outcome=applied_pending.
- Outcome endpoint allows pending in/out transitions; pending stamps
  applied_at but NOT verified_at (it's parked, not verified).
- Resolution-note + escalation-package prompts handle the new status:
  resolution note frames the fix as provisional; escalation package surfaces
  pending verification as the leading hypothesis with reference to what's
  being waited on.
- Migration: add column + extend status CHECK constraint.

Frontend
- New `BannerMode = 'pending'` + `PendingBanner` component (info-tone,
  parallel to PartialBanner) with worked / didn't / update-reason actions.
- VerifyingBanner overflow menu adds "Waiting to verify…".
- Nudge banner's "Still checking" button now actually records pending with
  a reason, instead of just silencing for the session.
- AssistantChatPage banner-mode derivation maps applied_pending → 'pending'.

Tests: 4 new integration tests covering pending notes requirement, reason
storage + applied_at/verified_at semantics, pending→success transition,
and pending_reason update on re-PATCH.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 16:28:45 -04:00
parent ac42f971fc
commit 00663a4734
10 changed files with 285 additions and 13 deletions

View File

@@ -13,12 +13,14 @@ export type FixStatus =
| 'applied_success'
| 'applied_failed'
| 'applied_partial'
| 'applied_pending'
| 'dismissed'
export type FixOutcome =
| 'applied_success'
| 'applied_failed'
| 'applied_partial'
| 'applied_pending'
| 'dismissed'
export interface AIOutcomeProposal {
@@ -41,6 +43,7 @@ export interface SessionSuggestedFix {
applied_at: string | null
verified_at: string | null
partial_notes: string | null
pending_reason: string | null
failure_reason: string | null
ai_outcome_proposal: AIOutcomeProposal | null
superseded_at: string | null
@@ -126,11 +129,12 @@ export const sessionSuggestedFixesApi = {
/**
* Record the outcome of applying a suggested fix. Transition rules:
* - from `proposed` or `applied_partial`: any outcome is valid (partial is
* parked, not terminal — engineer may update notes, abandon via dismiss,
* or advance to success/failed).
* - from `proposed`, `applied_partial`, or `applied_pending`: any outcome
* is valid. Partial = "did some of it"; pending = "did all of it but
* verification is deferred". Both are parked, not terminal.
* - from a terminal status (`applied_success`, `applied_failed`, `dismissed`):
* server returns 409.
* - `applied_pending` requires `notes` (the "what are you waiting on?" reason).
*/
async patchOutcome(
sessionId: string,

View File

@@ -10,7 +10,7 @@
* + 07-verify-states.html.
*/
import { useState } from 'react'
import { Sparkles, Check, ChevronDown, X, MoreHorizontal, Info } from 'lucide-react'
import { Sparkles, Check, ChevronDown, X, MoreHorizontal, Info, Clock3 } from 'lucide-react'
import { cn } from '@/lib/utils'
import type {
SessionSuggestedFix,
@@ -21,6 +21,7 @@ export type BannerMode =
| 'proposed' // AI just proposed; engineer hasn't applied yet
| 'verifying' // Engineer clicked Apply; awaiting outcome
| 'partial' // Applied partially; awaiting finish or terminal outcome
| 'pending' // Applied fully; verification deferred (waiting on client, etc)
| 'ai_confirming' // AI emitted [FIX_OUTCOME]; engineer confirms
| 'nudge' // Compact nudge shown after N post-apply messages
@@ -45,6 +46,7 @@ export function ProposalBanner(props: ProposalBannerProps) {
case 'proposed': return <ProposedBanner {...props} />
case 'verifying': return <VerifyingBanner {...props} />
case 'partial': return <PartialBanner {...props} />
case 'pending': return <PendingBanner {...props} />
case 'ai_confirming': return <AIConfirmingBanner {...props} />
case 'nudge': return <NudgeBanner {...props} />
}
@@ -148,7 +150,7 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
</button>
{showOverflow && (
<div className={cn(
'absolute top-full right-0 mt-1 w-48 rounded-lg',
'absolute top-full right-0 mt-1 w-56 rounded-lg',
'border border-white/10 bg-card shadow-xl py-1 z-10',
)}>
<button
@@ -161,6 +163,17 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
>
Mark partial
</button>
<button
onClick={() => {
setShowOverflow(false)
const reason = window.prompt('What are you waiting on? (e.g. "client power-cycling router")')
if (reason && reason.trim()) onOutcome('applied_pending', reason.trim())
}}
className="w-full text-left px-3 py-2 text-[12.5px] hover:bg-elevated text-primary inline-flex items-center gap-2"
>
<Clock3 size={12} className="text-info" />
Waiting to verify
</button>
</div>
)}
<button
@@ -247,6 +260,66 @@ function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
)
}
function PendingBanner({ fix, onOutcome }: ProposalBannerProps) {
return (
<div className="relative border-t border-info/30 bg-gradient-to-b from-info-dim/40 to-info-dim/20 px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-info" />
<div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-info/30 bg-info-dim flex items-center justify-center text-info">
<Clock3 size={15} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-info">
<span>Awaiting verification</span>
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[10.5px] font-bold normal-case tracking-normal">
Parked
</span>
</div>
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
{fix.title}
</div>
{fix.pending_reason && (
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-[12px] italic text-primary">
<span className="not-italic font-bold text-info text-[10.5px] uppercase tracking-[0.6px]">Waiting on</span>
<span>{fix.pending_reason}</span>
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0 pt-0.5">
<button
onClick={() => {
const reason = window.prompt(
'Update what you\'re waiting on:',
fix.pending_reason ?? '',
)
if (reason && reason.trim()) onOutcome('applied_pending', reason.trim())
}}
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary"
>
Update reason
</button>
<button
onClick={() => {
const reason = window.prompt("Why didn't it work? (optional)")
onOutcome('applied_failed', reason?.trim() || undefined)
}}
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger"
>
Didn't work
</button>
<button
onClick={() => onOutcome('applied_success')}
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5"
>
<Check size={12} strokeWidth={2.5} />
It worked
</button>
</div>
</div>
</div>
)
}
function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: ProposalBannerProps) {
const proposal = fix.ai_outcome_proposal
if (!proposal) return null
@@ -318,9 +391,19 @@ function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
Did <strong className="text-heading">"{fix.title}"</strong> work?
</span>
<button
onClick={onSilenceNudge}
className="px-2.5 py-1 rounded text-[12px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
onClick={() => {
const reason = window.prompt(
'What are you waiting on? (e.g. "client power-cycling router")',
)
if (reason && reason.trim()) {
onOutcome('applied_pending', reason.trim())
} else {
onSilenceNudge()
}
}}
className="px-2.5 py-1 rounded text-[12px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary inline-flex items-center gap-1"
>
<Clock3 size={11} />
Still checking
</button>
<button

View File

@@ -221,6 +221,7 @@ export default function AssistantChatPage() {
if (activeFix.status === 'dismissed') return null
if (activeFix.ai_outcome_proposal) return 'ai_confirming'
if (activeFix.status === 'applied_partial') return 'partial'
if (activeFix.status === 'applied_pending') return 'pending'
if (activeFix.status === 'applied_success' || activeFix.status === 'applied_failed') return null
if (activeFix.applied_at) {
if (postApplyMsgCount >= 3 && !nudgeSilenced) return 'nudge'