feat(M13 PBI-31 T-519b/T-520b): NavBar stand-by badge + quota-check runbook (#119)
* feat(M13 T-519b): SSE worker_heartbeat + NavBar stand-by badge Aanvulling op scrum4me-mcp PR #25 (worker_heartbeat MCP-tool). - app/api/realtime/solo/route.ts: WorkerHeartbeatPayload type + isWorkerHeartbeatPayload guard + shouldEmit-routing op user_id. - stores/solo-store.ts: workerQuotaPct + workerQuotaCheckAt state + setWorkerQuota action. Reset bij decrementWorkers naar 0. - lib/realtime/use-solo-realtime.ts: handle worker_heartbeat-event, roep setWorkerQuota. - components/solo/nav-status-indicators.tsx: stand-by badge wanneer workerQuotaPct < minQuotaPct + tooltip met drempel. - components/shared/nav-bar.tsx + app/(app)/layout.tsx: minQuotaPct prop plumbing van User.min_quota_pct naar NavStatusIndicators. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(M13 T-520b): pre-flight quota-check sectie in mcp-integration Documenteert de batch-loop-uitbreiding: 1. get_worker_settings → min_quota_pct 2. bin/worker-quota-probe.sh → pct + reset 3. worker_heartbeat naar server (NavBar stand-by-badge) 4. Sleep tot reset bij low quota; anders wait_for_job Verwijst naar bin/worker-quota-probe.sh in scrum4me-docker (zie PR daar). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
555ed8fe89
commit
31dc429b61
8 changed files with 126 additions and 8 deletions
|
|
@ -16,7 +16,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||||
const [user, userRoles, accessibleProducts] = await Promise.all([
|
const [user, userRoles, accessibleProducts] = await Promise.all([
|
||||||
prisma.user.findUnique({
|
prisma.user.findUnique({
|
||||||
where: { id: session.userId },
|
where: { id: session.userId },
|
||||||
select: { username: true, email: true, active_product_id: true },
|
select: { username: true, email: true, active_product_id: true, min_quota_pct: true },
|
||||||
}),
|
}),
|
||||||
prisma.userRole.findMany({
|
prisma.userRole.findMany({
|
||||||
where: { user_id: session.userId },
|
where: { user_id: session.userId },
|
||||||
|
|
@ -72,6 +72,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||||
activeProduct={activeProduct}
|
activeProduct={activeProduct}
|
||||||
products={accessibleProducts}
|
products={accessibleProducts}
|
||||||
hasActiveSprint={hasActiveSprint}
|
hasActiveSprint={hasActiveSprint}
|
||||||
|
minQuotaPct={user.min_quota_pct}
|
||||||
/>
|
/>
|
||||||
<MinWidthBanner />
|
<MinWidthBanner />
|
||||||
<main id="main-content" className="flex-1 flex flex-col overflow-y-auto min-h-0">
|
<main id="main-content" className="flex-1 flex flex-col overflow-y-auto min-h-0">
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,17 @@ type WorkerPayload = {
|
||||||
product_id?: string
|
product_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type NotifyPayload = EntityPayload | JobPayload | WorkerPayload
|
// M13: per-iteration quota-rapport van de worker. Geen product-scope —
|
||||||
|
// elke heartbeat geldt voor alle producten waar deze user toegang toe heeft.
|
||||||
|
type WorkerHeartbeatPayload = {
|
||||||
|
type: 'worker_heartbeat'
|
||||||
|
user_id: string
|
||||||
|
token_id: string
|
||||||
|
last_quota_pct: number
|
||||||
|
last_quota_check_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotifyPayload = EntityPayload | JobPayload | WorkerPayload | WorkerHeartbeatPayload
|
||||||
|
|
||||||
function isJobPayload(p: NotifyPayload): p is JobPayload {
|
function isJobPayload(p: NotifyPayload): p is JobPayload {
|
||||||
return 'type' in p && (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status')
|
return 'type' in p && (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status')
|
||||||
|
|
@ -74,6 +84,10 @@ function isWorkerPayload(p: NotifyPayload): p is WorkerPayload {
|
||||||
return 'type' in p && (p.type === 'worker_connected' || p.type === 'worker_disconnected')
|
return 'type' in p && (p.type === 'worker_connected' || p.type === 'worker_disconnected')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWorkerHeartbeatPayload(p: NotifyPayload): p is WorkerHeartbeatPayload {
|
||||||
|
return 'type' in p && p.type === 'worker_heartbeat'
|
||||||
|
}
|
||||||
|
|
||||||
function shouldEmit(
|
function shouldEmit(
|
||||||
payload: NotifyPayload,
|
payload: NotifyPayload,
|
||||||
productId: string,
|
productId: string,
|
||||||
|
|
@ -90,6 +104,10 @@ function shouldEmit(
|
||||||
return payload.user_id === userId
|
return payload.user_id === userId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isWorkerHeartbeatPayload(payload)) {
|
||||||
|
return payload.user_id === userId
|
||||||
|
}
|
||||||
|
|
||||||
// M11 (ST-1104): question-events horen op /api/realtime/notifications, niet hier.
|
// M11 (ST-1104): question-events horen op /api/realtime/notifications, niet hier.
|
||||||
if (payload.entity === 'question') return false
|
if (payload.entity === 'question') return false
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ interface NavBarProps {
|
||||||
activeProduct: { id: string; name: string } | null
|
activeProduct: { id: string; name: string } | null
|
||||||
products: { id: string; name: string }[]
|
products: { id: string; name: string }[]
|
||||||
hasActiveSprint: boolean
|
hasActiveSprint: boolean
|
||||||
|
minQuotaPct: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavBar({
|
export function NavBar({
|
||||||
|
|
@ -41,6 +42,7 @@ export function NavBar({
|
||||||
activeProduct,
|
activeProduct,
|
||||||
products,
|
products,
|
||||||
hasActiveSprint,
|
hasActiveSprint,
|
||||||
|
minQuotaPct,
|
||||||
}: NavBarProps) {
|
}: NavBarProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -188,7 +190,7 @@ export function NavBar({
|
||||||
|
|
||||||
{/* Rechts: solo-status + notifications + account-menu */}
|
{/* Rechts: solo-status + notifications + account-menu */}
|
||||||
<div className="flex items-center gap-2 flex-1 justify-end">
|
<div className="flex items-center gap-2 flex-1 justify-end">
|
||||||
<SoloNavStatusIndicators hasActiveProduct={!!activeProduct} />
|
<SoloNavStatusIndicators hasActiveProduct={!!activeProduct} minQuotaPct={minQuotaPct} />
|
||||||
<NotificationsBell currentUserId={userId} isDemo={isDemo} />
|
<NotificationsBell currentUserId={userId} isDemo={isDemo} />
|
||||||
<UserMenu userId={userId} username={username} email={email} roles={roles} />
|
<UserMenu userId={userId} username={username} email={email} roles={roles} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -40,13 +40,28 @@ function RealtimeIndicator({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SoloNavStatusIndicators({ hasActiveProduct }: { hasActiveProduct: boolean }) {
|
export function SoloNavStatusIndicators({
|
||||||
|
hasActiveProduct,
|
||||||
|
minQuotaPct,
|
||||||
|
}: {
|
||||||
|
hasActiveProduct: boolean
|
||||||
|
minQuotaPct: number
|
||||||
|
}) {
|
||||||
const realtimeStatus = useSoloStore((s) => s.realtimeStatus)
|
const realtimeStatus = useSoloStore((s) => s.realtimeStatus)
|
||||||
const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator)
|
const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator)
|
||||||
const connectedWorkers = useSoloStore((s) => s.connectedWorkers)
|
const connectedWorkers = useSoloStore((s) => s.connectedWorkers)
|
||||||
|
const workerQuotaPct = useSoloStore((s) => s.workerQuotaPct)
|
||||||
|
|
||||||
if (!hasActiveProduct) return null
|
if (!hasActiveProduct) return null
|
||||||
|
|
||||||
|
// M13: stand-by als alle workers low quota hebben (workerQuotaPct geldt
|
||||||
|
// voor de laatste-rapporterende worker; bij N>1 workers is dit een
|
||||||
|
// benadering — server-side aggregate is een v2-verbetering).
|
||||||
|
const isStandby =
|
||||||
|
connectedWorkers > 0 &&
|
||||||
|
workerQuotaPct !== null &&
|
||||||
|
workerQuotaPct < minQuotaPct
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 px-2">
|
<div className="flex items-center gap-3 px-2">
|
||||||
<RealtimeIndicator
|
<RealtimeIndicator
|
||||||
|
|
@ -56,9 +71,28 @@ export function SoloNavStatusIndicators({ hasActiveProduct }: { hasActiveProduct
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'size-2 rounded-full',
|
'size-2 rounded-full',
|
||||||
connectedWorkers > 0 ? 'bg-status-done' : 'bg-muted-foreground/40'
|
isStandby
|
||||||
|
? 'bg-warning'
|
||||||
|
: connectedWorkers > 0
|
||||||
|
? 'bg-status-done'
|
||||||
|
: 'bg-muted-foreground/40'
|
||||||
)} />
|
)} />
|
||||||
{connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'}
|
{isStandby ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={<span>Stand-by ({workerQuotaPct}%)</span>}
|
||||||
|
/>
|
||||||
|
<TooltipContent>
|
||||||
|
Worker wacht tot Anthropic-quota stijgt boven {minQuotaPct}%
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : connectedWorkers > 0 ? (
|
||||||
|
'Agent verbonden'
|
||||||
|
) : (
|
||||||
|
'Geen agent'
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
# Documentation Index
|
# Documentation Index
|
||||||
|
|
||||||
Auto-generated on 2026-05-05 from front-matter and headings.
|
Auto-generated on 2026-05-06 from front-matter and headings.
|
||||||
|
|
||||||
## Architecture Decision Records
|
## Architecture Decision Records
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,43 @@ Wanneer je als agent draait (na een instructie als *"pak de volgende job uit de
|
||||||
|
|
||||||
Dit blijft gelden als je tussen jobs door commits, branches of pushes hebt gedaan — die afsluiting hoort bij de individuele job, niet bij het einde van de batch.
|
Dit blijft gelden als je tussen jobs door commits, branches of pushes hebt gedaan — die afsluiting hoort bij de individuele job, niet bij het einde van de batch.
|
||||||
|
|
||||||
|
## Pre-flight quota-check (M13)
|
||||||
|
|
||||||
|
Vóór elke `wait_for_job`-aanroep doet de worker een pre-flight quota-check
|
||||||
|
om te voorkomen dat-ie 600 s blokkeert terwijl Anthropic-quota toch op
|
||||||
|
0 staat. Loop:
|
||||||
|
|
||||||
|
1. `mcp__scrum4me__get_worker_settings()` → `{ min_quota_pct }`
|
||||||
|
2. `bash bin/worker-quota-probe.sh` → JSON `{ pct, reset_at_iso, ... }`
|
||||||
|
3. `mcp__scrum4me__worker_heartbeat({ last_quota_pct: pct, last_quota_check_at })`
|
||||||
|
— server emit een SSE-event zodat NavBar realtime de stand-by-badge
|
||||||
|
kan tonen
|
||||||
|
4. **Als `pct < min_quota_pct`**: log "stand-by, wachten tot
|
||||||
|
`reset_at_iso`", sleep tot reset (cap op 1 uur), spring naar stap 2
|
||||||
|
5. **Anders**: ga door met `wait_for_job`
|
||||||
|
|
||||||
|
Pseudo-bash:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QUOTA_JSON=$(/opt/agent/bin/worker-quota-probe.sh)
|
||||||
|
PCT=$(echo "$QUOTA_JSON" | jq -r '.pct')
|
||||||
|
RESET=$(echo "$QUOTA_JSON" | jq -r '.reset_at_iso')
|
||||||
|
|
||||||
|
# Stuur naar server (best-effort; failure niet-fataal)
|
||||||
|
mcp_call worker_heartbeat "{\"last_quota_pct\": $PCT}"
|
||||||
|
|
||||||
|
if [[ "$PCT" -lt "$MIN_PCT" ]]; then
|
||||||
|
log "stand-by until $RESET (pct=$PCT < min=$MIN_PCT)"
|
||||||
|
sleep_until "$RESET"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beperking**: de probe kost ~1 outputtoken per check. 12 checks/uur =
|
||||||
|
12 tokens/uur overhead — verwaarloosbaar. De `min_quota_pct`-setting
|
||||||
|
staat per default op 20% — bij vrije Pro/Max-plans typisch ruim genoeg
|
||||||
|
om dagelijks werk niet te verstoren.
|
||||||
|
|
||||||
**Code koppelen aan app**
|
**Code koppelen aan app**
|
||||||
- 'Pak de volgende job uit de Scrum4Me-queue' / 'draai de queue leeg' / 'batch agent' — Server-startup registreert een ClaudeWorker-record + heartbeat (5s); SIGTERM/SIGINT ruimt 'm op. UI in NavBar telt actieve workers via `last_seen_at < now() - 15s`.
|
- 'Pak de volgende job uit de Scrum4Me-queue' / 'draai de queue leeg' / 'batch agent' — Server-startup registreert een ClaudeWorker-record + heartbeat (5s); SIGTERM/SIGINT ruimt 'm op. UI in NavBar telt actieve workers via `last_seen_at < now() - 15s`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ export function useSoloRealtime(productId: string | null) {
|
||||||
const setWorkers = useSoloStore.getState().setWorkers
|
const setWorkers = useSoloStore.getState().setWorkers
|
||||||
const incrementWorkers = useSoloStore.getState().incrementWorkers
|
const incrementWorkers = useSoloStore.getState().incrementWorkers
|
||||||
const decrementWorkers = useSoloStore.getState().decrementWorkers
|
const decrementWorkers = useSoloStore.getState().decrementWorkers
|
||||||
|
const setWorkerQuota = useSoloStore.getState().setWorkerQuota
|
||||||
|
|
||||||
if (!productId) {
|
if (!productId) {
|
||||||
// Geen actief product (gebruiker zit niet op /solo) — stream uit
|
// Geen actief product (gebruiker zit niet op /solo) — stream uit
|
||||||
|
|
@ -119,6 +120,15 @@ export function useSoloRealtime(productId: string | null) {
|
||||||
}
|
}
|
||||||
if (raw.type === 'worker_connected') { incrementWorkers(); return }
|
if (raw.type === 'worker_connected') { incrementWorkers(); return }
|
||||||
if (raw.type === 'worker_disconnected') { decrementWorkers(); return }
|
if (raw.type === 'worker_disconnected') { decrementWorkers(); return }
|
||||||
|
if (raw.type === 'worker_heartbeat') {
|
||||||
|
const hb = raw as {
|
||||||
|
type: 'worker_heartbeat'
|
||||||
|
last_quota_pct: number
|
||||||
|
last_quota_check_at: string
|
||||||
|
}
|
||||||
|
setWorkerQuota(hb.last_quota_pct, hb.last_quota_check_at)
|
||||||
|
return
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const payload = raw as RealtimeEvent
|
const payload = raw as RealtimeEvent
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,11 @@ interface SoloStore {
|
||||||
claudeJobsByTaskId: Record<string, JobState>
|
claudeJobsByTaskId: Record<string, JobState>
|
||||||
connectedWorkers: number
|
connectedWorkers: number
|
||||||
|
|
||||||
|
// M13: laatste quota-rapport van een actieve worker. null = geen
|
||||||
|
// worker actief of nog geen heartbeat met quota ontvangen.
|
||||||
|
workerQuotaPct: number | null
|
||||||
|
workerQuotaCheckAt: string | null
|
||||||
|
|
||||||
initTasks: (tasks: SoloTask[]) => void
|
initTasks: (tasks: SoloTask[]) => void
|
||||||
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
|
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
|
||||||
rollback: (taskId: string, prevStatus: TaskStatus) => void
|
rollback: (taskId: string, prevStatus: TaskStatus) => void
|
||||||
|
|
@ -82,6 +87,7 @@ interface SoloStore {
|
||||||
setWorkers: (count: number) => void
|
setWorkers: (count: number) => void
|
||||||
incrementWorkers: () => void
|
incrementWorkers: () => void
|
||||||
decrementWorkers: () => void
|
decrementWorkers: () => void
|
||||||
|
setWorkerQuota: (pct: number, checkAt: string) => void
|
||||||
|
|
||||||
handleRealtimeEvent: (event: RealtimeEvent) => void
|
handleRealtimeEvent: (event: RealtimeEvent) => void
|
||||||
}
|
}
|
||||||
|
|
@ -93,6 +99,8 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
|
||||||
showConnectingIndicator: false,
|
showConnectingIndicator: false,
|
||||||
claudeJobsByTaskId: {},
|
claudeJobsByTaskId: {},
|
||||||
connectedWorkers: 0,
|
connectedWorkers: 0,
|
||||||
|
workerQuotaPct: null,
|
||||||
|
workerQuotaCheckAt: null,
|
||||||
|
|
||||||
initTasks: (tasks) =>
|
initTasks: (tasks) =>
|
||||||
set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }),
|
set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }),
|
||||||
|
|
@ -145,7 +153,15 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
|
||||||
|
|
||||||
setWorkers: (count) => set({ connectedWorkers: Math.max(0, count) }),
|
setWorkers: (count) => set({ connectedWorkers: Math.max(0, count) }),
|
||||||
incrementWorkers: () => set(s => ({ connectedWorkers: s.connectedWorkers + 1 })),
|
incrementWorkers: () => set(s => ({ connectedWorkers: s.connectedWorkers + 1 })),
|
||||||
decrementWorkers: () => set(s => ({ connectedWorkers: Math.max(0, s.connectedWorkers - 1) })),
|
decrementWorkers: () =>
|
||||||
|
set((s) => ({
|
||||||
|
connectedWorkers: Math.max(0, s.connectedWorkers - 1),
|
||||||
|
// Reset quota-state als alle workers weg zijn — pct van een vertrokken
|
||||||
|
// worker is niet meer actueel.
|
||||||
|
workerQuotaPct: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaPct,
|
||||||
|
workerQuotaCheckAt: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaCheckAt,
|
||||||
|
})),
|
||||||
|
setWorkerQuota: (pct, checkAt) => set({ workerQuotaPct: pct, workerQuotaCheckAt: checkAt }),
|
||||||
|
|
||||||
handleJobEvent: (event) => {
|
handleJobEvent: (event) => {
|
||||||
const { job_id, task_id } = event
|
const { job_id, task_id } = event
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue