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:
commit
2fbb36bdbe
5 changed files with 260 additions and 1 deletions
97
__tests__/job-config.test.ts
Normal file
97
__tests__/job-config.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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
120
src/lib/job-config.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue