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/723-disabled-next-run.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A paused repository no longer promises a next run: the repository page says it is disabled instead of printing a schedule row's planner instant, and the dashboard never names a paused repository as the next automatic run while an enabled one has one, or prints an instant when every repository with auto-iteration is paused. The daily digest gives the same answer: a paused repository says it is disabled instead of printing its row's instant.
11 changes: 9 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,16 @@ defect.
calls `nextRunFor` (`src/lib/nextRunFor.ts`), which makes the same row-vs-interval
choice and delegates the instant to `nextTime`/`dueAfterMs`, so the email prints
the same state and time as the page. The digest owns only its Chinese wording; it
does not import `src/views/**`. The dashboard route passes open work, the
does not import `src/views/**`. A disabled repository is answered by `enabled`
before either rule: the worker only considers `enabled = 1 AND
auto_iterate = 1` (`src/db.ts`, `listAutoRepos`) and a manual start on a paused
repository is refused with `REPO_DISABLED`, so the page route withholds a saved
row's answer and `nextRunFor` answers `disabled` before the row — no surface
names a run for a paused repository. The page keeps the `disabled` sentence with
or without a schedule row, and the dashboard never names a paused repository
while an enabled one has a next run. The dashboard route passes open work, the
active job and instance readiness per repository because those live outside
`RepoRow`; the two surfaces therefore print one string, and
`RepoRow`; the surfaces therefore print one string, and
the wording says "next run", never an estimate. The newest job is read on its own, not
from the job-table slice: the table is paged and filtered, and `?jobsPage=2` or
`?status=` must not change the sentence the page opens with. The first-run
Expand Down
12 changes: 12 additions & 0 deletions docs/decisions/0017-one-next-run-decision-three-surfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,15 @@ dashboard use, so a deployment with no FlyBot image never prints "not ready".
planner instant for a scheduled repository — including a disabled repository
that has a saved row, where the row decides the cadence on both surfaces.
- `src/jobs/` remains a caller of the rule, never its owner.

## Amendment — #723 (2026-11-15)

