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/713.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The daily digest's next-run line now uses the same rule as the repository page and dashboard. A repository with a saved schedule row is answered by the planner, and a disabled, not-ready, idle, active-job or never-run repository prints the page's sentence instead of an interval time; a disabled repository with a saved row keeps the planner's instant. The email no longer promises a run that the page says will not start, and a cron repository no longer gets a different next-run time in the digest.
6 changes: 5 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,11 @@ defect.
because that row owns its cadence; the dashboard route passes the instant with
the repository's facts and both surfaces render it through `plannedNextLine`, so
the card cannot fall back to the interval rule for a scheduled repository and
disagree with the page below it. The dashboard route passes open work, the
disagree with the page below it. The daily digest is the third surface: `buildDigest`
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
active job and instance readiness per repository because those live outside
`RepoRow`; the two surfaces therefore print one string, and
the wording says "next run", never an estimate. The newest job is read on its own, not
Expand Down
66 changes: 66 additions & 0 deletions docs/decisions/0017-one-next-run-decision-three-surfaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# 0017 — one next-run decision, three surfaces

Status: **accepted** (2026-11-15)
Date: 2026-11-15
Supersedes: nothing; it applies the one-next-run rule to the daily digest

## Context

The repository page and the dashboard already print one next-run time per
repository. The rule that picks it is decided by the repository, not by the
surface: a repository with a saved schedule row is answered by the planner
(`src/schedules/next.ts`, `nextTime`), and a repository without one is answered
by the legacy interval rule (`src/lib/nextRun.ts`, `nextRun`, through
`dueAfterMs`).

The daily digest was the third surface and the only one outside that rule.
`buildDigest` (`src/jobs/service.ts`) computed `last_run_at + min(interval,
workCooldownMinutes)` for every `auto_iterate = 1` repository. It never read
`store.scheduleForRepo()`, `repos.enabled` or the interval floor, so a cron
repository got a different time in the email, and a disabled repository was
promised a run the page said would never start. The digest cannot import the
view helpers that make this choice in `src/views/pages.ts`.

## Decision

Add `src/lib/nextRunFor.ts` and use it from `buildDigest`:

- `nextRunFor` takes the `RepoRow`, the optional `ScheduleRow`, open-work,
active-job and instance-ready facts, plus the work cooldown.
- A saved row is answered by `scheduleFromRow` + `nextTime`; a row the planner
cannot read answers `unknown-unreadable`, and a closed window answers
`unknown-closed`. The state for a live instant is `due` when it is at or
before `now`, otherwise `scheduled`, exactly as the page renders it.
- A repository without a row delegates to `nextRun`, so the interval instant,
the disabled/not-ready/auto-off/active-job/idle/waiting-first-run states and
the 15-minute floor stay in one place.
- The digest maps those states to its own Chinese sentences. It does not import
`src/views/**`; only the wording is duplicated, because the email is a
Chinese-only surface today.

The instance-ready fallback stays in the caller: `config.flybot.instanceImage
=== '' ? true : instance?.status === 'ready'`, the same guard the page and
dashboard use, so a deployment with no FlyBot image never prints "not ready".

## Rejected

- Keeping the formula in `buildDigest`: it is a second implementation of the
rule, and the page already proves the failure mode.
- Widening `NextRunState` with the two planner answers: `nextRunLine` switches on
that union exhaustively, so a new member would drag a view change into this
slice.
- Moving `nextRunLine`/`plannedNextLine` out of `src/views/pages.ts`: that
re-points every view reference and the route's `ScheduledNext` contract for a
one-line email contradiction.

## Consequences

- The choice between a saved row and the interval rule now lives in the route
(for the two existing surfaces) and in `nextRunFor` (for the digest). The
instant itself remains single-sourced in `nextTime`/`dueAfterMs`; a later card
can collapse the route onto the helper if the view-layer refactor is worth it.
- The digest now prints the page's state sentences for disabled,
not-ready, auto-off, active-job, idle and never-run repositories, and the
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.
34 changes: 31 additions & 3 deletions src/jobs/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import { parseModelRun } from '../lib/modelRun.js'
import { retryDecision, reviewRetryDecision } from '../lib/retry.js'
import { jobEmailDecision } from '../lib/emailPolicy.js'
import { nextRunFor, type NextRunFor } from '../lib/nextRunFor.js'
import { duplicateOutcome, issueNumberFromText } from '../lib/duplicates.js'
import { incCounter, observeHistogram, setGauge } from '../lib/metrics.js'
import { checkStage, parseStageMode } from '../lib/stages.js'
Expand Down Expand Up @@ -825,7 +826,7 @@
}
}

private async preflight(repo: RepoRow, baseBranch: string, trigger: string): Promise<{ ok: boolean; baseSha?: string; warning?: string; error?: string; code?: EnqueueRefusalCode; detail?: string }> {

Check warning on line 829 in src/jobs/service.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unused-vars)

