Merge pull request #19 from madhura68/feat/story-o3ti9khw
feat(mcp): check_queue_empty tool — synchrone non-blocking queue-poll
This commit is contained in:
commit
85111f6dc7
6 changed files with 268 additions and 4 deletions
51
README.md
51
README.md
|
|
@ -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,55 @@ 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 — empty queue**
|
||||
|
||||
```json
|
||||
{ "empty": true, "remaining": 0, "by_product": {} }
|
||||
```
|
||||
|
||||
**Output — with product_id (non-empty)**
|
||||
|
||||
```json
|
||||
{ "empty": false, "remaining": 2 }
|
||||
```
|
||||
|
||||
**Output — without product_id (per-product split)**
|
||||
|
||||
```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
|
||||
|
|
|
|||
144
__tests__/check-queue-empty.test.ts
Normal file
144
__tests__/check-queue-empty.test.ts
Normal 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
4
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
67
src/tools/check-queue-empty.ts
Normal file
67
src/tools/check-queue-empty.ts
Normal 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 })
|
||||
}),
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue