Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
172ca80
feat: report PR costs with an opt-in durable queue
Oct 5, 2026
713cb08
feat: record PR cost health metrics
Oct 5, 2026
36637c8
fix: discard stale PR health retries
Oct 5, 2026
7c227e6
ci: install search tools before TUI tests
Oct 5, 2026
bdad601
fix: discard health counts across rapid consent changes
Oct 5, 2026
cfea43f
feat(pr-cost): report work as it progresses
Oct 5, 2026
f7579fd
docs(pr-cost): show unknown allocation before billing arrives
Oct 5, 2026
07841cf
test(pr-cost): allow turn-triggered discovery updates
Oct 5, 2026
8c3ae4f
fix: preserve reporting confidence and bounded progress
Oct 6, 2026
4a70609
fix: preserve startup notices and turn-end dialogs
Oct 6, 2026
61e0ca4
docs: clarify PR report verification and retention
Oct 6, 2026
dd0f9bc
feat(pr-costs): upload open work and recent finished PRs
Oct 7, 2026
abc9e3f
docs: explain PR upload windows and saved totals
Oct 7, 2026
0893dcb
fix: preserve finished PR reports and explicit corrections
Oct 7, 2026
9d2e7ba
refactor: reuse shared timestamp and lookup checks
Oct 7, 2026
028c1e1
fix: align PR reports with server completeness and back off rejections
Oct 7, 2026
419f9d1
refactor: share URL, durable write and channel helpers
Oct 7, 2026
51a92c1
fix: show the Studio reporting notice after a turn and label open-PR …
Oct 8, 2026
79028bc
test: pin PR totals shared with the server
Oct 8, 2026
11f5c04
fix: upload fewer requests and keep PR reports accurate
Oct 8, 2026
957451b
refactor: drop repeated PR report checks
Oct 8, 2026
05ee1bd
test: price reporting fixtures through request tags
Oct 8, 2026
ce5733e
refactor: reuse shared reader, URL and hash checks in reporting
Oct 8, 2026
29dcc52
feat: start a new reporting producer on another machine
Oct 8, 2026
77f54b5
feat: trim PR cost snapshots to the server limits
Oct 8, 2026
70bc61a
feat: report the largest queued PR cost snapshot
Oct 8, 2026
0aea4ad
feat: upload PR cost changes at most every five minutes
Oct 8, 2026
22bdd98
feat: treat PR cost limits as quota and show them in /pr-reporting st…
Oct 8, 2026
3f01e4a
feat: skip PR cost uploads from CI, print and benchmark runs
Oct 8, 2026
9c268da
docs: explain PR cost upload windows, trimming and limits
Oct 8, 2026
a5f0de1
test: clear CI variables in PR reporting tests
Oct 8, 2026
e0c7a32
fix: learn a PR cost cap that fits after one rejection
Oct 9, 2026
f21e9ae
fix: never report a likely PR assignment as sure
Oct 9, 2026
ef830b1
fix: keep PR cost history complete beside repositories without a prov…
Oct 9, 2026
330d397
refactor: share a PR's finish time in PR cost snapshots
Oct 9, 2026
0b7dcd8
docs: say that frozen PR cost reports do not expire
Oct 9, 2026
2139464
fix: keep an old PR's earlier claims when a later request names it
Oct 9, 2026
8418176
fix: hold only an oversized PR cost snapshot and skip unchanged queue…
Oct 9, 2026
e4828c8
fix: deliver queued PR cost reports when capture or the watcher fails
Oct 9, 2026
a590e21
fix: send a request with more than 8 bills without IDs
Oct 9, 2026
111c8e9
fix: report an invalid PR cost snapshot once
Oct 9, 2026
03b6a84
fix: explain when an API key's role cannot upload PR costs
Oct 9, 2026
992e8c7
refactor: drop write-only PR cost limit attempt counters
Oct 9, 2026
05c0f77
perf: skip the PR cost pass in sessions that never upload
Oct 9, 2026
c478140
test: keep unknown journal record types out of PR cost uploads
Oct 9, 2026
f564374
perf: keep PR cost repository identities for a day
Oct 9, 2026
fa399ac
fix: import discovery helpers from their split modules
Oct 9, 2026
6ecd2a8
fix: import billing helpers from their split modules
Oct 9, 2026
7456b5a
style: separate declarations in PR cost reporting
Oct 9, 2026
1e5f844
test: keep CI variables out of the reporting e2e and wait for the fin…
Oct 9, 2026
36045be
test: allow the final footer time on loaded runners
Oct 9, 2026
9958923
test: give settled merged links stable IDs in the footer test
Oct 9, 2026
218c44c
fix: pause PR cost uploads only for upload limit scopes
Oct 9, 2026
d11b89c
docs: drop the PR cost archive expiry and correct caches, budgets and…
Oct 9, 2026
7bb6913
style: keep PR cost doc comments on their functions
Oct 9, 2026
d62b2cb
fix: keep delivering queued PR cost snapshots while a replacement wai…
Oct 9, 2026
fb90367
fix: coalesce reporting state rereads during uploads
Oct 9, 2026
88320b4
feat: include PR cost reporting in the cost-per-PR switch
Oct 9, 2026
3f2e731
chore: drop an unused import
Oct 9, 2026
ffacea1
chore: say confirmed and inferred, and expect session uploads without…
Oct 9, 2026
c1e3a82
fix: skip idle and empty PR cost uploads and read journals without bl…
Oct 10, 2026
da5d1ce
test: keep an unbilled explicit request on its PR in the agreement fi…
Oct 10, 2026
2fdbe3f
Merge branch 'feat/recover-work-continuations' into feat/report-work-…
Oct 11, 2026
d97fe9c
fix: hint /pr-reporting arguments and make its status easier to read
Oct 12, 2026
55671c3
Merge branch 'feat/recover-work-continuations' into feat/report-work-…
Oct 12, 2026
a265e11
Merge branch 'feat/recover-work-continuations' into feat/report-work-…
Oct 12, 2026
147bbee
Merge branch 'feat/recover-work-continuations' into feat/report-work-…
Oct 12, 2026
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
7 changes: 7 additions & 0 deletions .github/workflows/tui-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ jobs:
go-version-file: tools/proxy-helper/go.mod
cache-dependency-path: tools/proxy-helper/go.sum

# Fresh test homes otherwise download these before the editor accepts input.
- name: Install search tools
timeout-minutes: 3
run: |
sudo apt-get update -qq
sudo apt-get install --yes --no-install-recommends ripgrep fd-find

- name: Build proxy-helper
run: make copy-for-dev # Builds the binary and copies it to bin/ where the tests expect it
working-directory: tools/proxy-helper
Expand Down
203 changes: 203 additions & 0 deletions docs/pr-cost-reporting.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/work-attribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ The footer and Ferment budgets have different token scopes. Ferment V2 counts pa

## Turning Cost per PR off

Cost per PR is listed in `/resources` like other built-in extensions and is on by default. Disabling it there, or with `kimchi resources disable extensions.cost-per-pr`, takes effect at the next start: Kimchi records no work, looks up no PRs and shows no `/work` or PR/MR status. Other extensions stop recording work at once. Records already saved stay on disk.
Cost per PR is listed in `/resources` like other built-in extensions and is on by default. Disabling it there, or with `kimchi resources disable extensions.cost-per-pr`, takes effect at the next start: Kimchi records no work, looks up no PRs, shows no `/work` or PR/MR status and uploads no PR cost reports. Other extensions stop recording work at once. Records already saved stay on disk.

## What still needs work

Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ import piiRedactionExtension from "./extensions/pii-redaction/index.js"
import plannotatorExtension from "./extensions/plannotator/index.js"
import pluginPackageHooksAdapter from "./extensions/plugin-package-hook-adapter/index.js"
import powershellGateExtension from "./extensions/powershell-gate.js"
import prCostReportingExtension from "./extensions/pr-cost-reporting/index.js"
import { setPrintGate } from "./extensions/print-mode.js"
import promptEnrichmentExtension from "./extensions/prompt-construction/prompt-enrichment.js"
import promptSummaryExtension from "./extensions/prompt-summary.js"
Expand Down Expand Up @@ -837,6 +838,7 @@ try {
...enabledExtensionFactories([
{ id: COST_PER_PR_RESOURCE_ID, factory: createWorkAttributionExtension() },
{ id: COST_PER_PR_RESOURCE_ID, factory: pullRequestStatusExtension },
{ id: COST_PER_PR_RESOURCE_ID, factory: prCostReportingExtension },
] satisfies ManagedExtensionFactory[]),
requestTimingExtension,
llmResponseLogExtension,
Expand Down
16 changes: 16 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
writeHideTips,
writeSessionModeWizardSeenAt,
writeStudioOnboardingSeenAt,
writeTelemetryEnabled,
writeTeleportCompactHintEnabled,
writeTuiWheelScrollLines,
} from "./config.js"
Expand Down Expand Up @@ -1032,6 +1033,21 @@ describe("readTelemetryConfig", () => {
expect(config.metricsEndpoint).toBe("https://custom.example.com/metrics:ingest")
})

