Report queued tasks the scheduling pass never considers

A task in retry backoff was filtered out before the candidate loop, so it
recorded no rejection at all: queued, apparently assignable, and silent. That is
the exact shape that made F5 take a live session to diagnose. It now reports
"retry backoff until <time>".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 23:42:26 +04:00
parent 0ead6d2d02
commit 4fbf3ac966
18 changed files with 1163 additions and 165 deletions
+13 -2
View File
@@ -17,9 +17,20 @@ describe('UI API client',()=>{
expect(fetch).toHaveBeenCalledWith('/v1/artifacts',expect.objectContaining({method:'POST',body:'report'}))
})
it('submits username and password to the browser login endpoint',async()=>{
const fetch=vi.fn().mockResolvedValue(new Response(null,{status:204}))
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'operator'}),{status:200}))
vi.stubGlobal('fetch',fetch)
await api.login('operator','not stored in the browser')
await expect(api.login('operator','not stored in the browser')).resolves.toEqual({username:'operator'})
expect(fetch).toHaveBeenCalledWith('/v1/ui/session',expect.objectContaining({method:'POST',body:JSON.stringify({username:'operator',password:'not stored in the browser'})}))
})
it('treats a missing browser session as a normal signed-out state',async()=>{
const fetch=vi.fn().mockResolvedValue(new Response('unauthorized',{status:401}))
vi.stubGlobal('fetch',fetch)
await expect(api.session()).resolves.toBeUndefined()
})
it('updates account credentials through the session-gated account endpoint',async()=>{
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'kami'}),{status:200}))
vi.stubGlobal('fetch',fetch)
await api.updateAccount({current_password:'old password',username:'kami',new_password:'new password'})
expect(fetch).toHaveBeenCalledWith('/v1/ui/account',expect.objectContaining({method:'PUT'}))
})
})
+16 -1
View File
@@ -1,4 +1,4 @@
import type { CreatedEvent, Detail, Overview } from './types'
import type { Account, CreatedEvent, Detail, Overview } from './types'
function sessionExpired(response: Response) {
if (response.status === 401) {
@@ -49,6 +49,13 @@ async function upload(body: string) {
return ((await response.json()) as { ref: string }).ref
}
async function session(): Promise<Account | undefined> {
const response = await fetch('/v1/ui/session', { credentials: 'same-origin' })
if (response.status === 401) return undefined
if (!response.ok) throw await responseError(response)
return response.json() as Promise<Account>
}
async function login(username: string, password: string) {
const response = await fetch('/v1/ui/session', {
method: 'POST',
@@ -57,6 +64,7 @@ async function login(username: string, password: string) {
body: JSON.stringify({ username, password }),
})
if (!response.ok) throw await responseError(response)
return response.json() as Promise<Account>
}
async function logout() {
@@ -68,8 +76,15 @@ async function logout() {
}
export const api = {
session,
login,
logout,
account: () => request<Account>('/v1/ui/account'),
updateAccount: (body: { current_password: string; username: string; new_password?: string }) =>
request<Account>('/v1/ui/account', {
method: 'PUT',
body: JSON.stringify(body),
}),
overview: () => request<Overview>('/v1/ui/overview'),
detail: (id: string) => request<Detail>(`/v1/ui/tasks/${id}`),
artifact: (ref: string) => text(`/v1/ui/artifacts/${ref}`),
+17 -1
View File
@@ -1,4 +1,11 @@
export type TaskState = 'queued' | 'leased' | 'blocked' | 'completed' | 'failed'
export type TaskState =
| 'queued'
| 'leased'
| 'needs_attention'
| 'blocked'
| 'in_review'
| 'completed'
| 'failed'
export type BlockReason =
| 'lease_failure'
@@ -8,8 +15,17 @@ export type BlockReason =
| 'handoff_validation'
| 'operator_block'
| 'system_error'
| 'trajectory_gate'
| 'human_decision'
| 'operator_required'
| 'unknown'
export interface Account {
username: string
created_at: string
updated_at: string
}
export interface SessionEvidence {
pane_id?: string
harness_id?: string
+160 -11
View File
@@ -19,6 +19,7 @@ import {
} from '@tanstack/react-query'
import { api } from './api/client'
import type {
Account,
Action,
BlockReason,
Capture,
@@ -38,14 +39,16 @@ const client = new QueryClient({
},
})
const allStates: TaskState[] = ['queued', 'leased', 'blocked', 'completed', 'failed']
const activeStates: TaskState[] = ['queued', 'leased', 'blocked']
const allStates: TaskState[] = ['queued', 'leased', 'needs_attention', 'blocked', 'in_review', 'completed', 'failed']
const activeStates: TaskState[] = ['queued', 'leased', 'needs_attention', 'blocked', 'in_review']
const historyStates: TaskState[] = ['completed', 'failed']
const stateLabel: Record<TaskState, string> = {
queued: 'Queued',
leased: 'In session',
needs_attention: 'Needs attention',
blocked: 'Blocked',
in_review: 'In review',
completed: 'Complete',
failed: 'Failed',
}
@@ -58,6 +61,9 @@ const blockLabel: Record<BlockReason, string> = {
handoff_validation: 'Handoff validation',
operator_block: 'Operator block',
system_error: 'System error',
trajectory_gate: 'Plan approval',
human_decision: 'Decision needed',
operator_required: 'Operator required',
unknown: 'Unknown',
}
@@ -94,6 +100,8 @@ type IconName =
| 'refresh'
| 'search'
| 'server'
| 'settings'
| 'shield'
| 'terminal'
| 'users'
| 'x'
@@ -161,6 +169,13 @@ function Icon({ name, size = 18 }: { name: IconName; size?: number }) {
<path d="M7 7h.01M7 17h.01" />
</>
),
settings: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
</>
),
shield: <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Zm-3-10 2 2 4-5" />,
terminal: (
<>
<path d="m4 6 5 5-5 5M11 18h9" />
@@ -240,6 +255,11 @@ function humanize(value: string) {
.replace(/^./, (letter) => letter.toUpperCase())
}
function initials(username: string) {
const parts = username.trim().split(/[\s._-]+/).filter(Boolean)
return (parts.length > 1 ? `${parts[0][0]}${parts[1][0]}` : username.slice(0, 2)).toUpperCase()
}
function sessionFor(task: Task, overview: Overview) {
const sessions = overview.sessions ?? []
const captured = sessions.find((session) => session.capture?.task_id === task.id)
@@ -264,9 +284,13 @@ function taskExplanation(task: Task, overview: Overview) {
if (session?.blocker) return session.blocker
return session?.capture ? 'Harness is publishing live output' : 'Leased · capture unavailable'
}
if (task.state === 'needs_attention') {
return task.blocker || 'The current lease is retained while an operator investigates'
}
if (task.state === 'blocked') {
return task.blocker || 'No blocker detail was retained for this task'
}
if (task.state === 'in_review') return 'Submitted change is waiting for human review'
if (task.state === 'failed') return task.last_error || 'Review the failure before retrying'
return 'Work and completion evidence retained'
}
@@ -320,6 +344,7 @@ function CommandPalette({ close }: { close: () => void }) {
['board', 'Open dispatch board', '', 'grid'],
['new', 'Create a new task', 'N', 'plus'],
['workers', 'Open worker pool', '', 'server'],
['settings', 'Open account settings', '', 'settings'],
['refresh', 'Refresh live data', 'R', 'refresh'],
] as const,
[],
@@ -339,6 +364,7 @@ function CommandPalette({ close }: { close: () => void }) {
const choose = (id: string) => {
if (id === 'board') navigate('/')
if (id === 'workers') navigate('/workers')
if (id === 'settings') navigate('/settings')
if (id === 'new') {
navigate('/')
window.setTimeout(() => window.dispatchEvent(new Event('orchestra:new-task')), 0)
@@ -405,7 +431,7 @@ function CommandPalette({ close }: { close: () => void }) {
)
}
function Shell({ children, onLogout }: { children: React.ReactNode; onLogout: () => void }) {
function Shell({ children, account, onLogout }: { children: React.ReactNode; account: Account; onLogout: () => void }) {
const location = useLocation()
const navigate = useNavigate()
const [palette, setPalette] = useState(false)
@@ -425,6 +451,8 @@ function Shell({ children, onLogout }: { children: React.ReactNode; onLogout: ()
? 'Dispatch board'
: location.pathname === '/workers'
? 'Worker pool'
: location.pathname === '/settings'
? 'Account settings'
: location.pathname.startsWith('/artifacts/')
? 'Evidence artifact'
: 'Task record'
@@ -473,6 +501,10 @@ function Shell({ children, onLogout }: { children: React.ReactNode; onLogout: ()
<span>Workers</span>
<b className="nav-count">{onlineWorkers}/{workers.length}</b>
</NavLink>
<NavLink to="/settings">
<Icon name="settings" />
<span>Settings</span>
</NavLink>
</nav>
<div className="sidebar-status">
<span className={onlineWorkers ? 'signal online' : 'signal'} />
@@ -506,13 +538,17 @@ function Shell({ children, onLogout }: { children: React.ReactNode; onLogout: ()
aria-expanded={accountOpen}
onClick={() => setAccountOpen((open) => !open)}
>
<span className="avatar">OP</span>
<span className="account-label">Operator</span>
<span className="avatar">{initials(account.username)}</span>
<span className="account-label">{account.username}</span>
</button>
{accountOpen && (
<div className="account-menu">
<span>Browser session active</span>
<button type="button" onClick={onLogout}>Sign out</button>
<div className="account-menu-user">
<span className="avatar">{initials(account.username)}</span>
<span><b>{account.username}</b><small>Operator account</small></span>
</div>
<Link to="/settings" onClick={() => setAccountOpen(false)}><Icon name="settings" size={15} /> Account settings</Link>
<button type="button" onClick={onLogout}><Icon name="arrow-left" size={15} /> Sign out</button>
</div>
)}
</div>
@@ -905,9 +941,9 @@ function OverviewPage() {
const projects = [...new Set(data.tasks.map((task) => task.project).filter(Boolean))].sort()
const pendingApprovals = sessions.filter((session) => session.pending_approval)
const approvalTask = pendingApprovals.find((session) => session.capture?.task_id)?.capture?.task_id
const inSession = data.tasks.filter((task) => task.state === 'leased').length
const inSession = data.tasks.filter((task) => task.state === 'leased' || task.state === 'needs_attention').length
const queued = data.tasks.filter((task) => task.state === 'queued').length
const attention = data.tasks.filter((task) => task.state === 'blocked' || task.state === 'failed').length
const attention = data.tasks.filter((task) => task.state === 'needs_attention' || task.state === 'blocked' || task.state === 'failed').length
const history = data.tasks.filter((task) => historyStates.includes(task.state)).length
const onlineWorkers = data.workers.filter((worker) => worker.online).length
const term = search.trim().toLowerCase()
@@ -1231,14 +1267,14 @@ function TaskDiagnosis({ detail }: { detail: Detail }) {
const observed = evidence?.captured_at || evidence?.checked_at || detail.session?.capture?.at
const title = approval
? 'Waiting for operator approval'
: detail.task.state === 'blocked'
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
? blockLabel[detail.task.block_reason || 'unknown']
: detail.task.state === 'leased'
? detail.session?.capture ? 'Agent session is active' : 'Session capture is unavailable'
: stateLabel[detail.task.state]
const explanation = approval
? 'The harness is paused at a permission boundary. Review the exact request below.'
: detail.task.state === 'blocked'
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
? detail.task.blocker || 'No blocker detail was retained.'
: detail.task.state === 'leased'
? detail.session?.capture
@@ -1246,6 +1282,8 @@ function TaskDiagnosis({ detail }: { detail: Detail }) {
: detail.session?.blocker || 'The lease exists, but Orchestra cannot read current pane output.'
: detail.task.state === 'queued'
? 'This task is eligible for routing when a compatible worker has capacity.'
: detail.task.state === 'in_review'
? 'The implementation was submitted and is waiting for the bound human review.'
: 'This is a terminal task record with retained evidence.'
return (
@@ -1542,6 +1580,117 @@ function Workers() {
)
}
function Settings({ account, onCredentialsChanged }: { account: Account; onCredentialsChanged: (username: string) => void }) {
const [username, setUsername] = useState(account.username)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmation, setConfirmation] = useState('')
const [visible, setVisible] = useState(false)
const [formError, setFormError] = useState('')
const mutation = useMutation({
mutationFn: () => api.updateAccount({
current_password: currentPassword,
username: username.trim(),
...(newPassword ? { new_password: newPassword } : {}),
}),
onSuccess: (updated) => onCredentialsChanged(updated.username),
})
const usernameChanged = username.trim() !== account.username
const changed = usernameChanged || !!newPassword
const passwordLongEnough = newPassword.length >= 10
const passwordWithinLimit = new TextEncoder().encode(newPassword).length <= 72
const passwordsMatch = newPassword === confirmation
const submit = (event: React.FormEvent) => {
event.preventDefault()
setFormError('')
if (!username.trim()) {
setFormError('Username cannot be empty.')
return
}
if (!changed) {
setFormError('Change the username or enter a new password first.')
return
}
if (!currentPassword) {
setFormError('Enter your current password to authorize this change.')
return
}
if (newPassword && (!passwordLongEnough || !passwordWithinLimit || !passwordsMatch)) {
setFormError(!passwordsMatch ? 'The new passwords do not match.' : 'Use a password between 10 and 72 bytes.')
return
}
mutation.mutate()
}
return (
<main className="page settings-page">
<header className="page-header">
<div>
<span className="eyebrow">Operator identity</span>
<h1>Your account.</h1>
<p>Change the credentials you use for this control plane. No environment hash is involved.</p>
</div>
</header>
<div className="settings-layout">
<aside className="profile-card">
<span className="profile-avatar">{initials(account.username)}</span>
<h2>{account.username}</h2>
<p>Full-control operator</p>
<dl>
<div><dt>Account created</dt><dd>{date(account.created_at)}</dd></div>
<div><dt>Credentials updated</dt><dd>{date(account.updated_at)}</dd></div>
</dl>
<div className="database-badge"><Icon name="shield" size={17} /><span><b>Local credential database</b><small>Password hashes stay inside Orchestras data volume.</small></span></div>
</aside>
<section className="settings-card">
<header>
<span className="settings-icon"><Icon name="settings" /></span>
<div><h2>Sign-in credentials</h2><p>Changing either field signs out every browser using this account.</p></div>
</header>
<form onSubmit={submit} noValidate>
<label htmlFor="account-username">Username</label>
<input id="account-username" autoComplete="username" value={username} onChange={(event) => { setUsername(event.target.value); setFormError('') }} />
<div className="settings-divider"><span>Optional password change</span></div>
<div className="form-row">
<label htmlFor="account-new-password">New password
<div className="password-field">
<input id="account-new-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={newPassword} onChange={(event) => { setNewPassword(event.target.value); setFormError('') }} placeholder="Leave blank to keep it" />
<button type="button" onClick={() => setVisible((value) => !value)}>{visible ? 'Hide' : 'Show'}</button>
</div>
</label>
<label htmlFor="account-confirm-password">Confirm new password
<input id="account-confirm-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={confirmation} onChange={(event) => { setConfirmation(event.target.value); setFormError('') }} placeholder="Repeat new password" />
</label>
</div>
{newPassword && (
<div className="password-rules" aria-live="polite">
<span className={passwordLongEnough ? 'met' : ''}><Icon name="check" size={13} /> 10+ characters</span>
<span className={passwordWithinLimit ? 'met' : ''}><Icon name="check" size={13} /> 72 bytes or fewer</span>
<span className={passwordsMatch && !!confirmation ? 'met' : ''}><Icon name="check" size={13} /> Passwords match</span>
</div>
)}
<div className="current-password-block">
<label htmlFor="account-current-password">Current password</label>
<p>Required to save account changes.</p>
<input id="account-current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => { setCurrentPassword(event.target.value); setFormError('') }} />
</div>
{(formError || mutation.error) && <p className="form-error" role="alert"><Icon name="alert" size={15} /> {formError || errorMessage(mutation.error)}</p>}
<footer className="settings-actions">
<span>Youll sign in again after saving.</span>
<button type="submit" disabled={mutation.isPending || !changed}>{mutation.isPending ? 'Saving…' : 'Save credentials'}</button>
</footer>
</form>
</section>
</div>
</main>
)
}
function Artifact() {
const { ref = '' } = useParams()
const query = useQuery({ queryKey: ['artifact', ref], queryFn: () => api.artifact(ref) })