From 00663a473411b83c86c0a16f40c9d0f7cc179cb0 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 30 Apr 2026 16:28:45 -0400 Subject: [PATCH] feat(suggested-fix): add applied_pending status for deferred verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...9_add_pending_status_to_suggested_fixes.py | 60 ++++++++++++ .../api/endpoints/session_suggested_fixes.py | 9 ++ backend/app/models/session_suggested_fix.py | 3 +- backend/app/schemas/session_suggested_fix.py | 20 +++- .../services/escalation_package_generator.py | 9 ++ .../app/services/resolution_note_generator.py | 6 ++ backend/tests/test_fix_outcome_endpoint.py | 89 ++++++++++++++++++ frontend/src/api/sessionSuggestedFixes.ts | 10 +- .../src/components/pilot/ProposalBanner.tsx | 91 ++++++++++++++++++- frontend/src/pages/AssistantChatPage.tsx | 1 + 10 files changed, 285 insertions(+), 13 deletions(-) create mode 100644 backend/alembic/versions/71efd2102f49_add_pending_status_to_suggested_fixes.py diff --git a/backend/alembic/versions/71efd2102f49_add_pending_status_to_suggested_fixes.py b/backend/alembic/versions/71efd2102f49_add_pending_status_to_suggested_fixes.py new file mode 100644 index 00000000..1217cb39 --- /dev/null +++ b/backend/alembic/versions/71efd2102f49_add_pending_status_to_suggested_fixes.py @@ -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") diff --git a/backend/app/api/endpoints/session_suggested_fixes.py b/backend/app/api/endpoints/session_suggested_fixes.py index fb3ef8ec..11ba5b46 100644 --- a/backend/app/api/endpoints/session_suggested_fixes.py +++ b/backend/app/api/endpoints/session_suggested_fixes.py @@ -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 diff --git a/backend/app/models/session_suggested_fix.py b/backend/app/models/session_suggested_fix.py index 9cedf1b0..fff133aa 100644 --- a/backend/app/models/session_suggested_fix.py +++ b/backend/app/models/session_suggested_fix.py @@ -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 diff --git a/backend/app/schemas/session_suggested_fix.py b/backend/app/schemas/session_suggested_fix.py index 2672c764..c23f506f 100644 --- a/backend/app/schemas/session_suggested_fix.py +++ b/backend/app/schemas/session_suggested_fix.py @@ -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) diff --git a/backend/app/services/escalation_package_generator.py b/backend/app/services/escalation_package_generator.py index 62978f27..1e0b7f0c 100644 --- a/backend/app/services/escalation_package_generator.py +++ b/backend/app/services/escalation_package_generator.py @@ -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}") diff --git a/backend/app/services/resolution_note_generator.py b/backend/app/services/resolution_note_generator.py index 974840eb..d2febc1c 100644 --- a/backend/app/services/resolution_note_generator.py +++ b/backend/app/services/resolution_note_generator.py @@ -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}") diff --git a/backend/tests/test_fix_outcome_endpoint.py b/backend/tests/test_fix_outcome_endpoint.py index 432a5c22..5fb0eca7 100644 --- a/backend/tests/test_fix_outcome_endpoint.py +++ b/backend/tests/test_fix_outcome_endpoint.py @@ -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 diff --git a/frontend/src/api/sessionSuggestedFixes.ts b/frontend/src/api/sessionSuggestedFixes.ts index bd440556..fd6a96a0 100644 --- a/frontend/src/api/sessionSuggestedFixes.ts +++ b/frontend/src/api/sessionSuggestedFixes.ts @@ -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, diff --git a/frontend/src/components/pilot/ProposalBanner.tsx b/frontend/src/components/pilot/ProposalBanner.tsx index 1bd91475..4d96592a 100644 --- a/frontend/src/components/pilot/ProposalBanner.tsx +++ b/frontend/src/components/pilot/ProposalBanner.tsx @@ -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 case 'verifying': return case 'partial': return + case 'pending': return case 'ai_confirming': return case 'nudge': return } @@ -148,7 +150,7 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) { {showOverflow && (
+
)} + + + + + + ) +} + 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 "{fix.title}" work?