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/grounded-attribution.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Job attribution is now grounded in the actual per-role call counts: the job page and PR automation comment use only roles that acted, and a claimed role with zero calls is recorded as a mismatch event instead of being presented as team participation.
1 change: 1 addition & 0 deletions changelog.d/token-repo-backfill.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Existing `flybot:<repo>` instance tokens and `job:<id>` tokens are backfilled with their repository id at startup, so new model usage is attributed per repository instead of an unattributed bucket.
4 changes: 4 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,10 @@ The bridge's model proxy normally uses the per-repository instance token. While
a job is running it uses that job's short-lived `flg_` token instead (the
`/run` payload carries it), so the gateway can write `job_id` into
`quota_ledger` and add `jobs.cost_usd`. The job page shows cost, attribution and
Team attribution is grounded in the DSH per-role call counts: the job page and the
PR automation comment use only roles with actual calls, and a claimed role with
zero calls is recorded as a `team_attribution_grounded` mismatch event.

structured evidence (`scope`, `acceptance`, `testPlan`, `testRed`, `testGreen`,
`tests`, `checks`, `files`, `risk`, `rollback`); failed jobs have a one-click
retry. A repository can set a team stage gate: `off` keeps the old behaviour,
Expand Down
4 changes: 4 additions & 0 deletions docs/quota.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ carries `repo_id` resolves `body.model` to a selected connection and calls that
provider; an unselected model falls back to the user's single own model, then the
platform model. `/v1/models` lists the selected aliases for a repository token.

Existing `flybot:<repo>` instance tokens and `job:<id>` tokens are backfilled
with their repository id at startup, so new model usage is attributed per
repository. Historical ledger rows without a job id remain unattributed.

