Merge pull request #38 from madhura68/feat/pbi-67-job-config

feat(PBI-67): job-config resolver + wait_for_job-config + thinking-tokens
This commit is contained in:
Janpeter Visser 2026-05-08 11:21:06 +02:00 committed by GitHub
commit 2fbb36bdbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 260 additions and 1 deletions

View file

@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest'
import { getKindDefault, resolveJobConfig } from '../src/lib/job-config.js'
const KIND_EXPECTED = {
IDEA_GRILL: { model: 'claude-sonnet-4-6', thinking_budget: 12000, permission_mode: 'plan', max_turns: 15 },
IDEA_MAKE_PLAN: { model: 'claude-opus-4-7', thinking_budget: 24000, permission_mode: 'plan', max_turns: 20 },
PLAN_CHAT: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'plan', max_turns: 5 },
TASK_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: 50 },
SPRINT_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: null },
} as const
describe('getKindDefault', () => {
for (const [kind, expected] of Object.entries(KIND_EXPECTED)) {
it(`returnt de juiste defaults voor ${kind}`, () => {
const cfg = getKindDefault(kind)
expect(cfg.model).toBe(expected.model)
expect(cfg.thinking_budget).toBe(expected.thinking_budget)
expect(cfg.permission_mode).toBe(expected.permission_mode)
expect(cfg.max_turns).toBe(expected.max_turns)
})
}
it('valt terug op een veilige fallback voor onbekende kinds', () => {
const cfg = getKindDefault('SOMETHING_NEW')
expect(cfg.model).toBe('claude-sonnet-4-6')
expect(cfg.permission_mode).toBe('default')
})
})
describe('resolveJobConfig — geen overrides', () => {
for (const kind of Object.keys(KIND_EXPECTED)) {
it(`returnt kind-default voor ${kind} zonder overrides`, () => {
const cfg = resolveJobConfig({ kind }, {})
expect(cfg).toEqual(getKindDefault(kind))
})
}
})
describe('resolveJobConfig — cascade', () => {
it('product.preferred_model overrult kind-default', () => {
const cfg = resolveJobConfig({ kind: 'TASK_IMPLEMENTATION' }, { preferred_model: 'claude-haiku-4-5-20251001' })
expect(cfg.model).toBe('claude-haiku-4-5-20251001')
})
it('job.requested_model overrult product.preferred_model', () => {
const cfg = resolveJobConfig(
{ kind: 'TASK_IMPLEMENTATION', requested_model: 'claude-opus-4-7' },
{ preferred_model: 'claude-haiku-4-5-20251001' },
)
expect(cfg.model).toBe('claude-opus-4-7')
})
it('task.requires_opus overrult product.preferred_model', () => {
const cfg = resolveJobConfig(
{ kind: 'TASK_IMPLEMENTATION' },
{ preferred_model: 'claude-sonnet-4-6' },
{ requires_opus: true },
)
expect(cfg.model).toBe('claude-opus-4-7')
})
it('task.requires_opus overrult ook job.requested_model = haiku', () => {
const cfg = resolveJobConfig(
{ kind: 'TASK_IMPLEMENTATION', requested_model: 'claude-haiku-4-5-20251001' },
{},
{ requires_opus: true },
)
expect(cfg.model).toBe('claude-opus-4-7')
})
it('job.requested_thinking_budget overrult kind-default', () => {
const cfg = resolveJobConfig({ kind: 'PLAN_CHAT', requested_thinking_budget: 1024 }, {})
expect(cfg.thinking_budget).toBe(1024)
})
it('product.thinking_budget_default overrult kind-default', () => {
const cfg = resolveJobConfig({ kind: 'IDEA_GRILL' }, { thinking_budget_default: 0 })
expect(cfg.thinking_budget).toBe(0)
})
it('product.preferred_permission_mode = acceptEdits overrult bypassPermissions voor TASK_IMPLEMENTATION', () => {
const cfg = resolveJobConfig(
{ kind: 'TASK_IMPLEMENTATION' },
{ preferred_permission_mode: 'acceptEdits' },
)
expect(cfg.permission_mode).toBe('acceptEdits')
})
it('max_turns en allowed_tools blijven kind-default ook met product- en job-overrides (geen V1-cascade)', () => {
const cfg = resolveJobConfig(
{ kind: 'IDEA_GRILL', requested_model: 'claude-haiku-4-5-20251001' },
{ preferred_model: 'claude-sonnet-4-6' },
)
expect(cfg.max_turns).toBe(15)
expect(cfg.allowed_tools).toEqual(['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion'])
})
})

