Merge pull request #163 from flagon-io/signals-tuning
Home's daily signal reads again, and counts agents' direct pushes
12 files+198−750/12 viewed
| 48 | 48 | ||
| 49 | 49 | | Figure | What it counts | | |
| 50 | 50 | | --- | --- | | |
| 51 | − | | **Agent-authored merges** | Pull requests that merged in the week and were an [agent's](#whose-work-is-an-agents). | | |
| 52 | − | | **Spend per agent merge** | The week's agent [usage](/guides/usage-and-billing/#the-usage-page) at price, plus what the [sessions](/guides/sessions/) started in it cost (estimated at list price from their token usage), divided by the agent pull requests that merged in it: an average, not what any one cost. A dash when usage can't be read or nothing an agent made merged. | | |
| 51 | + | | **Agent-authored changes** | Pull requests that merged in the week and were an [agent's](#whose-work-is-an-agents), and the commits agent sessions pushed outside any pull request (to the default branch, most of all), each once. A workspace that pushes straight to main lands most of its work the second way. | | |
| 52 | + | | **Spend per agent change** | The week's agent [usage](/guides/usage-and-billing/#the-usage-page) at price, plus what the [sessions](/guides/sessions/) started in it cost (estimated at list price from their token usage), divided by the agent changes that landed in it: an average, not what any one cost. A dash when usage can't be read or no agent change landed. | | |
| 53 | 53 | | **Stalled changes** | Pull requests open at the end of each week (now, for this one), opened three days or more before, with nothing in the three days before: no push, comment, review, timeline event, check or session. Drafts count. | | |
| 54 | − | | **Agent rework rate** | Of the agent pull requests that merged in the week, the share a person asked for changes on before they merged. | | |
| 54 | + | | **Agent rework rate** | Of the agent pull requests that merged in the week (direct commits get no review, so they're not counted here), the share a person asked for changes on before they merged. | | |
| 55 | 55 | ||
| 56 | 56 | ### Whose work is an agent's | |
| 57 | 57 | ||
| ⋯ | |||
| 78 | 78 | | **Stalled changes rising** | At least three more stalled changes than last week, and a quarter more. | | |
| 79 | 79 | | **Agent rework moving** | People asked for changes on agents' merged work at least 8 points more, or less, often than over the four weeks before (with five agent merges or more). | | |
| 80 | 80 | | **A practice catching on** | Two or more people started using a harness (Claude Code, Codex, Cursor and so on) this month, at least two weeks after the first person did. | | |
| 81 | − | | **Who works where** | Someone started merging to a project one person has carried (three quarters of its last 90 days' merges), or merged to a project for the first time this week after work elsewhere. | | |
| 81 | + | | **Who works where** | In a workspace where three people or more merged lately, someone started merging to a project one person has carried (three quarters of its last 90 days' merges); or merged to a project for the first time this week after work elsewhere. | | |
| 82 | 82 | ||
| 83 | 83 | Findings are ordered by how much they hold up: blocked work first, then | |
| 84 | 84 | waits, then what shipped and where spend went. Up to five show, and the | |
| 682 | 682 | figures as Home. | |
| 683 | 683 | 2. **Spend, threaded to the work**: the quarter's work in four columns: | |
| 684 | 684 | each milestone, its work items, each work item's change set in each | |
| 685 | − | project, and that change set's pull requests. Each box is sized by | |
| 685 | + | project, and that change set's pull requests (with no milestones in | |
| 686 | + | the workspace, it starts at the work items). Each box is sized by | |
| 686 | 687 | what agents cost on its pull requests (g1t's metered usage, and each | |
| 687 | 688 | pull request's share of its sessions' cost estimated at list price), | |
| 688 | 689 | and shaded by how many were an agent's (**Agent use**, less to more). Choose a box to | |
| ⋯ | |||
| 694 | 695 | organization ran, so it is never added to g1t's. | |
| 695 | 696 | 3. **What's moving and what's waiting**: each team's last two weeks, day | |
| 696 | 697 | by day, by the projects the team works on (each project, when the | |
| 697 | − | workspace has no teams). Above the line are pull requests opened and | |
| 698 | − | merged, and agent sessions started; below it, reviews and comments and | |
| 698 | + | workspace has no teams). Above the line are changes (pull requests | |
| 699 | + | opened and merged, and commits agents pushed outside one) and agent | |
| 700 | + | sessions started; below it, reviews and comments and | |
| 699 | 701 | requests for changes. Turn each layer on or off above the rows, point | |
| 700 | 702 | at a day for its numbers, or open **The numbers, as a table**. Each | |
| 701 | 703 | row is drawn to its own scale. Beside it, **Waiting on a first | |
| ⋯ | |||
| 703 | 705 | 4. **Quiet changes**: places to look, not verdicts. | |
| 704 | 706 | - **Knowledge silos**: projects where one person merged three quarters | |
| 705 | 707 | or more of the last 90 days' changes (six or more), and whoever has | |
| 706 | − | started there this month. | |
| 708 | + | started there this month. Only in a workspace where three people or | |
| 709 | + | more merged lately: with fewer, every project rests on someone. | |
| 707 | 710 | - **New ground**: people who merged to a project for the first time in | |
| 708 | 711 | the last two weeks, after three or more merges elsewhere. | |
| 709 | 712 | - **Spreading**: a harness two or more people in a team started using | |
| 1 | 1 | /** | |
| 2 | 2 | * What is moving and what is waiting (lib/signals.ts rhythm): each team's | |
| 3 | 3 | * last two weeks, day by day. Above the line, pull requests opened and | |
| 4 | − | * merged (bars) and agent sessions started (a wash); below it, reviews and | |
| 4 | + | * merged and commits agents pushed outside one (bars), and agent sessions | |
| 5 | + | * started (a wash); below it, reviews and | |
| 5 | 6 | * comments (a wash) and requests for changes (bars). Each row is drawn to | |
| 6 | 7 | * its own scale, so a small team's week is as legible as a big one's; the | |
| 7 | 8 | * numbers are in the readout and the table. Layers turn on and off above | |
| ⋯ | |||
| 16 | 17 | type Layer = "prs" | "rework" | "agent" | "reviews"; | |
| 17 | 18 | ||
| 18 | 19 | const LAYERS: { key: Layer; label: string; swatch: string }[] = [ | |
| 19 | − | { key: "prs", label: "PR activity", swatch: "bg-fg-soft" }, | |
| 20 | + | { key: "prs", label: "Changes", swatch: "bg-fg-soft" }, | |
| 20 | 21 | { key: "rework", label: "Rework", swatch: "bg-faint" }, | |
| 21 | 22 | { key: "agent", label: "Agent activity", swatch: "bg-series-1" }, | |
| 22 | 23 | { key: "reviews", label: "Reviews & comments", swatch: "bg-series-4" }, | |
| ⋯ | |||
| 54 | 55 | <ul className="mt-2 space-y-1 text-fg"> | |
| 55 | 56 | <li className="flex items-center gap-2"> | |
| 56 | 57 | <span className="size-2 rounded-full bg-fg-soft" /> | |
| 57 | − | {sum.opened} opened · {sum.merged} merged | |
| 58 | + | {sum.opened} opened · {sum.merged} merged{sum.commits > 0 && ` · ${sum.commits} pushed by agents`} | |
| 58 | 59 | </li> | |
| 59 | 60 | <li className="flex items-center gap-2"> | |
| 60 | 61 | <span className="size-2 rounded-full bg-faint" /> | |
| ⋯ | |||
| 89 | 90 | const n = row.days.length; | |
| 90 | 91 | const slot = WIDTH / n; | |
| 91 | 92 | const x = (i: number) => slot * i + slot / 2; | |
| 92 | − | const up = Math.max(1, ...row.days.map((day) => Math.max(on.has("prs") ? day.opened + day.merged : 0, on.has("agent") ? day.sessions : 0))); | |
| 93 | + | const up = Math.max(1, ...row.days.map((day) => Math.max(on.has("prs") ? day.opened + day.merged + day.commits : 0, on.has("agent") ? day.sessions : 0))); | |
| 93 | 94 | const down = Math.max(1, ...row.days.map((day) => Math.max(on.has("rework") ? day.reworked : 0, on.has("reviews") ? day.reviews : 0))); | |
| 94 | 95 | const above = (value: number) => MID - (value / up) * (MID - 6); | |
| 95 | 96 | const below = (value: number) => MID + (value / down) * (MID - 6); | |
| ⋯ | |||
| 133 | 134 | <line x1={0} x2={WIDTH} y1={MID} y2={MID} className="stroke-line-strong" strokeWidth={1} vectorEffect="non-scaling-stroke" /> | |
| 134 | 135 | {on.has("prs") && | |
| 135 | 136 | row.days.map((d, i) => | |
| 136 | − | d.opened + d.merged > 0 ? ( | |
| 137 | + | d.opened + d.merged + d.commits > 0 ? ( | |
| 137 | 138 | <rect | |
| 138 | 139 | key={`p${i}`} | |
| 139 | 140 | x={x(i) - BAR / 2} | |
| 140 | − | y={above(d.opened + d.merged)} | |
| 141 | + | y={above(d.opened + d.merged + d.commits)} | |
| 141 | 142 | width={BAR} | |
| 142 | − | height={MID - above(d.opened + d.merged)} | |
| 143 | + | height={MID - above(d.opened + d.merged + d.commits)} | |
| 143 | 144 | rx={1.5} | |
| 144 | 145 | className="fill-fg-soft" | |
| 145 | 146 | /> | |
| ⋯ | |||
| 224 | 225 | <th className="py-1 font-normal">{by === "team" ? "Team" : "Project"}</th> | |
| 225 | 226 | <th className="py-1 font-normal">Opened</th> | |
| 226 | 227 | <th className="py-1 font-normal">Merged</th> | |
| 228 | + | <th className="py-1 font-normal">Pushed by agents</th> | |
| 227 | 229 | <th className="py-1 font-normal">Rework</th> | |
| 228 | 230 | <th className="py-1 font-normal">Reviews & comments</th> | |
| 229 | 231 | <th className="py-1 font-normal">Agent sessions</th> | |
| ⋯ | |||
| 240 | 242 | <td className="py-1.5 text-fg">{row.label}</td> | |
| 241 | 243 | <td>{cell(last.opened, now.opened)}</td> | |
| 242 | 244 | <td>{cell(last.merged, now.merged)}</td> | |
| 245 | + | <td>{cell(last.commits, now.commits)}</td> | |
| 243 | 246 | <td>{cell(last.reworked, now.reworked)}</td> | |
| 244 | 247 | <td>{cell(last.reviews, now.reviews)}</td> | |
| 245 | 248 | <td>{cell(last.sessions, now.sessions)}</td> | |
| 141 | 141 | export function SpendFlow({ flow, sized }: { flow: FlowNode[]; sized: "spend" | "pulls" }) { | |
| 142 | 142 | const [path, setPath] = useState<string[]>([]); | |
| 143 | 143 | const weight = weightOf(sized); | |
| 144 | + | // A workspace with no milestones starts at its work items: one "No milestone" box tells nothing. | |
| 145 | + | const skip = flow.length === 1 && flow[0]!.key === "m-"; | |
| 146 | + | const depth = skip ? 2 : 3; | |
| 147 | + | const heads = HEADS.slice(3 - depth); | |
| 144 | 148 | // The chosen node at each level: the one asked for, else the biggest. | |
| 145 | − | const columns: FlowNode[][] = [flow]; | |
| 149 | + | const columns: FlowNode[][] = [skip ? flow[0]!.children : flow]; | |
| 146 | 150 | const chosen: FlowNode[] = []; | |
| 147 | − | for (let level = 0; level < 3; level++) { | |
| 151 | + | for (let level = 0; level < depth; level++) { | |
| 148 | 152 | const nodes = columns[level] ?? []; | |
| 149 | 153 | const pick = nodes.find((node) => node.key === path[level]) ?? nodes[0]; | |
| 150 | 154 | if (!pick) break; | |
| 151 | 155 | chosen.push(pick); | |
| 152 | − | if (level < 2) columns.push(pick.children); | |
| 156 | + | if (level < depth - 1) columns.push(pick.children); | |
| 153 | 157 | } | |
| 154 | − | const pulls = chosen[2]?.list ?? []; | |
| 158 | + | const pulls = chosen[depth - 1]?.list ?? []; | |
| 155 | 159 | const choose = (level: number, key: string) => setPath([...chosen.slice(0, level).map((node) => node.key), key]); | |
| 156 | 160 | const boxes = columns.map((nodes) => stack(nodes.map(weight))); | |
| 157 | 161 | const rowHeight = pulls.length > 0 ? HEIGHT / Math.max(pulls.length, 5) : 0; | |
| ⋯ | |||
| 162 | 166 | return ( | |
| 163 | 167 | <div className="space-y-4"> | |
| 164 | 168 | <Legend /> | |
| 165 | − | {/* Wide: the four columns, joined. */} | |
| 169 | + | {/* Wide: the columns, joined. */} | |
| 166 | 170 | <div className="hidden md:block"> | |
| 167 | 171 | <div className="relative h-7"> | |
| 168 | − | {HEADS.map((head, column) => ( | |
| 172 | + | {heads.map((head, column) => ( | |
| 169 | 173 | <p key={head} className="absolute top-0 font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase" style={{ left: `${left(column)}%` }}> | |
| 170 | 174 | {head} | |
| 171 | 175 | </p> | |
| ⋯ | |||
| 176 | 180 | {chosen.map((node, level) => { | |
| 177 | 181 | const index = columns[level]!.indexOf(node); | |
| 178 | 182 | const from = boxes[level]![index]!; | |
| 179 | − | if (level < 2) { | |
| 183 | + | if (level < depth - 1) { | |
| 180 | 184 | const children = columns[level + 1]!; | |
| 181 | 185 | return slices(from, children.map(weight)).map((slice, i) => ( | |
| 182 | 186 | <path key={`${level}-${i}`} d={band(level, slice, boxes[level + 1]![i]!)} className="fill-line-strong" opacity={0.45} /> | |
| ⋯ | |||
| 209 | 213 | )} | |
| 210 | 214 | <div | |
| 211 | 215 | className="absolute rounded-xl border border-line bg-surface" | |
| 212 | − | style={{ left: `${left(3)}%`, width: `${100 - left(3)}%`, top: 0, height: pulls.length * rowHeight || MIN_BOX }} | |
| 216 | + | style={{ left: `${left(depth)}%`, width: `${100 - left(depth)}%`, top: 0, height: pulls.length * rowHeight || MIN_BOX }} | |
| 213 | 217 | > | |
| 214 | 218 | {pulls.length === 0 ? ( | |
| 215 | 219 | <p className="px-3 py-4 text-sm text-muted">No pull requests here.</p> | |
| ⋯ | |||
| 223 | 227 | <div className="space-y-5 md:hidden"> | |
| 224 | 228 | {columns.map((nodes, level) => ( | |
| 225 | 229 | <div key={level} className="space-y-2"> | |
| 226 | − | <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">{HEADS[level]}</p> | |
| 230 | + | <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">{heads[level]}</p> | |
| 227 | 231 | <div className="grid gap-2"> | |
| 228 | 232 | {nodes.map((node) => ( | |
| 229 | 233 | <NodeBox key={node.key} node={node} sized={sized} chosen={chosen[level]?.key === node.key} onChoose={() => choose(level, node.key)} /> | |
| ⋯ | |||
| 232 | 236 | </div> | |
| 233 | 237 | ))} | |
| 234 | 238 | <div className="space-y-2"> | |
| 235 | − | <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">{HEADS[3]}</p> | |
| 239 | + | <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">{heads[depth]}</p> | |
| 236 | 240 | <div className="rounded-xl border border-line bg-surface"> | |
| 237 | 241 | {pulls.map((pull) => ( | |
| 238 | 242 | <PullRow key={pull.key} pull={pull} style={{ height: 48 }} /> | |
| 277 | 277 | author({ ownerId: "u1", ownerName: "ana", repoId: "r1", recent: 9, merged: 40 }), | |
| 278 | 278 | author({ ownerId: "u2", ownerName: "bo", repoId: "r1", firstMergedAt: recent, recent: 1, merged: 1 }), | |
| 279 | 279 | author({ ownerId: "u2", ownerName: "bo", repoId: "r2", firstMergedAt: "2026-02-01T00:00:00Z", merged: 12, recent: 2 }), | |
| 280 | + | author({ ownerId: "u3", ownerName: "cy", repoId: "r2", firstMergedAt: "2026-03-01T00:00:00Z", merged: 5, recent: 1 }), | |
| 280 | 281 | ]; | |
| 281 | 282 | assert.deepEqual( | |
| 282 | 283 | newGround(authors, NOW).map((row) => [row.name, row.repoId, row.elsewhere]), | |
| 283 | 284 | [["bo", "r1", 12]], | |
| 284 | 285 | ); | |
| 286 | + | assert.deepEqual(silos(authors.slice(0, 3), NOW), [], "two people: every project rests on one of them, which says nothing"); | |
| 285 | 287 | const [silo] = silos(authors, NOW); | |
| 286 | 288 | assert.equal(silo!.name, "ana"); | |
| 287 | 289 | assert.equal(silo!.share, 0.9); | |
| ⋯ | |||
| 306 | 308 | found.map((s) => s.key), | |
| 307 | 309 | ["blocked", "stalled"], | |
| 308 | 310 | ); | |
| 309 | − | assert.equal(quietHeadline(week("x")), "A quiet week: nothing has merged yet, and nothing is stuck."); | |
| 311 | + | assert.equal(quietHeadline(week("x")), "A quiet week: nothing has landed yet, and nothing is stuck."); | |
| 310 | 312 | assert.equal( | |
| 311 | 313 | quietHeadline(week("x", { merged: 4, agentMerged: 2 })), | |
| 312 | − | "Four changes merged this week, 50% of them an agent's, and nothing needs a closer look.", | |
| 314 | + | "Four changes landed this week, 50% of them an agent's, and nothing needs a closer look.", | |
| 315 | + | ); | |
| 316 | + | // A workspace that pushes straight to main: agents' direct commits are what landed. | |
| 317 | + | assert.equal( | |
| 318 | + | quietHeadline(week("x", { merged: 1, agentMerged: 0, agentCommits: 9 })), | |
| 319 | + | "Ten changes landed this week, 90% of them an agent's, and nothing needs a closer look.", | |
| 313 | 320 | ); | |
| 314 | 321 | }); | |
| 315 | 322 | ||
| ⋯ | |||
| 430 | 437 | ); | |
| 431 | 438 | assert.equal(namesWords(["a", "b", "c", "d"]), "a, b and 2 others"); | |
| 432 | 439 | }); | |
| 440 | + | ||
| 441 | + | test("agents' direct commits count as what they landed, and as what spend is spread over", () => { | |
| 442 | + | const weeks = [week("2026-10-05", { merged: 2, agentMerged: 1, agentCommits: 3 }), week("2026-10-12", { merged: 1, agentMerged: 1, agentCommits: 9 })]; | |
| 443 | + | const [changes, spend] = pulse({ slug: "acme", weeks, spend: [4_000_000, 20_000_000] }); | |
| 444 | + | assert.equal(changes!.label, "Agent-authored changes"); | |
| 445 | + | assert.equal(changes!.value, "10"); | |
| 446 | + | assert.deepEqual(changes!.delta, { text: "+6", tone: "good" }); | |
| 447 | + | assert.equal(changes!.points[1], "Week of Oct 12: 1 merged, 9 pushed directly, of 10 changes"); | |
| 448 | + | assert.equal(spend!.value, "$2.00", "$20 over ten agent changes"); | |
| 449 | + | }); | |
| 114 | 114 | reviewSeconds: 0, | |
| 115 | 115 | stalled: 0, | |
| 116 | 116 | sessionCostUsd: 0, | |
| 117 | + | agentCommits: 0, | |
| 117 | 118 | }); | |
| 118 | 119 | ||
| 119 | 120 | /** Every week in `starts`, quiet ones too, for the repositories in `only` (every one when not given). */ | |
| ⋯ | |||
| 131 | 132 | week.reviewSeconds += row.reviewSeconds; | |
| 132 | 133 | week.stalled += row.stalled; | |
| 133 | 134 | week.sessionCostUsd += row.sessionCostUsd ?? 0; | |
| 135 | + | week.agentCommits += row.agentCommits ?? 0; | |
| 134 | 136 | } | |
| 135 | 137 | return starts.map((start) => byStart.get(start)!); | |
| 136 | 138 | } | |
| ⋯ | |||
| 172 | 174 | return week.agentMerged > 0 ? week.agentReworked / week.agentMerged : null; | |
| 173 | 175 | } | |
| 174 | 176 | ||
| 175 | − | /** The share of merges that were an agent's, 0 to 1. */ | |
| 177 | + | /** | |
| 178 | + | * What agents landed in a week: their merged pull requests, and the | |
| 179 | + | * commits their sessions pushed outside any pull request (to the default | |
| 180 | + | * branch, most of all). A workspace that pushes straight to main lands | |
| 181 | + | * most of its work the second way. | |
| 182 | + | */ | |
| 183 | + | export function agentChanges(week: WeekTotals): number { | |
| 184 | + | return week.agentMerged + (week.agentCommits ?? 0); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /** Everything that landed in a week: merged pull requests, and agents' direct commits. */ | |
| 188 | + | export function allChanges(week: WeekTotals): number { | |
| 189 | + | return week.merged + (week.agentCommits ?? 0); | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /** The share of what landed that was an agent's, 0 to 1. */ | |
| 176 | 193 | export function agentShare(week: WeekTotals): number | null { | |
| 177 | − | return week.merged > 0 ? week.agentMerged / week.merged : null; | |
| 194 | + | return allChanges(week) > 0 ? agentChanges(week) / allChanges(week) : null; | |
| 178 | 195 | } | |
| 179 | 196 | ||
| 180 | − | /** Usage per agent merge, in micros. */ | |
| 197 | + | /** Usage per agent change (merge or direct commit), in micros. */ | |
| 181 | 198 | export function spendPerMerge(micros: number | null, week: WeekTotals): number | null { | |
| 182 | − | return micros != null && week.agentMerged > 0 ? micros / week.agentMerged : null; | |
| 199 | + | return micros != null && agentChanges(week) > 0 ? micros / agentChanges(week) : null; | |
| 183 | 200 | } | |
| 184 | 201 | ||
| 185 | 202 | // --- Words ------------------------------------------------------------------------ | |
| ⋯ | |||
| 282 | 299 | ||
| 283 | 300 | /** | |
| 284 | 301 | * The past week in four figures, each against the week before and drawn | |
| 285 | − | * over the last ten: agent merges, usage per agent merge, stalled changes, | |
| 286 | − | * and how often people asked for changes on an agent's work. | |
| 302 | + | * over the last ten: what agents landed (merges and direct commits), usage | |
| 303 | + | * per agent change, stalled changes, and how often people asked for | |
| 304 | + | * changes on an agent's merged work. | |
| 287 | 305 | */ | |
| 288 | 306 | export function pulse({ slug, weeks, spend }: PulseInput): PulseTile[] { | |
| 289 | 307 | const at = weeks.length - 1; | |
| ⋯ | |||
| 297 | 315 | return [ | |
| 298 | 316 | { | |
| 299 | 317 | key: "merges", | |
| 300 | − | label: "Agent-authored merges", | |
| 301 | − | value: String(now.agentMerged), | |
| 302 | − | delta: deltaOf(now.agentMerged, before?.agentMerged ?? null, count, true), | |
| 303 | − | series: weeks.map((week) => week.agentMerged), | |
| 304 | − | points: weeks.map((week, i) => `${labels[i]}: ${week.agentMerged} of ${week.merged} merges`), | |
| 305 | − | hint: "Merged pull requests an agent made: g1t's agent, or a harness session tied to it", | |
| 318 | + | label: "Agent-authored changes", | |
| 319 | + | value: String(agentChanges(now)), | |
| 320 | + | delta: deltaOf(agentChanges(now), before ? agentChanges(before) : null, count, true), | |
| 321 | + | series: weeks.map(agentChanges), | |
| 322 | + | points: weeks.map((week, i) => `${labels[i]}: ${week.agentMerged} merged, ${week.agentCommits ?? 0} pushed directly, of ${allChanges(week)} changes`), | |
| 323 | + | hint: "Agents' merged pull requests, and the commits their sessions pushed outside one", | |
| 306 | 324 | to: `/${slug}/-/overview?tab=landed`, | |
| 307 | 325 | }, | |
| 308 | 326 | { | |
| 309 | 327 | key: "spend", | |
| 310 | − | label: "Spend per agent merge", | |
| 328 | + | label: "Spend per agent change", | |
| 311 | 329 | value: dollars(perMerge[at] ?? null), | |
| 312 | 330 | delta: deltaOf(perMerge[at] ?? null, before ? (perMerge[at - 1] ?? null) : null, (abs) => dollars(abs), false), | |
| 313 | 331 | series: perMerge, | |
| 314 | − | points: perMerge.map((micros, i) => `${labels[i]}: ${micros == null ? "no agent merges" : dollars(micros)}`), | |
| 315 | − | hint: "g1t's agent usage at price and harness sessions' cost at list price, over the agent merges", | |
| 332 | + | points: perMerge.map((micros, i) => `${labels[i]}: ${micros == null ? "no agent changes" : dollars(micros)}`), | |
| 333 | + | hint: "g1t's agent usage at price and harness sessions' cost at list price, over what agents landed", | |
| 316 | 334 | to: `/${slug}/-/usage`, | |
| 317 | 335 | }, | |
| 318 | 336 | { | |
| ⋯ | |||
| 646 | 664 | /** | |
| 647 | 665 | * A project where one person has done most of the recent work: their | |
| 648 | 666 | * share of its merges in the last 90 days, and whoever else has started | |
| 649 | − | * merging there in the last 30 (knowledge spreading, or not yet). | |
| 667 | + | * merging there in the last 30 (knowledge spreading, or not yet). Only in | |
| 668 | + | * a workspace where three people or more merged lately. | |
| 650 | 669 | */ | |
| 651 | 670 | export type Silo = { | |
| 652 | 671 | repoId: string; | |
| ⋯ | |||
| 658 | 677 | joining: { ownerId: string; name: string }[]; | |
| 659 | 678 | }; | |
| 660 | 679 | ||
| 661 | − | export function silos(authors: SignalAuthor[], now: number, { minRecent = 6, minShare = 0.75 } = {}): Silo[] { | |
| 680 | + | export function silos(authors: SignalAuthor[], now: number, { minRecent = 6, minShare = 0.75, minPeople = 3 } = {}): Silo[] { | |
| 681 | + | // Fewer than `minPeople` people merging anywhere lately: every project rests on someone, and saying so tells nothing. | |
| 682 | + | const people = new Set(authors.filter((row) => row.recent > 0).map((row) => row.ownerId)); | |
| 683 | + | if (people.size < minPeople) return []; | |
| 662 | 684 | const byRepo = new Map<string, SignalAuthor[]>(); | |
| 663 | 685 | for (const row of authors) byRepo.set(row.repoId, [...(byRepo.get(row.repoId) ?? []), row]); | |
| 664 | 686 | const found: Silo[] = []; | |
| ⋯ | |||
| 815 | 837 | ||
| 816 | 838 | /** The headline when nothing stands out: still a sentence about the week. */ | |
| 817 | 839 | export function quietHeadline(week: WeekTotals | undefined): string { | |
| 818 | − | if (!week || week.merged === 0) return "A quiet week: nothing has merged yet, and nothing is stuck."; | |
| 840 | + | const landed = week ? allChanges(week) : 0; | |
| 841 | + | if (!week || landed === 0) return "A quiet week: nothing has landed yet, and nothing is stuck."; | |
| 819 | 842 | const share = agentShare(week); | |
| 820 | − | return `${countWord(week.merged)} ${week.merged === 1 ? "change" : "changes"} merged this week${share ? `, ${percent(share)} of them an agent's` : ""}, and nothing needs a closer look.`; | |
| 843 | + | return `${countWord(landed)} ${landed === 1 ? "change" : "changes"} landed this week${share ? `, ${percent(share)} of them an agent's` : ""}, and nothing needs a closer look.`; | |
| 821 | 844 | } | |
| 822 | 845 | ||
| 823 | 846 | /** What Home shows: the pulse, and the findings with the one that leads. */ | |
| ⋯ | |||
| 976 | 999 | ||
| 977 | 1000 | // --- The rhythm of the work ------------------------------------------------------- | |
| 978 | 1001 | ||
| 979 | − | export type RhythmDay = { day: string; opened: number; merged: number; reworked: number; reviews: number; sessions: number }; | |
| 1002 | + | export type RhythmDay = { day: string; opened: number; merged: number; reworked: number; reviews: number; sessions: number; commits: number }; | |
| 980 | 1003 | ||
| 981 | 1004 | export type RhythmRow = { | |
| 982 | 1005 | key: string; | |
| ⋯ | |||
| 995 | 1018 | export function rhythm(days: SignalDay[], list: string[], groups: Group[], max = 6): RhythmRow[] { | |
| 996 | 1019 | const rows = groups.map((group) => { | |
| 997 | 1020 | const ids = new Set(group.repoIds); | |
| 998 | − | const byDay = new Map(list.map((day) => [day, { day, opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0 }])); | |
| 1021 | + | const byDay = new Map(list.map((day) => [day, { day, opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0, commits: 0 }])); | |
| 999 | 1022 | for (const row of days) { | |
| 1000 | 1023 | const day = byDay.get(row.day); | |
| 1001 | 1024 | if (!day || !ids.has(row.repoId)) continue; | |
| ⋯ | |||
| 1004 | 1027 | day.reworked += row.reworked; | |
| 1005 | 1028 | day.reviews += row.reviews; | |
| 1006 | 1029 | day.sessions += row.sessions; | |
| 1030 | + | day.commits += row.agentCommits ?? 0; | |
| 1007 | 1031 | } | |
| 1008 | 1032 | const out = list.map((day) => byDay.get(day)!); | |
| 1009 | − | const total = out.reduce((sum, day) => sum + day.opened + day.merged + day.reworked + day.reviews + day.sessions, 0); | |
| 1033 | + | const total = out.reduce((sum, day) => sum + day.opened + day.merged + day.reworked + day.reviews + day.sessions + day.commits, 0); | |
| 1010 | 1034 | return { key: group.key, label: group.label, note: group.note, to: group.to, days: out, total }; | |
| 1011 | 1035 | }); | |
| 1012 | 1036 | return rows | |
| ⋯ | |||
| 1024 | 1048 | reworked: sum.reworked + day.reworked, | |
| 1025 | 1049 | reviews: sum.reviews + day.reviews, | |
| 1026 | 1050 | sessions: sum.sessions + day.sessions, | |
| 1051 | + | commits: sum.commits + day.commits, | |
| 1027 | 1052 | }), | |
| 1028 | − | { opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0 }, | |
| 1053 | + | { opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0, commits: 0 }, | |
| 1029 | 1054 | ); | |
| 1030 | 1055 | } | |
| 1031 | 1056 | ||
| ⋯ | |||
| 1034 | 1059 | const half = Math.floor(row.days.length / 2); | |
| 1035 | 1060 | const last = rhythmSum(row.days.slice(0, half)); | |
| 1036 | 1061 | const now = rhythmSum(row.days.slice(half)); | |
| 1037 | − | const prs = (sum: ReturnType<typeof rhythmSum>) => sum.opened + sum.merged; | |
| 1062 | + | const prs = (sum: ReturnType<typeof rhythmSum>) => sum.opened + sum.merged + sum.commits; | |
| 1038 | 1063 | if (prs(now) === 0 && prs(last) > 0) return "Quiet this week after a busy last week"; | |
| 1039 | 1064 | if (now.reviews === 0 && now.opened > 0) return `${now.opened} opened this week, no reviews yet`; | |
| 1040 | 1065 | if (prs(now) > 1.5 * prs(last) && prs(now) >= 4) return "Busier than last week"; | |
| 1041 | 1066 | if (now.reworked > Math.max(2, last.reworked * 2)) return "More rework than last week"; | |
| 1042 | − | return `${now.merged} merged this week, ${last.merged} last week`; | |
| 1067 | + | return `${now.merged + now.commits} landed this week, ${last.merged + last.commits} last week`; | |
| 1043 | 1068 | } | |
| 1044 | 1069 | ||
| 1045 | 1070 | export type WaitingRow = WaitingPull & { repo: RepoPath; to: string }; | |
| 58 | 58 | /// What the harness sessions started in the week cost, as they | |
| 59 | 59 | /// reported it (estimated at list price from their token usage). | |
| 60 | 60 | pub session_cost_usd: f64, | |
| 61 | + | /// Commits agent sessions pushed outside any pull request in the week | |
| 62 | + | /// (to the default branch, most of all), each once. | |
| 63 | + | pub agent_commits: u32, | |
| 61 | 64 | } | |
| 62 | 65 | ||
| 63 | 66 | /// One repository's UTC day. Days with nothing in them are left out. | |
| ⋯ | |||
| 76 | 79 | pub reviews: u32, | |
| 77 | 80 | /// Harness sessions started. | |
| 78 | 81 | pub sessions: u32, | |
| 82 | + | /// Commits agent sessions pushed outside any pull request. | |
| 83 | + | pub agent_commits: u32, | |
| 79 | 84 | } | |
| 80 | 85 | ||
| 81 | 86 | /// An open pull request no one has reviewed yet, oldest first. | |
| 1908 | 1908 | reviewSeconds: number; | |
| 1909 | 1909 | stalled: number; | |
| 1910 | 1910 | sessionCostUsd: number; | |
| 1911 | + | /** Commits agent sessions pushed outside any pull request (to the default branch, most of all), each once. */ | |
| 1912 | + | agentCommits: number; | |
| 1911 | 1913 | }; | |
| 1912 | − | export type SignalDay = { repoId: string; day: string; opened: number; merged: number; agentMerged: number; reworked: number; reviews: number; sessions: number }; | |
| 1914 | + | export type SignalDay = { repoId: string; day: string; opened: number; merged: number; agentMerged: number; reworked: number; reviews: number; sessions: number; agentCommits: number }; | |
| 1913 | 1915 | /** An open pull request no one has reviewed, oldest first; `blocks`: the open work items waiting on its work item. */ | |
| 1914 | 1916 | export type WaitingPull = { repoId: string; number: number; title: string; createdAt: string; agent: boolean; draftSeconds: number | null; item: number | null; blocks: number[] }; | |
| 1915 | 1917 | export type SignalPull = { |
| 23 | 23 | sha2 = "0.10" | |
| 24 | 24 | ||
| 25 | 25 | [dev-dependencies] | |
| 26 | − | rusqlite = { version = "0.32", features = ["bundled"] } | |
| 26 | + | # limits: the tests' databases refuse what D1 refuses (signals/tests.rs). | |
| 27 | + | rusqlite = { version = "0.32", features = ["bundled", "limits"] } | |
| 27 | 28 | ||
| 28 | 29 | # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the | |
| 29 | 30 | # time. |
| 2 | 2 | -- `YYYY-MM-DDT00:00:00`. One row per repository and UTC day that had | |
| 3 | 3 | -- anything in it. Reviews are comments of any kind on a pull request by a | |
| 4 | 4 | -- person other than its owner (not g1t's agent, a workspace agent or g1t | |
| 5 | − | -- itself); reworked, the ones of them that ask for changes. | |
| 5 | + | -- itself); reworked, the ones of them that ask for changes. Agent commits | |
| 6 | + | -- are those agent sessions pushed outside any pull request (weeks.sql). | |
| 6 | 7 | , | |
| 7 | − | ev(repo_id, day, opened, merged, agent_merged, reworked, reviews, sessions) AS ( | |
| 8 | − | SELECT repo_id, substr(created_at, 1, 10), 1, 0, 0, 0, 0, 0 FROM pr | |
| 8 | + | ev(repo_id, day, opened, merged, agent_merged, reworked, reviews, sessions, agent_commits) AS ( | |
| 9 | + | SELECT repo_id, substr(created_at, 1, 10), 1, 0, 0, 0, 0, 0, 0 FROM pr | |
| 9 | 10 | WHERE created_at >= ?2 AND created_at < ?3 | |
| 10 | − | UNION ALL SELECT repo_id, substr(merged_at, 1, 10), 0, 1, agent, 0, 0, 0 FROM pr | |
| 11 | + | UNION ALL SELECT repo_id, substr(merged_at, 1, 10), 0, 1, agent, 0, 0, 0, 0 FROM pr | |
| 11 | 12 | WHERE status = 'merged' AND merged_at >= ?2 AND merged_at < ?3 | |
| 12 | − | UNION ALL SELECT c.repo_id, substr(c.created_at, 1, 10), 0, 0, 0, c.verdict IS 'request_changes', 1, 0 | |
| 13 | + | UNION ALL SELECT c.repo_id, substr(c.created_at, 1, 10), 0, 0, 0, c.verdict IS 'request_changes', 1, 0, 0 | |
| 13 | 14 | FROM comments c JOIN pr ON pr.repo_id = c.repo_id AND pr.number = c.number | |
| 14 | 15 | WHERE c.repo_id IN (SELECT value FROM json_each(?1)) AND c.created_at >= ?2 AND c.created_at < ?3 | |
| 15 | 16 | AND c.author_id != pr.owner_id AND c.agent_id IS NULL | |
| 16 | 17 | AND c.author_id NOT IN ('usr_g1t_agent', 'g1t', 'g1t_policy', 'svc_runner', 'g1t_runner') | |
| 17 | − | UNION ALL SELECT repo_id, substr(started_at, 1, 10), 0, 0, 0, 0, 0, 1 FROM sessions | |
| 18 | + | UNION ALL SELECT repo_id, substr(started_at, 1, 10), 0, 0, 0, 0, 0, 1, 0 FROM sessions | |
| 18 | 19 | WHERE repo_id IN (SELECT value FROM json_each(?1)) AND started_at >= ?2 AND started_at < ?3 | |
| 20 | + | UNION ALL SELECT t.repo_id, substr(t.at, 1, 10), 0, 0, 0, 0, 0, 0, COUNT(DISTINCT t.value) FROM session_touches t | |
| 21 | + | WHERE t.repo_id IN (SELECT value FROM json_each(?1)) AND t.kind = 'commit' AND t.at >= ?2 AND t.at < ?3 | |
| 22 | + | AND t.session_id NOT IN (SELECT session_id FROM tied) | |
| 23 | + | GROUP BY t.repo_id, substr(t.at, 1, 10) | |
| 19 | 24 | ) | |
| 20 | 25 | SELECT repo_id, day, SUM(opened) AS opened, SUM(merged) AS merged, SUM(agent_merged) AS agent_merged, | |
| 21 | − | SUM(reworked) AS reworked, SUM(reviews) AS reviews, SUM(sessions) AS sessions | |
| 26 | + | SUM(reworked) AS reworked, SUM(reviews) AS reviews, SUM(sessions) AS sessions, | |
| 27 | + | SUM(agent_commits) AS agent_commits | |
| 22 | 28 | FROM ev GROUP BY repo_id, day ORDER BY day, repo_id |
| 9 | 9 | ||
| 10 | 10 | const IDS: &str = r#"["a"]"#; | |
| 11 | 11 | ||
| 12 | + | /// D1 takes at most five terms in one compound SELECT; SQLite allows 500. | |
| 13 | + | const D1_COMPOUND_SELECT: i32 = 5; | |
| 14 | + | ||
| 12 | 15 | fn schema() -> Connection { | |
| 13 | 16 | let db = Connection::open_in_memory().unwrap(); | |
| 17 | + | // Refuse what D1 refuses, so a statement these tests pass also runs there. | |
| 18 | + | db.set_limit(rusqlite::limits::Limit::SQLITE_LIMIT_COMPOUND_SELECT, D1_COMPOUND_SELECT); | |
| 14 | 19 | db.execute_batch( | |
| 15 | 20 | "CREATE TABLE pulls (id TEXT, repo_id TEXT, number INTEGER, title TEXT DEFAULT 'A change', status TEXT, | |
| 16 | 21 | item_id TEXT, milestone INTEGER, agent TEXT DEFAULT 'person', author_id TEXT DEFAULT 'usr_ana', | |
| ⋯ | |||
| 27 | 32 | CREATE TABLE commit_statuses (repo_id TEXT, sha TEXT, context TEXT, updated_at TEXT); | |
| 28 | 33 | CREATE TABLE commit_check_runs (id TEXT, repo_id TEXT, head_sha TEXT, created_at TEXT, updated_at TEXT); | |
| 29 | 34 | CREATE TABLE session_links (session_id TEXT, repo_id TEXT, kind TEXT, number INTEGER); | |
| 30 | − | CREATE TABLE session_touches (session_id TEXT, repo_id TEXT, kind TEXT, value TEXT); | |
| 35 | + | CREATE TABLE session_touches (session_id TEXT, repo_id TEXT, kind TEXT, value TEXT, at TEXT); | |
| 31 | 36 | CREATE TABLE work_items (id TEXT, workspace TEXT, number INTEGER, title TEXT, state TEXT, | |
| 32 | 37 | blocked_by TEXT DEFAULT '[]', milestone INTEGER); | |
| 33 | 38 | CREATE TABLE milestones (workspace TEXT, number INTEGER, title TEXT, state TEXT, due_on TEXT, | |
| ⋯ | |||
| 195 | 200 | INSERT INTO sessions (id, repo_id, pull, started_at) VALUES ('ses_4', 'a', 4, '2026-10-06T13:00:00Z'); | |
| 196 | 201 | INSERT INTO sessions (id, repo_id, pull, started_at) VALUES ('ses_5', 'a', NULL, '2026-10-06T11:30:00Z'); | |
| 197 | 202 | INSERT INTO sessions (id, repo_id, pull, started_at) VALUES ('ses_5b', 'a', NULL, '2026-10-06T10:00:00.500Z'); | |
| 198 | − | INSERT INTO session_touches VALUES ('ses_5', 'a', 'branch', 'branch-5'); | |
| 199 | − | INSERT INTO session_touches VALUES ('ses_5b', 'a', 'branch', 'branch-5'); | |
| 203 | + | INSERT INTO session_touches (session_id, repo_id, kind, value) VALUES ('ses_5', 'a', 'branch', 'branch-5'); | |
| 204 | + | INSERT INTO session_touches (session_id, repo_id, kind, value) VALUES ('ses_5b', 'a', 'branch', 'branch-5'); | |
| 200 | 205 | -- 6's branch is on a fork: a push to the same name is not its. | |
| 201 | 206 | UPDATE pulls SET fork_repo_id = 'fork' WHERE number = 6; | |
| 202 | 207 | INSERT INTO sessions (id, repo_id, pull, started_at) VALUES ('ses_6', 'a', NULL, '2026-10-06T11:00:00Z'); | |
| 203 | − | INSERT INTO session_touches VALUES ('ses_6', 'a', 'branch', 'branch-6'); | |
| 208 | + | INSERT INTO session_touches (session_id, repo_id, kind, value) VALUES ('ses_6', 'a', 'branch', 'branch-6'); | |
| 204 | 209 | -- A session of another repository with the same number is not its. | |
| 205 | 210 | INSERT INTO sessions (id, repo_id, pull, started_at) VALUES ('ses_x', 'b', 6, '2026-10-06T11:00:00Z');", | |
| 206 | 211 | ) | |
| ⋯ | |||
| 735 | 740 | -- A subagent tied itself, by a push to 3's branch, counts with that. | |
| 736 | 741 | INSERT INTO sessions (id, repo_id, parent_id, started_at, cost_usd) | |
| 737 | 742 | VALUES ('ses_b', 'a', 'ses_r', '2026-10-06T00:00:00Z', 3); | |
| 738 | − | INSERT INTO session_touches VALUES ('ses_b', 'a', 'branch', 'branch-3'); | |
| 743 | + | INSERT INTO session_touches (session_id, repo_id, kind, value) VALUES ('ses_b', 'a', 'branch', 'branch-3'); | |
| 739 | 744 | -- A session whose cost is not known. | |
| 740 | 745 | INSERT INTO sessions (id, repo_id, pull, started_at, cost_usd) VALUES ('ses_n', 'a', 3, '2026-10-06T00:00:00Z', NULL); | |
| 741 | 746 | -- A session tied to nothing counts with nothing. | |
| ⋯ | |||
| 793 | 798 | ] | |
| 794 | 799 | ); | |
| 795 | 800 | } | |
| 801 | + | ||
| 802 | + | #[test] | |
| 803 | + | fn agent_commits_are_a_sessions_own_outside_any_pull_request() { | |
| 804 | + | let db = schema(); | |
| 805 | + | pull(&db, 1, "open", "2026-10-06T00:00:00Z", None, "2026-10-06T00:00:00Z"); | |
| 806 | + | db.execute_batch( | |
| 807 | + | "UPDATE pulls SET source_branch = 'feature' WHERE number = 1; | |
| 808 | + | INSERT INTO sessions (id, repo_id, started_at) VALUES ('ses_main', 'a', '2026-10-06T00:00:00Z'); | |
| 809 | + | INSERT INTO sessions (id, repo_id, started_at) VALUES ('ses_pr', 'a', '2026-10-06T00:00:00Z'); | |
| 810 | + | INSERT INTO session_touches VALUES ('ses_main', 'a', 'commit', 'c1', '2026-10-06T10:00:00Z'); | |
| 811 | + | INSERT INTO session_touches VALUES ('ses_main', 'a', 'commit', 'c1', '2026-10-06T10:00:01Z'); | |
| 812 | + | INSERT INTO session_touches VALUES ('ses_main', 'a', 'commit', 'c2', '2026-10-01T10:00:00Z'); | |
| 813 | + | INSERT INTO session_touches VALUES ('ses_main', 'b', 'commit', 'c9', '2026-10-06T10:00:00Z'); | |
| 814 | + | INSERT INTO session_touches VALUES ('ses_pr', 'a', 'branch', 'feature', '2026-10-06T09:00:00Z'); | |
| 815 | + | INSERT INTO session_touches VALUES ('ses_pr', 'a', 'commit', 'c3', '2026-10-06T10:00:00Z');", | |
| 816 | + | ) | |
| 817 | + | .unwrap(); | |
| 818 | + | let found = weeks(&db, "2026-10-18T00:00:00Z"); | |
| 819 | + | // c1 once, in its week; c2 in the week before; c3 belongs to a pull request's session; c9 to another repository. | |
| 820 | + | assert_eq!(in_week(&found, "2026-10-05").agent_commits, 1); | |
| 821 | + | assert_eq!(in_week(&found, "2026-09-28").agent_commits, 1); | |
| 822 | + | let window = window("2026-10-18T00:00:00Z"); | |
| 823 | + | let days: Vec<SignalDay> = rows(&db, DAYS, &[&IDS, &window.days_from, &window.end]); | |
| 824 | + | let commits: Vec<(&str, u32)> = days | |
| 825 | + | .iter() | |
| 826 | + | .filter(|d| d.agent_commits > 0) | |
| 827 | + | .map(|d| (d.day.as_str(), d.agent_commits)) | |
| 828 | + | .collect(); | |
| 829 | + | assert_eq!( | |
| 830 | + | commits, | |
| 831 | + | [("2026-10-06", 1)], | |
| 832 | + | "the days read are the last two weeks; c2 is before them" | |
| 833 | + | ); | |
| 834 | + | } | |
| 42 | 42 | AND NOT (x.status = 'closed' AND COALESCE(x.u < cuts.lo, 0)) | |
| 43 | 43 | ), | |
| 44 | 44 | -- What each of them saw, as spans [t0, t1] (a moment where t0 = t1). | |
| 45 | − | -- CROSS JOIN keeps each read starting from the few candidates. | |
| 46 | − | act(pull_id, t0, t1) AS MATERIALIZED ( | |
| 45 | + | -- CROSS JOIN keeps each read starting from the few candidates. D1 takes | |
| 46 | + | -- at most five terms in one compound SELECT, so the sources are read in | |
| 47 | + | -- two halves of four and joined. | |
| 48 | + | act_a(pull_id, t0, t1) AS MATERIALIZED ( | |
| 47 | 49 | -- Comments of every kind: reviews, and the timeline's events (labels, | |
| 48 | 50 | -- assignees, review requests, milestones, ready, draft, closed, | |
| 49 | 51 | -- reopened, base changes, the merge queue, auto-merge, branches deleted | |
| ⋯ | |||
| 57 | 59 | -- Its acceptance checks, from queued to finished. | |
| 58 | 60 | UNION ALL SELECT k.id, julianday(cr.created_at), julianday(COALESCE(cr.finished_at, cr.created_at)) | |
| 59 | 61 | FROM cand k CROSS JOIN check_runs cr ON cr.pull_id = k.id | |
| 62 | + | ), | |
| 63 | + | act_b(pull_id, t0, t1) AS MATERIALIZED ( | |
| 60 | 64 | -- Statuses and check runs on its head commit, the one commit pulls keep | |
| 61 | 65 | -- (a status keeps only when it last changed). | |
| 62 | − | UNION ALL SELECT k.id, julianday(st.updated_at), julianday(st.updated_at) | |
| 66 | + | SELECT k.id, julianday(st.updated_at), julianday(st.updated_at) | |
| 63 | 67 | FROM cand k CROSS JOIN commit_statuses st ON st.repo_id = k.repo_id AND st.sha = k.head_commit | |
| 64 | 68 | UNION ALL SELECT k.id, julianday(ck.created_at), julianday(ck.updated_at) | |
| 65 | 69 | FROM cand k CROSS JOIN commit_check_runs ck ON ck.repo_id = k.repo_id AND ck.head_sha = k.head_commit | |
| ⋯ | |||
| 70 | 74 | UNION ALL SELECT k.id, julianday(s.started_at), julianday(s.last_seen_at) | |
| 71 | 75 | FROM cand k CROSS JOIN tied t ON t.pull_id = k.id CROSS JOIN sessions s ON s.parent_id = t.session_id | |
| 72 | 76 | ), | |
| 77 | + | act(pull_id, t0, t1) AS (SELECT * FROM act_a UNION ALL SELECT * FROM act_b), | |
| 73 | 78 | -- Each week a pull request saw something in the three days before its cut. | |
| 74 | 79 | busy AS MATERIALIZED ( | |
| 75 | 80 | SELECT DISTINCT a.pull_id, w.start FROM act a JOIN w ON a.t0 < w.cut AND a.t1 >= w.cut - 3 | |
| ⋯ | |||
| 84 | 89 | TOTAL(CASE WHEN x.agent AND x.c >= w.lo AND x.c < w.hi THEN x.draft_seconds END) AS agent_draft_seconds, | |
| 85 | 90 | COUNT(CASE WHEN x.r >= w.lo AND x.r < w.hi THEN 1 END) AS reviewed, | |
| 86 | 91 | TOTAL(CASE WHEN x.r >= w.lo AND x.r < w.hi THEN MAX(0, (x.r - x.c) * 86400) END) AS review_seconds, | |
| 87 | − | 0 AS stalled, 0.0 AS session_cost_usd | |
| 92 | + | 0 AS stalled, 0.0 AS session_cost_usd, 0 AS agent_commits | |
| 88 | 93 | FROM x CROSS JOIN w | |
| 89 | 94 | GROUP BY x.repo_id, w.start | |
| 90 | 95 | ), | |
| ⋯ | |||
| 92 | 97 | -- least three days before, and nothing in the three days before it. | |
| 93 | 98 | sx AS ( | |
| 94 | 99 | SELECT k.repo_id, w.start, 0 AS merged, 0 AS agent_merged, 0 AS agent_reworked, 0 AS agent_drafted, | |
| 95 | − | 0.0 AS agent_draft_seconds, 0 AS reviewed, 0.0 AS review_seconds, COUNT(*) AS stalled, 0.0 AS session_cost_usd | |
| 100 | + | 0.0 AS agent_draft_seconds, 0 AS reviewed, 0.0 AS review_seconds, COUNT(*) AS stalled, 0.0 AS session_cost_usd, 0 AS agent_commits | |
| 96 | 101 | FROM cand k CROSS JOIN w LEFT JOIN busy b ON b.pull_id = k.id AND b.start = w.start | |
| 97 | 102 | WHERE b.pull_id IS NULL AND k.c <= w.cut - 3 | |
| 98 | 103 | AND NOT COALESCE(k.m < w.cut, 0) | |
| ⋯ | |||
| 104 | 109 | sw AS ( | |
| 105 | 110 | SELECT s.repo_id, w.start, 0 AS merged, 0 AS agent_merged, 0 AS agent_reworked, 0 AS agent_drafted, | |
| 106 | 111 | 0.0 AS agent_draft_seconds, 0 AS reviewed, 0.0 AS review_seconds, 0 AS stalled, | |
| 107 | − | TOTAL(s.cost_usd) AS session_cost_usd | |
| 112 | + | TOTAL(s.cost_usd) AS session_cost_usd, 0 AS agent_commits | |
| 108 | 113 | FROM sessions s JOIN w ON s.started_at >= w.lo_at AND s.started_at < w.hi_at | |
| 109 | 114 | WHERE s.repo_id IN (SELECT value FROM json_each(?1)) | |
| 110 | 115 | AND s.started_at >= (SELECT MIN(lo_at) FROM w) AND s.started_at < (SELECT MAX(hi_at) FROM w) | |
| 111 | 116 | GROUP BY s.repo_id, w.start | |
| 117 | + | ), | |
| 118 | + | -- The commits agent sessions pushed outside any pull request (to a | |
| 119 | + | -- branch no pull request is from, the default branch most of all): a | |
| 120 | + | -- session's own commits, by when it recorded them, each once. | |
| 121 | + | cw AS ( | |
| 122 | + | SELECT t.repo_id, w.start, 0 AS merged, 0 AS agent_merged, 0 AS agent_reworked, 0 AS agent_drafted, | |
| 123 | + | 0.0 AS agent_draft_seconds, 0 AS reviewed, 0.0 AS review_seconds, 0 AS stalled, 0.0 AS session_cost_usd, | |
| 124 | + | COUNT(DISTINCT t.value) AS agent_commits | |
| 125 | + | FROM session_touches t JOIN w ON t.at >= w.lo_at AND t.at < w.hi_at | |
| 126 | + | WHERE t.repo_id IN (SELECT value FROM json_each(?1)) AND t.kind = 'commit' | |
| 127 | + | AND t.session_id NOT IN (SELECT session_id FROM tied) | |
| 128 | + | GROUP BY t.repo_id, w.start | |
| 112 | 129 | ) | |
| 113 | 130 | SELECT repo_id, start, SUM(merged) AS merged, SUM(agent_merged) AS agent_merged, | |
| 114 | 131 | SUM(agent_reworked) AS agent_reworked, SUM(agent_drafted) AS agent_drafted, | |
| 115 | 132 | TOTAL(agent_draft_seconds) AS agent_draft_seconds, SUM(reviewed) AS reviewed, | |
| 116 | − | TOTAL(review_seconds) AS review_seconds, SUM(stalled) AS stalled, TOTAL(session_cost_usd) AS session_cost_usd | |
| 117 | − | FROM (SELECT * FROM pw UNION ALL SELECT * FROM sx UNION ALL SELECT * FROM sw) | |
| 133 | + | TOTAL(review_seconds) AS review_seconds, SUM(stalled) AS stalled, TOTAL(session_cost_usd) AS session_cost_usd, | |
| 134 | + | SUM(agent_commits) AS agent_commits | |
| 135 | + | FROM (SELECT * FROM pw UNION ALL SELECT * FROM sx UNION ALL SELECT * FROM sw UNION ALL SELECT * FROM cw) | |
| 118 | 136 | GROUP BY repo_id, start | |
| 119 | − | HAVING SUM(merged) + SUM(agent_drafted) + SUM(reviewed) + SUM(stalled) > 0 OR TOTAL(session_cost_usd) > 0 | |
| 137 | + | HAVING SUM(merged) + SUM(agent_drafted) + SUM(reviewed) + SUM(stalled) + SUM(agent_commits) > 0 OR TOTAL(session_cost_usd) > 0 | |
| 120 | 138 | ORDER BY start, repo_id | |
Comments
Sign in to comment.