`enabled` answers before the saved row does, here and on every surface. The
worker only considers `enabled = 1 AND auto_iterate = 1` (`src/db.ts`,
`listAutoRepos`) and a manual start on a paused repository is refused with
`REPO_DISABLED` (`src/jobs/service.ts`), so a paused repository must not be
promised a run even when it keeps its schedule row. The Consequences sentence
that let a disabled repository with a saved row follow the row's cadence is
therefore superseded: the page route withholds the row's answer for a paused
repository and `nextRunFor` returns `disabled` before it reads the row, so the
page, the dashboard and the digest keep one sentence.
10 changes: 8 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1277,7 +1277,10 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
openWork: store.listOpenWorkItems(repo.id).length > 0,
activeJob: store.activeJobForRepo(repo.id) !== undefined,
instanceReady: store.getRepoInstance(repo.id)?.status === 'ready',
...(scheduleRow === undefined ? {} : {
// #723: the worker only considers an enabled repository (`listAutoRepos`
// is `WHERE enabled = 1 AND auto_iterate = 1`), so a paused repository
// must not travel with a planner answer the card would treat as runnable.
...(scheduleRow === undefined || repo.enabled !== 1 ? {} : {
scheduledNext: schedule === null
? { kind: 'unreadable' as const, at: null }
: { kind: schedule.kind, at: nextTime(schedule, Date.now()) },
Expand Down Expand Up @@ -1909,7 +1912,10 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
reviewTrend,
// #544: a repository with a schedule row is answered by the planner, not
// by the legacy interval rule the page computed for every repository.
...(scheduleRow === undefined ? {} : {
// #723: `enabled` answers before the row does, so a paused repository
// keeps the `disabled` sentence the page already has instead of a
// planner instant the worker will never run.
...(scheduleRow === undefined || repo.enabled !== 1 ? {} : {
scheduledNext: schedule === null ? { kind: 'unreadable' as const, at: null } : { kind: schedule.kind, at: nextTime(schedule, Date.now()) },
}),
workflowFailures: store.listWorkflowFailuresForRepo(repo.id, 10),
Expand Down
24 changes: 16 additions & 8 deletions src/lib/nextRunFor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,7 @@ export interface NextRunFor {

export function nextRunFor(input: NextRunForInput): NextRunFor {
const now = input.now ?? Date.now()
if (input.row !== undefined) {
const schedule = scheduleFromRow(input.row)
if (schedule === null) return { at: null, state: 'unknown-unreadable' }
const at = nextTime(schedule, now)
if (at === null) return { at: null, state: 'unknown-closed' }
return { at, state: at <= now ? 'due' : 'scheduled' }
}
return nextRun({
const intervalAnswer = (): NextRunFor => nextRun({
lastRunAt: input.repo.last_run_at,
intervalMinutes: input.repo.interval_minutes,
workCooldownMinutes: input.workCooldownMinutes,
Expand All @@ -57,4 +50,19 @@ export function nextRunFor(input: NextRunForInput): NextRunFor {
instanceReady: input.instanceReady,
now,
})
// #723: `enabled` answers before the row does, exactly as the worker does.
// `listAutoRepos()` is `WHERE enabled = 1 AND auto_iterate = 1` and a manual
// start on a paused repository is refused with `REPO_DISABLED`, so a saved
// schedule row must not buy a paused repository a next run on any surface.
// The interval answer already carries the `disabled` state, so the page,
// dashboard and digest keep one wording.
if (input.repo.enabled !== 1) return intervalAnswer()
if (input.row !== undefined) {
const schedule = scheduleFromRow(input.row)
if (schedule === null) return { at: null, state: 'unknown-unreadable' }
const at = nextTime(schedule, now)
if (at === null) return { at: null, state: 'unknown-closed' }
return { at, state: at <= now ? 'due' : 'scheduled' }
}
return intervalAnswer()
}
5 changes: 4 additions & 1 deletion src/views/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,7 +780,10 @@ function nextScheduledRun(input: { repos: RepoRow[]; facts: NextRunFact[]; confi
repo,
line: plannedNextLine(scheduled, t),
at: scheduled.at,
runnable: scheduled.kind !== 'unreadable' && scheduled.at !== null,
// #723: a paused repository keeps its row but is never a candidate; the
// route already withholds the row's answer for it, so this is the same
// condition stated where the choice is made.
runnable: repo.enabled === 1 && scheduled.kind !== 'unreadable' && scheduled.at !== null,
}
}
const state = nextRun({
Expand Down
6 changes: 3 additions & 3 deletions test/jobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ test('the digest says a disabled repository will not run when it has no schedule
}
})

test('the digest keeps the planner instant for a disabled repository with a schedule row', async () => {
test('the digest says a disabled repository will not run even with a schedule row', async () => {
const config = testConfig({ digest: { hour: 0, timeZone: 'Asia/Shanghai' } })
const store = new Store(':memory:')
const mailer = new MemoryMailer()
Expand All @@ -505,8 +505,8 @@ test('the digest keeps the planner instant for a disabled repository with a sche
await jobs.tick()
const digest = mailer.messages.find((message) => /今日摘要/.test(message.subject))
assert.ok(digest, 'the digest was sent')
assert.match(digest.text, new RegExp(`下次自动迭代:o/disabled-scheduled · ${escapeRegExp(expected)}`))
assert.doesNotMatch(digest.text, /仓库已停用,自动运行不会开始/, 'a saved row answers before enabled, exactly as on the page')
assert.match(digest.text, /下次自动迭代:o\/disabled-scheduled · 仓库已停用,自动运行不会开始/, 'enabled answers before the saved row')
assert.doesNotMatch(digest.text, new RegExp(escapeRegExp(expected)), 'a paused repository is not promised a run')
} finally {
store.close()
}
Expand Down
110 changes: 108 additions & 2 deletions test/nextScheduledRun.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ test('the shared next-run helper chooses the planner for a saved row', () => {
assert.equal(next.at, expected)
store.updateRepo(repo.id, { enabled: 0 })
const disabled = nextRunFor({ repo: store.getRepo(repo.id)!, row, openWork: true, activeJob: false, instanceReady: true, workCooldownMinutes: COOLDOWN_MINUTES, now: NOW })
assert.equal(disabled.state, 'scheduled', 'a saved row answers before enabled, exactly as on the page')
assert.equal(disabled.at, expected)
assert.equal(disabled.state, 'disabled', 'enabled answers before the saved row, exactly as the worker does')
assert.equal(disabled.at, null)
store.updateRepo(repo.id, { enabled: 1 })

const unreadable = store.saveSchedule({ id: 'sch_for_bad', repoId: repo.id, kind: 'cron', expression: null, timezone: 'Asia/Shanghai' })
assert.deepEqual(
Expand Down Expand Up @@ -298,3 +299,108 @@ test('the dashboard answers with the schedule row, not the interval, when one ex
store.close()
}
})

// #723: the worker only ever considers an enabled repository (`listAutoRepos` is
// `WHERE enabled = 1 AND auto_iterate = 1`) and a manual start on a paused one is
// refused with REPO_DISABLED, so no surface may name a run for it. These cases
// cover the two surfaces the card measured.
function seedAllClear(store: Store, user: ReturnType<typeof makeUser>, repoId: string): void {
const at = new Date().toISOString()
store.saveRepoInstance({
repo_id: repoId, user_id: user.id, container: 'c', network: 'n', data_dir: '/tmp/disabled', bridge_script: '/tmp/bridge',
dsh_port: 1, bridge_port: 2, model_port: 3, model_bind: '127.0.0.1', image: 'image', status: 'ready',
chief_session: 'c', team_json: null, bridge_token_enc: null, bridge_pid: null, log_pid: null,
last_error: null, created_at: at, updated_at: at, last_health_at: null,
})
store.createModelProvider({ id: `prov_${repoId}`, userId: user.id, label: 'p', baseUrl: 'https://example.test', keyEnc: 'x', allowPrivate: false })
store.createProviderModel({ id: `pm_${repoId}`, providerId: `prov_${repoId}`, model: 'm', label: 'm' })
// #732: a provider row is not an own model until a repository selects it, and
// this deployment configures no platform model, so the selection is what makes
// the first-run check complete.
store.setRepoModelSelection(repoId, [`pm_${repoId}`], `pm_${repoId}`)
store.createJob({ id: `job_${repoId}`, repoId, userId: user.id, trigger: 'manual', goal: 'g', callbackHash: `cb_${repoId}`, platformTokenHash: '' })
store.updateJob(`job_${repoId}`, { status: 'succeeded' })
}

function nextRunRepo(store: Store, user: ReturnType<typeof makeUser>, id: string, name: string) {
return store.createRepo({
id, userId: user.id, owner: 'acme', name, defaultBranch: 'main', tokenEnc: 'x', permission: 'admin',
repoRole: 'maintainer', goal: '', autoIterate: true, intervalMinutes: INTERVAL_MINUTES,
})
}

test('a disabled repository with a schedule row says so on the page, not a planner instant', async () => {
const config = testConfig()
const store = new Store(':memory:')
const built = await buildApp({ config, store, mailer: new MemoryMailer(), runner: new MockRunner(), startScheduler: false })
try {
const user = makeUser(store, { login: 'next-run-disabled-page', status: 'approved' })
const session = sessionFor(built, user)
const repo = nextRunRepo(store, user, 'repo_disabled_row', 'paused')
store.updateRepo(repo.id, { last_run_at: LAST_RUN_RECENT })
store.createWorkItem({ id: 'wi_disabled_row', repoId: repo.id, userId: user.id, title: 'open work', detail: '' })
store.saveSchedule({ id: 'sch_disabled_row', repoId: repo.id, kind: 'cron', expression: '0 9 * * 1-5', timezone: 'Asia/Shanghai' })
store.updateRepo(repo.id, { enabled: 0 })

const page = await built.app.inject({ method: 'GET', url: `/repos/${repo.id}`, headers: { cookie: session.cookie } })
assert.equal(page.statusCode, 200)
assert.equal(repoLine(page.body), '仓库已停用,自动运行不会开始', 'a paused repository is not given a next run')
const source = /data-next-source="([^"]*)"/.exec(page.body)?.[1]
assert.equal(source, 'interval', 'the line is not tagged as the planner answering for a paused repository')
} finally {
await built.app.close()
store.close()
}
})

test('the dashboard never names a paused repository while an enabled one has a next run', async () => {
const config = testConfig()
const store = new Store(':memory:')
const built = await buildApp({ config, store, mailer: new MemoryMailer(), runner: new MockRunner(), startScheduler: false })
try {
const user = makeUser(store, { login: 'next-run-disabled-dash', status: 'approved' })
const session = sessionFor(built, user)
const paused = nextRunRepo(store, user, 'repo_dash_paused', 'paused')
const enabled = nextRunRepo(store, user, 'repo_dash_enabled', 'enabled')
seedAllClear(store, user, enabled.id)
// The paused repository's cron is earlier, so before the fix it is both
// runnable and the earliest candidate, and the card names it.
store.saveSchedule({ id: 'sch_dash_paused', repoId: paused.id, kind: 'cron', expression: '0 * * * *', timezone: 'Asia/Shanghai' })
store.saveSchedule({ id: 'sch_dash_enabled', repoId: enabled.id, kind: 'cron', expression: '0 9 * * 1-5', timezone: 'Asia/Shanghai' })
store.updateRepo(paused.id, { enabled: 0 })

const dashboard = await built.app.inject({ method: 'GET', url: '/dashboard', headers: { cookie: session.cookie } })
assert.equal(dashboard.statusCode, 200)
const section = /<section class="card" id="next-action"[\s\S]*?<\/section>/.exec(dashboard.body)?.[0] ?? ''
assert.match(section, /data-next-action="clear"/, 'the fixture reaches the all-clear card, where the next run is named')
assert.match(section, /下次自动运行:acme\/enabled/, 'the enabled repository is the next automatic run')
assert.doesNotMatch(section, /acme\/paused/, 'a paused repository is never named as the next automatic run')
} finally {
await built.app.close()
store.close()
}
})

test('with only paused repositories the dashboard says runs are paused and names no instant', async () => {
const config = testConfig()
const store = new Store(':memory:')
const built = await buildApp({ config, store, mailer: new MemoryMailer(), runner: new MockRunner(), startScheduler: false })
try {
const user = makeUser(store, { login: 'next-run-disabled-only', status: 'approved' })
const session = sessionFor(built, user)
const paused = nextRunRepo(store, user, 'repo_dash_only_paused', 'paused')
seedAllClear(store, user, paused.id)
store.saveSchedule({ id: 'sch_dash_only_paused', repoId: paused.id, kind: 'cron', expression: '0 9 * * 1-5', timezone: 'Asia/Shanghai' })
store.updateRepo(paused.id, { enabled: 0 })

const dashboard = await built.app.inject({ method: 'GET', url: '/dashboard', headers: { cookie: session.cookie } })
assert.equal(dashboard.statusCode, 200)
const section = /<section class="card" id="next-action"[\s\S]*?<\/section>/.exec(dashboard.body)?.[0] ?? ''
assert.match(section, /data-next-action="clear"/, 'the fixture reaches the all-clear card')
assert.match(section, /自动运行暂停:acme\/paused · 仓库已停用,自动运行不会开始/, 'the card says why nothing runs')
assert.doesNotMatch(section, /\d{2}:\d{2}/, 'no instant is printed for a paused repository')
} finally {
await built.app.close()
store.close()
}
})
Loading