console: when an agent stops, say so and offer the way back
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) <noreply@anthropic.com>
This commit is contained in:
parent
5d146e9ffb
commit
93baba3fa1
@ -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
|
||||
|
||||
@ -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() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : stalled ? (
|
||||
<div className="doing doing--stalled">
|
||||
<span className="doing__stop" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{stage.doing} — nothing for {stalled.mins} minutes</strong>
|
||||
<span>
|
||||
{stage.by} has not come back. That is usually the model provider being
|
||||
slow or dropping a request, not a problem with this lead — the work so far
|
||||
is safe and nothing has been lost.
|
||||
{stalled.nudge
|
||||
? ` Running ${stalled.nudge.label.toLowerCase()} wakes ${stalled.nudge.by} to try again.`
|
||||
: ' There is no step to re-run from here; the actions below are the way on.'}
|
||||
</span>
|
||||
{stalled.nudge ? (
|
||||
<button
|
||||
type="button" className="doing__fix"
|
||||
onClick={() => setOpen(stalled.nudge.uid)}
|
||||
>
|
||||
{stalled.nudge.label}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : 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
|
||||
|
||||
@ -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); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user