Changing the selection marks the repository dirty. `POST
/repos/:id/models/apply` rewrites the instance `cordis.patch.yml` and restarts
the workspace only when no job is active; a running job returns `JOB_ACTIVE`.
Expand Down
6 changes: 6 additions & 0 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,7 @@ export class Store {
this.db.exec(SCHEMA)
this.migrateColumns()
this.migrateRepoColumns()
this.backfillTokenRepoIds()
this.run("UPDATE repos SET job_mode = 'iterate' WHERE job_mode != 'iterate'")
if (previousVersion < 15) {
this.run("UPDATE repos SET repo_role = CASE WHEN permission IN ('admin', 'maintain') THEN 'maintainer' ELSE 'developer' END WHERE repo_role IS NULL")
Expand All @@ -611,6 +612,11 @@ export class Store {
this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 24)};`)
}

backfillTokenRepoIds(): void {
this.run("UPDATE api_tokens SET repo_id = substr(name, 8) WHERE kind = 'instance' AND name LIKE 'flybot:%' AND repo_id IS NULL")
this.run("UPDATE api_tokens SET repo_id = (SELECT repo_id FROM jobs WHERE jobs.id = substr(api_tokens.name, 5)) WHERE kind = 'job' AND name LIKE 'job:%' AND repo_id IS NULL")
}

private migrateColumns(): void {
const columns = new Set(this.all<{ name: string }>('PRAGMA table_info(users)').map((row) => row.name))
const additions: Array<[string, string]> = [
Expand Down
27 changes: 21 additions & 6 deletions src/jobs/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { isValidBranchName } from '../lib/branches.js'
import { attributionSentence, parseAttribution, type Attribution } from '../lib/attribution.js'
import { parseEvidence, type Evidence } from '../lib/evidence.js'
import { checkStage, parseStageMode } from '../lib/stages.js'
import { observedAttribution } from '../lib/teamStats.js'
import { decryptSecret, randomId, randomToken, safeEqual, sha256Hex } from '../lib/secrets.js'
import { addMinutes, nowIso } from '../lib/time.js'
import { GithubClient, GithubError, prepareRepo, prepareRepoWithInstallation, repoPermissionWithToken } from '../repos/github.js'
Expand Down Expand Up @@ -498,7 +499,6 @@ export class Jobs {
this.store.event(`job_${result.status}`, { userId: job.user_id, repoId: job.repo_id, jobId })
if (result.status === 'succeeded' && prUrl !== null) this.store.event('pr_open', { userId: job.user_id, repoId: job.repo_id, jobId })
if (result.status === 'failed') this.store.notify(job.user_id, 'job', `${job.kind === 'review' ? '审核' : '任务'} ${jobId} 失败:${result.error ?? job.error ?? '未知原因'}`)
if (result.status === 'succeeded') void this.postAutomationComment(job, { prUrl: prUrl ?? undefined, issueUrl: issueUrl ?? undefined }, attribution)
if (result.status === 'succeeded' || result.status === 'failed') {
void this.sendJobEmail(job, result)
void sendNotificationWebhook(this.config, `job.${result.status}`, {
Expand Down Expand Up @@ -530,7 +530,11 @@ export class Jobs {
this.store.incrementRepoFailures(job.repo_id)
this.checkRepeatedFailures(job)
}
void this.captureTeamStats(jobId)
if (result.status === 'succeeded') {
void this.afterSucceeded(job, prUrl ?? undefined, issueUrl ?? undefined, attribution).catch((error) => log.warn('post-job side effects failed', { job: jobId, error: String(error) }))
} else {
void this.captureTeamStats(jobId, attribution).catch((error) => log.warn('team stats capture failed', { job: jobId, error: String(error) }))
}
}

private checkRepeatedFailures(job: JobRow): void {
Expand Down Expand Up @@ -771,10 +775,15 @@ export class Jobs {
}
}

private async captureTeamStats(jobId: string): Promise<void> {
if (this.instances === undefined || this.config.flybot.instanceImage === '') return
private async afterSucceeded(job: JobRow, prUrl: string | undefined, issueUrl: string | undefined, claimed: Attribution | null): Promise<void> {
const observed = await this.captureTeamStats(job.id, claimed).catch(() => null)
await this.postAutomationComment(job, { prUrl, issueUrl }, observed ?? claimed)
}

private async captureTeamStats(jobId: string, claimed: Attribution | null = null): Promise<Attribution | null> {
if (this.instances === undefined || this.config.flybot.instanceImage === '') return null
const job = this.store.getJob(jobId)
if (job === undefined) return
if (job === undefined) return null
try {
const state = await this.instances.teamState(job.repo_id, 20_000)
const stats = state.bots.map((bot) => ({
Expand All @@ -786,8 +795,12 @@ export class Jobs {
step: bot.step,
lastActivityAt: bot.lastActivityAt,
}))
this.store.updateJob(jobId, { team_stats_json: JSON.stringify(stats) })
const observed = observedAttribution(stats)
this.store.updateJob(jobId, { team_stats_json: JSON.stringify(stats), ...(observed === null ? {} : { attribution_json: JSON.stringify(observed) }) })
this.store.event('team_stats', { userId: job.user_id, repoId: job.repo_id, jobId })
if (observed !== null && claimed !== null) {
this.store.event('team_attribution_grounded', { userId: job.user_id, repoId: job.repo_id, jobId, meta: `claimed=${Object.values(claimed).join(',')};observed=${Object.values(observed).join(',')}`.slice(0, 500) })
}
if (job.kind === 'iterate') {
const active = stats.filter((entry) => entry.calls > 0).map((entry) => entry.role)
if (active.length === 1 && active[0] === 'lead') {
Expand All @@ -798,8 +811,10 @@ export class Jobs {
log.warn('team participation low', { job: jobId, repo: job.repo_id, roles: active })
}
}
return observed
} catch (error) {
log.warn('team stats capture failed', { job: jobId, error: String(error) })
return null
}
}

Expand Down
26 changes: 26 additions & 0 deletions src/lib/teamStats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Attribution } from './attribution.js'

export interface TeamStat {
role: string
calls: number
}

export function observedAttribution(stats: TeamStat[]): Attribution | null {
const calls = (role: string): number => stats.find((entry) => entry.role === role)?.calls ?? 0
const out: Attribution = {}
if (calls('lead') > 0) out.claimedBy = 'Lead'
if (calls('product') > 0) {
out.claimedBy = out.claimedBy ?? 'Product'
out.writtenBy = 'Product'
}
if (calls('dev1') > 0) out.implementedBy = 'Dev-1'
else if (calls('dev2') > 0) out.implementedBy = 'Dev-2'
if (calls('test') > 0) out.testedBy = 'Test'
if (calls('reviewer') > 0) out.reviewedBy = 'Reviewer'
else if (calls('security') > 0) out.reviewedBy = 'Security'
return Object.keys(out).length === 0 ? null : out
}

export function observedRoles(stats: TeamStat[]): string[] {
return stats.filter((entry) => entry.calls > 0).map((entry) => entry.role)
}
18 changes: 18 additions & 0 deletions test/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,21 @@ test('pull request jobs disappear from the delivery list after merge', () => {
store.close()
}
})

test('existing instance and job tokens are backfilled with their repository', () => {
const store = new Store(':memory:')
try {
const user = makeUser(store, { login: 'backfill-user', status: 'approved' })
const repo = store.createRepo({ id: 'repo_backfill', userId: user.id, owner: 'o', name: 'b', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: false, intervalMinutes: 1440 })
store.createJob({ id: 'job_backfill', repoId: repo.id, userId: user.id, trigger: 'manual', goal: 'g', callbackHash: 'cb', platformTokenHash: '', kind: 'iterate' })
const instance = store.createApiToken({ id: 'tok_instance', userId: user.id, kind: 'instance', name: `flybot:${repo.id}`, tokenHash: 'h1', prefix: 'flg', expiresAt: null })
const jobToken = store.createApiToken({ id: 'tok_job', userId: user.id, kind: 'job', name: 'job:job_backfill', tokenHash: 'h2', prefix: 'flg', expiresAt: null })
assert.equal(instance.repo_id, null)
assert.equal(jobToken.repo_id, null)
store.backfillTokenRepoIds()
assert.equal(store.findApiToken('h1')?.repo_id, repo.id)
assert.equal(store.findApiToken('h2')?.repo_id, repo.id)
} finally {
store.close()
}
})
12 changes: 12 additions & 0 deletions test/teamStats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { observedAttribution, observedRoles } from '../src/lib/teamStats.js'

test('observed attribution only includes roles with actual calls', () => {
const leadOnly = observedAttribution([{ role: 'lead', calls: 4 }, { role: 'dev1', calls: 0 }, { role: 'test', calls: 0 }])
assert.deepEqual(leadOnly, { claimedBy: 'Lead' })
const full = observedAttribution([{ role: 'lead', calls: 1 }, { role: 'product', calls: 2 }, { role: 'dev1', calls: 3 }, { role: 'test', calls: 4 }, { role: 'reviewer', calls: 5 }])
assert.deepEqual(full, { claimedBy: 'Lead', writtenBy: 'Product', implementedBy: 'Dev-1', testedBy: 'Test', reviewedBy: 'Reviewer' })
assert.deepEqual(observedRoles([{ role: 'lead', calls: 1 }, { role: 'dev1', calls: 0 }]), ['lead'])
assert.equal(observedAttribution([{ role: 'lead', calls: 0 }]), null)
})
Loading