From c13b5254efb4788ea2a34a2e5ed9333fd97c453d Mon Sep 17 00:00:00 2001 From: HugoXK Date: Thu, 5 Nov 2026 15:02:44 +0800 Subject: [PATCH] fix: ground attribution and backfill token repositories Derive job attribution from actual team role calls, record claimed-vs-observed mismatches, and backfill existing instance/job token repo ids so future model usage is attributed per repository. --- changelog.d/grounded-attribution.fixed.md | 1 + changelog.d/token-repo-backfill.fixed.md | 1 + docs/architecture.md | 4 ++++ docs/quota.md | 4 ++++ src/db.ts | 6 +++++ src/jobs/service.ts | 27 ++++++++++++++++++----- src/lib/teamStats.ts | 26 ++++++++++++++++++++++ test/db.test.ts | 18 +++++++++++++++ test/teamStats.test.ts | 12 ++++++++++ 9 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 changelog.d/grounded-attribution.fixed.md create mode 100644 changelog.d/token-repo-backfill.fixed.md create mode 100644 src/lib/teamStats.ts create mode 100644 test/teamStats.test.ts diff --git a/changelog.d/grounded-attribution.fixed.md b/changelog.d/grounded-attribution.fixed.md new file mode 100644 index 0000000..ee261d3 --- /dev/null +++ b/changelog.d/grounded-attribution.fixed.md @@ -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. diff --git a/changelog.d/token-repo-backfill.fixed.md b/changelog.d/token-repo-backfill.fixed.md new file mode 100644 index 0000000..58e5e4f --- /dev/null +++ b/changelog.d/token-repo-backfill.fixed.md @@ -0,0 +1 @@ +Existing `flybot:` instance tokens and `job:` tokens are backfilled with their repository id at startup, so new model usage is attributed per repository instead of an unattributed bucket. diff --git a/docs/architecture.md b/docs/architecture.md index b3119a7..432eb4a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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, diff --git a/docs/quota.md b/docs/quota.md index 51ca4d5..819021d 100644 --- a/docs/quota.md +++ b/docs/quota.md @@ -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:` instance tokens and `job:` 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`. diff --git a/src/db.ts b/src/db.ts index 824178a..a92cacc 100644 --- a/src/db.ts +++ b/src/db.ts @@ -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") @@ -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]> = [ diff --git a/src/jobs/service.ts b/src/jobs/service.ts index c20593d..462e51f 100644 --- a/src/jobs/service.ts +++ b/src/jobs/service.ts @@ -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' @@ -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}`, { @@ -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 { @@ -771,10 +775,15 @@ export class Jobs { } } - private async captureTeamStats(jobId: string): Promise { - if (this.instances === undefined || this.config.flybot.instanceImage === '') return + private async afterSucceeded(job: JobRow, prUrl: string | undefined, issueUrl: string | undefined, claimed: Attribution | null): Promise { + 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 { + 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) => ({ @@ -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') { @@ -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 } } diff --git a/src/lib/teamStats.ts b/src/lib/teamStats.ts new file mode 100644 index 0000000..14b2b1d --- /dev/null +++ b/src/lib/teamStats.ts @@ -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) +} diff --git a/test/db.test.ts b/test/db.test.ts index aeb1f38..4c02ef4 100644 --- a/test/db.test.ts +++ b/test/db.test.ts @@ -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() + } +}) diff --git a/test/teamStats.test.ts b/test/teamStats.test.ts new file mode 100644 index 0000000..a402b1f --- /dev/null +++ b/test/teamStats.test.ts @@ -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) +})