View file

@ -200,6 +200,9 @@ model Product {
definition_of_done String
auto_pr Boolean @default(false)
pr_strategy PrStrategy @default(SPRINT)
preferred_model String?
thinking_budget_default Int?
preferred_permission_mode String?
archived Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -353,6 +356,7 @@ model Task {
status TaskStatus @default(TO_DO)
verify_only Boolean @default(false)
verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL)
requires_opus Boolean @default(false)
// Override product.repo_url for branch/worktree/push purposes. Set when
// a task targets a different repo than its parent product (e.g. an
// MCP-server task tracked under the main product's PBI). Falls back to
@ -398,6 +402,10 @@ model ClaudeJob {
output_tokens Int?
cache_read_tokens Int?
cache_write_tokens Int?
requested_model String?
requested_thinking_budget Int?
requested_permission_mode String?
actual_thinking_tokens Int?
plan_snapshot String?
base_sha String?
head_sha String?

120
src/lib/job-config.ts Normal file
View file

@ -0,0 +1,120 @@
// PBI-67: model + mode-selectie per ClaudeJob-kind.
//
// Override-cascade (eerste match wint):
// 1. task.requires_opus === true → forceer Opus
// 2. job.requested_* (snapshot bij enqueue)
// 3. product.preferred_*
// 4. KIND_DEFAULTS hieronder
export type ClaudeModel =
| 'claude-opus-4-7'
| 'claude-sonnet-4-6'
| 'claude-haiku-4-5-20251001'
export type PermissionMode = 'plan' | 'default' | 'acceptEdits' | 'bypassPermissions'
export type JobConfig = {
model: ClaudeModel
thinking_budget: number // 0 = uit
permission_mode: PermissionMode
max_turns: number | null // null = onbegrensd
allowed_tools: string[] | null // null = alle
}
export type JobInput = {
kind: string
requested_model?: string | null
requested_thinking_budget?: number | null
requested_permission_mode?: string | null
}
export type ProductInput = {
preferred_model?: string | null
thinking_budget_default?: number | null
preferred_permission_mode?: string | null
}
export type TaskInput = {
requires_opus?: boolean | null
}
const KIND_DEFAULTS: Record<string, JobConfig> = {
IDEA_GRILL: {
model: 'claude-sonnet-4-6',
thinking_budget: 12000,
permission_mode: 'plan',
max_turns: 15,
allowed_tools: ['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion'],
},
IDEA_MAKE_PLAN: {
model: 'claude-opus-4-7',
thinking_budget: 24000,
permission_mode: 'plan',
max_turns: 20,
allowed_tools: ['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', 'Write'],
},
PLAN_CHAT: {
model: 'claude-sonnet-4-6',
thinking_budget: 6000,
permission_mode: 'plan',
max_turns: 5,
allowed_tools: ['Read', 'Grep', 'AskUserQuestion'],
},
TASK_IMPLEMENTATION: {
model: 'claude-sonnet-4-6',
thinking_budget: 6000,
permission_mode: 'bypassPermissions',
max_turns: 50,
allowed_tools: null,
},
SPRINT_IMPLEMENTATION: {
model: 'claude-sonnet-4-6',
thinking_budget: 6000,
permission_mode: 'bypassPermissions',
max_turns: null,
allowed_tools: null,
},
}
const FALLBACK: JobConfig = {
model: 'claude-sonnet-4-6',
thinking_budget: 6000,
permission_mode: 'default',
max_turns: 50,
allowed_tools: null,
}
export function getKindDefault(kind: string): JobConfig {
return KIND_DEFAULTS[kind] ?? FALLBACK
}
// max_turns en allowed_tools blijven kind-default (geen product/task override
// in V1 — als de behoefte ontstaat, voeg analoge velden toe aan Product/Task).
export function resolveJobConfig(
job: JobInput,
product: ProductInput,
task?: TaskInput,
): JobConfig {
const base = getKindDefault(job.kind)
const model = (
task?.requires_opus
? 'claude-opus-4-7'
: job.requested_model ?? product.preferred_model ?? base.model
) as ClaudeModel
const thinking_budget =
job.requested_thinking_budget ?? product.thinking_budget_default ?? base.thinking_budget
const permission_mode = (job.requested_permission_mode ??
product.preferred_permission_mode ??
base.permission_mode) as PermissionMode
return {
model,
thinking_budget,
permission_mode,
max_turns: base.max_turns,
allowed_tools: base.allowed_tools,
}
}

View file

@ -54,6 +54,7 @@ const inputSchema = z.object({
output_tokens: z.number().int().nonnegative().optional(),
cache_read_tokens: z.number().int().nonnegative().optional(),
cache_write_tokens: z.number().int().nonnegative().optional(),
actual_thinking_tokens: z.number().int().nonnegative().optional(),
})
export async function cleanupWorktreeForTerminalStatus(
@ -539,6 +540,7 @@ export function registerUpdateJobStatusTool(server: McpServer) {
output_tokens,
cache_read_tokens,
cache_write_tokens,
actual_thinking_tokens,
}) =>
withToolErrors(async () => {
const auth = await requireWriteAccess()
@ -707,6 +709,7 @@ export function registerUpdateJobStatusTool(server: McpServer) {
...(output_tokens !== undefined ? { output_tokens } : {}),
...(cache_read_tokens !== undefined ? { cache_read_tokens } : {}),
...(cache_write_tokens !== undefined ? { cache_write_tokens } : {}),
...(actual_thinking_tokens !== undefined ? { actual_thinking_tokens } : {}),
},
select: {
id: true,

View file

@ -18,6 +18,7 @@ import { createWorktreeForJob } from '../git/worktree.js'
import { getWorktreeRoot } from '../git/worktree-paths.js'
import { setupProductWorktrees, releaseLocksOnTerminal } from '../git/job-locks.js'
import { pushBranchForJob } from '../git/push.js'
import { resolveJobConfig } from '../lib/job-config.js'
/** Parse `https://github.com/<owner>/<name>(.git)?` → `<name>`. */
export function repoNameFromUrl(repoUrl: string | null | undefined): string | null {
@ -467,11 +468,38 @@ async function getFullJobContext(jobId: string) {
},
},
},
product: { select: { id: true, name: true, repo_url: true, definition_of_done: true } },
product: {
select: {
id: true,
name: true,
repo_url: true,
definition_of_done: true,
preferred_model: true,
thinking_budget_default: true,
preferred_permission_mode: true,
},
},
},
})
if (!job) return null
// PBI-67: model + mode-selectie. Resolved op claim-moment; override-cascade
// task.requires_opus → job.requested_* → product.preferred_* → kind-default.
const config = resolveJobConfig(
{
kind: job.kind,
requested_model: job.requested_model,
requested_thinking_budget: job.requested_thinking_budget,
requested_permission_mode: job.requested_permission_mode,
},
{
preferred_model: job.product.preferred_model,
thinking_budget_default: job.product.thinking_budget_default,
preferred_permission_mode: job.product.preferred_permission_mode,
},
job.task ? { requires_opus: job.task.requires_opus } : undefined,
)
// M12: branch on kind. Idea-jobs hebben geen task/story/pbi/sprint; ze
// hebben in plaats daarvan idea + embedded prompt_text.
if (job.kind === 'IDEA_GRILL' || job.kind === 'IDEA_MAKE_PLAN') {
@ -515,6 +543,7 @@ async function getFullJobContext(jobId: string) {
job_id: job.id,
kind: job.kind,
status: 'claimed',
config,
idea: {
id: idea.id,
code: idea.code,
@ -659,6 +688,7 @@ async function getFullJobContext(jobId: string) {
job_id: job.id,
kind: job.kind,
status: 'claimed',
config,
sprint: {
id: sprintRun.sprint.id,
sprint_goal: sprintRun.sprint.sprint_goal,
@ -724,6 +754,7 @@ async function getFullJobContext(jobId: string) {
job_id: job.id,
kind: job.kind,
status: 'claimed',
config,
task: {
id: task.id,
title: task.title,