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
|
definition_of_done String
|
||||||
auto_pr Boolean @default(false)
|
auto_pr Boolean @default(false)
|
||||||
pr_strategy PrStrategy @default(SPRINT)
|
pr_strategy PrStrategy @default(SPRINT)
|
||||||
|
preferred_model String?
|
||||||
|
thinking_budget_default Int?
|
||||||
|
preferred_permission_mode String?
|
||||||
archived Boolean @default(false)
|
archived Boolean @default(false)
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
|
|
@ -353,6 +356,7 @@ model Task {
|
||||||
status TaskStatus @default(TO_DO)
|
status TaskStatus @default(TO_DO)
|
||||||
verify_only Boolean @default(false)
|
verify_only Boolean @default(false)
|
||||||
verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL)
|
verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL)
|
||||||
|
requires_opus Boolean @default(false)
|
||||||
// Override product.repo_url for branch/worktree/push purposes. Set when
|
// Override product.repo_url for branch/worktree/push purposes. Set when
|
||||||
// a task targets a different repo than its parent product (e.g. an
|
// 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
|
// MCP-server task tracked under the main product's PBI). Falls back to
|
||||||
|
|
@ -398,6 +402,10 @@ model ClaudeJob {
|
||||||
output_tokens Int?
|
output_tokens Int?
|
||||||
cache_read_tokens Int?
|
cache_read_tokens Int?
|
||||||
cache_write_tokens Int?
|
cache_write_tokens Int?
|
||||||
|
requested_model String?
|
||||||
|
requested_thinking_budget Int?
|
||||||
|
requested_permission_mode String?
|
||||||
|
actual_thinking_tokens Int?
|
||||||
plan_snapshot String?
|
plan_snapshot String?
|
||||||
base_sha String?
|
base_sha String?
|
||||||
head_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(),
|
output_tokens: z.number().int().nonnegative().optional(),
|
||||||
cache_read_tokens: z.number().int().nonnegative().optional(),
|
cache_read_tokens: z.number().int().nonnegative().optional(),
|
||||||
cache_write_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(
|
export async function cleanupWorktreeForTerminalStatus(
|
||||||
|
|
@ -539,6 +540,7 @@ export function registerUpdateJobStatusTool(server: McpServer) {
|
||||||
output_tokens,
|
output_tokens,
|
||||||
cache_read_tokens,
|
cache_read_tokens,
|
||||||
cache_write_tokens,
|
cache_write_tokens,
|
||||||
|
actual_thinking_tokens,
|
||||||
}) =>
|
}) =>
|
||||||
withToolErrors(async () => {
|
withToolErrors(async () => {
|
||||||
const auth = await requireWriteAccess()
|
const auth = await requireWriteAccess()
|
||||||
|
|
@ -707,6 +709,7 @@ export function registerUpdateJobStatusTool(server: McpServer) {
|
||||||
...(output_tokens !== undefined ? { output_tokens } : {}),
|
...(output_tokens !== undefined ? { output_tokens } : {}),
|
||||||
...(cache_read_tokens !== undefined ? { cache_read_tokens } : {}),
|
...(cache_read_tokens !== undefined ? { cache_read_tokens } : {}),
|
||||||
...(cache_write_tokens !== undefined ? { cache_write_tokens } : {}),
|
...(cache_write_tokens !== undefined ? { cache_write_tokens } : {}),
|
||||||
|
...(actual_thinking_tokens !== undefined ? { actual_thinking_tokens } : {}),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { createWorktreeForJob } from '../git/worktree.js'
|
||||||
import { getWorktreeRoot } from '../git/worktree-paths.js'
|
import { getWorktreeRoot } from '../git/worktree-paths.js'
|
||||||
import { setupProductWorktrees, releaseLocksOnTerminal } from '../git/job-locks.js'
|
import { setupProductWorktrees, releaseLocksOnTerminal } from '../git/job-locks.js'
|
||||||
import { pushBranchForJob } from '../git/push.js'
|
import { pushBranchForJob } from '../git/push.js'
|
||||||
|
import { resolveJobConfig } from '../lib/job-config.js'
|
||||||
|
|
||||||
/** Parse `https://github.com/<owner>/<name>(.git)?` → `<name>`. */
|
/** Parse `https://github.com/<owner>/<name>(.git)?` → `<name>`. */
|
||||||
export function repoNameFromUrl(repoUrl: string | null | undefined): string | null {
|
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
|
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
|
// M12: branch on kind. Idea-jobs hebben geen task/story/pbi/sprint; ze
|
||||||
// hebben in plaats daarvan idea + embedded prompt_text.
|
// hebben in plaats daarvan idea + embedded prompt_text.
|
||||||
if (job.kind === 'IDEA_GRILL' || job.kind === 'IDEA_MAKE_PLAN') {
|
if (job.kind === 'IDEA_GRILL' || job.kind === 'IDEA_MAKE_PLAN') {
|
||||||
|
|
@ -515,6 +543,7 @@ async function getFullJobContext(jobId: string) {
|
||||||
job_id: job.id,
|
job_id: job.id,
|
||||||
kind: job.kind,
|
kind: job.kind,
|
||||||
status: 'claimed',
|
status: 'claimed',
|
||||||
|
config,
|
||||||
idea: {
|
idea: {
|
||||||
id: idea.id,
|
id: idea.id,
|
||||||
code: idea.code,
|
code: idea.code,
|
||||||
|
|
@ -659,6 +688,7 @@ async function getFullJobContext(jobId: string) {
|
||||||
job_id: job.id,
|
job_id: job.id,
|
||||||
kind: job.kind,
|
kind: job.kind,
|
||||||
status: 'claimed',
|
status: 'claimed',
|
||||||
|
config,
|
||||||
sprint: {
|
sprint: {
|
||||||
id: sprintRun.sprint.id,
|
id: sprintRun.sprint.id,
|
||||||
sprint_goal: sprintRun.sprint.sprint_goal,
|
sprint_goal: sprintRun.sprint.sprint_goal,
|
||||||
|
|
@ -724,6 +754,7 @@ async function getFullJobContext(jobId: string) {
|
||||||
job_id: job.id,
|
job_id: job.id,
|
||||||
kind: job.kind,
|
kind: job.kind,
|
||||||
status: 'claimed',
|
status: 'claimed',
|
||||||
|
config,
|
||||||
task: {
|
task: {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
title: task.title,
|
title: task.title,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue