From 93baba3fa19ab2a35df9231c77cbf3887de66f6e Mon Sep 17 00:00:00 2001 From: Yashas Date: Tue, 8 Sep 2026 16:37:25 +0530 Subject: [PATCH] console: when an agent stops, say so and offer the way back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lead page told an operator an automated step "typically completes within two minutes; this view refreshes automatically" — and went on saying it indefinitely. Today a lead sat forty minutes under that sentence while the model provider returned 502s. Agents stall, and for reasons this app does not control: the provider slows from 6s a call to 90s or drops the request, a thinking budget runs out mid-sentence, a late webhook wakes the wrong employee. From the console every one of those looks identical — a lead that stops — so the screen now reports the only thing it can honestly know (nothing has happened for N minutes), says the work so far is safe, and offers the way out. THE WAY OUT IS NOT A RETRY BUTTON, because the platform has none and no agent can be woken directly. But every agent is woken BY AN ACTIVITY, so performing that activity again wakes it again. nudgeFor() holds that mapping, and inside Document Pending it picks by how far the chain actually got — three agents work that state in sequence and the one to restart is the one that did not finish. That matters most for the Advisor. It is the only AI step nobody may perform by hand — permitted to ai_advisor alone — so re-performing the activity that wakes it is the ONLY route back, and it is the step that failed twice today. Without this table an operator's only option was to wait or to call me. Five minutes before it says anything: an employee wake plus a slow model is legitimately three or four minutes, and a console that cries stall on a working lead is one nobody reads. The clock lives in state, ticking every 20s, rather than Date.now() in the render body — reading the wall clock while rendering is impure, and the counter would otherwise only move when something else happened to re-render the page. Co-Authored-By: Claude Opus 5 (1M context) --- src/api/config.js | 68 +++++++++++++++++++++++++++++++++++++++++ src/screens/Lead.jsx | 46 +++++++++++++++++++++++++++- src/screens/screens.css | 11 +++++++ 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/src/api/config.js b/src/api/config.js index 0be3b88..e97c960 100644 --- a/src/api/config.js +++ b/src/api/config.js @@ -420,3 +420,71 @@ export function blockedOn(lead) { } return null } + +/** + * WHEN AN AGENT STOPS, WHAT DOES A PERSON PRESS? + * + * Agents stall. Not often, but they do, and for reasons nothing in this app + * controls: the model provider slows to ninety seconds a call or returns a 502, + * a thinking budget runs out mid-sentence, a late webhook wakes the wrong + * employee. Watched from the console every one of those looks identical — + * a lead that simply stops — and the screen kept promising it would "complete + * within two minutes" indefinitely. + * + * There is no retry button in the platform, and none of these agents can be + * woken directly. But every one of them is woken BY AN ACTIVITY, so performing + * that activity again wakes it again. That is what this table holds: for a + * lead sitting in an automated step, the activity a person can perform to make + * the stalled agent run once more. + * + * `by` is who to expect to move afterwards, so the button says what will + * happen rather than just what it does. + * + * The choice within Document Pending depends on HOW FAR the chain got, because + * three agents work that state in sequence and the one to restart is the one + * that did not finish. + */ +export function nudgeFor(stage, lead) { + if (!stage || !lead) return null + + switch (stage.uid) { + case 'zk-state-new': + return { uid: 'zk-act-qualify', label: 'Run the check again', by: 'Intake' } + + case 'zk-state-qualified': + return { uid: 'zk-act-contact', label: 'Record the call outcome', by: 'Engage' } + + case 'zk-state-contacted': + return { uid: 'zk-act-request-docs', label: 'Ask for the documents again', by: 'Engage' } + + case 'zk-state-docs': { + // Engage captures, the Advisor recommends, the rating engine prices — + // in that order, each woken by the one before. + const line = lead.product_line === 'sme_package' ? 'zk-act-capture-sme' : 'zk-act-capture-motor' + if (!lead.motor_idv && !lead.sme_value_at_risk) { + return { uid: line, label: 'Capture the risk again', by: 'Engage' } + } + if (!lead.ai_recommended_cover) { + // The Advisor is the ONE step nobody may perform by hand — it is + // permitted to ai_advisor alone. Re-performing the activity that wakes + // it is the only way back, and it is the reason this table exists. + return { uid: line, label: 'Wake the Advisor', by: 'the Advisor' } + } + if (!lead.quoted_premium) { + return { uid: 'zk-act-quote', label: 'Build the quote again', by: 'the rating engine' } + } + return null + } + + case 'zk-state-issued': + return { uid: 'zk-act-onboard', label: 'Close the file', by: 'Engage' } + + default: + return null + } +} + +/** How long an automated step may sit before the console stops reassuring and + * starts offering the way out. Generous: an employee wake plus a slow model + * can legitimately take three or four minutes. */ +export const STALL_AFTER_MS = 5 * 60 * 1000 diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx index 3c8d9f4..56cce39 100644 --- a/src/screens/Lead.jsx +++ b/src/screens/Lead.jsx @@ -7,7 +7,7 @@ import AgentChip from '../components/AgentChip.jsx' import ClampText from '../components/ClampText.jsx' import Conversation from '../components/Conversation.jsx' import Timeline from '../components/Timeline.jsx' -import { APP_ID, DV_LEAD, PRODUCTS, STAGES, blockedOn, phaseOf } from '../api/config.js' +import { APP_ID, DV_LEAD, PRODUCTS, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js' import { AGENTS } from '../api/agents.js' import { actionsFor, rolesOf } from '../api/permissions.js' import { describeError } from '../api/errors.js' @@ -152,6 +152,11 @@ export default function Lead() { const [audit, setAudit] = useState(null) const [showChat, setShowChat] = useState(false) const [openAgent, setOpenAgent] = useState(null) + // A clock in state rather than Date.now() in the render body. Reading the + // wall clock while rendering is impure — React may render twice and get two + // answers — and it also means the "nothing for 6 minutes" counter would only + // move when something else happened to re-render the page. This ticks it. + const [now, setNow] = useState(() => Date.now()) const load = useCallback((quiet = false) => { if (!quiet) setErr(null) @@ -206,6 +211,11 @@ export default function Lead() { * moves while it is on screen. Poll quietly: no spinner, no skeleton, and not * at all while the tab is in the background. */ + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 20000) + return () => clearInterval(id) + }, []) + useEffect(() => { // Not while a form is open. A successful poll rewrites `actions`, and if the // lead has moved on the open form unmounts — taking whatever was typed into @@ -268,6 +278,17 @@ export default function Lead() { // the "in progress" strip below, which would otherwise keep promising that // something is happening for as long as the lead is left alone. const blocked = stage?.kind === 'auto' ? blockedOn(row) : null + // An automated step that has sat too long. Distinct from `blocked`, which is + // the workflow refusing for a stated reason — this is the agent not having + // come back, and the cause is usually outside this app entirely: a slow or + // failing model provider, a thinking budget that ran out mid-sentence, a + // wake that never arrived. The console cannot see any of that, so it reports + // the only thing it can know — nothing has happened for a while — and offers + // the way out rather than going on promising two minutes. + const stillFor = row.updated_at ? now - Date.parse(row.updated_at) : 0 + const stalled = !blocked && stage?.kind === 'auto' && stillFor > STALL_AFTER_MS + ? { mins: Math.round(stillFor / 60000), nudge: nudgeFor(stage, row) } + : null return ( @@ -398,6 +419,29 @@ export default function Lead() { + ) : stalled ? ( +
+
) : stage?.kind === 'customer' && stage.doing ? ( /* Not an agent working, and not a task of yours either — the lead is with someone outside the business. Without this the screen diff --git a/src/screens/screens.css b/src/screens/screens.css index f1abc67..11a45ff 100644 --- a/src/screens/screens.css +++ b/src/screens/screens.css @@ -1358,3 +1358,14 @@ color: var(--zk-muted); white-space: nowrap; } + +/* ---- an agent that has not come back ---- + Amber like `blocked`, because both need a person — but worded and coloured + apart from it: blocked means the workflow refused for a stated reason and + the fix is known; stalled means nobody knows, and the honest offer is to try + again. No pulse either way. */ +.doing--stalled { + border-color: var(--zk-amber-line); + background: var(--zk-amber-tint); +} +.doing--stalled strong { color: var(--zk-amber-ink); }