Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
Scrum4Me Agent
bc6936159d docs(ST-qfpqpxzy): worker-quota-probe.sh + uitgebreide pre-flight runbook + architectuurdoc
- scripts/worker-quota-probe.sh: curl-probe die rate-limit-headers parset en { remaining, limit, pct, reset_at_iso } JSON retourneert
- mcp-integration.md: pre-flight sectie uitgebreid met bash-script voorbeeld, retry-strategie (sleep tot reset+5s), bekende beperking
- claude-question-channel.md: M13 quota-gate flow diagram + pre-flight loop uitleg

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 03:55:58 +02:00
Scrum4Me Agent
661601e833 feat(ST-qfpqpxzy): SSE + NavBar stand-by badge voor worker quota-gate
- SSE route: WorkerHeartbeatPayload type + shouldEmit handler voor worker_heartbeat
- solo-store: lowQuotaTokenIds Set<string> + 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 <noreply@anthropic.com>
2026-05-06 03:53:12 +02:00
Scrum4Me Agent
3a73b4f1c9 feat(ST-qfpqpxzy): DB schema + settings-UI voor min_quota_pct worker-drempel
- User.min_quota_pct Int @default(20) + ClaudeWorker.last_quota_pct/last_quota_check_at
- Migratie add_worker_quota_gate
- lib/schemas/user.ts: minQuotaPctSchema (int, 1-100)
- actions/settings.ts: updateMinQuotaPctAction met auth/demo/zod-guard
- MinQuotaEditor component met numeric input en DemoTooltip
- Settings-pagina: Worker-instellingen sectie

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 03:44:46 +02:00
15 changed files with 374 additions and 14 deletions

View file

@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockUserUpdate, mockGetIronSession } = vi.hoisted(() => ({
mockUserUpdate: vi.fn(),
mockGetIronSession: vi.fn(),
}))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
vi.mock('iron-session', () => ({ getIronSession: mockGetIronSession }))
vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 'test', password: 'test' } }))
vi.mock('@/lib/prisma', () => ({
prisma: { user: { update: mockUserUpdate } },
}))
import { updateMinQuotaPctAction } from '@/actions/settings'
const SESSION_USER = { userId: 'user-1', isDemo: false }
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
const SESSION_UNAUTH = { userId: undefined, isDemo: false }
describe('updateMinQuotaPctAction', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUserUpdate.mockResolvedValue({})
})
it('returns error when not authenticated', async () => {
mockGetIronSession.mockResolvedValue(SESSION_UNAUTH)
const result = await updateMinQuotaPctAction(20)
expect(result).toMatchObject({ error: expect.any(String) })
expect(mockUserUpdate).not.toHaveBeenCalled()
})
it('returns 403 error for demo session', async () => {
mockGetIronSession.mockResolvedValue(SESSION_DEMO)
const result = await updateMinQuotaPctAction(20)
expect(result).toMatchObject({ status: 403 })
expect(mockUserUpdate).not.toHaveBeenCalled()
})
it('returns 422 error when value is 0 (below min)', async () => {
mockGetIronSession.mockResolvedValue(SESSION_USER)
const result = await updateMinQuotaPctAction(0)
expect(result).toMatchObject({ status: 422 })
expect(mockUserUpdate).not.toHaveBeenCalled()
})
it('returns 422 error when value is 101 (above max)', async () => {
mockGetIronSession.mockResolvedValue(SESSION_USER)
const result = await updateMinQuotaPctAction(101)
expect(result).toMatchObject({ status: 422 })
expect(mockUserUpdate).not.toHaveBeenCalled()
})
it('saves valid value and returns success', async () => {
mockGetIronSession.mockResolvedValue(SESSION_USER)
const result = await updateMinQuotaPctAction(35)
expect(result).toEqual({ success: true })
expect(mockUserUpdate).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: { min_quota_pct: 35 },
})
})
it('accepts boundary values 1 and 100', async () => {
mockGetIronSession.mockResolvedValue(SESSION_USER)
await updateMinQuotaPctAction(1)
await updateMinQuotaPctAction(100)
expect(mockUserUpdate).toHaveBeenCalledTimes(2)
})
})

29
actions/settings.ts Normal file
View file

@ -0,0 +1,29 @@
'use server'
import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
import { minQuotaPctSchema } from '@/lib/schemas/user'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
}
export async function updateMinQuotaPctAction(value: number) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', status: 403 }
const parsed = minQuotaPctSchema.safeParse(value)
if (!parsed.success) return { error: 'Waarde moet tussen 1 en 100 liggen', status: 422 }
await prisma.user.update({
where: { id: session.userId },
data: { min_quota_pct: parsed.data },
})
revalidatePath('/settings')
return { success: true }
}

View file

@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
import { RoleManager } from '@/components/settings/role-manager'
import { LeaveProductButton } from '@/components/settings/leave-product-button'
import { ProfileEditor } from '@/components/settings/profile-editor'
import { MinQuotaEditor } from '@/components/settings/min-quota-editor'
import { ActivateProductButton } from '@/components/shared/activate-product-button'
import Link from 'next/link'
@ -14,7 +15,7 @@ export default async function SettingsPage() {
const [user, userRoles, ownedProducts, memberships] = await Promise.all([
prisma.user.findUnique({
where: { id: session.userId },
select: { username: true, email: true, bio: true, bio_detail: true, avatar_data: true, updated_at: true, active_product_id: true },
select: { username: true, email: true, bio: true, bio_detail: true, avatar_data: true, updated_at: true, active_product_id: true, min_quota_pct: true },
}),
prisma.userRole.findMany({ where: { user_id: session.userId } }),
prisma.product.findMany({
@ -157,6 +158,19 @@ export default async function SettingsPage() {
)}
</div>
<div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-4">
<div>
<h2 className="text-sm font-medium text-foreground">Worker-instellingen</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Drempelwaarden voor de Claude-worker.
</p>
</div>
<MinQuotaEditor
currentValue={user?.min_quota_pct ?? 20}
isDemo={session.isDemo ?? false}
/>
</div>
<div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-foreground">API Tokens</h2>

View file

@ -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

View file

@ -0,0 +1,59 @@
'use client'
import { useState, useTransition } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { updateMinQuotaPctAction } from '@/actions/settings'
interface MinQuotaEditorProps {
currentValue: number
isDemo: boolean
}
export function MinQuotaEditor({ currentValue, isDemo }: MinQuotaEditorProps) {
const [value, setValue] = useState(currentValue)
const [isPending, startTransition] = useTransition()
function handleSave() {
startTransition(async () => {
const result = await updateMinQuotaPctAction(value)
if ('error' in result) {
toast.error(result.error as string)
} else {
toast.success('Instelling opgeslagen')
}
})
}
return (
<div className="space-y-3">
<div>
<label htmlFor="min-quota-pct" className="text-sm font-medium text-foreground">
Minimaal beschikbaar Claude-quota voordat de worker een job oppakt (%)
</label>
<p className="text-xs text-muted-foreground mt-0.5">
Worker slaapt tot quota gereset is wanneer onder deze drempel.
</p>
</div>
<div className="flex items-center gap-3">
<input
id="min-quota-pct"
type="number"
min={1}
max={100}
value={value}
onChange={e => setValue(Number(e.target.value))}
disabled={isDemo || isPending}
className="w-24 rounded border border-border bg-surface-container px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
/>
<span className="text-sm text-muted-foreground">%</span>
<DemoTooltip show={isDemo}>
<Button onClick={handleSave} disabled={isDemo || isPending} size="sm">
Opslaan
</Button>
</DemoTooltip>
</div>
</div>
)
}

View file

@ -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 (
<div className="flex items-center gap-3 px-2">
<RealtimeIndicator
status={realtimeStatus}
showConnectingIndicator={showConnectingIndicator}
/>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<div className="flex items-center gap-1 text-xs text-muted-foreground" />
}>
<span className={cn(
'size-2 rounded-full',
connectedWorkers > 0 ? 'bg-status-done' : 'bg-muted-foreground/40'
connectedWorkers === 0
? 'bg-muted-foreground/40'
: allStandby
? 'bg-warning'
: 'bg-status-done'
)} />
{connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'}
</div>
{connectedWorkers === 0
? 'Geen agent'
: allStandby
? 'Worker stand-by'
: 'Agent verbonden'}
</TooltipTrigger>
{allStandby && (
<TooltipContent>Worker wacht op quota-reset</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
)
}

View file

@ -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

View file

@ -75,5 +75,27 @@ weert).
Dit patroon (notification-channel via een bestaande pg_notify-stream) is
herbruikbaar — zie `docs/patterns/claude-question-channel.md`.
## Worker quota-gate flow (M13)
De `worker_heartbeat` MCP-tool gebruikt hetzelfde `scrum4me_changes`-kanaal met een nieuw event-type `worker_heartbeat`:
```
Worker scrum4me-mcp Postgres SSE-route NavBar
| | | | |
|--worker_heartbeat(15,low)-->| | | |
| |--pg_notify('scrum4me_changes', {type:'worker_heartbeat',is_low:true})-->|
| | | |--data:{...}---->|
| | | | |--stand-by badge
```
**Pre-flight loop** (vóór elke `wait_for_job`):
1. `get_worker_settings``min_quota_pct`
2. `bash scripts/worker-quota-probe.sh``{ pct, reset_at_iso }`
3. `worker_heartbeat(last_quota_pct, last_quota_check_at, is_low = pct < min_quota_pct)`
4. Als `is_low`: log "wachten tot quota-reset om HH:MM", slaap tot `reset_at_iso + 5s`, herhaal stap 2
5. Anders: `wait_for_job` aanroepen
De solo-store houdt `lowQuotaTokenIds: Set<string>` bij. De NavBar toont een stand-by badge wanneer `lowQuotaTokenIds.size >= connectedWorkers && connectedWorkers > 0`.
---

View file

@ -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,53 @@ 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.
**Helper-script:** `scripts/worker-quota-probe.sh` doet een minimale API-call (1 token) en geeft JSON terug:
```json
{ "remaining": 180000, "limit": 200000, "pct": 90, "reset_at_iso": "2026-05-06T13:00:00Z" }
```
**Stappenplan:**
1. Haal drempel op: `get_worker_settings``min_quota_pct` (bijv. 20).
2. Probe quota:
```bash
PROBE=$(bash scripts/worker-quota-probe.sh)
PCT=$(echo "$PROBE" | grep -o '"pct":[0-9]*' | cut -d: -f2)
RESET_AT=$(echo "$PROBE" | grep -o '"reset_at_iso":"[^"]*"' | cut -d'"' -f4)
```
3. Vergelijk en meld via `worker_heartbeat`:
```
worker_heartbeat(
last_quota_pct=<PCT>,
last_quota_check_at=<nu als ISO-8601>,
is_low=<PCT < min_quota_pct>
)
```
4. Bij low quota: retry-strategie — slaap tot reset + 5s:
```bash
if [ "$PCT" -lt "$MIN_QUOTA_PCT" ]; then
RESET_S=$(date -d "$RESET_AT" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$RESET_AT" +%s 2>/dev/null || echo 0)
NOW_S=$(date +%s)
SLEEP_S=$(( RESET_S - NOW_S + 5 ))
echo "Wachten tot quota-reset om $(date -d "$RESET_AT" '+%H:%M' 2>/dev/null || echo "$RESET_AT")"
[ "$SLEEP_S" -gt 0 ] && sleep "$SLEEP_S"
fi
```
5. Na de sleep: herhaal stap 2 (re-probe) voordat `wait_for_job` wordt aangeroepen.
**Bekende beperking:** elke probe kost ~1 token; bij 12x/uur is de overhead 12 tokens/uur — verwaarloosbaar ten opzichte van taak-gebruik.
## Prompt
- `implement_next_story` (arg: `product_id`) — end-to-end workflow

View file

@ -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

3
lib/schemas/user.ts Normal file
View file

@ -0,0 +1,3 @@
import { z } from 'zod'
export const minQuotaPctSchema = z.number().int().min(1).max(100)

View file

@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "claude_workers" ADD COLUMN "last_quota_check_at" TIMESTAMP(3),
ADD COLUMN "last_quota_pct" INTEGER;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "min_quota_pct" INTEGER NOT NULL DEFAULT 20;

View file

@ -121,6 +121,7 @@ model User {
active_product_id String?
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
idea_code_counter Int @default(0)
min_quota_pct Int @default(20)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
roles UserRole[]
@ -381,6 +382,8 @@ model ClaudeWorker {
product_id String?
started_at DateTime @default(now())
last_seen_at DateTime @default(now())
last_quota_pct Int?
last_quota_check_at DateTime?
@@unique([token_id])
@@index([user_id, last_seen_at])

44
scripts/worker-quota-probe.sh Executable file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env bash
# worker-quota-probe.sh — Probe Claude API rate-limit headers en output JSON.
#
# Vereist: ANTHROPIC_API_KEY env-var.
# Output: { "remaining": N, "limit": N, "pct": N, "reset_at_iso": "..." }
# Exit 0 bij succes, exit 1 bij fout (geen API key, curl-fout, parse-fout).
set -euo pipefail
if [ -z "${ANTHROPIC_API_KEY:-}" ]; then
echo '{"error":"ANTHROPIC_API_KEY not set"}' >&2
exit 1
fi
HEADERS_FILE=$(mktemp /tmp/quota-probe-headers.XXXXXX)
trap 'rm -f "$HEADERS_FILE"' EXIT
# Minimale API-aanroep (1 token) om rate-limit-headers te lezen.
HTTP_STATUS=$(curl -s -w "%{http_code}" -D "$HEADERS_FILE" -o /dev/null \
-X POST https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"."}]}')
if [ "$HTTP_STATUS" -ge 500 ]; then
echo "{\"error\":\"API returned HTTP $HTTP_STATUS\"}" >&2
exit 1
fi
# Parse headers (case-insensitive grep)
REMAINING=$(grep -i 'anthropic-ratelimit-tokens-remaining:' "$HEADERS_FILE" | awk '{print $2}' | tr -d '\r' || true)
LIMIT=$(grep -i 'anthropic-ratelimit-tokens-limit:' "$HEADERS_FILE" | awk '{print $2}' | tr -d '\r' || true)
RESET=$(grep -i 'anthropic-ratelimit-tokens-reset:' "$HEADERS_FILE" | awk '{print $2}' | tr -d '\r' || true)
if [ -z "$REMAINING" ] || [ -z "$LIMIT" ] || [ "$LIMIT" -eq 0 ] 2>/dev/null; then
echo '{"error":"rate-limit headers not present in response"}' >&2
exit 1
fi
PCT=$(( REMAINING * 100 / LIMIT ))
printf '{"remaining":%s,"limit":%s,"pct":%s,"reset_at_iso":"%s"}\n' \
"$REMAINING" "$LIMIT" "$PCT" "$RESET"

View file

@ -63,6 +63,7 @@ interface SoloStore {
claudeJobsByTaskId: Record<string, JobState>
connectedWorkers: number
lowQuotaTokenIds: Set<string>
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<SoloStore>((set, get) => ({
showConnectingIndicator: false,
claudeJobsByTaskId: {},
connectedWorkers: 0,
lowQuotaTokenIds: new Set<string>(),
initTasks: (tasks) =>
set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }),
@ -146,6 +149,13 @@ export const useSoloStore = create<SoloStore>((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