feat(suggested-fix): add applied_pending status for deferred verification
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:
@@ -0,0 +1,60 @@
|
||||
"""add applied_pending status + pending_reason to session_suggested_fixes
|
||||
|
||||
Adds the `applied_pending` non-terminal status (engineer ran the fix but
|
||||
verification is deferred — waiting on client, async sync, etc) alongside
|
||||
the existing `applied_partial` status. Mirrors partial_notes with a new
|
||||
pending_reason column for the "what are you waiting on?" prose.
|
||||
|
||||
Revision ID: c0f3a4b7e91d
|
||||
Revises: 71efd2102f49
|
||||
Create Date: 2026-04-30
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "c0f3a4b7e91d"
|
||||
down_revision: Union[str, None] = "71efd2102f49"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"session_suggested_fixes",
|
||||
sa.Column("pending_reason", sa.Text(), nullable=True),
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_session_suggested_fixes_status",
|
||||
"session_suggested_fixes",
|
||||
type_="check",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_session_suggested_fixes_status",
|
||||
"session_suggested_fixes",
|
||||
"status IN ('proposed', 'applied_success', 'applied_failed', "
|
||||
"'applied_partial', 'applied_pending', 'dismissed')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"UPDATE session_suggested_fixes "
|
||||
"SET status = 'applied_partial', "
|
||||
" partial_notes = COALESCE(partial_notes, pending_reason) "
|
||||
"WHERE status = 'applied_pending'"
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_session_suggested_fixes_status",
|
||||
"session_suggested_fixes",
|
||||
type_="check",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_session_suggested_fixes_status",
|
||||
"session_suggested_fixes",
|
||||
"status IN ('proposed', 'applied_success', 'applied_failed', "
|
||||
"'applied_partial', 'dismissed')",
|
||||
)
|
||||
op.drop_column("session_suggested_fixes", "pending_reason")
|
||||
@@ -318,6 +318,11 @@ async def patch_suggested_fix_outcome(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="notes are required when outcome is applied_partial",
|
||||
)
|
||||
if body.outcome == "applied_pending" and not (body.notes and body.notes.strip()):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="notes are required when outcome is applied_pending",
|
||||
)
|
||||
|
||||
TERMINAL = {"applied_success", "applied_failed", "dismissed"}
|
||||
if fix.status in TERMINAL:
|
||||
@@ -329,6 +334,10 @@ async def patch_suggested_fix_outcome(
|
||||
fix.status = body.outcome
|
||||
if body.outcome == "applied_partial":
|
||||
fix.partial_notes = (body.notes or "").strip() or None
|
||||
elif body.outcome == "applied_pending":
|
||||
# Pending is parked, not terminal — keep applied_at, do NOT stamp
|
||||
# verified_at. Reason explains what the engineer is waiting on.
|
||||
fix.pending_reason = (body.notes or "").strip() or None
|
||||
elif body.outcome == "applied_failed":
|
||||
fix.failure_reason = (body.notes or "").strip() or None
|
||||
fix.verified_at = now
|
||||
|
||||
@@ -37,7 +37,7 @@ class SessionSuggestedFix(Base):
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('proposed', 'applied_success', 'applied_failed', "
|
||||
"'applied_partial', 'dismissed')",
|
||||
"'applied_partial', 'applied_pending', 'dismissed')",
|
||||
name="ck_session_suggested_fixes_status",
|
||||
),
|
||||
)
|
||||
@@ -81,6 +81,7 @@ class SessionSuggestedFix(Base):
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
partial_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
pending_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ai_outcome_proposal: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSONB, nullable=True
|
||||
|
||||
@@ -20,6 +20,7 @@ FixStatus = Literal[
|
||||
"applied_success",
|
||||
"applied_failed",
|
||||
"applied_partial",
|
||||
"applied_pending",
|
||||
"dismissed",
|
||||
]
|
||||
|
||||
@@ -40,6 +41,7 @@ class SessionSuggestedFixResponse(BaseModel):
|
||||
applied_at: datetime | None
|
||||
verified_at: datetime | None
|
||||
partial_notes: str | None
|
||||
pending_reason: str | None
|
||||
failure_reason: str | None
|
||||
ai_outcome_proposal: dict[str, Any] | None
|
||||
|
||||
@@ -91,7 +93,11 @@ class SessionSuggestedFixDecisionResponse(BaseModel):
|
||||
# Subset of FixStatus that the engineer can set via the outcome endpoint —
|
||||
# `proposed` is excluded because you can't un-decide a fix back to "proposed".
|
||||
FixOutcome = Literal[
|
||||
"applied_success", "applied_failed", "applied_partial", "dismissed"
|
||||
"applied_success",
|
||||
"applied_failed",
|
||||
"applied_partial",
|
||||
"applied_pending",
|
||||
"dismissed",
|
||||
]
|
||||
|
||||
|
||||
@@ -103,14 +109,18 @@ class SessionSuggestedFixOutcomeRequest(BaseModel):
|
||||
engineer took); outcome captures whether the fix actually worked.
|
||||
|
||||
Allowed transitions:
|
||||
- from `proposed` or `applied_partial`: any outcome is valid
|
||||
(partial is parked, not terminal — the engineer may update notes,
|
||||
abandon via dismiss, or advance to success/failed)
|
||||
- from `proposed`, `applied_partial`, or `applied_pending`: any outcome
|
||||
is valid. Partial means "did some of it"; pending means "did all of
|
||||
it but verification is deferred (waiting on client, async sync, etc)".
|
||||
Both are parked, not terminal — the engineer may advance them to
|
||||
success/failed/dismiss.
|
||||
- from any terminal outcome (`applied_success`, `applied_failed`,
|
||||
`dismissed`): server returns 409
|
||||
"""
|
||||
outcome: FixOutcome
|
||||
# Required for applied_partial, optional for applied_failed, ignored otherwise.
|
||||
# Required for applied_partial AND applied_pending; optional for
|
||||
# applied_failed; ignored otherwise. For pending, this is the
|
||||
# "what are you waiting on?" reason (e.g. "client power-cycling router").
|
||||
notes: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,9 @@ the active suggested fix, as given in the input bundle under "Outcome status":>
|
||||
provided. State that it did not resolve the issue.
|
||||
- applied_partial: Include the fix as a partially tried path. Include partial \
|
||||
notes if provided. Indicate it was not fully completed or not verified.
|
||||
- applied_pending: List the fix as applied but awaiting verification. Include \
|
||||
the pending reason if provided (e.g. "client power-cycling router"). Make it \
|
||||
clear the next engineer should follow up to confirm it worked.
|
||||
- applied_success: Note that the fix was applied and verified but escalation \
|
||||
is still needed for another reason (unusual — reflect this accurately).
|
||||
- dismissed: Do not mention the fix as a tried path; it was only considered.
|
||||
@@ -80,6 +83,8 @@ symptoms are still being narrowed."
|
||||
- applied_failed or dismissed: Say the proposed fix did not hold or was set \
|
||||
aside. State any remaining uncertainty.
|
||||
- applied_partial: Note the partial application and what remains open.
|
||||
- applied_pending: Note that the fix is in place but unverified. Reference the \
|
||||
pending reason. Frame this as the leading hypothesis pending confirmation.
|
||||
- applied_success: Unusual in an escalate path — state the fix resolved the \
|
||||
original symptom but a new or related issue requires escalation.
|
||||
|
||||
@@ -92,6 +97,8 @@ accordingly — e.g. suggest alternatives or deeper investigation paths, \
|
||||
drawing on the failure reason if provided. \
|
||||
If the fix is partially applied (applied_partial), the first step is typically \
|
||||
to complete or verify it. \
|
||||
If the fix is pending verification (applied_pending), the first step is \
|
||||
typically to confirm whether the fix held — reference what was being waited on. \
|
||||
If the fix is still proposed (no outcome), the first step is to try it if \
|
||||
confidence is high (>80%).>
|
||||
|
||||
@@ -299,6 +306,8 @@ class EscalationPackageGeneratorService:
|
||||
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
|
||||
if active_fix.partial_notes:
|
||||
lines.append(f"Partial notes: {active_fix.partial_notes}")
|
||||
if active_fix.pending_reason:
|
||||
lines.append(f"Pending reason: {active_fix.pending_reason}")
|
||||
if active_fix.failure_reason:
|
||||
lines.append(f"Failure reason: {active_fix.failure_reason}")
|
||||
|
||||
|
||||
@@ -83,6 +83,10 @@ state means the engineer resolved the issue another way; the note should cover \
|
||||
that actual resolution, not just the failed attempt.
|
||||
- applied_partial: Note that the fix was partially applied. If partial_notes \
|
||||
are provided, include them. Then describe the final resolution path taken.
|
||||
- applied_pending: Note that the fix was applied and verification is pending. \
|
||||
If pending_reason is provided, include it (e.g. "awaiting client power-cycle"). \
|
||||
Frame the resolution as provisional — the fix is in place but not yet \
|
||||
confirmed. Do not write closure language.
|
||||
- dismissed: Treat the fix as considered and set aside. Do not center the note \
|
||||
on it. Describe the resolution based on what was actually confirmed and done.
|
||||
- proposed (no outcome yet): Write "Resolution not yet applied — fix proposed: \
|
||||
@@ -322,6 +326,8 @@ class ResolutionNoteGeneratorService:
|
||||
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
|
||||
if active_fix.partial_notes:
|
||||
lines.append(f"Partial notes: {active_fix.partial_notes}")
|
||||
if active_fix.pending_reason:
|
||||
lines.append(f"Pending reason: {active_fix.pending_reason}")
|
||||
if active_fix.failure_reason:
|
||||
lines.append(f"Failure reason: {active_fix.failure_reason}")
|
||||
|
||||
|
||||
@@ -193,6 +193,95 @@ async def test_applied_at_auto_stamped_on_first_outcome(
|
||||
assert body["verified_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_requires_notes(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""applied_pending requires notes (the "what are you waiting on?" reason)."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_pending"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "notes" in r.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_stores_reason_and_stamps_applied_at(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""applied_pending stores notes under pending_reason and stamps applied_at
|
||||
but NOT verified_at — the fix is parked, not verified."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
r = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_pending", "notes": "client power-cycling router"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["status"] == "applied_pending"
|
||||
assert body["pending_reason"] == "client power-cycling router"
|
||||
assert body["applied_at"] is not None
|
||||
assert body["verified_at"] is None
|
||||
assert body["partial_notes"] is None
|
||||
assert body["failure_reason"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_to_success_allowed(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""pending is non-terminal — engineer can advance to success once verified."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r1 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_pending", "notes": "waiting on AD replication"},
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
|
||||
r2 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_success"},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
body = r2.json()
|
||||
assert body["status"] == "applied_success"
|
||||
assert body["verified_at"] is not None
|
||||
# pending_reason is preserved as audit trail
|
||||
assert body["pending_reason"] == "waiting on AD replication"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_reason_can_be_updated(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""pending→pending with new notes updates the stored pending_reason."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r1 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
json={"outcome": "applied_pending", "notes": "waiting on AD replication"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
assert r1.json()["pending_reason"] == "waiting on AD replication"
|
||||
|
||||
r2 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
json={"outcome": "applied_pending", "notes": "now waiting on client to confirm login"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["pending_reason"] == "now waiting on client to confirm login"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_outcome_stores_notes_as_failure_reason(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
|
||||
Reference in New Issue
Block a user