docs(ai): refresh handoff for compute swap
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 5m8s
CI / backend (pull_request) Successful in 9m46s
CI / e2e (pull_request) Successful in 10m16s

- HANDOFF: rewritten resume point. First action on resume is `git push`
  (commits 0f00ee5 and 665530f are local-only). Visual QA + bug bash is
  the active work; 4 plan-locked items + the structural task-lane fix
  all need real-browser verification.
- CURRENT_TASK: add 0f00ee5 and 665530f to the commit table; reframe
  "Just shipped" as a per-commit summary; flag the task-lane fix as
  needing visual confirmation.
- SESSION_LOG: chronological entry for this session with full detail
  (audit, four polish items, race-condition wiring, structural
  task-lane fix, test status, files touched).
- DECISIONS: new entry "Tag the task-lane state with an owner chatId"
  documenting the structural pattern, what was rejected, and the
  forward implication that future task-lane state slices follow the
  same owner-tagging pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 08:21:23 -04:00
parent 665530f812
commit b7d7ff06d2
4 changed files with 73 additions and 39 deletions

View File

@@ -8,7 +8,7 @@
**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) — primary input for `/qa` once feature-complete.
## Done on `feat/escalation-metric-endpoint` (8 commits, branched from `main` @ `c0ed6d9`)
## Done on `feat/escalation-metric-endpoint` (branched from `main` @ `c0ed6d9`)
| Commit | What it ships |
|---|---|
@@ -29,6 +29,9 @@
| `641853a` | Bell-icon notification opens the pickup flow — notification link template adds `?pickup=true`; GET `/ai-sessions/{id}` allows account-scoped read for `requesting_escalation` / `escalated` states |
| `2a2329a` | Handoff state docs after bell-icon fix; record draft PR #155 |
| `029680a` | Unify `/escalate` through `HandoffManager` — single canonical path for every escalation. `HandoffCreateRequest.target_user_id`, `create_handoff` does the legacy enriched-package work + sets `escalation_reason`, `finalize_escalation` runs documentation + PSA push + `notify()` pre-commit, `dispatch_escalation_notifications` keeps only fire-and-forget IO post-commit. `pickup_session` accepts either status for in-flight migration. `flowpilot_engine.escalate_session` no longer called from any endpoint |
| `8914391` | First task-lane race fix — initializer-time guards (`incomingPrefill || isPickup`) + eager `sessionStorage.removeItem` in `resetSessionDerivedState`. Insufficient (only covered mount-time entry paths) |
| `0f00ee5` | Four plan-locked wedge polish items in one commit — see "Just shipped" section below |
| `665530f` | **Structural fix for the task-lane stale-flash bug.** `taskLaneOwnerChatId` state tags the chatId the in-memory questions/actions belong to. Set at every populate site (sendPrefill, selectChat, handleSend, handleTaskSubmit, handleResumeNew, refreshFacts, handleApplyFix); cleared in `resetSessionDerivedState`. Persistence effect now writes `chatId: ownerChatId` (was `activeChatId` — that was the original write-side bug). Render gate `taskLaneIsForActiveChat = ownerChatId === activeChatId` ANDed into all three render conditions. Stale data is now structurally unable to display. See DECISIONS entry for full rationale |
**Test status:** full backend suite → `1103 passed in 259.63s` with `-n auto` after the unification. Frontend `tsc -b` clean. End-to-end smoke test against the running dev stack confirmed: SSE handshake delivers `ready` + `handoff_created` frames; `listHandoffs` returns the unclaimed handoff for a senior pre-claim; `claimHandoff` flips session status `escalated``active`; senior (non-owner, non-target) can `GET` an in-transit session detail; **a single legacy `/escalate` call now produces status='escalated', SessionDocumentation, SessionHandoff row, AppNotification with link `/pilot/{id}?pickup=true` for the team admin, and a PSA push attempt** — all from one funneled HandoffManager call. Branch pushed; draft PR #155 open.
@@ -40,13 +43,17 @@
4. **Owner-facing analytics page** at `/analytics/escalations` — period selector, conversion-rate, trend chart. ~0.5d. Optional for v1 demo.
5. **Playwright e2e** for the magic-moment demo flow (junior escalates → senior receives via SSE → senior claims → opens session). Critical for the GTM Loom not to crash mid-recording.
## Just shipped (4 plan-locked items, this session)
## Just shipped (this session — 2 commits)
**Commit `0f00ee5`** — four plan-locked wedge polish items:
- **Live AI assessment refresh on the magic-moment screen.** New `HandoffAssessmentReadyEvent` type + `onAssessmentReady` handler on `streamEscalations`. `AssistantChatPage` opens a scoped SSE subscription whenever it has a tracked handoff with no AI assessment yet; on a matching event it refetches and replaces both `magicHandoff` and `overlayHandoff` in place. Closes the loop on the async-assessment commit `e8ba74e`.
- **Suggested-step chips below the chat input.** New `chipsHidden` state in `AssistantChatPage` defaulting to false; a chip strip renders above the composer when `magicHandoff?.ai_assessment_data?.suggested_steps[]` is non-empty and the magic-moment has dissolved. Click prefills input + focus; first send hides the strip; explicit X also hides. Per-session lifetime (Codex correction locked design).
- **Unread 6px dot on `EscalationQueue` cards.** localStorage-persisted seen set (`rf-escalation-seen`, capped 200). Dot renders top-right of any card not yet seen. Cleared on **open (card click) or claim (Pick Up)** — NOT on hover (Codex correction). Pick Up onClick now stops propagation so the wrapper's open handler isn't double-fired.
- **Race-condition toast on claim conflict.** New `HandoffAlreadyClaimedError` exception class in `handoff_manager.py`. `claim_session` now eager-loads `claimed_by_user`, rejects different-user re-claims (idempotent for same-user), and raises with the winner's id/name/timestamp. Endpoint translates to 409 with structured detail. `AssistantChatPage.handleStartHere` extracts the detail, formats `"Already claimed by {name} {time_ago}."` via `timeAgo()`, drops `?pickup=true`, and dismisses the magic-moment so the loser flows back to the queue. Backed by 2 new unit tests in `test_handoff_manager.py`.
**Commit `665530f`** — structural fix for the recurring stale-task-lane bug. Owner-tagging pattern applied to `activeQuestions` / `activeActions` / `showTaskLane`. See [`DECISIONS.md`](DECISIONS.md) for the architecture write-up. **User-reported on next session: needs visual verification.**
## Two-metric framing — read this before quoting numbers to anyone
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 (The Assignment in the design doc). Don't roll the in-product number alone into "minutes recovered" — that's the apples-to-oranges miscount Codex caught.

View File

@@ -13,6 +13,27 @@
---
## 2026-04-28 — Tag the task-lane state with an owner chatId
**Context:** A recurring bug — every time the user returned to test escalation work, creating a new session would flash the previous session's task-lane data (questions, actions, "Tasks" pill counts) before the new session's AI response landed. The first attempt to fix it (`8914391`) added initializer-time guards (`incomingPrefill || isPickup`) that skipped the sessionStorage restore on mount. That covered exactly two entry paths and missed every other case: in-place URL navigation, mid-flight pickup, HMR re-runs, and the gap between `setActiveChatId(B)` and the AI response that finally populates B's questions/actions. The persistence effect made it worse by writing `{chatId: activeChatId, questions: activeQuestions}` — at any moment where activeChatId had flipped before the questions were updated, sessionStorage was stamped with `{chatId: B, questions: [A's data]}` and a subsequent restore would happily render A's data for B.
The root cause was that `activeQuestions` / `activeActions` / `showTaskLane` were three independent state slices implicitly assumed to be in sync with `activeChatId`. The synchronization was by convention, not by structure. Every code path that mutated them had to remember to call `resetSessionDerivedState` first; missing one created stale UI.
**Decision:** Add a `taskLaneOwnerChatId` state that records *which chatId the in-memory questions/actions belong to*, set at every site that populates them (sendPrefill, selectChat, handleSend, handleTaskSubmit, handleResumeNew, refreshFacts, handleApplyFix), cleared in `resetSessionDerivedState`. The persistence effect writes ownerChatId as the chatId tag. Render is gated on `taskLaneOwnerChatId === activeChatId` and ANDed into all three render conditions (toolbar Tasks button, narrow-viewport floating drawer, main side panel). The mount-time `skipTaskLaneRestore` guard stays as belt-and-braces for the prefill/pickup entry-flash window, which the owner-gate alone doesn't cover.
**Rejected:**
- **More entry-path guards.** That's whack-a-mole — the next path nobody anticipated will reproduce the bug. The owner-gate makes the bug structurally impossible regardless of which path triggers it.
- **Combining the four state slices into a single tagged object.** Cleaner long-term but a bigger refactor with more touch points. The owner-tracking approach gets the structural guarantee with a minimal diff and keeps the existing setState patterns.
- **Inlining the comparison at every render site.** Works but proliferates the comparison; one named derived value (`taskLaneIsForActiveChat`) reads better and groups the gate with the persistence-effect / state declarations as a named concept.
**Consequences:**
- Stale task-lane data is structurally unable to display. The lane is hidden during any window where `ownerChatId !== activeChatId`, no matter what mutation path got you there.
- Adding new sites that populate `activeQuestions` / `activeActions` requires also setting `taskLaneOwnerChatId`. The pattern is documented in the commit message and visible in every existing populate site as a paired call.
- The mount-time `skipTaskLaneRestore` guard is now redundant in steady-state but kept for the few-hundred-ms flash window between component mount and the first sendPrefill / selectChat effect. Deleting it would re-introduce a (smaller) flash without strong reason.
- Future task-lane state slices (e.g. `facts`, `activeFix`) follow the same pattern: gate their visibility on the owner check via the existing render conditions. Tagging more slices with their own `*OwnerChatId` is a future refactor if the slices diverge.
---
## 2026-04-24 — Adopt dual-agent handoff system (`.ai/` + `CLAUDE.md` + `AGENTS.md`)
**Context:** Claude Code hits session and weekly usage limits. Work stalls when the primary agent is locked out. Needed a structured way for OpenAI Codex to resume where Claude left off without losing architectural truth or drifting across sessions.

View File

@@ -2,62 +2,54 @@
# HANDOFF.md
**Last updated:** 2026-04-27 22:30 EDT
**Last updated:** 2026-04-28 02:00 EDT
**Active task:** **Escalation Mode** wedge build. See [`CURRENT_TASK.md`](CURRENT_TASK.md) for the full status; this file holds the resume point only.
**Active task:** **Escalation Mode** wedge build. Full status in [`CURRENT_TASK.md`](CURRENT_TASK.md); this file is the resume point.
**Branch:** `feat/escalation-metric-endpoint` — pushed (latest: `029680a`). **Draft PR #155** open against `main` ([gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/155](https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/155)). Wedge is feature-complete pending visual QA + the deferred follow-ups in `CURRENT_TASK.md`. **/escalate and /handoff are unified** — every escalation goes through `HandoffManager` and produces the full set of artifacts (handoff row, AppNotification, SSE bus event, Slack/Teams via `notify()`, per-user emails, documentation, PSA push) regardless of which URL it entered through.
**Branch:** `feat/escalation-metric-endpoint`. Local tip is `665530f`. **Remote (origin) is at `8914391`** — the last two commits (`0f00ee5`, `665530f`) are local-only because the user is swapping computers and asked for the docs/handoff first. **Push needed on next session before continuing work.** Draft PR #155 is open against `main`.
## Status
## What this session did
Previous session shipped the two remaining frontend slices: live-arrival SSE subscription in `EscalationQueue.tsx`, and the magic-moment `HandoffContextScreen` for senior pickup.
Two commits, both untested in a real browser:
What landed (commits added to the branch):
1. **`0f00ee5` feat(escalations): close out plan-locked wedge polish.** Four items from the design-plan audit ([`docs/plans/2026-04-27-escalation-mode-wedge-design.md`](../docs/plans/2026-04-27-escalation-mode-wedge-design.md)):
- **Live AI assessment refresh** — frontend listener for the `handoff_assessment_ready` SSE event, refetches the handoff and updates `magicHandoff` / `overlayHandoff` in place. Closes the async-assessment loop from `e8ba74e`.
- **Suggested-step chips** below the composer in `AssistantChatPage` — surfaces `ai_assessment_data.suggested_steps[]` post-claim, click prefills the input, hides on first send or explicit X.
- **Unread 6px dot** on `EscalationQueue` cards — localStorage-persisted seen set (`rf-escalation-seen`), clears on open OR claim (NOT hover; Codex correction).
- **Race-condition toast on claim conflict** — new `HandoffAlreadyClaimedError` exception, endpoint returns 409 with structured `{claimed_by_id, claimed_by_name, claimed_at}`, frontend shows `"Already claimed by {name} {time_ago}."` and bounces the loser back to the queue. Backed by 2 new tests; full handoff/escalation suite (34 tests) green.
- `b8627f4` feat(escalations): subscribe EscalationQueue to live SSE arrivals — `streamEscalations` in `aiSessions.ts` (fetch-based `ReadableStream` parser; native `EventSource` can't send auth headers); `HandoffCreatedEvent` + `EscalationStreamHandlers` types; `EscalationQueue.tsx` rewrite with `AbortController`-managed subscription, exponential-backoff reconnect (1s → 30s cap, resets on `ready`), prepend-on-arrival with locked 200ms slide-in, tab-title `(N)` prefix while `document.hidden`, `prefers-reduced-motion` swap, ARIA live region.
- `f65b657` docs(ai): handoff state after frontend SSE slice lands.
- `8e9d22e` feat(escalations): magic-moment handoff-context screen on pickup — new `HandoffContextScreen.tsx` (4 sections; renders gracefully when `ai_assessment` is null per the 5s timeout from `9bdd995`; ARIA dialog + focus on primary CTA + Esc dismiss for re-open overlay; `prefers-reduced-motion` honored). `FlowPilotSessionPage.tsx` integration: on `?pickup=true`, fetch the handoff list first (account-scoped via RLS, no claim required), find the latest unclaimed escalate handoff, render the screen and skip `loadSession` (senior would 404 pre-claim). "Start here" calls `claimHandoff`, drops the pickup query, and dismisses — `loadSession` then fires because senior is now `escalated_to_id`. Toolbar "Context" button on active sessions re-opens the screen as a dismissible overlay (visible only when senior arrived via the magic-moment flow this session).
- `c194ba4` docs(ai): handoff state after magic-moment screen lands.
- `641853a` fix(escalations): bell-icon notification opens the pickup flow — `_build_notification_link` for `session.escalated` now ends with `?pickup=true` so notification clicks route through the senior-pickup flow. `GET /ai-sessions/{id}` now allows account-scoped read for `requesting_escalation` / `escalated` status (RLS already enforces tenant boundary; the owner-only guard was overly restrictive for explicitly-shared in-transit states). Without these two fixes the user observed bell-icon clicks "just clearing the notification" — the navigation was happening but landing on a 404 the senior couldn't escape from.
- `2a2329a` docs(ai): handoff state after bell-icon fix; record draft PR #155.
- `029680a` feat(escalations): unify `/escalate` through `HandoffManager` — single canonical path for every escalation. `HandoffCreateRequest.target_user_id` added (rejects self-targeting). `HandoffManager.create_handoff` for intent='escalate' now sets `session.escalation_reason` + `escalated_to_id`, builds the legacy AI-enhanced escalation_package via Sonnet (lazy-import from flowpilot_engine, graceful fallback), and merges handoff metadata into it; eager-loads `session.steps` + `session.user` to dodge async lazy-load greenlet errors. New `HandoffManager.finalize_escalation` runs `_generate_documentation` + `_push_to_psa` + `notify()` pre-commit so the AppNotification rows and PSA writes land atomically with the handoff. `dispatch_escalation_notifications` keeps only fire-and-forget IO (bus publish + per-user emails) post-commit. The `/escalate` endpoint is a thin shim: owner-only session lookup → `create_handoff(intent='escalate')``finalize_escalation` → commit → `dispatch_escalation_notifications` → return `SessionCloseResponse`. `flowpilot_engine.escalate_session` is no longer called by any endpoint. `pickup_session` accepts both `requesting_escalation` and `escalated` for in-flight migration. Escalation queue list + sidebar count match either status.
2. **`665530f` fix(assistant-chat): tag task-lane state with owner chatId.** Structural fix for the recurring "new session shows previous session's task lane" bug. The earlier fix `8914391` only covered the mount-time entry path; this change makes stale data structurally unable to display by adding `taskLaneOwnerChatId` state and a render gate `taskLaneOwnerChatId === activeChatId` ANDed into all three render conditions. Persistence effect now writes ownership chatId, not active chatId — that was the original write-side bug. See [`DECISIONS.md`](DECISIONS.md) for the architecture write-up.
Verified:
- `tsc -b` exit 0 after each frontend commit.
- Full backend test suite after unification: `1103 passed in 259.63s` with `-n auto`.
- Live SSE handshake against the running dev stack: 200 + `text/event-stream`; `ready` frame on connect; `handoff_created` frame with full payload arrived after posting a handoff via the API. Wire format matches the parser exactly.
- Live claim flow against the running dev stack: `listHandoffs` returns the unclaimed handoff for a senior pre-claim; `claimHandoff` flips session status from `escalated``active` and sets `escalated_to_id`; subsequent `GET /ai-sessions/{id}` succeeds.
- Live access-policy verification: senior (non-owner, non-target) can now `GET` an in-transit escalated session detail.
- Live unification verification: a single legacy `/escalate` call from a junior produced status='escalated', a `SessionDocumentation`, a `SessionHandoff` row, an attempted PSA push (`no_psa` since no ticket linked), AND an `AppNotification` row for the team admin with title "Session escalated by Jordan Tech" and link `/pilot/{session_id}?pickup=true`. The bell-icon click now lands the senior in the magic-moment flow with the actual handoff data.
Not yet verified (would need a real browser session): the slide-in animation visually plays, tab title actually updates, reduced-motion media-query path renders, AbortController cleanup on unmount, exponential backoff after a real network blip, the magic-moment screen layout/typography looks right, dissolve transition feels right. Wire contract + integration semantics are confirmed; visuals are next.
Smoke-test artifact: a single test handoff (`0f6149db…` on session `50ea20d4…`) was claimed during verification and is now an `active` session owned by the engineer test user. Harmless; useful as visual demo data.
Verified: `tsc -b` clean after both. Backend handoff/escalation suite (34 tests) green. **Not verified:** anything in a real browser. The user explicitly asked for a debugging session after implementation — that's the next thing.
## Resume point
1. **Visual QA via `/qa` against the dev stack.** End-to-end demo flow: junior escalates via EscalateModal → senior gets bell-icon notification → senior clicks the notification (now routes through `?pickup=true`) → magic-moment screen renders with the rich handoff data → Start here → FlowPilot session view loads. Also: open `/escalations` as senior with a second session escalating in the background, watch the slide-in + tab-title flash. The PR description has a checklist mirroring this.
2. **Pick up the deferred follow-ups** in `CURRENT_TASK.md`. Highest-leverage: suggested-step chips below the chat input (Codex correction, locked design — needs threading through `FlowPilotSession``FlowPilotMessageBar`). Next: `HandoffManager._generate_snapshot` expansion to include the recent diagnostic timeline pre-claim — though this is lower-priority now that the unified path already merges the legacy enriched escalation_package into the dual-write, so the magic-moment screen has access to `steps_tried` / `remaining_hypotheses` / `suggested_next_steps` once it's wired to read them.
3. Optional v1: owner-facing `/analytics/escalations` page; Playwright e2e for the GTM Loom demo path.
4. Eventual cleanup: `flowpilot_engine.escalate_session` is no longer called by any endpoint and could be deleted; the legacy `SessionBriefing` render branch in `FlowPilotSessionPage.tsx` is effectively dead code for any new escalation (magic-moment takes over) but still useful for in-flight legacy `requesting_escalation` sessions during the transition window. Both can come out after pilots have run a couple of weeks on the unified path.
1. **First action: `git push` the two local commits.** `0f00ee5` and `665530f` are local-only.
2. **Visual QA + bug bash.** End-to-end demo flow:
- Junior escalates → senior gets bell-icon notification → click → magic-moment screen with **placeholder AI assessment** (because it's now async/background) → assessment populates **in place** within ~515s without manual reopen → Start here → chat surface loads with **suggested-step chips** above the composer → click a chip prefills input.
- On `/escalations`: backgrounded tab gets `(N)` title prefix when an arrival fires; new card has **6px accent dot** top-right; clicking the card body OR Pick Up clears the dot (verify it persists across refresh, doesn't clear on hover).
- Race condition: claim the same handoff from two browsers; loser sees toast `"Already claimed by {name} {time_ago}."` and bounces.
- **Task-lane regression check:** create a new session via dashboard prefill / pickup / "New Chat" — the lane must NOT flash the previous session's questions/actions. The user previously reported this happening repeatedly; the fix in `665530f` should kill it. If it still happens, that's the next debug target.
3. **Deferred follow-ups in `CURRENT_TASK.md`:** snapshot expansion, owner-facing `/analytics/escalations` page, Playwright e2e for the GTM Loom demo path, eventual cleanup of `flowpilot_engine.escalate_session` and the dead `FlowPilotSessionPage.tsx` magic-moment branch.
## Useful breadcrumbs
- SSE endpoint: [`backend/app/api/endpoints/session_handoffs.py`](../backend/app/api/endpoints/session_handoffs.py) — `stream_escalations`.
- Pub/sub bus: [`backend/app/core/escalation_bus.py`](../backend/app/core/escalation_bus.py).
- Frontend SSE consumer: [`frontend/src/api/aiSessions.ts`](../frontend/src/api/aiSessions.ts) → `streamEscalations`.
- Frontend SSE consumer: [`frontend/src/api/aiSessions.ts`](../frontend/src/api/aiSessions.ts) → `streamEscalations` (now dispatches `handoff_created` AND `handoff_assessment_ready`).
- Live-arrival queue UI: [`frontend/src/components/flowpilot/EscalationQueue.tsx`](../frontend/src/components/flowpilot/EscalationQueue.tsx).
- Magic-moment screen: [`frontend/src/components/flowpilot/HandoffContextScreen.tsx`](../frontend/src/components/flowpilot/HandoffContextScreen.tsx).
- Pickup integration: [`frontend/src/pages/FlowPilotSessionPage.tsx`](../frontend/src/pages/FlowPilotSessionPage.tsx) — `magicState`, `handleStartHere`, `openHandoffContextOverlay`.
- Notification dispatch: [`backend/app/services/handoff_manager.py`](../backend/app/services/handoff_manager.py) — `dispatch_escalation_notifications`.
- Pickup integration + magic state machine + suggested-step chips + assessment-ready subscription + claim 409 handling + task-lane owner tagging: [`frontend/src/pages/AssistantChatPage.tsx`](../frontend/src/pages/AssistantChatPage.tsx).
- Claim conflict exception: [`backend/app/services/handoff_manager.py`](../backend/app/services/handoff_manager.py) — `HandoffAlreadyClaimedError`, `claim_session`, `enrich_escalation_async`.
- Metric endpoint: [`backend/app/api/endpoints/flowpilot_analytics.py`](../backend/app/api/endpoints/flowpilot_analytics.py).
## Watch-outs
- The two new commits are **local-only** until pushed. Run `git push` before any other work.
- The assessment-ready subscription opens a fresh SSE connection scoped by `assessmentMissing && trackedHandoffId`. If you change the magic-moment lifecycle, double-check the cleanup deps don't churn the subscription.
- The claim conflict path is currently only wired into `AssistantChatPage.handleStartHere`. `useHandoff` (used by `SessionQueuePage`) and `FlowPilotSessionPage.tsx` (dead) were not updated. If `SessionQueuePage` claims start mattering, mirror the same `axios.isAxiosError(e) && e.response?.status === 409` extraction.
- The handoff snapshot is still sparse (`problem_summary, problem_domain, status, step_count, confidence_tier`). Magic-moment "What's been tried" still only shows engineer notes + step count pre-claim.
- `HandoffResponse.ai_assessment_data.confidence` is typed `number` on the frontend but the backend currently emits `'low' | 'medium' | 'high'`. Runtime handles both; type definition is stale.
- Toolbar "Context" button is hidden on revisited active sessions where the senior didn't arrive via magic-moment this session — known scope cut.
- Do not reintroduce `client.stream()`/ASGITransport tests for infinite SSE responses; test the generator directly.
- The bus is acceptable for v1 pilot scale only because Railway is single-replica. Redis pub/sub is the obvious swap when horizontal scaling appears.
- `streamEscalations` doesn't drive token refresh on a mid-stream 401 — the Axios interceptor only covers axios calls. Acceptable for v1.
- The handoff snapshot today is sparse (`problem_summary, problem_domain, status, step_count, confidence_tier` plus optional branch info). The magic-moment screen's "What's been tried" section currently shows engineer notes + step-count affordance, not the actual step timeline. Snapshot expansion is the right fix.
- `HandoffResponse.ai_assessment_data.confidence` is typed `number` on the frontend but the backend currently emits `'low' | 'medium' | 'high'` strings. The `ConfidenceBadge` component handles both shapes at runtime; the type definition is stale and should be widened to `number | 'low' | 'medium' | 'high'`.
- The toolbar "Context" button is hidden on revisited active sessions where the senior didn't arrive via magic-moment this session — known scope cut. Lazy-fetching handoff list on session-load (when status was previously `escalated`) is the cleanup.
- Bus is acceptable for v1 pilot scale only (Railway single-replica). Redis pub/sub is the obvious swap when horizontal scaling appears.

View File

@@ -12,6 +12,20 @@
---
## 2026-04-28 02:00 EDT — Claude Code — Plan-locked wedge polish + structural task-lane fix
- Audited `docs/plans/2026-04-27-escalation-mode-wedge-design.md` against the branch and identified four locked-design / Codex-correction items not yet shipped: live AI assessment refresh, suggested-step chips, unread 6px dot on queue cards, and race-condition toast on claim conflict.
- Shipped all four in commit `0f00ee5`:
- **Live AI assessment refresh.** New `HandoffAssessmentReadyEvent` type and `onAssessmentReady` handler on `streamEscalations`. `AssistantChatPage` opens a scoped SSE subscription whenever it tracks a handoff missing its AI assessment; on a matching event it calls `handoffsApi.listHandoffs(sessionId)`, finds the handoff by id, and replaces both `magicHandoff` and `overlayHandoff` in place. Closes the loop on the async-assessment commit `e8ba74e` — without this, the senior had to manually reopen the Context overlay to see the AI assessment when the background task finished.
- **Suggested-step chips.** New `chipsHidden` state in `AssistantChatPage`; chip strip renders above the composer when the magic-moment dissolves and `magicHandoff?.ai_assessment_data?.suggested_steps[]` is non-empty. Click prefills input and focuses; first send via `handleSend` flips `setChipsHidden(true)`; explicit X button also hides. Per-session lifetime by design (Codex correction locked).
- **Unread 6px dot.** localStorage-backed seen set (`rf-escalation-seen`, capped at 200 entries) hydrated in `EscalationQueue`. Card render adds a 6px `bg-accent` dot when not in the seen set. `markSeen` called on Pick Up click AND on card body click (the "open" affordance). Hover deliberately doesn't clear (Codex correction). Pick Up button's onClick now calls `e.stopPropagation()` so it doesn't double-fire the card-open path.
- **Race-condition toast on claim conflict.** New `HandoffAlreadyClaimedError` exception class in `handoff_manager.py`. `claim_session` now eager-loads `claimed_by_user` via `selectinload`, rejects different-user re-claims (idempotent for same-user double-clicks), and raises with `claimed_by_id` / `claimed_by_name` / `claimed_at`. The endpoint translates to HTTP 409 with structured `detail = {error: 'already_claimed', claimed_by_id, claimed_by_name, claimed_at}`. `AssistantChatPage.handleStartHere` extracts via `axios.isAxiosError`, formats `"Already claimed by {name} {time_ago}."` using the existing `timeAgo()` helper, drops `?pickup=true`, and dismisses the magic-moment so the loser flows back to the queue. Backed by 2 new unit tests (`test_claim_session_conflict_raises_already_claimed`, `test_claim_session_idempotent_for_same_user`).
- User then reported that the task-lane stale-flash bug was still happening despite the prior fix `8914391` — "every time we work on something that's related to this, when we go back to test we create a new session and then the task lane shows unrelated session data." The previous fix only covered mount-time entry paths (prefill + pickup); any in-place transition still flashed.
- Shipped structural fix in commit `665530f`. Introduced `taskLaneOwnerChatId` state that explicitly tags which chatId the in-memory `activeQuestions` / `activeActions` / `showTaskLane` values belong to. Set at every populate site (sendPrefill, selectChat, handleSend, handleTaskSubmit, handleResumeNew, refreshFacts, handleApplyFix). Cleared in `resetSessionDerivedState`. Persistence effect now writes `chatId: taskLaneOwnerChatId` (was `activeChatId` — that was the original write-side bug). Render gate `taskLaneIsForActiveChat = ownerChatId === activeChatId` ANDed into all three render conditions. The lane is structurally unable to display data tagged with a different chat. See DECISIONS entry. **Not yet verified in a real browser** — user is swapping computers and asked for the handoff first.
- The two commits `0f00ee5` and `665530f` are **local-only** at session end. The user did not explicitly authorize a push, so per the handoff rule the branch was left unpushed. First action on resume is `git push`.
- Tests: full handoff + escalation suite (`test_handoff_manager.py`, `test_session_handoffs_api.py`, `test_escalation_bus.py`, `test_flowpilot_analytics_escalations.py`) → 34 passed in 68.89s. Frontend `tsc -b` exit 0 after each commit.
- Files touched: `frontend/src/api/aiSessions.ts`, `frontend/src/components/flowpilot/EscalationQueue.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/types/ai-session.ts`, `backend/app/api/endpoints/session_handoffs.py`, `backend/app/services/handoff_manager.py`, `backend/tests/test_handoff_manager.py`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/DECISIONS.md`.
## 2026-04-27 22:30 EDT — Claude Code — Escalation Mode: unify /escalate through HandoffManager
- User pushed back on the dual-path proposal: "why would we want two different escalation methods? Should the new one just be the way we escalate regardless if we're using a PSA or not using a PSA?" Right answer. Unified everything through `HandoffManager`.