it("persists consent changes across off/on while repeated on keeps the current version", () => {
writeFileSync(configPath, JSON.stringify({ telemetry: { enabled: true } }))
expect(readTelemetryConfig(configPath).consentVersion).toBeUndefined()
writeTelemetryEnabled(false, configPath)
const disabled = readTelemetryConfig(configPath)
expect(disabled.enabled).toBe(false)
expect(disabled.consentVersion).toEqual(expect.any(String))
writeTelemetryEnabled(true, configPath)
const enabled = readTelemetryConfig(configPath)
expect(enabled.enabled).toBe(true)
expect(enabled.consentVersion).not.toBe(disabled.consentVersion)
writeTelemetryEnabled(true, configPath)
expect(readTelemetryConfig(configPath).consentVersion).toBe(enabled.consentVersion)
})

it("falls back to default metrics endpoint when absent", () => {
writeFileSync(
configPath,
Expand Down
7 changes: 7 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto"
import { existsSync, readFileSync, statSync } from "node:fs"
import { homedir } from "node:os"
import { join, relative, resolve } from "node:path"
Expand Down Expand Up @@ -95,6 +96,7 @@ export function buildSkillPathOptions(discoveredDirs: string[]): string[] {

export interface TelemetryConfig {
enabled: boolean
consentVersion?: string
endpoint: string
metricsEndpoint: string
headers: Record<string, string>
Expand Down Expand Up @@ -524,6 +526,7 @@ export function readTelemetryConfig(configPath?: string): TelemetryConfig {
const path = configPath ?? KIMCHI_CONFIG_PATH
const envEnabled = process.env.KIMCHI_TELEMETRY_ENABLED
let fileEnabled: boolean | undefined
let fileConsentVersion: string | undefined
let fileEndpoint: string | undefined
let fileMetricsEndpoint: string | undefined
let fileHeaders: Record<string, string> | undefined
Expand All @@ -538,6 +541,7 @@ export function readTelemetryConfig(configPath?: string): TelemetryConfig {
const t = parsed.telemetry
if (t && typeof t === "object") {
if (typeof t.enabled === "boolean") fileEnabled = t.enabled
if (typeof t.consentVersion === "string") fileConsentVersion = t.consentVersion
if (typeof t.endpoint === "string" && t.endpoint.length > 0) fileEndpoint = t.endpoint
if (typeof t.metricsEndpoint === "string" && t.metricsEndpoint.length > 0) fileMetricsEndpoint = t.metricsEndpoint
if (t.headers && typeof t.headers === "object" && !Array.isArray(t.headers)) {
Expand Down Expand Up @@ -579,6 +583,7 @@ export function readTelemetryConfig(configPath?: string): TelemetryConfig {

return {
enabled,
consentVersion: fileConsentVersion,
endpoint: fileEndpoint ?? telemetryLogsUrl(region),
metricsEndpoint: fileMetricsEndpoint ?? telemetryMetricsUrl(region),
headers,
Expand Down Expand Up @@ -1143,6 +1148,8 @@ export function writeTelemetryEnabled(enabled: boolean, configPath?: string): vo
const path = configPath ?? KIMCHI_CONFIG_PATH
updateConfigFile(path, (raw) => {
const t = (raw.telemetry as Record<string, unknown> | undefined) ?? {}
// A different process may turn telemetry off and on between export attempts.
if (t.enabled !== enabled) t.consentVersion = randomUUID()
t.enabled = enabled
raw.telemetry = t
})
Expand Down
135 changes: 135 additions & 0 deletions src/extensions/pr-cost-reporting/agreement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { readFileSync } from "node:fs"
import { afterEach, beforeEach, expect, it, vi } from "vitest"
import type { WorkPullRequest } from "../pull-request-status/pull-requests.js"
import { calculatePullRequestCosts, decimalNanos, usd } from "../work-attribution/costs.js"
import type { WorkRecord } from "../work-attribution/summary.js"
import { buildSnapshots } from "./snapshot.js"

// The backend checks the same content in services/ai-optimizer/internal/services/prcosts/testdata/client-agreement.golden.
// Update both copies together: they pin where /work and the console must agree.
const account = {
apiUrl: "https://api.example",
organizationId: "11111111-1111-4111-8111-111111111111",
userId: "22222222-2222-4222-8222-222222222222",
}
const started = "2026-10-04T12:00:00.000Z"
const merged = "2026-10-04T13:00:00.000Z"
const repositories = new Map([
["/repo-a/.git", { provider: "github" as const, host: "github.com", id: "42", name: "example/a" }],
["/repo-b/.git", { provider: "github" as const, host: "github.com", id: "43", name: "example/b" }],
])
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] })
vi.setSystemTime(new Date("2026-10-07T12:00:00Z"))
})
afterEach(() => vi.useRealTimers())

const id = (prefix: string, n: number) => `${prefix}0000000-0000-4000-8000-00000000000${n}`
function pull(number: number, repository: "a" | "b"): WorkPullRequest {
return {
provider: "github",
id: String(number),
repositoryId: repository === "a" ? "42" : "43",
host: "github.com",
repository: `example/${repository}`,
number,
url: `https://github.com/example/${repository}/pull/${number}`,
state: "merged",
mergedAt: merged,
closedAt: merged,
checkedAt: merged,
headSha: "a".repeat(40),
mergeCommitSha: "b".repeat(40),
}
}
function work(n: number, pulls: [WorkPullRequest, "a" | "b"][]): WorkRecord[] {
return pulls.map(([pr, repository], index) => ({
version: 1,
type: "commit",
workId: id("1", n),
sessionId: "session",
sha: String(n * 10 + index).padStart(40, "0"),
repository: `/repo-${repository}/.git`,
worktree: `/repo-${repository}`,
recordedAt: merged,
pullRequests: [pr],
}))
}
function request(n: number, workNumber: number, attribution: string, startedAt = started): WorkRecord {
return {
version: 1,
type: "request",
workId: id("1", workNumber),
sessionId: "session",
requestId: id("2", n),
startedAt,
recordedAt: startedAt,
scope: { account, repository: "/repo-a/.git" },
segment: { id: `segment-${n}`, attribution, reason: attribution },
}
}

it("pins the PR totals that /work and the console must agree on", () => {
const records = [
// 101: confirmed spend, a verified no-charge attempt and later post-merge work.
...work(1, [[pull(101, "a"), "a"]]),
request(1, 1, "explicit"),
request(6, 1, "explicit"),
request(7, 1, "explicit", "2026-10-04T14:00:00.000Z"),
// 102: a model match is inferred spend.
...work(2, [[pull(102, "a"), "a"]]),
request(2, 2, "inferred"),
// Unresolved matching with no candidate PR leaves every PR complete.
request(3, 3, "unknown"),
// 103: an explicit attempt with no bill yet stays on its PR, which stays incomplete until the bill arrives.
...work(4, [[pull(103, "a"), "a"]]),
request(4, 4, "explicit"),
// 104 and 201: one attempt shared across repositories is counted once and leaves both incomplete.
...work(5, [
[pull(104, "a"), "a"],
[pull(201, "b"), "b"],
]),
request(5, 5, "explicit"),
]
const bills = [
{ request: 1, costUsd: "1.25" },
{ request: 2, costUsd: "2" },
{ request: 3, costUsd: "3" },
{ request: 5, costUsd: "5" },
{ request: 7, costUsd: "7" },
].map(({ request, costUsd }) => ({
requestId: id("2", request),
billingRecordId: id("3", request),
costUsd,
account,
}))
const noCharge = new Map([[id("2", 6), account]])
const report = calculatePullRequestCosts(records, bills, new Set(), noCharge)
const { snapshots, incomplete } = buildSnapshots(records, report, repositories)
expect(incomplete).toBe(false)
// Each attempt appears once in the request list, whichever bucket or PR it belongs to.
const organizationNanos = report.requests.reduce((sum, row) => sum + (decimalNanos(row.knownCostUsd) ?? 0n), 0n)
const fixture = {
actorId: account.userId,
snapshots: snapshots.map((snapshot, index) => ({
schemaVersion: 1,
producerId: id("4", index + 1),
revision: "1",
generatedAt: "2026-10-07T12:00:00.000Z",
...snapshot.content,
})),
bills: bills.map((bill) => ({ id: bill.billingRecordId, ownerId: account.userId, totalPrice: bill.costUsd })),
noChargeRequestIds: [...noCharge.keys()],
organizationKnownCostUsd: usd(organizationNanos),
pullRequests: report.pullRequests
.map((pr) => ({
repositoryId: pr.pullRequest?.repositoryId,
id: pr.pullRequest?.id,
knownCostUsd: pr.knownCostUsd,
complete: pr.totalCostUsd !== null,
}))
.sort((a, b) => `${a.repositoryId}/${a.id}`.localeCompare(`${b.repositoryId}/${b.id}`)),
}
const committed = JSON.parse(readFileSync(new URL("./client-agreement.json", import.meta.url), "utf8"))
expect(fixture).toEqual(committed)
})
43 changes: 43 additions & 0 deletions src/extensions/pr-cost-reporting/automation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest"
import { uploadSkipReason } from "./automation.js"

describe("PR cost uploads from automated runs", () => {
it.each(["tui", "rpc"] as const)("lets an interactive %s session upload", (mode) => {
expect(uploadSkipReason(mode, {})).toBeUndefined()
})
it.each([
["print", "non-interactive print mode"],
["json", "non-interactive JSON mode"],
] as const)("skips a one-shot %s run", (mode, reason) => {
expect(uploadSkipReason(mode, {})).toBe(reason)
})
it.each([
"CI",
"GITHUB_ACTIONS",
"GITLAB_CI",
"BUILDKITE",
"JENKINS_URL",
"TEAMCITY_VERSION",
"CIRCLECI",
"TF_BUILD",
])("skips a CI job detected through %s", (name) => {
expect(uploadSkipReason("tui", { [name]: name === "JENKINS_URL" ? "https://jenkins.example/" : "true" })).toBe(
`CI environment (${name})`,
)
})
it.each(["", " ", "0", "false", "FALSE"])("treats CI=%j as a developer terminal", (value) => {
expect(uploadSkipReason("tui", { CI: value })).toBeUndefined()
})
it.each(["0", "false", "off", "no"])("honors KIMCHI_PR_COST_REPORTING=%s in every mode", (value) => {
expect(uploadSkipReason("rpc", { KIMCHI_PR_COST_REPORTING: value })).toBe("KIMCHI_PR_COST_REPORTING=0")
})
it("offers no way around CI detection", () => {
expect(uploadSkipReason("tui", { KIMCHI_PR_COST_REPORTING: "1", CI: "true" })).toBe("CI environment (CI)")
})
it("skips benchmark runs, the only users of the infrastructure breaker", () => {
expect(uploadSkipReason("tui", { KIMCHI_INFRA_BREAKER_THRESHOLD: "3" })).toBe(
"benchmark run (KIMCHI_INFRA_BREAKER_THRESHOLD)",
)
expect(uploadSkipReason("tui", { KIMCHI_INFRA_BREAKER_THRESHOLD: "0" })).toBeUndefined()
})
})
30 changes: 30 additions & 0 deletions src/extensions/pr-cost-reporting/automation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
import { INFRA_BREAKER_THRESHOLD_ENV, resolveInfrastructureBreakerThreshold } from "../../upstream-retry-patch.js"

export const CI_VARIABLES = [
"CI",
"GITHUB_ACTIONS",
"GITLAB_CI",
"BUILDKITE",
"JENKINS_URL",
"TEAMCITY_VERSION",
"CIRCLECI",
"TF_BUILD",
]

const disabled = (value: string | undefined) => ["0", "false", "off", "no"].includes(value?.trim().toLowerCase() ?? "")

/**
* Why this process must not upload PR costs, or undefined when it may. CI, one-shot print or JSON runs
* and benchmarks (the only callers of the infrastructure breaker) would report noise. Interactive TUI,
* ACP and RPC sessions still report. Local attribution runs either way.
*/
export function uploadSkipReason(mode: ExtensionContext["mode"], env = process.env): string | undefined {
if (disabled(env.KIMCHI_PR_COST_REPORTING)) return "KIMCHI_PR_COST_REPORTING=0"
if (mode === "print") return "non-interactive print mode"
if (mode === "json") return "non-interactive JSON mode"
const ci = CI_VARIABLES.find((name) => env[name]?.trim() && !disabled(env[name]))
if (ci) return `CI environment (${ci})`
if (resolveInfrastructureBreakerThreshold(env) > 0) return `benchmark run (${INFRA_BREAKER_THRESHOLD_ENV})`
return undefined
}
Loading
Loading