Explain blocked schedule runs and let a failed document be processed again

Schedule history now shows a readable reason instead of the engine's raw code,
and under the newest run stopped by knowledge_not_ready it lists the Library
material that blocks it, with a link to fix it in Learning Studio. The list
refreshes while that material is still processing or building.

Upload gets a "Process again" button for a failed document, using the engine's
new reprocess endpoint; a knowledge base the document already has is kept. A
document being processed again no longer shows as "In the Brain".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-28 15:13:03 +07:00
parent b63e9c2ca8
commit d1492b9c76
6 changed files with 159 additions and 13 deletions

View File

@@ -79,6 +79,8 @@ export interface KnowledgeDetail {
export const knowledgeDetail = (id: number) => request<KnowledgeDetail>(`/upload/${id}/knowledge`)
// Agree to the quoted price, build after a top-up, or retry a failed build. Spends workspace credits (402 when short).
// Reads a failed document's stored file again, for free. A knowledge base it already has is kept.
export const reprocessDocument = (id: number) => request<{ id: number; status: string }>(`/upload/${id}/reprocess`, { method: 'POST' })
export const buildKnowledge = (id: number) => request<{ status: string }>(`/upload/${id}/knowledge/build`, { method: 'POST' })
// The engine asks whether an upload is the next version of a similar document before building it.

View File

@@ -355,8 +355,22 @@ export interface RunSummary {
trigger_kind: string
safe_error_code: string | null
budget_spent: number | null
has_readiness_issues: boolean
created_at: string
finished_at: string | null
}
export const listRuns = (id: number) => request<RunSummary[]>(`${AGENTS}/${id}/runs`)
// What stops a run with knowledge_not_ready. The engine checks the whole Library, so a blocking
// document need not be part of the schedule's chosen material. This is the current state, not the run's.
export interface ReadinessIssue {
kind: 'document' | 'compose_set'
resource_id: number
title: string
reason: string
action_url: string
}
export const knowledgeReadiness = (id: number) =>
request<{ ready: boolean; issues: ReadinessIssue[] }>(`${AGENTS}/${id}/knowledge-readiness`)

View File

@@ -126,7 +126,11 @@ const en = {
'brain.needsCreditsNeed': ' (needs {credits})',
'brain.failed': 'Could not add this to the Brain.',
'brain.retryWith': 'Try again ({credits})',
'brain.failedAfterBuild': 'Processing this file failed after its knowledge was built. Schedules that use the whole Brain stop until it is removed: delete it and upload it again.',
'brain.failedAfterBuild': 'Processing this file failed after its knowledge was built. Schedules that use the whole Brain stop until it is fixed. Process it again: its knowledge is kept.',
'brain.failedProcessing': 'This file could not be processed. Schedules that use the whole Brain stop until it is fixed. Process it again, or delete it.',
'brain.failedVideoAfterBuild': 'Processing this video failed after its knowledge was built. Schedules that use the whole Brain stop until it is fixed: upload it again.',
'brain.reprocess': 'Process again',
'brain.reprocessing': 'Starting…',
'brain.confirmCopy': 'Adding this document to the Brain uses {credits} from the workspace.',
'brain.confirmCreditsFallback': 'credits',
'brain.addToBrain': 'Add to Brain',
@@ -244,6 +248,33 @@ const en = {
'sch.scheduled': 'Scheduled',
'sch.manual': 'Manual',
'run.queued': 'Queued', 'run.planning': 'Planning', 'run.running': 'Running', 'run.completed': 'Completed', 'run.failed': 'Failed', 'run.cancelled': 'Cancelled',
'run.paused_configuration': 'Needs attention', 'run.paused_budget': 'Paused: credits',
'runErr.knowledge_not_ready': 'Not all material in the Library is ready, so the engine will not start.',
'runErr.video_sources_unavailable': 'No ready material matched this schedule for a video.',
'runErr.material_sources_unavailable': 'No ready material matched this schedule.',
'runErr.knowledge_search_failed': 'Searching the material failed. Try again.',
'runErr.organization_wallet': 'The organization does not have enough credits.',
'runErr.run_cap': 'This run would exceed the per-run credit limit.',
'runErr.monthly_cap': 'The monthly credit limit for this schedule is reached.',
'runErr.reservation_cap': 'Too many credits are already reserved by other runs.',
'runErr.price_changed': 'The price changed. Try again.',
'runErr.video_price_changed': 'The video price changed. Try again.',
'runErr.agent_not_active': 'The schedule is paused or deleted.',
'runErr.run_access_revoked': 'You no longer have permission to run this schedule.',
'runErr.required_tool_unavailable': 'A tool this schedule needs is turned off.',
'sch.blockedBy': 'Blocking material:',
'sch.blockedNone': 'All material is ready now. Try again.',
'sch.fixInStudio': 'Open in Learning Studio',
'issue.document_processing': 'still processing',
'issue.document_failed': 'failed to process; reprocess or delete it',
'issue.graph_building': 'knowledge graph still building',
'issue.graph_not_ready': 'knowledge graph not prepared yet',
'issue.legacy_set_needs_conversion': 'old Compose set; convert it',
'issue.no_knowledge_sources': 'Compose set has no sources',
'issue.source_not_accessible': 'Compose set uses a document you cannot access',
'issue.source_graph_not_ready': 'a source document is not ready',
'issue.source_version_changed': 'a source document changed; refresh the set',
'issue.snapshot_not_ready': 'Compose graph not prepared yet',
// Results
'res.title': 'Results',
@@ -584,7 +615,11 @@ const id: Record<Key, string> = {
'brain.needsCreditsNeed': ' (butuh {credits})',
'brain.failed': 'Gagal memasukkan ke Otak.',
'brain.retryWith': 'Coba lagi ({credits})',
'brain.failedAfterBuild': 'Pemrosesan file ini gagal setelah pengetahuannya dibangun. Jadwal yang memakai seluruh Otak berhenti sampai file ini dihapus: hapus lalu unggah ulang.',
'brain.failedAfterBuild': 'Pemrosesan file ini gagal setelah pengetahuannya dibangun. Jadwal yang memakai seluruh Otak berhenti sampai ini diperbaiki. Proses ulang: pengetahuannya tetap disimpan.',
'brain.failedProcessing': 'File ini gagal diproses. Jadwal yang memakai seluruh Otak berhenti sampai ini diperbaiki. Proses ulang, atau hapus.',
'brain.failedVideoAfterBuild': 'Pemrosesan video ini gagal setelah pengetahuannya dibangun. Jadwal yang memakai seluruh Otak berhenti sampai ini diperbaiki: unggah ulang.',
'brain.reprocess': 'Proses ulang',
'brain.reprocessing': 'Memulai…',
'brain.confirmCopy': 'Memasukkan dokumen ini ke Otak memakai {credits} dari ruang kerja.',
'brain.confirmCreditsFallback': 'kredit',
'brain.addToBrain': 'Masukkan ke Otak',
@@ -699,6 +734,33 @@ const id: Record<Key, string> = {
'sch.scheduled': 'Terjadwal',
'sch.manual': 'Manual',
'run.queued': 'Antre', 'run.planning': 'Merencanakan', 'run.running': 'Berjalan', 'run.completed': 'Selesai', 'run.failed': 'Gagal', 'run.cancelled': 'Dibatalkan',
'run.paused_configuration': 'Perlu tindakan', 'run.paused_budget': 'Tertahan: kredit',
'runErr.knowledge_not_ready': 'Belum semua materi di Library siap, jadi engine tidak mau mulai.',
'runErr.video_sources_unavailable': 'Tidak ada materi siap yang cocok untuk video jadwal ini.',
'runErr.material_sources_unavailable': 'Tidak ada materi siap yang cocok dengan jadwal ini.',
'runErr.knowledge_search_failed': 'Pencarian materi gagal. Coba lagi.',
'runErr.organization_wallet': 'Kredit organisasi tidak cukup.',
'runErr.run_cap': 'Run ini melebihi batas kredit per run.',
'runErr.monthly_cap': 'Batas kredit bulanan jadwal ini sudah tercapai.',
'runErr.reservation_cap': 'Terlalu banyak kredit sudah dipesan oleh run lain.',
'runErr.price_changed': 'Harga berubah. Coba lagi.',
'runErr.video_price_changed': 'Harga video berubah. Coba lagi.',
'runErr.agent_not_active': 'Jadwal sedang dijeda atau sudah dihapus.',
'runErr.run_access_revoked': 'Kamu tidak lagi punya izin menjalankan jadwal ini.',
'runErr.required_tool_unavailable': 'Alat yang dibutuhkan jadwal ini sedang nonaktif.',
'sch.blockedBy': 'Materi yang menahan:',
'sch.blockedNone': 'Semua materi sudah siap. Coba lagi.',
'sch.fixInStudio': 'Buka di Learning Studio',
'issue.document_processing': 'masih diproses',
'issue.document_failed': 'gagal diproses; proses ulang atau hapus',
'issue.graph_building': 'knowledge graph masih dibangun',
'issue.graph_not_ready': 'knowledge graph belum disiapkan',
'issue.legacy_set_needs_conversion': 'set Compose lama; perlu dikonversi',
'issue.no_knowledge_sources': 'set Compose tidak punya sumber',
'issue.source_not_accessible': 'set Compose memakai dokumen yang tidak bisa kamu akses',
'issue.source_graph_not_ready': 'salah satu dokumen sumber belum siap',
'issue.source_version_changed': 'dokumen sumber berubah; perbarui set-nya',
'issue.snapshot_not_ready': 'graph Compose belum disiapkan',
'res.title': 'Hasil',
'res.description': 'Semua konten yang dihasilkan Express: teks, kuis, roleplay, video, audio, dan SCORM.',

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument,
answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, reprocessDocument, supportedExtensions, uploadDocument,
type Source,
} from '../api/engine'
import { Icon } from '../components/Icon'
@@ -15,8 +15,10 @@ type Phase = 'ready' | 'working' | 'answer' | 'failed'
function phaseOf(s: Source): Phase {
// A failed document blocks every whole-Brain schedule in the engine, even with a built knowledge base.
if (s.status === 'failed' || s.knowledge === 'failed') return 'failed'
// A reprocessed document keeps its built knowledge while it is read again, and still blocks schedules.
if (s.status !== 'completed') return 'working'
if (s.knowledge === 'ready') return 'ready'
if (s.knowledge === 'building' || s.status !== 'completed') return 'working'
if (s.knowledge === 'building') return 'working'
return NEEDS_ANSWER.has(s.knowledge ?? '') ? 'answer' : 'working'
}
@@ -298,8 +300,26 @@ function KnowledgeAction({ doc }: { doc: Source }) {
const refresh = () => queryClient.invalidateQueries({ queryKey: ['documents'] })
const build = useMutation({ mutationFn: () => buildKnowledge(doc.docId), onSuccess: refresh })
const version = useMutation({ mutationFn: (link: boolean) => answerVersion(doc.docId, link), onSuccess: refresh })
const error = build.error ?? version.error
const busy = build.isPending || version.isPending
const reprocess = useMutation({ mutationFn: () => reprocessDocument(doc.docId), onSuccess: refresh })
const error = build.error ?? version.error ?? reprocess.error
const busy = build.isPending || version.isPending || reprocess.isPending
// A failed file is read again, free; a knowledge base it already has is kept, so Compose sets
// that pin it (and block deleting it) keep working. Video goes back through upload instead.
if (doc.status === 'failed' && (doc.knowledge === 'ready' || doc.knowledge === 'not_built' || doc.knowledge === null)
&& doc.format.toLowerCase() !== 'mp4') {
return (
<div className="doc-action">
<small>{t(doc.knowledge === 'ready' ? 'brain.failedAfterBuild' : 'brain.failedProcessing')}</small>
<div className="doc-action-buttons">
<button className="btn small primary" disabled={busy} onClick={() => reprocess.mutate()}>
{reprocess.isPending ? t('brain.reprocessing') : t('brain.reprocess')}
</button>
</div>
{error && <span className="error">{error.message}</span>}
</div>
)
}
const d = detail.data
if (detail.isPending) return <div className="doc-action"><SkeletonLines label={t('common.loading')} /></div>
@@ -323,8 +343,8 @@ function KnowledgeAction({ doc }: { doc: Source }) {
actions = <button className="btn small" disabled={busy} onClick={() => build.mutate()}>{t('common.retry')}</button>
break
case 'ready':
// Processing failed after the build: building again is refused, only removing it unblocks schedules.
text = t('brain.failedAfterBuild')
// Processing failed after the build. Documents are reprocessed above; this is a video.
text = t('brain.failedVideoAfterBuild')
break
case 'failed':
text = d.error || t('brain.failed')

View File

@@ -7,16 +7,31 @@ import { toast } from '../components/Toast'
import { describeRule } from '../express/parse'
import { locale, t, type Key } from '../i18n'
import {
archiveSchedule, confirmProduction, isRunSchedule, listRuns, listSchedules, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
planProgress, runNow as startRun, updateTopic, type ContentPlan, type Schedule,
archiveSchedule, confirmProduction, isRunSchedule, knowledgeReadiness, listRuns, listSchedules, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
planProgress, runNow as startRun, updateTopic, type ContentPlan, type RunSummary, type Schedule,
} from '../express/schedules'
const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
const fmt = (iso: string | null | undefined) => (iso
? new Intl.DateTimeFormat(locale(), { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
: '–')
const RUN_STATUSES = new Set(['queued', 'planning', 'running', 'completed', 'failed', 'cancelled'])
const RUN_STATUSES = new Set(['queued', 'planning', 'running', 'completed', 'failed', 'cancelled', 'paused_configuration', 'paused_budget'])
const runStatus = (s: string) => (RUN_STATUSES.has(s) ? t(`run.${s}` as Key) : s)
const RUN_ERRORS = new Set([
'knowledge_not_ready', 'video_sources_unavailable', 'material_sources_unavailable', 'knowledge_search_failed',
'organization_wallet', 'run_cap', 'monthly_cap', 'reservation_cap', 'price_changed', 'video_price_changed',
'agent_not_active', 'run_access_revoked', 'required_tool_unavailable',
])
const ISSUE_REASONS = new Set([
'document_processing', 'document_failed', 'graph_building', 'graph_not_ready', 'legacy_set_needs_conversion',
'no_knowledge_sources', 'source_not_accessible', 'source_graph_not_ready', 'source_version_changed', 'snapshot_not_ready',
])
const WAITING_REASONS = new Set(['document_processing', 'graph_building', 'snapshot_not_ready'])
const runError = (code: string) => (RUN_ERRORS.has(code) ? t(`runErr.${code}` as Key) : code)
const issueReason = (reason: string) => (ISSUE_REASONS.has(reason) ? t(`issue.${reason}` as Key) : reason)
const isNotReady = (r: RunSummary) => r.safe_error_code === 'knowledge_not_ready' || r.has_readiness_issues
export function SchedulePage() {
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
@@ -206,17 +221,46 @@ function RunHistory({ agentId }: { agentId: number }) {
if (runs.isPending) return <div className="run-history"><SkeletonLines count={3} label={t('sch.loadingHistory')} /></div>
if (runs.isError) return <div className="run-history"><span className="error">{runs.error.message}</span></div>
if (!runs.data.length) return <div className="run-history"><small>{t('sch.neverRan')}</small></div>
const shown = runs.data.slice(0, 8)
// Readiness is today's state, so it is shown once, under the newest run it stopped.
const blockedId = shown.find(isNotReady)?.id
return (
<ul className="run-history">
{runs.data.slice(0, 8).map(r => (
{shown.map(r => (
<li key={r.id}>
<span>{fmt(r.created_at)}</span>
<span>{r.trigger_kind === 'scheduled' ? t('sch.scheduled') : t('sch.manual')}</span>
<span className={`status ${r.status === 'completed' ? 'ready' : 'draft'}`}><i className="dot"></i>{runStatus(r.status)}</span>
{r.budget_spent != null && <span>{t('common.credits', { n: r.budget_spent })}</span>}
{r.safe_error_code && <span className="error">{r.safe_error_code}</span>}
{r.safe_error_code && <span className="error" title={r.safe_error_code}>{runError(r.safe_error_code)}</span>}
{r.id === blockedId && <ReadinessIssues agentId={agentId} />}
</li>
))}
</ul>
)
}
function ReadinessIssues({ agentId }: { agentId: number }) {
const readiness = useQuery({
queryKey: ['readiness', agentId],
queryFn: () => knowledgeReadiness(agentId),
// Processing and building finish on their own; keep the list current until they do.
refetchInterval: q => (q.state.data?.issues.some(i => WAITING_REASONS.has(i.reason)) ? 5000 : false),
})
if (readiness.isPending) return <div className="run-issues"><SkeletonLines count={1} label={t('sch.loadingHistory')} /></div>
if (readiness.isError) return <div className="run-issues"><span className="error">{readiness.error.message}</span></div>
if (!readiness.data.issues.length) return <div className="run-issues"><small>{t('sch.blockedNone')}</small></div>
return (
<div className="run-issues">
<strong>{t('sch.blockedBy')}</strong>
<ul>
{readiness.data.issues.map(issue => (
<li key={`${issue.kind}-${issue.resource_id}`}>
<span><b>{issue.title}</b> · {issueReason(issue.reason)}</span>
<a href={`${STUDIO_URL}${issue.action_url}`} target="_blank" rel="noreferrer">{t('sch.fixInStudio')}</a>
</li>
))}
</ul>
</div>
)
}

View File

@@ -49,6 +49,10 @@ textarea{resize:vertical;line-height:1.6}
.run-history li{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
.run-history .status{margin-left:0}
.run-history .error{margin:0;font-size:11px}
.run-issues{flex-basis:100%;padding:8px 10px;border-radius:8px;background:#fff7ed;color:#7a4b1f;display:grid;gap:4px}
.run-issues ul{list-style:none;margin:0;padding:0;display:grid;gap:4px}
.run-issues li{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
.run-issues a{color:var(--blue);font-weight:600}
@media(max-width:700px){.schedule-actions,.run-history{padding-left:0;margin-left:0}.rule-approve{flex-direction:column;align-items:stretch}}
.lang-switch{display:inline-flex;border:1px solid var(--line);border-radius:7px;overflow:hidden;background:white}
.lang-switch button{border:0;background:transparent;padding:5px 9px;font-size:10px;font-weight:650;color:#8b97a9}