From 661601e833caa7bd6a6118787d2d9500d144f54e Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 6 May 2026 03:53:12 +0200 Subject: [PATCH] feat(ST-qfpqpxzy): SSE + NavBar stand-by badge voor worker quota-gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SSE route: WorkerHeartbeatPayload type + shouldEmit handler voor worker_heartbeat - solo-store: lowQuotaTokenIds Set + setWorkerLowQuota action - use-solo-realtime: worker_heartbeat event → setWorkerLowQuota; worker_disconnected verwijdert uit set - nav-status-indicators: stand-by badge (bg-warning) als alle workers low quota - docs/runbooks/mcp-integration.md: get_worker_settings + worker_heartbeat tools + pre-flight quota-check sectie Co-Authored-By: Claude Sonnet 4.6 --- app/api/realtime/solo/route.ts | 18 ++++++++- components/solo/nav-status-indicators.tsx | 34 ++++++++++++---- docs/INDEX.md | 2 +- docs/runbooks/mcp-integration.md | 47 +++++++++++++++++++++++ lib/realtime/use-solo-realtime.ts | 13 ++++++- stores/solo-store.ts | 10 +++++ 6 files changed, 114 insertions(+), 10 deletions(-) diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts index e514797..2babf2f 100644 --- a/app/api/realtime/solo/route.ts +++ b/app/api/realtime/solo/route.ts @@ -64,7 +64,15 @@ type WorkerPayload = { product_id?: string } -type NotifyPayload = EntityPayload | JobPayload | WorkerPayload +type WorkerHeartbeatPayload = { + type: 'worker_heartbeat' + user_id: string + token_id: string + last_quota_pct: number | null + is_low: boolean +} + +type NotifyPayload = EntityPayload | JobPayload | WorkerPayload | WorkerHeartbeatPayload function isJobPayload(p: NotifyPayload): p is JobPayload { return 'type' in p && (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') @@ -74,6 +82,10 @@ function isWorkerPayload(p: NotifyPayload): p is WorkerPayload { 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( payload: NotifyPayload, productId: string, @@ -90,6 +102,10 @@ function shouldEmit( 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. if (payload.entity === 'question') return false diff --git a/components/solo/nav-status-indicators.tsx b/components/solo/nav-status-indicators.tsx index e370540..8378659 100644 --- a/components/solo/nav-status-indicators.tsx +++ b/components/solo/nav-status-indicators.tsx @@ -44,22 +44,42 @@ export function SoloNavStatusIndicators({ hasActiveProduct }: { hasActiveProduct const realtimeStatus = useSoloStore((s) => s.realtimeStatus) const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator) const connectedWorkers = useSoloStore((s) => s.connectedWorkers) + const lowQuotaTokenIds = useSoloStore((s) => s.lowQuotaTokenIds) if (!hasActiveProduct) return null + const allStandby = connectedWorkers > 0 && lowQuotaTokenIds.size >= connectedWorkers + return (
-
- 0 ? 'bg-status-done' : 'bg-muted-foreground/40' - )} /> - {connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'} -
+ + + + }> + + {connectedWorkers === 0 + ? 'Geen agent' + : allStandby + ? 'Worker stand-by' + : 'Agent verbonden'} + + {allStandby && ( + Worker wacht op quota-reset + )} + +
) } diff --git a/docs/INDEX.md b/docs/INDEX.md index 9dd6afc..d156bcd 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -2,7 +2,7 @@ # 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 diff --git a/docs/runbooks/mcp-integration.md b/docs/runbooks/mcp-integration.md index 61e5f39..114e8f5 100644 --- a/docs/runbooks/mcp-integration.md +++ b/docs/runbooks/mcp-integration.md @@ -34,6 +34,10 @@ Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://g - `mcp__scrum4me__list_open_questions` — eigen vragen, max 50, recente eerst - `mcp__scrum4me__cancel_question` — asker-only annulering van een eigen open vraag +**Worker-instellingen + quota (M13):** +- `mcp__scrum4me__get_worker_settings` — geen args; retourneert `{ min_quota_pct }` afgeleid van het Bearer-token. Gebruik dit voor de pre-flight quota-check. +- `mcp__scrum4me__worker_heartbeat` — optionele args: `last_quota_pct` (0-100), `last_quota_check_at` (ISO-8601), `is_low` (boolean). Slaat quota-state op in `ClaudeWorker` en triggert een `worker_heartbeat` SSE-event zodat de NavBar een stand-by badge kan tonen. + **Job queue — agent worker mode (M13):** - `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED. **Sinds M12** retourneert de payload een `kind`-discriminator: - `kind: 'TASK_IMPLEMENTATION'` (default) — payload met `implementation_plan`, `story`, `pbi`, `sprint`, `repo_url` @@ -73,6 +77,49 @@ Dit blijft gelden als je tussen jobs door commits, branches of pushes hebt gedaa **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`. +## Pre-flight quota-check + +Vóór elke `wait_for_job` kan de worker controleren of er voldoende Claude-quota is. Stappenplan: + +1. Haal de drempel op: `get_worker_settings` → `min_quota_pct` (bijv. 20). +2. Probe de Claude API (rate-limit-headers): + +```bash +RESPONSE=$(curl -sI https://api.anthropic.com/v1/messages \ + -H "x-api-key: $ANTHROPIC_API_KEY" \ + -H "anthropic-version: 2023-06-01") + +REMAINING=$(echo "$RESPONSE" | grep -i "anthropic-ratelimit-tokens-remaining" | awk '{print $2}' | tr -d '\r') +LIMIT=$(echo "$RESPONSE" | grep -i "anthropic-ratelimit-tokens-limit" | awk '{print $2}' | tr -d '\r') +RESET_EPOCH=$(echo "$RESPONSE" | grep -i "anthropic-ratelimit-tokens-reset" | awk '{print $2}' | tr -d '\r') + +QUOTA_PCT=$(( REMAINING * 100 / LIMIT )) +``` + +3. Vergelijk met drempel. Bij low quota: rapporteer en slaap: + +```bash +if [ "$QUOTA_PCT" -lt "$MIN_QUOTA_PCT" ]; then + RESET_MS=$(date -d "@$RESET_EPOCH" +%s%3N 2>/dev/null || echo 0) + NOW_MS=$(date +%s%3N) + SLEEP_S=$(( (RESET_MS - NOW_MS) / 1000 + 5 )) + echo "Wachten tot quota-reset om $(date -d "@$RESET_EPOCH" '+%H:%M')" + sleep "$SLEEP_S" +fi +``` + +4. Meld de quota-state via `worker_heartbeat`: + +``` +worker_heartbeat( + last_quota_pct=15, + last_quota_check_at="2026-05-06T12:00:00Z", + is_low=true +) +``` + +5. Na de sleep: opnieuw controleren (stap 2) voordat `wait_for_job` wordt aangeroepen. + ## Prompt - `implement_next_story` (arg: `product_id`) — end-to-end workflow diff --git a/lib/realtime/use-solo-realtime.ts b/lib/realtime/use-solo-realtime.ts index 928dd80..105187c 100644 --- a/lib/realtime/use-solo-realtime.ts +++ b/lib/realtime/use-solo-realtime.ts @@ -40,6 +40,7 @@ export function useSoloRealtime(productId: string | null) { const setWorkers = useSoloStore.getState().setWorkers const incrementWorkers = useSoloStore.getState().incrementWorkers const decrementWorkers = useSoloStore.getState().decrementWorkers + const setWorkerLowQuota = useSoloStore.getState().setWorkerLowQuota if (!productId) { // Geen actief product (gebruiker zit niet op /solo) — stream uit @@ -118,7 +119,17 @@ export function useSoloRealtime(productId: string | null) { return } if (raw.type === 'worker_connected') { incrementWorkers(); return } - if (raw.type === 'worker_disconnected') { decrementWorkers(); return } + if (raw.type === 'worker_disconnected') { + const disconnected = raw as { type: string; token_id?: string } + if (disconnected.token_id) setWorkerLowQuota(disconnected.token_id, false) + decrementWorkers() + return + } + if (raw.type === 'worker_heartbeat') { + const hb = raw as { type: string; token_id: string; is_low: boolean } + setWorkerLowQuota(hb.token_id, hb.is_low) + return + } return } const payload = raw as RealtimeEvent diff --git a/stores/solo-store.ts b/stores/solo-store.ts index 592976e..377ef85 100644 --- a/stores/solo-store.ts +++ b/stores/solo-store.ts @@ -63,6 +63,7 @@ interface SoloStore { claudeJobsByTaskId: Record connectedWorkers: number + lowQuotaTokenIds: Set initTasks: (tasks: SoloTask[]) => void optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null @@ -82,6 +83,7 @@ interface SoloStore { setWorkers: (count: number) => void incrementWorkers: () => void decrementWorkers: () => void + setWorkerLowQuota: (tokenId: string, isLow: boolean) => void handleRealtimeEvent: (event: RealtimeEvent) => void } @@ -93,6 +95,7 @@ export const useSoloStore = create((set, get) => ({ showConnectingIndicator: false, claudeJobsByTaskId: {}, connectedWorkers: 0, + lowQuotaTokenIds: new Set(), initTasks: (tasks) => set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }), @@ -146,6 +149,13 @@ export const useSoloStore = create((set, get) => ({ setWorkers: (count) => set({ connectedWorkers: Math.max(0, count) }), incrementWorkers: () => set(s => ({ connectedWorkers: s.connectedWorkers + 1 })), decrementWorkers: () => set(s => ({ connectedWorkers: Math.max(0, s.connectedWorkers - 1) })), + setWorkerLowQuota: (tokenId, isLow) => + set(s => { + const next = new Set(s.lowQuotaTokenIds) + if (isLow) next.add(tokenId) + else next.delete(tokenId) + return { lowQuotaTokenIds: next } + }), handleJobEvent: (event) => { const { job_id, task_id } = event