Merge PR #156: pending-verification — applied_pending non-terminal outcome
All checks were successful
CI / frontend (push) Successful in 5m6s
Mirror to GitHub / mirror (push) Successful in 6s
CI / backend (push) Successful in 10m6s
CI / e2e (push) Successful in 10m33s

Adds applied_pending non-terminal status, pending_reason column, PendingBanner UI, and review fixes for page-level Resolve/Escalate intercepts.

QA: 5/7 scripted checks PASS with concrete evidence. 2 entry-path checks deferred — same handlers verified via tested transitions.
This commit was merged in pull request #156.
This commit is contained in:
2026-05-01 03:42:10 +00:00
17 changed files with 422 additions and 119 deletions

View File

@@ -1,83 +1,50 @@
# CURRENT_TASK.md
**Task:** Build **Escalation Mode** — the wedge for ResolutionFlow's GTM (first paying-customer push). When a junior tech escalates a FlowPilot session, the senior tech sees structured handoff context in seconds instead of running a 5-minute verbal "tell me what you tried" call.
**Task:** Add a fourth, non-terminal outcome to the suggested-fix banner — **Awaiting verification** (`applied_pending`). Today the verifying banner forces a synchronous verdict (worked / didn't / partial), but a lot of real fixes are async — engineer ran the script but is waiting on the client to power-cycle, AD replication, an O365 license sync. Without a fourth state the banner sits stale or the engineer guesses wrong.
**Status:****Engineering complete.** Browser QA passed (2026-04-30). Branch `feat/escalation-metric-endpoint`; PR #155 ready to mark ready-for-review.
**Status:****Engineering complete; PR #156 open.** Backend tests, prompt guardrail, frontend tsc/build clean; Alembic migration applies. Pending browser QA.
**Plan:** [`docs/plans/2026-04-27-escalation-mode-wedge-design.md`](../docs/plans/2026-04-27-escalation-mode-wedge-design.md). Reviewed by `/office-hours`, `/plan-eng-review`, `/plan-design-review`, `/codex review`. Eng + Design CLEARED.
**Branch:** `feat/fix-pending-verification` (off `main` after the Escalation Mode merge).
**Test plan artifact:** [`docs/plans/2026-04-27-escalation-mode-wedge-test-plan.md`](../docs/plans/2026-04-27-escalation-mode-wedge-test-plan.md).
**PR:** https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/156
## What's done (all sessions combined)
## What ships
All plan items complete. Key commits on `feat/escalation-metric-endpoint`:
| Commit | What it ships |
| Layer | Change |
|---|---|
| `d51e95c` | Plan + test-plan artifacts |
| `52f6d03` | `GET /analytics/flowpilot/escalations` — time-to-first-action metric |
| `7a5b853` | Role-gate claim to engineer-or-admin |
| `07d0db9` | Email notifications on escalation |
| `9f0bfd4` | `EscalationMetricCard` on `/escalations` |
| `b8627f4` | SSE live-arrival animations in `EscalationQueue` |
| `8e9d22e` | Magic-moment handoff-context screen |
| `641853a` | Bell-icon opens pickup flow |
| `029680a` | Unify `/escalate` through `HandoffManager` |
| `0f00ee5` | Plan-locked polish: chips, unread dot, race toast, AI refresh |
| `665530f` | Structural task-lane race fix |
| `db717b0` | 3-option CTA, copy button fix, post-escalation redirect, claim 500 fix |
| `dc69c9d` | Allow `escalated_to_id` to send chat (GET AI analysis fix) |
| Schema | New `FixStatus="applied_pending"` + new `pending_reason` Text column on `session_suggested_fixes`. |
| Migration | `c0f3a4b7e91d` — adds `pending_reason`, extends status CHECK constraint. |
| API | `PATCH /suggested-fixes/{id}/outcome` accepts `applied_pending`, requires `notes`, stamps `applied_at` only (NOT `verified_at`). Pending in/out transitions allowed (parked, like partial). |
| Generators | `resolution_note_generator` and `escalation_package_generator` system prompts handle the new status without real-looking examples; resolution note frames the fix as provisional; escalation package surfaces pending verification as the leading hypothesis with reference to what's being waited on. |
| Frontend | New `BannerMode='pending'` + `PendingBanner` component (info-tone, mirrors `PartialBanner`) with worked / didn't / update reason / dismiss actions; "Waiting to verify…" overflow option in `VerifyingBanner`; nudge "Still checking" now records pending with a reason instead of just silencing; `AssistantChatPage` banner-mode derivation maps `applied_pending → 'pending'`; page-level Resolve/Escalate now handle pending honestly. |
| Tests | 4 new integration tests in `test_fix_outcome_endpoint.py`: pending-requires-notes, pending stores reason + applied_at-not-verified_at, pending→success transition, pending_reason update on re-PATCH. 21/21 pass. Prompt anti-parrot guardrail passes. Frontend `tsc -b` and Vite build pass via Docker. |
**Browser QA results (2026-04-30):**
## Out of scope (intentionally)
- ✅ Post-escalation redirect (dashboard + toast)
- ✅ Magic-moment screen: header, AI assessment, 2-option CTA
- ✅ "I'll take it from here": claim → dismiss → composer focused
- ✅ "Get AI analysis": claim → briefing → AI responds → task lane populates
- ✅ Task lane copy button: toast + checkmark
- ✅ Chip expansion: inline detail + "Open in Tasks panel"
- ✅ Post-claim overlay: dismissible mode, Close only
- Cross-session "Follow-ups" dashboard rollup. The chat-anchored `PendingBanner` is the per-session reminder. Add the dashboard surface only if engineers report losing track across multiple pending sessions.
- Optional follow-up timer ("remind me in 30m"). Nice but not the wedge.
## Done on `feat/escalation-metric-endpoint` (branched from `main` @ `c0ed6d9`)
## Resume point — DO THIS NEXT
| Commit | What it ships |
|---|---|
| `d51e95c` | Plan + test-plan artifacts |
| `52f6d03` | `GET /analytics/flowpilot/escalations` — in-product time-to-first-action |
| `7a5b853` | Role-gate POST `/handoffs/{id}/claim` to engineer-or-admin |
| `07d0db9` | `HandoffManager.dispatch_escalation_notifications` — emails engineer/admin teammates |
| `9f0bfd4` | `EscalationMetricCard` mounted above the queue list |
| `bc15952` | Codex: stabilize SSE backend tests |
| `9bdd995` | Bound escalation assessment latency (ORIGINAL: 5s) |
| `b8627f4` | Frontend SSE subscription in `EscalationQueue.tsx` — live-arrival animations |
| `8e9d22e` | Magic-moment handoff-context screen on pickup |
| `641853a` | Bell-icon notification opens the pickup flow |
| `029680a` | Unify `/escalate` through `HandoffManager` |
| `8914391` | First task-lane race fix (insufficient — see `665530f`) |
| `0f00ee5` | Four plan-locked items: live AI refresh, suggested-step chips, unread dot, race-condition toast |
| `665530f` | Structural task-lane fix — `taskLaneOwnerChatId` tagging |
| `b7d7ff0` | docs(ai): refresh handoff for compute swap |
| `0d1b305` | **Live-test fixes**: selectChat-gating bug (loadedChatIdsRef), 45s timeout bump, Enter-to-submit on escalate forms, dashboard expand-to-preview |
**Browser QA:** verify the new flow end-to-end in dev:
1. Trigger a suggested fix, click Apply, then open the verifying banner overflow → "Waiting to verify…" → enter a reason → confirm `PendingBanner` renders with the reason.
2. From `PendingBanner`, click "It worked" → confirm transition to terminal success and banner dismissal.
3. From `PendingBanner`, click "Update reason" → confirm reason updates server-side.
4. From `PendingBanner`, click "Dismiss" → confirm banner dismissal and no terminal timestamps.
5. Trigger the nudge state (3+ post-apply messages) → click "Still checking" → enter a reason → confirm pending state takes over.
6. From a pending fix, use page-level Resolve → confirm the fix is patched to `applied_success` before the resolution note.
7. From a pending fix, use page-level Escalate → confirm the outcome intercept appears before the conclude modal.
## Live-test results (2026-04-29 morning)
After QA passes, commit/push the local review fixes and merge PR #156.
After the structural task-lane fix and the four polish items, end-to-end test confirmed:
## Just-shipped (2026-04-30)
- ✅ Junior escalates → senior gets bell-icon notification.
- ✅ Magic-moment screen renders with handoff data on Pick Up.
- ✅ Senior's chat surface loads with conversation history (after `0d1b305`'s selectChat fix — was completely broken before).
- ✅ Sidebar shows the picked-up session with the "Escalated" pill (after `0d1b305`'s `loadChats()` call).
- ✅ Suggested-step chips render below the composer.
- ✅ Unread 6px dot on queue cards.
- ✅ Task-lane regression is gone — no stale flash on new sessions.
-**AI assessment placeholder never clears.** Drives the consolidation work above.
**PR #155 — Escalation Mode wedge** merged into main as `ac42f97`. The wedge for ResolutionFlow's GTM (first paying-customer push). Senior tech sees structured handoff context in seconds via the magic-moment screen. Plan: [`docs/plans/2026-04-27-escalation-mode-wedge-design.md`](../docs/plans/2026-04-27-escalation-mode-wedge-design.md).
Untested live (low priority, can verify post-consolidation): race-condition toast (needs second user in same account).
## Two-metric framing (Escalation Mode — read before quoting numbers)
## Two-metric framing — read this before quoting numbers to anyone
The in-product `GET /analytics/flowpilot/escalations` endpoint measures *post-claim time-to-first-action*. The "minutes recovered" sales claim is `manual_baseline in_product_metric`. Manual baseline comes from the founder's stopwatch on the next 5 escalations. Don't roll the in-product number alone into "minutes recovered" — that's the apples-to-oranges miscount Codex caught.
The in-product endpoint measures *post-claim time-to-first-action*. The "minutes recovered" sales claim is `manual_baseline in_product_metric`. Manual baseline comes from the founder's stopwatch on the next 5 escalations. Don't roll the in-product number alone into "minutes recovered" — that's the apples-to-oranges miscount Codex caught.
## Kill-switch
## Kill-switch (Escalation Mode)
Week 8: if 0 of 3 pilots produce a verifiable hours-saved-per-week number above 1.0, revisit the wedge.

View File

@@ -13,6 +13,28 @@
---
## 2026-04-30 — Add `applied_pending` non-terminal status to suggested fixes
**Context:** The verifying banner forces a synchronous verdict — worked / didn't / partial — but a lot of real MSP fixes are async. Engineer ran the script but is waiting on the client to power-cycle, AD replication, an O365 license sync. With only the existing outcomes, the engineer either leaves the banner stale (eroding the verifying signal) or guesses wrong (corrupting outcome data). User flagged the gap directly. Today's `NudgeBanner` "Still checking" button just silences the nudge — it doesn't tell the system anything.
**Decision:** Add a fourth, non-terminal outcome `applied_pending`, parallel to `applied_partial`. Required `pending_reason` Text column stores the "what are you waiting on?" reason. Outcome endpoint allows pending → {success, failed, partial, dismissed} transitions; pending stamps `applied_at` but NOT `verified_at` (it's parked, not verified). Resolution-note generator frames the fix as provisional (no closure language); escalation-package generator surfaces pending verification as the leading hypothesis with a reference to what's being waited on. Frontend exposes the state via a new `PendingBanner` component (info-tone, mirrors `PartialBanner`) plus a "Waiting to verify…" overflow option in the verifying banner. `NudgeBanner` "Still checking" now records pending with a reason instead of just silencing.
**Rejected:**
- **Reuse `applied_partial`.** Semantically wrong — partial means "I did some of it." Pending means "I did all of it, just can't tell if it worked." Generators write different prose for each, and conflating them would lose the distinction in the customer-facing resolution note and the next-engineer escalation handoff.
- **Add a `pending_reason` column without a new status.** The status field is what the dashboard, banner, and generators all branch on. Hiding pending state in a separate column would proliferate `IF pending_reason IS NOT NULL` checks across every consumer.
- **Cross-session "Follow-ups" dashboard rollup in v1.** Per-session `PendingBanner` is the chat-anchored reminder. Add the dashboard surface only if engineers report losing track across multiple pending sessions in pilot use.
- **Optional follow-up timer ("remind me in 30m").** Out of scope; nice-to-have but not the wedge.
**Consequences:**
- Engineers can park a fix honestly without losing the verifying signal. The state survives across sessions because it's persisted server-side.
- `pending_reason` is preserved as audit trail when the engineer advances pending → success/failed/dismissed; it is not auto-cleared. Intentional — it tells the next reader "we waited for X, then it worked."
- New consumers of `FixStatus` must handle the `applied_pending` case. Currently three: the banner derivation in `AssistantChatPage`, the resolution-note generator, and the escalation-package generator. All three updated in this change.
- Migration `c0f3a4b7e91d` is reversible — downgrade rewrites pending rows back to `applied_partial` and copies `pending_reason` into `partial_notes` if the partial slot was empty, then drops the column.
---
## 2026-04-30 — Allow `escalated_to_id` to send chat messages in claimed sessions
**Context:** During browser QA, clicking "Get AI analysis" on the magic-moment screen returned `POST /ai-sessions/{id}/chat → 400`. The senior tech who claimed the session is stored as `escalated_to_id` on `AISession`, not `user_id` (which remains the junior who created the session). `unified_chat_service.send_chat_message` queried `WHERE ai_sessions.user_id = :user_id`, so the senior's ID never matched and the endpoint rejected the request.

View File

@@ -2,55 +2,51 @@
# HANDOFF.md
**Last updated:** 2026-04-30 (Codex review-fix pass)
**Last updated:** 2026-05-01 (session 5 — PR #156 review fixes applied)
**Active task:** **Escalation Mode** wedge — BROWSER QA COMPLETE + review fixes applied. Branch: `feat/escalation-metric-endpoint`. PR #155 ready to mark ready-for-review after committing this fix pass.
**Active task:** Suggested-fix `applied_pending` outcome. Branch: `feat/fix-pending-verification`. PR #156 open; review fixes applied locally and ready for browser QA, then commit/merge.
**Just-merged:** PR #155 (Escalation Mode wedge) merged into main as `ac42f97`.
## Where this session ended
Code-review fixes were applied after browser QA:
PR #156 was reviewed for missed bugs and three fixes were applied:
- `claim_session` now uses atomic conditional `UPDATE ... WHERE claimed_by IS NULL` instead of read-then-write, so simultaneous senior pickup cannot silently overwrite `claimed_by`.
- Original escalators cannot claim their own handoff. The escalation queue also excludes the current user's own escalated sessions, preventing the post-escalation dashboard from showing the junior their own handoff.
- `session.escalation_package["handoff_id"]` is now populated from a preassigned UUID instead of `None` before flush.
- Frontend build blockers removed: deleted unused legacy `claiming` / `handleStartHere` path in `AssistantChatPage` and unused `onStartHere` destructuring in `HandoffContextScreen`.
1. Page-level **Resolve** now treats `applied_pending` as a verifying state and patches the fix to `applied_success` before opening the resolution flow, avoiding provisional notes on a resolved session.
2. Page-level **Escalate** intercept now catches `applied_pending` as well as verifying/partial, so a pending fix cannot bypass outcome capture before handoff. Intercept copy was generalized from "Verifying state" to "still needs an outcome."
3. `PendingBanner` now includes a **Dismiss** action, matching the PR body and backend's allowed pending → dismissed transition.
4. Real-looking pending examples were removed from `resolution_note_generator` and `escalation_package_generator` system prompts to stay aligned with the prompt anti-parrot guardrail.
**Validation:**
**Validation on PR #156:**
- `git diff --check`
- `cd backend && pytest --override-ini='addopts=' tests/test_handoff_manager.py tests/test_session_handoffs_api.py tests/test_escalation_bus.py``28 passed in 42.23s`
- `cd frontend && /config/.bun/bin/bunx tsc -p tsconfig.app.json --noEmit --pretty false && /config/.bun/bin/bunx tsc -p tsconfig.node.json --noEmit --pretty false`
- Full frontend build could not complete because generated dirs are root-owned in this workspace: `frontend/node_modules/.tmp`, `frontend/node_modules/.vite-temp`, and likely `frontend/dist` produce EACCES. Type errors from review are fixed.
**Not testable in dev (known limitations):**
- "Continue where X left off": requires senior to have existing task lane for session (won't occur on first pickup)
- Browser-level 409 race toast still requires two distinct senior accounts. Backend claim write is now atomic and covered by service/API tests for conflict, self-claim, and idempotent same-user retry.
- `docker exec resolutionflow_backend pytest --override-ini="addopts=" tests/test_prompt_anti_parrot.py -q` ✅ 2/2 pass.
- `docker exec resolutionflow_backend pytest --override-ini="addopts=" tests/test_fix_outcome_endpoint.py -q` ✅ 21/21 pass.
- `docker exec -w /app resolutionflow_frontend npx tsc -b` ✅ exit 0.
- `docker exec -w /app resolutionflow_frontend npm run build` ✅ exit 0; only existing Vite large-chunk warning.
- `git diff --check` ✅ clean.
- Previous session also verified `alembic upgrade heads` applied migration `c0f3a4b7e91d` cleanly.
## Resume point — DO THIS NEXT
**Ship:** Commit this review-fix pass, then mark PR #155 ready-for-review and demo to stakeholder.
**Browser QA on PR #156** (see CURRENT_TASK.md "Resume point" for the checklist). Include the new review-fix paths: PendingBanner Dismiss, page-level Resolve from pending, and Escalate from pending. Then commit local fixes, push, and merge when QA is green.
Optional before shipping:
- Record Loom demo walking through the escalation flow end-to-end
## Key files for PR #156
## Key files changed this session
- `backend/app/services/handoff_manager.py``_generate_handoff_summary` replaces old assessment pair; `enrich_escalation_async` unified; `claim_session` eager-loads `handed_off_by_user`
- `backend/app/api/endpoints/ai_sessions.py` — escalation queue excludes the current user's own escalations
- `backend/app/api/endpoints/session_handoffs.py` — self-claim returns 403
- `backend/app/services/flowpilot_engine.py``generate_status_update` early-returns saved prose for `context='escalation'`
- `backend/app/schemas/session_handoff.py``handed_off_by_name: str | None = None` added
- `backend/app/api/endpoints/session_handoffs.py` — both create + claim endpoints pass `handed_off_by_name`
- `frontend/src/types/branching.ts``HandoffResponse` updated with `summary_prose`, `what_we_know`, `confidence: string`, `handed_off_by_name`
- `frontend/src/components/flowpilot/HandoffContextScreen.tsx` — 3-option CTA; `hasTaskLane`, `activeOptionKey`, `onContinue/onAIAnalysis/onOwnThing` props
- `frontend/src/components/assistant/TaskLane.tsx``id="task-lane-card-{idx}"` on all card variants
- `frontend/src/pages/AssistantChatPage.tsx``handleContinue`, `handleAIAnalysis`, `handleOwnThing` handlers; chip → card navigation; `activeOptionKey` state
- `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py` — regression coverage for atomic/idempotent claim, self-claim rejection, queue self-exclusion, and pre-flush handoff ID
- `backend/app/models/session_suggested_fix.py` — CHECK constraint extended; `pending_reason` Text column.
- `backend/app/schemas/session_suggested_fix.py``applied_pending` added to both `FixStatus` and `FixOutcome` literals; `pending_reason` on response model; updated docstring on `SessionSuggestedFixOutcomeRequest`.
- `backend/alembic/versions/71efd2102f49_add_pending_status_to_suggested_fixes.py` — new migration (rev `c0f3a4b7e91d`, down `71efd2102f49`).
- `backend/app/api/endpoints/session_suggested_fixes.py``patch_outcome` accepts pending, requires notes, stamps applied_at only.
- `backend/app/services/{resolution_note,escalation_package}_generator.py` — system-prompt handling for the new status; `pending_reason` line in input bundle; real-looking examples removed.
- `backend/tests/test_fix_outcome_endpoint.py`4 new tests.
- `frontend/src/api/sessionSuggestedFixes.ts` — types updated; `pending_reason` on `SessionSuggestedFix`.
- `frontend/src/components/pilot/ProposalBanner.tsx``'pending'` `BannerMode`; `PendingBanner` component + Dismiss; "Waiting to verify…" overflow option; nudge "Still checking" wired to record pending.
- `frontend/src/components/pilot/EscalateInterceptDialog.tsx`copy generalized for pending/partial/verifying outcome capture.
- `frontend/src/pages/AssistantChatPage.tsx` — banner-mode derivation maps `applied_pending → 'pending'`; Resolve/Escalate paths now handle pending.
## Watch-outs
- Dev stack: backend `:8000`, frontend `:5173`, postgres `:5433` (docker-compose). HMR works.
- Test users (Acme MSP, password `TestPass123!`): `engineer@resolutionflow.example.com` (junior), `teamadmin@resolutionflow.example.com` (senior).
- `handleAIAnalysis` pre-adds `urlSessionId` to `loadedChatIdsRef` before dismissing so the normal selectChat effect doesn't double-fire. It then calls `selectChat` manually before sending the briefing.
- Legacy `claiming` / `handleStartHere` on `AssistantChatPage` was removed; `activeOptionKey !== null` is the active pre-claim processing signal.
- The bus is acceptable for v1 pilot scale only (Railway single-replica). Redis pub/sub is the swap when horizontal scaling appears.
- Dev stack: backend `:8000`, frontend `:5173`, postgres `:5433` (docker-compose). HMR works. On this host use `docker exec` for Python/npm commands; see `.ai/PROJECT_CONTEXT.md`.
- Test users (Acme MSP, password `TestPass123!`): `engineer@resolutionflow.example.com`, `teamadmin@resolutionflow.example.com`.
- Multi-head alembic state on main is pre-existing (heads `070`, `c0f3a4b7e91d`, `024`); not introduced by this work but worth knowing if `alembic upgrade head` complains — use `upgrade heads` (plural).
- `pending_reason` is preserved as audit trail when an engineer advances pending → success/failed/dismissed; it is not auto-cleared. Intentional.
- Working tree also has documentation edits in `.ai/PROJECT_CONTEXT.md` and `AGENTS.md` describing Docker exec commands. Those were not part of the feature fix but should be preserved.

View File

@@ -89,6 +89,15 @@ python -m scripts.seed_trees # seed (from
**Never pass `--rev-id`** to alembic — let it generate the hex hash.
**On hosts without native `python`/`node`/`npm`** (e.g. the code-server LXC), run commands inside the already-running containers instead:
```bash
docker exec resolutionflow_backend pytest --override-ini="addopts="
docker exec resolutionflow_backend alembic upgrade head
docker exec -w /app resolutionflow_frontend npm run build
docker exec -w /app resolutionflow_frontend npx tsc -b
```
---
## URLs & test users

View File

@@ -12,6 +12,31 @@
---
## 2026-05-01 02:24 UTC — Codex — Review-fix PR #156 pending-verification flow
- Reviewed PR #156 for bugs and found three actionable gaps: pending fixes could be resolved from the page-level Resolve path without updating the fix outcome, the PendingBanner lacked the dismiss action described in the PR body, and new system-prompt examples used real-looking pending reasons contrary to the prompt anti-parrot lesson.
- Applied fixes locally on `feat/fix-pending-verification`: page-level Resolve now patches `applied_pending` to `applied_success`; page-level Escalate now intercepts `applied_pending` before handoff; PendingBanner now has Dismiss; escalation intercept copy no longer says only "Verifying state"; generator prompts no longer include real-looking pending examples.
- Verified via running containers: prompt anti-parrot guardrail `2 passed`, suggested-fix outcome suite `21 passed`, frontend `npx tsc -b` clean, frontend `npm run build` clean except the existing Vite large-chunk warning, and `git diff --check` clean.
- Left for next session: browser QA PR #156 using CURRENT_TASK.md checklist, then commit/push local review fixes and merge.
- Files touched: `backend/app/services/resolution_note_generator.py`, `backend/app/services/escalation_package_generator.py`, `frontend/src/components/pilot/ProposalBanner.tsx`, `frontend/src/components/pilot/EscalateInterceptDialog.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/SESSION_LOG.md`.
## 2026-04-30 — Claude Code — Land PR #155, ship pending-verification feature on PR #156
- Committed Codex's review-pass changes (atomic conditional `UPDATE` for `claim_session`, self-claim 403, queue self-exclusion, pre-flush handoff UUID, frontend dead-code removal) as `f10649a` on `feat/escalation-metric-endpoint`.
- Pushed `feat/escalation-metric-endpoint`, un-drafted PR #155, retitled it (stripped "WIP:"), and merged via Gitea API as a merge commit (`ac42f97`). 4/4 CI checks green at merge.
- Picked up follow-up work surfaced by the user: the suggested-fix verifying banner forces a synchronous verdict, but real fixes are often async (waiting on client power-cycle, AD replication, license sync). Added a fourth, non-terminal outcome.
- Designed the model: new `FixStatus="applied_pending"` parallel to `applied_partial`. Distinct semantics — partial = "did some of it"; pending = "did all of it, can't verify yet." Distinct prose in the resolution-note + escalation-package generators.
- Implemented on a fresh branch `feat/fix-pending-verification` off main:
- Backend: extended `FixStatus`/`FixOutcome` literals, added `pending_reason` Text column and CHECK constraint update via Alembic migration `c0f3a4b7e91d`. `patch_outcome` accepts pending, requires notes, stamps `applied_at` only (NOT `verified_at`); pending in/out transitions allowed.
- Frontend: new `BannerMode='pending'` + `PendingBanner` component (info-tone, mirrors `PartialBanner`). "Waiting to verify…" added to `VerifyingBanner` overflow menu. `NudgeBanner` "Still checking" button now records `applied_pending` with a reason instead of just silencing for the session — closes the loop semantically. `AssistantChatPage` banner-mode derivation maps the new status.
- Tests: 4 new integration tests in `test_fix_outcome_endpoint.py` covering notes-required, reason-storage with applied_at-not-verified_at semantics, pending→success transition, and pending_reason update on re-PATCH. 21/21 pass.
- Validation: `tsc --noEmit -p tsconfig.app.json` exit 0; `alembic upgrade heads` applied cleanly.
- Single-commit PR #156 opened: https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/156. Branch rebased onto post-merge main.
- Cleanup: removed 10 stray `core.*` dumps from the worktree; deleted merged `feat/escalation-metric-endpoint` locally and on the remote.
- Files touched: `backend/app/models/session_suggested_fix.py`, `backend/app/schemas/session_suggested_fix.py`, `backend/app/api/endpoints/session_suggested_fixes.py`, `backend/app/services/resolution_note_generator.py`, `backend/app/services/escalation_package_generator.py`, `backend/tests/test_fix_outcome_endpoint.py`, `backend/alembic/versions/71efd2102f49_add_pending_status_to_suggested_fixes.py`, `frontend/src/api/sessionSuggestedFixes.ts`, `frontend/src/components/pilot/ProposalBanner.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/DECISIONS.md`.
---
## 2026-04-30 06:25 UTC — Codex — Apply Escalation Mode review fixes
- Reviewed the recent Escalation Mode wedge work and fixed the actionable findings before PR #155 is marked ready.

View File

@@ -40,7 +40,7 @@ Prefer correct architecture over minimal diff. Flag "simpler approach" tradeoffs
### Tooling you do NOT have
- **No GitNexus tools.** Use `grep -r`, `rg`, `git grep`, or `find` for code search. For blast-radius reasoning, grep call sites manually and read the files.
- **No gstack slash commands** (`/review`, `/ship`, `/qa`, `/browse`, `/investigate`, `/design-review`, `/plan-*`). Run the equivalent work directly: `pytest` for tests, `npm run build` for frontend validation, manual PR description for review flow.
- **No gstack slash commands** (`/review`, `/ship`, `/qa`, `/browse`, `/investigate`, `/design-review`, `/plan-*`). Run the equivalent work directly: `pytest` for tests, `npm run build` for frontend validation, manual PR description for review flow. If `python`/`npm` aren't on PATH, the host runs services in Docker — use the `docker exec resolutionflow_{backend,frontend} …` form documented in `.ai/PROJECT_CONTEXT.md` rather than installing toolchains.
- **No `/codex` second-opinion command.** You are Codex.
### Git trailer

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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. 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}")

View File

@@ -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 as the provided waiting reason. \
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}")

View File

@@ -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

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

@@ -49,8 +49,8 @@ export function EscalateInterceptDialog({
Before escalating what happened with the fix?
</div>
<div className="text-[12px] text-muted-foreground leading-[1.5] mb-3">
&ldquo;{fixTitle}&rdquo; is still in the Verifying state. Tag its outcome so
the senior picking this up knows what&apos;s been tried.
&ldquo;{fixTitle}&rdquo; still needs an outcome. Tag it so the senior
picking this up knows what&apos;s been tried.
</div>
<div className="flex flex-col gap-1.5">
<button

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,72 @@ function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
)
}
function PendingBanner({ fix, onOutcome, onDismiss }: 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={onDismiss}
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary"
>
Dismiss
</button>
<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 +397,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'
@@ -965,7 +966,8 @@ export default function AssistantChatPage() {
const inVerifyState =
activeFix && (
(!!activeFix.applied_at && activeFix.status === 'proposed') ||
activeFix.status === 'applied_partial'
activeFix.status === 'applied_partial' ||
activeFix.status === 'applied_pending'
)
if (inVerifyState && activeFix) {
setEscalateIntercept({ fixId: activeFix.id, fixTitle: activeFix.title })
@@ -1003,10 +1005,15 @@ export default function AssistantChatPage() {
}
}, [activeChatId, escalateIntercept])
// Phase 8: Resolve click — auto-mark applied_success if in verifying state
// Phase 8: Resolve click — auto-mark applied_success if in verifying/pending state
// before opening the resolution note preview.
const handleResolveClick = useCallback(async () => {
if (activeFix && activeFix.applied_at && activeFix.status === 'proposed' && activeChatId) {
const shouldMarkFixSuccessful =
activeFix
&& activeFix.applied_at
&& (activeFix.status === 'proposed' || activeFix.status === 'applied_pending')
&& activeChatId
if (shouldMarkFixSuccessful) {
try {
const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, 'applied_success')
setActiveFix(updated)