src/jobs/service.ts:829:62: Parameter 'trigger' is declared but never used. Unused parameters should start with a '_'.
if (this.runner.name !== 'flybot') return { ok: true }
try {
const token = await repoGithubToken(repo, this.config, this.githubApp, this.store)
Expand Down Expand Up @@ -2611,6 +2612,26 @@
}).format(value)
}

// The digest's next-run line is the same decision the page and dashboard make,
// but the email owns its own Chinese wording because `src/jobs` must not import
// `src/views/**` (#713). The instant itself comes from `nextRunFor`, which
// delegates to `nextTime`/`dueAfterMs`, so the three surfaces cannot disagree
// about the time.
private digestNextRunLine(next: NextRunFor): string {
switch (next.state) {
case 'disabled': return '仓库已停用,自动运行不会开始'
case 'not-ready': return 'FlyBot 实例未就绪,自动运行不会开始'
case 'auto-off': return '自动工作已关闭'
case 'active-job': return '当前任务结束后再排'
case 'idle': return '空闲:没有开放工作项;会按发现间隔检查'
case 'waiting-first-run': return '等待首次运行'
case 'due': return '等待调度'
case 'scheduled': return this.digestTime(new Date(next.at ?? Date.now()))
case 'unknown-unreadable': return 'unknown(排期无法解析)'
case 'unknown-closed': return 'unknown(排期窗口已结束)'
}
}

private async buildDigest(user: UserRow, repos: RepoRow[], date: string): Promise<DigestInput> {
const since = new Date(Date.now() - 24 * 60 * 60 * 1_000).toISOString()
const jobs = this.store.listJobsSince(user.id, since)
Expand All @@ -2622,9 +2643,16 @@
const label = `${repo.upstream_owner || repo.owner}/${repo.upstream_name || repo.name}`
teamLinks.push({ repo: label, url: `${this.config.baseUrl}/repos/${repo.id}#inbox` })
if (repo.auto_iterate === 1) {
const last = repo.last_run_at === null ? Number.NaN : Date.parse(repo.last_run_at)
const next = Number.isFinite(last) ? new Date(last + Math.min(repo.interval_minutes, this.config.runner.workCooldownMinutes) * 60_000) : new Date()
nextRuns.push({ repo: label, when: next.getTime() <= Date.now() ? '等待调度' : this.digestTime(next) })
const instance = this.store.getRepoInstance(repo.id)
const next = nextRunFor({
repo,
row: this.store.scheduleForRepo(repo.id),
openWork: this.store.listOpenWorkItems(repo.id).length > 0,
activeJob: this.store.activeJobForRepo(repo.id) !== undefined,
instanceReady: this.config.flybot.instanceImage === '' ? true : instance?.status === 'ready',
workCooldownMinutes: this.config.runner.workCooldownMinutes,
})
nextRuns.push({ repo: label, when: this.digestNextRunLine(next) })
}
if (this.instances === undefined || this.config.flybot.instanceImage === '') continue
try {
Expand Down
60 changes: 60 additions & 0 deletions src/lib/nextRunFor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// #713: the third surface of the one next-run rule.
//
// `src/lib/nextRun.ts` owns the legacy interval answer and
// `src/schedules/next.ts` owns the planner answer. The repository page and the
// dashboard already pick between them in the view layer, which a job module
// cannot call. This function is that choice, moved where the digest can reach it
// without importing `src/views/**`.
//
// The instant stays single-sourced: the interval branch delegates to `nextRun`
// (and through it `dueAfterMs`), and the schedule branch delegates to
// `scheduleFromRow` + `nextTime`. Only the state names are this module's own.
import type { RepoRow, ScheduleRow } from '../db.js'
import { nextRun, type NextRunState } from './nextRun.js'
import { nextTime } from '../schedules/next.js'
import { scheduleFromRow } from '../schedules/tick.js'

export type NextRunForState = NextRunState | 'unknown-unreadable' | 'unknown-closed'

export interface NextRunForInput {
repo: RepoRow
/** Present only when the repository has saved a schedule row. */
row: ScheduleRow | undefined
openWork: boolean
activeJob: boolean
/**
* The caller applies the same fallback the page and dashboard use
* (`config.flybot.instanceImage === '' ? true : instance?.status === 'ready'`),
* so a deployment with no FlyBot image never reports `not-ready`.
*/
instanceReady: boolean
workCooldownMinutes: number
now?: number
}

export interface NextRunFor {
at: number | null
state: NextRunForState
}

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({
lastRunAt: input.repo.last_run_at,
intervalMinutes: input.repo.interval_minutes,
workCooldownMinutes: input.workCooldownMinutes,
autoIterate: input.repo.auto_iterate === 1,
hasOpenWork: input.openWork,
hasActiveJob: input.activeJob,
repoEnabled: input.repo.enabled === 1,
instanceReady: input.instanceReady,
now,
})
}
88 changes: 88 additions & 0 deletions test/jobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { buildCarryOver, buildRunDigest, classifyOutput, decideNextJob, failureS
import type { Runner, RunnerContext } from '../src/jobs/runner.js'
import { encryptSecret, sha256Hex } from '../src/lib/secrets.js'
import { REVIEW_INSTRUCTION_MAX, REVIEW_INSTRUCTION_LABEL } from '../src/lib/followUpInstruction.js'
import { nextTime } from '../src/schedules/next.js'
import { scheduleFromRow } from '../src/schedules/tick.js'
import { json, makeUser, MemoryMailer, readBody, startFakeServer, testConfig } from './helpers.js'

test('iterate dispatches to the Chief and review to the Reviewer', async () => {
Expand Down Expand Up @@ -395,6 +397,92 @@ test('daily digest sends once and includes the day summary', async () => {
}
})

function digestTime(at: number): string {
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(new Date(at))
}

function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

test('the digest prints the planner next run for a scheduled repository', async () => {
const config = testConfig({ digest: { hour: 0, timeZone: 'Asia/Shanghai' } })
const store = new Store(':memory:')
const mailer = new MemoryMailer()
const jobs = new Jobs(store, config, { name: 'noop', async run() { return { status: 'running' } } }, mailer)
try {
const user = makeUser(store, { login: 'digest-schedule', status: 'approved' })
const now = Date.now()
const repo = store.createRepo({ id: 'repo_digest_schedule', userId: user.id, owner: 'o', name: 'schedule', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: true, intervalMinutes: 1440 })
store.updateRepo(repo.id, { last_run_at: new Date(now - 10 * 60_000).toISOString() })
const row = store.saveSchedule({ id: 'sch_digest', repoId: repo.id, kind: 'cron', expression: '0 9 * * 1-5', timezone: 'Asia/Shanghai' })
const schedule = scheduleFromRow(row)
assert.ok(schedule !== null)
const expected = digestTime(nextTime(schedule, Date.now())!)
const intervalAnswer = digestTime(now - 10 * 60_000 + 15 * 60_000)
assert.notEqual(expected, intervalAnswer, 'the fixture separates the planner answer from the interval answer')

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/schedule · ${escapeRegExp(expected)}`))
assert.doesNotMatch(digest.text, new RegExp(escapeRegExp(intervalAnswer)))
} finally {
store.close()
}
})

test('the digest says a disabled repository will not run when it has no schedule row', async () => {
const config = testConfig({ digest: { hour: 0, timeZone: 'Asia/Shanghai' } })
const store = new Store(':memory:')
const mailer = new MemoryMailer()
const jobs = new Jobs(store, config, { name: 'noop', async run() { return { status: 'running' } } }, mailer)
try {
const user = makeUser(store, { login: 'digest-disabled', status: 'approved' })
const repo = store.createRepo({ id: 'repo_digest_disabled', userId: user.id, owner: 'o', name: 'disabled', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: true, intervalMinutes: 1440 })
store.updateRepo(repo.id, { enabled: 0, last_run_at: new Date(Date.now() - 10 * 60_000).toISOString() })

await jobs.tick()
const digest = mailer.messages.find((message) => /今日摘要/.test(message.subject))
assert.ok(digest, 'the digest was sent')
assert.match(digest.text, /下次自动迭代:o\/disabled · 仓库已停用,自动运行不会开始/)
assert.doesNotMatch(digest.text, /下次自动迭代:o\/disabled · \d{2}\/\d{2}/, 'no instant is promised for a disabled repository')
} finally {
store.close()
}
})

test('the digest keeps the planner instant for a disabled repository with a schedule row', async () => {
const config = testConfig({ digest: { hour: 0, timeZone: 'Asia/Shanghai' } })
const store = new Store(':memory:')
const mailer = new MemoryMailer()
const jobs = new Jobs(store, config, { name: 'noop', async run() { return { status: 'running' } } }, mailer)
try {
const user = makeUser(store, { login: 'digest-disabled-scheduled', status: 'approved' })
const repo = store.createRepo({ id: 'repo_digest_disabled_scheduled', userId: user.id, owner: 'o', name: 'disabled-scheduled', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: true, intervalMinutes: 1440 })
store.updateRepo(repo.id, { enabled: 0, last_run_at: new Date(Date.now() - 10 * 60_000).toISOString() })
const row = store.saveSchedule({ id: 'sch_digest_disabled', repoId: repo.id, kind: 'cron', expression: '0 9 * * 1-5', timezone: 'Asia/Shanghai' })
const schedule = scheduleFromRow(row)
assert.ok(schedule !== null)
const expected = digestTime(nextTime(schedule, Date.now())!)

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')
} finally {
store.close()
}
})

test('plan jobs dispatch as plan and keep a checkpoint', async () => {
const config = testConfig()
const store = new Store(':memory:')
Expand Down
Loading
Loading