feat: add check_queue_empty tool (v0.3.0)

Synchronous, non-blocking count of active ClaudeJobs per product or across
all accessible products. Registers check_queue_empty MCP tool with optional
product_id scope, productAccessFilter AuthZ, tests, and README docs.
This commit is contained in:
Scrum4Me Agent 2026-05-03 17:57:17 +02:00
parent 3ce2c044c4
commit d4522e8e53
6 changed files with 262 additions and 4 deletions

View file

@ -28,6 +28,8 @@ activity and create todos via native tool calls instead of curl.
| `wait_for_job` | Block until a QUEUED ClaudeJob is available, claim it atomically, return full task context with frozen `plan_snapshot`, `worktree_path`, and `branch_name` | no |
| `update_job_status` | Report job transition to `running`, `done`, or `failed`; triggers SSE event to UI; cleans up worktree on terminal transitions | no |
| `verify_task_against_plan` | Compare frozen `plan_snapshot` against current plan + story logs + commits; returns per-AC ✓/✗/? heuristic and drift-score | yes (read-only) |
| `cleanup_my_worktrees` | Remove stale git worktrees left by crashed or cancelled agent runs | no |
| `check_queue_empty` | Synchronous, non-blocking count of active jobs (QUEUED/CLAIMED/RUNNING); optional `product_id` scope | no |
| `set_pbi_pr` | Write `pr_url` on a PBI and clear `pr_merged_at`. Idempotent: re-calling overwrites `pr_url` and resets `pr_merged_at` to null | no |
| `mark_pbi_pr_merged` | Set `pr_merged_at = now()` on a PBI. Requires `pr_url` to already be set. Idempotent: re-calling overwrites the timestamp | no |
@ -128,6 +130,49 @@ Records that the linked PR has been merged by setting `pr_merged_at = now()`. Re
| `pr_url` not set | `PBI <id> heeft geen gekoppelde PR` |
| Demo account | `PERMISSION_DENIED: Demo accounts cannot perform write operations` |
### check_queue_empty
Synchronous, non-blocking poll that returns how many ClaudeJobs are still active (`QUEUED`, `CLAIMED`, `RUNNING`). No blocking — returns immediately. Use it after the last `update_job_status('done')` in a batch to decide whether to stay in the loop or finalise.
**Input**
```json
{ "product_id": "cmoprewcf000q..." } // optional — omit to aggregate all products
```
**Output — with product_id**
```json
{ "empty": false, "remaining": 2 }
```
**Output — without product_id**
```json
{
"empty": false,
"remaining": 3,
"by_product": {
"cmoprewcf000q...": 2,
"cmohry5yj0001...": 1
}
}
```
**Agent decision rule**
| `empty` | Action |
|---|---|
| `false` | Stay in loop — call `wait_for_job` again immediately |
| `true` | Finalise — push branch, open PR (if `auto_pr`), recap, exit |
**Errors**
| Condition | Message |
|---|---|
| `product_id` provided but not accessible | `Product <id> not found or not accessible` |
| Demo account | `PERMISSION_DENIED: Demo accounts cannot perform write operations` |
## Prompts
- `implement_next_story` — full workflow: fetch context, log plan, walk

View file

@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
claudeJob: {
count: vi.fn(),
groupBy: vi.fn(),
},
},
}))
vi.mock('../src/auth.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../src/auth.js')>()
return { ...original, requireWriteAccess: vi.fn() }
})
vi.mock('../src/access.js', () => ({
userCanAccessProduct: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
import { requireWriteAccess, PermissionDeniedError } from '../src/auth.js'
import { userCanAccessProduct } from '../src/access.js'
import { registerCheckQueueEmptyTool } from '../src/tools/check-queue-empty.js'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
const mockPrisma = prisma as unknown as {
claudeJob: {
count: ReturnType<typeof vi.fn>
groupBy: ReturnType<typeof vi.fn>
}
}
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
const USER_ID = 'user-abc'
const PRODUCT_A = 'product-aaa'
const PRODUCT_B = 'product-bbb'
function makeServer() {
let handler: (args: Record<string, unknown>) => Promise<unknown>
const server = {
registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => {
handler = fn
}),
call: (args: Record<string, unknown>) => handler(args),
}
registerCheckQueueEmptyTool(server as unknown as McpServer)
return server
}
beforeEach(() => {
vi.clearAllMocks()
mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'agent', isDemo: false })
mockUserCanAccessProduct.mockResolvedValue(true)
})
describe('check_queue_empty — no product_id', () => {
it('returns empty:true when no active jobs exist', async () => {
mockPrisma.claudeJob.groupBy.mockResolvedValue([])
const server = makeServer()
const result = await server.call({}) as { content: { text: string }[] }
const body = JSON.parse(result.content[0].text)
expect(body).toEqual({ empty: true, remaining: 0, by_product: {} })
})
it('returns correct counts for one product with active jobs', async () => {
mockPrisma.claudeJob.groupBy.mockResolvedValue([{ product_id: PRODUCT_A, _count: 3 }])
const server = makeServer()
const result = await server.call({}) as { content: { text: string }[] }
const body = JSON.parse(result.content[0].text)
expect(body).toEqual({ empty: false, remaining: 3, by_product: { [PRODUCT_A]: 3 } })
})
it('aggregates across two products', async () => {
mockPrisma.claudeJob.groupBy.mockResolvedValue([
{ product_id: PRODUCT_A, _count: 2 },
{ product_id: PRODUCT_B, _count: 1 },
])
const server = makeServer()
const result = await server.call({}) as { content: { text: string }[] }
const body = JSON.parse(result.content[0].text)
expect(body).toEqual({
empty: false,
remaining: 3,
by_product: { [PRODUCT_A]: 2, [PRODUCT_B]: 1 },
})
})
it('passes correct where clause to groupBy', async () => {
mockPrisma.claudeJob.groupBy.mockResolvedValue([])
const server = makeServer()
await server.call({})
expect(mockPrisma.claudeJob.groupBy).toHaveBeenCalledWith(
expect.objectContaining({
by: ['product_id'],
where: expect.objectContaining({
user_id: USER_ID,
status: { in: expect.arrayContaining(['QUEUED', 'CLAIMED', 'RUNNING']) },
product: expect.objectContaining({ OR: expect.any(Array) }),
}),
_count: true,
}),
)
})
})
describe('check_queue_empty — with product_id', () => {
it('returns empty:true when product queue is empty', async () => {
mockPrisma.claudeJob.count.mockResolvedValue(0)
const server = makeServer()
const result = await server.call({ product_id: PRODUCT_A }) as { content: { text: string }[] }
const body = JSON.parse(result.content[0].text)
expect(body).toEqual({ empty: true, remaining: 0 })
expect(body.by_product).toBeUndefined()
})
it('returns correct remaining count for a product with jobs', async () => {
mockPrisma.claudeJob.count.mockResolvedValue(2)
const server = makeServer()
const result = await server.call({ product_id: PRODUCT_A }) as { content: { text: string }[] }
const body = JSON.parse(result.content[0].text)
expect(body).toEqual({ empty: false, remaining: 2 })
})
it('returns error when user has no access to the product', async () => {
mockUserCanAccessProduct.mockResolvedValue(false)
const server = makeServer()
const result = await server.call({ product_id: PRODUCT_A }) as { content: { text: string }[]; isError: boolean }
expect(result.isError).toBe(true)
expect(result.content[0].text).toContain('not found or not accessible')
expect(mockPrisma.claudeJob.count).not.toHaveBeenCalled()
})
})
describe('check_queue_empty — demo user', () => {
it('returns PERMISSION_DENIED error for demo accounts', async () => {
mockRequireWriteAccess.mockRejectedValue(new PermissionDeniedError())
const server = makeServer()
const result = await server.call({}) as { content: { text: string }[]; isError: boolean }
expect(result.isError).toBe(true)
expect(result.content[0].text).toContain('PERMISSION_DENIED')
})
})

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "scrum4me-mcp",
"version": "0.2.0",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "scrum4me-mcp",
"version": "0.2.0",
"version": "0.3.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {

View file

@ -1,6 +1,6 @@
{
"name": "scrum4me-mcp",
"version": "0.2.0",
"version": "0.3.0",
"description": "MCP server for Scrum4Me — exposes dev-flow tools and prompts via the Model Context Protocol",
"type": "module",
"bin": {

View file

@ -21,6 +21,7 @@ import { registerWaitForJobTool } from './tools/wait-for-job.js'
import { registerUpdateJobStatusTool } from './tools/update-job-status.js'
import { registerVerifyTaskAgainstPlanTool } from './tools/verify-task-against-plan.js'
import { registerCleanupMyWorktreesTool } from './tools/cleanup-my-worktrees.js'
import { registerCheckQueueEmptyTool } from './tools/check-queue-empty.js'
import { registerSetPbiPrTool } from './tools/set-pbi-pr.js'
import { registerMarkPbiPrMergedTool } from './tools/mark-pbi-pr-merged.js'
import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js'
@ -29,7 +30,7 @@ import { registerWorker } from './presence/worker.js'
import { startHeartbeat } from './presence/heartbeat.js'
import { registerShutdownHandlers } from './presence/shutdown.js'
const VERSION = '0.2.0'
const VERSION = '0.3.0'
async function main() {
const server = new McpServer(
@ -61,6 +62,7 @@ async function main() {
registerUpdateJobStatusTool(server)
registerVerifyTaskAgainstPlanTool(server)
registerCleanupMyWorktreesTool(server)
registerCheckQueueEmptyTool(server)
registerSetPbiPrTool(server)
registerMarkPbiPrMergedTool(server)
registerImplementNextStoryPrompt(server)

View file

@ -0,0 +1,67 @@
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userCanAccessProduct } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
const ACTIVE_STATUSES = ['QUEUED', 'CLAIMED', 'RUNNING'] as const
const inputSchema = z.object({
product_id: z.string().min(1).optional(),
})
export function registerCheckQueueEmptyTool(server: McpServer) {
server.registerTool(
'check_queue_empty',
{
title: 'Check queue empty',
description:
'Synchronous, non-blocking check of how many ClaudeJobs are still active ' +
"(QUEUED, CLAIMED, RUNNING). Optionally scoped to one product via product_id; " +
'without it, aggregates across all accessible products. ' +
"Use after the last update_job_status('done') in a batch to decide whether to " +
'keep working or finalize. Forbidden for demo accounts.',
inputSchema,
annotations: { readOnlyHint: true, idempotentHint: true },
},
async ({ product_id }) =>
withToolErrors(async () => {
const auth = await requireWriteAccess()
const { userId } = auth
if (product_id) {
if (!(await userCanAccessProduct(product_id, userId))) {
return toolError(`Product ${product_id} not found or not accessible`)
}
const remaining = await prisma.claudeJob.count({
where: {
user_id: userId,
product_id,
status: { in: [...ACTIVE_STATUSES] },
},
})
return toolJson({ empty: remaining === 0, remaining })
}
const groups = await prisma.claudeJob.groupBy({
by: ['product_id'],
where: {
user_id: userId,
status: { in: [...ACTIVE_STATUSES] },
product: {
OR: [
{ user_id: userId },
{ members: { some: { user_id: userId } } },
],
},
},
_count: true,
})
const by_product = Object.fromEntries(groups.map((g) => [g.product_id, g._count]))
const remaining = groups.reduce((sum, g) => sum + g._count, 0)
return toolJson({ empty: remaining === 0, remaining, by_product })
}),
)
}