Compare commits

...

2 Commits

Author SHA1 Message Date
Miguel Palhas ab48785254 feat(week-review): price the gpt-5.6, Kimi and GLM models
ci / nix (pull_request) Successful in 9s
ci / lint (pull_request) Failing after 14s
Unpriced models counted as zero, so every codex row read as free and the
tool comparison was wrong. Rates are the providers' published ones; the
sol row reproduces a pi-reported total to the cent, which also confirms
the 0.1 cache-read multiplier applies to OpenAI.

GLM-4.7-Flash has no published per-token rate and stays out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 17:18:50 +01:00
Miguel Palhas e90137b17c fix(pr-daemon): rate-limit and coalesce hints
Every hint costs the receiving session a full model turn. The daemon sent
one per forge event with no quiet period, so a busy PR produced 20+ in a
day and sometimes repeated a payload verbatim.

Reasons are now banked per PR and role until a hint actually goes out, so
a busy pane or a cooldown delays one but never loses it. After a hint,
the next waits hintCooldownSeconds (default 300) and carries everything
that accumulated. A payload identical to the last is dropped, as is a ci
hint to a land session whose worktree already holds that head commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 17:18:50 +01:00
4 changed files with 84 additions and 11 deletions
+8
View File
@@ -145,6 +145,14 @@ skill on arrival if it doesn't have it.
bump `updated_at` and move nothing in the snapshot, so no hint is sent at all.
With webhooks the filter is sharper still, by event action.
**Hints are rate-limited per PR and role.** Every hint costs the receiving
session a full model turn, so after one goes out the next waits
`hintCooldownSeconds` (default 300) and arrives carrying every reason that
accumulated meanwhile. A hint identical to the last one sent is dropped, and so
is a `ci` hint to a `land` session whose own worktree already holds that head
commit — it pushed it. Reasons are banked until they are actually delivered, so
a busy pane or a cooldown delays a hint but never loses one.
**An epoch guards the first run.** `~/.local/state/reviewer/epoch` is written
once; PRs created before it never spawn a session, so switching the daemon on
doesn't wake every open PR you have. It gates creation only — start a session
+1
View File
@@ -2,6 +2,7 @@
"pollSeconds": 60,
"reconcileSeconds": 120,
"maxSessionsPerTick": 2,
"hintCooldownSeconds": 300,
"reviewProfile": "review",
"group": "pr",
"webhookPort": 7474,
+67 -9
View File
@@ -42,6 +42,7 @@ const ROLES_DEFAULT = ["review", "blitz"];
type Config = {
pollSeconds?: number;
reconcileSeconds?: number;
hintCooldownSeconds?: number; // quiet period per PR and role between hints
maxSessionsPerTick?: number;
reviewProfile?: string;
reviewTool?: string;
@@ -97,6 +98,17 @@ const dirty = new Set<string>();
const noPulls = new Set<string>();
let firstRun = false;
// The snapshot advances on the tick that diffed it, so a reason not sent
// immediately can never be recomputed. Held per PR and role until it goes out.
const pending = new Map<string, Set<string>>();
const hintedAt = new Map<string, number>();
const lastHint = new Map<string, string>();
const HINT_COOLDOWN_MS = (config.hintCooldownSeconds ?? 300) * 1000;
// Canonical order, so a coalesced hint reads the same however it accumulated.
// That is what makes the duplicate check below meaningful.
const REASON_ORDER = ["ci", "state", "conflicts", "comments"];
const log = (...args: unknown[]) => console.log(new Date().toISOString(), ...args);
// A webhook has to cut the wait short, or its only effect would be to mark a
@@ -321,6 +333,14 @@ async function seenIds(worktree: string, n: number): Promise<Set<string> | null>
}
}
// True when the session's own worktree already holds the PR head, which means
// the session pushed it and does not need waking to hear about its own commit.
function pushedLocally(worktree: string, sha: string): boolean {
if (!sha) return false;
const proc = Bun.spawnSync(["git", "-C", worktree, "rev-parse", "HEAD"]);
return proc.exitCode === 0 && proc.stdout.toString().trim() === sha;
}
// Ids of everything commented after `since`. null means the fetch failed.
async function newCommentIds(pr: Pr, since: string): Promise<string[] | null> {
try {
@@ -838,6 +858,16 @@ async function evaluate(prs: Pr[], mentioned: Set<string>, budget: { sessions: n
log(`${session.title} is waiting on input (${full.key})`);
}
// Conflicts are the author's to resolve on their own branch, so the
// reviewer never hears about them. Comments it does hear: a reply to a
// finding is addressed to the reviewer, and an addressed thread is the
// reviewer's to resolve (review-pr §3.1).
// Banked before anything can skip out of this iteration.
const pkey = `${full.key}:${role}`;
const banked = pending.get(pkey) ?? new Set<string>();
for (const r of role === "land" ? why : why.filter((w) => w !== "conflicts")) banked.add(r);
if (banked.size) pending.set(pkey, banked);
// A send into a busy pane can be swallowed. Since hints are idempotent,
// holding it costs one cycle and nothing else.
if (st !== "idle") {
@@ -849,25 +879,53 @@ async function evaluate(prs: Pr[], mentioned: Set<string>, budget: { sessions: n
if (known && !known.prompted) {
await send(known.profile, session.id, opening(full, skill));
known.prompted = true;
pending.delete(pkey); // the opening sends it to read the PR whole
hintedAt.set(pkey, Date.now());
continue;
}
// Conflicts are the author's to resolve on their own branch, so the
// reviewer never hears about them. Comments it does hear: a reply to a
// finding is addressed to the reviewer, and an addressed thread is the
// reviewer's to resolve (review-pr §3.1).
let mine = role === "land" ? why : why.filter((w) => w !== "conflicts");
if (mine.includes("comments") && prev) {
const acc = pending.get(pkey);
if (!acc?.size) continue; // label, assignee, edited title: nothing to act on
if (acc.has("comments") && prev) {
const ids = await newCommentIds(full, prev.updatedAt);
const seen = session.path ? await seenIds(session.path, full.number) : null;
if (ids?.length && seen && ids.every((id) => seen.has(id))) {
mine = mine.filter((w) => w !== "comments");
acc.delete("comments");
log(`comments on ${full.key} already in ${session.title}'s seen file, hint dropped`);
}
}
if (!mine.length) continue; // label, assignee, edited title: nothing to act on
// The land session pushed the commit CI is running on, so the run is no
// news to it. A reviewer still hears about it: the head moved under them.
if (acc.has("ci") && role === "land" && session.path && pushedLocally(session.path, full.headSha)) {
acc.delete("ci");
log(`ci on ${full.key} is ${session.title}'s own push, hint dropped`);
}
if (!acc.size) {
pending.delete(pkey);
continue;
}
const wait = HINT_COOLDOWN_MS - (Date.now() - (hintedAt.get(pkey) ?? 0));
if (wait > 0) {
dirty.add(full.key);
setTimeout(wake, wait + 1000);
log(`cooling ${full.key} (${[...acc].join(",")}): ${Math.round(wait / 1000)}s left`);
continue;
}
const mine = REASON_ORDER.filter((r) => acc.has(r));
const message = hint(full, mine, skill);
if (lastHint.get(pkey) === message) {
pending.delete(pkey);
log(`hint ${full.key} repeats the last one verbatim, dropped`);
continue;
}
try {
await send(session.profile, session.id, hint(full, mine, skill));
await send(session.profile, session.id, message);
pending.delete(pkey);
hintedAt.set(pkey, Date.now());
lastHint.set(pkey, message);
log(`hint ${full.key} reason=${mine.join(",")} -> ${session.title}`);
} catch (e) {
dirty.add(full.key);
+8 -2
View File
@@ -1,5 +1,6 @@
{
"_note": "USD per million tokens. Anthropic rates from the claude-api skill (cached 2026-06-24); check them when a model is added or repriced. Used only for tools that do not report their own cost (Claude Code, Codex) — pi and opencode report real cost per message and are never estimated.",
"_note": "USD per million tokens. Anthropic rates from the claude-api skill (cached 2026-06-24); OpenAI and open-weight rates cached 2026-08-28. Check them when a model is added or repriced. Used only for tools that do not report their own cost (Claude Code, Codex) — pi and opencode report real cost per message and are never estimated.",
"_check": "The gpt-5.6-sol row reproduces a pi-reported total to the cent (411369 in, 38462 out, 8897536 cache read = $7.6595), which also confirms the 0.1 cache-read multiplier for OpenAI.",
"cache_write_multiplier": 1.25,
"cache_read_multiplier": 0.1,
"models": {
@@ -11,6 +12,11 @@
"claude-opus-4-6": {"input": 5.0, "output": 25.0},
"claude-sonnet-5": {"input": 3.0, "output": 15.0},
"claude-sonnet-4-6": {"input": 3.0, "output": 15.0},
"claude-haiku-4-5": {"input": 1.0, "output": 5.0}
"claude-haiku-4-5": {"input": 1.0, "output": 5.0},
"gpt-5.6-sol": {"input": 5.0, "output": 30.0},
"gpt-5.6-terra": {"input": 2.0, "output": 12.0},
"gpt-5.6-luna": {"input": 0.2, "output": 1.2},
"hf:moonshotai/Kimi-K3": {"input": 3.0, "output": 15.0},
"hf:zai-org/GLM-5.2": {"input": 1.4, "output": 4.4}
}
}