diff --git a/changelog.d/803-discovery-promotion.added.md b/changelog.d/803-discovery-promotion.added.md new file mode 100644 index 00000000..020fdeeb --- /dev/null +++ b/changelog.d/803-discovery-promotion.added.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 0047c91d..da4e80ca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/src/config.ts b/src/config.ts index 1da9e599..6854bb16 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 @@ -286,6 +290,8 @@ export function loadConfig(overrides: Partial = {}): 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)), diff --git a/src/jobs/discoveryPromotion.ts b/src/jobs/discoveryPromotion.ts new file mode 100644 index 00000000..b01bfb2b --- /dev/null +++ b/src/jobs/discoveryPromotion.ts @@ -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 } +} diff --git a/src/jobs/service.ts b/src/jobs/service.ts index 8a7eaebe..d8995af7 100644 --- a/src/jobs/service.ts +++ b/src/jobs/service.ts @@ -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' @@ -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' @@ -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({ @@ -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 }) diff --git a/test/discoveryPromotion.test.ts b/test/discoveryPromotion.test.ts new file mode 100644 index 00000000..4ef89852 --- /dev/null +++ b/test/discoveryPromotion.test.ts @@ -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() + } +}) diff --git a/test/jobs.test.ts b/test/jobs.test.ts index af334b7a..273740da 100644 --- a/test/jobs.test.ts +++ b/test/jobs.test.ts @@ -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 { @@ -2517,12 +2520,15 @@ 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 { @@ -2530,6 +2536,34 @@ test('a discover callback with an issue creates exactly one work item', async () } }) +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:')