Skip to content
Open
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/803-discovery-promotion.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Discovery can now promote a successful discovered issue into one proposed work item when AUTO_PROMOTE_DISCOVERY is enabled; promotions are deduplicated by issue URL, capped per UTC day and never create a branch or pull request. The default remains off.
5 changes: 4 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1282,7 +1282,10 @@ open work it returns `none` with reason `idle`: the scheduler writes
discovery interval is due, it may instead run one `discover` job; that job may
create at most one concrete issue (never a PR/branch), and if it finds nothing
concrete it creates nothing. The callback refuses a discovery result that carries
a branch or pull request (`DISCOVERY_PR_FORBIDDEN`). The job goal gets a bounded
a branch or pull request (`DISCOVERY_PR_FORBIDDEN`). A discovered issue does not
create a work item by default; with `AUTO_PROMOTE_DISCOVERY` enabled,
`promoteDiscoveredIssue` creates one `proposed` work item, deduped by issue URL
and capped per UTC day. It never creates a branch or a pull request. The job goal gets a bounded
`Carry-over` block with the last three run digests and up to four open items; the
block is data, not an instruction, and is capped well below the 4000-character
goal limit. Every line that names an issue or a pull request is checked once
Expand Down
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export interface Config {
// erased. The sweep's purge phase reads this; 72 hours is the host-side cron it
// replaces.
trialPurgeGraceHours: number
/** #803: promote a discovered issue into one proposed work item. Off by default. */
autoPromoteDiscovery: boolean
/** #803: maximum automatic discovery promotions per UTC day; 0 disables them. */
autoPromoteDiscoveryDailyCap: number
// #708: the per-class windows mutating routes take. A route names its class;
// the numbers live here so a deployment tightens a limit without a rebuild.
rateLimit: RateLimitBudgets
Expand Down Expand Up @@ -286,6 +290,8 @@ export function loadConfig(overrides: Partial<Config> = {}): Config {
},
recentAuthMinutes: Math.max(1, number('RECENT_AUTH_MINUTES', 15)),
trialPurgeGraceHours: Math.max(1, number('TRIAL_PURGE_GRACE_HOURS', 72)),
autoPromoteDiscovery: bool('AUTO_PROMOTE_DISCOVERY', false),
autoPromoteDiscoveryDailyCap: Math.max(0, number('AUTO_PROMOTE_DISCOVERY_DAILY_CAP', 3)),
rateLimit: {
login: {
max: Math.max(1, number('RATE_LIMIT_LOGIN_MAX', DEFAULT_RATE_LIMIT_BUDGETS.login.max)),
Expand Down
37 changes: 37 additions & 0 deletions src/jobs/discoveryPromotion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Config } from '../config.js'
import type { Store } from '../db.js'
import { randomId } from '../lib/secrets.js'

// #803: discovery stays issue-only. This is the explicit, opt-in bridge from a
// discovered issue to the normal work-item queue. It never creates a branch or
// a pull request and it never changes the issue itself.
export type DiscoveryPromotionStatus = 'promoted' | 'disabled' | 'duplicate' | 'cap' | 'no-issue'

export interface DiscoveryPromotionResult {
status: DiscoveryPromotionStatus
workItemId?: string
}

export function promoteDiscoveredIssue(
store: Store,
config: Config,
input: { repoId: string; userId: string; issueUrl: string | null; title?: string; now?: Date },
): DiscoveryPromotionResult {
const issueUrl = input.issueUrl ?? ''
if (issueUrl === '') return { status: 'no-issue' }
if (!config.autoPromoteDiscovery || config.autoPromoteDiscoveryDailyCap <= 0) return { status: 'disabled' }

const existing = store.findWorkItemByIssueUrl(input.repoId, issueUrl)
if (existing !== undefined) return { status: 'duplicate', workItemId: existing.id }

const day = (input.now ?? new Date()).toISOString().slice(0, 10)
const key = `discover-promote:${day}`
const used = Number(store.getSetting(key) ?? 0)
if (used >= config.autoPromoteDiscoveryDailyCap) return { status: 'cap' }

const id = randomId('work')
const title = (input.title ?? '').trim().slice(0, 160) || issueUrl
store.createWorkItem({ id, repoId: input.repoId, userId: input.userId, title, issueUrl, state: 'proposed' })
store.setSetting(key, String(used + 1))
return { status: 'promoted', workItemId: id }
}
19 changes: 17 additions & 2 deletions src/jobs/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { addMinutes, nowIso } from '../lib/time.js'
import { GithubClient, GithubError, prepareRepo, prepareRepoWithInstallation, repoPermissionWithToken } from '../repos/github.js'
import { GithubApp } from '../repos/app.js'
import { parseIssueUrl, parsePrUrl } from './prUrl.js'
import { promoteDiscoveredIssue } from './discoveryPromotion.js'
import { buildCarryOver, buildRunDigest, decideNextJob, failureStreak, lastDurationMs, MIN_DISCOVERY_INTERVAL_MINUTES, parseCarryOverLink, type CarryOverReader } from './continuity.js'
import { planTick, scheduleFromRow, type TickPlan } from '../schedules/tick.js'
import { utcDayEnd, utcDayStart } from '../schedules/budget.js'
Expand Down Expand Up @@ -1327,7 +1328,7 @@ export class Jobs {
}
}

private syncWorkItemForJob(job: JobRow, result: { status: 'succeeded' | 'failed' | 'quota_exhausted'; prUrl: string | null; issueUrl: string | null }): void {
private syncWorkItemForJob(job: JobRow, result: { status: 'succeeded' | 'failed' | 'quota_exhausted'; prUrl: string | null; issueUrl: string | null; summary?: string }): void {
const existing = job.work_item_id === null ? undefined : this.store.getWorkItem(job.work_item_id)
if (existing !== undefined) {
const prState = result.prUrl === null ? existing.pr_state : 'open'
Expand All @@ -1348,6 +1349,20 @@ export class Jobs {
})
return
}
if (job.kind === 'discover') {
const promotion = promoteDiscoveredIssue(this.store, this.config, {
repoId: job.repo_id,
userId: job.user_id,
issueUrl: result.issueUrl,
title: result.summary ?? '',
})
this.store.audit(
job.user_id,
promotion.status === 'promoted' ? 'job.discovery_promoted' : 'job.discovery_promotion_skipped',
`${job.id}: ${promotion.status}${promotion.workItemId === undefined ? '' : ` ${promotion.workItemId}`}`,
)
return
}
if (result.status !== 'succeeded' || (result.prUrl === null && result.issueUrl === null)) return
const title = (result.prUrl ?? result.issueUrl ?? job.goal).slice(0, 160)
this.store.createWorkItem({
Expand Down Expand Up @@ -1422,7 +1437,7 @@ export class Jobs {
const started = Date.parse(job.started_at ?? job.created_at)
const ended = Date.parse(nowIso())
if (Number.isFinite(started) && Number.isFinite(ended)) observeHistogram('fledge_job_seconds', Math.max(0, (ended - started) / 1000), { kind: job.kind, status: result.status }, 'Job duration')
this.syncWorkItemForJob(job, { status: result.status, prUrl, issueUrl })
this.syncWorkItemForJob(job, { status: result.status, prUrl, issueUrl, summary })
const finalJob = this.store.getJob(jobId) ?? job
const digest = buildRunDigest(finalJob, { status: result.status, summary, issueUrl, prUrl })
this.store.updateJob(jobId, { continuity_json: JSON.stringify(digest), output_kind: digest.output_kind })
Expand Down
64 changes: 64 additions & 0 deletions test/discoveryPromotion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { Store } from '../src/db.js'
import { promoteDiscoveredIssue } from '../src/jobs/discoveryPromotion.js'
import { makeUser, testConfig } from './helpers.js'

function fixture() {
const store = new Store(':memory:')
const user = makeUser(store, { login: 'discovery-promote', status: 'approved' })
const repo = store.createRepo({ id: 'repo_discovery_promote', userId: user.id, owner: 'o', name: 'r', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: false, intervalMinutes: 1440 })
return { store, user, repo }
}

const now = new Date('2026-11-15T00:00:00.000Z')
const issue = (n: number) => `https://github.com/o/r/issues/${String(n)}`

test('discovery promotion is off by default and never creates a work item', () => {
const { store, user, repo } = fixture()
try {
const result = promoteDiscoveredIssue(store, testConfig(), { repoId: repo.id, userId: user.id, issueUrl: issue(1), now })
assert.equal(result.status, 'disabled')
assert.equal(store.findWorkItemByIssueUrl(repo.id, issue(1)), undefined)
} finally {
store.close()
}
})

test('an enabled promotion creates one proposed work item, dedupes the issue and caps the day', () => {
const { store, user, repo } = fixture()
try {
const config = testConfig({ autoPromoteDiscovery: true, autoPromoteDiscoveryDailyCap: 2 })
const first = promoteDiscoveredIssue(store, config, { repoId: repo.id, userId: user.id, issueUrl: issue(1), title: 'First discovered issue', now })
assert.equal(first.status, 'promoted')
const item = store.getWorkItem(first.workItemId ?? '')
assert.ok(item !== undefined)
assert.equal(item.state, 'proposed')
assert.equal(item.issue_url, issue(1))
assert.equal(item.pr_url, null)

const duplicate = promoteDiscoveredIssue(store, config, { repoId: repo.id, userId: user.id, issueUrl: issue(1), now })
assert.equal(duplicate.status, 'duplicate')
assert.equal(duplicate.workItemId, first.workItemId)

const second = promoteDiscoveredIssue(store, config, { repoId: repo.id, userId: user.id, issueUrl: issue(2), now })
assert.equal(second.status, 'promoted')

const capped = promoteDiscoveredIssue(store, config, { repoId: repo.id, userId: user.id, issueUrl: issue(3), now })
assert.equal(capped.status, 'cap')
assert.equal(store.findWorkItemByIssueUrl(repo.id, issue(3)), undefined)
assert.equal(store.getSetting('discover-promote:2026-11-15'), '2')
} finally {
store.close()
}
})

test('a discovery result without an issue URL is not promoted', () => {
const { store, user, repo } = fixture()
try {
const config = testConfig({ autoPromoteDiscovery: true, autoPromoteDiscoveryDailyCap: 3 })
assert.equal(promoteDiscoveredIssue(store, config, { repoId: repo.id, userId: user.id, issueUrl: null, now }).status, 'no-issue')
} finally {
store.close()
}
})
42 changes: 38 additions & 4 deletions test/jobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2503,8 +2503,11 @@ test('a discover callback cannot open a branch or pull request', async () => {
}
})

test('a discover callback with an issue creates exactly one work item', async () => {
const config = testConfig()
// #803: discover no longer promotes unconditionally. With the switch off the
// callback leaves the backlog alone; with it on, exactly one `proposed` item is
// the bridge into the normal work-item queue (never a branch or PR).
test('a discover callback with an issue creates exactly one proposed work item when promotion is on', async () => {
const config = testConfig({ autoPromoteDiscovery: true, autoPromoteDiscoveryDailyCap: 3 })
const store = new Store(':memory:')
const jobs = new Jobs(store, config, { name: 'noop', async run() { return { status: 'running' } } }, new MemoryMailer())
try {
Expand All @@ -2517,19 +2520,50 @@ test('a discover callback with an issue creates exactly one work item', async ()
const result = await jobs.handleCallback('job_discover_issue', secret, { status: 'succeeded', issueUrl, summary: 'found a concrete bug' })
assert.equal(result.ok, true)
const item = store.findWorkItemByIssueUrl(repo.id, issueUrl)
assert.equal(item?.state, 'in_progress')
assert.equal(item?.state, 'proposed')
assert.equal(item?.pr_url, null)
assert.equal(store.listWorkItems(repo.id).length, 1)
assert.equal(store.getJob('job_discover_issue')?.output_kind, 'issue')
assert.ok(store.listAudit().some((entry) => entry.action === 'job.discovery_promoted'))

await jobs.tick()
const after = store.listJobsForRepo(repo.id)
assert.equal(after.length, 2, 'the discovered issue feeds the next work session')
assert.equal(after.length, 2, 'the proposed issue feeds the next work session')
assert.equal(after[0]?.kind, 'iterate')
assert.equal(after[0]?.work_item_id, item?.id)
} finally {
store.close()
}
})

test('a discover callback leaves the backlog unchanged when promotion is off', async () => {
const config = testConfig()
const store = new Store(':memory:')
const jobs = new Jobs(store, config, { name: 'noop', async run() { return { status: 'running' } } }, new MemoryMailer())
try {
const user = makeUser(store, { login: 'discover-no-promote-user', status: 'approved' })
const repo = store.createRepo({ id: 'repo_discover_no_promote', userId: user.id, owner: 'o', name: 'r', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'developer', upstreamHtmlUrl: 'https://github.com/o/r', goal: '', autoIterate: true, intervalMinutes: 60 })
const secret = 'discover-no-promote-secret'
store.createJob({ id: 'job_discover_no_promote', repoId: repo.id, userId: user.id, trigger: 'schedule', goal: 'discover', callbackHash: sha256Hex(secret), platformTokenHash: '', kind: 'discover' })
store.updateJob('job_discover_no_promote', { status: 'running', started_at: new Date().toISOString() })
const issueUrl = 'https://github.com/o/r/issues/8'
const result = await jobs.handleCallback('job_discover_no_promote', secret, { status: 'succeeded', issueUrl, summary: 'found a concrete bug' })
assert.equal(result.ok, true)
assert.equal(store.findWorkItemByIssueUrl(repo.id, issueUrl), undefined)
assert.equal(store.listWorkItems(repo.id).length, 0)
assert.equal(store.getJob('job_discover_no_promote')?.output_kind, 'issue')
const skipped = store.listAudit().find((entry) => entry.action === 'job.discovery_promotion_skipped')
assert.ok(skipped !== undefined)
assert.ok(String(skipped.detail).includes('disabled'))

await jobs.tick()
assert.equal(store.listWorkItems(repo.id).length, 0)
assert.equal(store.listJobsForRepo(repo.id).filter((job) => job.kind === 'iterate').length, 0)
} finally {
store.close()
}
})

test('the scheduler idles with no work and starts once a work item appears', async () => {
const config = testConfig()
const store = new Store(':memory:')
Expand Down
Loading