Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/409-budget-stop-english.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A job stopped by its per-job budget now explains itself in the console's language: the job page banner and the in-app notification are translated instead of showing the Chinese sentence written by the job service. The Chinese console is unchanged, the job's state and the recorded cost are untouched, and the raw sentence stays readable under the page's technical details.
12 changes: 12 additions & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,15 @@ export function localizeMessage(locale: Locale, message: string | undefined): st
.join(', ')
return `Merged ${count} ${count === '1' ? 'pull request' : 'pull requests'}${skipped === '' ? '' : `, ${skipped}`}.`
}
// #409: the per-job budget stop writes its reason with the submitted cost and
// the repository budget interpolated, and `budgetStop` strips the code prefix
// afterwards — so the arm has to keep the prefix to stay a drop-in localiser.
const jobBudgetStop = /^JOB_BUDGET_(PAUSED|CANCELLED): 单任务成本 ([\d.]+) 元已超过预算 ([\d.]+) 元。提高单任务预算后(从断点恢复,或)?重新开始任务。$/.exec(message)
if (jobBudgetStop !== null) {
const paused = jobBudgetStop[1] === 'PAUSED'
const action = paused ? 'Resume from the checkpoint once the budget is raised, or start the job again.' : 'Start the job again once the budget is raised.'
return `JOB_BUDGET_${jobBudgetStop[1] ?? ''}: this job cost ${jobBudgetStop[2] ?? ''} CNY, over its ${jobBudgetStop[3] ?? ''} CNY per-job budget. ${action}`
}
const modelsSaved = /^(\d+) 个模型已通过测试并保存(?:;失败:([\s\S]+))?$/.exec(message)
if (modelsSaved !== null) {
const count = modelsSaved[1] ?? '0'
Expand Down Expand Up @@ -302,6 +311,9 @@ export const ZH_TO_EN: ReadonlyArray<readonly [RegExp, Localizer]> = [
// `${job.kind}` verbatim, so the kind is read as data rather than enumerated.
[/^(\S+) 任务 (\S+) 缺少阶段证据:([\s\S]+)$/, (m) => `The ${m[1]} job ${m[2]} is missing stage evidence: ${m[3]}`],
[/^任务 (\S+) 成本 ([\d.]+) 元,超过单任务预算 ([\d.]+) 元。$/, (m) => `Job ${m[1]} cost ${m[2]} CNY, over its ${m[3]} CNY single-job budget.`],
// #409: the stop's notification is a second, different sentence from the reason
// in the same function; the dashboard reaches it through `localizeNotification`.
[/^任务 (\S+) 因单任务预算(取消|暂停):已用 ([\d.]+) 元,预算 ([\d.]+) 元。提高预算或重新开始任务。$/, (m) => `Job ${m[1]} was ${m[2] === '取消' ? 'cancelled' : 'paused'} for its per-job budget: ${m[3]} CNY used, ${m[4]} CNY budget. Raise the budget or start the job again.`],
[/^任务 (\S+) 属于临时失败,将在约 (\d+) 分钟后自动重试。$/, (m) => `Job ${m[1]} hit a temporary failure; it will retry automatically in about ${m[2]} minutes.`],
[/^任务 (\S+) 的 PR 改动的文件列表不完整,无法确认路径约束,已阻止合并:([\s\S]+)$/, (m) => `The PR for job ${m[1]} has an incomplete changed-file list, so the path constraint could not be confirmed and the merge was blocked: ${m[2]}`],
[/^任务 (\S+) 改动的文件列表不完整,无法确认路径约束:([\s\S]+)$/, (m) => `Job ${m[1]} has an incomplete changed-file list, so the path constraint could not be confirmed: ${m[2]}`],
Expand Down
6 changes: 5 additions & 1 deletion src/views/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2697,7 +2697,11 @@ export function jobPage(input: {
// #357: a budget stop is not a failure category — the job stopped on purpose
// and the only useful next step is the budget. The reason string carries the
// observed cost, so the page says both without a second lookup.
const budgetStop = (job.error ?? '').startsWith('JOB_BUDGET_') ? redact((job.error ?? '').replace(/^JOB_BUDGET_(PAUSED|CANCELLED):\s*/, '')) : ''
// #409: the sentence is written by the job service in Chinese; localise it
// before the code prefix is stripped, so the banner body follows the reader.
const budgetStop = (job.error ?? '').startsWith('JOB_BUDGET_')
? redact((localizeMessage(locale, job.error ?? '') ?? '').replace(/^JOB_BUDGET_(PAUSED|CANCELLED):\s*/, ''))
: ''
const attribution = jobAttribution(t, job)
// #358: which model actually answered, and what it fell through. A job whose
// primary was rate-limited finished on another model, so the page must name the
Expand Down
7 changes: 7 additions & 0 deletions test/jobBudgetAction.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { Store } from '../src/db.js'

const CJK = /[\u3400-\u9fff]/
import type { RepoRow } from '../src/db.js'
import { Jobs } from '../src/jobs/service.js'
import { sha256Hex } from '../src/lib/secrets.js'
import { makeUser, MemoryMailer, testConfig } from './helpers.js'
import { localizeMessage } from '../src/i18n.js'

// #357: the per-job budget was an after-the-spend alert. The owner who sets a
// budget expects it to stop spend, so the action is a repository setting with
Expand Down Expand Up @@ -64,6 +67,9 @@ test('pause stops a job at the next checkpoint once its recorded cost crosses th
assert.equal(job?.status, 'paused', 'the pause action stops the job at the checkpoint')
assert.match(String(job?.error), /JOB_BUDGET_PAUSED/, 'the job records why it stopped')
assert.match(String(job?.error), /预算|budget/i, 'and records the observed cost against the budget')
// #409: the sentence is written by the job service, so it reaches an English
// console verbatim unless `localizeMessage` knows the sentence it wrote.
assert.equal(CJK.test(localizeMessage('en', String(job?.error)) ?? ''), false, `the English reason must not be the raw Chinese sentence: ${String(job?.error)}`)

const audit = store.listAudit(50).find((row) => row.action === 'job.budget_paused')
assert.ok(audit, 'the stop is audited')
Expand All @@ -86,6 +92,7 @@ test('cancel stops a job at the next checkpoint once its recorded cost crosses t
const job = store.getJob(id)
assert.equal(job?.status, 'cancelled')
assert.match(String(job?.error), /JOB_BUDGET_CANCELLED/)
assert.equal(CJK.test(localizeMessage('en', String(job?.error)) ?? ''), false, `the English reason must not be the raw Chinese sentence: ${String(job?.error)}`)
assert.ok(store.listAudit(50).some((row) => row.action === 'job.budget_cancelled'))
} finally {
store.close()
Expand Down
43 changes: 43 additions & 0 deletions test/jobFailureLocale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,46 @@ test('the stall and lease reapers record the code on the row and on the notifica
store.close()
}
})

// #409: the stop's reason is written by `src/jobs/service.ts` with the submitted
// cost and the repository budget interpolated, so an English console showed the
// Chinese paragraph under an English title. `budgetStop` is the banner body; the
// sentence must arrive in the reader's language there, while the Chinese render
// keeps exactly what the backend wrote.
test("a per-job budget stop renders the job page banner in the reader's language", () => {
const store = new Store(':memory:')
try {
const reason = 'JOB_BUDGET_PAUSED: 单任务成本 12.50 元已超过预算 10.00 元。提高单任务预算后从断点恢复,或重新开始任务。'
const { userId, repo, job } = failedJob(store, 'budget-banner', reason, 'JOB_BUDGET_PAUSED')

// The page can carry more than one error card (a quota banner also renders
// `state-error`), so the budget card is found by its own action target.
const card = (html: string): { title: string; body: string } => {
const at = html.indexOf('#repo-settings')
assert.ok(at > 0, 'the per-job budget banner action is missing')
const open = html.lastIndexOf('<div class="state state-error"', at)
assert.ok(open >= 0, 'the per-job budget banner is missing')
const slice = html.slice(open, at + 200)
return {
title: /<strong>([\s\S]*?)<\/strong>/.exec(slice)?.[1] ?? '',
body: /<p>([\s\S]*?)<\/p>/.exec(slice)?.[1] ?? '',
}
}

const en = card(jobPageHtml(store, 'en', userId, repo, job))
assert.match(en.title, /Per-job budget reached/)
assert.equal(CJK.test(en.body), false, `the English banner leaked the backend sentence: ${en.body}`)
assert.match(en.body, /12\.50 CNY/, `the English banner lost the observed cost: ${en.body}`)
assert.match(en.body, /budget/i, `the English banner must name what stopped the job: ${en.body}`)

const zh = card(jobPageHtml(store, 'zh', userId, repo, job))
assert.match(zh.title, /单任务预算/)
assert.match(zh.body, /12\.50 元/, `the Chinese banner keeps the sentence the service wrote: ${zh.body}`)

// The raw sentence stays readable under the technical-details block.
const detail = /Technical detail<\/summary><p>([\s\S]*?)<\/p>/.exec(jobPageHtml(store, 'en', userId, repo, job))?.[1] ?? ''
assert.equal(detail, reason, 'the technical-details copy is the operator text, unchanged')
} finally {
store.close()
}
})
25 changes: 25 additions & 0 deletions test/notificationsLocale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,29 @@ function noticeRow(html: string): string {
return /<div class="notice"><span>(.*?)<\/span>/.exec(html)?.[1] ?? ''
}

// #409: the per-job budget stop writes two different sentences — one into
// `jobs.error` and one into the notification — and only the plain `localizeMessage`
// arm can fix the first. The dashboard reads notifications through
// `localizeNotification`, so the notice needs its own row or an English user
// sees the Chinese sentence under an English title.
test('a per-job budget stop notifies an English dashboard in English', () => {
const store = new Store(':memory:')
try {
const user = makeUser(store, { login: 'notice-budget', status: 'approved' })
store.notify(user.id, 'quota', '任务 job_8 因单任务预算暂停:已用 12.50 元,预算 10.00 元。提高预算或重新开始任务。', 'JOB_BUDGET_PAUSED')

const en = noticeRow(render(store, user.id, 'en'))
assert.equal(CJK.test(en), false, `the English budget notice must not contain CJK: ${en}`)
assert.match(en, /per-job budget/i, `the English budget notice must say why it stopped: ${en}`)
assert.match(en, /12\.50 CNY/, `the English budget notice must keep the observed cost: ${en}`)

const zh = noticeRow(render(store, user.id, 'zh'))
assert.match(zh, /因单任务预算暂停/, `the Chinese dashboard keeps the sentence the service wrote: ${zh}`)
} finally {
store.close()
}
})

test('a quota notice written in Chinese renders in English on an English dashboard', async () => {
const store = new Store(':memory:')
try {
Expand Down Expand Up @@ -145,6 +168,8 @@ test('every localisation row is pinned by a sample in both directions', () => {
['en', 'alice 团队提问:继续吗', 'alice team question: 继续吗'],
['en', '平台额度已用完,请到 Settings → Model 配置你自己的模型 API。后重试。', 'Platform quota is used up. Configure your own model API in Settings → Model.'],
['en', '平台额度已用完,请在 Settings → Model 配置自己的模型 API。', 'Platform quota is used up. Configure your own model API in Settings → Model.'],
['en', '任务 job_8 因单任务预算暂停:已用 12.50 元,预算 10.00 元。提高预算或重新开始任务。', 'Job job_8 was paused for its per-job budget: 12.50 CNY used, 10.00 CNY budget. Raise the budget or start the job again.'],
['en', '任务 job_19 因单任务预算取消:已用 12.50 元,预算 10.00 元。提高预算或重新开始任务。', 'Job job_19 was cancelled for its per-job budget: 12.50 CNY used, 10.00 CNY budget. Raise the budget or start the job again.'],
['zh', 'The scope job job_3 is missing stage evidence: 缺少范围(evidence.scope)', 'scope 任务 job_3 缺少阶段证据:缺少范围(evidence.scope)'],
['zh', 'job job_9 has no callback for 49m', '任务 job_9 已有 49 分钟没有回报。'],
['zh', 'PR https://github.com/o/r/pull/7 has been open for 49h', 'PR https://github.com/o/r/pull/7 已开启 49 小时。'],
Expand Down
Loading